diff --git a/.github/workflows/keyfactor-bootstrap-workflow.yml b/.github/workflows/keyfactor-bootstrap-workflow.yml index 500c271..487d4c0 100644 --- a/.github/workflows/keyfactor-bootstrap-workflow.yml +++ b/.github/workflows/keyfactor-bootstrap-workflow.yml @@ -11,17 +11,9 @@ on: jobs: call-starter-workflow: - uses: keyfactor/actions/.github/workflows/starter.yml@v4 - with: - command_token_url: ${{ vars.COMMAND_TOKEN_URL }} - command_hostname: ${{ vars.COMMAND_HOSTNAME }} - command_base_api_path: ${{ vars.COMMAND_API_PATH }} + uses: keyfactor/actions/.github/workflows/starter.yml@v5 secrets: token: ${{ secrets.V2BUILDTOKEN }} gpg_key: ${{ secrets.KF_GPG_PRIVATE_KEY }} gpg_pass: ${{ secrets.KF_GPG_PASSPHRASE }} scan_token: ${{ secrets.SAST_TOKEN }} - entra_username: ${{ secrets.DOCTOOL_ENTRA_USERNAME }} - entra_password: ${{ secrets.DOCTOOL_ENTRA_PASSWD }} - command_client_id: ${{ secrets.COMMAND_CLIENT_ID }} - command_client_secret: ${{ secrets.COMMAND_CLIENT_SECRET }} diff --git a/.gitignore b/.gitignore index f920fa6..68b2f86 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,13 @@ terraform/terraform.tfvars # macOS .DS_Store + +# Analysis / scratch — never commit +analysis/ +issues/ +.claude/ +.codex/ +CLAUDE.md +AGENTS.md +/logs*/ +*.log diff --git a/CERTInext.IntegrationTests/AlgorithmMatrixTests.cs b/CERTInext.IntegrationTests/AlgorithmMatrixTests.cs new file mode 100644 index 0000000..2a8cb2b --- /dev/null +++ b/CERTInext.IntegrationTests/AlgorithmMatrixTests.cs @@ -0,0 +1,181 @@ +// Copyright 2024 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// At http://www.apache.org/licenses/LICENSE-2.0 + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Pkcs; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// Key-algorithm coverage matrix: RSA 2048/3072/4096/6144/8192, ECDSA P-256/P-384/P-521, + /// Ed25519, and Ed448 (see ). + /// + /// Motivation: every other test in the suite hardcoded an RSA-2048 CSR, so only RSA-2048 + /// certificates were ever exercised end-to-end (and that is all that showed up in Command). + /// The plugin takes the CSR as enrollment input and submits it verbatim, so the key + /// algorithm is entirely determined by the CSR. + /// + /// This file is the offline / submission-only layer (no DCV, no issuance): + /// 1. — deterministic, no API, always runs. Proves we + /// emit a structurally valid, self-consistent PKCS#10 CSR for each algorithm (the public key + /// type/size round-trips and the request signature verifies). + /// 2. — opt-in (creates real sandbox orders). Proves + /// whether CERTInext *accepts* each algorithm at order submission. A CA-side rejection is + /// reported as an explicit Skip carrying the CA's own message. + /// + /// The end-to-end "does CERTInext actually issue this algorithm" matrix (DCV on, one real + /// scrup.org cert per type) lives in DcvLifecycleTests.EnrollWithDcvOn_IssuesPerKeyAlgorithm + /// and only exists on the DCV build. + /// + public class AlgorithmMatrixTests : IClassFixture + { + /// Set CERTINEXT_ALGO_MATRIX=1 to run the live submission theory (creates real orders). + private const string OptInFlag = "CERTINEXT_ALGO_MATRIX"; + + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + + public AlgorithmMatrixTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + } + + public static IEnumerable KeyTypes => KeyAlgorithms.AsMemberData; + + // --------------------------------------------------------------------------- + // Layer 1 — deterministic CSR-validity round-trip (no API, always runs) + // --------------------------------------------------------------------------- + + /// + /// Generates a CSR for the given key type, re-parses it, and asserts the public key + /// algorithm/size round-trips and the request signature verifies. Fully offline. + /// + /// Note: RSA-6144 and RSA-8192 key generation is intentionally slow (seconds to tens of + /// seconds) — that cost is inherent to large RSA keygen, not the test. + /// + [Theory] + [MemberData(nameof(KeyTypes))] + public void Csr_RoundTripsKeyAlgorithm(string tag) + { + var spec = KeyAlgorithms.For(tag); + + string pem = KeyAlgorithms.GenerateCsrPem($"algo-{KeyAlgorithms.Slug(tag)}.example.com", spec); + + var request = new Pkcs10CertificationRequest(KeyAlgorithms.DerFromPem(pem)); + + request.Verify().Should().BeTrue($"the {tag} CSR must be self-signed with a verifiable signature"); + + var pub = request.GetPublicKey(); + + switch (spec.Kind) + { + case KeyKind.Rsa: + pub.Should().BeOfType(); + // BouncyCastle generates a modulus of exactly 'Strength' bits (top bit set). + ((RsaKeyParameters)pub).Modulus.BitLength.Should().Be(spec.Strength, + $"the RSA modulus must be {spec.Strength} bits"); + break; + + case KeyKind.Ecdsa: + pub.Should().BeOfType(); + ((ECPublicKeyParameters)pub).Parameters.Curve.FieldSize.Should().Be(spec.Strength, + $"the EC field size must be {spec.Strength} bits"); + break; + + case KeyKind.Ed25519: + pub.Should().BeOfType(); + break; + + case KeyKind.Ed448: + pub.Should().BeOfType(); + break; + } + + _output.WriteLine($"[OK] {tag}: CSR generated ({pem.Length} chars PEM), signature verified, public key type confirmed."); + } + + // --------------------------------------------------------------------------- + // Layer 2 — live submission acceptance (opt-in; creates real sandbox orders) + // --------------------------------------------------------------------------- + + /// + /// Submits a real order to CERTInext for each key type and asserts the order is accepted + /// (a CARequestID is returned). A CA-side rejection is reported as an explicit Skip carrying + /// the CA's own error message — so the suite documents which algorithms CERTInext accepts + /// rather than failing on a legitimate CA limitation. + /// + /// Opt-in: requires CERTINEXT_ALGO_MATRIX=1 because each run creates a real (pending, + /// non-issued) DV order on the sandbox account. No DCV is performed, so the orders park at + /// EXTERNALVALIDATION and are not cleaned up here. "Accepted at submission" is weaker than + /// "will issue" — see DcvLifecycleTests.EnrollWithDcvOn_IssuesPerKeyAlgorithm for the + /// end-to-end issuance matrix. + /// + [SkippableTheory] + [MemberData(nameof(KeyTypes))] + public async Task Enroll_AcceptsKeyAlgorithm(string tag) + { + IntegrationSkip.IfNotConfigured(_fixture); + Skip.IfNot( + Environment.GetEnvironmentVariable(OptInFlag) == "1", + $"Set {OptInFlag}=1 to run the live algorithm-submission matrix (creates real sandbox orders)."); + + var spec = KeyAlgorithms.For(tag); + string cn = $"algo-{KeyAlgorithms.Slug(tag)}.example.com"; + string csrPem = KeyAlgorithms.GenerateCsrPem(cn, spec); + + var productInfo = new EnrollmentProductInfo + { + ProductID = _fixture.ProductCode, + ProductParameters = new Dictionary + { + [Constants.EnrollmentParam.ProfileId] = _fixture.ProductCode, + [Constants.EnrollmentParam.ProductCode] = _fixture.ProductCode, + [Constants.EnrollmentParam.RequesterName] = _fixture.RequestorName, + [Constants.EnrollmentParam.RequesterEmail] = _fixture.RequestorEmail, + } + }; + + var sanDict = new Dictionary { ["DNS"] = new[] { cn } }; + + var plugin = new CERTInextCAPlugin(_fixture.Client, _fixture.Config); + + EnrollmentResult enrollResult = null; + try + { + enrollResult = await plugin.Enroll( + csrPem, + $"CN={cn}", + sanDict, + productInfo, + RequestFormat.PKCS10, + EnrollmentType.New); + } + catch (Exception ex) + { + // Per agreed scope: a CA-side rejection becomes an explicit Skip carrying the CA's + // message (classified so an unsupported algorithm isn't confused with a credit/ + // account limitation), so the matrix documents real CERTInext support honestly. + string reason = KeyAlgorithms.ClassifyRejection(ex.Message); + _output.WriteLine($"[SKIP] {tag}: {reason} — {ex.Message}"); + Skip.If(true, $"CERTInext did not accept a {tag} order: {reason}. CA message: {ex.Message}"); + } + + enrollResult.Should().NotBeNull($"{tag}: Enroll must return a non-null result when accepted"); + if (enrollResult == null) return; // satisfies nullable analysis; assertion above already failed + + enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace( + $"{tag}: a CARequestID must be returned when CERTInext accepts the order"); + + _output.WriteLine($"[OK] {tag}: CERTInext accepted the order. CARequestID={enrollResult.CARequestID}"); + } + } +} diff --git a/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj b/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj index 91e6472..70e5245 100644 --- a/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj +++ b/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj @@ -6,12 +6,29 @@ 12.0 false true + + false + $(DefineConstants);SUPPORTS_DCV + + + + + + + + + + @@ -21,6 +38,7 @@ + diff --git a/CERTInext.IntegrationTests/CloudflareDomainValidator.cs b/CERTInext.IntegrationTests/CloudflareDomainValidator.cs new file mode 100644 index 0000000..db56616 --- /dev/null +++ b/CERTInext.IntegrationTests/CloudflareDomainValidator.cs @@ -0,0 +1,133 @@ +// Copyright 2024 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// At http://www.apache.org/licenses/LICENSE-2.0 + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Keyfactor.AnyGateway.Extensions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// that publishes and removes DNS TXT records via + /// the Cloudflare v4 API. Intended for integration tests against a real domain. + /// + /// Credentials are read from the : + /// CERTINEXT_CF_API_TOKEN and CERTINEXT_CF_ZONE_ID. + /// + internal sealed class CloudflareDomainValidator : IDomainValidator, IDisposable + { + private const string CfApiBase = "https://api.cloudflare.com/client/v4"; + + private readonly string _apiToken; + private readonly string _zoneId; + private readonly HttpClient _http; + + // Maps staging hostname → Cloudflare record ID so CleanupValidation can delete it + private readonly ConcurrentDictionary _stagedRecordIds = new(); + + public CloudflareDomainValidator(string apiToken, string zoneId) + { + _apiToken = apiToken ?? throw new ArgumentNullException(nameof(apiToken)); + _zoneId = zoneId ?? throw new ArgumentNullException(nameof(zoneId)); + + _http = new HttpClient(); + _http.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", _apiToken); + } + + public void Initialize(IDomainValidatorConfigProvider configProvider) { } + + public async Task StageValidation(string key, string value, CancellationToken cancellationToken) + { + var payload = new + { + type = "TXT", + name = key, + content = value, + ttl = 60 + }; + + var response = await _http.PostAsJsonAsync( + $"{CfApiBase}/zones/{_zoneId}/dns_records", + payload, + cancellationToken); + + string body = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + return new DomainValidationResult + { + Success = false, + ErrorMessage = $"Cloudflare API error {(int)response.StatusCode}: {body}" + }; + + using var doc = JsonDocument.Parse(body); + bool success = doc.RootElement.GetProperty("success").GetBoolean(); + string recordId = success + ? doc.RootElement.GetProperty("result").GetProperty("id").GetString() + : null; + + if (!success || string.IsNullOrEmpty(recordId)) + return new DomainValidationResult + { + Success = false, + ErrorMessage = $"Cloudflare record creation failed: {body}" + }; + + _stagedRecordIds[key] = recordId; + + return new DomainValidationResult { Success = true }; + } + + public async Task CleanupValidation(string key, CancellationToken cancellationToken) + { + if (!_stagedRecordIds.TryRemove(key, out string recordId)) + return new DomainValidationResult { Success = true }; // nothing to clean up + + var response = await _http.DeleteAsync( + $"{CfApiBase}/zones/{_zoneId}/dns_records/{recordId}", + cancellationToken); + + if (!response.IsSuccessStatusCode) + { + string body = await response.Content.ReadAsStringAsync(cancellationToken); + return new DomainValidationResult + { + Success = false, + ErrorMessage = $"Cloudflare delete error {(int)response.StatusCode}: {body}" + }; + } + + return new DomainValidationResult { Success = true }; + } + + public Task ValidateConfiguration(Dictionary configuration) => Task.CompletedTask; + public Dictionary GetDomainValidatorAnnotations() => new(); + public string GetValidationType() => "dns-01"; + + public void Dispose() => _http.Dispose(); + } + + internal sealed class CloudflareDomainValidatorFactory : IDomainValidatorFactory, IDisposable + { + private readonly CloudflareDomainValidator _validator; + + public CloudflareDomainValidatorFactory(string apiToken, string zoneId) + { + _validator = new CloudflareDomainValidator(apiToken, zoneId); + } + + public IDomainValidator ResolveDomainValidator(string domain, string validationType) => _validator; + + public void Dispose() => _validator.Dispose(); + } +} diff --git a/CERTInext.IntegrationTests/DcvLifecycleTests.cs b/CERTInext.IntegrationTests/DcvLifecycleTests.cs new file mode 100644 index 0000000..5f2d57e --- /dev/null +++ b/CERTInext.IntegrationTests/DcvLifecycleTests.cs @@ -0,0 +1,903 @@ +// Copyright 2024 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// At http://www.apache.org/licenses/LICENSE-2.0 + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.PKI.Enums.EJBCA; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// Integration tests for the DNS DCV enrollment path. + /// + /// DNS validator selection: + /// • When CERTINEXT_CF_API_TOKEN and CERTINEXT_CF_ZONE_ID are set in + /// ~/.env_certinext, a is used and + /// a real TXT record is published and cleaned up around the enrollment. + /// • Otherwise a is used. The plugin still + /// exercises the full DCV orchestration path (Stage → propagation wait → VerifyDcv + /// → Cleanup), but no real DNS record is published. Whether CERTInext's VerifyDcv + /// succeeds in this mode depends on the sandbox environment. + /// + /// All tests skip when CERTInext credentials are absent (). + /// Add the following to ~/.env_certinext to run with real DNS: + /// + /// CERTINEXT_CF_API_TOKEN=<your Cloudflare API token with DNS:Edit> + /// CERTINEXT_CF_ZONE_ID=<Cloudflare Zone ID for your test domain> + /// CERTINEXT_DCV_DOMAIN=<subdomain to use, e.g. dcv-test.example.com> + /// + /// + public class DcvLifecycleTests : IClassFixture, IDisposable + { + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + private readonly List _toDispose = new List(); + + public DcvLifecycleTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + } + + public void Dispose() + { + foreach (var d in _toDispose) + d.Dispose(); + _toDispose.Clear(); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private static string GenerateCsrPem(string commonName) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + var keyPair = keyGen.GenerateKeyPair(); + + var subject = new X509Name($"CN={commonName}"); + var csr = new Pkcs10CertificationRequest("SHA256withRSA", subject, keyPair.Public, null, keyPair.Private); + + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + private IDomainValidatorFactory BuildDnsFactory() + { + if (_fixture.IsCloudflareConfigured) + { + var factory = new CloudflareDomainValidatorFactory( + _fixture.CloudflareApiToken, _fixture.CloudflareZoneId); + _toDispose.Add(factory); + return factory; + } + return new StubDomainValidatorFactory(); + } + + /// + /// Runs plugin.Synchronize and returns every record that came out of the + /// blocking buffer. Mirrors the helper in LifecycleTests; kept local so + /// the DCV bulk test isn't coupled to that file's private member. + /// + private static async Task> RunSyncAsync(CERTInextCAPlugin plugin) + { + var buffer = new System.Collections.Concurrent.BlockingCollection(boundedCapacity: 10_000); + var collected = new List(); + + var syncTask = Task.Run(async () => + { + await plugin.Synchronize(buffer, lastSync: null, fullSync: true, cancelToken: System.Threading.CancellationToken.None); + // Synchronize calls CompleteAdding() in its finally block; guard against double-call. + if (!buffer.IsAddingCompleted) + buffer.CompleteAdding(); + }); + + foreach (var record in buffer.GetConsumingEnumerable()) + collected.Add(record); + + await syncTask; + return collected; + } + + private CERTInextCAPlugin BuildPlugin(bool dcvEnabled, int propagationDelaySeconds = 5, int? pageSize = null) + { + var config = new CERTInextConfig + { + ApiUrl = _fixture.Config.ApiUrl, + AuthMode = _fixture.Config.AuthMode, + ApiKey = _fixture.Config.ApiKey, + AccountNumber = _fixture.Config.AccountNumber, + GroupNumber = _fixture.Config.GroupNumber, + OrganizationNumber = _fixture.Config.OrganizationNumber, + RequestorName = _fixture.Config.RequestorName, + RequestorEmail = _fixture.Config.RequestorEmail, + RequestorIsdCode = _fixture.Config.RequestorIsdCode, + RequestorMobileNumber = _fixture.Config.RequestorMobileNumber, + SignerPlace = _fixture.Config.SignerPlace, + SignerIp = _fixture.Config.SignerIp, + DefaultProductCode = _fixture.Config.DefaultProductCode, + PageSize = pageSize ?? _fixture.Config.PageSize, + DcvEnabled = dcvEnabled, + DcvPropagationDelaySeconds = propagationDelaySeconds, + DcvTimeoutMinutes = 3 + }; + + return new CERTInextCAPlugin(_fixture.Client, BuildDnsFactory(), config); + } + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + /// + /// Enroll with DCV enabled. Uses a real Cloudflare DNS record when CF credentials + /// are configured, otherwise uses . + /// + /// The test verifies that the plugin completes without throwing. The enrollment + /// result status depends on whether the CERTInext sandbox auto-issues after DCV. + /// + [SkippableFact] + public async Task DcvEnroll_CompletesWithoutThrowing() + { + IntegrationSkip.IfNotConfigured(_fixture); + + var plugin = BuildPlugin(dcvEnabled: true); + + var result = await plugin.Enroll( + csr: GenerateCsrPem(IntegrationTestData.DcvTestDomain), + subject: $"CN={IntegrationTestData.DcvTestDomain}", + san: new Dictionary + { + ["dns"] = new[] { IntegrationTestData.DcvTestDomain } + }, + productInfo: IntegrationTestData.DvSslProductInfo(_fixture.Config.DefaultProductCode), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Should().NotBeNull(); + _output.WriteLine($"Domain: {IntegrationTestData.DcvTestDomain}"); + _output.WriteLine($"CARequestID: {result.CARequestID}"); + _output.WriteLine($"Status: {result.Status}"); + _output.WriteLine($"Message: {result.StatusMessage}"); + + if (_fixture.IsCloudflareConfigured) + { + // With real DNS, CERTInext should be able to verify — assert issuance or pending + new[] { (int)EndEntityStatus.GENERATED, (int)EndEntityStatus.EXTERNALVALIDATION } + .Should().Contain(result.Status, + "enrollment with real DNS DCV should produce a valid terminal or pending status"); + } + else + { + // Without real DNS the VerifyDcv may fail; we only assert no unhandled exception + // was thrown (the Enroll method handles the error gracefully). + result.Should().NotBeNull("enrollment should return a result even when stub DNS is used"); + } + } + + /// + /// Enroll without DCV enabled — verifies the plugin skips the DCV path entirely + /// and returns a result from the normal enrollment flow. + /// + [SkippableFact] + public async Task EnrollWithoutDcv_DoesNotInvokeDnsProvider() + { + IntegrationSkip.IfNotConfigured(_fixture); + + // Use a plugin backed by the real client but DcvEnabled=false + var plugin = BuildPlugin(dcvEnabled: false); + + var result = await plugin.Enroll( + csr: GenerateCsrPem(IntegrationTestData.DcvTestDomain), + subject: $"CN={IntegrationTestData.DcvTestDomain}", + san: new Dictionary + { + ["dns"] = new[] { IntegrationTestData.DcvTestDomain } + }, + productInfo: IntegrationTestData.DvSslProductInfo(_fixture.Config.DefaultProductCode), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Should().NotBeNull(); + } + + /// + /// End-to-end "DCV mode off" scenario, mirroring how a v3.2 gateway host would + /// experience the plugin (no IDomainValidatorFactory available, so DCV silently + /// no-ops). Enrolls a fresh domain with DcvEnabled=false, then runs the plugin's + /// own Synchronize and asserts the order surfaces in pending-DCV state. + /// This is the live verification for GitHub issue #7. + /// + /// The CERTInext side may auto-issue some orders very quickly thanks to cached + /// DCV for previously-validated parent domains; this test uses a freshly random + /// subdomain to minimize that but tolerates either pending or issued in the + /// assertion (the real signal we want is "the plugin did not invoke DCV"). + /// + [SkippableFact] + public async Task EnrollWithDcvOff_OrderAppearsInSync_PluginDidNotInvokeDcv() + { + IntegrationSkip.IfNotConfigured(_fixture); + + // Generate a unique CN so prior cached-DCV state on the parent zone doesn't + // bias the result. + string suffix = System.Guid.NewGuid().ToString("N").Substring(0, 8); + string cn = $"dcv-off-{suffix}.scrup.org"; + + // Plugin built with DCV disabled. BuildPlugin still wires a Cloudflare or stub + // factory but PerformDcvIfNeededAsync gates on _config.DcvEnabled so neither + // factory will be touched on this Enroll path. + var plugin = BuildPlugin(dcvEnabled: false); + + // --- Enroll phase --- + var enrollSw = System.Diagnostics.Stopwatch.StartNew(); + var enrollResult = await plugin.Enroll( + csr: GenerateCsrPem(cn), + subject: $"CN={cn}", + san: new Dictionary { ["dns"] = new[] { cn } }, + productInfo: IntegrationTestData.DvSslProductInfo(_fixture.Config.DefaultProductCode), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + enrollSw.Stop(); + + enrollResult.Should().NotBeNull(); + enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace( + "the CA must accept the order even with DCV off — DCV-off ≠ no enrollment"); + + _output.WriteLine($"Enroll completed in {enrollSw.Elapsed:mm\\:ss\\.fff}"); + _output.WriteLine($" CARequestID: {enrollResult.CARequestID}"); + _output.WriteLine($" Status: {enrollResult.Status}"); + _output.WriteLine($" Message: {enrollResult.StatusMessage}"); + + // The plugin's "DCV off" contract: with DcvEnabled=false the plugin does NOT + // wait for issuance. Even if CERTInext later auto-issues from cached DCV, the + // immediate Enroll response should be pending (no issuance polling ran). + // We allow GENERATED too because cached DCV on the parent zone could plausibly + // make CERTInext mark the order issued before its first reply — but the most + // common case is EXTERNALVALIDATION. + new[] { (int)EndEntityStatus.EXTERNALVALIDATION, (int)EndEntityStatus.GENERATED } + .Should().Contain(enrollResult.Status, + $"DCV-off Enroll must return a recognizable terminal/pending state; got {enrollResult.Status}"); + + // --- Sync phase: pull the whole account, find our order --- + var syncSw = System.Diagnostics.Stopwatch.StartNew(); + var synced = await RunSyncAsync(plugin); + syncSw.Stop(); + _output.WriteLine($"Synchronize returned {synced.Count} records in {syncSw.Elapsed:mm\\:ss\\.fff}"); + + var record = synced.FirstOrDefault(r => r.CARequestID == enrollResult.CARequestID); + record.Should().NotBeNull( + $"the enrolled order ({enrollResult.CARequestID}) must appear in plugin.Synchronize results"); + _output.WriteLine($" Sync record status: {record!.Status}"); + + // Final shape assertion: order is in the inventory, and its status is either + // pending (EXTERNALVALIDATION — typical when CERTInext hasn't moved it yet) + // or issued (GENERATED — if CERTInext autoissued from cached DCV). It must + // NOT be FAILED — DCV-off should not produce a failed cert. + new[] { (int)EndEntityStatus.EXTERNALVALIDATION, (int)EndEntityStatus.GENERATED } + .Should().Contain(record.Status, + "the synced record must reflect either pending or issued — never FAILED with DCV off"); + + // Surface the human-readable summary so the live behavior is visible in the + // test output without needing to grep the gateway logs. + _output.WriteLine($"--- Verdict: DCV-off enroll for {cn} succeeded, plugin did not invoke DCV, " + + $"order {enrollResult.CARequestID} surfaced in sync with Status={record.Status}. ---"); + } + + /// + /// Symmetric counterpart to . + /// Drives a fresh enrollment with DCV ON end-to-end against the live sandbox and + /// asserts the issued cert flows through Synchronize. This is the v3.3+ + /// production scenario — plugin places the order, runs DNS TXT staging via + /// Cloudflare, asks CERTInext to verify, waits for issuance, and the resulting + /// GENERATED record surfaces in the gateway's inventory. + /// + [SkippableFact] + public async Task EnrollWithDcvOn_OrderIssuedEndToEnd_AndAppearsInSync() + { + IntegrationSkip.IfNotConfigured(_fixture); + Skip.If(!_fixture.IsCloudflareConfigured, + "CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID required — DCV-on test must publish real TXT records."); + + string suffix = System.Guid.NewGuid().ToString("N").Substring(0, 8); + string cn = $"dcv-on-{suffix}.scrup.org"; + + var plugin = BuildPlugin(dcvEnabled: true); + + // --- Enroll phase --- + var enrollSw = System.Diagnostics.Stopwatch.StartNew(); + var enrollResult = await plugin.Enroll( + csr: GenerateCsrPem(cn), + subject: $"CN={cn}", + san: new Dictionary { ["dns"] = new[] { cn } }, + productInfo: IntegrationTestData.DvSslProductInfo(_fixture.Config.DefaultProductCode), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + enrollSw.Stop(); + + enrollResult.Should().NotBeNull(); + enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace(); + _output.WriteLine($"Enroll completed in {enrollSw.Elapsed:mm\\:ss\\.fff}"); + _output.WriteLine($" CARequestID: {enrollResult.CARequestID}"); + _output.WriteLine($" Status: {enrollResult.Status}"); + _output.WriteLine($" Certificate: {(string.IsNullOrWhiteSpace(enrollResult.Certificate) ? "(not in Enroll response)" : enrollResult.Certificate[..60] + "...")}"); + + // Enroll must NOT be FAILED. GENERATED if the bounded issuance wait caught + // the cert before returning; EXTERNALVALIDATION if not — sync will catch it. + new[] { (int)EndEntityStatus.EXTERNALVALIDATION, (int)EndEntityStatus.GENERATED } + .Should().Contain(enrollResult.Status, + $"DCV-on Enroll must return pending or issued; got {enrollResult.Status}"); + + // --- Sync phase --- + var syncSw = System.Diagnostics.Stopwatch.StartNew(); + var synced = await RunSyncAsync(plugin); + syncSw.Stop(); + _output.WriteLine($"Synchronize returned {synced.Count} records in {syncSw.Elapsed:mm\\:ss\\.fff}"); + + var record = synced.FirstOrDefault(r => r.CARequestID == enrollResult.CARequestID); + record.Should().NotBeNull( + $"the enrolled order ({enrollResult.CARequestID}) must appear in plugin.Synchronize results"); + _output.WriteLine($" Sync record status: {record!.Status}"); + _output.WriteLine($" Cert PEM length: {(record.Certificate?.Length ?? 0)}"); + + // The plugin's sync-DCV-retry should have advanced any still-pending orders. + // With Cloudflare DCV available, every DCV-on enrollment should resolve to + // GENERATED by the time sync returns. If we see EXTERNALVALIDATION here it + // means CERTInext's async issuance window is still in flight after our sync — + // worth noting but not a hard failure (the next sync will pick it up). + record.Status.Should().BeOneOf((int)EndEntityStatus.GENERATED, (int)EndEntityStatus.EXTERNALVALIDATION); + + // Issue 0001: Synchronize now materialises the PEM for issued certs. + // ListCertificatesAsync returns order-report metadata (no body), so the plugin + // refetches the full certificate for GENERATED/REVOKED records during sync. + if (record.Status == (int)EndEntityStatus.GENERATED) + { + record.Certificate.Should().NotBeNullOrWhiteSpace( + "Synchronize must populate the cert body for issued orders (issue 0001) — " + + "the order-report listing carries none, so the plugin refetches it."); + + // GetSingleRecord is the same on-demand fetch the gateway uses for inventory. + var fetched = await plugin.GetSingleRecord(enrollResult.CARequestID); + fetched.Should().NotBeNull(); + fetched.Status.Should().Be((int)EndEntityStatus.GENERATED); + fetched.Certificate.Should().NotBeNullOrWhiteSpace( + "GetSingleRecord must populate the PEM for a GENERATED order."); + _output.WriteLine($" Sync cert PEM length: {record.Certificate!.Length}; " + + $"GetSingleRecord PEM length: {fetched.Certificate!.Length}"); + } + + _output.WriteLine($"--- Verdict: DCV-on enroll for {cn} drove DCV end-to-end via plugin, " + + $"order {enrollResult.CARequestID} surfaced in sync with Status={record.Status}. ---"); + } + + /// + /// End-to-end key-algorithm issuance matrix: RSA 2048/3072/4096/6144/8192, ECDSA + /// P-256/P-384/P-521, Ed25519, Ed448 (see ). For each type, + /// enroll a fresh scrup.org DV order with DCV ON, drive it to issuance via the plugin + /// (Cloudflare TXT publish → VerifyDcv → bounded sync passes), and assert the issued cert + /// carries a parseable body whose public key matches the requested algorithm. + /// + /// An algorithm CERTInext won't issue — rejected at submission, FAILED, or never reaching + /// GENERATED within the polling window — is reported as an explicit Skip carrying the + /// observed reason, so the matrix documents which algorithms CERTInext actually issues + /// without hard-failing on a legitimate CA limitation. + /// + /// Opt-in (issues a real cert per accepted algorithm): set CERTINEXT_ALGO_MATRIX_DCV=1. + /// Requires Cloudflare DCV credentials. + /// + [SkippableTheory] + [MemberData(nameof(KeyAlgorithms.AsMemberData), MemberType = typeof(KeyAlgorithms))] + public async Task EnrollWithDcvOn_IssuesPerKeyAlgorithm(string tag) + { + IntegrationSkip.IfNotConfigured(_fixture); + Skip.If(System.Environment.GetEnvironmentVariable("CERTINEXT_ALGO_MATRIX_DCV") != "1", + "Opt-in: set CERTINEXT_ALGO_MATRIX_DCV=1 to issue one real scrup.org cert per key algorithm."); + Skip.If(!_fixture.IsCloudflareConfigured, + "CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID required — DCV issuance must publish real TXT records."); + + var spec = KeyAlgorithms.For(tag); + string suffix = System.Guid.NewGuid().ToString("N").Substring(0, 8); + string cn = $"algo-{KeyAlgorithms.Slug(tag)}-{suffix}.scrup.org"; + string csr = KeyAlgorithms.GenerateCsrPem(cn, spec); + + var plugin = BuildPlugin(dcvEnabled: true); + + // --- Enroll. A submission-time rejection (unsupported algorithm) → Skip with the CA's reason. --- + EnrollmentResult enrollResult; + try + { + enrollResult = await plugin.Enroll( + csr: csr, + subject: $"CN={cn}", + san: new Dictionary { ["dns"] = new[] { cn } }, + productInfo: IntegrationTestData.DvSslProductInfo(_fixture.Config.DefaultProductCode), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + } + catch (Exception ex) + { + string reason = KeyAlgorithms.ClassifyRejection(ex.Message); + _output.WriteLine($"[SKIP] {tag}: {reason} — {ex.Message}"); + Skip.If(true, $"CERTInext did not issue a {tag} cert: {reason}. CA message: {ex.Message}"); + return; // unreachable — Skip throws + } + + enrollResult.Should().NotBeNull(); + enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace($"{tag}: CA must return a CARequestID when it accepts the order"); + _output.WriteLine($"[{tag}] enrolled cn={cn} id={enrollResult.CARequestID} status={enrollResult.Status}"); + + // --- Poll this one order to issuance via GetSingleRecord (targeted; avoids the + // full-account sync, which would also drive DCV on unrelated pending orders). --- + const int maxPolls = 6; + const int delaySeconds = 15; + AnyCAPluginCertificate record = null; + for (int poll = 1; poll <= maxPolls; poll++) + { + record = await plugin.GetSingleRecord(enrollResult.CARequestID); + int status = record?.Status ?? -1; + _output.WriteLine($"[{tag}] poll #{poll}: status={status} certLen={record?.Certificate?.Length ?? 0}"); + + // Wait for GENERATED *with a materialized body*. CERTInext flips status to + // GENERATED a beat before GetCertificate returns the PEM, so an order that + // issues quickly can report GENERATED with an empty body for a poll or two. + if (status == (int)EndEntityStatus.GENERATED && !string.IsNullOrWhiteSpace(record?.Certificate)) + break; + if (status == (int)EndEntityStatus.FAILED) + { + _output.WriteLine($"[SKIP] {tag}: order {enrollResult.CARequestID} went FAILED — CERTInext will not issue this algorithm."); + Skip.If(true, $"CERTInext FAILED the {tag} order — algorithm not issuable on this account/profile."); + return; + } + if (poll < maxPolls) + await Task.Delay(TimeSpan.FromSeconds(delaySeconds)); + } + + record.Should().NotBeNull($"{tag}: enrolled order {enrollResult.CARequestID} must be retrievable"); + + if (record!.Status != (int)EndEntityStatus.GENERATED) + { + // Accepted at submission but not issued within the window — document as Skip, not fail. + _output.WriteLine($"[SKIP] {tag}: order {enrollResult.CARequestID} still Status={record.Status} after {maxPolls} polls."); + Skip.If(true, $"CERTInext accepted the {tag} order but it did not reach GENERATED within the polling window " + + $"(Status={record.Status}) — possible unsupported algorithm or slow server-side validation."); + return; + } + + record.Certificate.Should().NotBeNullOrWhiteSpace( + $"{tag}: issued cert must carry a PEM body (issue 0001)"); + + // Strong check: the issued cert's public key must match the algorithm we requested. + AssertIssuedCertMatchesAlgorithm(record.Certificate, spec, tag); + + _output.WriteLine($"--- {tag}: DCV-on issuance OK — order {enrollResult.CARequestID} GENERATED, " + + $"cert public key confirmed as {tag}. ---"); + } + + /// + /// Parses an issued certificate PEM and asserts its public key matches the requested + /// algorithm/size — proves CERTInext issued the key type we submitted, not a substitute. + /// + private static void AssertIssuedCertMatchesAlgorithm(string certPem, KeyAlgorithmSpec spec, string tag) + { + var b64 = certPem + .Replace("-----BEGIN CERTIFICATE-----", string.Empty) + .Replace("-----END CERTIFICATE-----", string.Empty) + .Replace("\r", string.Empty).Replace("\n", string.Empty).Trim(); + + var cert = new Org.BouncyCastle.X509.X509CertificateParser().ReadCertificate(Convert.FromBase64String(b64)); + cert.Should().NotBeNull($"{tag}: issued cert PEM must parse"); + + var pub = cert.GetPublicKey(); + switch (spec.Kind) + { + case KeyKind.Rsa: + pub.Should().BeOfType(); + ((RsaKeyParameters)pub).Modulus.BitLength.Should().Be(spec.Strength, + $"{tag}: issued RSA cert must have a {spec.Strength}-bit modulus"); + break; + case KeyKind.Ecdsa: + pub.Should().BeOfType(); + ((ECPublicKeyParameters)pub).Parameters.Curve.FieldSize.Should().Be(spec.Strength, + $"{tag}: issued EC cert must use a {spec.Strength}-bit curve"); + break; + case KeyKind.Ed25519: + pub.Should().BeOfType(); + break; + case KeyKind.Ed448: + pub.Should().BeOfType(); + break; + } + } + + /// + /// Exercises the deferred-DCV retry path during single-record refresh against an + /// existing pending order. Reads CERTINEXT_PENDING_ORDER_ID from the + /// environment; the test is skipped if not set, since this scenario requires a + /// real order that CERTInext has parked at Pending System RA with + /// dcvStatus=0 after the initial enrollment. + /// + /// On success, GetSingleRecord drives DCV (Cloudflare TXT publish → + /// CERTInext VerifyDcv → wait for verification → cleanup) and returns either an + /// issued record () or a still-pending + /// record if CERTInext has not finished server-side validation yet. + /// + [SkippableFact] + public async Task GetSingleRecord_DrivesDcvForPendingOrder() + { + IntegrationSkip.IfNotConfigured(_fixture); + + string orderId = System.Environment.GetEnvironmentVariable("CERTINEXT_PENDING_ORDER_ID"); + Skip.If(string.IsNullOrWhiteSpace(orderId), + "Set CERTINEXT_PENDING_ORDER_ID to a real pending-DCV order to run this test."); + + Skip.If(!_fixture.IsCloudflareConfigured, + "CERTINEXT_CF_API_TOKEN and CERTINEXT_CF_ZONE_ID must be set so the plugin " + + "can publish a real TXT record for CERTInext to verify."); + + // DCV must be enabled and a real DNS provider must be wired up — otherwise the + // sync-retry helper short-circuits with no effect. + var plugin = BuildPlugin(dcvEnabled: true); + + var record = await plugin.GetSingleRecord(orderId); + + record.Should().NotBeNull(); + _output.WriteLine($"CARequestID: {record.CARequestID}"); + _output.WriteLine($"Status: {record.Status}"); + _output.WriteLine($"Certificate: {(string.IsNullOrWhiteSpace(record.Certificate) ? "(not yet issued)" : record.Certificate[..60] + "...")}"); + + // We assert no unhandled exception was thrown and a record came back. The exact + // final status is environment-dependent (CERTInext may still be working through + // VerifyDcv even after the plugin returns), so we accept either GENERATED or + // a still-pending EXTERNALVALIDATION status here — the regression we're guarding + // against is the silent no-op the plugin used to do on this path. + new[] { (int)EndEntityStatus.GENERATED, (int)EndEntityStatus.EXTERNALVALIDATION } + .Should().Contain(record.Status, + "deferred-DCV retry should leave the order in a valid pending or issued state"); + } + + /// + /// Volume / pagination smoke test — enrolls a configurable number of DV orders + /// concurrently (default 101) against fresh unique subdomains, then runs + /// plugin.Synchronize with the connector's PageSize=100 to verify + /// (a) every order issued, (b) every order shows up in sync, and (c) the sync + /// iterator correctly crosses the 100-record page boundary in + /// ListCertificatesAsync. + /// + /// This is an opt-in test because it places real CA orders and takes several + /// minutes. Set CERTINEXT_RUN_BULK_TEST=1 in the environment to run. + /// Override the count with CERTINEXT_BULK_TEST_COUNT (default 101) and + /// the concurrency cap with CERTINEXT_BULK_TEST_PARALLEL (default 5). + /// + [SkippableFact] + public async Task BulkDvEnrollment_AllOrdersIssue_AndPaginationWorks() + { + IntegrationSkip.IfNotConfigured(_fixture); + Skip.If(System.Environment.GetEnvironmentVariable("CERTINEXT_RUN_BULK_TEST") != "1", + "Opt-in: set CERTINEXT_RUN_BULK_TEST=1 to run the volume/pagination test."); + Skip.If(!_fixture.IsCloudflareConfigured, + "CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID required — bulk test must publish real TXT records."); + + int count = int.TryParse(System.Environment.GetEnvironmentVariable("CERTINEXT_BULK_TEST_COUNT"), out int c) + ? c : 101; + int parallel = int.TryParse(System.Environment.GetEnvironmentVariable("CERTINEXT_BULK_TEST_PARALLEL"), out int p) + ? p : 5; + + // PageSize=100 ensures the 101st order forces a second page during Synchronize. + var plugin = BuildPlugin(dcvEnabled: true, propagationDelaySeconds: 5, pageSize: 100); + + // --- Phase 1: bounded-parallel enrollments --- + var enrolled = new System.Collections.Concurrent.ConcurrentBag<(int idx, string cn, EnrollmentResult result)>(); + var failures = new System.Collections.Concurrent.ConcurrentBag<(int idx, string cn, string error)>(); + var sw = System.Diagnostics.Stopwatch.StartNew(); + + using (var sem = new System.Threading.SemaphoreSlim(parallel, parallel)) + { + var tasks = Enumerable.Range(0, count).Select(async i => + { + await sem.WaitAsync(); + try + { + // Unique CN per order — uses Guid hex prefix so reruns don't collide. + string suffix = Guid.NewGuid().ToString("N").Substring(0, 8); + string cn = $"bulk-{suffix}.scrup.org"; + string csr = GenerateCsrPem(cn); + + var result = await plugin.Enroll( + csr: csr, + subject: $"CN={cn}", + san: new Dictionary { ["dns"] = new[] { cn } }, + productInfo: IntegrationTestData.DvSslProductInfo(_fixture.Config.DefaultProductCode), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + enrolled.Add((i, cn, result)); + _output.WriteLine($"[{i:000}] OK cn={cn} id={result.CARequestID} status={result.Status}"); + } + catch (Exception ex) + { + failures.Add((i, $"#{i}", ex.Message)); + _output.WriteLine($"[{i:000}] FAIL {ex.GetType().Name}: {ex.Message}"); + } + finally + { + sem.Release(); + } + }); + await Task.WhenAll(tasks); + } + + sw.Stop(); + _output.WriteLine($"--- Enroll phase: enrolled={enrolled.Count}, failed={failures.Count}, elapsed={sw.Elapsed:mm\\:ss} ---"); + + failures.Should().BeEmpty( + "every Enroll() call must succeed (the plugin's EMS-956 tolerance means even pending DCV returns gracefully); " + + $"got {failures.Count} hard failures."); + enrolled.Count.Should().Be(count, $"expected {count} successful Enroll() calls"); + + var enrolledIds = enrolled + .Where(e => !string.IsNullOrEmpty(e.result.CARequestID)) + .Select(e => e.result.CARequestID) + .ToHashSet(); + enrolledIds.Count.Should().Be(count, "every enrollment must return a CARequestID"); + + // --- Phase 2: Synchronize until every enrolled order reaches GENERATED --- + // + // CERTInext's pipeline is async: VerifyDcv triggers a server-side DNS-01 check + // and certificate generation that completes a few seconds *after* the plugin's + // Enroll() returns. A single Synchronize captures whatever state CERTInext has + // settled at that exact moment, so a chunk of orders typically remain at + // EXTERNALVALIDATION on the first pass. The sync-driven DCV retry in the plugin + // handles staggered completion across subsequent gateway sync cycles — so this + // test mimics that by running Synchronize repeatedly until either all 101 are + // GENERATED or a bounded number of attempts is exhausted. + const int maxSyncPasses = 8; + const int delayBetweenPassesSeconds = 30; + + List synced = null; + System.Diagnostics.Stopwatch syncPhaseSw = System.Diagnostics.Stopwatch.StartNew(); + int passesUsed = 0; + + for (int pass = 1; pass <= maxSyncPasses; pass++) + { + passesUsed = pass; + var passSw = System.Diagnostics.Stopwatch.StartNew(); + synced = await RunSyncAsync(plugin); + passSw.Stop(); + + // Classify enrolled orders by their current status so that FAILED orders + // are not silently counted as still-pending, which would burn the full + // pass budget before producing a misleading "expected 0" assertion. + int generated = synced.Count(r => enrolledIds.Contains(r.CARequestID) && r.Status == (int)EndEntityStatus.GENERATED); + int failed = synced.Count(r => enrolledIds.Contains(r.CARequestID) && r.Status == (int)EndEntityStatus.FAILED); + int pending = enrolledIds.Count - generated - failed; + + _output.WriteLine( + $"--- Sync pass #{pass}: returned {synced.Count} records, {generated}/{enrolledIds.Count} GENERATED, " + + $"{failed} FAILED, {pending} still pending, elapsed={passSw.Elapsed:mm\\:ss} ---"); + + if (failed > 0) + { + var failedIds = synced + .Where(r => enrolledIds.Contains(r.CARequestID) && r.Status == (int)EndEntityStatus.FAILED) + .Select(r => r.CARequestID) + .Take(5); + Assert.Fail( + $"Pass #{pass}: {failed} order(s) reached FAILED status and will never issue: " + + string.Join(", ", failedIds)); + } + + if (pending == 0) + break; + + if (pass < maxSyncPasses) + { + _output.WriteLine($" Waiting {delayBetweenPassesSeconds}s before next sync pass…"); + await Task.Delay(TimeSpan.FromSeconds(delayBetweenPassesSeconds)); + } + } + syncPhaseSw.Stop(); + + // Pagination check — sync must have returned strictly more than one page. + synced!.Count.Should().BeGreaterThan(100, + "with 101 freshly-enrolled orders + any pre-existing, sync must return >100 records " + + "to prove the ListCertificatesAsync paginator crossed PageSize=100."); + + // Every enrolled CARequestID must show up. + var syncedIds = synced.Select(r => r.CARequestID).ToHashSet(); + var missing = enrolledIds.Where(id => !syncedIds.Contains(id)).ToList(); + missing.Should().BeEmpty( + $"{missing.Count} enrolled orders did not appear in sync results: " + + $"{string.Join(", ", missing.Take(5))}{(missing.Count > 5 ? ", ..." : "")}"); + + // Final assertion — every enrolled order must be GENERATED after the polling window. + // Filter null CARequestIDs before building the lookup (guards against any CA response + // that omits the ID, which would otherwise throw ArgumentNullException in ToDictionary). + var lookup = synced + .Where(r => r.CARequestID != null) + .ToDictionary(r => r.CARequestID, r => r); + var notIssued = enrolledIds + .Where(id => lookup.TryGetValue(id, out var rec) && rec.Status != (int)EndEntityStatus.GENERATED) + .Select(id => lookup[id]) + .ToList(); + + if (notIssued.Count > 0) + { + _output.WriteLine($"--- After {passesUsed} sync passes, {notIssued.Count} order(s) still not GENERATED: ---"); + foreach (var r in notIssued.Take(10)) + _output.WriteLine($" {r.CARequestID} Status={r.Status}"); + } + + notIssued.Should().BeEmpty( + $"every enrolled DV order should auto-issue on the new sandbox after {maxSyncPasses} sync passes; " + + $"{notIssued.Count} did not."); + + _output.WriteLine($"--- SUCCESS: {count}/{count} DV orders enrolled, synced, and issued in {passesUsed} sync pass(es). " + + $"Enroll={sw.Elapsed:mm\\:ss} SyncPhase={syncPhaseSw.Elapsed:mm\\:ss} Total={(sw.Elapsed + syncPhaseSw.Elapsed):mm\\:ss} ---"); + } + + /// + /// Operational task: drive every existing pending-DV order to completion. + /// + /// Unlike , this enrolls + /// nothing — it just runs the plugin's full Synchronize with DCV enabled, which + /// invokes TryRunDcvDuringSyncAsync for every order sitting at + /// (Cloudflare TXT publish → VerifyDcv → + /// wait → cleanup). It repeats the sync until no order remains pending or the pass budget + /// is exhausted, reporting which orders transitioned to . + /// + /// Opt-in (it mutates real CA orders and publishes real DNS records): set + /// CERTINEXT_COMPLETE_PENDING=1. Requires Cloudflare DCV credentials. + /// + [SkippableFact] + public async Task CompleteAllPendingDvOrders() + { + IntegrationSkip.IfNotConfigured(_fixture); + Skip.If(System.Environment.GetEnvironmentVariable("CERTINEXT_COMPLETE_PENDING") != "1", + "Opt-in: set CERTINEXT_COMPLETE_PENDING=1 to drive all pending DV orders to completion."); + Skip.If(!_fixture.IsCloudflareConfigured, + "CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID required — completing DCV must publish real TXT records."); + + var plugin = BuildPlugin(dcvEnabled: true); + + const int maxSyncPasses = 8; + const int delayBetweenPassesSeconds = 30; + + List synced = null; + int passesUsed = 0; + var phaseSw = System.Diagnostics.Stopwatch.StartNew(); + + for (int pass = 1; pass <= maxSyncPasses; pass++) + { + passesUsed = pass; + var passSw = System.Diagnostics.Stopwatch.StartNew(); + synced = await RunSyncAsync(plugin); + passSw.Stop(); + + var pending = synced.Where(r => r.Status == (int)EndEntityStatus.EXTERNALVALIDATION).ToList(); + int generated = synced.Count(r => r.Status == (int)EndEntityStatus.GENERATED); + + _output.WriteLine( + $"--- Sync pass #{pass}: {synced.Count} records, {generated} GENERATED, " + + $"{pending.Count} still pending DV, elapsed={passSw.Elapsed:mm\\:ss} ---"); + foreach (var r in pending.Take(20)) + _output.WriteLine($" pending: {r.CARequestID}"); + + if (pending.Count == 0) + break; + + if (pass < maxSyncPasses) + { + _output.WriteLine($" Waiting {delayBetweenPassesSeconds}s before next sync pass…"); + await Task.Delay(TimeSpan.FromSeconds(delayBetweenPassesSeconds)); + } + } + phaseSw.Stop(); + + synced.Should().NotBeNull("Synchronize must have run at least once"); + var stillPending = synced!.Where(r => r.Status == (int)EndEntityStatus.EXTERNALVALIDATION).ToList(); + + _output.WriteLine( + $"--- Done after {passesUsed} pass(es) in {phaseSw.Elapsed:mm\\:ss}: " + + $"{synced!.Count(r => r.Status == (int)EndEntityStatus.GENERATED)} GENERATED, " + + $"{stillPending.Count} still pending DV. ---"); + + // Orders may legitimately remain pending if CERTInext is still working server-side or + // a domain isn't in the configured Cloudflare zone — surface that rather than failing. + stillPending.Should().BeEmpty( + $"all pending DV orders should reach GENERATED after {maxSyncPasses} passes; " + + $"{stillPending.Count} remain (e.g. {string.Join(", ", stillPending.Take(5).Select(r => r.CARequestID))}). " + + "These likely have domains outside the configured Cloudflare zone or are still validating server-side."); + } + + // Regression for issue 0001 — a full Synchronize must return every issued cert WITH + // its PEM body. The order-report listing carries no body, so the plugin must refetch + // the full certificate; before the fix, issued certs synced with a null body and + // never appeared in Command. This is the end-to-end "issued certs fill in" check. + [SkippableFact] + public async Task FullSync_AllIssuedCerts_CarryParseableCertificateBody() + { + IntegrationSkip.IfNotConfigured(_fixture); + + var plugin = BuildPlugin(dcvEnabled: false); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + var synced = await RunSyncAsync(plugin); + sw.Stop(); + + var issued = synced.Where(r => r.Status == (int)EndEntityStatus.GENERATED).ToList(); + _output.WriteLine( + $"Synchronize returned {synced.Count} records in {sw.Elapsed:mm\\:ss} ({issued.Count} GENERATED)."); + + issued.Should().NotBeEmpty( + "the account has known issued certs (e.g. scrup.org) that a full sync must surface"); + + var parser = new Org.BouncyCastle.X509.X509CertificateParser(); + var bad = new System.Collections.Generic.List(); + foreach (var r in issued) + { + if (string.IsNullOrWhiteSpace(r.Certificate)) + { + bad.Add($"{r.CARequestID} (empty body)"); + continue; + } + try + { + var b64 = r.Certificate + .Replace("-----BEGIN CERTIFICATE-----", string.Empty) + .Replace("-----END CERTIFICATE-----", string.Empty) + .Replace("\r", string.Empty).Replace("\n", string.Empty).Trim(); + if (parser.ReadCertificate(Convert.FromBase64String(b64)) == null) + bad.Add($"{r.CARequestID} (unparseable)"); + } + catch (Exception ex) + { + bad.Add($"{r.CARequestID} ({ex.GetType().Name})"); + } + } + + bad.Should().BeEmpty( + "every issued cert must carry a parseable certificate body after sync; " + + $"offenders: {string.Join(", ", bad.Take(10))}"); + _output.WriteLine($"--- Verdict: all {issued.Count} issued certs carry a valid certificate body. ---"); + } + } + + /// + /// Shared test data for DCV integration tests. + /// + internal static class IntegrationTestData + { + /// + /// Domain used for DCV tests. Override via CERTINEXT_DCV_DOMAIN in + /// ~/.env_certinext. + /// + public static string DcvTestDomain => + System.Environment.GetEnvironmentVariable("CERTINEXT_DCV_DOMAIN") + ?? "dcv-test.example.com"; + + public static EnrollmentProductInfo DvSslProductInfo(string productCode = null) => + new EnrollmentProductInfo + { + ProductID = productCode ?? Constants.Products.DvSsl, + ProductParameters = new Dictionary + { + ["ProfileId"] = productCode ?? Constants.Products.DvSsl, + ["ValidityYears"] = "1" + } + }; + } +} diff --git a/CERTInext.IntegrationTests/EmailNotificationsV2ProbeTests.cs b/CERTInext.IntegrationTests/EmailNotificationsV2ProbeTests.cs new file mode 100644 index 0000000..4f9e096 --- /dev/null +++ b/CERTInext.IntegrationTests/EmailNotificationsV2ProbeTests.cs @@ -0,0 +1,843 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Live probe for issue 0027 item 1b (EmailNotifications value vocabulary — V2). The V2 +// spec documents `emailNotifications` as "Optional (default `all`)" but every single +// create-request example across all three product families sends exactly `"all"` — no +// alternate value appears anywhere in the spec text. This probe places ONE real V2 OV SSL +// order with `emailNotifications` set to `"0"` (V1's connector-config "notifications off" +// value) instead of `"all"`, and captures the raw HTTP status + response body from the +// create-order call (and, if the order is accepted, the Track Order response too) to +// determine whether the live CA accepts the value as-is, silently coerces it, or rejects +// the order outright. +// +// Raw HTTP only (same idiom as IdempotencyKeyV2ProbeTests.PlaceOrderRawAsync / +// OrganizationBlockV2ProbeTests.CancelSslOrderRawAsync) — deliberately bypasses +// CERTInextClient.PlaceOrderV2Async so the exact, unmodified response body can be +// inspected (the V2CreateOrderResponse/V2OrderStatusResponse DTOs have no +// emailNotifications slot at all, so going through them would silently discard the one +// piece of evidence this probe needs if the CA does echo the field back). +// +// Does NOT submit a CSR and does NOT attempt to push the order to issuance — the question +// this probe answers is fully contained in the create-order (and optional Track Order) +// response. Best-effort cancels the order afterward via the same raw cancel helper used by +// the other V2 probes in this project. +// +// Opt-in: requires CERTINEXT_PROBE_EMAIL_NOTIFICATIONS=1 (same convention as this project's +// sibling probes' CERTINEXT_PROBE_ORG_MISSING/CERTINEXT_PROBE_ORG_FIX/ +// CERTINEXT_PROBE_IDEMPOTENCY_KEY flags). Not armed by default in ~/.env_certinext or +// ~/.env_certinext_v2 — an operator must deliberately opt in, since this places one real, +// potentially cost-bearing V2 OV SSL order. Uses an OV product code confirmed orderable on +// this sandbox account (issues/V2_AUDIT_TRIAGE_HANDOFF.md's "Credentials / live access" +// section: 846/847/848/849/850/851) — default 846 (OV SSL, single-domain, non-UCC), +// overridable via CERTINEXT_OV_PRODUCT_CODE. Requires CERTINEXT_ORG_NUMBER (already present +// in most ~/.env_certinext files per this repo's other live tests) since OV orders require +// the `organization` block (issue 0028). +// +// IMPORTANT — observed during authoring (2026-09-26): on this sandbox, an OV order-create +// call can silently exceed 120s and cause a client-side TaskCanceledException with NO HTTP +// response ever received (RestSharp reports it as StatusCode=0/empty body), even though the +// CA actually created the order server-side. A client-side timeout on this endpoint is +// therefore NOT proof the order was never created — before assuming a failed create call +// means "no order," check the orders report (ListOrdersV2Async / GET +// /api/certinext/v2/reports/orders) for the domain used. This is why NewApiClient below uses +// an explicit long timeout matching CERTInextClient's own 120s, and why a caller hitting this +// timeout should not simply retry without checking for an orphaned order first — retrying +// blindly after a timeout is exactly how this authoring session ended up with two live OV +// orders (1815749817, 2743834762 — both since cancelled) instead of the single order this +// probe is meant to place. +// +// --- Phase 2 (added 2026-09-28): real-inbox test, for the same open question this file's +// original probe above could not resolve --- +// +// The API-only probe above could not distinguish "CERTInext honors emailNotifications='0'" +// from "CERTInext silently coerces it back to 'all'" — the field is never echoed back in any +// response, create or Track Order. The user's chosen resolution (issues/ +// V2_AUDIT_TRIAGE_HANDOFF.md, "EmailNotifications' open decision") is the strongest test +// available short of asking CERTInext directly: place two real V2 DV SSL orders, two minutes +// apart, one with emailNotifications="all" (baseline) and one with emailNotifications="0" +// (test), and have a human compare what actually arrives in the requestor's real inbox for +// each. Product: DV SSL, non-UCC, catalog code 842 — verified against the live catalog's +// productTypeID ("13" = DV SSL, non-UCC; see ProductDetail.ProductTypeId's own doc comment) +// before either order is placed, matched on productTypeID only, never productName (catalog +// productName strings are unstable across account/catalog-version — see this repo's Gotchas +// in issues/V2_AUDIT_TRIAGE_HANDOFF.md). No CSR is submitted and DCV is never invoked for +// either order. +// +// Both orders deliberately omit requestor.designation — the JSON key itself is absent, not +// null/empty, via the same global `DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull` +// serializer setting this file already relies on for other optional fields — which is also an +// (unplanned, incidental) live-answer opportunity for issue 0027 item 5e's still-open +// "requestor.designation hardcoded, no config field" design question: if either create call +// is rejected with a 4xx that mentions "designation", that rejection is logged verbatim as a +// live answer to 5e. The probe is not designed around that outcome, but it is free +// information if it happens. technicalPointOfContact is omitted entirely, matching this +// file's existing EmailNotifications_V2_NonAllValue_ObservesCaResponse probe above (which also +// never sets it). No delegation/recipientEmails field is set either (the DTO has none). +// +// Split into two [SkippableFact] tests, both gated behind a NEW flag, +// CERTINEXT_PROBE_EMAIL_NOTIFICATIONS_INBOX=1 — deliberately distinct from +// CERTINEXT_PROBE_EMAIL_NOTIFICATIONS above, since this test places two real orders and +// requires a human to manually check a real inbox afterward, a materially bigger commitment +// than the existing single-order, API-only probe. Not armed by default in ~/.env_certinext or +// ~/.env_certinext_v2. +// +// Phase 1 — EmailNotifications_V2_RealInboxTest_PlaceOrders: verifies the catalog product +// code resolves to productTypeID "13" (aborts before placing anything if it does not), +// places the baseline ("all") order, waits 2 minutes, places the test ("0") order. Exactly +// one create call per run — no retry path of any kind. A timeout or any non-2xx response on +// either call logs the domain/timestamp/error, prints "STOP — check the orders report for +// this domain before re-running" (per this file's own timeout gotcha above — a client-side +// timeout does not mean the CA never processed the request), and skips placing the second +// order. Never cancels either order in this phase — they are meant to sit open long enough +// for notification emails to actually arrive. Ends with an ACTION REQUIRED block naming both +// order IDs/domains and instructing the operator to check the inbox (including spam) at the +// 5- and 30-minute marks, recording subject/sender/time for every email that arrives, for +// each run. +// +// Phase 2 — EmailNotifications_V2_RealInboxTest_CancelOrders: run only after the human has +// finished checking the inbox. Gated behind the same flag plus +// CERTINEXT_INBOX_TEST_BASELINE_ORDER_ID / CERTINEXT_INBOX_TEST_ORDER_ID (the two order IDs +// phase 1 printed in its ACTION REQUIRED block). Skips entirely if neither is set; if only +// one is set (phase 1 stopped early after placing the baseline order but before the test +// order), cancels only that one rather than requiring both. Cancels via this file's existing +// CancelOrderRawAsync, then confirms cancellation via a follow-up read-only Track Order call +// (orderState == "Order Cancelled"). +// +// Like the probe above, this makes NO live API calls of any kind unless +// CERTINEXT_PROBE_EMAIL_NOTIFICATIONS_INBOX=1 is explicitly set. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using RestSharp; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + public class EmailNotificationsV2ProbeTests : IClassFixture + { + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + private readonly string _v2ApiUrl; + private readonly string _v2ClientId; + private readonly string _v2ClientSecret; + private readonly string _v2OvProductCode; + private readonly string _dcvDomainBase; + private readonly string _inboxTestEmail; + private readonly bool _v2Enabled; + + /// + /// Catalog product code for the phase 1/2 real-inbox probe — V2 DV SSL, non-UCC. + /// Deliberately a fixed constant (not read from CERTINEXT_PRODUCT_CODE/ + /// CERTINEXT_OV_PRODUCT_CODE) per the approved design: this probe's product choice is + /// locked in independently of whatever other env-configured product code a given shell + /// session happens to have set for unrelated tests. Verified against the live catalog's + /// productTypeID ("13") at the start of phase 1 before any order is placed — see + /// . + /// + private const string InboxProbeProductCode = "842"; + + public EmailNotificationsV2ProbeTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + + var env = V2EnvHelper.LoadAndPromote(); + + _v2ApiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL"); + _v2ClientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID"); + _v2ClientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET"); + _v2OvProductCode = V2EnvHelper.GetEnv(env, "CERTINEXT_OV_PRODUCT_CODE", "846"); + _dcvDomainBase = V2EnvHelper.GetEnv(env, "CERTINEXT_DCV_DOMAIN", "example.com"); + _inboxTestEmail = Environment.GetEnvironmentVariable("CERTINEXT_INBOX_TEST_EMAIL")?.Trim(); + + _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API")) + && !string.IsNullOrWhiteSpace(_v2ApiUrl) + && !string.IsNullOrWhiteSpace(_v2ClientId) + && !string.IsNullOrWhiteSpace(_v2ClientSecret); + } + + /// + /// Builds a typed against the V2 API — needed only for + /// 's catalog lookup (phase 1's + /// productTypeID guard), which has no raw-HTTP equivalent already in this file. Mirrors + /// OrganizationBlockV2ProbeTests.BuildV2Client exactly. + /// + private CERTInextClient BuildV2Client() + { + return new CERTInextClient(new CERTInextConfig + { + ApiUrl = _v2ApiUrl, + UseV2Api = true, + OAuthClientId = _v2ClientId, + OAuthClientSecret = _v2ClientSecret, + RequestorName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Test", + RequestorEmail = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com", + SignerIp = "127.0.0.1", + SignerPlace = "Gateway Lab", + PageSize = 100 + }); + } + + /// + /// Places ONE real V2 OV order with emailNotifications: "0" (not the spec's + /// only-documented value, "all") and reports the raw create-order HTTP + /// status/body, whether the field is echoed back anywhere (create response or Track + /// Order), and whether the order was accepted, rejected, or something in between. + /// No CSR is submitted and the order is never pushed toward issuance. Best-effort + /// cancels the order afterward. + /// + /// Opt-in: requires CERTINEXT_PROBE_EMAIL_NOTIFICATIONS=1, plus a configured + /// CERTINEXT_ORG_NUMBER for the OV order's required organization block. Neither is + /// set by default. + /// + [SkippableFact] + public async Task EmailNotifications_V2_NonAllValue_ObservesCaResponse() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + string probeFlag = Environment.GetEnvironmentVariable("CERTINEXT_PROBE_EMAIL_NOTIFICATIONS"); + Skip.If(string.IsNullOrWhiteSpace(probeFlag) || probeFlag != "1", + "CERTINEXT_PROBE_EMAIL_NOTIFICATIONS=1 not set — this probe places a real, " + + "potentially cost-bearing OV order and is opt-in only. Skipping."); + + string organizationNumber = _fixture.IsConfigured ? _fixture.OrgNumber : null; + Skip.If(string.IsNullOrWhiteSpace(organizationNumber), + "CERTINEXT_ORG_NUMBER not set in ~/.env_certinext — no pre-vetted organization " + + "number is available to populate the OV order's required organization block. Skipping."); + + string domain = $"probe-0027-emailnotif-{DateTime.UtcNow:yyyyMMddHHmmss}.example.com"; + var orderReq = new V2CreateSslOrderRequest + { + ProductVariant = "ov", + EmailNotifications = "0", // <-- the value under test; spec only documents "all" + Requestor = new V2Requestor + { + Name = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test", + Email = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com", + Phone = "0000000000", + Designation = "IT Administrator" + }, + Organization = new V2OrganizationParams + { + OrganizationNumber = organizationNumber, + PreVetted = true + }, + Certificate = new V2CertificateParams + { + Domain = domain, + AutoSecureWww = false + }, + Subscription = new V2SubscriptionParams + { + ValidityYears = 1, + AutoRenew = false, + RenewBeforeDays = 30 + }, + Agreement = new V2AgreementParams + { + SignerName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test", + SignerIp = "127.0.0.1", + SignerPlace = "Gateway Lab", + Accepted = true + }, + Remarks = "Issue 0027 item 1b live probe — emailNotifications=\"0\" instead of \"all\". " + + "No CSR submitted; not pushed to issuance." + }; + + string requestJson = JsonSerializer.Serialize(orderReq, GetJsonOptions()); + + _output.WriteLine("=== Issue 0027 item 1b live probe: V2 emailNotifications=\"0\" on an OV order ==="); + _output.WriteLine($"Domain={domain} ProductCode={_v2OvProductCode} OrganizationNumber={organizationNumber}"); + _output.WriteLine($"Request body: {requestJson}"); + _output.WriteLine(""); + + var createResp = await PlaceOrderRawAsync(_v2OvProductCode, requestJson); + _output.WriteLine($"--- Create-order response ---"); + _output.WriteLine($"HTTP {createResp.StatusCode}"); + _output.WriteLine($"Body: {createResp.Body}"); + + bool echoedInCreate = createResp.Body?.IndexOf("emailNotifications", StringComparison.OrdinalIgnoreCase) >= 0; + _output.WriteLine($"emailNotifications present in create response body: {echoedInCreate}"); + + if (!createResp.IsSuccessful) + { + _output.WriteLine(""); + _output.WriteLine("=== VERDICT: REJECTED — CERTInext returned a non-success status for " + + $"emailNotifications=\"0\". HTTP {createResp.StatusCode}: {createResp.Body} ==="); + return; + } + + string orderId = TryExtractOrderId(createResp.Body); + _output.WriteLine($"OrderId={orderId ?? ""}"); + + string trackBody = null; + bool echoedInTrack = false; + if (!string.IsNullOrWhiteSpace(orderId)) + { + try + { + var trackResp = await TrackOrderRawAsync(orderId); + trackBody = trackResp.Body; + _output.WriteLine(""); + _output.WriteLine("--- Track Order response ---"); + _output.WriteLine($"HTTP {trackResp.StatusCode}"); + _output.WriteLine($"Body: {trackResp.Body}"); + echoedInTrack = trackBody?.IndexOf("emailNotifications", StringComparison.OrdinalIgnoreCase) >= 0; + _output.WriteLine($"emailNotifications present in Track Order response body: {echoedInTrack}"); + } + catch (Exception trackEx) + { + _output.WriteLine($"Track Order call failed (non-fatal to this probe): {trackEx.Message}"); + } + } + + _output.WriteLine(""); + if (echoedInCreate || echoedInTrack) + { + _output.WriteLine("=== VERDICT: ACCEPTED, AND emailNotifications IS ECHOED BACK — inspect the " + + "captured body above to see the echoed value (accepted-as-sent vs. coerced). ==="); + } + else + { + _output.WriteLine("=== VERDICT: ACCEPTED (HTTP 2xx, no rejection) but emailNotifications is NOT " + + "echoed back anywhere in the create or Track Order response — matches the spec, " + + "which never surfaces this field in any response schema. Whether the CA silently " + + "coerces \"0\" back to \"all\" server-side cannot be confirmed or ruled out via " + + "the API alone from this probe; only that a non-\"all\" value does not cause a " + + "hard rejection at create time. ==="); + } + + if (!string.IsNullOrWhiteSpace(orderId)) + { + _output.WriteLine(""); + _output.WriteLine($"Attempting best-effort cleanup: cancelling order {orderId}..."); + try + { + var cancelResp = await CancelOrderRawAsync(orderId, + "Issue 0027 item 1b live probe — cleaning up after observing CA response."); + _output.WriteLine(cancelResp.IsSuccessful + ? $"Cleanup: order {orderId} cancel request returned HTTP {cancelResp.StatusCode} (success)." + : $"Cleanup FAILED for order {orderId}: HTTP {cancelResp.StatusCode}: {cancelResp.Body}. " + + "Cancel it by hand in the CERTInext portal if it should not remain pending."); + } + catch (Exception cleanupEx) + { + _output.WriteLine( + $"Cleanup FAILED for order {orderId}: {cleanupEx.Message}. " + + "Cancel it by hand in the CERTInext portal if it should not remain pending."); + } + } + } + + // --------------------------------------------------------------------------- + // Phase 1/2 real-inbox probe (issue 0027 item 1b) — see this file's header comment + // for the full design. Both phases share InboxProbeProductCode/_dcvDomainBase and the + // CERTINEXT_PROBE_EMAIL_NOTIFICATIONS_INBOX gate. + // --------------------------------------------------------------------------- + + /// + /// Phase 1 of the EmailNotifications real-inbox probe (issue 0027 item 1b). Verifies + /// the catalog resolves to productTypeID "13" (DV + /// SSL, non-UCC) — matched on productTypeID only, never productName — and aborts + /// before placing any order if it does not. Places the baseline + /// (emailNotifications="all") order, waits 2 minutes, then places the test + /// (emailNotifications="0") order. Exactly one create call per run; no retry + /// path of any kind. A timeout or any non-2xx response on either call stops the run + /// (and, for the baseline call, skips the test order entirely) — see + /// . Neither order is cancelled by this phase; run + /// after the human + /// inbox-check window has elapsed. + /// + /// Opt-in: requires CERTINEXT_PROBE_EMAIL_NOTIFICATIONS_INBOX=1. Not armed by default + /// in ~/.env_certinext or ~/.env_certinext_v2 — this places two real, potentially + /// cost-bearing V2 DV SSL orders and requires a human to manually check a real inbox + /// afterward, which cannot be automated or concluded by an agent. + /// + [SkippableFact] + public async Task EmailNotifications_V2_RealInboxTest_PlaceOrders() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + string probeFlag = Environment.GetEnvironmentVariable("CERTINEXT_PROBE_EMAIL_NOTIFICATIONS_INBOX"); + Skip.If(string.IsNullOrWhiteSpace(probeFlag) || probeFlag != "1", + "CERTINEXT_PROBE_EMAIL_NOTIFICATIONS_INBOX=1 not set — this probe places TWO real, " + + "potentially cost-bearing V2 DV SSL orders and requires a human to manually check a " + + "real inbox afterward. Opt-in only. Skipping."); + + // The inbox must be one a human actually reads — the env files' CERTINEXT_REQUESTOR_EMAIL + // is a non-deliverable placeholder, so this probe never falls back to it. + Skip.If(string.IsNullOrWhiteSpace(_inboxTestEmail), + "CERTINEXT_INBOX_TEST_EMAIL not set — set it to the real mailbox that will be checked. Skipping."); + + _output.WriteLine("=== Issue 0027 item 1b real-inbox probe — phase 1: place baseline + test orders ==="); + _output.WriteLine($"Requestor email (inbox to check): {_inboxTestEmail}"); + + // Catalog guard — abort before placing anything if the pinned product code does + // not resolve to productTypeID "13" (DV SSL, non-UCC) on this account/catalog + // version. Matched on productTypeID only, never productName (catalog productName + // strings are unstable across account/catalog-version/spellings — see this + // repo's Gotchas in issues/V2_AUDIT_TRIAGE_HANDOFF.md). + using CERTInextClient catalogClient = BuildV2Client(); + List catalog = await catalogClient.GetProductDetailsV2Async(); + ProductDetail product = catalog.FirstOrDefault(p => p.ProductCode == InboxProbeProductCode); + + Skip.If(product == null, + $"Catalog product code {InboxProbeProductCode} was not found in the live V2 catalog " + + "for this account — aborting before placing any order."); + Skip.If(product.ProductTypeId != "13", + $"Catalog product code {InboxProbeProductCode} has productTypeID=\"{product.ProductTypeId}\" " + + "(expected \"13\" for DV SSL, non-UCC) — aborting before placing any order."); + + _output.WriteLine($"Catalog check OK: ProductCode={product.ProductCode} " + + $"ProductTypeId={product.ProductTypeId} ProductName={product.ProductName}"); + + DateTime baselineTs = DateTime.UtcNow; + string baselineDomain = $"emailnotif-baseline-{baselineTs:yyyyMMddHHmmss}.{_dcvDomainBase}"; + + InboxProbeRunResult baselineResult = + await RunInboxProbeOrderAsync("BASELINE", baselineDomain, "all"); + + if (!baselineResult.Success) + { + // RunInboxProbeOrderAsync already logged the domain/timestamp/error and the + // STOP message (and the designation-answer block, if applicable). Per the + // approved design, a failed baseline call means the test order must not be + // placed at all. + return; + } + + _output.WriteLine(""); + _output.WriteLine("Waiting 2 minutes before placing the test (\"0\") order..."); + await Task.Delay(TimeSpan.FromMinutes(2)); + + DateTime testTs = DateTime.UtcNow; + string testDomain = $"emailnotif-test0-{testTs:yyyyMMddHHmmss}.{_dcvDomainBase}"; + + InboxProbeRunResult testResult = await RunInboxProbeOrderAsync("TEST", testDomain, "0"); + + _output.WriteLine(""); + _output.WriteLine("=== ACTION REQUIRED ==="); + _output.WriteLine($"Baseline (\"all\") order: OrderId={baselineResult.OrderId ?? ""} " + + $"Domain={baselineDomain}"); + _output.WriteLine(testResult.Success + ? $"Test (\"0\") order: OrderId={testResult.OrderId ?? ""} Domain={testDomain}" + : $"Test (\"0\") order: FAILED — see STOP message above. Domain={testDomain}"); + _output.WriteLine(""); + _output.WriteLine( + "Check the requestor's real inbox (INCLUDING SPAM) at the 5-minute and 30-minute marks " + + "after each order above was placed. For EVERY email that arrives for either domain, " + + "record its subject, sender, and time received, and which run (baseline/test) it " + + "corresponds to. This step cannot be automated or concluded by an agent — a human must " + + "check the inbox and report back before issue 0027 item 1b can be closed."); + _output.WriteLine(""); + _output.WriteLine( + "When the inbox-check window is done, set CERTINEXT_INBOX_TEST_BASELINE_ORDER_ID / " + + "CERTINEXT_INBOX_TEST_ORDER_ID to the order ID(s) above (whichever were placed) and run " + + $"{nameof(EmailNotifications_V2_RealInboxTest_CancelOrders)} to clean up."); + } + + /// + /// Phase 2 of the EmailNotifications real-inbox probe (issue 0027 item 1b) — run only + /// after the human inbox-check window from phase 1 has elapsed. Cancels whichever + /// order ID(s) are supplied via CERTINEXT_INBOX_TEST_BASELINE_ORDER_ID / + /// CERTINEXT_INBOX_TEST_ORDER_ID, then confirms cancellation via a follow-up read-only + /// Track Order call (orderState == "Order Cancelled"). + /// + /// Opt-in: requires CERTINEXT_PROBE_EMAIL_NOTIFICATIONS_INBOX=1 (same flag as phase 1) + /// plus at least one of the two order-ID env vars. Skips entirely if neither is set. + /// If only one is set — e.g. phase 1 stopped early after the baseline order but before + /// the test order — this cancels only that one rather than requiring both, so a + /// partial phase 1 run is never stuck without a cleanup path. + /// + [SkippableFact] + public async Task EmailNotifications_V2_RealInboxTest_CancelOrders() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + string probeFlag = Environment.GetEnvironmentVariable("CERTINEXT_PROBE_EMAIL_NOTIFICATIONS_INBOX"); + Skip.If(string.IsNullOrWhiteSpace(probeFlag) || probeFlag != "1", + "CERTINEXT_PROBE_EMAIL_NOTIFICATIONS_INBOX=1 not set — skipping."); + + string baselineOrderId = Environment.GetEnvironmentVariable("CERTINEXT_INBOX_TEST_BASELINE_ORDER_ID"); + string testOrderId = Environment.GetEnvironmentVariable("CERTINEXT_INBOX_TEST_ORDER_ID"); + + Skip.If(string.IsNullOrWhiteSpace(baselineOrderId) && string.IsNullOrWhiteSpace(testOrderId), + "Neither CERTINEXT_INBOX_TEST_BASELINE_ORDER_ID nor CERTINEXT_INBOX_TEST_ORDER_ID is " + + "set — nothing to cancel. Skipping."); + + _output.WriteLine("=== Issue 0027 item 1b real-inbox probe — phase 2: cancel + confirm ==="); + + if (!string.IsNullOrWhiteSpace(baselineOrderId)) + { + await CancelAndConfirmInboxProbeOrderAsync(baselineOrderId, "baseline (\"all\")"); + } + else + { + _output.WriteLine( + "CERTINEXT_INBOX_TEST_BASELINE_ORDER_ID not set — skipping baseline-order cancel " + + "(phase 1 apparently never placed it, or it is being handled separately)."); + } + + if (!string.IsNullOrWhiteSpace(testOrderId)) + { + await CancelAndConfirmInboxProbeOrderAsync(testOrderId, "test (\"0\")"); + } + else + { + _output.WriteLine( + "CERTINEXT_INBOX_TEST_ORDER_ID not set — skipping test-order cancel " + + "(phase 1 apparently stopped before placing it, or it is being handled separately)."); + } + } + + // --------------------------------------------------------------------------- + // Private helpers — same raw-HTTP idiom as IdempotencyKeyV2ProbeTests / + // OrganizationBlockV2ProbeTests (see those files' header comments for rationale). + // --------------------------------------------------------------------------- + + private static JsonSerializerOptions GetJsonOptions() => new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private sealed class RawApiResponse + { + public int StatusCode { get; set; } + public string Body { get; set; } + public bool IsSuccessful { get; set; } + } + + /// + /// 120s timeout — matches CERTInextClient's own V1/V2 RestClientOptions + /// (CERTInextClient.cs:71/88). The framework's default HttpClient timeout (100s) is + /// too short for this sandbox's OV order-create latency and was observed to trip + /// during authoring (HTTP 0 / empty body after ~100s, i.e. a transport-level + /// timeout, not a real CA rejection). + /// + private static RestClient NewApiClient(string baseUrl) => + new RestClient(new RestClientOptions(baseUrl) { Timeout = TimeSpan.FromSeconds(120) }); + + private async Task GetV2AccessTokenAsync() + { + string tokenUrl = _v2ApiUrl.TrimEnd('/') + "/oauth/token"; + using var tokenClient = NewApiClient(tokenUrl); + var tokenReq = new RestRequest(string.Empty, Method.Post); + tokenReq.AddHeader("Content-Type", "application/x-www-form-urlencoded"); + tokenReq.AddParameter("grant_type", "client_credentials"); + tokenReq.AddParameter("client_id", _v2ClientId); + tokenReq.AddParameter("client_secret", _v2ClientSecret); + var tokenResp = await tokenClient.ExecuteAsync(tokenReq); + if (!tokenResp.IsSuccessful || string.IsNullOrWhiteSpace(tokenResp.Content)) + throw new Exception($"Token request failed: {(int)tokenResp.StatusCode}"); + + using var tokenDoc = JsonDocument.Parse(tokenResp.Content); + return tokenDoc.RootElement.GetProperty("access_token").GetString(); + } + + /// + /// Raw HTTP POST to /api/certinext/v2/ssl-certificates. Does not throw on non-2xx — + /// the caller needs the exact raw status/body either way, which is the entire point + /// of this probe. + /// + private async Task PlaceOrderRawAsync(string productCode, string requestJson) + { + string accessToken = await GetV2AccessTokenAsync(); + + using var apiClient = NewApiClient(_v2ApiUrl.TrimEnd('/')); + var req = new RestRequest(Constants.ApiV2.SslCertificatesPath, Method.Post); + req.AddHeader("Authorization", $"Bearer {accessToken}"); + req.AddHeader("Accept", "application/json"); + req.AddHeader("X-Product-Code", productCode ?? string.Empty); + req.AddHeader("Idempotency-Key", Guid.NewGuid().ToString()); + req.AddJsonBody(requestJson); + + var resp = await apiClient.ExecuteAsync(req); + return ToRawApiResponse(resp); + } + + /// + /// Raw HTTP GET to /api/certinext/v2/ssl-certificates/{orderId} (Track Order) — used + /// here purely to check whether emailNotifications is echoed back post-creation, not + /// to drive any lifecycle logic. + /// + private async Task TrackOrderRawAsync(string orderId) + { + string accessToken = await GetV2AccessTokenAsync(); + + using var apiClient = NewApiClient(_v2ApiUrl.TrimEnd('/')); + var req = new RestRequest($"{Constants.ApiV2.SslCertificatesPath}/{orderId}", Method.Get); + req.AddHeader("Authorization", $"Bearer {accessToken}"); + req.AddHeader("Accept", "application/json"); + + var resp = await apiClient.ExecuteAsync(req); + return ToRawApiResponse(resp); + } + + /// + /// Raw HTTP POST to /api/certinext/v2/ssl-certificates/{orderId}/cancel — same idiom + /// as OrganizationBlockV2ProbeTests.CancelSslOrderRawAsync, but returns the raw + /// status/body instead of throwing on non-2xx so cleanup failure can be reported + /// without losing the underlying detail. + /// + private async Task CancelOrderRawAsync(string orderId, string reason) + { + string accessToken = await GetV2AccessTokenAsync(); + + using var apiClient = NewApiClient(_v2ApiUrl.TrimEnd('/')); + var cancelReq = new RestRequest($"{Constants.ApiV2.SslCertificatesPath}/{orderId}/cancel", Method.Post); + cancelReq.AddHeader("Authorization", $"Bearer {accessToken}"); + cancelReq.AddJsonBody(new { reason }); + var cancelResp = await apiClient.ExecuteAsync(cancelReq); + return ToRawApiResponse(cancelResp); + } + + /// + /// Converts a RestSharp into the probe's raw + /// status/body/success tuple. On a transport-level failure (no HTTP status ever + /// received — e.g. a timeout), StatusCode/Content come back empty/zero; in that case + /// this falls back to RestSharp's own ErrorMessage/ErrorException so the failure + /// reason is still visible in the report rather than an unexplained "HTTP 0". + /// + private static RawApiResponse ToRawApiResponse(RestResponse resp) + { + string body = resp.Content; + if (string.IsNullOrEmpty(body) && !resp.IsSuccessful) + { + body = resp.ErrorException != null + ? $"" + : $""; + } + + return new RawApiResponse + { + StatusCode = (int)resp.StatusCode, + Body = body, + IsSuccessful = resp.IsSuccessful + }; + } + + /// + /// Best-effort orderId extraction from a raw JSON response body. Returns null rather + /// than throwing if the body is empty, malformed, or lacks an orderId field. + /// + private static string TryExtractOrderId(string body) + { + if (string.IsNullOrWhiteSpace(body)) + return null; + + try + { + using var doc = JsonDocument.Parse(body); + return doc.RootElement.TryGetProperty("orderId", out var el) ? el.GetString() : null; + } + catch (JsonException) + { + return null; + } + } + + /// + /// Best-effort extraction of a named top-level string field (e.g. orderState, + /// certificateState) from a raw Track Order JSON response body. Returns null + /// rather than throwing if the body is empty, malformed, or lacks the field. + /// + private static string TryExtractStringField(string body, string fieldName) + { + if (string.IsNullOrWhiteSpace(body)) + return null; + + try + { + using var doc = JsonDocument.Parse(body); + return doc.RootElement.TryGetProperty(fieldName, out var el) ? el.GetString() : null; + } + catch (JsonException) + { + return null; + } + } + + // --------------------------------------------------------------------------- + // Phase 1/2 real-inbox probe helpers (issue 0027 item 1b). + // --------------------------------------------------------------------------- + + /// Outcome of a single create-order run inside . + private sealed class InboxProbeRunResult + { + public bool Success { get; set; } + public string OrderId { get; set; } + public bool MentionsDesignation { get; set; } + } + + /// + /// Builds the request body for one baseline/test run — DV SSL, non-UCC, no CSR, no + /// DCV. Only requestor.email/requestor.name are populated (from the + /// fixture's requestor config); requestor.phone and, deliberately, + /// requestor.designation are left unset so the global + /// DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull serializer + /// option () omits the JSON key entirely rather than + /// sending null or an empty string — issue 0027 item 5e's open design question. + /// technicalPointOfContact is left unset for the same reason, matching this + /// file's existing + /// probe (which also never sets it). No organization block (DV never requires + /// one) and no delegation/recipientEmails field (the DTO has none). + /// + private V2CreateSslOrderRequest BuildInboxProbeOrderRequest( + string domain, string emailNotificationsValue, string runLabel) => + new V2CreateSslOrderRequest + { + ProductVariant = "dv", + EmailNotifications = emailNotificationsValue, + Requestor = new V2Requestor + { + Name = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test", + Email = _inboxTestEmail + // Phone and Designation deliberately left unset (null) — omitted from the + // wire body entirely, not sent as null/empty. + }, + Certificate = new V2CertificateParams + { + Domain = domain, + AutoSecureWww = false + }, + Subscription = new V2SubscriptionParams + { + ValidityYears = 1, + AutoRenew = false, + RenewBeforeDays = 30 + }, + Agreement = new V2AgreementParams + { + SignerName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test", + SignerIp = "127.0.0.1", + SignerPlace = "Gateway Lab", + Accepted = true + }, + // TechnicalPointOfContact deliberately left unset (null) — see doc comment above. + Remarks = $"Issue 0027 item 1b real-inbox probe — {runLabel} run, " + + $"emailNotifications=\"{emailNotificationsValue}\", requestor.designation " + + "omitted (issue 0027 item 5e). No CSR submitted, DCV never invoked." + }; + + /// + /// Runs one baseline/test create-order call for the phase 1 real-inbox probe: builds + /// and logs the request body (no token in it to redact), places EXACTLY one + /// create-order call (no retry of any kind), logs the HTTP status/full response + /// body/order ID, and — on success — a follow-up read-only Track Order status. On a + /// timeout or any non-2xx response, logs the domain/timestamp/error and prints "STOP — + /// check the orders report for this domain before re-running" (a client-side timeout + /// on this endpoint does not mean the CA never processed the request — see this file's + /// header comment). If the failing response body mentions "designation", that is + /// additionally logged verbatim as a live answer to issue 0027 item 5e. + /// + private async Task RunInboxProbeOrderAsync( + string runLabel, string domain, string emailNotificationsValue) + { + var orderReq = BuildInboxProbeOrderRequest(domain, emailNotificationsValue, runLabel); + string requestJson = JsonSerializer.Serialize(orderReq, GetJsonOptions()); + + _output.WriteLine(""); + _output.WriteLine($"--- {runLabel} run: emailNotifications=\"{emailNotificationsValue}\", domain={domain} ---"); + _output.WriteLine($"Request body: {requestJson}"); + + var createResp = await PlaceOrderRawAsync(InboxProbeProductCode, requestJson); + _output.WriteLine($"Create-order response ({runLabel}): HTTP {createResp.StatusCode}"); + _output.WriteLine($"Body: {createResp.Body}"); + + var result = new InboxProbeRunResult(); + + if (!createResp.IsSuccessful) + { + result.Success = false; + result.MentionsDesignation = + createResp.Body?.IndexOf("designation", StringComparison.OrdinalIgnoreCase) >= 0; + + _output.WriteLine(""); + _output.WriteLine($"=== {runLabel} run FAILED at {DateTime.UtcNow:O} — Domain={domain} " + + $"HTTP {createResp.StatusCode}: {createResp.Body} ==="); + _output.WriteLine("STOP — check the orders report for this domain before re-running."); + + if (result.MentionsDesignation) + { + _output.WriteLine(""); + _output.WriteLine( + "=== DESIGNATION ANSWER (issue 0027 item 5e) — the create call was rejected and " + + $"the error mentions \"designation\"; verbatim response: {createResp.Body} ==="); + } + + return result; + } + + result.Success = true; + result.OrderId = TryExtractOrderId(createResp.Body); + _output.WriteLine($"OrderId={result.OrderId ?? ""}"); + + if (!string.IsNullOrWhiteSpace(result.OrderId)) + { + try + { + var trackResp = await TrackOrderRawAsync(result.OrderId); + string orderState = TryExtractStringField(trackResp.Body, "orderState"); + string certState = TryExtractStringField(trackResp.Body, "certificateState"); + _output.WriteLine( + $"Track Order ({runLabel}): HTTP {trackResp.StatusCode}, " + + $"orderState={orderState ?? ""}, certificateState={certState ?? ""}"); + } + catch (Exception trackEx) + { + _output.WriteLine($"Track Order call failed for {runLabel} (non-fatal to this probe): {trackEx.Message}"); + } + } + + return result; + } + + /// + /// Cancels one order from the real-inbox probe (phase 2) via the existing + /// idiom, then confirms cancellation via a + /// follow-up read-only Track Order call, logging whether orderState came back + /// as "Order Cancelled". + /// + private async Task CancelAndConfirmInboxProbeOrderAsync(string orderId, string label) + { + _output.WriteLine(""); + _output.WriteLine($"--- Cancelling {label} order {orderId} ---"); + + var cancelResp = await CancelOrderRawAsync(orderId, + "Issue 0027 item 1b real-inbox probe — cleanup after the inbox-check window."); + _output.WriteLine($"Cancel response: HTTP {cancelResp.StatusCode}: {cancelResp.Body}"); + + var trackResp = await TrackOrderRawAsync(orderId); + _output.WriteLine($"Track Order (post-cancel) response: HTTP {trackResp.StatusCode}: {trackResp.Body}"); + + string orderState = TryExtractStringField(trackResp.Body, "orderState"); + bool confirmed = string.Equals(orderState, "Order Cancelled", StringComparison.OrdinalIgnoreCase); + + _output.WriteLine(confirmed + ? $"CONFIRMED: order {orderId} ({label}) orderState=\"Order Cancelled\"." + : $"NOT CONFIRMED: order {orderId} ({label}) orderState=\"{orderState ?? ""}\" " + + "(expected \"Order Cancelled\"). Check manually in the CERTInext portal."); + } + } +} diff --git a/CERTInext.IntegrationTests/INTEGRATION_TESTING.md b/CERTInext.IntegrationTests/INTEGRATION_TESTING.md index c57d0f5..5a982ef 100644 --- a/CERTInext.IntegrationTests/INTEGRATION_TESTING.md +++ b/CERTInext.IntegrationTests/INTEGRATION_TESTING.md @@ -8,7 +8,7 @@ so the project is safe to include in CI pipelines that do not have API access. ## Prerequisites -- .NET 8 SDK +- .NET 8 or .NET 10 SDK - Access to a CERTInext account (sandbox or production) - An API Access Key generated in the CERTInext portal under **Integrations → APIs** @@ -59,6 +59,10 @@ The file is parsed line by line: - Each line must be in `KEY=VALUE` format. - Values are not quoted — do not surround values with `"` or `'`. - Real environment variables override file values (useful for CI injection). +- Exception: the fixture fails fast if the resolved `CERTINEXT_API_URL` lacks `/emSignHub-API` + (a V2 base URL leaked in, issue 0017). Source only `~/.env_certinext` into the shell, never + `~/.env_certinext_v2`. The V2 test classes read that file from disk themselves and never write + V1-shared keys (`CERTINEXT_API_URL`, `CERTINEXT_ACCESS_KEY`, ...) into the process environment. --- @@ -119,7 +123,6 @@ pipeline failure. | Test | What it checks | |------|---------------| | `GetOrderReport_ReturnsOrders` | Fetches page 1; asserts at least one order is returned | -| `GetOrderReport_ContainsKnownDraftOrder` | Fetches all pages; asserts requestNumber `4572531551` (DV SSL 838 draft) is present | | `GetOrderReport_AllOrders_HaveRequiredFields` | For each order on page 1: `requestNumber`, `productCode`, and `orderDate` are non-empty | ### `PluginSmokeTests` @@ -162,4 +165,3 @@ never transmitted over the wire — only the derived `authKey` hash is sent. | `Ping` fails with 401 | Wrong `CERTINEXT_ACCESS_KEY` | Regenerate the key in the CERTInext portal | | `Ping` fails with timeout | Wrong `CERTINEXT_API_URL` | Verify the URL matches your account region | | `GetOrderReport` returns 0 orders | Account has no orders | Place a test order first (see `make generate-order` in the project Makefile) | -| `ContainsKnownDraftOrder` fails | Draft order `4572531551` not on this account | Update `KnownDraftRequestNumber` in `OrderReportTests.cs` to a request number from your account | diff --git a/CERTInext.IntegrationTests/IdempotencyKeyV2ProbeTests.cs b/CERTInext.IntegrationTests/IdempotencyKeyV2ProbeTests.cs new file mode 100644 index 0000000..930021e --- /dev/null +++ b/CERTInext.IntegrationTests/IdempotencyKeyV2ProbeTests.cs @@ -0,0 +1,327 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Live investigation probe for issue 0032 (V2 Idempotency-Key is minted fresh on every +// call, so it can never dedupe a retry). This is purely informational — no code fix is +// planned regardless of the outcome (see issues/0032-v2-idempotency-key-not-reused-on- +// retry.md's "Live investigation" section); the maintainer asked whether the live sandbox +// already enforces Idempotency-Key dedup on V2 order-create today, ahead of the spec's own +// "parsed today, enforced in a future release" wording. Places TWO real V2 DV SSL +// order-create calls with byte-identical request bodies and the SAME hardcoded +// Idempotency-Key header value, back-to-back, and reports which of three outcomes was +// observed: +// +// 1. Same orderId returned both times -> dedup IS enforced today. +// 2. Two distinct orderIds returned -> dedup is NOT enforced (matches the +// spec's "future release" wording). +// 3. Second call returns a non-2xx (e.g. 409) -> an explicit-rejection enforcement +// mode (neither of the above). +// +// Raw HTTP only (mirrors OrganizationBlockV2ProbeTests.CancelSslOrderRawAsync's idiom) — +// deliberately does NOT go through CERTInextClient.PlaceOrderV2Async, since that method +// mints a fresh GUID per call (the exact behavior under investigation) and has no +// parameter for a caller-supplied idempotency key. No production code is touched by this +// probe. +// +// Opt-in: requires CERTINEXT_PROBE_IDEMPOTENCY_KEY=1 (same convention as this file's +// sibling OrganizationBlockV2ProbeTests's CERTINEXT_PROBE_ORG_MISSING/CERTINEXT_PROBE_ORG_FIX +// flags). Not armed by default in ~/.env_certinext or ~/.env_certinext_v2 — an operator +// must deliberately opt in, since this places one or two real, potentially cost-bearing V2 +// DV SSL orders (product code from CERTINEXT_PRODUCT_CODE, default 842 — the standard +// cheap/throwaway DV SSL test product already reused across V2LifecycleTests.cs/ +// V2ApiTests.cs). Both orders (if dedup fails and two distinct orders are created) are +// best-effort cancelled in a finally block. Neither order ever reaches 'issued' state (no +// DCV/CSR step is involved), so plugin.Revoke is never called — matching this repo's +// convention for never-issued probe orders. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using RestSharp; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + public class IdempotencyKeyV2ProbeTests : IClassFixture + { + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + private readonly string _v2ApiUrl; + private readonly string _v2ClientId; + private readonly string _v2ClientSecret; + private readonly string _v2ProductCode; + private readonly string _v2Domain; + private readonly bool _v2Enabled; + + /// + /// Fixed, hardcoded Idempotency-Key value reused across BOTH order-create calls in + /// — the entire + /// point of the probe is that this value does NOT change between calls (unlike + /// CERTInextClient.PlaceOrderV2Async's own Guid.NewGuid()-per-call + /// behavior, issue 0032's core finding). + /// + private const string ProbeIdempotencyKey = "00320032-0032-0032-0032-003200320032"; + + public IdempotencyKeyV2ProbeTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + + var env = V2EnvHelper.LoadAndPromote(); + + _v2ApiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL"); + _v2ClientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID"); + _v2ClientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET"); + _v2ProductCode = V2EnvHelper.GetEnv(env, "CERTINEXT_PRODUCT_CODE", "842"); + _v2Domain = V2EnvHelper.GetEnv(env, "CERTINEXT_DCV_DOMAIN", "test.example.com"); + + _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API")) + && !string.IsNullOrWhiteSpace(_v2ApiUrl) + && !string.IsNullOrWhiteSpace(_v2ClientId) + && !string.IsNullOrWhiteSpace(_v2ClientSecret); + } + + /// + /// Places two real V2 DV SSL order-create calls, back-to-back, with byte-identical + /// request bodies and the same hardcoded header + /// value, and reports which of the three outcomes described in this file's header + /// comment was observed. Informational only — no assertion is made on WHICH outcome + /// occurs (that is the unknown this probe exists to answer); the only hard assertion + /// is that the first call itself succeeds, since a first-call failure would be an + /// unrelated test-environment/account problem, not a dedup-behavior finding. + /// + /// Opt-in: requires CERTINEXT_PROBE_IDEMPOTENCY_KEY=1. Not armed by default in + /// ~/.env_certinext or ~/.env_certinext_v2. + /// + [SkippableFact] + public async Task IdempotencyKey_V2_SameKeyTwice_ObservesDedupBehavior() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + string probeFlag = Environment.GetEnvironmentVariable("CERTINEXT_PROBE_IDEMPOTENCY_KEY"); + Skip.If(string.IsNullOrWhiteSpace(probeFlag) || probeFlag != "1", + "CERTINEXT_PROBE_IDEMPOTENCY_KEY=1 not set — this probe places one or two real, " + + "potentially cost-bearing V2 DV SSL orders and is opt-in only. Skipping."); + + var orderReq = BuildStandardOrderRequest(); + string requestJson = JsonSerializer.Serialize(orderReq, GetJsonOptions()); + + _output.WriteLine("=== Issue 0032 live probe: V2 Idempotency-Key dedup on order-create ==="); + _output.WriteLine($"ProductCode={_v2ProductCode} Domain={_v2Domain} IdempotencyKey={ProbeIdempotencyKey}"); + _output.WriteLine($"Request body (identical bytes sent on both calls): {requestJson}"); + + var orderIds = new List(); + try + { + _output.WriteLine(""); + _output.WriteLine("--- Call 1 ---"); + var firstResp = await PlaceOrderRawAsync(ProbeIdempotencyKey, requestJson); + _output.WriteLine($"HTTP {firstResp.StatusCode}: {firstResp.Body}"); + + firstResp.IsSuccessful.Should().BeTrue( + "the FIRST order-create call must succeed on its own merits (unrelated to " + + $"idempotency-key behavior) — got HTTP {firstResp.StatusCode}: {firstResp.Body}"); + + string firstOrderId = TryExtractOrderId(firstResp.Body); + firstOrderId.Should().NotBeNullOrWhiteSpace( + "a successful order-create response must carry a non-empty orderId"); + orderIds.Add(firstOrderId); + + _output.WriteLine(""); + _output.WriteLine("--- Call 2 (same Idempotency-Key, same request body) ---"); + var secondResp = await PlaceOrderRawAsync(ProbeIdempotencyKey, requestJson); + _output.WriteLine($"HTTP {secondResp.StatusCode}: {secondResp.Body}"); + + string verdict; + if (secondResp.IsSuccessful) + { + string secondOrderId = TryExtractOrderId(secondResp.Body); + if (!string.IsNullOrWhiteSpace(secondOrderId) && secondOrderId != firstOrderId) + orderIds.Add(secondOrderId); + + verdict = !string.IsNullOrWhiteSpace(secondOrderId) && secondOrderId == firstOrderId + ? $"OUTCOME 1 — DEDUP IS ENFORCED TODAY: both calls returned the same orderId '{firstOrderId}'." + : "OUTCOME 2 — DEDUP IS NOT ENFORCED: two distinct orderIds returned " + + $"('{firstOrderId}' and '{secondOrderId ?? ""}'). Matches the spec's " + + "\"parsed today, enforced in a future release\" wording."; + } + else + { + verdict = $"OUTCOME 3 — EXPLICIT REJECTION: first call succeeded (orderId='{firstOrderId}'), " + + "second call with the same Idempotency-Key was rejected: " + + $"HTTP {secondResp.StatusCode}: {secondResp.Body}"; + } + + _output.WriteLine(""); + _output.WriteLine($"=== VERDICT: {verdict} ==="); + } + finally + { + _output.WriteLine(""); + foreach (string orderId in orderIds) + { + _output.WriteLine($"Attempting best-effort cleanup: cancelling order {orderId}..."); + try + { + await CancelSslOrderRawAsync(orderId, + "Issue 0032 idempotency-key probe — cleaning up after observing CA response."); + _output.WriteLine($"Cleanup: order {orderId} cancel request returned success."); + } + catch (Exception cleanupEx) + { + _output.WriteLine( + $"Cleanup FAILED for order {orderId}: {cleanupEx.Message}. " + + "Cancel it by hand in the CERTInext portal if it should not remain pending."); + } + } + } + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /// + /// Mirrors V2ApiTests.BuildStandardOrderRequest's exact shape/fields — a + /// standard DV SSL order-create body with no CSR and no per-call-unique field, so + /// serializing it once and reusing the resulting JSON string for both calls + /// guarantees byte-identical request bodies. + /// + private V2CreateSslOrderRequest BuildStandardOrderRequest() => + new V2CreateSslOrderRequest + { + ProductVariant = "dv", + EmailNotifications = "all", + Requestor = new V2Requestor + { + Name = _fixture.Config?.RequestorName ?? "Keyfactor Test", + Email = _fixture.Config?.RequestorEmail ?? "test@example.com", + Phone = "0000000000", + Designation = "IT Administrator" + }, + Certificate = new V2CertificateParams + { + Domain = _v2Domain, + AutoSecureWww = false + }, + Subscription = new V2SubscriptionParams + { + ValidityYears = 1, + AutoRenew = false, + RenewBeforeDays = 30 + }, + Agreement = new V2AgreementParams + { + SignerName = _fixture.Config?.RequestorName ?? "Keyfactor Test", + SignerIp = "127.0.0.1", + SignerPlace = "Gateway Lab", + Accepted = true + }, + Remarks = "Issue 0032 idempotency-key live probe — safe to cancel immediately." + }; + + /// + /// Same serializer options as CERTInextClient.GetJsonOptions (private in that + /// class, so duplicated here) — needed so the JSON body this probe sends matches + /// what the production client would actually send byte-for-byte. + /// + private static JsonSerializerOptions GetJsonOptions() => new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private sealed class RawApiResponse + { + public int StatusCode { get; set; } + public string Body { get; set; } + public bool IsSuccessful { get; set; } + } + + /// + /// Raw HTTP POST to /api/certinext/v2/ssl-certificates with an explicit, + /// caller-supplied Idempotency-Key header — deliberately bypasses + /// CERTInextClient.PlaceOrderV2Async, which mints its own fresh GUID per call + /// and has no parameter for a caller-supplied key. Mirrors + /// OrganizationBlockV2ProbeTests.CancelSslOrderRawAsync's token-fetch idiom. + /// Does not throw on non-2xx — the caller needs the raw status/body either way. + /// + private async Task PlaceOrderRawAsync(string idempotencyKey, string requestJson) + { + string accessToken = await GetV2AccessTokenAsync(); + + using var apiClient = new RestClient(_v2ApiUrl.TrimEnd('/')); + var req = new RestRequest(Constants.ApiV2.SslCertificatesPath, Method.Post); + req.AddHeader("Authorization", $"Bearer {accessToken}"); + req.AddHeader("Accept", "application/json"); + req.AddHeader("X-Product-Code", _v2ProductCode); + req.AddHeader("Idempotency-Key", idempotencyKey); + req.AddJsonBody(requestJson); + + var resp = await apiClient.ExecuteAsync(req); + return new RawApiResponse + { + StatusCode = (int)resp.StatusCode, + Body = resp.Content, + IsSuccessful = resp.IsSuccessful + }; + } + + /// + /// Standalone OAuth2 client_credentials token fetch. Delegates to + /// (issue 0058) — this file used + /// to carry its own private copy, near-identical to + /// OrganizationBlockV2ProbeTests.CancelSslOrderRawAsync's inline token request; + /// extracted so this file, OrganizationBlockV2ProbeTests, and + /// V2GapProbeTests share one implementation. Behavior is unchanged. + /// + private Task GetV2AccessTokenAsync() => + V2RawProbeHelpers.GetV2AccessTokenAsync(_v2ApiUrl, _v2ClientId, _v2ClientSecret); + + /// + /// Standalone cancel call for triage cleanup only. Delegates to + /// (issue 0058) — same idiom as + /// above. Behavior is unchanged. + /// + private Task CancelSslOrderRawAsync(string orderId, string reason) => + V2RawProbeHelpers.CancelSslOrderRawAsync(_v2ApiUrl, _v2ClientId, _v2ClientSecret, orderId, reason); + + /// + /// Best-effort orderId extraction from a raw JSON response body. Returns null rather + /// than throwing if the body is empty, malformed, or lacks an orderId field — + /// callers treat a null/empty result as "could not confirm an orderId", which is + /// itself part of the outcome this probe reports. + /// + private static string TryExtractOrderId(string body) + { + if (string.IsNullOrWhiteSpace(body)) + return null; + + try + { + using var doc = JsonDocument.Parse(body); + return doc.RootElement.TryGetProperty("orderId", out var el) ? el.GetString() : null; + } + catch (JsonException) + { + return null; + } + } + } +} diff --git a/CERTInext.IntegrationTests/IntegrationTestFixture.cs b/CERTInext.IntegrationTests/IntegrationTestFixture.cs index 8147730..67f2888 100644 --- a/CERTInext.IntegrationTests/IntegrationTestFixture.cs +++ b/CERTInext.IntegrationTests/IntegrationTestFixture.cs @@ -20,6 +20,85 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests /// public sealed class IntegrationTestFixture : IDisposable { + // --------------------------------------------------------------------------- + // Opt-in guard + // --------------------------------------------------------------------------- + + /// + /// Env-var keys that must be set explicitly in the shell and must NOT be + /// auto-promoted from either env file. These gate destructive or mutating tests + /// so a developer cannot accidentally arm them by leaving flags in ~/.env_certinext + /// OR ~/.env_certinext_v2. Exposed internal (rather than private) so + /// can exclude the same names from its own + /// promotion of ~/.env_certinext_v2 — without that, a flag left in the V2 file would + /// be read as unset by the first test class constructed in a run, then promoted into + /// process env, silently arming every later test in the same run (issue 0058). + /// + internal static readonly System.Collections.Generic.HashSet _optInOnlyFlags = + new System.Collections.Generic.HashSet(StringComparer.OrdinalIgnoreCase) + { + "CERTINEXT_COMPLETE_PENDING", + "CERTINEXT_RUN_BULK_TEST", + "CERTINEXT_V2_RUN_BULK_TEST", + "CERTINEXT_PRIVATE_PKI_LIVE", + "CERTINEXT_V2_GAP_PROBES", + // V2 full-lifecycle readiness suite (DV UCC / OV / OV UCC / EV / wildcard DV / + // renew+reissue / fresh-domain DCV) — each places real sandbox orders and must + // be armed individually in the real shell, never left in either env file. + "CERTINEXT_V2_LIFECYCLE_DV_UCC", + "CERTINEXT_V2_LIFECYCLE_OV", + "CERTINEXT_V2_LIFECYCLE_OV_UCC", + "CERTINEXT_V2_LIFECYCLE_EV", + "CERTINEXT_V2_LIFECYCLE_WILDCARD_DV", + "CERTINEXT_V2_LIFECYCLE_RENEW_REISSUE", + "CERTINEXT_V2_LIFECYCLE_FRESH_DCV", + }; + + // --------------------------------------------------------------------------- + // V1 env keys + // --------------------------------------------------------------------------- + + internal const string ApiUrlKey = "CERTINEXT_API_URL"; + internal const string AccessKeyKey = "CERTINEXT_ACCESS_KEY"; + internal const string AccountNumberKey = "CERTINEXT_ACCOUNT_NUMBER"; + internal const string GroupNumberKey = "CERTINEXT_GROUP_NUMBER"; + internal const string OrgNumberKey = "CERTINEXT_ORG_NUMBER"; + internal const string ProductCodeKey = "CERTINEXT_PRODUCT_CODE"; + internal const string RequestorEmailKey = "CERTINEXT_REQUESTOR_EMAIL"; + internal const string RequestorNameKey = "CERTINEXT_REQUESTOR_NAME"; + internal const string CloudflareApiTokenKey = "CERTINEXT_CF_API_TOKEN"; + internal const string CloudflareZoneIdKey = "CERTINEXT_CF_ZONE_ID"; + + /// + /// Path segment every V1 (emSignHub-API) base URL carries. A resolved + /// without it is almost always the V2 base URL from + /// ~/.env_certinext_v2 (issue 0017). + /// + internal const string V1ApiPathSegment = "/emSignHub-API"; + + /// + /// Every env key the V1 side of the harness reads: the keys this fixture resolves, plus + /// CERTINEXT_DCV_DOMAIN, which V1 DcvLifecycleTests reads straight from + /// process env. must never write these into + /// process env, because real env vars take precedence over ~/.env_certinext here + /// and the V2 file defines the same names with V2 values (issue 0017). + /// + internal static readonly IReadOnlySet V1EnvKeys = + new HashSet(StringComparer.OrdinalIgnoreCase) + { + ApiUrlKey, + AccessKeyKey, + AccountNumberKey, + GroupNumberKey, + OrgNumberKey, + ProductCodeKey, + RequestorEmailKey, + RequestorNameKey, + CloudflareApiTokenKey, + CloudflareZoneIdKey, + "CERTINEXT_DCV_DOMAIN", + }; + // --------------------------------------------------------------------------- // Credential properties // --------------------------------------------------------------------------- @@ -33,6 +112,22 @@ public sealed class IntegrationTestFixture : IDisposable public string RequestorEmail { get; } public string RequestorName { get; } + // --------------------------------------------------------------------------- + // Cloudflare DCV credentials (optional) + // --------------------------------------------------------------------------- + + /// Cloudflare API token with DNS:Edit permission on . + public string CloudflareApiToken { get; } + + /// Cloudflare Zone ID for the domain used in DCV integration tests. + public string CloudflareZoneId { get; } + + /// + /// True when Cloudflare credentials are present, enabling real DNS DCV tests. + /// When false, DCV integration tests fall back to a . + /// + public bool IsCloudflareConfigured { get; } + /// /// True when at minimum ApiUrl and AccessKey are both non-empty, /// indicating that live credential configuration is present. @@ -67,18 +162,41 @@ public IntegrationTestFixture() var env = LoadEnvFile(envPath); - ApiUrl = GetEnvValue(env, "CERTINEXT_API_URL"); - AccessKey = GetEnvValue(env, "CERTINEXT_ACCESS_KEY"); - AccountNumber = GetEnvValue(env, "CERTINEXT_ACCOUNT_NUMBER"); - GroupNumber = GetEnvValue(env, "CERTINEXT_GROUP_NUMBER"); - OrgNumber = GetEnvValue(env, "CERTINEXT_ORG_NUMBER"); - ProductCode = GetEnvValue(env, "CERTINEXT_PRODUCT_CODE"); - RequestorEmail = GetEnvValue(env, "CERTINEXT_REQUESTOR_EMAIL"); - RequestorName = GetEnvValue(env, "CERTINEXT_REQUESTOR_NAME"); + ApiUrl = GetEnvValue(env, ApiUrlKey); + AccessKey = GetEnvValue(env, AccessKeyKey); + AccountNumber = GetEnvValue(env, AccountNumberKey); + GroupNumber = GetEnvValue(env, GroupNumberKey); + OrgNumber = GetEnvValue(env, OrgNumberKey); + ProductCode = GetEnvValue(env, ProductCodeKey); + RequestorEmail = GetEnvValue(env, RequestorEmailKey); + RequestorName = GetEnvValue(env, RequestorNameKey); + + CloudflareApiToken = GetEnvValue(env, CloudflareApiTokenKey); + CloudflareZoneId = GetEnvValue(env, CloudflareZoneIdKey); + IsCloudflareConfigured = !string.IsNullOrWhiteSpace(CloudflareApiToken) && + !string.IsNullOrWhiteSpace(CloudflareZoneId); IsConfigured = !string.IsNullOrWhiteSpace(ApiUrl) && !string.IsNullOrWhiteSpace(AccessKey); + // Issue 0017: fail fast (before promoting anything into process env and before any + // client/network call) when a V2 base URL has leaked into the V1 fixture. Only + // checked when the fixture would otherwise be configured, so an unconfigured run + // still skips cleanly. + if (IsConfigured) + EnsureV1ApiUrl(ApiUrl, + fromProcessEnvironment: System.Environment.GetEnvironmentVariable(ApiUrlKey) != null); + + // Promote env-file values into the process environment so that any code + // calling System.Environment.GetEnvironmentVariable() picks them up. + // Opt-in destructive-test flags are deliberately excluded: they must be + // set explicitly in the shell so a developer who leaves them in the file + // does not accidentally arm bulk/mutating tests on every bare `dotnet test`. + foreach (var kv in env) + if (System.Environment.GetEnvironmentVariable(kv.Key) == null + && !_optInOnlyFlags.Contains(kv.Key)) + System.Environment.SetEnvironmentVariable(kv.Key, kv.Value); + if (IsConfigured) { Config = new CERTInextConfig @@ -88,6 +206,7 @@ public IntegrationTestFixture() ApiKey = AccessKey, AccountNumber = AccountNumber, GroupNumber = GroupNumber, + OrganizationNumber = OrgNumber, RequestorName = string.IsNullOrWhiteSpace(RequestorName) ? "Keyfactor Integration Test" : RequestorName, @@ -113,8 +232,11 @@ public void Dispose() { } /// /// Reads a KEY=VALUE file, stripping blank lines and lines starting with '#'. /// Real environment variables overlay the file so CI overrides always win. + /// defaults to the real process environment; unit + /// tests pass their own so they never have to mutate shared process state. /// - private static Dictionary LoadEnvFile(string path) + internal static Dictionary LoadEnvFile( + string path, System.Collections.IDictionary processEnvironment = null) { var result = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -131,13 +253,14 @@ private static Dictionary LoadEnvFile(string path) continue; string key = line.Substring(0, idx).Trim(); - string val = line.Substring(idx + 1).Trim(); + string val = ParseEnvValue(line.Substring(idx + 1)); result[key] = val; } } // Real environment variables take precedence over the file - foreach (System.Collections.DictionaryEntry de in System.Environment.GetEnvironmentVariables()) + foreach (System.Collections.DictionaryEntry de in + processEnvironment ?? System.Environment.GetEnvironmentVariables()) { string k = de.Key?.ToString(); string v = de.Value?.ToString(); @@ -148,6 +271,58 @@ private static Dictionary LoadEnvFile(string path) return result; } + /// + /// Parses a raw value from a KEY=VALUE env-file line: trims surrounding + /// whitespace, then strips a single pair of matching surrounding double or single + /// quotes if present. Without quote stripping a line like + /// CERTINEXT_REQUESTOR_NAME="Keyfactor Plugin Test" would parse as the 24-char + /// literal "Keyfactor Plugin Test" (quotes included), diverging from any + /// other shell-style env consumer reading the same file. See GitHub issue #8. + /// Exposed internal for direct unit-testing. + /// + internal static string ParseEnvValue(string rawValue) + { + if (rawValue is null) return string.Empty; + string val = rawValue.Trim(); + if (val.Length >= 2 && + ((val[0] == '"' && val[val.Length - 1] == '"') || + (val[0] == '\'' && val[val.Length - 1] == '\''))) + { + val = val.Substring(1, val.Length - 2); + } + return val; + } + + /// + /// Throws when lacks + /// the V1 , i.e. a V2 base URL has leaked into the V1 + /// fixture (issue 0017). Left unchecked, every V1 call 404s and surfaces as the + /// misleading "unrecognised error body" (issue 0044). The message names the key and + /// where it came from, and shows only scheme/host/path — never credentials, userinfo, + /// or query strings. Exposed internal for direct unit-testing. + /// + internal static void EnsureV1ApiUrl(string apiUrl, bool fromProcessEnvironment) + { + if (string.IsNullOrWhiteSpace(apiUrl) || + apiUrl.IndexOf(V1ApiPathSegment, StringComparison.OrdinalIgnoreCase) >= 0) + return; + + string shown = Uri.TryCreate(apiUrl.Trim(), UriKind.Absolute, out Uri uri) + ? $"{uri.Scheme}://{uri.Authority}{uri.AbsolutePath}" + : "(not an absolute URL)"; + string source = fromProcessEnvironment + ? "the process environment (real env vars override ~/.env_certinext)" + : "~/.env_certinext"; + + throw new InvalidOperationException( + $"IntegrationTestFixture: {ApiUrlKey} resolved to '{shown}' (from {source}), which lacks " + + $"the V1 path segment '{V1ApiPathSegment}'. This looks like a CERTInext V2 base URL leaking " + + "into the V1 fixture (issue 0017); V1 calls against it fail with 'unrecognised error body'. " + + "Source only ~/.env_certinext into the shell (set -a; . ~/.env_certinext; set +a), never " + + "~/.env_certinext_v2 — the V2 tests read that file from disk themselves. In an already-" + + $"polluted shell, run 'unset {ApiUrlKey}' or open a fresh shell."); + } + private static string GetEnvValue(Dictionary env, string key) { return env.TryGetValue(key, out string val) ? val : string.Empty; diff --git a/CERTInext.IntegrationTests/IntegrationTestFixtureTests.cs b/CERTInext.IntegrationTests/IntegrationTestFixtureTests.cs new file mode 100644 index 0000000..1db8470 --- /dev/null +++ b/CERTInext.IntegrationTests/IntegrationTestFixtureTests.cs @@ -0,0 +1,53 @@ +// Copyright 2024 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using FluentAssertions; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// Pure unit tests (no live-API dependency) for the env-file parser used by + /// . See GitHub issue #8 — without quote + /// stripping, a shell-style quoted line was being parsed with the quote characters + /// included in the value. + /// + public class IntegrationTestFixtureTests + { + [Theory] + [InlineData("plain", "plain")] + [InlineData(" plain ", "plain")] + [InlineData("\"Keyfactor Plugin Test\"", "Keyfactor Plugin Test")] + [InlineData(" \"Keyfactor Plugin Test\" ", "Keyfactor Plugin Test")] + [InlineData("'single quoted'", "single quoted")] + [InlineData("\"\"", "")] // empty quoted string + [InlineData("''", "")] // empty single-quoted + [InlineData("\"un-paired'", "\"un-paired'")] // mismatched quotes — leave alone + [InlineData("\"", "\"")] // single naked quote, length<2 after trim — leave alone + [InlineData("", "")] + [InlineData(" ", "")] + public void ParseEnvValue_HandlesQuotingAndWhitespace(string input, string expected) + { + IntegrationTestFixture.ParseEnvValue(input).Should().Be(expected); + } + + [Fact] + public void ParseEnvValue_NullInput_ReturnsEmptyString() + { + IntegrationTestFixture.ParseEnvValue(null).Should().Be(string.Empty); + } + + [Fact] + public void ParseEnvValue_DoesNotStripEmbeddedQuotes() + { + // Quotes in the middle of the value must NOT be stripped; only matching + // outer wrappers count. + IntegrationTestFixture.ParseEnvValue("foo\"bar\"baz") + .Should().Be("foo\"bar\"baz"); + } + } +} diff --git a/CERTInext.IntegrationTests/KeyAlgorithms.cs b/CERTInext.IntegrationTests/KeyAlgorithms.cs new file mode 100644 index 0000000..6f2489b --- /dev/null +++ b/CERTInext.IntegrationTests/KeyAlgorithms.cs @@ -0,0 +1,137 @@ +// Copyright 2024 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// At http://www.apache.org/licenses/LICENSE-2.0 + +using System; +using System.Collections.Generic; +using System.Linq; +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.Asn1.Sec; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + internal enum KeyKind { Rsa, Ecdsa, Ed25519, Ed448 } + + /// One row of the key-algorithm coverage matrix. + internal sealed class KeyAlgorithmSpec + { + public string Tag; // stable, human-readable id ("RSA-2048", "ECDSA-P256", ...) + public KeyKind Kind; + public int Strength; // RSA modulus bits, or EC field size in bits (informational for Ed) + public string SignatureAlgorithm; // BouncyCastle signature-algorithm name used to sign the CSR + public DerObjectIdentifier CurveOid; // EC named-curve OID (null for non-EC) + } + + /// + /// Shared key-algorithm matrix + BouncyCastle CSR generation, used by both the offline + /// submission/round-trip tests (AlgorithmMatrixTests) and the live DCV-issuance + /// theory (DcvLifecycleTests). BouncyCastle only — never BCL crypto. + /// + /// Hash pairing follows the CA/Browser Forum Baseline Requirements: P-256→SHA256, + /// P-384→SHA384, P-521→SHA512. + /// + internal static class KeyAlgorithms + { + public static readonly KeyAlgorithmSpec[] All = + { + new() { Tag = "RSA-2048", Kind = KeyKind.Rsa, Strength = 2048, SignatureAlgorithm = "SHA256withRSA" }, + new() { Tag = "RSA-3072", Kind = KeyKind.Rsa, Strength = 3072, SignatureAlgorithm = "SHA256withRSA" }, + new() { Tag = "RSA-4096", Kind = KeyKind.Rsa, Strength = 4096, SignatureAlgorithm = "SHA256withRSA" }, + new() { Tag = "RSA-6144", Kind = KeyKind.Rsa, Strength = 6144, SignatureAlgorithm = "SHA256withRSA" }, + new() { Tag = "RSA-8192", Kind = KeyKind.Rsa, Strength = 8192, SignatureAlgorithm = "SHA256withRSA" }, + new() { Tag = "ECDSA-P256", Kind = KeyKind.Ecdsa, Strength = 256, SignatureAlgorithm = "SHA256withECDSA", CurveOid = SecObjectIdentifiers.SecP256r1 }, + new() { Tag = "ECDSA-P384", Kind = KeyKind.Ecdsa, Strength = 384, SignatureAlgorithm = "SHA384withECDSA", CurveOid = SecObjectIdentifiers.SecP384r1 }, + new() { Tag = "ECDSA-P521", Kind = KeyKind.Ecdsa, Strength = 521, SignatureAlgorithm = "SHA512withECDSA", CurveOid = SecObjectIdentifiers.SecP521r1 }, + new() { Tag = "Ed25519", Kind = KeyKind.Ed25519, Strength = 256, SignatureAlgorithm = "Ed25519" }, + new() { Tag = "Ed448", Kind = KeyKind.Ed448, Strength = 448, SignatureAlgorithm = "Ed448" }, + }; + + public static KeyAlgorithmSpec For(string tag) => All.Single(s => s.Tag == tag); + + /// xUnit member-data source — one row per key type, keyed by its stable tag. + public static IEnumerable AsMemberData => All.Select(s => new object[] { s.Tag }); + + public static AsymmetricCipherKeyPair GenerateKeyPair(KeyAlgorithmSpec spec) + { + switch (spec.Kind) + { + case KeyKind.Rsa: + { + var gen = new RsaKeyPairGenerator(); + gen.Init(new KeyGenerationParameters(new SecureRandom(), spec.Strength)); + return gen.GenerateKeyPair(); + } + case KeyKind.Ecdsa: + { + var gen = new ECKeyPairGenerator("ECDSA"); + gen.Init(new ECKeyGenerationParameters(spec.CurveOid, new SecureRandom())); + return gen.GenerateKeyPair(); + } + case KeyKind.Ed25519: + { + var gen = new Ed25519KeyPairGenerator(); + gen.Init(new Ed25519KeyGenerationParameters(new SecureRandom())); + return gen.GenerateKeyPair(); + } + case KeyKind.Ed448: + { + var gen = new Ed448KeyPairGenerator(); + gen.Init(new Ed448KeyGenerationParameters(new SecureRandom())); + return gen.GenerateKeyPair(); + } + default: + throw new ArgumentOutOfRangeException(nameof(spec), spec.Kind, "unhandled key kind"); + } + } + + public static string GenerateCsrPem(string commonName, KeyAlgorithmSpec spec) + { + var keyPair = GenerateKeyPair(spec); + var subject = new X509Name($"CN={commonName}"); + var csr = new Pkcs10CertificationRequest(spec.SignatureAlgorithm, subject, keyPair.Public, null, keyPair.Private); + + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + /// Strips PEM armor and returns the DER bytes of a CSR. + public static byte[] DerFromPem(string pem) + { + var b64 = pem + .Replace("-----BEGIN CERTIFICATE REQUEST-----", string.Empty) + .Replace("-----END CERTIFICATE REQUEST-----", string.Empty) + .Replace("\r", string.Empty) + .Replace("\n", string.Empty) + .Trim(); + return Convert.FromBase64String(b64); + } + + /// A filesystem/DNS-safe slug for a tag, e.g. "ECDSA-P256" → "ecdsap256". + public static string Slug(string tag) => tag.ToLowerInvariant().Replace("-", string.Empty); + + /// + /// Classifies a CERTInext order-rejection message so the algorithm matrix doesn't + /// conflate "this key algorithm is unsupported" with "the account can't place orders + /// right now". CERTInext's live envelope (observed): RSA 2048/3072/4096 + ECC P-256/P-384 + /// are accepted; larger RSA, P-521, and the Ed* curves return "Invalid key size" / + /// "Something went Wrong". A credit shortfall returns "Insufficient Credits" regardless + /// of algorithm. + /// + public static string ClassifyRejection(string caMessage) + { + caMessage ??= string.Empty; + if (caMessage.IndexOf("Invalid key size", StringComparison.OrdinalIgnoreCase) >= 0) + return "key algorithm/size not supported by CERTInext"; + if (caMessage.IndexOf("Insufficient Credits", StringComparison.OrdinalIgnoreCase) >= 0) + return "CERTInext account is out of credits — algorithm support was not exercised"; + return "rejected by CERTInext"; + } + } +} diff --git a/CERTInext.IntegrationTests/KfclabCsrEmitterTests.cs b/CERTInext.IntegrationTests/KfclabCsrEmitterTests.cs new file mode 100644 index 0000000..159cb8f --- /dev/null +++ b/CERTInext.IntegrationTests/KfclabCsrEmitterTests.cs @@ -0,0 +1,82 @@ +// Copyright 2026 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// At http://www.apache.org/licenses/LICENSE-2.0 +// +// Utility: emit BouncyCastle-generated PKCS#10 CSRs (CN + DNS SANs) to disk for manual +// Command-driven lab enrollments (e.g. Command Reissue via /Enrollment/CSR, UCC multi-SAN +// checks). Makes no CA calls. Opt-in: set CERTINEXT_EMIT_CSR_DIR (output directory) and +// CERTINEXT_EMIT_CSR_SPEC, a ';'-separated list of "=[,...]". +// The CN is always included as the first DNS SAN. +// +// Example: +// CERTINEXT_EMIT_CSR_DIR=/tmp/csrs \ +// CERTINEXT_EMIT_CSR_SPEC="reissue=a.example.com;ucc=b.example.com,c.example.com" \ +// dotnet test --filter FullyQualifiedName~KfclabCsrEmitterTests + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.Asn1.Pkcs; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + public class KfclabCsrEmitterTests + { + private readonly ITestOutputHelper _out; + + public KfclabCsrEmitterTests(ITestOutputHelper output) + { + _out = output; + } + + private static string GenerateCsrPem(string cn, IReadOnlyList dnsSans) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + var kp = keyGen.GenerateKeyPair(); + + var names = new GeneralNames(dnsSans.Select(s => new GeneralName(GeneralName.DnsName, s)).ToArray()); + var extGen = new X509ExtensionsGenerator(); + extGen.AddExtension(X509Extensions.SubjectAlternativeName, false, names); + var attrs = new DerSet(new AttributePkcs( + PkcsObjectIdentifiers.Pkcs9AtExtensionRequest, new DerSet(extGen.Generate()))); + + var csr = new Pkcs10CertificationRequest( + "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, attrs, kp.Private); + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----\n"; + } + + [SkippableFact] + public void EmitCsrs() + { + string dir = Environment.GetEnvironmentVariable("CERTINEXT_EMIT_CSR_DIR"); + string spec = Environment.GetEnvironmentVariable("CERTINEXT_EMIT_CSR_SPEC"); + Skip.If(string.IsNullOrWhiteSpace(dir) || string.IsNullOrWhiteSpace(spec), + "Set CERTINEXT_EMIT_CSR_DIR and CERTINEXT_EMIT_CSR_SPEC to emit CSRs."); + + Directory.CreateDirectory(dir); + foreach (string entry in spec.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + string[] kv = entry.Split('=', 2); + Assert.True(kv.Length == 2, $"Bad CSR spec entry '{entry}' (expected =[,...])."); + string[] hosts = kv[1].Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + Assert.NotEmpty(hosts); + + string path = Path.Combine(dir, kv[0] + ".csr"); + File.WriteAllText(path, GenerateCsrPem(hosts[0], hosts)); + _out.WriteLine($"{path}: CN={hosts[0]} SANs={string.Join(",", hosts)}"); + } + } + } +} diff --git a/CERTInext.IntegrationTests/KfclabFieldProbeTests.cs b/CERTInext.IntegrationTests/KfclabFieldProbeTests.cs new file mode 100644 index 0000000..b7b250c --- /dev/null +++ b/CERTInext.IntegrationTests/KfclabFieldProbeTests.cs @@ -0,0 +1,169 @@ +// Copyright 2026 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// At http://www.apache.org/licenses/LICENSE-2.0 +// +// Probe: reproduce the kfclab gateway's "Inactive Account User." failure on +// PlaceOrder by submitting the exact field shape kfclab sends. Then flip one +// field at a time back to the integration-test default to isolate which +// individual field trips the CERTInext error. +// +// Baseline integration test config (proven to work — created order 7518968666): +// SignerIp = "127.0.0.1" +// SignerPlace = "Gateway" +// RequestorMobileNumber = "0000000000" +// RequestorIsdCode = "1" +// +// kfclab gateway config: +// SignerIp = "0.0.0.0" +// SignerPlace = "Lab" +// RequestorMobileNumber = "" (unset → empty string on the wire) +// RequestorIsdCode = "" (unset → plugin defaults to "1") +// +// Run with: dotnet test --filter FullyQualifiedName~KfclabFieldProbeTests + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Keyfactor.Extensions.CAPlugin.CERTInext; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + public class KfclabFieldProbeTests : IClassFixture + { + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _out; + + public KfclabFieldProbeTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _out = output; + } + + private static string GenerateCsrPem(string cn) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + var kp = keyGen.GenerateKeyPair(); + var csr = new Pkcs10CertificationRequest( + "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, null, kp.Private); + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + private CERTInextConfig BuildConfig( + string signerIp, + string signerPlace, + string mobile, + string isdCode, + string requestorName) + { + return new CERTInextConfig + { + ApiUrl = _fixture.ApiUrl.TrimEnd('/') + "/", + AuthMode = "AccessKey", + ApiKey = _fixture.AccessKey, + AccountNumber = _fixture.AccountNumber, + GroupNumber = _fixture.GroupNumber, + OrganizationNumber = _fixture.OrgNumber, + RequestorName = requestorName, + RequestorEmail = _fixture.RequestorEmail, + RequestorIsdCode = isdCode, + RequestorMobileNumber = mobile, + SignerPlace = signerPlace, + SignerIp = signerIp, + DefaultProductCode = "842", + PageSize = 100 + }; + } + + private async Task<(bool ok, string detail)> TryEnrollAsync(CERTInextConfig cfg, string label) + { + var client = new CERTInextClient(cfg); + string cn = $"probe-{label}-{DateTime.UtcNow:yyyyMMddHHmmss}.lab.example.com"; + string csr = GenerateCsrPem(cn); + + var req = new EnrollCertificateRequest + { + Csr = csr, + Subject = $"CN={cn}", + Sans = new List { new SanEntry { Type = "DNS", Value = cn } }, + ProfileId = "842", + RequesterName = cfg.RequestorName, + RequesterEmail = cfg.RequestorEmail, + }; + + try + { + var resp = await client.EnrollCertificateAsync(req); + return (true, $"OrderNumber={resp?.Id} Status={resp?.Status}"); + } + catch (Exception ex) + { + return (false, ex.Message); + } + } + + // Run each variant in a single fact so we get one consolidated report. + [SkippableFact] + public async Task Probe_FieldByField() + { + IntegrationSkip.IfNotConfigured(_fixture); + + // 1. baseline: integration-test config (known good) + var baseline = BuildConfig( + signerIp: "127.0.0.1", + signerPlace: "Gateway", + mobile: "0000000000", + isdCode: "1", + requestorName: _fixture.RequestorName); + + // 2. kfclab exact + var kfclab = BuildConfig( + signerIp: "0.0.0.0", + signerPlace: "Lab", + mobile: "", + isdCode: "", + requestorName: "Keyfactor Plugin Test"); // no quotes + + // 3..n. baseline-but-one-field-set-to-kfclab-value + var bSignerIp = BuildConfig("0.0.0.0", "Gateway", "0000000000", "1", _fixture.RequestorName); + var bSignerPlc = BuildConfig("127.0.0.1", "Lab", "0000000000", "1", _fixture.RequestorName); + var bMobile = BuildConfig("127.0.0.1", "Gateway", "", "1", _fixture.RequestorName); + var bIsd = BuildConfig("127.0.0.1", "Gateway", "0000000000", "", _fixture.RequestorName); + var bReqName = BuildConfig("127.0.0.1", "Gateway", "0000000000", "1", "Keyfactor Plugin Test"); + + var probes = new (string Label, CERTInextConfig Cfg)[] + { + ("baseline (integration-test defaults)", baseline), + ("kfclab exact", kfclab), + ("baseline + SignerIp=0.0.0.0", bSignerIp), + ("baseline + SignerPlace=Lab", bSignerPlc), + ("baseline + RequestorMobile=empty", bMobile), + ("baseline + RequestorIsdCode=empty", bIsd), + ("baseline + RequestorName=unquoted", bReqName), + }; + + _out.WriteLine("=== Field-by-field PlaceOrder probe ==="); + _out.WriteLine($"fixture RequestorName (literal, may contain quotes): [{_fixture.RequestorName}]"); + _out.WriteLine(""); + + foreach (var (label, cfg) in probes) + { + var (ok, detail) = await TryEnrollAsync(cfg, label.Replace(" ", "-")); + _out.WriteLine($"[{(ok ? "PASS" : "FAIL")}] {label,-44} → {detail}"); + // throttle ~1s between probes — sandbox sometimes throttles bursts + await Task.Delay(1000); + } + } + } +} diff --git a/CERTInext.IntegrationTests/LifecycleTests.cs b/CERTInext.IntegrationTests/LifecycleTests.cs index d58bade..185ff64 100644 --- a/CERTInext.IntegrationTests/LifecycleTests.cs +++ b/CERTInext.IntegrationTests/LifecycleTests.cs @@ -6,14 +6,18 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; using System.Threading; using System.Threading.Tasks; using FluentAssertions; using Keyfactor.AnyGateway.Extensions; using Keyfactor.PKI.Enums.EJBCA; using Xunit; +using Xunit.Abstractions; namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests { @@ -34,10 +38,12 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests public class LifecycleTests : IClassFixture { private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; - public LifecycleTests(IntegrationTestFixture fixture) + public LifecycleTests(IntegrationTestFixture fixture, ITestOutputHelper output) { _fixture = fixture; + _output = output; } // --------------------------------------------------------------------------- @@ -60,22 +66,15 @@ private CERTInextCAPlugin BuildPlugin() /// private static string GenerateCsrPem(string commonName) { - using var rsa = RSA.Create(2048); + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + var keyPair = keyGen.GenerateKeyPair(); - var certReq = new CertificateRequest( - $"CN={commonName}", - rsa, - HashAlgorithmName.SHA256, - RSASignaturePadding.Pkcs1); - - var sanBuilder = new SubjectAlternativeNameBuilder(); - sanBuilder.AddDnsName(commonName); - certReq.CertificateExtensions.Add(sanBuilder.Build()); - - byte[] csrDer = certReq.CreateSigningRequest(); + var subject = new X509Name($"CN={commonName}"); + var csr = new Pkcs10CertificationRequest("SHA256withRSA", subject, keyPair.Public, null, keyPair.Private); return "-----BEGIN CERTIFICATE REQUEST-----\n" - + Convert.ToBase64String(csrDer, Base64FormattingOptions.InsertLineBreaks) + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + "\n-----END CERTIFICATE REQUEST-----"; } @@ -237,5 +236,6 @@ await revokeAct.Should().NotThrowAsync( (int)EndEntityStatus.REVOKED, "Revoke must return the REVOKED status code on success"); } + } } diff --git a/CERTInext.IntegrationTests/ListOrdersErrorBodyProbeTests.cs b/CERTInext.IntegrationTests/ListOrdersErrorBodyProbeTests.cs new file mode 100644 index 0000000..5409514 --- /dev/null +++ b/CERTInext.IntegrationTests/ListOrdersErrorBodyProbeTests.cs @@ -0,0 +1,239 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.Logging; +using Microsoft.Extensions.Logging; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// Issue 0044 triage probe: why did V2ReportProbeTests.Probe4 fail inside the V1 + /// with "unrecognised error body for operation + /// 'list orders page 1'"? + /// + /// Read-only. Makes exactly two V1 GetOrderReport page-1 list calls (pageSize 5, + /// enumeration stopped after page 1) with the same access-key config the + /// builds, differing only in ApiUrl: + /// + /// Control: the V1 URL read straight from ~/.env_certinext (immune to shell env). + /// Repro: the V2 base URL from ~/.env_certinext_v2, which is what the fixture's + /// ApiUrl becomes when that file is sourced into the shell (issue 0017). + /// + /// It also shows, offline, which ApiUrl the fixture resolves under each env overlay. + /// Since the 0017 fix the shell overlay makes the fixture fail fast with an actionable message + /// (printed as FAIL-FAST: ...), and an earlier + /// no longer changes the fixture's ApiUrl. The repro call still hits the V2 base URL + /// directly (bypassing the fixture) to capture the raw error body. + /// + /// The response body is captured from the client's own non-success log line (already + /// redacted via ApplyLoggingRedaction) by swapping + /// before the first is constructed. That only works when this + /// class runs alone in the test process, which is why it takes no class fixture and must be + /// run with its own filter. It also briefly mutates process env (restored afterwards), so it + /// sits in a non-parallel collection. Opt-in: CERTINEXT_0044_PROBE=1 must be set in + /// the shell. Both env files are read from disk; don't source either into the shell. + /// + /// CERTINEXT_0044_PROBE=1 dotnet test CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj -c Release \ + /// --filter "FullyQualifiedName~ListOrdersErrorBodyProbeTests" \ + /// --logger "console;verbosity=detailed" > /tmp/0044-probe.log 2>&1 + /// + /// + [Collection(ListOrdersErrorBodyProbeCollection.Name)] + public class ListOrdersErrorBodyProbeTests + { + private const string ProbeFlag = "CERTINEXT_0044_PROBE"; + private const string ApiUrlKey = "CERTINEXT_API_URL"; + + private readonly ITestOutputHelper _output; + + public ListOrdersErrorBodyProbeTests(ITestOutputHelper output) + { + _output = output; + } + + private sealed class CapturingLoggerFactory : ILoggerFactory + { + public ConcurrentQueue Messages { get; } = new(); + public ILogger CreateLogger(string categoryName) => new CapturingLogger(Messages); + public void AddProvider(ILoggerProvider provider) { } + public void Dispose() { } + + private sealed class CapturingLogger : ILogger + { + private readonly ConcurrentQueue _messages; + public CapturingLogger(ConcurrentQueue messages) => _messages = messages; + public IDisposable BeginScope(TState state) => null; + public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) + => _messages.Enqueue($"[{logLevel}] {formatter(state, exception)}"); + } + } + + [SkippableFact] + public async Task ListOrdersPage1_V1UrlVsV2BaseUrl_CapturesErrorBody() + { + Skip.If(string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(ProbeFlag)), + $"{ProbeFlag} not set — skipping issue 0044 ListOrders error-body probe."); + + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var (v1File, _) = V2EnvHelper.LoadEnvFile(Path.Combine(home, ".env_certinext")); + var (v2File, _) = V2EnvHelper.LoadEnvFile(Path.Combine(home, ".env_certinext_v2")); + string v1Url = V2EnvHelper.GetEnv(v1File, ApiUrlKey); + string v2Url = V2EnvHelper.GetEnv(v2File, ApiUrlKey); + Skip.If(string.IsNullOrWhiteSpace(v1Url) || string.IsNullOrWhiteSpace(v2Url), + "Need CERTINEXT_API_URL in both ~/.env_certinext and ~/.env_certinext_v2."); + + string shellApiUrl = Environment.GetEnvironmentVariable(ApiUrlKey); + _output.WriteLine("=== Issue 0044: V1 ListOrdersAsync page 1 ==="); + _output.WriteLine($"V1 file ApiUrl = {v1Url}"); + _output.WriteLine($"V2 file ApiUrl = {v2Url}"); + _output.WriteLine($"Shell {ApiUrlKey} = {shellApiUrl ?? "(unset)"}"); + + var capture = new CapturingLoggerFactory(); + string pollutedResolved; + string promotedResolved; + try + { + // Must happen before any CERTInextClient exists: its Logger is static readonly. + LogHandler.Factory = capture; + + // Offline: which ApiUrl the fixture resolves under the current shell env. + var fixture = new IntegrationTestFixture(); + Skip.IfNot(fixture.IsConfigured, "V1 fixture not configured (~/.env_certinext)."); + _output.WriteLine($"Fixture ApiUrl (current shell env) = {fixture.Config.ApiUrl}"); + + // Live call 1 (control): V1 URL from the file. + await RunListOrdersPage1Async("control: V1 file URL", fixture.Config, v1Url, capture); + + // Live call 2 (repro): V2 base URL, as the fixture sees it under the 0017 overlay. + await RunListOrdersPage1Async("repro: V2 base URL", fixture.Config, v2Url, capture); + + // Offline: fixture ApiUrl when the shell has sourced ~/.env_certinext_v2 (0017). + pollutedResolved = ResolveFixtureApiUrlWith(() => Environment.SetEnvironmentVariable(ApiUrlKey, v2Url)); + + // Offline: fixture ApiUrl when another V2 test class's constructor already ran + // V2EnvHelper.LoadAndPromote() earlier in the same test process. + promotedResolved = ResolveFixtureApiUrlWith(() => V2EnvHelper.LoadAndPromote()); + } + finally + { + // Factory is write-only; reset to the unconfigured default (same as the unit tests). + LogHandler.Factory = Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance; + } + + _output.WriteLine($"Fixture ApiUrl after shell sources ~/.env_certinext_v2 = {pollutedResolved}"); + _output.WriteLine($"Fixture ApiUrl after an earlier V2EnvHelper.LoadAndPromote() = {promotedResolved}"); + } + + private async Task RunListOrdersPage1Async( + string label, CERTInextConfig template, string apiUrl, CapturingLoggerFactory capture) + { + _output.WriteLine($"--- {label}: POST {apiUrl.TrimEnd('/')}/{Constants.Api.GetOrderReportPath} (page 1, pageSize 5) ---"); + while (capture.Messages.TryDequeue(out _)) { } + + using var client = new CERTInextClient(WithApiUrl(template, apiUrl)); + int count = 0; + try + { + await foreach (var entry in client.ListOrdersAsync(pageSize: 5)) + { + count++; + if (count >= 5) break; // page 1 only + } + _output.WriteLine($"RESULT: success, {count} order(s) on page 1."); + } + catch (Exception ex) + { + _output.WriteLine($"RESULT: {ex.GetType().Name}: {ex.Message}"); + } + + var lines = capture.Messages.Where(m => + m.Contains(Constants.Api.GetOrderReportPath, StringComparison.Ordinal) || + m.Contains("list orders", StringComparison.Ordinal)) + .ToList(); + foreach (string line in lines) + _output.WriteLine($"LOG {line}"); + if (lines.Count == 0) + _output.WriteLine("NOTE: no client log lines captured. CERTInextClient's static logger was " + + "bound before this probe ran; run this class alone."); + } + + /// + /// Builds a fresh fixture after and reports its ApiUrl. Since + /// the 0017 fix, the shell-overlay case makes the fixture fail fast instead of resolving the + /// V2 URL (reported as FAIL-FAST: ...), and + /// no longer promotes CERTINEXT_API_URL, so the in-process case resolves the V1 URL. + /// + private static string ResolveFixtureApiUrlWith(Action mutateEnv) + { + var snapshot = new System.Collections.Generic.Dictionary(StringComparer.Ordinal); + foreach (System.Collections.DictionaryEntry de in Environment.GetEnvironmentVariables()) + snapshot[(string)de.Key] = (string)de.Value; + try + { + mutateEnv(); + return new IntegrationTestFixture().Config?.ApiUrl ?? "(not configured)"; + } + catch (InvalidOperationException ex) + { + return $"FAIL-FAST: {ex.Message}"; + } + finally + { + var current = Environment.GetEnvironmentVariables().Keys.Cast().ToList(); + foreach (string key in current.Where(k => !snapshot.ContainsKey(k))) + Environment.SetEnvironmentVariable(key, null); + foreach (var kv in snapshot) + Environment.SetEnvironmentVariable(kv.Key, kv.Value); + } + } + + private static CERTInextConfig WithApiUrl(CERTInextConfig c, string apiUrl) => new CERTInextConfig + { + ApiUrl = apiUrl.TrimEnd('/') + "/", + AuthMode = c.AuthMode, + ApiKey = c.ApiKey, + AccountNumber = c.AccountNumber, + GroupNumber = c.GroupNumber, + OrganizationNumber = c.OrganizationNumber, + RequestorName = c.RequestorName, + RequestorEmail = c.RequestorEmail, + RequestorIsdCode = c.RequestorIsdCode, + RequestorMobileNumber = c.RequestorMobileNumber, + SignerPlace = c.SignerPlace, + SignerIp = c.SignerIp, + DefaultProductCode = c.DefaultProductCode, + PageSize = c.PageSize + }; + } + + /// + /// Runs on its own, never alongside other + /// classes, because it swaps and mutates process env. + /// + [CollectionDefinition(Name, DisableParallelization = true)] + public sealed class ListOrdersErrorBodyProbeCollection + { + public const string Name = "ListOrdersErrorBodyProbe-NoParallel"; + } +} diff --git a/CERTInext.IntegrationTests/OrganizationBlockV2ProbeTests.cs b/CERTInext.IntegrationTests/OrganizationBlockV2ProbeTests.cs new file mode 100644 index 0000000..e9cd009 --- /dev/null +++ b/CERTInext.IntegrationTests/OrganizationBlockV2ProbeTests.cs @@ -0,0 +1,370 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Triage probe for issue 0028 (V2 OrganizationNumber not sent). Three tests: +// +// 1. Catalog_V2_ListsOrganizationVettedEntitlements — read-only, always safe to run +// whenever V2 creds are configured. Lists the live catalog/products response and +// flags anything that looks like an OV/EV entitlement, so we know BEFORE attempting +// any order-placement probe whether one is even possible on this sandbox account. +// +// 2. PlaceOvOrder_WithoutOrganizationBlock_ObservesCaResponse — places one real V2 OV +// order with no `organization` block (the pre-fix code path) and reports whether +// CERTInext rejects it (400/422) or silently accepts it. This is opt-in behind +// CERTINEXT_PROBE_ORG_MISSING=1 AND an explicit CERTINEXT_OV_PRODUCT_CODE — neither +// is set by default in ~/.env_certinext or ~/.env_certinext_v2, so this test skips +// unless an operator deliberately configures both after confirming an OV/EV product +// is entitled and understanding a real order (and its cost) may result. +// +// 3. PlaceOvOrder_WithOrganizationBlock_ExpectsAcceptance — places one real V2 OV order +// WITH the fix's `organization` block populated (organizationNumber + preVetted=true) +// and asserts CERTInext accepts it (no 422), then best-effort cancels it. This is the +// live acceptance check for the fix itself. Opt-in behind CERTINEXT_PROBE_ORG_FIX=1, +// CERTINEXT_OV_PRODUCT_CODE, and a configured CERTINEXT_ORG_NUMBER (~/.env_certinext) — +// none set by default. NOT executed by the agent that authored this fix; a human must +// deliberately opt in and run it, since it places a real, potentially cost-bearing +// order (same standard already applied to test #2 and to issues/f3-v2-multi-san- +// limitation.md's UCC probe). + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using RestSharp; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + public class OrganizationBlockV2ProbeTests : IClassFixture + { + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + private readonly string _v2ApiUrl; + private readonly string _v2ClientId; + private readonly string _v2ClientSecret; + private readonly bool _v2Enabled; + + public OrganizationBlockV2ProbeTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + + var env = V2EnvHelper.LoadAndPromote(); + + _v2ApiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL"); + _v2ClientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID"); + _v2ClientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET"); + + _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API")) + && !string.IsNullOrWhiteSpace(_v2ApiUrl) + && !string.IsNullOrWhiteSpace(_v2ClientId) + && !string.IsNullOrWhiteSpace(_v2ClientSecret); + } + + private CERTInextClient BuildV2Client() + { + return new CERTInextClient(new CERTInextConfig + { + ApiUrl = _v2ApiUrl, + UseV2Api = true, + OAuthClientId = _v2ClientId, + OAuthClientSecret = _v2ClientSecret, + RequestorName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Test", + RequestorEmail = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com", + SignerIp = "127.0.0.1", + SignerPlace = "Gateway Lab", + PageSize = 100 + }); + } + + /// + /// Read-only. Lists the live V2 catalog and reports every product whose name or + /// productType text suggests OV/EV entitlement. No order is placed. Safe to run + /// any time V2 creds are configured. + /// + [SkippableFact] + public async Task Catalog_V2_ListsOrganizationVettedEntitlements() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + using var client = BuildV2Client(); + List products = await client.GetProductDetailsV2Async(); + + products.Should().NotBeNull(); + + _output.WriteLine($"=== V2 catalog: {products.Count} product(s) ==="); + foreach (var p in products) + { + _output.WriteLine( + $"ProductCode={p.ProductCode,-8} ProductTypeId={p.ProductTypeId,-6} " + + $"ProductType={p.ProductType,-30} ProductName={p.ProductName} Active={p.Active}"); + } + + var ovEvCandidates = products + .Where(p => + (p.ProductName ?? "").IndexOf("OV", StringComparison.OrdinalIgnoreCase) >= 0 || + (p.ProductName ?? "").IndexOf("EV", StringComparison.OrdinalIgnoreCase) >= 0 || + (p.ProductName ?? "").IndexOf("Organization", StringComparison.OrdinalIgnoreCase) >= 0 || + (p.ProductName ?? "").IndexOf("Extended Validation", StringComparison.OrdinalIgnoreCase) >= 0) + .ToList(); + + _output.WriteLine(""); + _output.WriteLine(ovEvCandidates.Count > 0 + ? $"=== {ovEvCandidates.Count} OV/EV-looking product(s) found — a live order-placement probe (issue 0028) is feasible: ===" + : "=== No OV/EV-looking product found in this account's catalog — issue 0028's order-placement probe is NOT feasible on this sandbox account. ==="); + foreach (var p in ovEvCandidates) + _output.WriteLine($" ProductCode={p.ProductCode} ProductName={p.ProductName}"); + } + + /// + /// Places ONE real V2 OV order with no organization block — i.e. exercises the + /// exact code path issue 0028 flags as broken/degraded — and reports whether CERTInext + /// rejects it (400/422, with body) or silently accepts it. Best-effort cancel afterward + /// via a raw DELETE/cancel call (the plugin has no V2 CancelOrderAsync client method to + /// reuse) so the order does not linger if the CA does accept it. + /// + /// Opt-in: requires BOTH CERTINEXT_PROBE_ORG_MISSING=1 and CERTINEXT_OV_PRODUCT_CODE to + /// be set. Neither exists in ~/.env_certinext or ~/.env_certinext_v2 by default — an + /// operator must deliberately add both after confirming (via + /// Catalog_V2_ListsOrganizationVettedEntitlements above) that an OV/EV product is + /// actually entitled on the target account. + /// + [SkippableFact] + public async Task PlaceOvOrder_WithoutOrganizationBlock_ObservesCaResponse() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + string probeFlag = Environment.GetEnvironmentVariable("CERTINEXT_PROBE_ORG_MISSING"); + string ovProductCode = Environment.GetEnvironmentVariable("CERTINEXT_OV_PRODUCT_CODE"); + + Skip.If(string.IsNullOrWhiteSpace(probeFlag) || probeFlag != "1", + "CERTINEXT_PROBE_ORG_MISSING=1 not set — this probe places a real, potentially " + + "cost-bearing OV order and is opt-in only. Skipping."); + Skip.If(string.IsNullOrWhiteSpace(ovProductCode), + "CERTINEXT_OV_PRODUCT_CODE not set — no confirmed-entitled OV/EV product code " + + "was supplied. Run Catalog_V2_ListsOrganizationVettedEntitlements first. Skipping."); + + using var client = BuildV2Client(); + + string domain = $"probe-0028-{DateTime.UtcNow:yyyyMMddHHmmss}.example.com"; + var orderReq = new V2CreateSslOrderRequest + { + ProductVariant = "ov", + EmailNotifications = "all", + Requestor = new V2Requestor + { + Name = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test", + Email = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com", + Phone = "0000000000", + Designation = "IT Administrator" + }, + Certificate = new V2CertificateParams + { + Domain = domain, + AutoSecureWww = false + }, + Subscription = new V2SubscriptionParams + { + ValidityYears = 1, + AutoRenew = false, + RenewBeforeDays = 30 + }, + Agreement = new V2AgreementParams + { + SignerName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test", + SignerIp = "127.0.0.1", + SignerPlace = "Gateway Lab", + Accepted = true + }, + Remarks = "Issue 0028 triage probe — no organization block sent on purpose." + }; + // Deliberately NOT setting any organization/organizationNumber/preVetted field — + // this is the exact gap issue 0028 describes. + + string outcome; + string orderId = null; + try + { + var resp = await client.PlaceOrderV2Async(Constants.ApiV2.FamilySsl, ovProductCode, orderReq); + orderId = resp.OrderId; + outcome = $"ACCEPTED — OrderId={resp.OrderId}, Status={resp.Status}. " + + "CERTInext did NOT reject the OV order for missing organization data."; + } + catch (Exception ex) + { + outcome = $"REJECTED — {ex.GetType().Name}: {ex.Message}"; + } + + _output.WriteLine("=== Issue 0028 live probe: OV order with no organization block ==="); + _output.WriteLine($"Domain={domain} ProductCode={ovProductCode}"); + _output.WriteLine(outcome); + + if (orderId != null) + { + _output.WriteLine($"Attempting best-effort cleanup: cancelling order {orderId}..."); + try + { + await CancelSslOrderRawAsync(orderId, + "Issue 0028 triage probe — cleaning up after observing CA response."); + _output.WriteLine($"Cleanup: order {orderId} cancel request returned success."); + } + catch (Exception cleanupEx) + { + _output.WriteLine( + $"Cleanup FAILED for order {orderId}: {cleanupEx.Message}. " + + "Cancel it by hand in the CERTInext portal if it should not remain pending."); + } + } + } + + /// + /// Places ONE real V2 OV order WITH the fix's organization block populated + /// (organizationNumber from CERTINEXT_ORG_NUMBER, preVetted=true) + /// and asserts CERTInext accepts it — i.e. does NOT return the HTTP 422 EMS-1180 + /// "Organization Name cannot be empty" that test #2 above observes for the pre-fix, + /// no-organization-block request. Best-effort cancels the order afterward via the same + /// raw cancel helper. + /// + /// Opt-in: requires CERTINEXT_PROBE_ORG_FIX=1, CERTINEXT_OV_PRODUCT_CODE, AND a + /// configured CERTINEXT_ORG_NUMBER (already present in most `~/.env_certinext` files + /// per this repo's other live tests, e.g. DcvLifecycleTests). None of these are armed + /// by default. This test was authored as part of the issue 0028 fix but deliberately + /// NOT executed by the authoring agent — placing a real order has cost/state + /// implications an operator should explicitly authorize, the same standard already + /// applied to . + /// + [SkippableFact] + public async Task PlaceOvOrder_WithOrganizationBlock_ExpectsAcceptance() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + string probeFlag = Environment.GetEnvironmentVariable("CERTINEXT_PROBE_ORG_FIX"); + string ovProductCode = Environment.GetEnvironmentVariable("CERTINEXT_OV_PRODUCT_CODE"); + string organizationNumber = _fixture.IsConfigured ? _fixture.OrgNumber : null; + + Skip.If(string.IsNullOrWhiteSpace(probeFlag) || probeFlag != "1", + "CERTINEXT_PROBE_ORG_FIX=1 not set — this probe places a real, potentially " + + "cost-bearing OV order and is opt-in only. Skipping."); + Skip.If(string.IsNullOrWhiteSpace(ovProductCode), + "CERTINEXT_OV_PRODUCT_CODE not set — no confirmed-entitled OV/EV product code " + + "was supplied. Run Catalog_V2_ListsOrganizationVettedEntitlements first. Skipping."); + Skip.If(string.IsNullOrWhiteSpace(organizationNumber), + "CERTINEXT_ORG_NUMBER not set in ~/.env_certinext — no pre-vetted organization " + + "number is available to populate the organization block. Skipping."); + + using var client = BuildV2Client(); + + string domain = $"probe-0028-fix-{DateTime.UtcNow:yyyyMMddHHmmss}.example.com"; + var orderReq = new V2CreateSslOrderRequest + { + ProductVariant = "ov", + EmailNotifications = "all", + Requestor = new V2Requestor + { + Name = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test", + Email = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com", + Phone = "0000000000", + Designation = "IT Administrator" + }, + Organization = new V2OrganizationParams + { + OrganizationNumber = organizationNumber, + PreVetted = true + }, + Certificate = new V2CertificateParams + { + Domain = domain, + AutoSecureWww = false + }, + Subscription = new V2SubscriptionParams + { + ValidityYears = 1, + AutoRenew = false, + RenewBeforeDays = 30 + }, + Agreement = new V2AgreementParams + { + SignerName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test", + SignerIp = "127.0.0.1", + SignerPlace = "Gateway Lab", + Accepted = true + }, + Remarks = "Issue 0028 fix-verification probe — organization block populated." + }; + + string orderId = null; + try + { + var resp = await client.PlaceOrderV2Async(Constants.ApiV2.FamilySsl, ovProductCode, orderReq); + orderId = resp.OrderId; + + _output.WriteLine("=== Issue 0028 fix-verification probe: OV order WITH organization block ==="); + _output.WriteLine($"Domain={domain} ProductCode={ovProductCode} OrganizationNumber={organizationNumber}"); + _output.WriteLine($"ACCEPTED — OrderId={resp.OrderId}, Status={resp.Status}."); + + resp.OrderId.Should().NotBeNullOrWhiteSpace(); + resp.Status.Should().NotBe("rejected"); + } + catch (Exception ex) + { + _output.WriteLine("=== Issue 0028 fix-verification probe: OV order WITH organization block ==="); + _output.WriteLine($"Domain={domain} ProductCode={ovProductCode} OrganizationNumber={organizationNumber}"); + _output.WriteLine($"REJECTED — {ex.GetType().Name}: {ex.Message}"); + throw; + } + finally + { + if (orderId != null) + { + _output.WriteLine($"Attempting best-effort cleanup: cancelling order {orderId}..."); + try + { + await CancelSslOrderRawAsync(orderId, + "Issue 0028 fix-verification probe — cleaning up after confirming acceptance."); + _output.WriteLine($"Cleanup: order {orderId} cancel request returned success."); + } + catch (Exception cleanupEx) + { + _output.WriteLine( + $"Cleanup FAILED for order {orderId}: {cleanupEx.Message}. " + + "Cancel it by hand in the CERTInext portal if it should not remain pending."); + } + } + } + } + + /// + /// Standalone cancel call for triage cleanup only — the plugin's + /// has no V2 CancelOrderAsync method to reuse (issue + /// 0028's probe is the only caller), so this authenticates and calls + /// POST /api/certinext/v2/ssl-certificates/{orderId}/cancel directly per the spec + /// (docs/reference/specs/CERTInext API v2.postman_collection (1).json, + /// "SSL/TLS Certificates/Cancel Order"). Not a product code path. + /// + /// Delegates to (issue 0058) — + /// this file's own token-fetch-plus-cancel body used to be inlined here; extracted so + /// this file, IdempotencyKeyV2ProbeTests, and V2GapProbeTests share one + /// implementation instead of three near-duplicates. Behavior is unchanged: same + /// endpoint, same headers, same throw-on-failure semantics. + /// + private Task CancelSslOrderRawAsync(string orderId, string reason) => + V2RawProbeHelpers.CancelSslOrderRawAsync(_v2ApiUrl, _v2ClientId, _v2ClientSecret, orderId, reason); + + } +} diff --git a/CERTInext.IntegrationTests/PendingDvDiagnosticsTests.cs b/CERTInext.IntegrationTests/PendingDvDiagnosticsTests.cs new file mode 100644 index 0000000..49fa064 --- /dev/null +++ b/CERTInext.IntegrationTests/PendingDvDiagnosticsTests.cs @@ -0,0 +1,123 @@ +// Copyright 2026 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// At http://www.apache.org/licenses/LICENSE-2.0 +// +// Read-only diagnostic for pending-DV orders that won't advance through sync-DCV. +// For each "orderId|domain" pair in CERTINEXT_DIAG_ORDER_IDS (comma-separated), it +// dumps the TrackOrder DCV state and probes GetDcv to determine whether CERTInext +// has actually exposed a DCV challenge for the order — the question that decides +// whether the plugin's deferred-DCV retry can ever complete it. +// +// Run: +// export CERTINEXT_DIAG_ORDER_IDS="9937569678|bulk-0b3cbd54.scrup.org,6373633518|bulk-49818a84.scrup.org" +// dotnet test --filter FullyQualifiedName~PendingDvDiagnostics + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + public class PendingDvDiagnosticsTests : IClassFixture + { + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _out; + + public PendingDvDiagnosticsTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _out = output; + } + + [SkippableFact] + public async Task PendingDvDiagnostics_DumpDcvState() + { + IntegrationSkip.IfNotConfigured(_fixture); + + string raw = Environment.GetEnvironmentVariable("CERTINEXT_DIAG_ORDER_IDS"); + Skip.If(string.IsNullOrWhiteSpace(raw), + "Set CERTINEXT_DIAG_ORDER_IDS=\"orderId|domain,orderId|domain,...\" to run the diagnostic."); + + var pairs = raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(p => + { + var bits = p.Split('|', 2); + return (Id: bits[0].Trim(), Domain: bits.Length > 1 ? bits[1].Trim() : null); + }) + .ToList(); + + var ct = CancellationToken.None; + int challengeReady = 0, challengeNotReady = 0, alreadyValidated = 0, errored = 0; + + foreach (var (id, domain) in pairs) + { + _out.WriteLine($"==================== Order {id} ({domain ?? "?"}) ===================="); + ICERTInextClient client = _fixture.Client; + + try + { + var track = await client.TrackOrderAsync(id, ct); + var od = track.OrderDetails; + _out.WriteLine($" OrderStatus: {od?.OrderStatus} (id={od?.OrderStatusId})"); + _out.WriteLine($" CertStatus: {od?.CertificateStatus} (id={od?.CertificateStatusId})"); + + var dv = od?.DomainVerification; + if (dv == null) + { + _out.WriteLine(" DomainVerification: (CERTInext has NOT exposed a DCV challenge slot)"); + } + else + { + _out.WriteLine($" DomainVerification.status: '{dv.Status}' (0=Pending,1=Validated,2=Rejected)"); + var entries = dv.GetDomainEntries(); + if (entries.Count == 0) + _out.WriteLine(" per-domain entries: "); + foreach (var kv in entries) + _out.WriteLine( + $" [{kv.Key}] dcvMethod='{kv.Value.DcvMethod}' dcvStatus='{kv.Value.DcvStatus}' " + + $"status='{kv.Value.Status}' caaStatus='{kv.Value.CaaStatus}' verifiedDate='{kv.Value.VerifiedDate}'"); + + if (dv.Status == Constants.Dcv.StatusValidated || + entries.Values.All(e => e.DcvStatus == Constants.Dcv.StatusValidated)) + alreadyValidated++; + } + + // Probe GetDcv — the decisive test: does CERTInext hand back a challenge token? + if (!string.IsNullOrWhiteSpace(domain)) + { + try + { + var dcv = await client.GetDcvAsync(id, domain, Constants.Dcv.MethodDnsTxt, ct); + bool tokenPresent = !string.IsNullOrWhiteSpace(dcv.DcvDetails?.Token); + _out.WriteLine($" GetDcv: tokenPresent={tokenPresent}"); + if (tokenPresent) challengeReady++; + } + catch (Exception gex) + { + _out.WriteLine($" GetDcv: FAILED -> {gex.Message}"); + if (gex.Message.Contains("956", StringComparison.OrdinalIgnoreCase) || + gex.Message.Contains("not ready", StringComparison.OrdinalIgnoreCase)) + challengeNotReady++; + else + errored++; + } + } + } + catch (Exception ex) + { + _out.WriteLine($" TrackOrder FAILED: {ex.Message}"); + errored++; + } + } + + _out.WriteLine(""); + _out.WriteLine($"=== SUMMARY over {pairs.Count} orders: " + + $"challengeReady={challengeReady}, challengeNotReady={challengeNotReady}, " + + $"alreadyValidated={alreadyValidated}, errored={errored} ==="); + } + } +} diff --git a/CERTInext.IntegrationTests/PrivatePkiV2LiveTests.cs b/CERTInext.IntegrationTests/PrivatePkiV2LiveTests.cs new file mode 100644 index 0000000..028c6b3 --- /dev/null +++ b/CERTInext.IntegrationTests/PrivatePkiV2LiveTests.cs @@ -0,0 +1,481 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.Asn1.Pkcs; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Org.BouncyCastle.X509; +using Org.BouncyCastle.X509.Extension; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// Opt-in live verification of the V2 private-pki enrollment path (issue 0033, commit + /// 88845bf) end to end through the real plugin surface: plugin.Enroll with + /// ProductFamily=private-pki / ProductVariant=intranet-ssl against the CERTInext + /// sandbox, then plugin.Revoke (CRL reason 4, superseded) in cleanup. + /// + /// PLACES EXACTLY ONE REAL ORDER. Gated behind CERTINEXT_PRIVATE_PKI_LIVE=1, which must be + /// exported in the shell (it is read from the process environment before the V2 env file is + /// promoted). Skips with no network calls when the flag or the V2 OAuth2 credentials are absent. + /// No retries anywhere: a thrown or FAILED enroll is reported, never re-attempted. + /// + /// Env: + /// CERTINEXT_PRIVATE_PKI_LIVE=1 required opt-in + /// CERTINEXT_PRIVATE_PKI_PRODUCT_CODE default 149 (Sandbox emSign Intranet SSL 1 Year) + /// CERTINEXT_PRIVATE_PKI_CN default pki0033-<UTC MMddHHmm>.intranet.lab + /// V2 creds (CERTINEXT_API_URL / CERTINEXT_CLIENT_ID / CERTINEXT_CLIENT_SECRET) are loaded + /// from ~/.env_certinext_v2 by , same as . + /// + [Collection(PrivatePkiV2LiveCollection.Name)] + public class PrivatePkiV2LiveTests : IClassFixture + { + private const string OptInFlag = "CERTINEXT_PRIVATE_PKI_LIVE"; + private const string IpSan = "10.0.0.50"; + + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + + private readonly bool _optedIn; + private readonly string _v2ApiUrl; + private readonly string _v2ClientId; + private readonly string _v2ClientSecret; + private readonly string _productCode; + private readonly string _cnOverride; + private readonly bool _v2CredsPresent; + + public PrivatePkiV2LiveTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + + // Read the opt-in flag from the real process environment BEFORE promoting the V2 env + // file, so leaving the flag in ~/.env_certinext_v2 cannot arm this order-placing test. + _optedIn = Environment.GetEnvironmentVariable(OptInFlag)?.Trim() == "1"; + + var env = V2EnvHelper.LoadAndPromote(); + _v2ApiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL"); + _v2ClientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID"); + _v2ClientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET"); + _productCode = V2EnvHelper.GetEnv(env, "CERTINEXT_PRIVATE_PKI_PRODUCT_CODE", "149"); + _cnOverride = V2EnvHelper.GetEnv(env, "CERTINEXT_PRIVATE_PKI_CN"); + + _v2CredsPresent = !string.IsNullOrWhiteSpace(_v2ApiUrl) + && !string.IsNullOrWhiteSpace(_v2ClientId) + && !string.IsNullOrWhiteSpace(_v2ClientSecret); + } + + /// + /// V2 config for the private-pki order: mirrors V2LifecycleTests.BuildV2Config (V2 + /// mode, no V1-only fields, requestor placeholders) with DCV disabled. Private PKI has no DCV + /// anyway; disabling it keeps the no-DCV and DCV builds on the same path. + /// + private CERTInextConfig BuildV2Config() => new CERTInextConfig + { + ApiUrl = _v2ApiUrl, + UseV2Api = true, + OAuthClientId = _v2ClientId, + OAuthClientSecret = _v2ClientSecret, + + RequestorName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test", + RequestorEmail = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "0000000000", + SignerPlace = "Gateway Lab", + SignerIp = "127.0.0.1", + PageSize = 100, + + DcvEnabled = false + }; + + /// + /// BouncyCastle-only RSA-2048 PKCS#10 CSR with a SAN extension request. Same construction as + /// KfclabCsrEmitterTests.GenerateCsrPem (which is private and DNS-only), extended to + /// carry IP SANs. + /// + private static string GenerateCsrPem(string cn, IReadOnlyList dnsSans, IReadOnlyList ipSans) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + var kp = keyGen.GenerateKeyPair(); + + var generalNames = dnsSans.Select(d => new GeneralName(GeneralName.DnsName, d)) + .Concat(ipSans.Select(ip => new GeneralName(GeneralName.IPAddress, ip))) + .ToArray(); + var extGen = new X509ExtensionsGenerator(); + extGen.AddExtension(X509Extensions.SubjectAlternativeName, false, new GeneralNames(generalNames)); + var attrs = new DerSet(new AttributePkcs( + PkcsObjectIdentifiers.Pkcs9AtExtensionRequest, new DerSet(extGen.Generate()))); + + var csr = new Pkcs10CertificationRequest( + "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, attrs, kp.Private); + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----\n"; + } + + /// Parses the first (leaf) PEM block of a possibly chained PEM string. + private static X509Certificate ParseLeaf(string pem) + { + var m = Regex.Match(pem ?? string.Empty, + @"-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----", RegexOptions.Singleline); + if (!m.Success) return null; + string b64 = m.Groups[1].Value.Replace("\r", string.Empty).Replace("\n", string.Empty).Trim(); + return new X509CertificateParser().ReadCertificate(Convert.FromBase64String(b64)); + } + + /// SAN entries as ("dns"|"ip"|"other:<tag>", value), read with BouncyCastle. + private static List<(string Type, string Value)> ReadSans(X509Certificate cert) + { + var result = new List<(string, string)>(); + var ext = cert.GetExtensionValue(X509Extensions.SubjectAlternativeName); + if (ext == null) return result; + + var names = GeneralNames.GetInstance(X509ExtensionUtilities.FromExtensionValue(ext)); + foreach (var gn in names.GetNames()) + { + switch (gn.TagNo) + { + case GeneralName.DnsName: + result.Add(("dns", DerIA5String.GetInstance(gn.Name).GetString())); + break; + case GeneralName.IPAddress: + // IPAddress parses raw octets only (no crypto) — not a BCL-crypto dependency. + result.Add(("ip", new IPAddress(Asn1OctetString.GetInstance(gn.Name).GetOctets()).ToString())); + break; + default: + result.Add(($"other:{gn.TagNo}", gn.Name.ToString())); + break; + } + } + return result; + } + + [SkippableFact] + public async Task PrivatePki_V2_EnrollIntranetSsl_ThenRevoke_Live() + { + Skip.If(!_optedIn, + $"{OptInFlag} is not set to 1 — this test places ONE real private-pki order; skipping (no network calls)."); + Skip.If(!_v2CredsPresent, + "V2 OAuth2 credentials (CERTINEXT_API_URL / CERTINEXT_CLIENT_ID / CERTINEXT_CLIENT_SECRET) not configured — skipping (no network calls)."); + + string cn = string.IsNullOrWhiteSpace(_cnOverride) + ? $"pki0033-{DateTime.UtcNow:MMddHHmm}.intranet.lab" + : _cnOverride.Trim(); + + var config = BuildV2Config(); + var realClient = new CERTInextClient(config); + // Pass-through proxy around the real client: records the order id the moment + // PlaceOrderV2Async returns, so cleanup still knows the order if a later step inside + // Enroll (CSR submit, track, download) throws before an EnrollmentResult exists. + var recorder = OrderIdRecordingClientProxy.Wrap(realClient, out ICERTInextClient proxiedClient); + var plugin = new CERTInextCAPlugin(proxiedClient, config); + + var productInfo = new EnrollmentProductInfo + { + ProductID = _productCode, + ProductParameters = new Dictionary + { + [Constants.EnrollmentParam.ProductFamily] = "private-pki", + [Constants.EnrollmentParam.ProductVariant] = Constants.ApiV2.PrivatePkiVariantIntranetSsl, + [Constants.EnrollmentParam.ProductCode] = _productCode, + } + }; + // Gateway SAN dictionary exactly as Command sends it ("dnsname" / "ipaddress" keys). + var san = new Dictionary + { + ["dnsname"] = new[] { cn }, + ["ipaddress"] = new[] { IpSan }, + }; + + _output.WriteLine("=== Issue 0033 private-pki live enrollment (ONE order, no retries) ==="); + _output.WriteLine($"Family=private-pki, Variant={Constants.ApiV2.PrivatePkiVariantIntranetSsl}, ProductCode={_productCode}"); + _output.WriteLine($"CN={cn}, SANs: dnsname=[{cn}], ipaddress=[{IpSan}]"); + + string orderId = null; + // Set only once Enroll returned GENERATED with a certificate body; cleanup revokes an + // issued order and cancels anything else (issue 0039). + bool issued = false; + try + { + EnrollmentResult result = null; + Exception enrollEx = null; + try + { + result = await plugin.Enroll( + csr: GenerateCsrPem(cn, new[] { cn }, new[] { IpSan }), + subject: $"CN={cn}", + san: san, + productInfo: productInfo, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + } + catch (Exception ex) + { + enrollEx = ex; + } + + orderId = !string.IsNullOrWhiteSpace(result?.CARequestID) ? result.CARequestID : recorder.PlacedOrderId; + + // Order id first — before anything below that can fail. + _output.WriteLine($"CARequestID (order id): {orderId ?? ""}"); + _output.WriteLine($" (recorded from PlaceOrderV2Async: {recorder.PlacedOrderId ?? ""}, " + + $"initial CA status: {recorder.PlacedOrderStatus ?? ""})"); + + if (enrollEx != null) + { + _output.WriteLine($"Enroll THREW {enrollEx.GetType().Name}: {enrollEx.Message}"); + _output.WriteLine("Not retrying. NOTE: a client-side timeout does not prove no order exists — if the " + + "order id above is , check the CERTInext portal for a private-pki order " + + $"with hostname '{cn}'."); + ExceptionDispatchInfo.Capture(enrollEx).Throw(); + } + + if (result == null) + { + _output.WriteLine("Enroll returned a null EnrollmentResult. Not retrying."); + Assert.Fail("private-pki Enroll returned a null EnrollmentResult."); + return; // unreachable + } + + _output.WriteLine($"Enroll status: {result.Status} ({(EndEntityStatus)result.Status})"); + _output.WriteLine($"Enroll message: {result.StatusMessage}"); + + if (result.Status == (int)EndEntityStatus.FAILED) + { + _output.WriteLine("Enroll returned FAILED. Not retrying; no further order will be placed."); + Assert.Fail($"private-pki Enroll returned FAILED: {result.StatusMessage}"); + } + + if (result.Status != (int)EndEntityStatus.GENERATED || string.IsNullOrWhiteSpace(result.Certificate)) + { + _output.WriteLine($"Order {orderId} is still pending (not issued within the plugin's pickup poll). " + + "Not polling further; cleanup below will cancel it once and otherwise print " + + "manual-cleanup instructions."); + Assert.Fail($"INCONCLUSIVE: private-pki order '{orderId}' did not issue within the pickup poll " + + $"(status {result.Status}); end-to-end issuance not verified."); + } + + issued = true; + var leaf = ParseLeaf(result.Certificate); + leaf.Should().NotBeNull("the GENERATED result must carry a parseable leaf certificate PEM"); + + var sans = ReadSans(leaf); + _output.WriteLine($"Issued subject: {leaf.SubjectDN}"); + _output.WriteLine($"Issued issuer: {leaf.IssuerDN}"); + _output.WriteLine($"Issued serial: {leaf.SerialNumber.ToString(16).ToUpperInvariant()}"); + _output.WriteLine($"Issued SANs: [{string.Join(", ", sans.Select(s => $"{s.Type}:{s.Value}"))}]"); + _output.WriteLine($"Validity: {leaf.NotBefore:o} .. {leaf.NotAfter:o}"); + + sans.Should().Contain(s => s.Type == "ip" && s.Value == IpSan, + "the IP SAN submitted via additionalHosts must appear on the issued certificate"); + sans.Should().Contain(s => s.Type == "dns" && string.Equals(s.Value, cn, StringComparison.OrdinalIgnoreCase), + "the CN (sent as hostname) must appear as a DNS SAN on the issued certificate"); + } + finally + { + await CleanupAsync(plugin, realClient, orderId, cn, issued); + realClient.Dispose(); + } + } + + /// + /// Best-effort cleanup: exactly one cleanup action, never retried, never throwing (a cleanup + /// failure must not mask the test's own result). An issued order is revoked via + /// plugin.Revoke (CRL reason 4, superseded); any other order is cancelled via + /// on the private-pki family (issue 0039 — + /// plugin.Revoke refuses non-issued orders). Manual-cleanup instructions are printed + /// only when that action fails. If Enroll's own Submit CSR failure path already cancelled the + /// order, this cancel reports the CA's 422 "already terminal" answer. Finishes with one + /// read-only GET on the private-pki order. + /// + private async Task CleanupAsync(CERTInextCAPlugin plugin, CERTInextClient client, string orderId, string cn, bool issued) + { + _output.WriteLine("--- Cleanup ---"); + if (string.IsNullOrWhiteSpace(orderId)) + { + _output.WriteLine("No order id captured — nothing to revoke or cancel. If Enroll threw after sending the " + + $"create request, check the CERTInext portal for a private-pki order with hostname '{cn}'."); + return; + } + + bool cleanedUp = false; + if (issued) + { + try + { + int revokeResult = await plugin.Revoke(orderId, hexSerialNumber: string.Empty, revocationReason: 4 /* superseded */); + _output.WriteLine($"Revoke(order={orderId}, reason=4 superseded) returned {revokeResult} ({(EndEntityStatus)revokeResult})."); + cleanedUp = true; + } + catch (Exception ex) + { + _output.WriteLine($"Revoke FAILED for order {orderId}: {ex.GetType().Name}: {ex.Message}"); + } + } + else + { + try + { + var outcome = await client.CancelOrderV2Async( + Constants.ApiV2.FamilyPrivatePki, orderId, "Keyfactor plugin live test cleanup (issue 0033)."); + _output.WriteLine($"CancelOrderV2Async(private-pki, order={orderId}) returned {outcome}" + + (outcome == V2CancelOrderOutcome.AlreadyTerminal + ? " (HTTP 422: already in a terminal state; nothing cancelled)." + : " (HTTP 2xx: order cancelled).")); + cleanedUp = outcome == V2CancelOrderOutcome.Cancelled; + } + catch (Exception ex) + { + _output.WriteLine($"Cancel FAILED for order {orderId}: {ex.GetType().Name}: {ex.Message}"); + } + } + + if (!cleanedUp) + { + _output.WriteLine("Not retrying. Unless the track below shows the order cancelled or revoked, MANUAL " + + "CLEANUP REQUIRED: in the CERTInext portal, cancel (if pending) or revoke (if issued) " + + $"order id {orderId}, product family private-pki " + + $"({Constants.ApiV2.PrivatePkiCertificatesPath}/{orderId})."); + } + + try + { + var (status, _, body) = await client.ProbeV2GetAsync($"{Constants.ApiV2.PrivatePkiCertificatesPath}/{orderId}"); + _output.WriteLine($"Post-cleanup track (read-only GET, private-pki): HTTP {status}, " + + $"status={Field(body, "status")}, certificateState={Field(body, "certificateState")}, " + + $"orderState={Field(body, "orderState")}, revocation.status={Field(body, "revocation", "status")}, " + + $"revocation.reason={Field(body, "revocation", "reason")}"); + } + catch (Exception ex) + { + _output.WriteLine($"Post-cleanup track failed (read-only; not retried): {ex.GetType().Name}: {ex.Message}"); + } + } + + /// Reads a (nested) string field from a JSON body; "<none>" when absent/unparseable. + private static string Field(string json, params string[] path) + { + if (string.IsNullOrWhiteSpace(json)) return ""; + try + { + using var doc = JsonDocument.Parse(json); + var el = doc.RootElement; + foreach (var p in path) + { + if (el.ValueKind != JsonValueKind.Object || !el.TryGetProperty(p, out el)) + return ""; + } + return el.ValueKind == JsonValueKind.String ? el.GetString() : el.ToString(); + } + catch (JsonException) + { + return ""; + } + } + + /// + /// Offline sanity check (no network): the recording proxy can be generated for + /// . A generation failure would otherwise only surface inside + /// the live test, after the opt-in. + /// + [Fact] + public void PrivatePki_V2_RecordingProxy_BuildsOffline() + { + using var client = new CERTInextClient(new CERTInextConfig { UseV2Api = true, ApiUrl = "https://invalid.example" }); + var recorder = OrderIdRecordingClientProxy.Wrap(client, out ICERTInextClient proxied); + proxied.Should().NotBeNull(); + recorder.PlacedOrderId.Should().BeNull(); + } + } + + /// + /// Pass-through over a real that + /// records the order id returned by any PlaceOrderV2Async overload. Changes no behavior: + /// every call is forwarded unchanged and its result/exception returned as-is. + /// + public class OrderIdRecordingClientProxy : DispatchProxy + { + private ICERTInextClient _inner; + + public string PlacedOrderId { get; private set; } + public string PlacedOrderStatus { get; private set; } + + public static OrderIdRecordingClientProxy Wrap(ICERTInextClient inner, out ICERTInextClient proxied) + { + proxied = Create(); + var recorder = (OrderIdRecordingClientProxy)(object)proxied; + recorder._inner = inner; + return recorder; + } + + protected override object Invoke(MethodInfo targetMethod, object[] args) + { + object result; + try + { + result = targetMethod.Invoke(_inner, args); + } + catch (TargetInvocationException tie) when (tie.InnerException != null) + { + ExceptionDispatchInfo.Capture(tie.InnerException).Throw(); + throw; // unreachable + } + + if (targetMethod.Name == nameof(ICERTInextClient.PlaceOrderV2Async) + && result is Task placeTask) + return RecordAsync(placeTask); + + return result; + } + + private async Task RecordAsync(Task placeTask) + { + var resp = await placeTask; + PlacedOrderId = resp?.OrderId; + PlacedOrderStatus = resp?.Status; + return resp; + } + } + + /// + /// Runs alone: its constructor promotes ~/.env_certinext_v2 + /// into process env (). + /// + [CollectionDefinition(Name, DisableParallelization = true)] + public sealed class PrivatePkiV2LiveCollection + { + public const string Name = "PrivatePkiV2Live-NoParallel"; + } +} diff --git a/CERTInext.IntegrationTests/ProductTests.cs b/CERTInext.IntegrationTests/ProductTests.cs index 99f45f3..51dd85b 100644 --- a/CERTInext.IntegrationTests/ProductTests.cs +++ b/CERTInext.IntegrationTests/ProductTests.cs @@ -2,11 +2,13 @@ // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. // At http://www.apache.org/licenses/LICENSE-2.0 +using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; using Keyfactor.Extensions.CAPlugin.CERTInext.API; using Xunit; @@ -73,5 +75,40 @@ await act.Should().NotThrowAsync( "in the account's product list when GetProductDetails returns results"); } } + + /// + /// V1 parity test for issue 0025 — drives + /// + /// (not just the client method) through the plugin, the same path AnyGatewayREST's + /// ConfigurationValidator exercises when a template is saved. V1 mode (the + /// default, UseV2Api unset) must be unaffected by the V2 branch this issue adds. + /// + [SkippableFact] + public async Task ValidateProductInfo_V1_AcceptsConfiguredProductCode() + { + IntegrationSkip.IfNotConfigured(_fixture); + Skip.If(string.IsNullOrWhiteSpace(_fixture.ProductCode), + "CERTINEXT_PRODUCT_CODE not set — cannot assert against a real product code."); + + var plugin = new Keyfactor.Extensions.CAPlugin.CERTInext.CERTInextCAPlugin(); + var connectionInfo = new Dictionary + { + ["ApiUrl"] = _fixture.ApiUrl, + ["AuthMode"] = "AccessKey", + ["ApiKey"] = _fixture.AccessKey, + ["AccountNumber"] = _fixture.AccountNumber, + ["GroupNumber"] = _fixture.GroupNumber + }; + var productInfo = new EnrollmentProductInfo + { + ProductID = "ssl", + ProductParameters = new Dictionary { ["ProductCode"] = _fixture.ProductCode } + }; + + Func act = () => plugin.ValidateProductInfo(productInfo, connectionInfo); + + await act.Should().NotThrowAsync( + $"configured product code \"{_fixture.ProductCode}\" should validate in V1 mode"); + } } } diff --git a/CERTInext.IntegrationTests/RecordingDomainValidator.cs b/CERTInext.IntegrationTests/RecordingDomainValidator.cs new file mode 100644 index 0000000..be46395 --- /dev/null +++ b/CERTInext.IntegrationTests/RecordingDomainValidator.cs @@ -0,0 +1,94 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Keyfactor.AnyGateway.Extensions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// spy that wraps a real (Cloudflare or stub) validator + /// and records every StageValidation/CleanupValidation call, including the + /// FQDN and staged value, so DCV-on tests can assert whether the plugin actually staged + /// a TXT record rather than just asserting that Enroll did not throw (gap G5, issues/0020). + /// + internal sealed class RecordingDomainValidator : IDomainValidator + { + private readonly IDomainValidator _inner; + private readonly ConcurrentQueue<(string Fqdn, string Value)> _staged = new(); + private readonly ConcurrentQueue _cleanedUp = new(); + + public RecordingDomainValidator(IDomainValidator inner) + { + _inner = inner; + } + + public IReadOnlyList<(string Fqdn, string Value)> StagedCalls => _staged.ToList(); + public IReadOnlyList CleanedUpFqdns => _cleanedUp.ToList(); + + public void Initialize(IDomainValidatorConfigProvider configProvider) => _inner.Initialize(configProvider); + + public async Task StageValidation(string key, string value, CancellationToken cancellationToken) + { + _staged.Enqueue((key, value)); + return await _inner.StageValidation(key, value, cancellationToken); + } + + public async Task CleanupValidation(string key, CancellationToken cancellationToken) + { + _cleanedUp.Enqueue(key); + return await _inner.CleanupValidation(key, cancellationToken); + } + + public Task ValidateConfiguration(Dictionary configuration) => _inner.ValidateConfiguration(configuration); + public Dictionary GetDomainValidatorAnnotations() => _inner.GetDomainValidatorAnnotations(); + public string GetValidationType() => _inner.GetValidationType(); + } + + /// + /// that wraps another factory and hands out + /// spies so tests can inspect what the plugin + /// actually did with the DNS provider, keyed by (domain, validationType). Does not own + /// disposal of the wrapped factory — callers that build a disposable inner factory + /// (e.g. CloudflareDomainValidatorFactory) remain responsible for disposing it. + /// + internal sealed class RecordingDomainValidatorFactory : IDomainValidatorFactory + { + private readonly IDomainValidatorFactory _inner; + private readonly ConcurrentDictionary _wrapped = new(); + + public RecordingDomainValidatorFactory(IDomainValidatorFactory inner) + { + _inner = inner; + } + + public IDomainValidator ResolveDomainValidator(string domain, string validationType) + { + string cacheKey = $"{domain}|{validationType}"; + return _wrapped.GetOrAdd(cacheKey, _ => new RecordingDomainValidator(_inner.ResolveDomainValidator(domain, validationType))); + } + + /// All StageValidation calls recorded across every domain resolved so far. + public IReadOnlyList<(string Fqdn, string Value)> StagedCalls => + _wrapped.Values.SelectMany(v => v.StagedCalls).ToList(); + + /// All CleanupValidation calls recorded across every domain resolved so far. + public IReadOnlyList CleanedUpFqdns => + _wrapped.Values.SelectMany(v => v.CleanedUpFqdns).ToList(); + } +} diff --git a/CERTInext.IntegrationTests/RevocationShapeV2ProbeTests.cs b/CERTInext.IntegrationTests/RevocationShapeV2ProbeTests.cs new file mode 100644 index 0000000..178e415 --- /dev/null +++ b/CERTInext.IntegrationTests/RevocationShapeV2ProbeTests.cs @@ -0,0 +1,316 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Read-only investigation probe for issue 0034 (V2 revocation date/reason DTO shape +// mismatch). Static analysis (issues/0034-v2-revocation-date-reason-dto-mismatch.md) +// already confirmed the CERTInext V2 spec documents a nested +// `revocation: {status, reason, processedAt}` object on Track Order, while +// V2OrderStatusResponse (CertificateResponseV2.cs:130-136) instead models flat top-level +// `revocationReason`/`revocationDate` properties. This probe settles the exact live wire +// shape before that DTO is fixed: it searches the sandbox account's recent V2 order +// history for an order already in a revoked state (issues/0026's live revoke work left +// several behind on 2026-09-24), then calls the raw, unparsed Track Order response for +// that order and reports the revocation-related JSON verbatim. +// +// Deliberately read-only: no order is placed, no order is revoked. Only GET calls are +// made — ListOrdersV2Async's report endpoint to search, and a raw GET against each of +// the three V2 product-family Track Order paths in turn to locate the revoked order's +// family. If no already-revoked order is found within the search window, the test +// reports that and passes without asserting a shape — creating one on demand would be a +// mutating, cost-bearing action requiring separate, explicit sign-off (out of scope here). +// +// Opt-in: requires CERTINEXT_PROBE_REVOCATION_SHAPE=1. Not armed by default in +// ~/.env_certinext or ~/.env_certinext_v2 — an operator must deliberately opt in, per +// this repo's convention for live-API probes (mirrors OrganizationBlockV2ProbeTests / +// IdempotencyKeyV2ProbeTests). + +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using RestSharp; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + public class RevocationShapeV2ProbeTests : IClassFixture + { + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + private readonly string _v2ApiUrl; + private readonly string _v2ClientId; + private readonly string _v2ClientSecret; + private readonly bool _v2Enabled; + + /// + /// V2 product-family URL path segments to probe, in the same order as + /// CERTInextClient.ResolveV2OrderFamilyAsync (SSL, then Private PKI, then + /// Signature) — an order id is only ever valid within exactly one family. + /// + private static readonly string[] Families = + { + Constants.ApiV2.FamilySsl, + Constants.ApiV2.FamilyPrivatePki, + Constants.ApiV2.FamilySignature + }; + + public RevocationShapeV2ProbeTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + + var env = V2EnvHelper.LoadAndPromote(); + + _v2ApiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL"); + _v2ClientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID"); + _v2ClientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET"); + + _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API")) + && !string.IsNullOrWhiteSpace(_v2ApiUrl) + && !string.IsNullOrWhiteSpace(_v2ClientId) + && !string.IsNullOrWhiteSpace(_v2ClientSecret); + } + + private CERTInextClient BuildV2Client() + { + return new CERTInextClient(new CERTInextConfig + { + ApiUrl = _v2ApiUrl, + UseV2Api = true, + OAuthClientId = _v2ClientId, + OAuthClientSecret = _v2ClientSecret, + RequestorName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Test", + RequestorEmail = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com", + SignerIp = "127.0.0.1", + SignerPlace = "Gateway Lab", + PageSize = 100 + }); + } + + /// + /// Read-only. Searches recent V2 order-report history (last 90 days) for an order + /// whose orderStatus/certificateStatus display string indicates it has been + /// revoked (mirrors CERTInextCAPlugin.TryMapV2ReportDisplayStatus's + /// "revoked" / "certificate revoked" vocabulary), then calls the raw Track Order + /// endpoint for that order and reports the unparsed JSON response body — + /// specifically the revocation-related portion — so the exact wire shape (nested + /// vs flat, field names, casing, timestamp format) can be confirmed before issue + /// 0034's DTO fix is written. + /// + /// Bounded to a 90-day lookback and a capped number of scanned rows so this never + /// walks full account history (this repo's convention for shared-account + /// contention — see the "Only run 1 fullSync at a time" memory note). issues/0026's + /// live revoke work (2026-09-24) left multiple V2 orders in a revoked state, so a + /// 90-day window from today should already cover them without a full-history scan. + /// + /// If no revoked order is found within that window, the test reports so and passes + /// without asserting a shape — placing/revoking a fresh order to force one is a + /// mutating, cost-bearing action out of scope for this probe. + /// + [SkippableFact] + public async Task RevocationShape_V2_FindsRevokedOrderAndCapturesRawTrackOrderJson() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + string probeFlag = Environment.GetEnvironmentVariable("CERTINEXT_PROBE_REVOCATION_SHAPE"); + Skip.If(string.IsNullOrWhiteSpace(probeFlag) || probeFlag != "1", + "CERTINEXT_PROBE_REVOCATION_SHAPE=1 not set — this probe is opt-in only per this " + + "repo's live-API-probe convention. Skipping."); + + using var client = BuildV2Client(); + + string from = DateTime.UtcNow.AddDays(-90).ToString("yyyy-MM-dd"); + + _output.WriteLine("=== Issue 0034 live probe: V2 revocation date/reason wire shape ==="); + _output.WriteLine($"Searching V2 order report from={from} for an already-revoked order..."); + + string revokedOrderId = null; + string matchedOn = null; + int scanned = 0; + const int maxScanned = 1000; // bound the scan regardless of account size + + await foreach (var row in client.ListOrdersV2Async(from, null, 100, CancellationToken.None)) + { + scanned++; + + if (LooksRevoked(row.CertificateStatus)) + { + revokedOrderId = row.OrderNumber; + matchedOn = $"certificateStatus='{row.CertificateStatus}'"; + break; + } + if (LooksRevoked(row.OrderStatus)) + { + revokedOrderId = row.OrderNumber; + matchedOn = $"orderStatus='{row.OrderStatus}'"; + break; + } + if (scanned >= maxScanned) + break; + } + + _output.WriteLine($"Scanned {scanned} order report row(s)."); + + if (revokedOrderId == null) + { + _output.WriteLine( + "No already-revoked V2 order found within the search window. Not creating one — " + + "that would be a mutating, cost-bearing action requiring separate, explicit " + + "sign-off. Reporting NONE FOUND for issue 0034; re-run with a wider window (or " + + "after a live revoke exists) if this needs to be re-verified."); + return; + } + + _output.WriteLine($"Found revoked order candidate: orderId={revokedOrderId} ({matchedOn})"); + + var raw = await TrackOrderRawAsync(revokedOrderId); + + raw.Should().NotBeNull("a revoked order located via the report endpoint must resolve to some family"); + + _output.WriteLine($"Family={raw.Family} HTTP={raw.StatusCode}"); + _output.WriteLine("Raw Track Order response body:"); + _output.WriteLine(raw.Body); + + // Best-effort slice of just the revocation-related keys, for a quick eyeball of + // nested-vs-flat shape without re-reading the whole body by hand — the full body + // is logged above regardless. + string revocationSlice = ExtractRevocationSlice(raw.Body); + _output.WriteLine(""); + _output.WriteLine(revocationSlice != null + ? $"Revocation-related JSON slice: {revocationSlice}" + : "No top-level 'revocation' object or flat 'revocationReason'/'revocationDate' " + + "keys found in the raw body — see the full body above."); + + raw.Body.Should().NotBeNullOrWhiteSpace( + "the raw Track Order response for a revoked order must have a non-empty body"); + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private static bool LooksRevoked(string displayStatus) + { + if (string.IsNullOrWhiteSpace(displayStatus)) + return false; + return displayStatus.Trim().ToLowerInvariant().Contains("revok"); + } + + private sealed class RawTrackOrderResult + { + public string Family { get; set; } + public int StatusCode { get; set; } + public string Body { get; set; } + } + + /// + /// Raw HTTP GET against each V2 product-family Track Order path in turn, stopping + /// at the first non-404 response — mirrors + /// CERTInextClient.ResolveV2OrderFamilyAsync's try-each-family algorithm, but + /// deliberately bypasses CERTInextClient.TrackOrderV2Async's typed + /// deserialization so the exact unparsed response body can be captured (same raw- + /// HTTP idiom as OrganizationBlockV2ProbeTests.CancelSslOrderRawAsync / + /// IdempotencyKeyV2ProbeTests.PlaceOrderRawAsync). Returns null if the order + /// is not found in any of the three families. + /// + private async Task TrackOrderRawAsync(string orderId) + { + string accessToken = await GetV2AccessTokenAsync(); + + using var apiClient = new RestClient(_v2ApiUrl.TrimEnd('/')); + foreach (string family in Families) + { + var req = new RestRequest($"/api/certinext/v2/{family}/{orderId}", Method.Get); + req.AddHeader("Authorization", $"Bearer {accessToken}"); + req.AddHeader("Accept", "application/json"); + var resp = await apiClient.ExecuteAsync(req); + + if ((int)resp.StatusCode == 404) + continue; + + return new RawTrackOrderResult + { + Family = family, + StatusCode = (int)resp.StatusCode, + Body = resp.Content + }; + } + return null; + } + + /// + /// Same OAuth2 client_credentials token-fetch idiom as + /// OrganizationBlockV2ProbeTests.CancelSslOrderRawAsync / + /// IdempotencyKeyV2ProbeTests.GetV2AccessTokenAsync. + /// + private async Task GetV2AccessTokenAsync() + { + string tokenUrl = _v2ApiUrl.TrimEnd('/') + "/oauth/token"; + using var tokenClient = new RestClient(tokenUrl); + var tokenReq = new RestRequest(string.Empty, Method.Post); + tokenReq.AddHeader("Content-Type", "application/x-www-form-urlencoded"); + tokenReq.AddParameter("grant_type", "client_credentials"); + tokenReq.AddParameter("client_id", _v2ClientId); + tokenReq.AddParameter("client_secret", _v2ClientSecret); + var tokenResp = await tokenClient.ExecuteAsync(tokenReq); + if (!tokenResp.IsSuccessful || string.IsNullOrWhiteSpace(tokenResp.Content)) + throw new Exception($"Token request failed: {(int)tokenResp.StatusCode}"); + + using var tokenDoc = JsonDocument.Parse(tokenResp.Content); + return tokenDoc.RootElement.GetProperty("access_token").GetString(); + } + + /// + /// Best-effort extraction of just the revocation-related portion of a raw Track + /// Order JSON body, checking both the spec-documented nested revocation + /// object and the DTO's current (likely-wrong) flat revocationReason/ + /// revocationDate keys, whichever is present. Returns null if neither is + /// found — the full body is still logged by the caller either way. + /// + private static string ExtractRevocationSlice(string body) + { + if (string.IsNullOrWhiteSpace(body)) + return null; + + try + { + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + + if (root.TryGetProperty("revocation", out var nested)) + return nested.GetRawText(); + + bool hasFlatReason = root.TryGetProperty("revocationReason", out var flatReason); + bool hasFlatDate = root.TryGetProperty("revocationDate", out var flatDate); + if (hasFlatReason || hasFlatDate) + { + return "{" + + (hasFlatReason ? $"\"revocationReason\":{flatReason.GetRawText()}" : "") + + (hasFlatReason && hasFlatDate ? "," : "") + + (hasFlatDate ? $"\"revocationDate\":{flatDate.GetRawText()}" : "") + + "}"; + } + + return null; + } + catch (JsonException) + { + return null; + } + } + } +} diff --git a/CERTInext.IntegrationTests/SanSubmissionProbeTests.cs b/CERTInext.IntegrationTests/SanSubmissionProbeTests.cs new file mode 100644 index 0000000..23681d1 --- /dev/null +++ b/CERTInext.IntegrationTests/SanSubmissionProbeTests.cs @@ -0,0 +1,391 @@ +// Copyright 2026 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// At http://www.apache.org/licenses/LICENSE-2.0 +// +// Probe: establish empirically how CERTInext treats the SAN/domain fields on +// GenerateOrderSSL. Written because the plugin's original behaviour encoded three +// assumptions that were never measured: +// +// A. certificateInformation.additionalDomains is the field that puts extra names on +// the certificate (so a UCC order that omits it yields a CN-only certificate). +// B. additionalDomains accepts DNS names only, so a non-DNS SAN is rejected by the CA. +// C. Repeating the primary domainName inside additionalDomains is harmful (duplicate +// domain / consumes the UCC allowance), so it should be de-duplicated. +// +// None of these had a test. This probe answers them against the live API by placing one +// order per variant and reading back the domain set CERTInext actually registered, via +// TrackOrder's domainVerification block (keys are the domains on the order). That is +// ground truth for "which names did the CA put on this order" without waiting for DCV +// and issuance to complete. +// +// --------------------------------------------------------------------------------------- +// MEASURED RESULTS — SANDBOX ONLY: sandbox-us, account 4951571271, product 844 (OV SSL UCC), +// 2026-08-12. (Product 840 / DV UCC is not enabled on that account: "Invalid Product Code".) +// +// These are sandbox observations. Re-run against production before treating B or C as +// settled there — point ~/.env_certinext at the production account and set +// CERTINEXT_SAN_PROBE_PRODUCTS to a UCC code that account can actually order (product +// numbering is per-account; the codes in Constants.Products are defaults, not guarantees). +// Finding A and the CSR-SAN result below are separately corroborated by production: the +// customer report that prompted this work was a production UCC order whose CSR carried the +// SANs and whose issued certificate held only the CN. +// +// A. CONFIRMED. additionalDomains is what puts extra names on the order. Submitting +// CN + extra1. registered BOTH domains. +// +// B. DISPROVEN. Non-DNS values are NOT rejected. An email address, an IPv4 literal and +// an https:// URI were each accepted at placement AND registered as order domains +// ("san-probe@example.com", "192.0.2.10", "https://san-probe.example.com/x" all came +// back as domainVerification keys). So the CA does not validate the field's contents +// at order time; such an order is created and then cannot pass DCV, rather than +// failing cleanly up front. +// +// C. PARTLY DISPROVEN. Repeating the primary domainName inside additionalDomains is +// accepted and CERTInext collapses it itself — the order came back with the CN +// registered once. De-duplicating on our side is therefore belt-and-braces, not a +// correctness requirement. +// +// Root cause of the customer-reported "UCC SANs not populating": CERTInext IGNORES the +// subjectAltName extension in the CSR. A CSR carrying CN + extra2., submitted with +// additionalDomains omitted, registered ONLY the CN. SANs must be sent in +// additionalDomains or they do not reach the certificate, no matter what the CSR says. +// --------------------------------------------------------------------------------------- +// +// Opt-in: this places real orders against whatever account ~/.env_certinext points at. +// +// set -a; . ~/.env_certinext; set +a +// export CERTINEXT_SAN_PROBE=1 +// dotnet test CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj -c Release \ +// --filter "FullyQualifiedName~SanSubmissionProbeTests" \ +// --logger "console;verbosity=detailed" > /tmp/sanprobe.log 2>&1 +// +// (xUnit buffers ITestOutputHelper output until the test ends — read the report at the tail.) + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.Asn1.Pkcs; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + public class SanSubmissionProbeTests : IClassFixture + { + private const string OptInFlag = "CERTINEXT_SAN_PROBE"; + + /// + /// Comma-separated product codes to probe. Defaults to the Multi-Domain (UCC) codes, + /// because additional domains are only meaningful on a UCC product — a single-domain + /// product (e.g. 842 = OV SSL) registers the CN and nothing else no matter what + /// additionalDomains contains, which makes it useless as a probe target. + /// + private const string ProductCodesFlag = "CERTINEXT_SAN_PROBE_PRODUCTS"; + private const string DefaultProductCodes = "840,844"; + + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _out; + + public SanSubmissionProbeTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _out = output; + } + + // ------------------------------------------------------------------------- + // CSR generation (BouncyCastle — project crypto policy) + // ------------------------------------------------------------------------- + + /// + /// Generates a PKCS#10 CSR for , optionally carrying a + /// subjectAltName extension (via the PKCS#9 extensionRequest attribute) holding + /// . The SAN-bearing form is what lets this probe ask + /// whether CERTInext reads SANs out of the CSR at all. + /// + private static string GenerateCsrPem(string cn, params string[] dnsSans) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + AsymmetricCipherKeyPair kp = keyGen.GenerateKeyPair(); + + Asn1Set attributes = null; + if (dnsSans != null && dnsSans.Length > 0) + { + var names = new GeneralNames( + dnsSans.Select(d => new GeneralName(GeneralName.DnsName, d)).ToArray()); + + var extGen = new X509ExtensionsGenerator(); + extGen.AddExtension(X509Extensions.SubjectAlternativeName, critical: false, extValue: names); + + attributes = new DerSet(new AttributePkcs( + PkcsObjectIdentifiers.Pkcs9AtExtensionRequest, + new DerSet(extGen.Generate()))); + } + + var csr = new Pkcs10CertificationRequest( + "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, attributes, kp.Private); + + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + // ------------------------------------------------------------------------- + // One probe variant + // ------------------------------------------------------------------------- + + private sealed class ProbeOutcome + { + public string ProductCode; + public string Label; + public bool Accepted; + public string OrderNumber; + public string Detail; + /// Domains CERTInext registered on the order, per TrackOrder. + public List RegisteredDomains = new List(); + /// Names we asked CERTInext to put on the order, for comparison. + public List RequestedDomains = new List(); + + /// + /// True when the rejection was "Invalid Product Code" — the product simply is not + /// enabled on this account, which is not a data point about SAN handling. + /// + public bool ProductUnavailable; + } + + /// + /// Places one order and reads back the domain set CERTInext registered for it. + /// drives certificateInformation.additionalDomains; + /// drives the SAN extension inside the CSR. They are + /// varied independently on purpose — that separation is the whole point of the probe. + /// + private async Task ProbeAsync( + string productCode, + string label, + Func> sansFactory, + string[] csrSans) + { + var outcome = new ProbeOutcome { ProductCode = productCode, Label = label }; + + var client = new CERTInextClient(_fixture.Config); + string cn = $"sanprobe-{DateTime.UtcNow:yyyyMMddHHmmssfff}.{SafeLabel(label)}.example.com"; + + var sans = sansFactory?.Invoke(cn); + outcome.RequestedDomains = sans == null + ? new List() + : sans.Select(s => $"{s.Type}:{s.Value}").ToList(); + + var req = new EnrollCertificateRequest + { + Csr = GenerateCsrPem(cn, csrSans == null ? null : csrSans.Select(s => Format(s, cn)).ToArray()), + Subject = $"CN={cn}", + Sans = sans, + ProfileId = productCode, + RequesterName = _fixture.RequestorName, + RequesterEmail = _fixture.RequestorEmail + }; + + try + { + var resp = await client.EnrollCertificateAsync(req); + outcome.Accepted = true; + outcome.OrderNumber = resp?.Id; + outcome.Detail = $"OrderNumber={resp?.Id} Status={resp?.Status}"; + } + catch (Exception ex) + { + outcome.Accepted = false; + outcome.Detail = ex.Message; + outcome.ProductUnavailable = + ex.Message.IndexOf("Invalid Product Code", StringComparison.OrdinalIgnoreCase) >= 0; + return outcome; + } + + // Read back which domains the CA actually put on the order. + try + { + var track = await client.TrackOrderAsync(outcome.OrderNumber); + var entries = track.OrderDetails?.DomainVerification?.GetDomainEntries(); + if (entries != null) + outcome.RegisteredDomains = entries.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase).ToList(); + } + catch (Exception ex) + { + outcome.Detail += $" | TrackOrder failed: {ex.Message}"; + } + + return outcome; + } + + /// Substitutes the generated CN into a variant's placeholder template. + private static string Format(string template, string cn) => template.Replace("{cn}", cn); + + private static string SafeLabel(string label) => + new string(label.ToLowerInvariant().Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray()) + .Trim('-'); + + // ------------------------------------------------------------------------- + // The probe + // ------------------------------------------------------------------------- + + [SkippableFact] + public async Task Probe_SanSubmissionBehaviour() + { + IntegrationSkip.IfNotConfigured(_fixture); + Skip.IfNot( + Environment.GetEnvironmentVariable(OptInFlag) == "1", + $"Set {OptInFlag}=1 to run this probe — it places real orders on the configured account."); + + var variants = new List<(string Label, Func> Sans, string[] CsrSans)> + { + // 1. Assumption A, positive control: additionalDomains carries an extra DNS + // name. If the extra name comes back registered, additionalDomains works. + ("dns-extra-via-additionalDomains", + cn => new List + { + new SanEntry { Type = "dns", Value = cn }, + new SanEntry { Type = "dns", Value = $"extra1.{cn}" } + }, + new[] { "{cn}", "extra1.{cn}" }), + + // 2. Assumption A, the actual bug: CSR carries both names, additionalDomains + // is omitted entirely. This is what v1.0.1 sent for every UCC enrollment. + // If only the CN comes back registered, the CA does NOT read CSR SANs and + // the diagnosis is confirmed. + ("csr-sans-only-no-additionalDomains", + _ => null, + new[] { "{cn}", "extra2.{cn}" }), + + // 3. Assumption C: primary domainName repeated inside additionalDomains. + // Does the CA reject it, or silently collapse it? + ("cn-duplicated-in-additionalDomains", + cn => new List + { + new SanEntry { Type = "dns", Value = cn }, + new SanEntry { Type = "dns", Value = cn } + }, + new[] { "{cn}" }), + + // 4-6. Assumption B: non-DNS values in additionalDomains. Rejected, ignored, + // or accepted? Each is submitted alongside a valid DNS name so a rejection + // is attributable to the non-DNS value rather than an empty domain set. + ("nondns-email-in-additionalDomains", + cn => new List + { + new SanEntry { Type = "dns", Value = cn }, + new SanEntry { Type = "email", Value = "san-probe@example.com" } + }, + new[] { "{cn}" }), + + ("nondns-ip-in-additionalDomains", + cn => new List + { + new SanEntry { Type = "dns", Value = cn }, + new SanEntry { Type = "ip", Value = "192.0.2.10" } + }, + new[] { "{cn}" }), + + ("nondns-uri-in-additionalDomains", + cn => new List + { + new SanEntry { Type = "dns", Value = cn }, + new SanEntry { Type = "uri", Value = "https://san-probe.example.com/x" } + }, + new[] { "{cn}" }), + }; + + string[] productCodes = + (Environment.GetEnvironmentVariable(ProductCodesFlag) ?? DefaultProductCodes) + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(p => p.Trim()) + .Where(p => p.Length > 0) + .ToArray(); + + var results = new List(); + foreach (string productCode in productCodes) + { + bool unavailable = false; + foreach (var (label, sans, csrSans) in variants) + { + var outcome = await ProbeAsync(productCode, label, sans, csrSans); + results.Add(outcome); + + // Don't burn five more orders proving the same product code is not + // enabled on this account. + if (outcome.ProductUnavailable) + { + unavailable = true; + break; + } + + // Throttle: the sandbox rate-limits order bursts (~16 orders / 10 s). + await Task.Delay(1500); + } + + if (unavailable) + _out.WriteLine($"(product {productCode} is not enabled on this account — skipped)"); + } + + _out.WriteLine("=== CERTInext SAN submission probe ==="); + _out.WriteLine($"ProductCodes probed : {string.Join(", ", productCodes)}"); + _out.WriteLine($"(fixture default : {_fixture.ProductCode})"); + _out.WriteLine(""); + + foreach (var group in results.GroupBy(r => r.ProductCode)) + { + _out.WriteLine($"--- ProductCode {group.Key} ---"); + foreach (var r in group) + { + _out.WriteLine($"[{(r.Accepted ? "ACCEPTED" : "REJECTED")}] {r.Label}"); + _out.WriteLine($" requested (additionalDomains): {(r.RequestedDomains.Count > 0 ? string.Join(", ", r.RequestedDomains) : "(field omitted)")}"); + _out.WriteLine($" detail : {r.Detail}"); + _out.WriteLine($" registeredDomains (TrackOrder): {(r.RegisteredDomains.Count > 0 ? string.Join(", ", r.RegisteredDomains) : "(none reported)")}"); + _out.WriteLine(""); + } + } + + _out.WriteLine("=== How to read this ==="); + _out.WriteLine("registeredDomains is TrackOrder's domainVerification key set — the domains"); + _out.WriteLine("CERTInext put on the order. Compare it against 'requested':"); + _out.WriteLine("(1) vs (2): if (1) registers the extra name and (2) does not, then"); + _out.WriteLine(" additionalDomains is required and CSR SANs alone are ignored."); + _out.WriteLine("(3) : whether repeating the CN is rejected or collapsed."); + _out.WriteLine("(4)-(6) : whether non-DNS values are rejected, ignored, or accepted"); + _out.WriteLine(" AT PLACEMENT TIME. An order accepted here can still be"); + _out.WriteLine(" rejected later during validation/approval."); + + // The probe reports; it does not assert a specific CA behaviour, because its purpose + // is to discover what that behaviour is. What must hold is that at least one UCC + // product was actually exercised — otherwise the run proved nothing and should not + // read as a pass. + var usable = results + .Where(r => !r.ProductUnavailable) + .GroupBy(r => r.ProductCode) + .ToList(); + + Skip.If( + usable.Count == 0, + "None of the probed product codes are enabled on this account " + + $"({string.Join(", ", productCodes)}). Set {ProductCodesFlag} to a Multi-Domain (UCC) " + + "code this account can order."); + + foreach (var group in usable) + { + var control = group.First(r => r.Label == "dns-extra-via-additionalDomains"); + Assert.True( + control.Accepted, + $"Positive control failed on product {group.Key} — could not place even a " + + $"plain DNS UCC order: {control.Detail}"); + } + } + } +} diff --git a/CERTInext.IntegrationTests/SmokeTests.cs b/CERTInext.IntegrationTests/SmokeTests.cs new file mode 100644 index 0000000..8817413 --- /dev/null +++ b/CERTInext.IntegrationTests/SmokeTests.cs @@ -0,0 +1,199 @@ +// Copyright 2024 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// At http://www.apache.org/licenses/LICENSE-2.0 + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// Basic smoke tests — one operation per test, no side effects. + /// These verify the API is reachable and returning sensible data without + /// creating or modifying any orders. + /// + /// All tests skip when CERTInext credentials are absent (). + /// + public class SmokeTests : IClassFixture + { + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + + public SmokeTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + } + + [SkippableFact] + public async Task Ping_Succeeds() + { + IntegrationSkip.IfNotConfigured(_fixture); + + await _fixture.Client.Invoking(c => c.PingAsync()) + .Should().NotThrowAsync("credentials should be valid and API should be reachable"); + } + + [SkippableFact] + public async Task GetProductDetails_ReturnsProducts() + { + IntegrationSkip.IfNotConfigured(_fixture); + + var products = await _fixture.Client.GetProductDetailsAsync(); + + products.Should().NotBeNullOrEmpty("account must have at least one product configured"); + + foreach (var p in products) + _output.WriteLine($" ProductCode={p.ProductCode} Name={p.ProductName} Type={p.ProductType}"); + } + + [SkippableFact] + public async Task ListOrders_ReturnsFirstPage() + { + IntegrationSkip.IfNotConfigured(_fixture); + + var orders = new List(); + + await foreach (var entry in _fixture.Client.ListOrdersAsync(pageSize: 10)) + { + orders.Add(entry); + if (orders.Count >= 10) break; + } + + orders.Should().NotBeEmpty("sandbox account should have at least one order"); + + _output.WriteLine($"Returned {orders.Count} orders (capped at 10):"); + foreach (var o in orders) + _output.WriteLine($" OrderNumber={o.OrderNumber} Domain={o.DomainName} Status={o.CertificateStatus} Expiry={o.CertificateExpiryDate}"); + } + + [SkippableFact] + public async Task TrackOrder_ReturnsDetails() + { + IntegrationSkip.IfNotConfigured(_fixture); + + string orderId = System.Environment.GetEnvironmentVariable("CERTINEXT_ORDER_ID"); + Skip.If(string.IsNullOrWhiteSpace(orderId), + "Set CERTINEXT_ORDER_ID in ~/.env_certinext to run this test."); + + var response = await _fixture.Client.TrackOrderAsync(orderId); + + response.Should().NotBeNull(); + response.OrderDetails.Should().NotBeNull(); + + var od = response.OrderDetails; + _output.WriteLine($"OrderNumber: {orderId}"); + _output.WriteLine($"OrderStatus: {od.OrderStatus} (id={od.OrderStatusId})"); + _output.WriteLine($"CertificateStatus: {od.CertificateStatus} (id={od.CertificateStatusId})"); + _output.WriteLine($"CertificateExpiry: {od.CertificateExpiryDate}"); + _output.WriteLine($"TrackingUrl: {od.TrackingUrl}"); + + if (od.DomainVerification != null) + { + foreach (var kv in od.DomainVerification.GetDomainEntries()) + _output.WriteLine($" Domain [{kv.Key}]: dcvMethod={kv.Value.DcvMethod} dcvStatus={kv.Value.DcvStatus} verifiedDate={kv.Value.VerifiedDate}"); + } + } + + [SkippableFact] + public async Task GetSingleRecord_ReturnsRecord() + { + IntegrationSkip.IfNotConfigured(_fixture); + + string orderId = System.Environment.GetEnvironmentVariable("CERTINEXT_ORDER_ID"); + Skip.If(string.IsNullOrWhiteSpace(orderId), + "Set CERTINEXT_ORDER_ID in ~/.env_certinext to run this test."); + + var plugin = new CERTInextCAPlugin(_fixture.Client, _fixture.Config); + var record = await plugin.GetSingleRecord(orderId); + + record.Should().NotBeNull(); + + _output.WriteLine($"CARequestID: {record.CARequestID}"); + _output.WriteLine($"Status: {record.Status}"); + _output.WriteLine($"Certificate: {(string.IsNullOrWhiteSpace(record.Certificate) ? "(not yet issued)" : record.Certificate[..60] + "...")}"); + } + + /// + /// Exercises against every order + /// returned by ListOrdersAsync. Validates that the per-order plugin + /// code path (TrackOrder → GetCertificate → AnyCAPluginCertificate mapping) + /// succeeds for every order on the account, regardless of certificate status. + /// + [SkippableFact] + public async Task GetSingleRecord_ForAllOrders_AllSucceed() + { + IntegrationSkip.IfNotConfigured(_fixture); + + var plugin = new CERTInextCAPlugin(_fixture.Client, _fixture.Config); + + var orderNumbers = new List(); + await foreach (var entry in _fixture.Client.ListOrdersAsync()) + { + if (!string.IsNullOrWhiteSpace(entry.OrderNumber)) + orderNumbers.Add(entry.OrderNumber); + } + + orderNumbers.Should().NotBeEmpty("sandbox account should have at least one order"); + _output.WriteLine($"Calling GetSingleRecord for {orderNumbers.Count} order(s):"); + + var failures = new List<(string Order, string Error)>(); + foreach (var orderId in orderNumbers) + { + try + { + var record = await plugin.GetSingleRecord(orderId); + string certPreview = string.IsNullOrWhiteSpace(record.Certificate) + ? "(none)" + : $"{record.Certificate.Length} chars"; + _output.WriteLine($" [OK] Order={orderId} Status={record.Status} Cert={certPreview}"); + } + catch (Exception ex) + { + failures.Add((orderId, ex.Message)); + _output.WriteLine($" [FAIL] Order={orderId} Error={ex.Message}"); + } + } + + failures.Should().BeEmpty( + $"every order's GetSingleRecord call should succeed; {failures.Count} failed: " + + string.Join("; ", failures.Select(f => $"{f.Order}={f.Error}"))); + } + + [SkippableFact] + public async Task Synchronize_DumpsAllRecords() + { + IntegrationSkip.IfNotConfigured(_fixture); + + var plugin = new CERTInextCAPlugin(_fixture.Client, _fixture.Config); + + var records = new List(); + var blockingCollection = new System.Collections.Concurrent.BlockingCollection(); + + var syncTask = plugin.Synchronize(blockingCollection, lastSync: null, fullSync: true, cancelToken: default); + var collectTask = Task.Run(() => + { + foreach (var r in blockingCollection.GetConsumingEnumerable()) + records.Add(r); + }); + + await syncTask; + blockingCollection.CompleteAdding(); + await collectTask; + + records.Should().NotBeEmpty("sandbox account should have at least one order"); + + _output.WriteLine($"Synchronized {records.Count} records:"); + foreach (var r in records.Take(20)) + _output.WriteLine($" CARequestID={r.CARequestID} Status={r.Status}"); + + if (records.Count > 20) + _output.WriteLine($" ... and {records.Count - 20} more"); + } + } +} diff --git a/CERTInext.IntegrationTests/StubDomainValidator.cs b/CERTInext.IntegrationTests/StubDomainValidator.cs new file mode 100644 index 0000000..2493021 --- /dev/null +++ b/CERTInext.IntegrationTests/StubDomainValidator.cs @@ -0,0 +1,37 @@ +// Copyright 2024 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// At http://www.apache.org/licenses/LICENSE-2.0 + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Keyfactor.AnyGateway.Extensions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// No-op DNS validator used when Cloudflare credentials are not available. + /// Records are not actually published; DCV verification by CERTInext may or may + /// not succeed depending on whether the sandbox enforces real DNS lookups. + /// + internal sealed class StubDomainValidator : IDomainValidator + { + public void Initialize(IDomainValidatorConfigProvider configProvider) { } + + public Task StageValidation(string key, string value, CancellationToken cancellationToken) => + Task.FromResult(new DomainValidationResult { Success = true }); + + public Task CleanupValidation(string key, CancellationToken cancellationToken) => + Task.FromResult(new DomainValidationResult { Success = true }); + + public Task ValidateConfiguration(Dictionary configuration) => Task.CompletedTask; + public Dictionary GetDomainValidatorAnnotations() => new(); + public string GetValidationType() => "dns-01"; + } + + internal sealed class StubDomainValidatorFactory : IDomainValidatorFactory + { + private readonly IDomainValidator _validator = new StubDomainValidator(); + public IDomainValidator ResolveDomainValidator(string domain, string validationType) => _validator; + } +} diff --git a/CERTInext.IntegrationTests/TESTING.md b/CERTInext.IntegrationTests/TESTING.md index 21e4de1..c17dddd 100644 --- a/CERTInext.IntegrationTests/TESTING.md +++ b/CERTInext.IntegrationTests/TESTING.md @@ -41,7 +41,7 @@ which ones return a `requestNumber` (valid) vs. an error (invalid or not provisi ## Prerequisites -- .NET 8 SDK +- .NET 8 or .NET 10 SDK - Access to a CERTInext sandbox or production account - An API Access Key generated in the CERTInext portal under **Integrations → APIs** @@ -94,6 +94,10 @@ The file is parsed line by line: - Each line must be in `KEY=VALUE` format. - Values are not quoted — do not surround values with `"` or `'`. - Real environment variables override file values (useful for CI injection). +- Exception: the fixture fails fast if the resolved `CERTINEXT_API_URL` lacks `/emSignHub-API` + (a V2 base URL leaked in, issue 0017). Source only `~/.env_certinext` into the shell, never + `~/.env_certinext_v2`. The V2 test classes read that file from disk themselves and never write + V1-shared keys (`CERTINEXT_API_URL`, `CERTINEXT_ACCESS_KEY`, ...) into the process environment. --- @@ -154,6 +158,7 @@ Verifies product discovery. | Test | What it checks | |------|---------------| | `GetProductDetails_ReturnsProducts` | Calls `GetProductDetails`; asserts the call succeeds without throwing; when products are returned, asserts the expected product code from `CERTINEXT_PRODUCT_CODE` is among them | +| `ValidateProductInfo_V1_AcceptsConfiguredProductCode` | (issue 0025) Drives `CERTInextCAPlugin.ValidateProductInfo` (not just the client) in V1 mode with `CERTINEXT_PRODUCT_CODE`; asserts no throw. Skips if not configured or `CERTINEXT_PRODUCT_CODE` unset | Note: some CERTInext accounts return an empty list from `GetProductDetails` even though orders using those product codes are visible in `GetOrderReport`. An empty list is diff --git a/CERTInext.IntegrationTests/UccDcvShapeV2ProbeTests.cs b/CERTInext.IntegrationTests/UccDcvShapeV2ProbeTests.cs new file mode 100644 index 0000000..6374665 --- /dev/null +++ b/CERTInext.IntegrationTests/UccDcvShapeV2ProbeTests.cs @@ -0,0 +1,399 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Read-only investigation probe for issue 0042 (V2 DCV machinery only drives the primary +// domain on a UCC order — issues/0042-v2-ucc-dcv-only-drives-primary-domain.md). Before +// generalizing PerformDcvV2IfNeededAsync to loop over a UCC order's full SAN set, this +// confirms the real wire shape of a live UCC order sitting in pending-dcv: +// +// 1. Does Track Order (GET /api/certinext/v2/{family}/{orderId}) surface per-SAN DCV +// status anywhere (field names/shape), or only ever the single top-level "domain" +// field that V2OrderStatusResponse currently models (CertificateResponseV2.cs:124)? +// 2. Does Get DCV Challenges (GET /api/certinext/v2/ssl-certificates/{orderId}/dcv), +// called once per SAN via its documented optional "domain" query parameter, return a +// distinct challenge token per domain, or does it 404 / error / silently ignore the +// query param for a UCC order? +// +// Targets a specific already-existing live order rather than placing a new one — no +// order-create, CSR-submission, or DCV-verify call is made by this probe. Points at +// order 9295677273 (product 844, DV SSL Multi-Domain UCC; primary +// ucc-0047-09282059.dcv-test.scrup.org, additionalDomains +// ucc-0047-09282059-b.dcv-test.scrup.org / ucc-0047-09282059-c.dcv-test.scrup.org) by +// default via env vars, so this probe can be re-pointed at a different UCC order later +// without editing code. +// +// Raw HTTP only (same idiom as RevocationShapeV2ProbeTests.TrackOrderRawAsync / +// EmailNotificationsV2ProbeTests' raw-response helpers) — deliberately bypasses +// CERTInextClient.TrackOrderV2Async / GetDcvV2Async's typed deserialization (the whole +// point is to see the exact, unmodified response body, including any fields those DTOs +// do not yet model — V2DcvChallengeResponse in particular is already known to have +// diverged from earlier spec examples once before, issues/0037). +// +// Strictly GET-only: Track Order once, then Get DCV Challenges once per domain. No +// verify-DCV, publish, cancel, revoke, or order-create calls of any kind, and no +// retries/polling loops — each call is attempted exactly once and its raw result (success +// or failure) is logged as-is. +// +// Opt-in: requires CERTINEXT_PROBE_UCC_ORDER_ID to be set (skips otherwise — this repo's +// convention for live-API probes). CERTINEXT_PROBE_UCC_DOMAINS (comma-separated) is +// optional; if unset, this falls back to a best-effort scan of the raw Track Order body +// for a primary "domain" field plus a handful of plausible SAN-array field names +// (additionalDomains/domains/sans/sanList/sanDomains/domainList) — logged either way, full +// raw body always printed regardless of what the scan finds. +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text.Json; + using System.Threading; + using System.Threading.Tasks; + using FluentAssertions; + using RestSharp; + using Xunit; + using Xunit.Abstractions; + + public class UccDcvShapeV2ProbeTests : IClassFixture + { + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + private readonly string _v2ApiUrl; + private readonly string _v2ClientId; + private readonly string _v2ClientSecret; + private readonly bool _v2Enabled; + + /// + /// V2 product-family URL path segments to probe, in the same order as + /// CERTInextClient.ResolveV2OrderFamilyAsync (SSL, then Private PKI, then + /// Signature) — an order id is only ever valid within exactly one family. The + /// target order for issue 0042 is a UCC SSL order, so this is expected to resolve + /// on the first entry, but all three are tried for robustness (mirrors + /// RevocationShapeV2ProbeTests.Families). + /// + private static readonly string[] Families = + { + Constants.ApiV2.FamilySsl, + Constants.ApiV2.FamilyPrivatePki, + Constants.ApiV2.FamilySignature + }; + + /// + /// Best-effort candidate field names for a UCC order's SAN list on the raw Track + /// Order body, tried only when CERTINEXT_PROBE_UCC_DOMAINS is unset. None of these + /// are confirmed live — issue 0042 explicitly flags this as unconfirmed — this is + /// purely a diagnostic aid; the full raw body is always logged regardless of what + /// (if anything) this scan finds. + /// + private static readonly string[] CandidateSanArrayFields = + { + "additionalDomains", "domains", "sans", "sanList", "sanDomains", "domainList" + }; + + public UccDcvShapeV2ProbeTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + + var env = V2EnvHelper.LoadAndPromote(); + + _v2ApiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL"); + _v2ClientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID"); + _v2ClientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET"); + + _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API")) + && !string.IsNullOrWhiteSpace(_v2ApiUrl) + && !string.IsNullOrWhiteSpace(_v2ClientId) + && !string.IsNullOrWhiteSpace(_v2ClientSecret); + } + + /// + /// Read-only. Fetches the raw Track Order response for the order named by + /// CERTINEXT_PROBE_UCC_ORDER_ID, then calls the raw Get DCV Challenges endpoint + /// once per domain (CERTINEXT_PROBE_UCC_DOMAINS, or a best-effort scan of the Track + /// Order body if that env var is unset), logging every raw status/body verbatim. + /// Makes no mutating call of any kind. Passes as long as the Track Order call + /// itself succeeds — the actual DCV-shape findings are reported via the logged raw + /// bodies, not asserted, since the whole point of this probe is that the shape is + /// currently unknown. + /// + [SkippableFact] + public async Task UccDcvShape_V2_TrackOrderAndGetDcvChallengesRawJson() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + string orderId = Environment.GetEnvironmentVariable("CERTINEXT_PROBE_UCC_ORDER_ID"); + Skip.If(string.IsNullOrWhiteSpace(orderId), + "CERTINEXT_PROBE_UCC_ORDER_ID not set — this probe is opt-in only and targets a " + + "specific live UCC order (issue 0042). Skipping."); + + _output.WriteLine("=== Issue 0042 live probe: V2 UCC order DCV wire shape ==="); + _output.WriteLine($"OrderId={orderId}"); + + // --- 1. Track Order (raw) --- + var track = await TrackOrderRawAsync(orderId); + + track.Should().NotBeNull( + $"order {orderId} must resolve to one of the known V2 product families (ssl/private-pki/signature)"); + + _output.WriteLine(""); + _output.WriteLine($"--- Track Order response --- Family={track.Family} HTTP={track.StatusCode}"); + _output.WriteLine("Raw body:"); + _output.WriteLine(track.Body); + + track.Body.Should().NotBeNullOrWhiteSpace( + "the raw Track Order response for a known live order must have a non-empty body"); + + // --- 2. Resolve the domain list to probe --- + string domainsEnv = Environment.GetEnvironmentVariable("CERTINEXT_PROBE_UCC_DOMAINS"); + List domains; + if (!string.IsNullOrWhiteSpace(domainsEnv)) + { + domains = domainsEnv + .Split(',') + .Select(d => d.Trim()) + .Where(d => !string.IsNullOrWhiteSpace(d)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + _output.WriteLine(""); + _output.WriteLine($"Domains from CERTINEXT_PROBE_UCC_DOMAINS: {string.Join(", ", domains)}"); + } + else + { + domains = DeriveDomainsFromTrackOrderBody(track.Body, out string derivationNote); + _output.WriteLine(""); + _output.WriteLine("CERTINEXT_PROBE_UCC_DOMAINS not set — derived from raw Track Order body instead."); + _output.WriteLine(derivationNote); + _output.WriteLine(domains.Count > 0 + ? $"Derived domain(s): {string.Join(", ", domains)}" + : "No domain(s) could be derived — see the full Track Order body above."); + } + + if (domains.Count == 0) + { + _output.WriteLine(""); + _output.WriteLine("No domains to probe against Get DCV Challenges — stopping after Track Order."); + return; + } + + // --- 3. Get DCV Challenges (raw), once per domain, exactly one call each --- + foreach (string domain in domains) + { + var dcv = await GetDcvChallengesRawAsync(orderId, domain, track.Family); + + _output.WriteLine(""); + _output.WriteLine($"--- Get DCV Challenges response --- Domain={domain} HTTP={dcv.StatusCode}"); + _output.WriteLine("Raw body:"); + _output.WriteLine(dcv.Body); + } + + _output.WriteLine(""); + _output.WriteLine("=== End of issue 0042 probe. See the raw bodies above for the live DCV wire shape. ==="); + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private sealed class RawV2Response + { + public string Family { get; set; } + public int StatusCode { get; set; } + public string Body { get; set; } + } + + /// + /// 120s timeout — matches CERTInextClient's own V1/V2 RestClientOptions and this + /// repo's other V2 probes (e.g. EmailNotificationsV2ProbeTests.NewApiClient) — this + /// sandbox has been observed to occasionally exceed the framework default HttpClient + /// timeout (100s) on some V2 endpoints. + /// + private static RestClient NewApiClient(string baseUrl) => + new RestClient(new RestClientOptions(baseUrl) { Timeout = TimeSpan.FromSeconds(120) }); + + /// + /// Same OAuth2 client_credentials token-fetch idiom as this project's other V2 + /// probes (RevocationShapeV2ProbeTests / EmailNotificationsV2ProbeTests / + /// IdempotencyKeyV2ProbeTests / OrganizationBlockV2ProbeTests). The token itself is + /// never logged by this file — only used to set the Authorization header on the + /// raw requests below. + /// + private async Task GetV2AccessTokenAsync() + { + string tokenUrl = _v2ApiUrl.TrimEnd('/') + "/oauth/token"; + using var tokenClient = NewApiClient(tokenUrl); + var tokenReq = new RestRequest(string.Empty, Method.Post); + tokenReq.AddHeader("Content-Type", "application/x-www-form-urlencoded"); + tokenReq.AddParameter("grant_type", "client_credentials"); + tokenReq.AddParameter("client_id", _v2ClientId); + tokenReq.AddParameter("client_secret", _v2ClientSecret); + var tokenResp = await tokenClient.ExecuteAsync(tokenReq); + if (!tokenResp.IsSuccessful || string.IsNullOrWhiteSpace(tokenResp.Content)) + throw new Exception($"Token request failed: {(int)tokenResp.StatusCode}"); + + using var tokenDoc = JsonDocument.Parse(tokenResp.Content); + return tokenDoc.RootElement.GetProperty("access_token").GetString(); + } + + /// + /// Raw HTTP GET against each V2 product-family Track Order path in turn, stopping + /// at the first non-404 response — same algorithm as + /// CERTInextClient.ResolveV2OrderFamilyAsync / this project's + /// RevocationShapeV2ProbeTests.TrackOrderRawAsync, but deliberately bypasses + /// CERTInextClient.TrackOrderV2Async's typed deserialization so the exact + /// unparsed response body can be captured. Returns null if the order is not found + /// in any of the three families. + /// + private async Task TrackOrderRawAsync(string orderId) + { + string accessToken = await GetV2AccessTokenAsync(); + + using var apiClient = NewApiClient(_v2ApiUrl.TrimEnd('/')); + foreach (string family in Families) + { + var req = new RestRequest($"/api/certinext/v2/{family}/{orderId}", Method.Get); + req.AddHeader("Authorization", $"Bearer {accessToken}"); + req.AddHeader("Accept", "application/json"); + var resp = await apiClient.ExecuteAsync(req); + + if ((int)resp.StatusCode == 404) + continue; + + return new RawV2Response + { + Family = family, + StatusCode = (int)resp.StatusCode, + Body = resp.Content + }; + } + return null; + } + + /// + /// Raw HTTP GET against the V2 "Get DCV Challenges" endpoint + /// (GET /api/certinext/v2/ssl-certificates/{orderId}/dcv?domain={domain}) for a + /// single domain, within the already-resolved product family. Deliberately + /// bypasses CERTInextClient.GetDcvV2Async (which never sends a "domain" + /// query parameter today — issue 0042) so this probe can send it explicitly and + /// observe the CA's raw response verbatim, whatever it turns out to be. Does not + /// throw on a non-success status — the raw status/body is exactly what this probe + /// needs either way. + /// + private async Task GetDcvChallengesRawAsync(string orderId, string domain, string family) + { + string accessToken = await GetV2AccessTokenAsync(); + + using var apiClient = NewApiClient(_v2ApiUrl.TrimEnd('/')); + var req = new RestRequest($"/api/certinext/v2/{family}/{orderId}/dcv", Method.Get); + req.AddHeader("Authorization", $"Bearer {accessToken}"); + req.AddHeader("Accept", "application/json"); + req.AddQueryParameter("domain", domain); + var resp = await apiClient.ExecuteAsync(req); + + string body = resp.Content; + if (string.IsNullOrEmpty(body) && !resp.IsSuccessful) + { + body = resp.ErrorException != null + ? $"" + : $""; + } + + return new RawV2Response + { + Family = family, + StatusCode = (int)resp.StatusCode, + Body = body + }; + } + + /// + /// Best-effort scan of a raw Track Order JSON body for a domain list to probe + /// against Get DCV Challenges, used only when CERTINEXT_PROBE_UCC_DOMAINS is + /// unset. Collects the single top-level "domain" field (the only SAN-related field + /// currently models) plus, if present, any of + /// as a string array. None of the array field + /// names are confirmed live for issue 0042 — this is purely a diagnostic + /// convenience; the caller always logs the full raw body regardless of this + /// method's result. + /// + private static List DeriveDomainsFromTrackOrderBody(string body, out string derivationNote) + { + var domains = new List(); + var notes = new List(); + + if (string.IsNullOrWhiteSpace(body)) + { + derivationNote = "Track Order body was empty — nothing to derive from."; + return domains; + } + + try + { + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + + if (root.TryGetProperty("domain", out var domainEl) && domainEl.ValueKind == JsonValueKind.String) + { + string primary = domainEl.GetString(); + if (!string.IsNullOrWhiteSpace(primary)) + { + domains.Add(primary); + notes.Add($"Found top-level \"domain\"=\"{primary}\"."); + } + } + else + { + notes.Add("No top-level \"domain\" string field found."); + } + + foreach (string field in CandidateSanArrayFields) + { + if (root.TryGetProperty(field, out var arrEl) && arrEl.ValueKind == JsonValueKind.Array) + { + var found = arrEl.EnumerateArray() + .Where(e => e.ValueKind == JsonValueKind.String) + .Select(e => e.GetString()) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .ToList(); + + if (found.Count > 0) + { + notes.Add($"Found array field \"{field}\" with {found.Count} entrie(s): {string.Join(", ", found)}."); + domains.AddRange(found); + } + else + { + notes.Add($"Array field \"{field}\" is present but empty."); + } + } + } + + if (notes.Count == 1 && domains.Count <= 1) + { + notes.Add( + "None of the candidate SAN-array field names " + + $"({string.Join(", ", CandidateSanArrayFields)}) were found on the Track Order body — " + + "matches issue 0042's finding that V2OrderStatusResponse has no confirmed field for a " + + "UCC order's additional domains yet."); + } + } + catch (JsonException ex) + { + notes.Add($"Track Order body was not valid JSON: {ex.Message}"); + } + + derivationNote = string.Join(" ", notes); + return domains.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + } + } +} diff --git a/CERTInext.IntegrationTests/UccPendingSanOrderProbeTests.cs b/CERTInext.IntegrationTests/UccPendingSanOrderProbeTests.cs new file mode 100644 index 0000000..f609a5b --- /dev/null +++ b/CERTInext.IntegrationTests/UccPendingSanOrderProbeTests.cs @@ -0,0 +1,414 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Live-order support for issue 0042 (V2 DCV machinery only drives the primary domain on a UCC +// order — issues/0042-v2-ucc-dcv-only-drives-primary-domain.md). UccDcvShapeV2ProbeTests is +// strictly GET-only and targets an *existing* order; this file is the one mutating step the +// 0042 investigation needs — placing exactly one V2 DV SSL UCC order whose additionalDomains +// land on a never-before-verified base domain (*.example.com, RFC 2606 reserved), so the SANs +// stay genuinely PENDING for UccDcvShapeV2ProbeTests to inspect, instead of validating +// instantly via base-domain reuse the way order 9295677273's scrup.org SANs did. +// +// Two independent opt-in tests, each gated on its own env var so neither runs by accident: +// +// 1. PlaceUccOrder_V2_PendingSanDcv_ForIssue0042Probe (CERTINEXT_LIVE_UCC_ENROLL=1) — places +// the order. Built with DcvEnabled=false (default) so no inline DCV publish is attempted +// against example.com. Never retries: on any exception (including a client-side timeout, +// which does not prove the order was never created — see v2-api-support-questions.md +// Finding 1/2), it makes exactly one read-only /reports/orders lookup for the same primary +// domain and logs whatever it finds, then rethrows without placing a second order. +// +// 2. CancelOrder_V2_Issue0042Probe (CERTINEXT_CANCEL_ORDER_ID=) — cleanup. Same shape as +// V2LifecycleTests.Revoke_V2_ExplicitOrder_Superseded: one plugin.GetSingleRecord check +// before (skips if already terminal), one raw Cancel Order call (no client method exists +// for this yet — same raw-HTTP idiom as EmailNotificationsV2ProbeTests/ +// IdempotencyKeyV2ProbeTests/OrganizationBlockV2ProbeTests' CancelSslOrderRawAsync, but +// non-throwing so a failed cancel is reported rather than escalated), then one fresh +// plugin.GetSingleRecord check after. No retries either direction. +// +// Run (place): +// set -a; . ~/.env_certinext; set +a +// export CERTINEXT_LIVE_UCC_ENROLL=1 +// dotnet test CERTInext.IntegrationTests -c Release \ +// --filter "FullyQualifiedName~PlaceUccOrder_V2_PendingSanDcv_ForIssue0042Probe" \ +// --logger "console;verbosity=detailed" > /tmp/lab0042/place.log 2>&1 +// +// Run (cancel, after capturing the wire shape via UccDcvShapeV2ProbeTests): +// export CERTINEXT_CANCEL_ORDER_ID= +// dotnet test CERTInext.IntegrationTests -c Release \ +// --filter "FullyQualifiedName~CancelOrder_V2_Issue0042Probe" \ +// --logger "console;verbosity=detailed" > /tmp/lab0042/cancel.log 2>&1 +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + using System; + using System.Collections.Generic; + using System.Text.Json; + using System.Threading; + using System.Threading.Tasks; + using FluentAssertions; + using Keyfactor.AnyGateway.Extensions; + using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; + using Keyfactor.Extensions.CAPlugin.CERTInext.Client; + using Keyfactor.PKI.Enums.EJBCA; + using Org.BouncyCastle.Asn1.X509; + using Org.BouncyCastle.Crypto; + using Org.BouncyCastle.Crypto.Generators; + using Org.BouncyCastle.Pkcs; + using Org.BouncyCastle.Security; + using RestSharp; + using Xunit; + using Xunit.Abstractions; + + public class UccPendingSanOrderProbeTests : IClassFixture + { + private const string LiveEnrollFlag = "CERTINEXT_LIVE_UCC_ENROLL"; + private const string CancelOrderIdFlag = "CERTINEXT_CANCEL_ORDER_ID"; + private const string CancelFamilyFlag = "CERTINEXT_CANCEL_FAMILY"; + + /// + /// V2 catalog product code for "DV SSL Multi-Domain (UCC)" on this sandbox account, as + /// live-confirmed by the 0042 wire-shape probe (order 9295677273, see the issue file's + /// "Live wire shape" section) — NOT the V1-era Constants.Products.DefaultProductCodes + /// value (issue 0036: V1 numbering does not match the live V2 catalog). Overridable via + /// CERTINEXT_UCC_PRODUCT_CODE for re-use against a different account. + /// + private const string DefaultUccProductCode = "844"; + + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + + private readonly string _v2ApiUrl; + private readonly string _v2ClientId; + private readonly string _v2ClientSecret; + private readonly string _uccProductCode; + private readonly bool _v2Enabled; + + public UccPendingSanOrderProbeTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + + var env = V2EnvHelper.LoadAndPromote(); + + _v2ApiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL"); + _v2ClientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID"); + _v2ClientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET"); + _uccProductCode = V2EnvHelper.GetEnv(env, "CERTINEXT_UCC_PRODUCT_CODE", DefaultUccProductCode); + + _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API")) + && !string.IsNullOrWhiteSpace(_v2ApiUrl) + && !string.IsNullOrWhiteSpace(_v2ClientId) + && !string.IsNullOrWhiteSpace(_v2ClientSecret); + } + + // --------------------------------------------------------------------------- + // Shared helpers (deliberately duplicated per this repo's existing convention + // of small per-test-file helpers — see V2UccEnrollmentTests.cs's own comment + // to the same effect — rather than sharing test infrastructure across files). + // --------------------------------------------------------------------------- + + /// + /// DcvEnabled is fixed false — this probe must not attempt to publish a DNS-01 TXT + /// record for any *.example.com SAN (there is no real DNS provider for it, and the + /// whole point is to observe the CA's PENDING challenge shape, not drive it to + /// issuance). Matches this repo's documented default build (DcvSupport=false on this + /// branch) — see CLAUDE.md. + /// + private CERTInextConfig BuildV2Config() + { + return new CERTInextConfig + { + ApiUrl = _v2ApiUrl, + UseV2Api = true, + OAuthClientId = _v2ClientId, + OAuthClientSecret = _v2ClientSecret, + + RequestorName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test", + RequestorEmail = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "0000000000", + SignerPlace = "Gateway Lab", + SignerIp = "127.0.0.1", + + PageSize = 100, + + DcvEnabled = false + }; + } + + private CERTInextCAPlugin BuildV2Plugin(CERTInextConfig config = null) + { + config ??= BuildV2Config(); + var client = new CERTInextClient(config); + return new CERTInextCAPlugin(client, config); + } + + /// Generates a fresh RSA-2048 PKCS#10 CSR for the given CN using BouncyCastle only. + private static string GenerateCsrPem(string commonName) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + var keyPair = keyGen.GenerateKeyPair(); + + var subject = new X509Name($"CN={commonName}"); + var csr = new Pkcs10CertificationRequest("SHA256withRSA", subject, keyPair.Public, null, keyPair.Private); + + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + // --------------------------------------------------------------------------- + // 1. Place the order + // --------------------------------------------------------------------------- + + /// + /// Places exactly one V2 DV SSL UCC order: primary domain on the always-verified + /// dcv-test.scrup.org base domain (so the order itself places cleanly), additionalDomains + /// on two fresh *.example.com subdomains that have never been validated on this account + /// and cannot be (no real DNS provider is wired for RFC 2606 reserved space) — so they + /// stay PENDING for UccDcvShapeV2ProbeTests to inspect. Never retries: a caught exception + /// (including a client-side timeout — see v2-api-support-questions.md Finding 2's + /// "Secondary observation") triggers exactly one read-only orders-report lookup for the + /// same primary domain, logs whatever it finds, and rethrows. + /// + [SkippableFact] + public async Task PlaceUccOrder_V2_PendingSanDcv_ForIssue0042Probe() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + Skip.If(Environment.GetEnvironmentVariable(LiveEnrollFlag) != "1", + $"Set {LiveEnrollFlag}=1 to run this probe — it places one real UCC order (issue 0042). " + + "See this file's header comment for the full run recipe."); + + string stamp = DateTime.UtcNow.ToString("yyyyMMddHHmm"); + string primary = $"ucc0042-{stamp}.dcv-test.scrup.org"; + string sanA = $"a.pending0042-{stamp}.example.com"; + string sanB = $"b.pending0042-{stamp}.example.com"; + + var config = BuildV2Config(); + var plugin = BuildV2Plugin(config); + + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.DvSslUcc, + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [Constants.EnrollmentParam.ProductCode] = _uccProductCode + } + }; + + _output.WriteLine("=== Issue 0042 live probe: placing one V2 UCC order ==="); + _output.WriteLine($"Primary domain: {primary}"); + _output.WriteLine($"Additional domain A: {sanA}"); + _output.WriteLine($"Additional domain B: {sanB}"); + _output.WriteLine($"ProductCode: {_uccProductCode}"); + + EnrollmentResult result; + try + { + result = await plugin.Enroll( + csr: GenerateCsrPem(primary), + subject: $"CN={primary}", + san: new Dictionary { ["dns"] = new[] { sanA, sanB } }, + productInfo: productInfo, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + } + catch (Exception ex) + { + _output.WriteLine(""); + _output.WriteLine($"Enroll threw: {ex.GetType().Name}: {ex.Message}"); + _output.WriteLine( + "Per issue 0042's no-retry rule: NOT placing a second order. A client-side error " + + "(including a timeout) does not prove no order was created server-side — checking " + + "the read-only orders report once for a same-domain match before giving up."); + + try + { + string today = DateTime.UtcNow.ToString("yyyy-MM-dd"); + var rawClient = new CERTInextClient(config); + var matches = new List(); + await foreach (var entry in rawClient.ListOrdersV2Async(from: today, to: today, ct: CancellationToken.None)) + { + if (string.Equals(entry.DomainName, primary, StringComparison.OrdinalIgnoreCase)) + matches.Add(entry); + } + + if (matches.Count == 0) + { + _output.WriteLine($"No order for domain '{primary}' found in today's orders report."); + } + else + { + foreach (var m in matches) + { + _output.WriteLine( + $"FOUND despite the exception above: OrderNumber={m.OrderNumber}, " + + $"OrderStatus={m.OrderStatus}, CertificateStatus={m.CertificateStatus}, " + + $"Domain={m.DomainName}. This order must be accounted for (report/cancel) " + + "even though Enroll() itself threw."); + } + } + } + catch (Exception lookupEx) + { + _output.WriteLine($"Read-only orders-report lookup also failed: {lookupEx.Message}"); + } + + throw; + } + + result.Should().NotBeNull(); + result.CARequestID.Should().NotBeNullOrWhiteSpace( + "V2 Enroll must return a non-empty CARequestID even for a UCC order with pending SANs"); + + _output.WriteLine(""); + _output.WriteLine("=== Order placed ==="); + _output.WriteLine($"CARequestID (OrderId): {result.CARequestID}"); + _output.WriteLine($"Status: {result.Status}"); + _output.WriteLine($"StatusMessage: {result.StatusMessage}"); + _output.WriteLine(""); + _output.WriteLine("Next: capture the wire shape with UccDcvShapeV2ProbeTests:"); + _output.WriteLine($" CERTINEXT_PROBE_UCC_ORDER_ID={result.CARequestID}"); + _output.WriteLine($" CERTINEXT_PROBE_UCC_DOMAINS={primary},{sanA},{sanB}"); + _output.WriteLine("Then clean up with CancelOrder_V2_Issue0042Probe:"); + _output.WriteLine($" CERTINEXT_CANCEL_ORDER_ID={result.CARequestID}"); + } + + // --------------------------------------------------------------------------- + // 2. Cancel (cleanup) — same shape as V2LifecycleTests.Revoke_V2_ExplicitOrder_Superseded + // --------------------------------------------------------------------------- + + /// + /// Cleanup for the order placed above. One plugin.GetSingleRecord check before (skips if + /// the order is already GENERATED/REVOKED — a terminal state Cancel Order does not apply + /// to), one raw Cancel Order call (no ICERTInextClient method exists for this V2 + /// endpoint yet — same non-throwing raw-HTTP idiom as + /// EmailNotificationsV2ProbeTests.CancelOrderRawAsync), and one fresh + /// plugin.GetSingleRecord check after. Never retries the cancel call itself; a failed + /// cancel is reported as-is and the order is left in place, per issue 0042's runbook. + /// + [SkippableFact] + public async Task CancelOrder_V2_Issue0042Probe() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + string orderId = Environment.GetEnvironmentVariable(CancelOrderIdFlag); + Skip.If(string.IsNullOrWhiteSpace(orderId), $"{CancelOrderIdFlag} not set — skipping."); + + var plugin = BuildV2Plugin(); + + var before = await plugin.GetSingleRecord(orderId); + _output.WriteLine($"Before cancel: CARequestID={orderId}, Status={before?.Status}"); + + Skip.If( + before?.Status == (int)EndEntityStatus.GENERATED || before?.Status == (int)EndEntityStatus.REVOKED, + $"Order '{orderId}' is already terminal (status={before?.Status}) — Cancel Order does not apply; not cancelling."); + + // Optional CERTINEXT_CANCEL_FAMILY (ssl | private-pki | signature, default ssl) selects + // the family's Cancel Order path — each family has its own /{family}/:orderId/cancel. + string family = Environment.GetEnvironmentVariable(CancelFamilyFlag)?.Trim().ToLowerInvariant(); + string familyPath = family switch + { + null or "" or "ssl" => Constants.ApiV2.SslCertificatesPath, + "private-pki" => Constants.ApiV2.PrivatePkiCertificatesPath, + "signature" => Constants.ApiV2.SignatureCertificatesPath, + _ => throw new ArgumentException($"{CancelFamilyFlag} must be ssl, private-pki or signature (got '{family}').") + }; + _output.WriteLine($"Cancel path: {familyPath}/{orderId}/cancel"); + + var cancelResp = await CancelOrderRawAsync( + familyPath, orderId, "Keyfactor plugin live probe cleanup."); + + _output.WriteLine(""); + _output.WriteLine($"Cancel response: HTTP {cancelResp.StatusCode}, IsSuccessful={cancelResp.IsSuccessful}"); + _output.WriteLine($"Cancel response body: {cancelResp.Body}"); + + if (!cancelResp.IsSuccessful) + { + _output.WriteLine( + "Cancel did not succeed. Per issue 0042's runbook: NOT escalating to any other " + + "destructive call. Leaving the order as-is; see the response above for the exact detail."); + } + + var after = await plugin.GetSingleRecord(orderId); + _output.WriteLine(""); + _output.WriteLine($"After cancel: Status={after?.Status}, RevocationDate={after?.RevocationDate:o}"); + } + + // --------------------------------------------------------------------------- + // Raw HTTP helpers for the one V2 operation with no ICERTInextClient method yet + // (Cancel Order) — same idiom as EmailNotificationsV2ProbeTests/ + // IdempotencyKeyV2ProbeTests/OrganizationBlockV2ProbeTests' CancelSslOrderRawAsync. + // --------------------------------------------------------------------------- + + private sealed class RawApiResponse + { + public int StatusCode { get; set; } + public string Body { get; set; } + public bool IsSuccessful { get; set; } + } + + private static RestClient NewApiClient(string baseUrl) => + new RestClient(new RestClientOptions(baseUrl) { Timeout = TimeSpan.FromSeconds(120) }); + + private async Task GetV2AccessTokenAsync() + { + string tokenUrl = _v2ApiUrl.TrimEnd('/') + "/oauth/token"; + using var tokenClient = NewApiClient(tokenUrl); + var tokenReq = new RestRequest(string.Empty, Method.Post); + tokenReq.AddHeader("Content-Type", "application/x-www-form-urlencoded"); + tokenReq.AddParameter("grant_type", "client_credentials"); + tokenReq.AddParameter("client_id", _v2ClientId); + tokenReq.AddParameter("client_secret", _v2ClientSecret); + var tokenResp = await tokenClient.ExecuteAsync(tokenReq); + if (!tokenResp.IsSuccessful || string.IsNullOrWhiteSpace(tokenResp.Content)) + throw new Exception($"Token request failed: {(int)tokenResp.StatusCode}"); + + using var tokenDoc = JsonDocument.Parse(tokenResp.Content); + return tokenDoc.RootElement.GetProperty("access_token").GetString(); + } + + /// + /// Raw HTTP POST to {familyPath}/{orderId}/cancel. Returns the raw + /// status/body instead of throwing on non-2xx, so a failed cancel can be reported without + /// losing detail and without the caller needing to catch an exception to see it. + /// + private async Task CancelOrderRawAsync(string familyPath, string orderId, string reason) + { + string accessToken = await GetV2AccessTokenAsync(); + + using var apiClient = NewApiClient(_v2ApiUrl.TrimEnd('/')); + var cancelReq = new RestRequest($"{familyPath}/{orderId}/cancel", Method.Post); + cancelReq.AddHeader("Authorization", $"Bearer {accessToken}"); + cancelReq.AddHeader("Accept", "application/json"); + cancelReq.AddJsonBody(new { reason }); + var cancelResp = await apiClient.ExecuteAsync(cancelReq); + + string body = cancelResp.Content; + if (string.IsNullOrEmpty(body) && !cancelResp.IsSuccessful) + { + body = cancelResp.ErrorException != null + ? $"" + : $""; + } + + return new RawApiResponse + { + StatusCode = (int)cancelResp.StatusCode, + Body = body, + IsSuccessful = cancelResp.IsSuccessful + }; + } + } +} diff --git a/CERTInext.IntegrationTests/UnmodeledSpecFeaturesV2ProbeTests.cs b/CERTInext.IntegrationTests/UnmodeledSpecFeaturesV2ProbeTests.cs new file mode 100644 index 0000000..e28109a --- /dev/null +++ b/CERTInext.IntegrationTests/UnmodeledSpecFeaturesV2ProbeTests.cs @@ -0,0 +1,274 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// Issue 0039 triage probes — READ-ONLY. Every call here is a GET; nothing is placed, + /// cancelled, revoked or modified. + /// + /// + /// — for every product in this + /// account's V2 catalog, calls the spec's "Get Custom Fields for Product" + /// (GET /catalog/products/{code}/custom-fields) and reports whether any field is + /// isMandatory="1" (the EMS-918 "Additional information missing" risk). + /// — reads the ledger statement + /// and the orders report and reports, per order status, how many orders have a ledger + /// row. Answers "does an order the plugin abandons (pending/cancelled) get billed?". + /// Only counts and column names are printed — no amounts or invoice numbers. + /// + /// + /// Gated by CERTINEXT_0039_PROBE=1 in addition to CERTINEXT_USE_V2_API + V2 + /// credentials (loaded from ~/.env_certinext_v2 via ). + /// + /// set -a; . ~/.env_certinext; set +a + /// export CERTINEXT_USE_V2_API=1 CERTINEXT_0039_PROBE=1 + /// dotnet test CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj -c Release \ + /// --filter "FullyQualifiedName~UnmodeledSpecFeaturesV2Probe" \ + /// --logger "console;verbosity=detailed" > /tmp/probe0039.log 2>&1 + /// + /// + public class UnmodeledSpecFeaturesV2ProbeTests : IClassFixture + { + private const string ProbeFlag = "CERTINEXT_0039_PROBE"; + private const int MaxPages = 5; + private const int PageSize = 100; + + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + private readonly string _v2ApiUrl; + private readonly string _v2ClientId; + private readonly string _v2ClientSecret; + private readonly bool _probeEnabled; + + public UnmodeledSpecFeaturesV2ProbeTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + + var env = V2EnvHelper.LoadAndPromote(); + _v2ApiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL"); + _v2ClientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID"); + _v2ClientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET"); + + bool v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API")) + && !string.IsNullOrWhiteSpace(_v2ApiUrl) + && !string.IsNullOrWhiteSpace(_v2ClientId) + && !string.IsNullOrWhiteSpace(_v2ClientSecret); + _probeEnabled = v2Enabled && V2EnvHelper.GetEnv(env, ProbeFlag) == "1"; + } + + [SkippableFact] + public async Task CustomFields_V2_PerCatalogProduct() + { + Skip.IfNot(_probeEnabled, $"{ProbeFlag}=1 not set (or V2 not enabled) — skipping 0039 custom-fields probe."); + + using var client = BuildV2Client(); + var catalog = await client.GetProductDetailsV2Async(); + _output.WriteLine($"Catalog products (groupNumber scoped={!string.IsNullOrWhiteSpace(_fixture.GroupNumber)}): {catalog.Count}"); + + int withMandatory = 0; + foreach (var product in catalog.Where(p => !string.IsNullOrWhiteSpace(p.ProductCode)) + .GroupBy(p => p.ProductCode).Select(g => g.First())) + { + string path = $"/api/certinext/v2/catalog/products/{Uri.EscapeDataString(product.ProductCode)}/custom-fields"; + var (status, _, content) = await client.ProbeV2GetAsync(path); + string header = $"[{product.ProductCode}] typeId={product.ProductTypeId} name='{product.ProductName}'"; + + if (status != 200 || string.IsNullOrWhiteSpace(content)) + { + _output.WriteLine($"{header} -> HTTP {status}; body: {Truncate(content, 300)}"); + continue; + } + + var fields = new List<(string Group, string Name, string FieldId, string Type, string Mandatory)>(); + string shape; + try + { + using var doc = JsonDocument.Parse(content); + shape = DescribeShape(doc.RootElement); + CollectFields(doc.RootElement, "(root)", fields); + } + catch (JsonException jex) + { + _output.WriteLine($"{header} -> HTTP 200 but non-JSON body ({jex.Message}): {Truncate(content, 300)}"); + continue; + } + + var mandatory = fields.Where(f => f.Mandatory == "1").ToList(); + if (mandatory.Count > 0) withMandatory++; + _output.WriteLine($"{header} -> HTTP 200 shape={shape} fields={fields.Count} mandatory={mandatory.Count}"); + foreach (var f in fields) + _output.WriteLine($" group={f.Group} name='{f.Name}' fieldId={f.FieldId ?? "(none)"} type={f.Type ?? "(none)"} isMandatory={f.Mandatory ?? "(absent)"}"); + if (fields.Count == 0) + _output.WriteLine($" raw: {Truncate(content, 400)}"); + } + + _output.WriteLine($"FINDING: {withMandatory} catalog product(s) report at least one isMandatory=\"1\" custom field."); + } + + [SkippableFact] + public async Task Ledger_V2_OrderStatusCrossReference() + { + Skip.IfNot(_probeEnabled, $"{ProbeFlag}=1 not set (or V2 not enabled) — skipping 0039 ledger probe."); + + using var client = BuildV2Client(); + + var ledgerOrderIds = new HashSet(StringComparer.OrdinalIgnoreCase); + var ledgerKeys = new SortedSet(); + int ledgerRows = 0; + for (int page = 1; page <= MaxPages; page++) + { + var (status, _, content) = await client.ProbeV2GetAsync($"/api/certinext/v2/reports/ledger?page={page}&size={PageSize}"); + if (status != 200) + { + _output.WriteLine($"Ledger page {page}: HTTP {status}; body: {Truncate(content, 300)}"); + break; + } + using var doc = JsonDocument.Parse(content); + if (page == 1) + { + var root = doc.RootElement; + string envelope = root.ValueKind == JsonValueKind.Object + ? string.Join(", ", root.EnumerateObject().Select(p => + p.Value.ValueKind == JsonValueKind.Array ? $"{p.Name}:array[{p.Value.GetArrayLength()}]" + : p.Value.ValueKind == JsonValueKind.Number ? $"{p.Name}={p.Value}" + : $"{p.Name}:{p.Value.ValueKind}")) + : root.ValueKind.ToString(); + _output.WriteLine($"Ledger page 1: HTTP 200 envelope=[{envelope}]"); + } + if (!doc.RootElement.TryGetProperty("content", out var arr) || arr.ValueKind != JsonValueKind.Array || arr.GetArrayLength() == 0) + break; + foreach (var row in arr.EnumerateArray()) + { + ledgerRows++; + foreach (var p in row.EnumerateObject()) ledgerKeys.Add(p.Name); + foreach (string k in new[] { "orderId", "orderNumber", "requestNumber" }) + if (row.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(v.GetString())) + ledgerOrderIds.Add(v.GetString()); + } + if (doc.RootElement.TryGetProperty("totalPages", out var tp) && tp.TryGetInt32(out int total) && page >= total) + break; + } + _output.WriteLine($"Ledger: rows read={ledgerRows}, distinct order/request ids={ledgerOrderIds.Count}, row keys=[{string.Join(", ", ledgerKeys)}]"); + + var byStatus = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int page = 1; page <= MaxPages; page++) + { + var (status, _, content) = await client.ProbeV2GetAsync($"/api/certinext/v2/reports/orders?page={page}&size={PageSize}"); + if (status != 200) + { + _output.WriteLine($"Orders report page {page}: HTTP {status}; body: {Truncate(content, 300)}"); + break; + } + using var doc = JsonDocument.Parse(content); + if (!doc.RootElement.TryGetProperty("content", out var arr) || arr.ValueKind != JsonValueKind.Array || arr.GetArrayLength() == 0) + break; + foreach (var row in arr.EnumerateArray()) + { + string orderStatus = row.TryGetProperty("orderStatus", out var s) && s.ValueKind == JsonValueKind.String ? s.GetString() : "(none)"; + string certStatus = row.TryGetProperty("certificateStatus", out var c) && c.ValueKind == JsonValueKind.String ? c.GetString() : "(none)"; + string key = $"orderStatus='{orderStatus}' certificateStatus='{certStatus}'"; + bool inLedger = new[] { "orderNumber", "requestNumber" }.Any(k => + row.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.String && ledgerOrderIds.Contains(v.GetString() ?? string.Empty)); + byStatus.TryGetValue(key, out var agg); + byStatus[key] = (agg.Total + 1, agg.InLedger + (inLedger ? 1 : 0)); + } + if (doc.RootElement.TryGetProperty("totalPages", out var tp) && tp.TryGetInt32(out int total) && page >= total) + break; + } + + foreach (var kv in byStatus.OrderByDescending(k => k.Value.Total)) + _output.WriteLine($" {kv.Key}: orders={kv.Value.Total}, withLedgerRow={kv.Value.InLedger}"); + _output.WriteLine("FINDING: see per-status withLedgerRow counts above (ledger ids matched against orderNumber/requestNumber)."); + } + + private static string DescribeShape(JsonElement root) + { + if (root.ValueKind != JsonValueKind.Object) + return root.ValueKind.ToString(); + if (!root.TryGetProperty("customFields", out var cf)) + return $"object[{string.Join(",", root.EnumerateObject().Select(p => p.Name))}]"; + if (cf.ValueKind != JsonValueKind.Array) + return $"customFields:{cf.ValueKind}"; + if (cf.GetArrayLength() == 0) + return "customFields:[] (empty)"; + var first = cf[0]; + bool nested = first.ValueKind == JsonValueKind.Object + && (first.TryGetProperty("certificateInformation", out _) || first.TryGetProperty("additionalInformation", out _)); + return nested ? "customFields:[{certificateInformation,additionalInformation}] (spec description shape)" + : "customFields:[flat rows] (spec example shape)"; + } + + private static void CollectFields(JsonElement el, string group, List<(string, string, string, string, string)> sink) + { + if (el.ValueKind == JsonValueKind.Array) + { + foreach (var item in el.EnumerateArray()) CollectFields(item, group, sink); + return; + } + if (el.ValueKind != JsonValueKind.Object) return; + + bool looksLikeField = el.TryGetProperty("isMandatory", out _) || el.TryGetProperty("fieldId", out _); + if (looksLikeField) + { + sink.Add((group, + Str(el, "name") ?? Str(el, "displayName") ?? Str(el, "fieldName"), + Str(el, "fieldId"), + Str(el, "type"), + Str(el, "isMandatory"))); + return; + } + foreach (var p in el.EnumerateObject()) + if (p.Value.ValueKind == JsonValueKind.Array || p.Value.ValueKind == JsonValueKind.Object) + CollectFields(p.Value, p.Name, sink); + } + + private static string Str(JsonElement el, string name) + { + if (!el.TryGetProperty(name, out var v)) return null; + return v.ValueKind == JsonValueKind.String ? v.GetString() : v.GetRawText(); + } + + private CERTInextClient BuildV2Client() + { + return new CERTInextClient(new CERTInextConfig + { + ApiUrl = _v2ApiUrl, + UseV2Api = true, + OAuthClientId = _v2ClientId, + OAuthClientSecret = _v2ClientSecret, + GroupNumber = _fixture.IsConfigured ? _fixture.GroupNumber : null, + RequestorName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Test", + RequestorEmail = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com", + PageSize = PageSize + }); + } + + private static string Truncate(string value, int maxLength) + { + if (string.IsNullOrEmpty(value) || value.Length <= maxLength) return value; + return value.Substring(0, maxLength) + "...(truncated)"; + } + } +} diff --git a/CERTInext.IntegrationTests/V1FixtureApiUrlGuardTests.cs b/CERTInext.IntegrationTests/V1FixtureApiUrlGuardTests.cs new file mode 100644 index 0000000..5ab11d6 --- /dev/null +++ b/CERTInext.IntegrationTests/V1FixtureApiUrlGuardTests.cs @@ -0,0 +1,179 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections; +using System.IO; +using FluentAssertions; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// Pure offline regression tests for issue 0017 (no live-API dependency, no process-env + /// mutation, so they are safe to run in parallel with every other class): + /// + /// (D) the V1 fixture's guard + /// rejects a V2 base URL with an actionable message that never echoes secrets; + /// (B) never promotes a key the V1 side reads, + /// nor any of the fixture's opt-in-only flags (issue 0058). + /// + /// + public class V1FixtureApiUrlGuardTests + { + private const string V1Url = "https://sandbox-us-api.certinext.io/emSignHub-API"; + private const string V2Url = "https://sandbox-us-api.certinext.io"; + + // ------------------------------------------------------------------------- + // (D) fail-fast guard + // ------------------------------------------------------------------------- + + [Theory] + [InlineData(V1Url)] + [InlineData(V1Url + "/")] + [InlineData("https://api.certinext.io/emsignhub-api/")] // case-insensitive + [InlineData("")] // unconfigured: nothing to check + [InlineData(null)] + public void EnsureV1ApiUrl_V1OrEmptyUrl_DoesNotThrow(string apiUrl) + { + Action act = () => IntegrationTestFixture.EnsureV1ApiUrl(apiUrl, fromProcessEnvironment: false); + act.Should().NotThrow(); + } + + [Theory] + [InlineData(V2Url, true)] + [InlineData(V2Url + "/", false)] + [InlineData("https://sandbox-us-api.certinext.io/v2", true)] + public void EnsureV1ApiUrl_V2BaseUrl_ThrowsActionableMessage(string apiUrl, bool fromProcessEnv) + { + Action act = () => IntegrationTestFixture.EnsureV1ApiUrl(apiUrl, fromProcessEnv); + + var ex = act.Should().Throw().Which; + ex.Message.Should().Contain("CERTINEXT_API_URL") + .And.Contain("/emSignHub-API") + .And.Contain("V2 base URL") + .And.Contain("set -a; . ~/.env_certinext; set +a") + .And.Contain("~/.env_certinext_v2"); + ex.Message.Should().Contain(fromProcessEnv ? "process environment" : "(from ~/.env_certinext)"); + } + + [Fact] + public void EnsureV1ApiUrl_UrlWithUserInfoAndQuery_NeverEchoesThem() + { + Action act = () => IntegrationTestFixture.EnsureV1ApiUrl( + "https://someuser:not-a-real-secret@sandbox-us-api.certinext.io/?token=not-a-real-token", + fromProcessEnvironment: true); + + var ex = act.Should().Throw().Which; + ex.Message.Should().Contain("sandbox-us-api.certinext.io"); + ex.Message.Should().NotContain("someuser") + .And.NotContain("not-a-real-secret") + .And.NotContain("not-a-real-token"); + } + + /// + /// End-to-end offline composition of the 0017 shell-overlay path: a correct V1 file, a + /// V2 CERTINEXT_API_URL in the (simulated) process environment. Real env vars keep + /// precedence (documented behaviour), and the guard then rejects the leaked value — the + /// same two steps the fixture constructor runs before it builds any client. + /// + [Fact] + public void LoadEnvFile_ProcessEnvV2UrlOverridesV1File_GuardRejectsIt() + { + string path = Path.Combine(Path.GetTempPath(), $"certinext-0017-{Guid.NewGuid():N}.env"); + try + { + File.WriteAllLines(path, new[] + { + $"CERTINEXT_API_URL={V1Url}", + "CERTINEXT_ACCESS_KEY=dummy-access-key", + }); + var processEnv = new Hashtable { ["CERTINEXT_API_URL"] = V2Url }; + + var env = IntegrationTestFixture.LoadEnvFile(path, processEnv); + + env["CERTINEXT_API_URL"].Should().Be(V2Url, "real env vars still override the file"); + Action act = () => IntegrationTestFixture.EnsureV1ApiUrl(env["CERTINEXT_API_URL"], true); + act.Should().Throw() + .Which.Message.Should().NotContain("dummy-access-key"); + } + finally + { + File.Delete(path); + } + } + + // ------------------------------------------------------------------------- + // (B) V2EnvHelper no longer promotes V1-shared keys + // ------------------------------------------------------------------------- + + [Fact] + public void PromotableKeys_ExcludesEveryV1Key_KeepsV2OnlyKeys() + { + // Mirrors the key set ~/.env_certinext_v2 defines today (names only). + string[] v2FileKeys = + { + "CERTINEXT_ACCOUNT_NUMBER", "CERTINEXT_API_URL", "CERTINEXT_CF_API_TOKEN", + "CERTINEXT_CF_ZONE_ID", "CERTINEXT_CLIENT_ID", "CERTINEXT_CLIENT_SECRET", + "CERTINEXT_DCV_DOMAIN", "CERTINEXT_GROUP_NUMBER", "CERTINEXT_ORG_NUMBER", + "CERTINEXT_PRODUCT_CODE", "CERTINEXT_REQUESTOR_EMAIL", "CERTINEXT_REQUESTOR_MOBILE", + "CERTINEXT_REQUESTOR_NAME", "CERTINEXT_SIGNER_IP", "CERTINEXT_USE_V2_API", + "certinext_api_url", // case-insensitive match + }; + + var promoted = V2EnvHelper.PromotableKeys(v2FileKeys); + + promoted.Should().BeEquivalentTo( + "CERTINEXT_CLIENT_ID", "CERTINEXT_CLIENT_SECRET", "CERTINEXT_REQUESTOR_MOBILE", + "CERTINEXT_SIGNER_IP", "CERTINEXT_USE_V2_API"); + } + + /// + /// Issue 0058: if a developer ever left one of the fixture's opt-in-only flags (e.g. + /// CERTINEXT_V2_GAP_PROBES, CERTINEXT_PRIVATE_PKI_LIVE) in ~/.env_certinext_v2, it must + /// NOT come back out of — otherwise the first + /// test class constructed in a run reads the flag as unset, then promotes it into real + /// process env, silently arming every later-constructed test class in the same run even + /// though nothing was ever exported in the shell. Covers every flag in + /// , not just the two named in the + /// issue, so a future addition to that set is covered automatically. + /// + [Fact] + public void PromotableKeys_ExcludesEveryOptInOnlyFlag() + { + var promoted = V2EnvHelper.PromotableKeys(IntegrationTestFixture._optInOnlyFlags); + + promoted.Should().BeEmpty( + "every opt-in-only flag must be excluded from V2-file promotion, or a value left " + + "in ~/.env_certinext_v2 could silently arm a later test in the same run"); + } + + [Theory] + [InlineData("CERTINEXT_API_URL")] + [InlineData("CERTINEXT_ACCESS_KEY")] + [InlineData("CERTINEXT_ACCOUNT_NUMBER")] + [InlineData("CERTINEXT_GROUP_NUMBER")] + [InlineData("CERTINEXT_ORG_NUMBER")] + [InlineData("CERTINEXT_PRODUCT_CODE")] + [InlineData("CERTINEXT_REQUESTOR_EMAIL")] + [InlineData("CERTINEXT_REQUESTOR_NAME")] + [InlineData("CERTINEXT_CF_API_TOKEN")] + [InlineData("CERTINEXT_CF_ZONE_ID")] + [InlineData("CERTINEXT_DCV_DOMAIN")] // read from process env by V1 DcvLifecycleTests + public void V1EnvKeys_CoversEveryKeyTheV1SideReads(string key) + { + IntegrationTestFixture.V1EnvKeys.Should().Contain(key); + } + } +} diff --git a/CERTInext.IntegrationTests/V2ApiTests.cs b/CERTInext.IntegrationTests/V2ApiTests.cs new file mode 100644 index 0000000..6bcad75 --- /dev/null +++ b/CERTInext.IntegrationTests/V2ApiTests.cs @@ -0,0 +1,735 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// Integration test stubs for the V2 REST API code path. + /// + /// All tests are gated behind the CERTINEXT_USE_V2_API=1 environment variable + /// and skip gracefully when it is not set, so they are safe to run in CI environments + /// that do not have V2 credentials configured. + /// + /// To run against a live V2 environment: + /// + /// set -a; . ~/.env_certinext; set +a + /// export CERTINEXT_USE_V2_API=1 + /// dotnet test CERTInext.IntegrationTests/ --filter "FullyQualifiedName~V2ApiTests" + /// + /// Note: the shell must source ONLY ~/.env_certinext (never ~/.env_certinext_v2 — + /// see issue 0017); this class loads ~/.env_certinext_v2 itself from disk at + /// test-construction time. + /// + /// Required variables in ~/.env_certinext_v2 (or real env vars): + /// + /// CERTINEXT_API_URL — V2 base URL (e.g. https://sandbox-us-api.certinext.io) + /// CERTINEXT_CLIENT_ID — OAuth2 client ID + /// CERTINEXT_CLIENT_SECRET — OAuth2 client secret + /// CERTINEXT_PRODUCT_CODE — product code for lifecycle test (e.g. 842) + /// CERTINEXT_DCV_DOMAIN — domain for lifecycle test (e.g. dcv-test.example.com) + /// + /// V1 variables (CERTINEXT_API_URL, CERTINEXT_ACCESS_KEY, etc.) are NOT required + /// for V2-mode tests — Synchronize now uses V2 /reports/orders when UseV2Api is true + /// (issues/0022), and V1 credentials are optional in that mode. + /// + public class V2ApiTests : IClassFixture + { + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + private readonly string _v2ApiUrl; + private readonly string _v2ClientId; + private readonly string _v2ClientSecret; + private readonly string _v2ProductCode; + private readonly string _v2Domain; + private readonly bool _v2Enabled; + private readonly string _cfApiToken; + private readonly string _cfZoneId; + private readonly bool _dcvEnabled; + private readonly string _issuedOrderId; + + public V2ApiTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + + // Load ~/.env_certinext_v2 via the shared helper (issues/0017, gap G14 — one env + // loader, not a private copy per test class). V2 file values take priority over + // process env because IntegrationTestFixture may have already promoted the V1 + // CERTINEXT_API_URL (with /emSignHub-API suffix) into process env, and the V2 base + // URL is different. + var env = V2EnvHelper.LoadAndPromote(); + + _v2ApiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL"); + _v2ClientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID"); + _v2ClientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET"); + _v2ProductCode = V2EnvHelper.GetEnv(env, "CERTINEXT_PRODUCT_CODE", "842"); + _v2Domain = V2EnvHelper.GetEnv(env, "CERTINEXT_DCV_DOMAIN", "test.example.com"); + _cfApiToken = V2EnvHelper.GetEnv(env, "CERTINEXT_CF_API_TOKEN"); + _cfZoneId = V2EnvHelper.GetEnv(env, "CERTINEXT_CF_ZONE_ID"); + _issuedOrderId = V2EnvHelper.GetEnv(env, "CERTINEXT_V2_ISSUED_ORDER_ID"); + + _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API")) + && !string.IsNullOrWhiteSpace(_v2ApiUrl) + && !string.IsNullOrWhiteSpace(_v2ClientId) + && !string.IsNullOrWhiteSpace(_v2ClientSecret); + + _dcvEnabled = _v2Enabled + && !string.IsNullOrWhiteSpace(_cfApiToken) + && !string.IsNullOrWhiteSpace(_cfZoneId); + } + + // --------------------------------------------------------------------------- + // V2 Connectivity + // --------------------------------------------------------------------------- + + /// + /// Calls GET /api/certinext/v2/auth/me and verifies a non-empty accountNumber + /// is returned. Skips when CERTINEXT_USE_V2_API is not set. + /// + [SkippableFact] + public async Task Connectivity_V2_Ping() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + using var client = BuildV2Client(); + var me = await client.GetAuthMeV2Async(); + + me.Should().NotBeNull(); + me.AccountNumber.Should().NotBeNullOrEmpty("auth/me must return accountNumber for a valid OAuth2 client"); + me.AuthType.Should().Be("oauth2"); + } + + // --------------------------------------------------------------------------- + // V2 Lifecycle: place order → track → revoke + // --------------------------------------------------------------------------- + + /// + /// Places a V2 SSL order, asserts that the CARequestID starts with "ord_", + /// then revokes the order. + /// Skips when CERTINEXT_USE_V2_API is not set. + /// + [SkippableFact] + public async Task Lifecycle_V2_EnrollTrackRevoke() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + using var client = BuildV2Client(); + + // Place order + var orderReq = BuildStandardOrderRequest(); + var createResp = await client.PlaceOrderV2Async( + Constants.ApiV2.FamilySsl, _v2ProductCode, orderReq); + + createResp.Should().NotBeNull(); + createResp.OrderId.Should().NotBeNullOrEmpty( + "V2 place-order must return a non-empty orderId (sandbox may return numeric IDs rather than 'ord_' prefix)"); + + // Track the order + var (_, trackResp) = await ResolveOrderFamilyAsync(client, createResp.OrderId); + trackResp.OrderId.Should().Be(createResp.OrderId); + trackResp.Status.Should().NotBeNullOrEmpty( + "V2 TrackOrder must return a status for the placed order"); + // Best-effort structural check: this sandbox's TrackOrder response has been + // observed to omit "_links" entirely (see issues/0016), so we log rather than + // hard-fail — the regression we actually guard against is OrderId/Status shape. + if (trackResp.Links?.Self?.Href is string href && !string.IsNullOrWhiteSpace(href)) + _output.WriteLine($"TrackOrder links.self.href: {href}"); + else + _output.WriteLine("TrackOrder response did not include a links.self.href (sandbox may omit _links)."); + + // Note: revoke requires the order to reach 'issued' state first. + // The sandbox processes orders asynchronously, so we only assert enroll + track here. + // A full revoke smoke test requires waiting for issuance (run separately with DCV configured). + } + + // --------------------------------------------------------------------------- + // Synchronize uses V2 /reports/orders when UseV2Api=true (issues/0022) + // --------------------------------------------------------------------------- + + /// + /// Verifies that Synchronize calls V2 /reports/orders (not V1 GetOrderReport) + /// when UseV2Api=true, and succeeds with ZERO V1 credentials configured at all — + /// the hard acceptance criterion from the Phase 4 parent plan. A single + /// now serves both modes (issues/0022 config + /// consolidation), so the V1-only fields (ApiKey/AccountNumber/AuthMode) below are + /// simply never set. + /// + [SkippableFact] + public async Task Sync_UsesV2_WithZeroV1Credentials() + { + Skip.If(!_v2Enabled, "V2 opt-in (CERTINEXT_USE_V2_API) or V2 credentials not configured — skipping."); + + var config = new CERTInextConfig + { + ApiUrl = _v2ApiUrl, + UseV2Api = true, + OAuthClientId = _v2ClientId, + OAuthClientSecret = _v2ClientSecret, + RequestorName = "Keyfactor Test", + RequestorEmail = "test@example.com", + SignerPlace = "Gateway Lab", + SignerIp = "127.0.0.1", + PageSize = 10, + // A small lookback keeps this test's live API call volume bounded — every + // issued row in the window needs a live certificate download (the report + // carries no body), and ResolveAndDownloadCertificateV2Async re-resolves the + // product family via a sequential TrackOrder probe when it isn't already known. + // The DEFAULT 72h lookback margin (Constants.ApiV2.DefaultSyncLookbackHours) is + // always added on top of lastSync regardless of how recent lastSync is, so on a + // busy shared sandbox account even a "last hour" delta sync still touches + // several days of orders unless this is overridden. See issues/0022's "V2 sync + // per-row download cost" note — this is a real, currently-unbounded cost on the + // live path, not just a test-tuning artifact. + V2SyncLookbackHours = 1 + // Deliberately NOT set: ApiKey, AccountNumber, AuthMode, OAuthTokenUrl — all + // V1-only fields. Proving Synchronize succeeds without them is the point of + // this test. + }; + + using var client = new CERTInextClient(config); + var plugin = new CERTInextCAPlugin(client, config); + + var buffer = new BlockingCollection(1000); + // 300s: this shared sandbox has been observed to return 100+ orders even within a + // narrow ~1-2h window (heavy ongoing test activity), and each issued row costs a + // live download plus (when family isn't already known) a family-probe TrackOrder + // call — real, measured durations for comparable scope elsewhere in this class are + // 3-4.5 minutes. See issues/0022's "V2 sync per-row download cost" note — this is a + // genuine current performance characteristic of the live path, not a test artifact. + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(300)); + + await plugin.Synchronize(buffer, DateTime.UtcNow.AddHours(-1), false, cts.Token); + if (!buffer.IsAddingCompleted) + buffer.CompleteAdding(); + + var records = new List(); + foreach (var record in buffer.GetConsumingEnumerable()) + records.Add(record); + + // The delta sync window is narrow (see V2SyncLookbackHours above), so this only + // proves correctness (zero V1 creds, records returned, shape is sane) — not sync + // performance at scale, which issues/0022 flags as a separate, real concern. + records.Should().NotBeEmpty( + "Synchronize must return records via V2 /reports/orders when UseV2Api=true, with zero V1 " + + "credentials configured — an empty result here proves nothing about which code path ran"); + records.Should().OnlyContain(r => !string.IsNullOrWhiteSpace(r.CARequestID)); + + _output.WriteLine( + $"Sync_UsesV2_WithZeroV1Credentials: {records.Count} record(s) returned via V2 /reports/orders, " + + "with no ApiKey/AccountNumber/AuthMode configured."); + } + + // --------------------------------------------------------------------------- + // V2 Product catalogue + // --------------------------------------------------------------------------- + + /// + /// Calls GET /api/certinext/v2/catalog/products and asserts a non-empty list + /// is returned. Skips when CERTINEXT_USE_V2_API is not set. + /// + [SkippableFact] + public async Task GetProductDetails_V2_ReturnsProducts() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + using var client = BuildV2Client(); + List products = await client.GetProductDetailsV2Async(); + + products.Should().NotBeNull("V2 catalog/products must return a non-null list"); + products.Should().NotBeEmpty("V2 catalog/products must return at least one product"); + + // Hard assertion restored (issues/0016 item 1, fixed by issues/0025): the live + // catalog/products response is a nested category envelope, the same shape V1's + // GetProductDetails returns. ParseProductDetailsV2Response now flattens it, so + // every parsed product must carry a non-empty ProductCode. + products.Should().OnlyContain(p => !string.IsNullOrWhiteSpace(p.ProductCode), + "ParseProductDetailsV2Response must flatten the nested category envelope into ProductCode-bearing rows"); + _output.WriteLine($"{products.Count}/{products.Count} catalog products carry a non-empty ProductCode."); + } + + /// + /// Drives (not just the client + /// method) end-to-end in V2 mode against the configured product code — the regression + /// test for issue 0025. Read-only. + /// + [SkippableFact] + public async Task ValidateProductInfo_V2_AcceptsConfiguredProductCode() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + var plugin = new CERTInextCAPlugin(); + var connectionInfo = BuildV2ConnectionInfo(); + var productInfo = new EnrollmentProductInfo + { + ProductID = "ssl", + ProductParameters = new Dictionary { ["ProductCode"] = _v2ProductCode } + }; + + Func act = () => plugin.ValidateProductInfo(productInfo, connectionInfo); + + await act.Should().NotThrowAsync( + $"ProductCode '{_v2ProductCode}' should be present in the live V2 catalog"); + } + + /// + /// Same as but with a + /// product code that should never exist, asserting the same "not found" failure mode + /// V1 has always had. Read-only — no order is placed. + /// + [SkippableFact] + public async Task ValidateProductInfo_V2_RejectsUnknownProductCode() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + var plugin = new CERTInextCAPlugin(); + var connectionInfo = BuildV2ConnectionInfo(); + var productInfo = new EnrollmentProductInfo + { + ProductID = "ssl", + ProductParameters = new Dictionary { ["ProductCode"] = "999999" } + }; + + Func act = () => plugin.ValidateProductInfo(productInfo, connectionInfo); + + await act.Should().ThrowAsync() + .WithMessage("*not found*"); + } + + /// + /// ignores the constructor-injected + /// client/config and builds its own from connectionInfo, so integration tests + /// must pass a real dictionary — UseV2Api is a bool, not a string + /// (CERTInextCAPluginConfig.cs, CERTInextCAPlugin.cs's is bool check). + /// + private Dictionary BuildV2ConnectionInfo() + { + var info = new Dictionary + { + ["UseV2Api"] = true, + ["ApiUrl"] = _v2ApiUrl, + ["OAuthClientId"] = _v2ClientId, + ["OAuthClientSecret"] = _v2ClientSecret + }; + + string groupNumber = _fixture.IsConfigured ? _fixture.GroupNumber : null; + if (!string.IsNullOrWhiteSpace(groupNumber)) + info["GroupNumber"] = groupNumber; + + return info; + } + + // --------------------------------------------------------------------------- + // GetSingleRecord via V2 (ResolveAndTrackOrderV2Async) + // --------------------------------------------------------------------------- + + /// + /// Places a fresh DV SSL order then calls ResolveAndTrackOrderV2Async on the + /// returned orderId. Asserts that the order can be found and has a non-empty + /// status. The order will typically be pending-csr or pending-dcv; that is fine. + /// Skips when CERTINEXT_USE_V2_API is not set. + /// + [SkippableFact] + public async Task GetSingleRecord_V2_ReturnsOrderDetails() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + using var client = BuildV2Client(); + + var orderReq = BuildStandardOrderRequest(); + var createResp = await client.PlaceOrderV2Async( + Constants.ApiV2.FamilySsl, _v2ProductCode, orderReq); + + createResp.Should().NotBeNull(); + string orderId = createResp.OrderId; + orderId.Should().NotBeNullOrEmpty("PlaceOrderV2Async must return a non-empty orderId"); + + var status = await client.ResolveAndTrackOrderV2Async(orderId); + + status.Should().NotBeNull("ResolveAndTrackOrderV2Async must return a non-null status"); + status.OrderId.Should().Be(orderId, "tracked order ID must match the placed order"); + status.Status.Should().NotBeNullOrEmpty("TrackOrder must return a non-empty status string"); + } + + // --------------------------------------------------------------------------- + // Revoke a known-issued V2 order + // --------------------------------------------------------------------------- + + /// + /// Revokes a previously issued V2 order. Prefers CERTINEXT_V2_ISSUED_ORDER_ID; + /// otherwise self-enrolls a fresh order via + /// and polls (bounded) for issuance (V2_TEST_GAP_PLAN.md Phase 1.4b) — so the test + /// no longer depends on another test's run order (issues/0017, gap G7) to have a + /// usable order ID. + /// + [SkippableFact] + public async Task Revoke_V2_IssuedOrder() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + using var client = BuildV2Client(); + var (orderId, family) = await EnsureIssuedOrderIdAsync(client); + + // Revoke — sandbox may report 'issued' via track but reject revocation + // with 422 ("Certificate Request still being processed") while the order + // is still being processed internally (issues/0019). + var revokeReq = new V2RevokeRequest + { + Reason = "superseded", + Note = "V2 integration test cleanup" + }; + + try + { + await client.RevokeOrderV2Async(family, orderId, revokeReq); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("still being processed")) + { + // Retry once after a short delay before giving up — any other exception + // (or a second failure) must fail the test rather than be swallowed here. + _output.WriteLine($"Revoke rejected as still-processing; retrying once after 15s: {ex.Message}"); + await Task.Delay(TimeSpan.FromSeconds(15)); + try + { + await client.RevokeOrderV2Async(family, orderId, revokeReq); + } + catch (InvalidOperationException ex2) when (ex2.Message.Contains("still being processed")) + { + Skip.If(true, + $"Order {orderId} tracked as 'issued' but CA rejected revocation twice (sandbox timing): {ex2.Message}"); + return; // unreachable; satisfies compiler + } + } + + // Re-track — must be revoked + var trackAfter = await client.ResolveAndTrackOrderV2Async(orderId); + trackAfter.Status.Should().Be( + Constants.ApiV2.StatusRevoked, + $"order {orderId} must be 'revoked' after revocation"); + } + + // --------------------------------------------------------------------------- + // DCV flow (publishes real Cloudflare TXT record) — requires SUPPORTS_DCV build + // --------------------------------------------------------------------------- + +#if SUPPORTS_DCV + /// + /// Places a DV SSL order, publishes the DCV TXT token via real Cloudflare DNS, + /// calls VerifyDcvV2Async, and polls until the order leaves pending-dcv. + /// Requires CERTINEXT_CF_API_TOKEN and CERTINEXT_CF_ZONE_ID in addition to + /// CERTINEXT_USE_V2_API. Skips if either is absent. + /// + /// CERTInext's domain DCV is account-scoped and reusable (BR 3.2.2.5): once + /// CERTINEXT_DCV_DOMAIN is verified once, it stays verified for the + /// validTill reuse window, and GetDcv/VerifyDcv return EMS-1080 + /// ("Domain is already verified") instead of issuing a fresh challenge — see + /// issues/0020. That is treated here as the reuse-path outcome, not a failure: + /// the publish/verify steps are skipped and the order is polled directly for + /// leaving pending-dcv. + /// + [SkippableFact] + public async Task DcvFlow_V2_PublishesAndVerifies() + { + Skip.If(!_dcvEnabled, + "DCV test requires CERTINEXT_USE_V2_API + CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID — skipping."); + + using var client = BuildV2Client(); + var dns = new CloudflareDomainValidator(_cfApiToken, _cfZoneId); + string txtKey = null; + string orderId = null; + + var (domainVerifiedBeforeEnroll, rawStatus) = await V2DomainStatusHelper.GetDcvStatusAsync(client, _v2Domain); + _output.WriteLine($"Pre-enroll domain status for '{_v2Domain}': dcvStatus={rawStatus ?? ""}"); + + try + { + // 1. Place a DV SSL order — it lands in pending-dcv + var orderReq = BuildStandardOrderRequest(); + var createResp = await client.PlaceOrderV2Async( + Constants.ApiV2.FamilySsl, _v2ProductCode, orderReq); + orderId = createResp.OrderId; + orderId.Should().NotBeNullOrEmpty(); + + // 2. Get DCV challenge + V2DcvChallengeResponse dcvResp; + try + { + dcvResp = await client.GetDcvV2Async(orderId, Constants.ApiV2.FamilySsl); + } + catch (Exception ex) when (ex.Message.Contains("EMS-1080")) + { + // Reuse path: the domain is already verified account-wide, so there is no + // fresh challenge to publish. Prove the order still reaches a non-pending-dcv + // state without ever staging a TXT record. + _output.WriteLine($"GetDcv returned EMS-1080 (domain already verified) — reuse path: {ex.Message}"); + _output.WriteLine($"(pre-enroll domain probe {(domainVerifiedBeforeEnroll ? "agreed: VERIFIED" : "did NOT show VERIFIED — status may have changed between the probe and this order")}.)"); + + V2OrderStatusResponse reuseStatus = null; + var reuseDeadline = DateTime.UtcNow.AddSeconds(30); + while (DateTime.UtcNow < reuseDeadline) + { + reuseStatus = await client.ResolveAndTrackOrderV2Async(orderId); + _output.WriteLine($"Poll (reuse path): orderId={orderId} status={reuseStatus.Status}"); + if (reuseStatus.Status != Constants.ApiV2.StatusPendingDcv) + break; + await Task.Delay(TimeSpan.FromSeconds(5)); + } + + reuseStatus.Should().NotBeNull(); + reuseStatus!.Status.Should().NotBe( + Constants.ApiV2.StatusPendingDcv, + $"order {orderId} must leave pending-dcv on a reused/already-verified domain (EMS-1080) " + + "without a fresh TXT challenge. If this fails, see issues/0020."); + return; + } + dcvResp.Should().NotBeNull(); + dcvResp.Token.Should().NotBeNullOrEmpty( + "GetDcvV2Async must return a TXT token in Token"); + + // The live response has no domainName field (issues/0037) — the domain is + // already known locally from the order-placement request. + string domainName = _v2Domain; + + // 3. Publish TXT record + txtKey = $"_emudhra-challenge.{domainName}"; + _output.WriteLine($"Publishing TXT {txtKey} = {dcvResp.Token}"); + var staged = await dns.StageValidation(txtKey, dcvResp.Token, CancellationToken.None); + staged.Success.Should().BeTrue($"Cloudflare TXT record creation must succeed: {staged.ErrorMessage}"); + + // Brief propagation pause + await Task.Delay(TimeSpan.FromSeconds(5)); + + // 4. Ask CERTInext to verify + var verifyResp = await client.VerifyDcvV2Async(orderId, _v2Domain, Constants.ApiV2.FamilySsl); + verifyResp.Should().NotBeNull(); + verifyResp.OverallStatus.Should().Be("VERIFIED", + "VerifyDcvV2Async must return OverallStatus=VERIFIED after DNS record is published"); + + // 5. Poll until order leaves pending-dcv (up to 60s) + V2OrderStatusResponse finalStatus = null; + var deadline = DateTime.UtcNow.AddSeconds(60); + while (DateTime.UtcNow < deadline) + { + finalStatus = await client.ResolveAndTrackOrderV2Async(orderId); + _output.WriteLine($"Poll: orderId={orderId} status={finalStatus.Status}"); + if (finalStatus.Status != Constants.ApiV2.StatusPendingDcv) + break; + await Task.Delay(TimeSpan.FromSeconds(5)); + } + + finalStatus.Should().NotBeNull(); + finalStatus!.Status.Should().NotBe( + Constants.ApiV2.StatusPendingDcv, + "order must leave pending-dcv after successful DCV verification"); + } + finally + { + if (txtKey != null) + { + _output.WriteLine($"Cleaning up TXT record: {txtKey}"); + await dns.CleanupValidation(txtKey, CancellationToken.None); + } + } + } +#endif + + // --------------------------------------------------------------------------- + // Chain PEM assembly + // --------------------------------------------------------------------------- + + /// + /// Downloads the certificate for a known-issued V2 order and logs whether + /// ChainPem is populated. The test passes in either case — it is a + /// best-effort diagnostic to confirm chain assembly works in production. + /// Prefers CERTINEXT_V2_ISSUED_ORDER_ID; otherwise self-enrolls a fresh order + /// via (V2_TEST_GAP_PLAN.md Phase 1.4b). + /// + [SkippableFact] + public async Task ChainPem_V2_IsAssembled() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + using var client = BuildV2Client(); + var (orderId, family) = await EnsureIssuedOrderIdAsync(client); + + V2CertificateDownloadResponse downloadResp; + try + { + downloadResp = await client.DownloadCertificateV2Async(family, orderId); + } + catch (Exception ex) when (ex.Message.Contains("422") || ex.Message.Contains("Invalid request status")) + { + Skip.If(true, + $"Order {orderId} tracked as 'issued' but CA rejected download (sandbox timing): {ex.Message}"); + return; // unreachable; satisfies compiler + } + + downloadResp.Should().NotBeNull("DownloadCertificateV2Async must return a non-null response"); + downloadResp.CertificatePem.Should().NotBeNull( + "CertificatePem must be present for an issued order"); + downloadResp.CertificatePem.Should().StartWith( + "-----BEGIN CERTIFICATE-----", + "leaf certificate must be PEM-encoded"); + + bool chainPresent = downloadResp.ChainPem != null && downloadResp.ChainPem.Count > 0; + _output.WriteLine(chainPresent + ? $"ChainPem: {downloadResp.ChainPem!.Count} intermediate(s) returned." + : "ChainPem: null or empty — sandbox may not return chain."); + + if (chainPresent) + { + foreach (string chainCert in downloadResp.ChainPem!) + { + chainCert.Should().StartWith( + "-----BEGIN CERTIFICATE-----", + "each chain entry must be a PEM-encoded certificate"); + } + } + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private V2CreateSslOrderRequest BuildStandardOrderRequest() => + new V2CreateSslOrderRequest + { + ProductVariant = "dv", + EmailNotifications = "all", + Requestor = new V2Requestor + { + Name = _fixture.Config?.RequestorName ?? "Keyfactor Test", + Email = _fixture.Config?.RequestorEmail ?? "test@example.com", + Phone = "0000000000", + Designation = "IT Administrator" + }, + Certificate = new V2CertificateParams + { + Domain = _v2Domain, + AutoSecureWww = false + }, + Subscription = new V2SubscriptionParams + { + ValidityYears = 1, + AutoRenew = false, + RenewBeforeDays = 30 + }, + Agreement = new V2AgreementParams + { + SignerName = _fixture.Config?.RequestorName ?? "Keyfactor Test", + SignerIp = "127.0.0.1", + SignerPlace = "Gateway Lab", + Accepted = true + }, + Remarks = "Keyfactor V2 integration test — safe to revoke immediately." + }; + + private CERTInextClient BuildV2Client() + { + return new CERTInextClient(new CERTInextConfig + { + // A single ApiUrl now serves V2 (issues/0022 config consolidation) — no V1-only + // fields are set here. + ApiUrl = _v2ApiUrl, + UseV2Api = true, + OAuthClientId = _v2ClientId, + OAuthClientSecret = _v2ClientSecret, + RequestorName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Test", + RequestorEmail = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com", + SignerIp = "127.0.0.1", + SignerPlace = "Gateway Lab", + PageSize = 100 + }); + } + + /// + /// Returns an issued V2 order (and the family it lives in) to exercise. Prefers + /// CERTINEXT_V2_ISSUED_ORDER_ID if set; otherwise places a fresh order on + /// and polls (bounded) until it reaches issued, so + /// tests using this helper are self-contained and don't depend on env state or + /// another test's run order (V2_TEST_GAP_PLAN.md Phase 1.4b). Skip.Ifs when + /// no env ID is set and the freshly-placed order never reaches issued within + /// the poll budget — sandboxes may require DCV to auto-issue. + /// + private async Task<(string orderId, string family)> EnsureIssuedOrderIdAsync(CERTInextClient client) + { + if (!string.IsNullOrWhiteSpace(_issuedOrderId)) + { + var (family, status) = await ResolveOrderFamilyAsync(client, _issuedOrderId); + Skip.If(status.Status != Constants.ApiV2.StatusIssued, + $"Order '{_issuedOrderId}' is in '{status.Status}' state, not 'issued' — skipping."); + return (_issuedOrderId, family); + } + + var orderReq = BuildStandardOrderRequest(); + var createResp = await client.PlaceOrderV2Async(Constants.ApiV2.FamilySsl, _v2ProductCode, orderReq); + createResp.Should().NotBeNull(); + string orderId = createResp.OrderId; + orderId.Should().NotBeNullOrEmpty("PlaceOrderV2Async must return a non-empty orderId"); + _output.WriteLine( + $"EnsureIssuedOrderIdAsync: no CERTINEXT_V2_ISSUED_ORDER_ID set — placed fresh order {orderId}."); + + V2OrderStatusResponse trackResp = null; + var deadline = DateTime.UtcNow.AddSeconds(90); + while (DateTime.UtcNow < deadline) + { + trackResp = await client.TrackOrderV2Async(Constants.ApiV2.FamilySsl, orderId); + _output.WriteLine($"EnsureIssuedOrderIdAsync poll: orderId={orderId} status={trackResp.Status}"); + if (trackResp.Status == Constants.ApiV2.StatusIssued) + break; + await Task.Delay(TimeSpan.FromSeconds(15)); + } + + Skip.If(trackResp?.Status != Constants.ApiV2.StatusIssued, + $"Freshly-placed order '{orderId}' did not reach 'issued' within the poll budget " + + $"(status={trackResp?.Status}) — sandbox may require DCV to auto-issue. Set " + + "CERTINEXT_V2_ISSUED_ORDER_ID to a known-issued order to bypass placement."); + + return (orderId, Constants.ApiV2.FamilySsl); + } + + private static async Task<(string family, V2OrderStatusResponse status)> ResolveOrderFamilyAsync( + CERTInextClient client, string orderId) + { + foreach (var family in new[] { Constants.ApiV2.FamilySsl, Constants.ApiV2.FamilyPrivatePki, Constants.ApiV2.FamilySignature }) + { + try + { + var s = await client.TrackOrderV2Async(family, orderId); + return (family, s); + } + catch (KeyNotFoundException) + { + // try next + } + } + throw new KeyNotFoundException($"Order '{orderId}' not found in any V2 product family."); + } + + } +} diff --git a/CERTInext.IntegrationTests/V2DcvLifecycleTests.cs b/CERTInext.IntegrationTests/V2DcvLifecycleTests.cs new file mode 100644 index 0000000..533c405 --- /dev/null +++ b/CERTInext.IntegrationTests/V2DcvLifecycleTests.cs @@ -0,0 +1,637 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if SUPPORTS_DCV +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// Plugin-level DCV integration tests for the V2 (OAuth2) API path (gaps 5-8, 14-15). + /// Mirrors 's structure for V1: uses a real + /// when Cloudflare credentials are + /// configured, otherwise a . + /// + /// Requires the SUPPORTS_DCV build (-p:DcvSupport=true) because it uses + /// the v3.3-only constructor. Excluded from the + /// no-DCV build via the test project's <Compile Remove> item group. + /// + public class V2DcvLifecycleTests : IClassFixture, IDisposable + { + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + private readonly List _toDispose = new List(); + + private readonly string _v2ApiUrl; + private readonly string _v2ClientId; + private readonly string _v2ClientSecret; + private readonly string _v2ProductCode; + private readonly string _v2Domain; + private readonly bool _v2Enabled; + private readonly string _cfApiToken; + private readonly string _cfZoneId; + private readonly bool _dcvEnabled; + + public V2DcvLifecycleTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + + var env = V2EnvHelper.LoadAndPromote(); + + _v2ApiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL"); + _v2ClientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID"); + _v2ClientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET"); + _v2ProductCode = V2EnvHelper.GetEnv(env, "CERTINEXT_PRODUCT_CODE", "842"); + _v2Domain = V2EnvHelper.GetEnv(env, "CERTINEXT_DCV_DOMAIN", "test.example.com"); + _cfApiToken = V2EnvHelper.GetEnv(env, "CERTINEXT_CF_API_TOKEN"); + _cfZoneId = V2EnvHelper.GetEnv(env, "CERTINEXT_CF_ZONE_ID"); + + _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API")) + && !string.IsNullOrWhiteSpace(_v2ApiUrl) + && !string.IsNullOrWhiteSpace(_v2ClientId) + && !string.IsNullOrWhiteSpace(_v2ClientSecret); + + _dcvEnabled = _v2Enabled + && !string.IsNullOrWhiteSpace(_cfApiToken) + && !string.IsNullOrWhiteSpace(_cfZoneId); + } + + public void Dispose() + { + foreach (var d in _toDispose) + d.Dispose(); + _toDispose.Clear(); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private static string GenerateCsrPem(string commonName) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + var keyPair = keyGen.GenerateKeyPair(); + + var subject = new X509Name($"CN={commonName}"); + var csr = new Pkcs10CertificationRequest("SHA256withRSA", subject, keyPair.Public, null, keyPair.Private); + + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + private static async Task> RunSyncAsync( + CERTInextCAPlugin plugin, DateTime? lastSync = null, bool fullSync = true) + { + var buffer = new BlockingCollection(boundedCapacity: 10_000); + var collected = new List(); + + var syncTask = Task.Run(async () => + { + await plugin.Synchronize(buffer, lastSync: lastSync, fullSync: fullSync, cancelToken: CancellationToken.None); + if (!buffer.IsAddingCompleted) + buffer.CompleteAdding(); + }); + + foreach (var record in buffer.GetConsumingEnumerable()) + collected.Add(record); + + await syncTask; + return collected; + } + + private IDomainValidatorFactory BuildV2DnsFactory() + { + if (_dcvEnabled) + { + var factory = new CloudflareDomainValidatorFactory(_cfApiToken, _cfZoneId); + _toDispose.Add(factory); + return factory; + } + return new StubDomainValidatorFactory(); + } + + /// + /// Builds a wired for the V2 API. A single + /// now serves both modes (issues/0022 config + /// consolidation), and V2 auth reuses / + /// . Deliberately omits every V1-only + /// field (ApiKey/AccountNumber/AuthMode) — V1 credentials are optional when UseV2Api + /// is true, including for Synchronize (now V2 /reports/orders — issues/0022). + /// + private CERTInextConfig BuildV2Config( + bool dcvEnabled = true, int propagationDelaySeconds = 5, int? pageSize = null, int? syncLookbackHours = null) + { + return new CERTInextConfig + { + ApiUrl = _v2ApiUrl, + UseV2Api = true, + OAuthClientId = _v2ClientId, + OAuthClientSecret = _v2ClientSecret, + + // Default 72h (Constants.ApiV2.DefaultSyncLookbackHours) is always added on top + // of lastSync — on a busy shared sandbox that makes an un-narrowed delta sync + // slow, since every issued row costs a live certificate download (issues/0022's + // "V2 sync per-row download cost" note). Narrow via syncLookbackHours in tests + // that don't need the full margin. + V2SyncLookbackHours = syncLookbackHours ?? Constants.ApiV2.DefaultSyncLookbackHours, + + RequestorName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test", + RequestorEmail = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "0000000000", + SignerPlace = "Gateway Lab", + SignerIp = "127.0.0.1", + + PageSize = pageSize ?? 100, + + DcvEnabled = dcvEnabled, + DcvPropagationDelaySeconds = propagationDelaySeconds, + DcvTimeoutMinutes = 3 + }; + } + + /// + /// Builds a plugin wired for the V2 API with a real DNS factory injected via the + /// v3.3-only three-arg test constructor, so EnrollV2Async / + /// GetSingleRecordV2Async can drive DCV inline. + /// + private CERTInextCAPlugin BuildV2DcvPlugin( + bool dcvEnabled = true, int propagationDelaySeconds = 5, int? pageSize = null, int? syncLookbackHours = null) + { + var config = BuildV2Config(dcvEnabled, propagationDelaySeconds, pageSize, syncLookbackHours); + var client = new CERTInextClient(config); + return new CERTInextCAPlugin(client, BuildV2DnsFactory(), config); + } + + private EnrollmentProductInfo BuildV2ProductInfo() => + new EnrollmentProductInfo + { + ProductID = _v2ProductCode, + ProductParameters = new Dictionary + { + [Constants.EnrollmentParam.ProductCode] = _v2ProductCode, + [Constants.EnrollmentParam.ProfileId] = _v2ProductCode, + } + }; + + /// + /// Parses an issued certificate PEM and asserts its public key matches the requested + /// algorithm/size. Copy of the equivalent helper in . + /// + private static void AssertIssuedCertMatchesAlgorithm(string certPem, KeyAlgorithmSpec spec, string tag) + { + var b64 = certPem + .Replace("-----BEGIN CERTIFICATE-----", string.Empty) + .Replace("-----END CERTIFICATE-----", string.Empty) + .Replace("\r", string.Empty).Replace("\n", string.Empty).Trim(); + + var cert = new Org.BouncyCastle.X509.X509CertificateParser().ReadCertificate(Convert.FromBase64String(b64)); + cert.Should().NotBeNull($"{tag}: issued cert PEM must parse"); + + var pub = cert.GetPublicKey(); + switch (spec.Kind) + { + case KeyKind.Rsa: + pub.Should().BeOfType(); + ((RsaKeyParameters)pub).Modulus.BitLength.Should().Be(spec.Strength, + $"{tag}: issued RSA cert must have a {spec.Strength}-bit modulus"); + break; + case KeyKind.Ecdsa: + pub.Should().BeOfType(); + ((ECPublicKeyParameters)pub).Parameters.Curve.FieldSize.Should().Be(spec.Strength, + $"{tag}: issued EC cert must use a {spec.Strength}-bit curve"); + break; + case KeyKind.Ed25519: + pub.Should().BeOfType(); + break; + case KeyKind.Ed448: + pub.Should().BeOfType(); + break; + } + } + + // --------------------------------------------------------------------------- + // Gap 5 — Enroll with DCV on, V2 path, does not throw + // --------------------------------------------------------------------------- + + [SkippableFact] + public async Task DcvEnroll_V2_CompletesWithoutThrowing() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + var config = BuildV2Config(dcvEnabled: true); + using var probeClient = new CERTInextClient(config); + var (domainVerified, rawStatus) = await V2DomainStatusHelper.GetDcvStatusAsync(probeClient, _v2Domain); + _output.WriteLine($"Pre-enroll domain status for '{_v2Domain}': dcvStatus={rawStatus ?? ""}"); + + var recordingFactory = new RecordingDomainValidatorFactory(BuildV2DnsFactory()); + var plugin = new CERTInextCAPlugin(new CERTInextClient(config), recordingFactory, config); + + var result = await plugin.Enroll( + csr: GenerateCsrPem(_v2Domain), + subject: $"CN={_v2Domain}", + san: new Dictionary { ["dns"] = new[] { _v2Domain } }, + productInfo: BuildV2ProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Should().NotBeNull("Enroll must return a result even when DCV verification does not complete inline"); + _output.WriteLine($"CARequestID: {result.CARequestID}"); + _output.WriteLine($"Status: {result.Status}"); + _output.WriteLine($"Message: {result.StatusMessage}"); + + var staged = recordingFactory.StagedCalls; + var cleaned = recordingFactory.CleanedUpFqdns; + _output.WriteLine($"DNS provider calls: staged={staged.Count}, cleaned={cleaned.Count}"); + + if (domainVerified) + { + // Reuse path (issues/0020): the domain is already verified account-wide, so no + // fresh TXT record should ever be staged for it. + staged.Should().BeEmpty( + $"domain '{_v2Domain}' was already VERIFIED before enrollment (reuse path) — no TXT record " + + "should be staged. If this fails, see issues/0020 (the plugin currently treats the CA's " + + "EMS-1080 'already verified' response as a failure and defers, rather than as satisfied)."); + new[] { (int)EndEntityStatus.EXTERNALVALIDATION, (int)EndEntityStatus.GENERATED } + .Should().Contain(result.Status, + $"a reused, already-verified domain must let the order proceed to pending or issued; " + + $"got {result.Status}. Message: {result.StatusMessage}"); + } + else + { + // Publish path: a fresh challenge must actually get staged and cleaned up. + staged.Should().NotBeEmpty( + $"domain '{_v2Domain}' was not yet VERIFIED (dcvStatus={rawStatus ?? ""}) — Enroll " + + "must stage a TXT record to exercise the publish path."); + cleaned.Should().NotBeEmpty( + "a staged DCV TXT record must be cleaned up after the publish-path attempt."); + } + } + + // --------------------------------------------------------------------------- + // Gap 6 — Enroll with DCV off, V2 path, does not invoke the DNS provider + // --------------------------------------------------------------------------- + + [SkippableFact] + public async Task EnrollWithoutDcv_V2_DoesNotInvokeDnsProvider() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + var config = BuildV2Config(dcvEnabled: false); + var recordingFactory = new RecordingDomainValidatorFactory(BuildV2DnsFactory()); + var plugin = new CERTInextCAPlugin(new CERTInextClient(config), recordingFactory, config); + + var result = await plugin.Enroll( + csr: GenerateCsrPem(_v2Domain), + subject: $"CN={_v2Domain}", + san: new Dictionary { ["dns"] = new[] { _v2Domain } }, + productInfo: BuildV2ProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Should().NotBeNull(); + result.CARequestID.Should().NotBeNullOrWhiteSpace( + "the CA must accept the order even with DCV off — DCV-off must not block enrollment"); + + recordingFactory.StagedCalls.Should().BeEmpty( + "with DcvEnabled=false the plugin must never stage a DCV TXT record — this test's name promised " + + "that, but nothing previously checked it"); + recordingFactory.CleanedUpFqdns.Should().BeEmpty( + "with DcvEnabled=false the plugin must never attempt DCV cleanup either"); + } + + // --------------------------------------------------------------------------- + // Gap 7 — GetSingleRecord drives DCV for an existing pending V2 order + // --------------------------------------------------------------------------- + + [SkippableFact] + public async Task GetSingleRecord_V2_DrivesDcvForPendingOrder() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + string orderId = Environment.GetEnvironmentVariable("CERTINEXT_V2_PENDING_ORDER_ID"); + Skip.If(string.IsNullOrWhiteSpace(orderId), + "Set CERTINEXT_V2_PENDING_ORDER_ID to a real pending-dcv V2 order to run this test."); + Skip.If(!_dcvEnabled, + "CERTINEXT_CF_API_TOKEN and CERTINEXT_CF_ZONE_ID must be set so the plugin can publish a real TXT record."); + + var plugin = BuildV2DcvPlugin(dcvEnabled: true); + var record = await plugin.GetSingleRecord(orderId); + + record.Should().NotBeNull(); + _output.WriteLine($"CARequestID: {record.CARequestID}"); + _output.WriteLine($"Status: {record.Status}"); + + new[] { (int)EndEntityStatus.GENERATED, (int)EndEntityStatus.EXTERNALVALIDATION } + .Should().Contain(record.Status, + "deferred-DCV retry should leave the V2 order in a valid pending or issued state"); + } + + // --------------------------------------------------------------------------- + // Gap 8 — End-to-end DCV-on enrollment, issued cert appears in sync + // --------------------------------------------------------------------------- + + [SkippableFact] + public async Task EnrollWithDcvOn_V2_OrderIssuedEndToEnd_AndAppearsInSync() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + Skip.If(!_dcvEnabled, + "CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID required — DCV-on test must publish real TXT records."); + + var config = BuildV2Config(dcvEnabled: true); + using var probeClient = new CERTInextClient(config); + var (domainVerified, rawStatus) = await V2DomainStatusHelper.GetDcvStatusAsync(probeClient, _v2Domain); + _output.WriteLine($"Pre-enroll domain status for '{_v2Domain}': dcvStatus={rawStatus ?? ""}"); + + var recordingFactory = new RecordingDomainValidatorFactory(BuildV2DnsFactory()); + var plugin = new CERTInextCAPlugin(new CERTInextClient(config), recordingFactory, config); + + var enrollResult = await plugin.Enroll( + csr: GenerateCsrPem(_v2Domain), + subject: $"CN={_v2Domain}", + san: new Dictionary { ["dns"] = new[] { _v2Domain } }, + productInfo: BuildV2ProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + enrollResult.Should().NotBeNull(); + enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace(); + _output.WriteLine($"Enroll CARequestID={enrollResult.CARequestID}, Status={enrollResult.Status}"); + + new[] { (int)EndEntityStatus.EXTERNALVALIDATION, (int)EndEntityStatus.GENERATED } + .Should().Contain(enrollResult.Status, + $"DCV-on V2 Enroll must return pending or issued; got {enrollResult.Status}"); + + var staged = recordingFactory.StagedCalls; + var cleaned = recordingFactory.CleanedUpFqdns; + _output.WriteLine($"DNS provider calls: staged={staged.Count}, cleaned={cleaned.Count}"); + + if (domainVerified) + { + // Reuse path (issues/0020): no fresh TXT record should be staged for an + // already-verified domain. + staged.Should().BeEmpty( + $"domain '{_v2Domain}' was already VERIFIED before enrollment (reuse path) — no TXT record " + + "should be staged. See issues/0020."); + } + else + { + staged.Should().NotBeEmpty( + $"domain '{_v2Domain}' was not yet VERIFIED (dcvStatus={rawStatus ?? ""}) — Enroll " + + "must stage a TXT record to exercise the publish path."); + cleaned.Should().NotBeEmpty( + "a staged DCV TXT record must be cleaned up after the publish-path attempt."); + } + + // Delta sync — this sandbox account has 1000+ historical orders. + var synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddDays(-1), fullSync: false); + var record = synced.FirstOrDefault(r => r.CARequestID == enrollResult.CARequestID); + record.Should().NotBeNull( + $"the enrolled V2 order ({enrollResult.CARequestID}) must appear in plugin.Synchronize results"); + + _output.WriteLine($"Synced record status: {record!.Status}"); + + if (record.Status == (int)EndEntityStatus.GENERATED) + { + record.Certificate.Should().NotBeNullOrWhiteSpace( + "Synchronize must populate the cert body for an issued V2 order (mirrors issue 0001 for V1)"); + } + } + + // --------------------------------------------------------------------------- + // Gap 14 — Key-algorithm issuance matrix, V2 path (opt-in) + // --------------------------------------------------------------------------- + + [SkippableTheory] + [MemberData(nameof(KeyAlgorithms.AsMemberData), MemberType = typeof(KeyAlgorithms))] + public async Task EnrollWithDcvOn_V2_IssuesPerKeyAlgorithm(string tag) + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_ALGO_MATRIX") != "1", + "Opt-in: set CERTINEXT_V2_ALGO_MATRIX=1 to issue one real V2 cert per key algorithm."); + Skip.If(!_dcvEnabled, + "CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID required — DCV issuance must publish real TXT records."); + + var spec = KeyAlgorithms.For(tag); + string suffix = Guid.NewGuid().ToString("N").Substring(0, 8); + string cn = $"algo-{KeyAlgorithms.Slug(tag)}-{suffix}.{_v2Domain}"; + string csr = KeyAlgorithms.GenerateCsrPem(cn, spec); + + var plugin = BuildV2DcvPlugin(dcvEnabled: true); + + EnrollmentResult enrollResult; + try + { + enrollResult = await plugin.Enroll( + csr: csr, + subject: $"CN={cn}", + san: new Dictionary { ["dns"] = new[] { cn } }, + productInfo: BuildV2ProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + } + catch (Exception ex) + { + string reason = KeyAlgorithms.ClassifyRejection(ex.Message); + _output.WriteLine($"[SKIP] {tag}: {reason} — {ex.Message}"); + Skip.If(true, $"CERTInext did not issue a {tag} V2 cert: {reason}. CA message: {ex.Message}"); + return; // unreachable + } + + enrollResult.Should().NotBeNull(); + enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace($"{tag}: CA must return a CARequestID when it accepts the order"); + _output.WriteLine($"[{tag}] enrolled cn={cn} id={enrollResult.CARequestID} status={enrollResult.Status}"); + + const int maxPolls = 6; + const int delaySeconds = 15; + AnyCAPluginCertificate record = null; + for (int poll = 1; poll <= maxPolls; poll++) + { + record = await plugin.GetSingleRecord(enrollResult.CARequestID); + int status = record?.Status ?? -1; + _output.WriteLine($"[{tag}] poll #{poll}: status={status} certLen={record?.Certificate?.Length ?? 0}"); + + if (status == (int)EndEntityStatus.GENERATED && !string.IsNullOrWhiteSpace(record?.Certificate)) + break; + if (status == (int)EndEntityStatus.FAILED) + { + Skip.If(true, $"CERTInext FAILED the {tag} V2 order — algorithm not issuable on this account/profile."); + return; // unreachable + } + if (poll < maxPolls) + await Task.Delay(TimeSpan.FromSeconds(delaySeconds)); + } + + record.Should().NotBeNull($"{tag}: enrolled order {enrollResult.CARequestID} must be retrievable"); + if (record!.Status != (int)EndEntityStatus.GENERATED) + { + Skip.If(true, $"CERTInext accepted the {tag} V2 order but it did not reach GENERATED within the polling window " + + $"(Status={record.Status})."); + return; // unreachable + } + + record.Certificate.Should().NotBeNullOrWhiteSpace($"{tag}: issued V2 cert must carry a PEM body"); + AssertIssuedCertMatchesAlgorithm(record.Certificate, spec, tag); + _output.WriteLine($"--- {tag}: V2 DCV-on issuance OK — order {enrollResult.CARequestID} GENERATED. ---"); + } + + // --------------------------------------------------------------------------- + // Gap 15 — Bulk V2 enrollment + pagination smoke test (opt-in) + // --------------------------------------------------------------------------- + + [SkippableFact] + public async Task BulkV2Enrollment_AllOrdersIssue_AndPaginationWorks() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_RUN_BULK_TEST") != "1", + "Opt-in: set CERTINEXT_V2_RUN_BULK_TEST=1 to run the V2 volume/pagination test."); + Skip.If(!_dcvEnabled, + "CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID required — bulk test must publish real TXT records."); + + int count = int.TryParse(Environment.GetEnvironmentVariable("CERTINEXT_V2_BULK_TEST_COUNT"), out int c) ? c : 101; + int parallel = int.TryParse(Environment.GetEnvironmentVariable("CERTINEXT_V2_BULK_TEST_PARALLEL"), out int p) ? p : 5; + + // PageSize=100 ensures the 101st order forces a second page during Synchronize. + // Since this plugin is UseV2Api=true, Synchronize now pages through V2 + // /reports/orders (ListOrdersV2Async, issues/0022) rather than V1 GetOrderReport — + // this is the live pagination proof for that path, not just the WireMock-based + // client unit tests. + // syncLookbackHours narrowed to 2h: the default 72h margin would otherwise re-download + // every issued cert in a multi-day window on EACH of the (up to 8) sync passes below + // — issues/0022's "V2 sync per-row download cost" note, compounded by the retry loop. + var plugin = BuildV2DcvPlugin(dcvEnabled: true, propagationDelaySeconds: 5, pageSize: 100, syncLookbackHours: 2); + + var enrolled = new ConcurrentBag<(int idx, string cn, EnrollmentResult result)>(); + var failures = new ConcurrentBag<(int idx, string error)>(); + var sw = System.Diagnostics.Stopwatch.StartNew(); + + using (var sem = new SemaphoreSlim(parallel, parallel)) + { + var tasks = Enumerable.Range(0, count).Select(async i => + { + await sem.WaitAsync(); + try + { + string suffix = Guid.NewGuid().ToString("N").Substring(0, 8); + string cn = $"v2bulk-{suffix}.{_v2Domain}"; + string csr = GenerateCsrPem(cn); + + var result = await plugin.Enroll( + csr: csr, + subject: $"CN={cn}", + san: new Dictionary { ["dns"] = new[] { cn } }, + productInfo: BuildV2ProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + enrolled.Add((i, cn, result)); + _output.WriteLine($"[{i:000}] OK cn={cn} id={result.CARequestID} status={result.Status}"); + } + catch (Exception ex) + { + failures.Add((i, ex.Message)); + _output.WriteLine($"[{i:000}] FAIL {ex.GetType().Name}: {ex.Message}"); + } + finally + { + sem.Release(); + } + }); + await Task.WhenAll(tasks); + } + + sw.Stop(); + _output.WriteLine($"--- Enroll phase: enrolled={enrolled.Count}, failed={failures.Count}, elapsed={sw.Elapsed:mm\\:ss} ---"); + + failures.Should().BeEmpty($"every V2 Enroll() call must succeed; got {failures.Count} hard failures."); + enrolled.Count.Should().Be(count, $"expected {count} successful V2 Enroll() calls"); + + var enrolledIds = enrolled + .Where(e => !string.IsNullOrEmpty(e.result.CARequestID)) + .Select(e => e.result.CARequestID) + .ToHashSet(); + enrolledIds.Count.Should().Be(count, "every V2 enrollment must return a CARequestID"); + + const int maxSyncPasses = 8; + const int delayBetweenPassesSeconds = 30; + + List synced = null; + int passesUsed = 0; + + for (int pass = 1; pass <= maxSyncPasses; pass++) + { + passesUsed = pass; + synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddDays(-1), fullSync: false); + + int generated = synced.Count(r => enrolledIds.Contains(r.CARequestID) && r.Status == (int)EndEntityStatus.GENERATED); + int failed = synced.Count(r => enrolledIds.Contains(r.CARequestID) && r.Status == (int)EndEntityStatus.FAILED); + int pending = enrolledIds.Count - generated - failed; + + _output.WriteLine($"--- Sync pass #{pass}: {generated}/{enrolledIds.Count} GENERATED, {failed} FAILED, {pending} pending ---"); + + if (failed > 0) + { + var failedIds = synced + .Where(r => enrolledIds.Contains(r.CARequestID) && r.Status == (int)EndEntityStatus.FAILED) + .Select(r => r.CARequestID) + .Take(5); + Assert.Fail($"Pass #{pass}: {failed} V2 order(s) reached FAILED status: {string.Join(", ", failedIds)}"); + } + + if (pending == 0) + break; + + if (pass < maxSyncPasses) + await Task.Delay(TimeSpan.FromSeconds(delayBetweenPassesSeconds)); + } + + var syncedIds = synced!.Select(r => r.CARequestID).ToHashSet(); + var missing = enrolledIds.Where(id => !syncedIds.Contains(id)).ToList(); + missing.Should().BeEmpty( + $"{missing.Count} enrolled V2 orders did not appear in sync results: {string.Join(", ", missing.Take(5))}"); + + var lookup = synced!.Where(r => r.CARequestID != null).ToDictionary(r => r.CARequestID, r => r); + var notIssued = enrolledIds + .Where(id => lookup.TryGetValue(id, out var rec) && rec.Status != (int)EndEntityStatus.GENERATED) + .Select(id => lookup[id]) + .ToList(); + + notIssued.Should().BeEmpty( + $"every enrolled V2 order should auto-issue after {maxSyncPasses} sync passes; {notIssued.Count} did not."); + + _output.WriteLine($"--- SUCCESS: {count}/{count} V2 orders enrolled and issued in {passesUsed} sync pass(es). ---"); + } + } +} +#endif diff --git a/CERTInext.IntegrationTests/V2DomainStatusHelper.cs b/CERTInext.IntegrationTests/V2DomainStatusHelper.cs new file mode 100644 index 0000000..92909b7 --- /dev/null +++ b/CERTInext.IntegrationTests/V2DomainStatusHelper.cs @@ -0,0 +1,58 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Text.Json; +using System.Threading.Tasks; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// Read-only helper for querying GET /api/certinext/v2/domains?search=&exactMatch=true + /// via the existing escape hatch, so DCV-on V2 + /// tests can tell the reuse path (domain already VERIFIED, see issues/0020) apart from + /// the publish path before asserting what the DNS provider spy should have recorded. + /// + internal static class V2DomainStatusHelper + { + /// + /// Returns whether currently has dcvStatus=VERIFIED, plus + /// the raw dcvStatus string (null if the domain has no row at all, e.g. never + /// submitted on any order yet). + /// + public static async Task<(bool IsVerified, string RawStatus)> GetDcvStatusAsync( + CERTInextClient client, string domain) + { + string query = $"/api/certinext/v2/domains?search={Uri.EscapeDataString(domain)}&exactMatch=true"; + var (statusCode, _, content) = await client.ProbeV2GetAsync(query); + + // A failed lookup must not masquerade as "not verified" — that would steer the caller + // into asserting the publish path for the wrong reason. + if (statusCode != 200 || string.IsNullOrWhiteSpace(content)) + throw new InvalidOperationException( + $"GET /domains lookup for '{domain}' failed: HTTP {statusCode}; cannot tell reuse path from publish path."); + + using var doc = JsonDocument.Parse(content); + if (!doc.RootElement.TryGetProperty("content", out var arr) + || arr.ValueKind != JsonValueKind.Array + || arr.GetArrayLength() == 0) + return (false, null); + + var row = arr[0]; + string dcvStatus = row.TryGetProperty("dcvStatus", out var v) ? v.GetString() : null; + return (string.Equals(dcvStatus, "VERIFIED", StringComparison.OrdinalIgnoreCase), dcvStatus); + } + } +} diff --git a/CERTInext.IntegrationTests/V2FreshDomainDcvLifecycleTests.cs b/CERTInext.IntegrationTests/V2FreshDomainDcvLifecycleTests.cs new file mode 100644 index 0000000..e5c71fe --- /dev/null +++ b/CERTInext.IntegrationTests/V2FreshDomainDcvLifecycleTests.cs @@ -0,0 +1,412 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// V2 release-candidate readiness: DCV against a FRESH, never-before-seen domain. Every DCV +// test in V2DcvLifecycleTests.cs targets CERTINEXT_DCV_DOMAIN, which this sandbox account has +// reused across dozens of prior test runs and is therefore typically already VERIFIED +// account-wide — so those tests observe staged=0 (the reuse path, issue 0020) and never actually +// exercise the TXT publish/verify/cleanup path. This file's test targets a freshly-generated +// subdomain instead, so a real TXT challenge must be staged and cleaned up (staged>0). + +#if SUPPORTS_DCV +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + public class V2FreshDomainDcvLifecycleTests : IClassFixture, IDisposable + { + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + private readonly List _toDispose = new List(); + + private readonly string _v2ApiUrl; + private readonly string _v2ClientId; + private readonly string _v2ClientSecret; + private readonly string _v2Domain; + private readonly string _freshDcvParent; + private readonly bool _v2Enabled; + private readonly string _cfApiToken; + private readonly string _cfZoneId; + private readonly bool _dcvEnabled; + + public V2FreshDomainDcvLifecycleTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + + var env = V2EnvHelper.LoadAndPromote(); + + _v2ApiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL"); + _v2ClientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID"); + _v2ClientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET"); + _v2Domain = V2EnvHelper.GetEnv(env, "CERTINEXT_DCV_DOMAIN", "test.example.com"); + _cfApiToken = V2EnvHelper.GetEnv(env, "CERTINEXT_CF_API_TOKEN"); + _cfZoneId = V2EnvHelper.GetEnv(env, "CERTINEXT_CF_ZONE_ID"); + + // A fresh subdomain of CERTINEXT_DCV_DOMAIN is NOT genuinely unverified — this + // sandbox account has already completed DCV for CERTINEXT_DCV_DOMAIN itself, and + // CERTInext (like most DCV implementations) treats that as covering every + // subdomain beneath it. A dcv-fresh- name built under CERTINEXT_DCV_DOMAIN + // therefore never actually exercises the publish path (staged stays 0) — it is + // simply inheriting the parent's prior verification. A sibling domain under a + // DIFFERENT, still-unverified parent is required instead. Defaults to + // CERTINEXT_DCV_DOMAIN with its first label stripped (e.g. + // "dcv-test.scrup.org" -> "scrup.org"), which this sandbox account has never + // itself completed DCV against. + _freshDcvParent = V2EnvHelper.GetEnv(env, "CERTINEXT_V2_FRESH_DCV_PARENT", DeriveDefaultFreshDcvParent(_v2Domain)); + + _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API")) + && !string.IsNullOrWhiteSpace(_v2ApiUrl) + && !string.IsNullOrWhiteSpace(_v2ClientId) + && !string.IsNullOrWhiteSpace(_v2ClientSecret); + + _dcvEnabled = _v2Enabled + && !string.IsNullOrWhiteSpace(_cfApiToken) + && !string.IsNullOrWhiteSpace(_cfZoneId); + } + + /// + /// Strips the first DNS label from (e.g. + /// "dcv-test.scrup.org" -> "scrup.org") to derive a default value for + /// CERTINEXT_V2_FRESH_DCV_PARENT when it is unset — a sibling built under this + /// parent is not covered by the DCV domain's own prior verification. Falls back to the + /// input unchanged if it has no "." to strip. + /// + private static string DeriveDefaultFreshDcvParent(string domain) + { + if (string.IsNullOrWhiteSpace(domain)) return domain; + int dot = domain.IndexOf('.'); + return dot >= 0 && dot < domain.Length - 1 ? domain.Substring(dot + 1) : domain; + } + + public void Dispose() + { + foreach (var d in _toDispose) + d.Dispose(); + _toDispose.Clear(); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private static string GenerateCsrPem(string commonName) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + var keyPair = keyGen.GenerateKeyPair(); + + var subject = new X509Name($"CN={commonName}"); + var csr = new Pkcs10CertificationRequest("SHA256withRSA", subject, keyPair.Public, null, keyPair.Private); + + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + private IDomainValidatorFactory BuildV2DnsFactory() + { + if (_dcvEnabled) + { + var factory = new CloudflareDomainValidatorFactory(_cfApiToken, _cfZoneId); + _toDispose.Add(factory); + return factory; + } + return new StubDomainValidatorFactory(); + } + + private CERTInextConfig BuildV2Config() + { + return new CERTInextConfig + { + ApiUrl = _v2ApiUrl, + UseV2Api = true, + DefaultProductCode = Environment.GetEnvironmentVariable("CERTINEXT_PRODUCT_CODE") ?? "842", + OAuthClientId = _v2ClientId, + OAuthClientSecret = _v2ClientSecret, + + V2SyncLookbackHours = 1, + + RequestorName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test", + RequestorEmail = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "0000000000", + SignerPlace = "Gateway Lab", + SignerIp = "127.0.0.1", + + PageSize = 100, + + DcvEnabled = true, + DcvPropagationDelaySeconds = 5, + DcvTimeoutMinutes = 3 + }; + } + + /// + /// Same revoke-if-issued / cancel-otherwise cleanup as V2FullLifecycleTests. + /// CleanupOrderAsync — single attempt only, never retries a cancel, logs rather than + /// throws so a cleanup problem never masks the test's own assertion result. Returns + /// whether cleanup completed without throwing (true = revoked or cancelled successfully, + /// or nothing to do), so a caller that wants to assert "nothing leaks" — e.g. the + /// wildcard fresh-subdomain test below — has something other than log text to check. + /// + private async System.Threading.Tasks.Task CleanupOrderAsync(CERTInextCAPlugin plugin, string orderId) + { + if (string.IsNullOrWhiteSpace(orderId)) + return true; + + try + { + var current = await plugin.GetSingleRecord(orderId); + if (current?.Status == (int)EndEntityStatus.GENERATED) + { + int revokeResult = await plugin.Revoke(orderId, hexSerialNumber: string.Empty, revocationReason: 4 /* superseded */); + _output.WriteLine($"Cleanup: revoked issued order {orderId} -> {revokeResult}."); + } + else + { + await V2RawProbeHelpers.CancelSslOrderRawAsync( + _v2ApiUrl, _v2ClientId, _v2ClientSecret, orderId, + "V2 fresh-domain DCV test cleanup — order not issued, cancelling."); + _output.WriteLine($"Cleanup: cancelled non-issued order {orderId} (status={current?.Status})."); + } + return true; + } + catch (Exception ex) + { + _output.WriteLine( + $"Cleanup FAILED for order {orderId}: {ex.GetType().Name}: {ex.Message}. " + + "Revoke/cancel it by hand in the CERTInext portal if it should not remain pending."); + return false; + } + } + + // --------------------------------------------------------------------------- + // 7. DCV against a fresh, never-before-verified subdomain + // --------------------------------------------------------------------------- + + /// + /// Enrolls a DV order for a freshly-generated subdomain of a genuinely unverified parent + /// (CERTINEXT_V2_FRESH_DCV_PARENT, NOT a subdomain of CERTINEXT_DCV_DOMAIN itself + /// — that domain's own prior DCV covers every subdomain beneath it, so a + /// dcv-fresh-<ts>.CERTINEXT_DCV_DOMAIN name never actually exercises the publish + /// path), with DcvEnabled=true and a real Cloudflare-backed + /// wrapped in . + /// Because the domain is guaranteed unseen, this is the one DCV test in the V2 suite that + /// actually exercises the publish path: every existing V2DcvLifecycleTests case targets + /// the long-reused CERTINEXT_DCV_DOMAIN, which this account has verified account-wide, so + /// those always take the reuse path (issue 0020) and observe staged=0. Asserts staged>0 + /// and cleaned==staged. + /// Expected sandbox order count: 1. + /// + [SkippableFact] + public async System.Threading.Tasks.Task EnrollWithDcvOn_V2_FreshUnverifiedSubdomain_StagesAndCleansUpTxt() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + Skip.If(!_dcvEnabled, + "CERTINEXT_CF_API_TOKEN and CERTINEXT_CF_ZONE_ID must be set so the plugin can publish a real TXT record."); + Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_LIFECYCLE_FRESH_DCV") != "1", + "CERTINEXT_V2_LIFECYCLE_FRESH_DCV=1 not set — this places a real sandbox order and publishes a live DNS TXT record. Skipping."); + + string freshDomain = $"dcv-fresh-{DateTime.UtcNow:yyyyMMddHHmmssfff}.{_freshDcvParent}"; + + var config = BuildV2Config(); + var recordingFactory = new RecordingDomainValidatorFactory(BuildV2DnsFactory()); + var plugin = new CERTInextCAPlugin(new CERTInextClient(config), recordingFactory, config); + + string orderId = null; + try + { + var result = await plugin.Enroll( + csr: GenerateCsrPem(freshDomain), + subject: $"CN={freshDomain}", + san: new Dictionary { ["dns"] = new[] { freshDomain } }, + productInfo: new EnrollmentProductInfo { ProductID = Constants.Products.DvSsl }, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Should().NotBeNull(); + result.CARequestID.Should().NotBeNullOrWhiteSpace("Enroll must return a CARequestID even if DCV verification does not complete inline"); + orderId = result.CARequestID; + _output.WriteLine($"Fresh-domain order {orderId} for '{freshDomain}' (parent={_freshDcvParent}): Status={result.Status}, Message={result.StatusMessage}"); + + var staged = recordingFactory.StagedCalls; + var cleaned = recordingFactory.CleanedUpFqdns; + _output.WriteLine($"DNS provider calls: staged={staged.Count}, cleaned={cleaned.Count}"); + + // Expected behavior is staged>0 (see class-level remarks). In practice this + // sandbox account's CA has been observed treating the fresh subdomain's parent + // as already covering it and issuing immediately with zero TXT records staged — + // in which case the TXT publish/verify path was never exercised and the + // assertions below cannot be meaningfully evaluated. Skip rather than fail; the + // finally below still runs cleanup regardless of this skip. + Skip.If(staged.Count == 0, + $"blocked by sandbox: CA treated {freshDomain} as pre-validated; TXT publish/verify path not exercised"); + + staged.Should().NotBeEmpty( + $"domain '{freshDomain}' is freshly generated under a genuinely unverified parent " + + "and cannot already be VERIFIED on this account — unlike every pre-existing " + + "V2DcvLifecycleTests case (which targets the long-reused CERTINEXT_DCV_DOMAIN and " + + "always observes staged=0 via the reuse path, issue 0020), Enroll must actually stage " + + "a TXT record here."); + cleaned.Count.Should().Be(staged.Count, + "every staged DCV TXT record for a fresh domain must be cleaned up after the attempt."); + + new[] { (int)EndEntityStatus.EXTERNALVALIDATION, (int)EndEntityStatus.GENERATED } + .Should().Contain(result.Status, + $"DCV-on Enroll for a fresh domain must return pending or issued; got {result.Status}. Message: {result.StatusMessage}"); + } + finally + { + await CleanupOrderAsync(plugin, orderId); + } + } + + // --------------------------------------------------------------------------- + // 8. Wildcard DV DCV against a fresh, never-before-verified subdomain + // --------------------------------------------------------------------------- + + /// + /// Wildcard-only CSR shape (CN = SAN = the wildcard) on a freshly-generated, + /// never-before-seen subdomain of a genuinely unverified parent + /// (CERTINEXT_V2_FRESH_DCV_PARENT, same freshness rationale as + /// above), + /// for . Asserts the staged TXT hostname + /// does NOT contain a literal '*' (a wildcard's "*." label is not a queryable DNS name — + /// see the base-domain hostname fix). DOES fail if the order ends FAILED. If the order + /// is still at EXTERNALVALIDATION (pending DCV) when this test's wait elapses, wildcard + /// DCV completion was never actually exercised, so the test Skips with a "blocked by + /// sandbox" message rather than claiming wildcard DCV works — cleanup (cancel) still + /// runs regardless. Also records what Track Order's + /// verifications.domain.domains[].domain echoes back for the same order, and + /// whether any domain entry reached VERIFIED within the wait. Cleanup (revoke-if-issued + /// or cancel) must succeed regardless of outcome, so this probe never leaks a live order. + /// Expected sandbox order count: 1. + /// + [SkippableFact] + public async System.Threading.Tasks.Task EnrollWithDcvOn_V2_WildcardFreshSubdomain_RecordsTxtHostnameAndCleansUp() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + Skip.If(!_dcvEnabled, + "CERTINEXT_CF_API_TOKEN and CERTINEXT_CF_ZONE_ID must be set so the plugin can publish a real TXT record."); + Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_LIFECYCLE_FRESH_DCV") != "1", + "CERTINEXT_V2_LIFECYCLE_FRESH_DCV=1 not set — this places a real sandbox order and publishes a live DNS TXT record. Skipping."); + + string freshSubdomain = $"dcv-fresh-{DateTime.UtcNow:yyyyMMddHHmmssfff}.{_freshDcvParent}"; + string wildcard = $"*.{freshSubdomain}"; + + var config = BuildV2Config(); + var recordingFactory = new RecordingDomainValidatorFactory(BuildV2DnsFactory()); + var client = new CERTInextClient(config); + var plugin = new CERTInextCAPlugin(client, recordingFactory, config); + + string orderId = null; + try + { + var result = await plugin.Enroll( + csr: GenerateCsrPem(wildcard), + subject: $"CN={wildcard}", + san: new Dictionary { ["dns"] = new[] { wildcard } }, + productInfo: new EnrollmentProductInfo { ProductID = Constants.Products.DvSslWildcard }, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Should().NotBeNull(); + _output.WriteLine($"Wildcard fresh-subdomain order ({wildcard}, parent={_freshDcvParent}): Status={result.Status}, Message={result.StatusMessage}"); + + if (!string.IsNullOrWhiteSpace(result.CARequestID)) + orderId = result.CARequestID; + + // Don't fail solely on CA verification timing (EXTERNALVALIDATION is fine) — + // but a FAILED order (CERTInext rejected/cancelled it) is a real problem, not a + // timing artifact. + result.Status.Should().NotBe((int)EndEntityStatus.FAILED, + $"the order must not end FAILED; Message: {result.StatusMessage}"); + + // Ending at EXTERNALVALIDATION means DCV never actually completed within this + // test's wait — the wildcard DCV path was not exercised to issuance, so this test + // cannot claim wildcard DCV works. Skip rather than pass silently; cleanup + // (cancel) still runs in the finally below regardless of this skip. + Skip.If(result.Status == (int)EndEntityStatus.EXTERNALVALIDATION, + $"blocked by sandbox: wildcard order for '{wildcard}' ended at pending-approval (EXTERNALVALIDATION); wildcard DCV completion not exercised"); + + var staged = recordingFactory.StagedCalls; + var cleaned = recordingFactory.CleanedUpFqdns; + _output.WriteLine($"DNS provider calls: staged={staged.Count}, cleaned={cleaned.Count}"); + foreach (var call in staged) + _output.WriteLine($"OBSERVATION: staged TXT hostname Fqdn='{call.Fqdn}'."); + foreach (var fqdn in cleaned) + _output.WriteLine($"Cleaned-up TXT hostname: Fqdn='{fqdn}'."); + + staged.Should().OnlyContain(call => call.Fqdn == null || !call.Fqdn.Contains('*'), + "a literal '*' DNS label is not queryable by the CA and must never be staged — " + + "see the wildcard base-domain hostname fix."); + + if (!string.IsNullOrWhiteSpace(orderId)) + { + try + { + var tracked = await client.ResolveAndTrackOrderV2Async(orderId); + var domainEntries = tracked?.Verifications?.Domain?.Domains; + if (domainEntries != null && domainEntries.Count > 0) + { + foreach (var entry in domainEntries) + _output.WriteLine( + $"OBSERVATION: Track Order verifications.domain.domains[]: domain='{entry.Domain}', dcvStatus={entry.DcvStatus ?? ""}."); + + bool anyVerified = domainEntries.Any(e => + string.Equals(e.DcvStatus, "VERIFIED", StringComparison.OrdinalIgnoreCase)); + _output.WriteLine(anyVerified + ? "OBSERVATION: at least one domain entry reached VERIFIED within this test's wait — " + + "CA-side acceptance of a base-domain TXT record for a wildcard domain entry is CONFIRMED live." + : "OBSERVATION: no domain entry reached VERIFIED within this test's wait (CA-side " + + "timing, or the base-domain TXT record is not accepted for a wildcard domain entry " + + "— still UNVERIFIED; this is an observation, not a test failure)."); + } + else + { + _output.WriteLine("Track Order returned no verifications.domain.domains[] entries for this order."); + } + } + catch (Exception ex) + { + _output.WriteLine($"Track Order (for verifications detail) FAILED: {ex.GetType().Name}: {ex.Message}."); + } + } + } + finally + { + bool cleanedUp = await CleanupOrderAsync(plugin, orderId); + cleanedUp.Should().BeTrue( + "cleanup (revoke-if-issued or cancel) must succeed so this wildcard fresh-subdomain probe " + + "never leaves a live order on the sandbox, regardless of what the TXT-hostname/Track-Order " + + "observations above turn out to show — see the 'Cleanup FAILED' output above if this fails."); + } + } + } +} +#endif diff --git a/CERTInext.IntegrationTests/V2FullLifecycleTests.cs b/CERTInext.IntegrationTests/V2FullLifecycleTests.cs new file mode 100644 index 0000000..2818922 --- /dev/null +++ b/CERTInext.IntegrationTests/V2FullLifecycleTests.cs @@ -0,0 +1,960 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// V2 release-candidate readiness: assertion-bearing live lifecycle coverage for the product +// families/shapes the existing V2 suite (V2LifecycleTests/V2ApiTests/V2DcvLifecycleTests/ +// V2GapProbeTests) never exercised end to end — DV UCC, OV, OV UCC, EV, wildcard DV, and +// renew/reissue. Each test is gated by its own CERTINEXT_V2_LIFECYCLE_=1 flag (never +// promoted from ~/.env_certinext_v2 — see IntegrationTestFixture._optInOnlyFlags), places real +// sandbox orders, and always cleans up (revoke if issued, cancel otherwise) via +// CleanupOrderAsync in a try/finally. Fresh-domain DCV coverage lives in +// V2FreshDomainDcvLifecycleTests.cs (requires the SUPPORTS_DCV build). + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// Plugin-level V2 lifecycle tests for product shapes/flows the pre-existing V2 suite did + /// not cover with an assertion-bearing live test (readiness audit, 2026-10-01): DV UCC, OV, + /// OV UCC, EV, wildcard DV (both CSR shapes), and renew/reissue of an issued DV order. + /// + public class V2FullLifecycleTests : IClassFixture + { + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + + private readonly string _v2ApiUrl; + private readonly string _v2ClientId; + private readonly string _v2ClientSecret; + private readonly string _v2Domain; + private readonly bool _v2Enabled; + + /// + /// 300s target for OV/EV order-creation calls per the task brief (known CA latency — + /// issue 0064). NOT actually enforceable at this (plugin-level) layer: CERTInextClient's + /// V2 RestClient hard-codes Timeout = TimeSpan.FromSeconds(120) (CERTInextClient.cs, + /// both the V1 and V2 RestClientOptions blocks) with no CERTInextConfig override to raise + /// it. OV/EV tests below catch a client-side timeout distinctly from a CA-side rejection + /// and record it rather than assert past it — see IsClientTimeout below. Flagged in the + /// handoff report as a production gap, not silently worked around here. + /// + private static readonly TimeSpan OvEvCreateTimeoutTarget = TimeSpan.FromSeconds(300); + + public V2FullLifecycleTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + + var env = V2EnvHelper.LoadAndPromote(); + + _v2ApiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL"); + _v2ClientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID"); + _v2ClientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET"); + _v2Domain = V2EnvHelper.GetEnv(env, "CERTINEXT_DCV_DOMAIN", "test.example.com"); + + _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API")) + && !string.IsNullOrWhiteSpace(_v2ApiUrl) + && !string.IsNullOrWhiteSpace(_v2ClientId) + && !string.IsNullOrWhiteSpace(_v2ClientSecret); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private static string Timestamp() => DateTime.UtcNow.ToString("yyyyMMddHHmmssfff"); + + private static string GenerateCsrPem(string commonName) => GenerateCsrPem(commonName, ouTag: null); + + /// + /// , when supplied, is folded into the CSR subject as an OU — + /// e.g. ov- for the OV/OV-UCC orphan-sweep probes below. The orders report + /// () does not surface OU anywhere, so this + /// tag is NOT how an orphan is actually located (that's domain + creation-time window — + /// see ); it exists only so a human reviewing + /// the order in the CERTInext portal or a raw CSR dump can see which test run placed it. + /// + private static string GenerateCsrPem(string commonName, string ouTag) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + var keyPair = keyGen.GenerateKeyPair(); + + string subjectDn = string.IsNullOrWhiteSpace(ouTag) ? $"CN={commonName}" : $"CN={commonName},OU={ouTag}"; + var subject = new X509Name(subjectDn); + var csr = new Pkcs10CertificationRequest("SHA256withRSA", subject, keyPair.Public, null, keyPair.Private); + + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + private static async Task> RunSyncAsync( + CERTInextCAPlugin plugin, DateTime? lastSync = null, bool fullSync = true) + { + var buffer = new BlockingCollection(boundedCapacity: 10_000); + var collected = new List(); + + var syncTask = Task.Run(async () => + { + await plugin.Synchronize(buffer, lastSync: lastSync, fullSync: fullSync, cancelToken: CancellationToken.None); + if (!buffer.IsAddingCompleted) + buffer.CompleteAdding(); + }); + + foreach (var record in buffer.GetConsumingEnumerable()) + collected.Add(record); + + await syncTask; + return collected; + } + + private CERTInextConfig BuildV2Config( + int? syncLookbackHours = null, string organizationNumber = null) + { + return new CERTInextConfig + { + ApiUrl = _v2ApiUrl, + UseV2Api = true, + DefaultProductCode = Environment.GetEnvironmentVariable("CERTINEXT_PRODUCT_CODE") ?? "842", + OAuthClientId = _v2ClientId, + OAuthClientSecret = _v2ClientSecret, + + RequestorName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test", + RequestorEmail = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "0000000000", + SignerPlace = "Gateway Lab", + SignerIp = "127.0.0.1", + + PageSize = 100, + + V2SyncLookbackHours = syncLookbackHours ?? 1, + + OrganizationNumber = organizationNumber ?? string.Empty, + + DcvEnabled = false + }; + } + + private static CERTInextCAPlugin BuildV2Plugin(CERTInextConfig config) + { + var client = new CERTInextClient(config); + return new CERTInextCAPlugin(client, config); + } + + /// + /// Distinguishes a client-side HTTP timeout (RestSharp/TaskCanceledException — the + /// plugin's hard-coded 120s V2 RestClient timeout expiring before the CA responds) from a + /// genuine CA-side rejection. See 's doc comment. + /// + /// Also matches the shape actually observed on a live run: CERTInextClient's + /// ThrowOnV2Failure does not always surface a + /// for a RestSharp-level transport timeout — it can instead produce a plain + /// reading "CERTInext V2 API error during '...'. HTTP 0. + /// CERTInext V2 returned no body for '...'." (StatusCode 0 = no HTTP response was ever + /// received). Both substrings ("HTTP 0" and "returned no body") must be present so this + /// never also matches a genuine HTTP-0-with-a-body CA-side condition. + /// + private static bool IsClientTimeout(Exception ex) => + ex is TaskCanceledException + || ex is OperationCanceledException + || (ex.Message?.IndexOf("timed out", StringComparison.OrdinalIgnoreCase) >= 0) + || (ex.Message != null + && ex.Message.IndexOf("HTTP 0", StringComparison.OrdinalIgnoreCase) >= 0 + && ex.Message.IndexOf("returned no body", StringComparison.OrdinalIgnoreCase) >= 0); + + /// + /// Best-effort search for an order the CA may have created despite the plugin's own + /// client-side timeout () — a timeout proves nothing about + /// what happened server-side. Scans the V2 orders report + /// ( via ListOrdersV2Async) for the + /// "UTC today" window, matches on domainName == domain (the only field this report + /// row model exposes — no OU/SAN/tag field is echoed there) plus + /// orderDate >= windowStartUtc - 5min to avoid grabbing an older, unrelated + /// order on the same long-reused , picks the single most-recent + /// match if more than one row qualifies, and cancels it (one attempt, never retried) if + /// it is not already terminal. Never throws — every failure path is folded into the + /// returned description string so the caller's Skip.If message always has something + /// actionable. is logged only (see ). + /// + private async Task TryCancelOrphanByWindowAsync(string domain, DateTime windowStartUtc, string probeTag) + { + try + { + using var client = new CERTInextClient(BuildV2Config()); + + string from = windowStartUtc.Date.ToString("yyyy-MM-dd"); + string to = windowStartUtc.Date.AddDays(1).ToString("yyyy-MM-dd"); + + OrderReportEntryV2 best = null; + DateTime bestDate = DateTime.MinValue; + int scanned = 0; + + await foreach (var row in client.ListOrdersV2Async(from, to, pageSize: 100)) + { + scanned++; + if (!string.Equals(row.DomainName, domain, StringComparison.OrdinalIgnoreCase)) + continue; + + DateTime rowDate = DateTime.TryParse( + row.OrderDate, null, + System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, + out var parsed) + ? parsed + : windowStartUtc; // unparseable date: don't exclude it from consideration on that basis alone + + if (rowDate < windowStartUtc.AddMinutes(-5)) + continue; + + if (best == null || rowDate >= bestDate) + { + best = row; + bestDate = rowDate; + } + } + + _output.WriteLine( + $"Orphan sweep (tag={probeTag}): scanned {scanned} report row(s) for domain '{domain}', " + + $"window >= {windowStartUtc:O} (-5min grace)."); + + if (best == null) + return "orphan sweep found no matching report row for this domain/window (nothing to cancel, " + + "or the order has not appeared in the report yet — try again later by hand if needed)"; + + string orderId = best.OrderNumber; + if (string.IsNullOrWhiteSpace(orderId)) + return $"orphan sweep found a matching report row for domain '{domain}' with no orderNumber — cannot cancel it programmatically"; + + var (family, status) = await client.ResolveAndTrackOrderV2WithFamilyAsync(orderId); + bool terminal = + string.Equals(status.Status, Constants.ApiV2.StatusCancelled, StringComparison.OrdinalIgnoreCase) || + string.Equals(status.Status, Constants.ApiV2.StatusRevoked, StringComparison.OrdinalIgnoreCase) || + string.Equals(status.Status, Constants.ApiV2.StatusRejected, StringComparison.OrdinalIgnoreCase); + + if (terminal) + return $"orphan sweep found order {orderId} already terminal (status={status.Status}) — nothing to cancel"; + + try + { + var outcome = await client.CancelOrderV2Async( + family, orderId, + $"V2 full-lifecycle test orphan sweep — client-side timeout at submission (issue 0064), tag={probeTag}."); + return $"orphan sweep found order {orderId} (status was {status.Status}) and cancelled it (outcome={outcome})"; + } + catch (Exception cancelEx) + { + return $"orphan sweep found order {orderId} but the cancel call itself FAILED " + + $"({cancelEx.GetType().Name}: {cancelEx.Message}) — not retried; cancel it by hand in the CERTInext portal"; + } + } + catch (Exception ex) + { + return $"orphan sweep itself FAILED ({ex.GetType().Name}: {ex.Message}) — could not search for an orphaned order; check the CERTInext portal by hand"; + } + } + + /// + /// Cleans up a sandbox order this test created: revokes it via the plugin's real V2 + /// Revoke if it reached GENERATED, otherwise cancels it via the raw cancel endpoint + /// (the plugin has no V2 cancel method — ). Single attempt + /// only — never retries a cancel. Logs rather than throws on failure so a cleanup problem + /// never masks the test's own assertion result; failures are surfaced in test output for + /// manual follow-up in the CERTInext portal. + /// + private async Task CleanupOrderAsync(CERTInextCAPlugin plugin, string orderId) + { + if (string.IsNullOrWhiteSpace(orderId)) + return; + + try + { + var current = await plugin.GetSingleRecord(orderId); + if (current?.Status == (int)EndEntityStatus.GENERATED) + { + int revokeResult = await plugin.Revoke(orderId, hexSerialNumber: string.Empty, revocationReason: 4 /* superseded */); + _output.WriteLine($"Cleanup: revoked issued order {orderId} -> {revokeResult}."); + } + else + { + await V2RawProbeHelpers.CancelSslOrderRawAsync( + _v2ApiUrl, _v2ClientId, _v2ClientSecret, orderId, + "V2 full-lifecycle test cleanup — order not issued, cancelling."); + _output.WriteLine($"Cleanup: cancelled non-issued order {orderId} (status={current?.Status})."); + } + } + catch (Exception ex) + { + _output.WriteLine( + $"Cleanup FAILED for order {orderId}: {ex.GetType().Name}: {ex.Message}. " + + "Revoke/cancel it by hand in the CERTInext portal if it should not remain pending."); + } + } + + // --------------------------------------------------------------------------- + // 1. DV UCC — enroll (2+ SANs) -> track -> sync/GetSingleRecord -> revoke + // --------------------------------------------------------------------------- + + /// + /// Places one V2 DV SSL UCC order () with the + /// primary domain on the account's long-reused, likely-already-verified + /// CERTINEXT_DCV_DOMAIN, plus two fresh never-seen subdomains as additional SANs + /// (so the order itself places cleanly regardless of whether the extra SANs clear DCV — + /// mirrors UccPendingSanOrderProbeTests' reasoning). Exercises Enroll -> GetSingleRecord + /// -> Synchronize, then cleans up (revoke if GENERATED, else cancel). + /// Expected sandbox order count: 1. + /// + [SkippableFact] + public async Task Enroll_V2_DvUcc_WithMultipleSans_FullLifecycle() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_LIFECYCLE_DV_UCC") != "1", + "CERTINEXT_V2_LIFECYCLE_DV_UCC=1 not set — this places a real DV UCC sandbox order. Skipping."); + + string ts = Timestamp(); + string primary = _v2Domain; + string sanA = $"ucc-a-{ts}.{_v2Domain}"; + string sanB = $"ucc-b-{ts}.{_v2Domain}"; + + var config = BuildV2Config(); + var plugin = BuildV2Plugin(config); + + string orderId = null; + try + { + var productInfo = new EnrollmentProductInfo { ProductID = Constants.Products.DvSslUcc }; + + var enrollResult = await plugin.Enroll( + csr: GenerateCsrPem(primary), + subject: $"CN={primary}", + san: new Dictionary { ["dns"] = new[] { sanA, sanB } }, + productInfo: productInfo, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + enrollResult.Should().NotBeNull(); + enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace( + "V2 DV UCC Enroll must return a non-empty CARequestID"); + orderId = enrollResult.CARequestID; + enrollResult.Status.Should().NotBe((int)EndEntityStatus.FAILED, + $"DV UCC Enroll must not FAILED at submission; message: {enrollResult.StatusMessage}"); + _output.WriteLine($"DV UCC order {orderId}: Status={enrollResult.Status}, Primary={primary}, SANs=[{sanA}, {sanB}]"); + + var tracked = await plugin.GetSingleRecord(orderId); + tracked.Should().NotBeNull("GetSingleRecord must return a record for a just-placed DV UCC order"); + tracked.CARequestID.Should().Be(orderId); + _output.WriteLine($"Tracked: Status={tracked.Status}"); + + var synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddHours(-1), fullSync: false); + synced.Should().Contain(r => r.CARequestID == orderId, + $"the newly placed DV UCC order '{orderId}' must appear in a delta sync via V2 /reports/orders"); + + var syncedRecord = synced.First(r => r.CARequestID == orderId); + _output.WriteLine($"Synced status: {syncedRecord.Status}"); + if (syncedRecord.Status == (int)EndEntityStatus.GENERATED) + syncedRecord.Certificate.Should().NotBeNullOrWhiteSpace("an issued DV UCC order must carry a cert body via Synchronize"); + } + finally + { + await CleanupOrderAsync(plugin, orderId); + } + } + + // --------------------------------------------------------------------------- + // 2. OV — enroll -> track -> sync -> revoke/cancel + // --------------------------------------------------------------------------- + + /// + /// Places one V2 OV SSL order (); productVariant + /// "ov" and the organization block are both derived/required automatically by + /// EnrollV2Async (issues 0028, 0059) from the connector's OrganizationNumber. Sandbox + /// OV orders commonly park in a pending-vetting state rather than auto-issuing — this + /// test asserts on whatever state machine is actually observed (only FAILED at submission + /// is treated as a hard failure) rather than forcing GENERATED. See + /// for the 120s-vs-300s client timeout caveat. + /// Expected sandbox order count: 1. + /// + [SkippableFact] + public async Task Enroll_V2_Ov_FullLifecycle() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_LIFECYCLE_OV") != "1", + "CERTINEXT_V2_LIFECYCLE_OV=1 not set — this places a real OV sandbox order. Skipping."); + + string organizationNumber = _fixture.IsConfigured ? _fixture.OrgNumber : null; + Skip.If(string.IsNullOrWhiteSpace(organizationNumber), + "CERTINEXT_ORG_NUMBER not set in ~/.env_certinext — OV requires a pre-vetted organization number. Skipping."); + + var config = BuildV2Config(organizationNumber: organizationNumber); + var plugin = BuildV2Plugin(config); + + string domain = _v2Domain; + string probeTag = $"ov-{Timestamp()}"; + DateTime windowStart = DateTime.UtcNow; + string orderId = null; + try + { + EnrollmentResult enrollResult; + try + { + enrollResult = await plugin.Enroll( + csr: GenerateCsrPem(domain, probeTag), + subject: $"CN={domain},OU={probeTag}", + san: new Dictionary { ["dns"] = new[] { domain } }, + productInfo: new EnrollmentProductInfo { ProductID = Constants.Products.OvSsl }, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + } + catch (Exception ex) when (IsClientTimeout(ex)) + { + string sweepResult = await TryCancelOrphanByWindowAsync(domain, windowStart, probeTag); + Skip.If(true, + "OV order creation did not return within the plugin's hard-coded 120s V2 HTTP client " + + "timeout. Per issue 0064 (closed won't-fix) that timeout stays as-is, so this is an " + + "expected skip rather than a production bug on its own — but a client timeout does not " + + $"prove the CA never created the order; it likely did. {sweepResult}. " + + $"Observed: {ex.GetType().Name}: {ex.Message}"); + return; + } + + enrollResult.Should().NotBeNull(); + enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace("V2 OV Enroll must return a non-empty CARequestID"); + orderId = enrollResult.CARequestID; + enrollResult.Status.Should().NotBe((int)EndEntityStatus.FAILED, + $"OV Enroll must not FAILED at submission; message: {enrollResult.StatusMessage}"); + _output.WriteLine($"OV order {orderId}: Status={enrollResult.Status}, Message={enrollResult.StatusMessage}"); + + var tracked = await plugin.GetSingleRecord(orderId); + tracked.Should().NotBeNull(); + _output.WriteLine($"Tracked OV order {orderId}: Status={tracked.Status} (OV sandbox orders commonly sit in a pending-vetting state — this is the observed, not forced, state machine)."); + + var synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddHours(-1), fullSync: false); + synced.Should().Contain(r => r.CARequestID == orderId, + $"the newly placed OV order '{orderId}' must appear in a delta sync"); + } + finally + { + await CleanupOrderAsync(plugin, orderId); + } + } + + // --------------------------------------------------------------------------- + // 3. OV UCC — enroll (2+ SANs) -> track -> sync -> revoke/cancel + // --------------------------------------------------------------------------- + + /// + /// Places one V2 OV SSL UCC order () — the + /// organization-block requirement (OV/EV) and the UCC multi-SAN path (additionalDomains) + /// are exercised together, which neither OrganizationBlockV2ProbeTests nor + /// UccPendingSanOrderProbeTests combined into one order. Same pending-vetting + /// observation and timeout caveat as the plain OV test above. + /// Expected sandbox order count: 1. + /// + [SkippableFact] + public async Task Enroll_V2_OvUcc_WithMultipleSans_FullLifecycle() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_LIFECYCLE_OV_UCC") != "1", + "CERTINEXT_V2_LIFECYCLE_OV_UCC=1 not set — this places a real OV UCC sandbox order. Skipping."); + + string organizationNumber = _fixture.IsConfigured ? _fixture.OrgNumber : null; + Skip.If(string.IsNullOrWhiteSpace(organizationNumber), + "CERTINEXT_ORG_NUMBER not set in ~/.env_certinext — OV UCC requires a pre-vetted organization number. Skipping."); + + string ts = Timestamp(); + string primary = _v2Domain; + string sanA = $"ovucc-a-{ts}.{_v2Domain}"; + string sanB = $"ovucc-b-{ts}.{_v2Domain}"; + + var config = BuildV2Config(organizationNumber: organizationNumber); + var plugin = BuildV2Plugin(config); + + string probeTag = $"ovucc-{ts}"; + DateTime windowStart = DateTime.UtcNow; + string orderId = null; + try + { + EnrollmentResult enrollResult; + try + { + enrollResult = await plugin.Enroll( + csr: GenerateCsrPem(primary, probeTag), + subject: $"CN={primary},OU={probeTag}", + san: new Dictionary { ["dns"] = new[] { sanA, sanB } }, + productInfo: new EnrollmentProductInfo { ProductID = Constants.Products.OvSslUcc }, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + } + catch (Exception ex) when (IsClientTimeout(ex)) + { + string sweepResult = await TryCancelOrphanByWindowAsync(primary, windowStart, probeTag); + Skip.If(true, + "OV UCC order creation did not return within the plugin's hard-coded 120s V2 HTTP client " + + "timeout — same accepted-stays-as-is condition as the plain OV test (issue 0064, closed " + + "won't-fix). A client timeout does not prove the CA never created the order; it likely " + + $"did. {sweepResult}. Observed: {ex.GetType().Name}: {ex.Message}"); + return; + } + + enrollResult.Should().NotBeNull(); + enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace("V2 OV UCC Enroll must return a non-empty CARequestID"); + orderId = enrollResult.CARequestID; + enrollResult.Status.Should().NotBe((int)EndEntityStatus.FAILED, + $"OV UCC Enroll must not FAILED at submission; message: {enrollResult.StatusMessage}"); + _output.WriteLine($"OV UCC order {orderId}: Status={enrollResult.Status}, Primary={primary}, SANs=[{sanA}, {sanB}]"); + + var tracked = await plugin.GetSingleRecord(orderId); + tracked.Should().NotBeNull(); + _output.WriteLine($"Tracked OV UCC order {orderId}: Status={tracked.Status}"); + + var synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddHours(-1), fullSync: false); + synced.Should().Contain(r => r.CARequestID == orderId, + $"the newly placed OV UCC order '{orderId}' must appear in a delta sync"); + } + finally + { + await CleanupOrderAsync(plugin, orderId); + } + } + + // --------------------------------------------------------------------------- + // 4. EV — enroll -> track -> sync -> revoke/cancel + // --------------------------------------------------------------------------- + + /// + /// Places one V2 EV SSL order () — same + /// organization-block requirement as OV (issue 0028/0059's ProductVariantsV2 mapping + /// resolves "ev" automatically), same pending-vetting observation, same client-timeout + /// caveat. Uses CERTINEXT_EV_ORG_NUMBER — NOT CERTINEXT_ORG_NUMBER/ + /// , which is only pre-vetted for OV. EV + /// requires its own, separately-vetted organization number that this account does not + /// currently have; the test skips cleanly rather than guessing. + /// Expected sandbox order count: 1. + /// + [SkippableFact] + public async Task Enroll_V2_Ev_FullLifecycle() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_LIFECYCLE_EV") != "1", + "CERTINEXT_V2_LIFECYCLE_EV=1 not set — this places a real EV sandbox order. Skipping."); + + string organizationNumber = Environment.GetEnvironmentVariable("CERTINEXT_EV_ORG_NUMBER"); + Skip.If(string.IsNullOrWhiteSpace(organizationNumber), + "CERTINEXT_EV_ORG_NUMBER not set — EV requires its own pre-vetted organization number " + + "(distinct from CERTINEXT_ORG_NUMBER, which is only vetted for OV). Skipping."); + + var config = BuildV2Config(organizationNumber: organizationNumber); + var plugin = BuildV2Plugin(config); + + string domain = _v2Domain; + string orderId = null; + try + { + EnrollmentResult enrollResult; + try + { + enrollResult = await plugin.Enroll( + csr: GenerateCsrPem(domain), + subject: $"CN={domain}", + san: new Dictionary { ["dns"] = new[] { domain } }, + productInfo: new EnrollmentProductInfo { ProductID = Constants.Products.EvSsl }, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + } + catch (Exception ex) when (IsClientTimeout(ex)) + { + Skip.If(true, + $"EV order creation did not return within the plugin's hard-coded 120s V2 HTTP " + + $"client timeout — same production gap flagged for OV (issue 0064). " + + $"Observed: {ex.GetType().Name}: {ex.Message}"); + return; + } + + enrollResult.Should().NotBeNull(); + enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace("V2 EV Enroll must return a non-empty CARequestID"); + orderId = enrollResult.CARequestID; + enrollResult.Status.Should().NotBe((int)EndEntityStatus.FAILED, + $"EV Enroll must not FAILED at submission; message: {enrollResult.StatusMessage}"); + _output.WriteLine($"EV order {orderId}: Status={enrollResult.Status}, Message={enrollResult.StatusMessage}"); + + var tracked = await plugin.GetSingleRecord(orderId); + tracked.Should().NotBeNull(); + _output.WriteLine($"Tracked EV order {orderId}: Status={tracked.Status} (EV sandbox orders commonly sit in a pending-vetting state)."); + + var synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddHours(-1), fullSync: false); + synced.Should().Contain(r => r.CARequestID == orderId, + $"the newly placed EV order '{orderId}' must appear in a delta sync"); + } + finally + { + await CleanupOrderAsync(plugin, orderId); + } + } + + // --------------------------------------------------------------------------- + // 5. Wildcard DV — both CSR shapes (wildcard-only, wildcard+apex SAN) + // --------------------------------------------------------------------------- + + /// + /// Resolves the wildcard domain to use: CERTINEXT_V2_WILDCARD_DOMAIN if set, + /// else the literal *.dcv-test.scrup.org named in the task brief — this repo's own + /// always-reused sandbox base domain (see UccPendingSanOrderProbeTests' header comment), + /// not customer data. + /// + private static string ResolveWildcardDomain() => + Environment.GetEnvironmentVariable("CERTINEXT_V2_WILDCARD_DOMAIN") ?? "*.dcv-test.scrup.org"; + + /// + /// Wildcard-only CSR shape: CN and sole SAN are both the wildcard + /// (). Expected to be accepted — wildcard is a + /// first-class DV SSL Wildcard product shape. + /// Expected sandbox order count: 1. + /// + [SkippableFact] + public async Task Enroll_V2_WildcardDv_WildcardOnly_FullLifecycle() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_LIFECYCLE_WILDCARD_DV") != "1", + "CERTINEXT_V2_LIFECYCLE_WILDCARD_DV=1 not set — this places real wildcard DV sandbox orders. Skipping."); + + string wildcard = ResolveWildcardDomain(); + var config = BuildV2Config(); + var plugin = BuildV2Plugin(config); + + string orderId = null; + try + { + var enrollResult = await plugin.Enroll( + csr: GenerateCsrPem(wildcard), + subject: $"CN={wildcard}", + san: new Dictionary { ["dns"] = new[] { wildcard } }, + productInfo: new EnrollmentProductInfo { ProductID = Constants.Products.DvSslWildcard }, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + enrollResult.Should().NotBeNull(); + _output.WriteLine($"Wildcard-only ({wildcard}): Status={enrollResult.Status}, Message={enrollResult.StatusMessage}"); + enrollResult.Status.Should().NotBe((int)EndEntityStatus.FAILED, + $"a wildcard-only CSR/SAN shape must not be rejected; message: {enrollResult.StatusMessage}"); + enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace(); + orderId = enrollResult.CARequestID; + + var tracked = await plugin.GetSingleRecord(orderId); + tracked.Should().NotBeNull(); + _output.WriteLine($"Tracked: Status={tracked.Status}"); + } + finally + { + await CleanupOrderAsync(plugin, orderId); + } + } + + /// + /// Wildcard+apex CSR shape: CN is the wildcard, SAN dictionary carries BOTH the wildcard + /// and its bare apex domain. The non-UCC V2 SAN guard (CERTInextCAPlugin.cs, EnrollV2Async) + /// now explicitly exempts exactly this shape for a wildcard product (fix: 1f1de1b) — the + /// guard computes domain as the literal CN ("*.dcv-test.scrup.org" here), and + /// without the exemption the apex ("dcv-test.scrup.org") would match neither that nor its + /// "www." variant and be treated as a disallowed "extra SAN", even though a wildcard+apex + /// pairing is an extremely common, legitimate certificate shape. The order is therefore + /// expected to be accepted and issued. This test still RECORDS the actual observed + /// behavior rather than hard-asserting on it everywhere: if the order is rejected anyway, + /// it asserts the rejection is specifically this guard's (by message content) rather than + /// some unrelated failure; if accepted and issued, it parses the issued leaf (BouncyCastle) + /// and logs — as an observation only, not an assertion — whether the apex is covered by + /// the certificate's own SAN list (CERTInext may or may not add the apex to + /// additionalDomains automatically for a non-UCC wildcard product; unconfirmed live). + /// Expected sandbox order count: 0 or 1 (0 if CERTInext itself rejects a FAILED result + /// before any order is ever placed — see the FAILED branch below). + /// + [SkippableFact] + public async Task Enroll_V2_WildcardDv_WildcardPlusApexSan_RecordsActualBehavior() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_LIFECYCLE_WILDCARD_DV") != "1", + "CERTINEXT_V2_LIFECYCLE_WILDCARD_DV=1 not set — this places real wildcard DV sandbox orders. Skipping."); + + string wildcard = ResolveWildcardDomain(); + string apex = wildcard.StartsWith("*.", StringComparison.Ordinal) ? wildcard.Substring(2) : wildcard; + + var config = BuildV2Config(); + var plugin = BuildV2Plugin(config); + + string orderId = null; + try + { + var enrollResult = await plugin.Enroll( + csr: GenerateCsrPem(wildcard), + subject: $"CN={wildcard}", + san: new Dictionary { ["dns"] = new[] { wildcard, apex } }, + productInfo: new EnrollmentProductInfo { ProductID = Constants.Products.DvSslWildcard }, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + enrollResult.Should().NotBeNull(); + _output.WriteLine($"Wildcard+apex ({wildcard} + {apex}): Status={enrollResult.Status}, Message={enrollResult.StatusMessage}"); + + if (enrollResult.Status == (int)EndEntityStatus.FAILED) + { + _output.WriteLine( + "RESULT: wildcard+apex was REJECTED before any CA call — the non-UCC SAN guard's " + + $"wildcard-apex exemption did not cover this case: domain==CN=='{wildcard}', and the " + + $"apex '{apex}' matched neither that nor its 'www.' variant."); + enrollResult.StatusMessage.Should().Contain("SAN", + "a FAILED result here must specifically be the non-UCC multi-SAN guard's rejection " + + "(StatusMessage mentions SAN/domain count), not some unrelated failure masquerading as it"); + enrollResult.CARequestID.Should().BeNullOrWhiteSpace( + "the guard rejects before PlaceOrderV2Async — no CARequestID should be minted"); + } + else + { + _output.WriteLine( + "RESULT: wildcard+apex was ACCEPTED — the non-UCC single-domain SAN guard's " + + "wildcard-apex exemption (fix: 1f1de1b) allows the bare apex alongside the wildcard CN."); + enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace(); + orderId = enrollResult.CARequestID; + + var tracked = await plugin.GetSingleRecord(orderId); + tracked.Should().NotBeNull(); + _output.WriteLine($"Tracked: Status={tracked.Status}"); + + if (tracked.Status == (int)EndEntityStatus.GENERATED && !string.IsNullOrWhiteSpace(tracked.Certificate)) + { + var sans = ExtractDnsSansOrEmpty(tracked.Certificate); + _output.WriteLine($"OBSERVATION: issued certificate SAN list: [{string.Join(", ", sans)}]"); + + bool apexCovered = sans.Any(s => string.Equals(s, apex, StringComparison.OrdinalIgnoreCase)); + _output.WriteLine(apexCovered + ? $"OBSERVATION: the apex '{apex}' IS covered by the issued certificate's SAN list." + : $"OBSERVATION: the apex '{apex}' is NOT covered by the issued certificate's SAN " + + "list (observation only, not asserted — whether CERTInext adds the apex to " + + "additionalDomains automatically for a non-UCC wildcard product is unconfirmed live)."); + } + else + { + _output.WriteLine( + $"Order not yet issued (Status={tracked.Status}) — skipping the SAN-coverage observation."); + } + } + } + finally + { + await CleanupOrderAsync(plugin, orderId); + } + } + + // --------------------------------------------------------------------------- + // 6. Renew and Reissue of an issued DV order + // --------------------------------------------------------------------------- + + /// + /// Enrolls a DV order (New), then calls Enroll again with EnrollmentType.Renew and then + /// EnrollmentType.Reissue for the same domain, passing the prior order's serial via + /// ProductParameters["PriorCertSN"] (the V1 RenewOrReissueAsync convention). V2's + /// EnrollV2Async never branches on enrollmentType beyond logging it — every enrollment + /// type is dispatched identically (CERTInextCAPlugin.cs: "V2 path: all enrollment types + /// go through EnrollV2Async", and EnrollV2Async itself never reads PriorCertSN or + /// enrollmentType except in log statements). This test records the real observed + /// behavior (distinct CARequestIDs, original never implicitly revoked) but — unlike an + /// earlier version of this test — does NOT pass merely because the CA accepted each + /// submission; a FAILED order at the CA (e.g. the product-selection bug this plugin's own + /// catalog code resolves — now a separate, actively-fixed issue) must still fail this + /// test, since "records actual behavior" was never meant to license "observe FAILED + /// three times and call it a pass." + /// Expected sandbox order count: up to 3 (original + renew + reissue). + /// + [SkippableFact] + public async Task EnrollRenewReissue_V2_IssuedDvOrder_RecordsActualBehavior() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_LIFECYCLE_RENEW_REISSUE") != "1", + "CERTINEXT_V2_LIFECYCLE_RENEW_REISSUE=1 not set — this places up to 3 real DV sandbox orders. Skipping."); + + var config = BuildV2Config(); + var plugin = BuildV2Plugin(config); + + string domain = _v2Domain; + string originalOrderId = null, renewOrderId = null, reissueOrderId = null; + try + { + var original = await plugin.Enroll( + csr: GenerateCsrPem(domain), + subject: $"CN={domain}", + san: new Dictionary { ["dns"] = new[] { domain } }, + productInfo: new EnrollmentProductInfo { ProductID = Constants.Products.DvSsl }, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + original.Should().NotBeNull(); + original.CARequestID.Should().NotBeNullOrWhiteSpace(); + originalOrderId = original.CARequestID; + _output.WriteLine($"Original order {originalOrderId}: Status={original.Status}, Message={original.StatusMessage}"); + original.Status.Should().BeOneOf( + new[] { (int)EndEntityStatus.GENERATED, (int)EndEntityStatus.EXTERNALVALIDATION }, + $"the original New enrollment must actually reach an in-flight or issued state for this to be a " + + $"meaningful renew/reissue lifecycle test, not FAILED; message: {original.StatusMessage}"); + + string priorSn = ExtractHexSerialOrEmpty(original.Certificate); + var priorParams = new Dictionary { ["PriorCertSN"] = priorSn }; + + var renewResult = await plugin.Enroll( + csr: GenerateCsrPem(domain), + subject: $"CN={domain}", + san: new Dictionary { ["dns"] = new[] { domain } }, + productInfo: new EnrollmentProductInfo { ProductID = Constants.Products.DvSsl, ProductParameters = priorParams }, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.Renew); + + renewResult.Should().NotBeNull(); + renewResult.CARequestID.Should().NotBeNullOrWhiteSpace(); + renewOrderId = renewResult.CARequestID; + renewOrderId.Should().NotBe(originalOrderId, + "V2 has no dedicated renew endpoint — EnrollV2Async dispatches every EnrollmentType " + + "identically, so Renew places a brand-new order with a new CARequestID rather than " + + "reusing or superseding the original's ID"); + _output.WriteLine($"Renew order {renewOrderId}: Status={renewResult.Status}, Message={renewResult.StatusMessage} (new order, distinct CARequestID)."); + renewResult.Status.Should().BeOneOf( + new[] { (int)EndEntityStatus.GENERATED, (int)EndEntityStatus.EXTERNALVALIDATION }, + $"Renew must actually reach an in-flight or issued state, not FAILED; message: {renewResult.StatusMessage}"); + + var reissueResult = await plugin.Enroll( + csr: GenerateCsrPem(domain), + subject: $"CN={domain}", + san: new Dictionary { ["dns"] = new[] { domain } }, + productInfo: new EnrollmentProductInfo { ProductID = Constants.Products.DvSsl, ProductParameters = priorParams }, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.Reissue); + + reissueResult.Should().NotBeNull(); + reissueResult.CARequestID.Should().NotBeNullOrWhiteSpace(); + reissueOrderId = reissueResult.CARequestID; + reissueOrderId.Should().NotBe(originalOrderId); + reissueOrderId.Should().NotBe(renewOrderId); + _output.WriteLine($"Reissue order {reissueOrderId}: Status={reissueResult.Status}, Message={reissueResult.StatusMessage} (new order, distinct CARequestID)."); + reissueResult.Status.Should().BeOneOf( + new[] { (int)EndEntityStatus.GENERATED, (int)EndEntityStatus.EXTERNALVALIDATION }, + $"Reissue must actually reach an in-flight or issued state, not FAILED; message: {reissueResult.StatusMessage}"); + + var originalAfter = await plugin.GetSingleRecord(originalOrderId); + originalAfter.Should().NotBeNull(); + originalAfter.Status.Should().NotBe((int)EndEntityStatus.REVOKED, + "neither Renew nor Reissue should implicitly revoke the original order under the V2 " + + "path — EnrollV2Async never calls Revoke on a prior order"); + _output.WriteLine($"Original order {originalOrderId} after renew+reissue: Status={originalAfter.Status} (unaffected, as expected)."); + } + finally + { + await CleanupOrderAsync(plugin, originalOrderId); + await CleanupOrderAsync(plugin, renewOrderId); + await CleanupOrderAsync(plugin, reissueOrderId); + } + } + + /// + /// Extracts the issued certificate's serial number as an uppercase hex string using + /// BouncyCastle (never BCL System.Security.Cryptography). Returns empty when + /// is null/blank/unparseable — e.g. a DV order still pending + /// DCV at enrollment time has no cert body yet, and PriorCertSN is not read at all by + /// EnrollV2Async under V2 (see this method's caller), so an empty value is harmless here. + /// + private static string ExtractHexSerialOrEmpty(string certPem) + { + if (string.IsNullOrWhiteSpace(certPem)) + return string.Empty; + + try + { + var match = System.Text.RegularExpressions.Regex.Match( + certPem, + @"-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----", + System.Text.RegularExpressions.RegexOptions.Singleline); + if (!match.Success) + return string.Empty; + + string b64 = match.Groups[1].Value.Replace("\r", string.Empty).Replace("\n", string.Empty).Trim(); + var cert = new Org.BouncyCastle.X509.X509CertificateParser().ReadCertificate(Convert.FromBase64String(b64)); + return cert.SerialNumber.ToString(16).ToUpperInvariant(); + } + catch + { + return string.Empty; + } + } + + /// + /// Extracts the issued certificate's dNSName SAN entries using BouncyCastle (never BCL + /// System.Security.Cryptography) — mirrors the main plugin's own GeneralNameToSanEntry + /// dNSName handling, but reading the ISSUED certificate's own SAN extension rather than a + /// CSR's. Returns an empty list when is null/blank/unparseable, + /// or the certificate carries no SAN extension. + /// + private static List ExtractDnsSansOrEmpty(string certPem) + { + var result = new List(); + if (string.IsNullOrWhiteSpace(certPem)) + return result; + + try + { + var match = System.Text.RegularExpressions.Regex.Match( + certPem, + @"-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----", + System.Text.RegularExpressions.RegexOptions.Singleline); + if (!match.Success) + return result; + + string b64 = match.Groups[1].Value.Replace("\r", string.Empty).Replace("\n", string.Empty).Trim(); + var cert = new Org.BouncyCastle.X509.X509CertificateParser().ReadCertificate(Convert.FromBase64String(b64)); + + var sanExtensionOctets = cert.GetExtensionValue(X509Extensions.SubjectAlternativeName)?.GetOctets(); + if (sanExtensionOctets == null) + return result; + + var generalNames = GeneralNames.GetInstance( + Org.BouncyCastle.Asn1.Asn1Object.FromByteArray(sanExtensionOctets)); + + foreach (var generalName in generalNames.GetNames()) + { + if (generalName.TagNo == GeneralName.DnsName) + result.Add(Org.BouncyCastle.Asn1.DerIA5String.GetInstance(generalName.Name).GetString()); + } + } + catch + { + // Observation-only helper — an unparseable cert/extension just yields no SAN + // observations rather than failing the test. + } + + return result; + } + } +} diff --git a/CERTInext.IntegrationTests/V2GapProbeTests.cs b/CERTInext.IntegrationTests/V2GapProbeTests.cs new file mode 100644 index 0000000..cbc078b --- /dev/null +++ b/CERTInext.IntegrationTests/V2GapProbeTests.cs @@ -0,0 +1,1207 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Sandbox gap probes P1-P5 (issue 0058 — issues/0058-v2-sandbox-gap-probes.md), settling open +// V2 wire behaviors that the pending designs 0060 (multi-domain auto-resolve), 0062 (inline +// CSR lifecycle) and 0063 (UCC CSR SAN shape) depend on. All five probes place a real V2 SSL +// order on the sandbox, record what CERTInext does with it (never asserting on the CA's own +// answer — that is the finding, not a test failure), then cancel it in a `finally` and confirm +// the cancellation with a fresh read-only GET. +// +// PRE — read-only. Tracks the existing UCC order 9295677273 (already placed in issue 0047) +// and logs its status/domain, so the probes below have a live wire-shape baseline +// before placing anything new. +// P1 — productVariant:"ov" + one additionalDomains entry + an organization block, with NO +// X-Product-Code header. Does auto-resolve pick an OV UCC product? Records whatever +// the response echoes back as a resolved product code (best-effort scan — none of +// this repo's V2 response DTOs model a productCode field on any order response). +// P2 — the same body, with X-Product-Code pinned to the catalog's live, non-UCC OV SSL +// product (productTypeID 16 — Constants.Products.ProductTypeIdsV2[OvSsl] = "16"), +// resolved from the live catalog at run time (never hard-coded). Records reject +// (HTTP + EMS code), re-route to UCC, or a silently-dropped additionalDomains. +// P3a/P3b — a DV create with an inline "csr" field the plugin's own V2CreateSslOrderRequest +// DTO does not model (issue 0062) — added by hand to the raw JSON body, as PEM (P3a) +// and as headerless Base64 DER exactly like the spec's own create-order samples +// (P3b). Records whether the order skips Constants.ApiV2.StatusPendingCsr, then +// attempts the documented follow-up PUT .../csr and records accepted vs rejected. +// P4 — a DV UCC order (productTypeID 15, live-resolved) with 2 additionalDomains, followed +// by PUT .../csr with a CN-only CSR — the spec's own documented shape for a UCC CSR +// (SANs come from the order, not the CSR). Order 9295677273 (issue 0047) already +// confirmed a CSR carrying every SAN is accepted; this probe covers the other shape +// the spec itself documents. Records accepted vs EMS-921/EMS-922. +// P5 — productVariant:"dv" with X-Product-Code pinned to the live, non-UCC OV SSL product +// (same productTypeID 16 resolution as P2), and no organization block — issue 0059's +// variant/product mismatch. Records reject, DV, or a stalled OV-shaped order. +// +// Pattern: raw-body place-then-cancel, same idiom as OrganizationBlockV2ProbeTests / +// IdempotencyKeyV2ProbeTests / EmailNotificationsV2ProbeTests — deliberately bypasses +// CERTInextClient's typed request/response DTOs so the exact, unmodified wire body can be +// inspected (several of this file's own findings are about fields those DTOs do not model at +// all). The two pre-existing files' token-fetch and cancel helpers are shared via +// V2RawProbeHelpers (issue 0058's helper-extraction requirement) rather than copied a third +// time; every other raw-HTTP helper below (place/track/submit-CSR, and a non-throwing cancel +// that reports rather than throws) is local to this file, matching this project's existing +// convention of small per-probe-file helpers for the parts that are NOT shared duplicates. +// +// Gating (deliberately layered, same two-part mechanism as PrivatePkiV2LiveTests, plus a third +// layer issue 0058 added to close a latent hole in V2EnvHelper itself): +// 1. CERTINEXT_V2_GAP_PROBES=1 must be set in the real process environment. It is read here +// BEFORE V2EnvHelper.LoadAndPromote() runs, so a value left in ~/.env_certinext_v2 can +// never arm THIS constructor, even if V2EnvHelper went on to promote it. +// 2. It has also been added to IntegrationTestFixture's own _optInOnlyFlags (same guard as +// 83968ee added for CERTINEXT_PRIVATE_PKI_LIVE), so a value left in ~/.env_certinext can +// never arm it either. +// 3. V2EnvHelper.PromotableKeys itself now excludes every _optInOnlyFlags name (issue 0058), +// not just the V1-shared keys it already excluded — without this, a value left in +// ~/.env_certinext_v2 would be read as unset by whichever test class is constructed +// FIRST in a run (this constructor runs before LoadAndPromote), but LoadAndPromote would +// then still write it into real process env, silently arming every LATER-constructed test +// class in the same run even though nothing was ever exported in the shell. See +// V1FixtureApiUrlGuardTests.PromotableKeys_ExcludesEveryOptInOnlyFlag. +// All three layers must agree for this flag to ever be considered "on". +// 2. CERTINEXT_INBOX_TEST_EMAIL must be set (no fallback to CERTINEXT_REQUESTOR_EMAIL, which +// is a non-deliverable placeholder — issue 0058's own constraint). Used as both the +// requestor email and the technicalPointOfContact email; name and phone come from the V1 +// fixture's CERTINEXT_REQUESTOR_NAME plus the fixture's own ISD/mobile defaults via +// CERTInextCAPlugin.ComposeV2Phone (issue 0027 item 5b's phone-composition helper). +// 3. Live V2 OAuth2 credentials (CERTINEXT_API_URL/CLIENT_ID/CLIENT_SECRET, ~/.env_certinext_v2 +// via V2EnvHelper) must be present. +// +// emailNotifications: every probe sends "all" (full notification set) rather than omitting the +// field or sending "0" — the user's explicit choice for these probes (issue 0058's Constraints +// section: "emailNotifications: confirm... whether the user wants CERTInext's emails"; resolved +// 2026-09-29 in favor of "all"). "all" is also the only value the V2 spec's own create-order +// examples ever show (docs/reference/specs/CERTInext API v2.postman_collection (1).json — every +// SSL/private-pki/signature create sample sends exactly "all"; see also +// EmailNotificationsV2ProbeTests's header comment, which independently confirms the same reading +// of the spec text for issue 0027 item 1b). +// +// Domains: unique subdomains of CERTINEXT_DCV_DOMAIN (via V2EnvHelper, default "example.com"), +// e.g. gap-p1-. — never a real customer domain. Org number: +// CERTINEXT_ORG_NUMBER (via the V1 fixture). Every SSL create includes an `agreement` block +// with signerPlace populated (Constants default "Gateway Lab", matching this project's sibling +// V2 probes) — 0039 already made SignerPlace required for V2, so a raw body omitting it would +// only test 0039's own already-answered question, not one of P1-P5. +// +// CSRs: BouncyCastle only (repo convention — see CLAUDE.md). No System.Security.Cryptography +// anywhere in this file. +// +// Logging: every raw request/response body is passed through +// CERTInextClient.ApplyLoggingRedaction(body, logSensitiveRequestData: false) before being +// written via ITestOutputHelper, so the real inbox email these probes carry in +// requestor/technicalPointOfContact is never written to a log file in the clear (issue 0058 +// constraint: "Logging: log each raw request and response through the redaction helpers +// (0040)"; ApplyLoggingRedaction is internal — reachable here via CERTInext's +// InternalsVisibleTo("CERTInext.IntegrationTests"), same access RedactPersonalDataTests uses). +// Each probe ends with one ITestOutputHelper summary line: probe id, HTTP status, EMS code (if +// any), status, orderId, cancel outcome. +// +// No automatic retries of any place call anywhere in this file — exactly one create call per +// probe, matching UccPendingSanOrderProbeTests/EmailNotificationsV2ProbeTests' documented +// no-retry rule for this sandbox (a client-side timeout does not prove the CA never processed +// the request). +// +// Run (after the user's go/no-go on this design): +// set -a; . ~/.env_certinext; set +a +// export CERTINEXT_V2_GAP_PROBES=1 +// export CERTINEXT_INBOX_TEST_EMAIL= +// dotnet test CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj -c Release -p:DcvSupport=false \ +// --filter "FullyQualifiedName~V2GapProbeTests" --logger "console;verbosity=detailed" > /tmp/v2gap.log 2>&1 +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text.Json; + using System.Text.Json.Nodes; + using System.Text.Json.Serialization; + using System.Text.RegularExpressions; + using System.Threading.Tasks; + using FluentAssertions; + using Keyfactor.Extensions.CAPlugin.CERTInext.API; + using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; + using Keyfactor.Extensions.CAPlugin.CERTInext.Client; + using Org.BouncyCastle.Asn1.X509; + using Org.BouncyCastle.Crypto; + using Org.BouncyCastle.Crypto.Generators; + using Org.BouncyCastle.Pkcs; + using Org.BouncyCastle.Security; + using RestSharp; + using Xunit; + using Xunit.Abstractions; + + public class V2GapProbeTests : IClassFixture + { + private const string OptInFlag = "CERTINEXT_V2_GAP_PROBES"; + + /// Order placed for issue 0047's UCC CSR-shape probe; see this file's PRE probe. + private const string TrackedOrderId = "9295677273"; + + /// + /// 120s — matches this repo's other slow-endpoint V2 probes (EmailNotificationsV2ProbeTests/ + /// UccDcvShapeV2ProbeTests/UccPendingSanOrderProbeTests' own NewApiClient timeout). Observed + /// during earlier probe authoring: an OV/OV-shaped order-create call on this sandbox can + /// exceed the framework's 100s default. + /// + private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(120); + + /// + /// 300s — P1 and P2 both place an OV-shaped order (productVariant:"ov" + an + /// organization block + an additionalDomains entry) and both hit the 120s + /// as a client-side TaskCanceledException (HTTP 0) + /// on a live sandbox run (issue 0058 sweep follow-up — see + /// Sweep_FindsAndCancelsOrphanedGapProbeOrders's header comment). A client timeout does + /// not prove CERTInext never created the order, so raising only the OV-create timeout + /// (not every probe's) gives a future P1/P2 run enough headroom to get a real HTTP + /// response — success or CA-side rejection — back from the create call itself. + /// + private static readonly TimeSpan OvCreateProbeTimeout = TimeSpan.FromSeconds(300); + + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + + private readonly bool _armed; + private readonly string _v2ApiUrl; + private readonly string _v2ClientId; + private readonly string _v2ClientSecret; + private readonly string _dcvDomainBase; + private readonly string _inboxTestEmail; + private readonly bool _v2Enabled; + + public V2GapProbeTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + + // Read the opt-in flag from the real process environment BEFORE promoting the V2 env + // file (mirrors PrivatePkiV2LiveTests) — a value left in ~/.env_certinext_v2 must + // never arm this file. IntegrationTestFixture's own _optInOnlyFlags list (this file's + // constructor parameter) already keeps ~/.env_certinext from arming it either. + _armed = Environment.GetEnvironmentVariable(OptInFlag)?.Trim() == "1"; + + var env = V2EnvHelper.LoadAndPromote(); + _v2ApiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL"); + _v2ClientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID"); + _v2ClientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET"); + _dcvDomainBase = V2EnvHelper.GetEnv(env, "CERTINEXT_DCV_DOMAIN", "example.com"); + + // Never falls back to CERTINEXT_REQUESTOR_EMAIL — that placeholder is non-deliverable + // (issue 0058's own constraint; see also EmailNotificationsV2ProbeTests). + _inboxTestEmail = Environment.GetEnvironmentVariable("CERTINEXT_INBOX_TEST_EMAIL")?.Trim(); + + _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API")) + && !string.IsNullOrWhiteSpace(_v2ApiUrl) + && !string.IsNullOrWhiteSpace(_v2ClientId) + && !string.IsNullOrWhiteSpace(_v2ClientSecret); + } + + // --------------------------------------------------------------------------- + // Shared skip guards + // --------------------------------------------------------------------------- + + private void SkipUnlessArmedAndConfigured() + { + Skip.If(!_armed, + $"{OptInFlag}=1 not set in the real process environment — these probes place real, " + + "potentially cost-bearing V2 SSL orders and require the user's explicit go/no-go " + + "(issue 0058). Skipping."); + Skip.If(!_v2Enabled, + "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + Skip.If(string.IsNullOrWhiteSpace(_inboxTestEmail), + "CERTINEXT_INBOX_TEST_EMAIL not set — set it to a real inbox you can check, " + + "never CERTINEXT_REQUESTOR_EMAIL (non-deliverable placeholder). Skipping."); + } + + /// + /// Narrower gate than for + /// — that sweep lists/tracks/ + /// cancels pre-existing orders rather than building a requestor/technicalPointOfContact + /// block, so it does not need CERTINEXT_INBOX_TEST_EMAIL. Still requires the same + /// go/no-go opt-in and live V2 credentials as P1-P5, since it can cancel real sandbox + /// orders. + /// + private void SkipUnlessArmedForSweep() + { + Skip.If(!_armed, + $"{OptInFlag}=1 not set in the real process environment — this sweep can cancel " + + "real sandbox orders and requires the same explicit go/no-go as P1-P5 (issue 0058). Skipping."); + Skip.If(!_v2Enabled, + "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + } + + // --------------------------------------------------------------------------- + // PRE — read-only baseline: order 9295677273 (issue 0047) + // --------------------------------------------------------------------------- + + /// + /// Read-only. Tracks order and logs its status and, if the + /// response carries them, its SAN(s) — a best-effort scan, since none of this repo's + /// response DTOs model a confirmed SAN-array field on a Track Order response (issue + /// 0042). Gated behind the same as P1-P5 even though it makes no + /// mutating call, per issue 0058's explicit instruction. + /// + [SkippableFact] + public async Task Pre_TrackOrder_V2_ExistingOrder9295677273_LogsStatusAndSans() + { + SkipUnlessArmedAndConfigured(); + + var track = await TrackOrderRawAsync(TrackedOrderId); + + _output.WriteLine("=== Issue 0058 PRE probe: Track Order 9295677273 (issue 0047 baseline) ==="); + _output.WriteLine($"HTTP {track.StatusCode}"); + _output.WriteLine(RedactForLog(track.Body)); + + string status = TryExtractStringField(track.Body, "status"); + string domain = TryExtractStringField(track.Body, "domain"); + List sanFieldHits = ScanForKeyValues(track.Body, "domain"); + + _output.WriteLine(""); + _output.WriteLine(sanFieldHits.Count > 0 + ? $"Domain-related fields found: {string.Join("; ", sanFieldHits.Select(RedactForLog))}" + : "No domain-related fields found in the raw body."); + + _output.WriteLine(""); + _output.WriteLine( + $"SUMMARY | Probe=PRE OrderId={TrackedOrderId} HTTP={track.StatusCode} " + + $"Status={status ?? ""} PrimaryDomain={domain ?? ""} Cancel=N/A (read-only)"); + + track.StatusCode.Should().NotBe(0, + "the token call and the Track Order GET itself must succeed — HTTP 0 means a " + + "transport-level failure reaching the CA, not a CA response to record"); + } + + // --------------------------------------------------------------------------- + // P1 — OV + 1 additionalDomains + organization, NO X-Product-Code + // --------------------------------------------------------------------------- + + /// + /// productVariant:"ov" + one additionalDomains entry + an organization block, with NO + /// X-Product-Code header at all. Records whatever the CA echoes back as a resolved + /// product code (best-effort scan of the create/Track Order bodies). + /// + [SkippableFact] + public async Task P1_OvWithAdditionalDomains_NoProductCodeHeader_RecordsAutoResolve() + { + SkipUnlessArmedAndConfigured(); + + string organizationNumber = _fixture.IsConfigured ? _fixture.OrgNumber : null; + Skip.If(string.IsNullOrWhiteSpace(organizationNumber), + "CERTINEXT_ORG_NUMBER not set in ~/.env_certinext — P1 needs it for the OV order's " + + "organization block. Skipping."); + + string stamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); + string primary = $"gap-p1-{stamp}.{_dcvDomainBase}"; + string additional = $"gap-p1-{stamp}-b.{_dcvDomainBase}"; + + var orderReq = new V2CreateSslOrderRequest + { + ProductVariant = "ov", + EmailNotifications = "all", + Requestor = BuildRequestor(), + Organization = new V2OrganizationParams { OrganizationNumber = organizationNumber, PreVetted = true }, + Certificate = new V2CertificateParams { Domain = primary, AutoSecureWww = false, AdditionalDomains = new List { additional } }, + Subscription = BuildSubscription(), + Agreement = BuildAgreement(), + TechnicalPointOfContact = BuildTechnicalPointOfContact(), + Remarks = "Issue 0058 P1 probe — OV + additionalDomains, no X-Product-Code header." + }; + string requestJson = JsonSerializer.Serialize(orderReq, GetJsonOptions()); + + await RunCreateThenCancelAsync( + probeId: "P1", + productCodeOrNull: null, + requestJson: requestJson, + primaryDomain: primary, + createTimeoutOverride: OvCreateProbeTimeout, + extraProbeWork: async (createResp, orderId) => + { + List productCodeHits = ScanForKeyValues(createResp.Body, "productcode"); + _output.WriteLine(productCodeHits.Count > 0 + ? $"resolvedProductCode candidate field(s): {string.Join("; ", productCodeHits)}" + : "resolvedProductCode: not echoed in the create response — check the order in the CERTInext portal."); + + if (!string.IsNullOrWhiteSpace(orderId)) + { + var track = await TrackOrderRawAsync(orderId); + _output.WriteLine(""); + _output.WriteLine("--- Track Order (post-create) ---"); + _output.WriteLine(RedactForLog(track.Body)); + List trackProductCodeHits = ScanForKeyValues(track.Body, "productcode"); + if (trackProductCodeHits.Count > 0) + _output.WriteLine($"resolvedProductCode candidate field(s) in Track Order: {string.Join("; ", trackProductCodeHits)}"); + } + }); + } + + // --------------------------------------------------------------------------- + // P2 — same body, X-Product-Code pinned to the live non-UCC OV SSL code + // --------------------------------------------------------------------------- + + /// + /// Same body as P1, but with X-Product-Code pinned to the catalog's live, non-UCC OV + /// SSL product (productTypeID 16), resolved from the live catalog at run time. Records + /// rejected (HTTP + EMS code), re-routed to UCC, or additionalDomains silently dropped. + /// + [SkippableFact] + public async Task P2_OvWithAdditionalDomains_PinnedNonUccProductCode_RecordsCaResponse() + { + SkipUnlessArmedAndConfigured(); + + string organizationNumber = _fixture.IsConfigured ? _fixture.OrgNumber : null; + Skip.If(string.IsNullOrWhiteSpace(organizationNumber), + "CERTINEXT_ORG_NUMBER not set in ~/.env_certinext — P2 needs it for the OV order's " + + "organization block. Skipping."); + + string ovSslProductCode = await ResolveProductCodeByTypeIdAsync(Constants.Products.ProductTypeIdsV2[Constants.Products.OvSsl]); + Skip.If(ovSslProductCode == null, + $"No live catalog product found with productTypeID=\"{Constants.Products.ProductTypeIdsV2[Constants.Products.OvSsl]}\" " + + "(non-UCC OV SSL) on this account — P2 cannot resolve a product code to pin. Skipping."); + + string stamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); + string primary = $"gap-p2-{stamp}.{_dcvDomainBase}"; + string additional = $"gap-p2-{stamp}-b.{_dcvDomainBase}"; + + var orderReq = new V2CreateSslOrderRequest + { + ProductVariant = "ov", + EmailNotifications = "all", + Requestor = BuildRequestor(), + Organization = new V2OrganizationParams { OrganizationNumber = organizationNumber, PreVetted = true }, + Certificate = new V2CertificateParams { Domain = primary, AutoSecureWww = false, AdditionalDomains = new List { additional } }, + Subscription = BuildSubscription(), + Agreement = BuildAgreement(), + TechnicalPointOfContact = BuildTechnicalPointOfContact(), + Remarks = "Issue 0058 P2 probe — OV + additionalDomains, X-Product-Code pinned to non-UCC OV SSL." + }; + string requestJson = JsonSerializer.Serialize(orderReq, GetJsonOptions()); + + _output.WriteLine($"Resolved non-UCC OV SSL product code from live catalog: {ovSslProductCode}"); + + await RunCreateThenCancelAsync( + probeId: "P2", + productCodeOrNull: ovSslProductCode, + requestJson: requestJson, + primaryDomain: primary, + createTimeoutOverride: OvCreateProbeTimeout, + extraProbeWork: async (createResp, orderId) => + { + if (string.IsNullOrWhiteSpace(orderId)) + { + _output.WriteLine("No orderId parsed — likely a hard rejection; see the create response above for the EMS code."); + return; + } + + var track = await TrackOrderRawAsync(orderId); + _output.WriteLine(""); + _output.WriteLine("--- Track Order (post-create) ---"); + _output.WriteLine(RedactForLog(track.Body)); + + List additionalDomainHits = ScanForKeyValues(track.Body, "additionaldomains"); + _output.WriteLine(additionalDomainHits.Count > 0 + ? $"additionalDomains field found on Track Order: {string.Join("; ", additionalDomainHits.Select(RedactForLog))} — NOT silently dropped." + : "No additionalDomains field found on Track Order — either silently dropped, or the field is never echoed back for this product (compare against the create response above)."); + }); + } + + // --------------------------------------------------------------------------- + // P3a / P3b — DV create with an inline "csr" field (issue 0062) + // --------------------------------------------------------------------------- + + /// P3a — inline csr as PEM (with BEGIN/END markers). + [SkippableFact] + public async Task P3a_DvCreate_InlineCsrPem_RecordsLifecycleAndFollowUpPut() + { + SkipUnlessArmedAndConfigured(); + await RunInlineCsrProbeAsync(probeId: "P3a", useDerEncoding: false); + } + + /// P3b — inline csr as headerless Base64 DER, exactly like the spec's own samples. + [SkippableFact] + public async Task P3b_DvCreate_InlineCsrBase64Der_RecordsLifecycleAndFollowUpPut() + { + SkipUnlessArmedAndConfigured(); + await RunInlineCsrProbeAsync(probeId: "P3b", useDerEncoding: true); + } + + private async Task RunInlineCsrProbeAsync(string probeId, bool useDerEncoding) + { + string stamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); + string primary = $"gap-{probeId.ToLowerInvariant()}-{stamp}.{_dcvDomainBase}"; + + // Resolved live, never from the V1 fixture's CERTINEXT_PRODUCT_CODE — Constants.cs + // documents the V1-era numbering as wrong for V2 (issue 0036), same reasoning as + // P2/P4/P5's own live catalog resolution below. + string dvSslTypeId = Constants.Products.ProductTypeIdsV2[Constants.Products.DvSsl]; + string productCode = await ResolveProductCodeByTypeIdAsync(dvSslTypeId); + Skip.If(productCode == null, + $"No live catalog product found with productTypeID=\"{dvSslTypeId}\" (DV SSL) on " + + $"this account — {probeId} cannot resolve a product code. Skipping."); + + var csr = GenerateCsr(primary); + string inlineCsrValue = useDerEncoding ? CsrToBase64Der(csr) : CsrToPem(csr); + + var orderReq = new V2CreateSslOrderRequest + { + ProductVariant = "dv", + EmailNotifications = "all", + Requestor = BuildRequestor(), + Certificate = new V2CertificateParams { Domain = primary, AutoSecureWww = false }, + Subscription = BuildSubscription(), + Agreement = BuildAgreement(), + TechnicalPointOfContact = BuildTechnicalPointOfContact(), + Remarks = $"Issue 0058 {probeId} probe — DV create with an inline csr field " + + $"({(useDerEncoding ? "Base64 DER, no PEM markers" : "PEM")})." + }; + string baseJson = JsonSerializer.Serialize(orderReq, GetJsonOptions()); + + // V2CreateSslOrderRequest has no "csr" property (issue 0062 — the gap this probe + // exists to test) — added by hand onto the serialized JSON so the exact wire body + // matches what a hand-authored request could send. + JsonNode node = JsonNode.Parse(baseJson)!; + node["csr"] = inlineCsrValue; + string requestJson = node.ToJsonString(); + + _output.WriteLine($"Resolved DV SSL product code from live catalog: {productCode}"); + + // Routed through RunCreateThenCancelAsync (rather than place/PUT/cancel inline) so + // the cancel-in-finally guarantee applies to the follow-up PUT and its token fetch + // too — either one throwing used to skip cleanup entirely. + string putStatus = "not attempted (no orderId)"; + await RunCreateThenCancelAsync( + probeId: probeId, + productCodeOrNull: productCode, + requestJson: requestJson, + primaryDomain: primary, + extraProbeWork: async (createResp, orderId) => + { + string createStatus = TryExtractStringField(createResp.Body, "status"); + bool skippedPendingCsr = !string.IsNullOrWhiteSpace(createStatus) + && !string.Equals(createStatus, Constants.ApiV2.StatusPendingCsr, StringComparison.OrdinalIgnoreCase); + _output.WriteLine(""); + _output.WriteLine(!string.IsNullOrWhiteSpace(createStatus) + ? $"Order status after create: \"{createStatus}\" — {(skippedPendingCsr ? "skips" : "does NOT skip")} \"{Constants.ApiV2.StatusPendingCsr}\"." + : "No status field parsed from the create response."); + + if (string.IsNullOrWhiteSpace(orderId)) + return; + + var freshCsr = GenerateCsr(primary); + var putResp = await SubmitCsrRawAsync(orderId, CsrToPem(freshCsr)); + _output.WriteLine(""); + _output.WriteLine("--- Follow-up PUT .../csr ---"); + _output.WriteLine($"HTTP {putResp.StatusCode}"); + _output.WriteLine(RedactForLog(putResp.Body)); + putStatus = putResp.IsSuccessful + ? "ACCEPTED" + : $"REJECTED (HTTP {putResp.StatusCode}, EMS={TryExtractEmsCode(putResp.Body) ?? ""})"; + }); + + _output.WriteLine($"[{probeId}] FollowUpPut={putStatus}"); + } + + // --------------------------------------------------------------------------- + // P4 — DV UCC order + 2 additionalDomains, then PUT .../csr with a CN-only CSR + // --------------------------------------------------------------------------- + + /// + /// Places a DV UCC order (productTypeID 15, live-resolved) with 2 additionalDomains, + /// then submits a CN-only CSR (the spec's own documented UCC CSR shape — SANs come from + /// the order, not the CSR). Records accepted vs EMS-921/EMS-922. + /// + [SkippableFact] + public async Task P4_DvUccOrder_TwoAdditionalDomains_CnOnlyCsr_RecordsAcceptance() + { + SkipUnlessArmedAndConfigured(); + + string uccProductCode = await ResolveProductCodeByTypeIdAsync(Constants.Products.ProductTypeIdsV2[Constants.Products.DvSslUcc]); + Skip.If(uccProductCode == null, + $"No live catalog product found with productTypeID=\"{Constants.Products.ProductTypeIdsV2[Constants.Products.DvSslUcc]}\" " + + "(DV SSL UCC) on this account — P4 cannot resolve a product code. Skipping."); + + string stamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); + string primary = $"gap-p4-{stamp}.{_dcvDomainBase}"; + string sanA = $"gap-p4-{stamp}-a.{_dcvDomainBase}"; + string sanB = $"gap-p4-{stamp}-b.{_dcvDomainBase}"; + + var orderReq = new V2CreateSslOrderRequest + { + ProductVariant = "dv", + EmailNotifications = "all", + Requestor = BuildRequestor(), + Certificate = new V2CertificateParams { Domain = primary, AutoSecureWww = false, AdditionalDomains = new List { sanA, sanB } }, + Subscription = BuildSubscription(), + Agreement = BuildAgreement(), + TechnicalPointOfContact = BuildTechnicalPointOfContact(), + Remarks = "Issue 0058 P4 probe — DV UCC + 2 additionalDomains, CN-only follow-up CSR." + }; + string requestJson = JsonSerializer.Serialize(orderReq, GetJsonOptions()); + + _output.WriteLine($"Resolved DV SSL UCC product code from live catalog: {uccProductCode}"); + + await RunCreateThenCancelAsync( + probeId: "P4", + productCodeOrNull: uccProductCode, + requestJson: requestJson, + primaryDomain: primary, + extraProbeWork: async (createResp, orderId) => + { + if (string.IsNullOrWhiteSpace(orderId)) + { + _output.WriteLine("No orderId parsed — the CN-only CSR follow-up cannot be attempted."); + return; + } + + var cnOnlyCsr = GenerateCsr(primary); + var putResp = await SubmitCsrRawAsync(orderId, CsrToPem(cnOnlyCsr)); + _output.WriteLine(""); + _output.WriteLine("--- PUT .../csr with a CN-only CSR (spec's documented UCC shape) ---"); + _output.WriteLine($"HTTP {putResp.StatusCode}"); + _output.WriteLine(RedactForLog(putResp.Body)); + string putEms = TryExtractEmsCode(putResp.Body); + _output.WriteLine(putResp.IsSuccessful + ? "CN-only CSR ACCEPTED." + : $"CN-only CSR REJECTED — EMS={putEms ?? ""}. " + + "Compare against EMS-921/EMS-922 in v2-api-support-questions.md."); + }); + } + + // --------------------------------------------------------------------------- + // P5 — productVariant:"dv" + X-Product-Code pinned to the live OV SSL code, no organization + // --------------------------------------------------------------------------- + + /// + /// productVariant:"dv" with X-Product-Code pinned to the catalog's live, non-UCC OV SSL + /// product (productTypeID 16, same resolution as P2) and no organization block — issue + /// 0059's variant/product mismatch. Records reject, DV, or a stalled OV-shaped order. + /// + [SkippableFact] + public async Task P5_DvVariant_PinnedOvProductCode_NoOrganization_RecordsMismatchBehavior() + { + SkipUnlessArmedAndConfigured(); + + string ovSslProductCode = await ResolveProductCodeByTypeIdAsync(Constants.Products.ProductTypeIdsV2[Constants.Products.OvSsl]); + Skip.If(ovSslProductCode == null, + $"No live catalog product found with productTypeID=\"{Constants.Products.ProductTypeIdsV2[Constants.Products.OvSsl]}\" " + + "(non-UCC OV SSL) on this account — P5 cannot resolve a product code to pin. Skipping."); + + string stamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); + string primary = $"gap-p5-{stamp}.{_dcvDomainBase}"; + + var orderReq = new V2CreateSslOrderRequest + { + ProductVariant = "dv", // deliberately mismatched against the pinned OV product code + EmailNotifications = "all", + Requestor = BuildRequestor(), + // Deliberately NO Organization block — issue 0059's exact mismatch shape. + Certificate = new V2CertificateParams { Domain = primary, AutoSecureWww = false }, + Subscription = BuildSubscription(), + Agreement = BuildAgreement(), + TechnicalPointOfContact = BuildTechnicalPointOfContact(), + Remarks = "Issue 0058 P5 probe — productVariant:dv, X-Product-Code pinned to non-UCC OV SSL, no organization." + }; + string requestJson = JsonSerializer.Serialize(orderReq, GetJsonOptions()); + + _output.WriteLine($"Resolved non-UCC OV SSL product code from live catalog: {ovSslProductCode}"); + + await RunCreateThenCancelAsync( + probeId: "P5", + productCodeOrNull: ovSslProductCode, + requestJson: requestJson, + primaryDomain: primary, + extraProbeWork: async (createResp, orderId) => + { + if (string.IsNullOrWhiteSpace(orderId)) + { + _output.WriteLine("No orderId parsed — likely a hard EMS-915-style reject; see the create response above."); + return; + } + + var track = await TrackOrderRawAsync(orderId); + _output.WriteLine(""); + _output.WriteLine("--- Track Order (post-create) ---"); + _output.WriteLine(RedactForLog(track.Body)); + string echoedVariant = TryExtractStringField(track.Body, "productVariant"); + _output.WriteLine($"productVariant echoed on Track Order: {echoedVariant ?? ""} " + + "(compare against the pinned OV product code above to see which one 'won')."); + }); + } + + // --------------------------------------------------------------------------- + // Sweep — find and clean up orders P1-P5 may have orphaned on the sandbox + // --------------------------------------------------------------------------- + + /// + /// P1 and P2 both place an OV-shaped order (productVariant:"ov" + an organization block + /// + one additionalDomains entry) and both hit as a + /// client-side TaskCanceledException (HTTP 0) on a live sandbox run. A client + /// timeout does not prove CERTInext never created the order — if it did, that order is + /// now orphaned on the sandbox with no CARequestID ever recorded by this test + /// process. This sweep answers that by listing the V2 orders report for "today" (UTC), + /// finding every row whose domainName starts with "gap-p" (covers all of + /// P1-P5's own domain naming, not just P1/P2 — any of them could have left an orphan + /// the same way), tracking each one raw, and cancelling it if it is not already + /// cancelled/revoked/rejected — confirming with a fresh GET afterward. + /// + /// Read-only discovery, not a mutation gate: gated by the same + + /// V2-credentials guard as P1-P5 (this sweep can cancel real sandbox orders), but — unlike + /// — it does NOT require + /// CERTINEXT_INBOX_TEST_EMAIL: it never builds a requestor/technicalPointOfContact block, + /// so the placeholder email otherwise threads through + /// (CERTInextConfig.RequestorEmail) is immaterial to a report-list/track/cancel-only run. + /// + /// Uses GET /api/certinext/v2/reports/orders via the already-typed + /// (paging handled internally, + /// domainName confirmed live per 's own + /// doc comment) rather than hand-rolling pagination against + /// a second time. If that call throws, the + /// finding is logged (nothing else in the report envelope carries a domain value to fall + /// back to) and the exception is rethrown — a report failure is a probe-mechanism break, + /// not a CA-response finding. + /// + [SkippableFact] + public async Task Sweep_FindsAndCancelsOrphanedGapProbeOrders() + { + SkipUnlessArmedForSweep(); + + DateTime todayUtc = DateTime.UtcNow.Date; + string from = todayUtc.ToString("yyyy-MM-dd"); + string to = todayUtc.AddDays(1).ToString("yyyy-MM-dd"); // +1 day margin — Probe3 confirmed from/to are inclusive date brackets. + + _output.WriteLine("=== Issue 0058 sweep: orphaned gap-probe orders ==="); + _output.WriteLine($"Report window: GET {Constants.ApiV2.OrdersReportPath}?from={from}&to={to} (UTC 'today' + 1 day margin)."); + + using CERTInextClient client = BuildV2Client(); + + var candidates = new List(); + int rowsScanned = 0; + try + { + await foreach (var row in client.ListOrdersV2Async(from, to, pageSize: 100)) + { + rowsScanned++; + if (!string.IsNullOrWhiteSpace(row.DomainName) && + row.DomainName.StartsWith("gap-p", StringComparison.OrdinalIgnoreCase)) + { + candidates.Add(row); + } + } + } + catch (Exception ex) + { + _output.WriteLine($"FINDING: GET {Constants.ApiV2.OrdersReportPath} (paged) threw: " + + $"{ex.GetType().Name}: {RedactForLog(ex.Message)}"); + _output.WriteLine("Tried: OrderReportEntryV2.DomainName (confirmed-live field) via " + + "CERTInextClient.ListOrdersV2Async, paging page=1.. with size=100, " + + $"from={from}&to={to}. No other field on this report row models a " + + "domain value to fall back to."); + throw; + } + + _output.WriteLine($"Report rows scanned: {rowsScanned}. Candidates (domainName starts with \"gap-p\"): {candidates.Count}."); + + bool foundP1 = false, foundP2 = false; + bool anyCancelFailed = false; + + foreach (var row in candidates) + { + string orderId = row.OrderNumber; + string domainLower = row.DomainName?.ToLowerInvariant() ?? string.Empty; + if (domainLower.StartsWith("gap-p1-")) foundP1 = true; + if (domainLower.StartsWith("gap-p2-")) foundP2 = true; + + _output.WriteLine(""); + _output.WriteLine($"--- Candidate: OrderId={orderId ?? ""} Domain={RedactForLog(row.DomainName)} " + + $"OrderStatus={row.OrderStatus} CertificateStatus={row.CertificateStatus} OrderDate={row.OrderDate} ---"); + + if (string.IsNullOrWhiteSpace(orderId)) + { + _output.WriteLine("No orderNumber on this report row — cannot track or cancel it. Skipping."); + _output.WriteLine($"SUMMARY | OrderId= Domain={RedactForLog(row.DomainName)} " + + "StatusBefore= StatusAfter= ProductVariant= " + + "ResolvedProductCode= AdditionalDomainsPresent=False Cancel=SKIPPED (no orderNumber)"); + continue; + } + + var before = await TrackOrderRawAsync(orderId); + _output.WriteLine($"Track (before): HTTP {before.StatusCode}"); + _output.WriteLine(RedactForLog(before.Body)); + + string statusBefore = TryExtractStringField(before.Body, "status"); + string productVariant = TryExtractStringField(before.Body, "productVariant"); + List productCodeHits = ScanForKeyValues(before.Body, "productcode"); + List additionalDomainHits = ScanForKeyValues(before.Body, "additionaldomains"); + List domainHits = ScanForKeyValues(before.Body, "domain"); + string resolvedProductCode = productCodeHits.Count > 0 + ? string.Join("; ", productCodeHits) + : ""; + + _output.WriteLine($"StatusBefore={statusBefore ?? ""} ProductVariant={productVariant ?? ""} " + + $"ResolvedProductCode={resolvedProductCode}"); + _output.WriteLine(additionalDomainHits.Count > 0 + ? $"additionalDomains field(s) found: {string.Join("; ", additionalDomainHits.Select(RedactForLog))}" + : "No additionalDomains field found on Track Order."); + _output.WriteLine(domainHits.Count > 0 + ? $"domain-related field(s) found: {string.Join("; ", domainHits.Select(RedactForLog))}" + : "No domain-related field found on Track Order."); + + bool alreadyTerminal = + string.Equals(statusBefore, Constants.ApiV2.StatusCancelled, StringComparison.OrdinalIgnoreCase) || + string.Equals(statusBefore, Constants.ApiV2.StatusRevoked, StringComparison.OrdinalIgnoreCase) || + string.Equals(statusBefore, Constants.ApiV2.StatusRejected, StringComparison.OrdinalIgnoreCase); + + string statusAfter = statusBefore; + string cancelOutcome; + + if (alreadyTerminal) + { + cancelOutcome = $"SKIPPED (already {statusBefore})"; + _output.WriteLine($"Order is already terminal ({statusBefore}) — not cancelling."); + } + else + { + try + { + V2CancelOrderOutcome outcome = await client.CancelOrderV2Async( + Constants.ApiV2.FamilySsl, + orderId, + "Issue 0058 sweep — cancelling an orphaned gap-probe order found via the orders report."); + cancelOutcome = outcome.ToString(); + _output.WriteLine($"Cancel outcome: {cancelOutcome}"); + + var after = await TrackOrderRawAsync(orderId); + statusAfter = TryExtractStringField(after.Body, "status"); + _output.WriteLine($"Track (after): HTTP {after.StatusCode}"); + _output.WriteLine(RedactForLog(after.Body)); + } + catch (Exception ex) + { + cancelOutcome = $"FAILED ({ex.GetType().Name}: {RedactForLog(ex.Message)})"; + statusAfter = ""; + anyCancelFailed = true; + _output.WriteLine($"Cancel call FAILED — not retried: {RedactForLog(ex.Message)}"); + } + } + + _output.WriteLine( + $"SUMMARY | OrderId={orderId} Domain={RedactForLog(row.DomainName)} " + + $"StatusBefore={statusBefore ?? ""} StatusAfter={statusAfter ?? ""} " + + $"ProductVariant={productVariant ?? ""} ResolvedProductCode={resolvedProductCode} " + + $"AdditionalDomainsPresent={additionalDomainHits.Count > 0} Cancel={cancelOutcome}"); + } + + _output.WriteLine(""); + _output.WriteLine( + $"SUMMARY | Sweep complete. RowsScanned={rowsScanned} CandidatesFound={candidates.Count} " + + $"P1Found={foundP1} P2Found={foundP2}"); + + anyCancelFailed.Should().BeFalse( + "one or more gap-probe orders could not be cancelled during the sweep — see the FAILED " + + "cancel outcome(s) logged above; per issue 0058's own rule, this sweep does not retry a " + + "failed cancel automatically."); + } + + // --------------------------------------------------------------------------- + // Shared create-then-cancel runner + // --------------------------------------------------------------------------- + + /// + /// Places exactly one raw SSL create-order call, logs and records the outcome, invokes + /// for any probe-specific follow-up read/write, then + /// unconditionally cancels the order in a finally and confirms cancellation with + /// a fresh read-only GET — even if throws. Emits the + /// probe's one-line summary at the end. + /// + private async Task RunCreateThenCancelAsync( + string probeId, + string productCodeOrNull, + string requestJson, + string primaryDomain, + Func extraProbeWork, + TimeSpan? createTimeoutOverride = null) + { + _output.WriteLine($"=== Issue 0058 {probeId} probe ==="); + _output.WriteLine($"Domain={primaryDomain} ProductCode={productCodeOrNull ?? ""}"); + _output.WriteLine($"Request body: {RedactForLog(requestJson)}"); + + string orderId = null; + RawApiResponse createResp = null; + try + { + createResp = await PlaceSslOrderRawAsync(productCodeOrNull, requestJson, createTimeoutOverride); + _output.WriteLine(""); + _output.WriteLine("--- Create-order response ---"); + _output.WriteLine($"HTTP {createResp.StatusCode}"); + _output.WriteLine(RedactForLog(createResp.Body)); + + orderId = TryExtractOrderId(createResp.Body); + + if (extraProbeWork != null) + await extraProbeWork(createResp, orderId); + } + finally + { + await CancelAndConfirmAsync(probeId, orderId); + } + + string status = createResp != null ? TryExtractStringField(createResp.Body, "status") : null; + string emsCode = createResp != null ? TryExtractEmsCode(createResp.Body) : null; + _output.WriteLine(""); + _output.WriteLine( + $"SUMMARY | Probe={probeId} HTTP={createResp?.StatusCode.ToString() ?? ""} " + + $"EMS={emsCode ?? ""} Status={status ?? ""} OrderId={orderId ?? ""}"); + } + + /// + /// Cancels (no-op, logged, if null/empty — some probes never + /// get an orderId back), then always does a fresh read-only GET afterward so cleanup is + /// verifiable regardless of the cancel outcome. A failed cancel call itself (not merely + /// a non-2xx CANCEL response — see below) or a failed confirming GET is a probe-mechanism + /// break and is asserted; the CA's own cancel response code is logged either way. + /// + private async Task CancelAndConfirmAsync(string probeId, string orderId) + { + if (string.IsNullOrWhiteSpace(orderId)) + { + _output.WriteLine(""); + _output.WriteLine($"[{probeId}] No orderId to cancel — nothing to clean up."); + return; + } + + _output.WriteLine(""); + _output.WriteLine($"[{probeId}] Cancelling order {orderId}..."); + var cancelResp = await CancelOrderRawAsync(orderId, + $"Issue 0058 {probeId} probe — cleaning up after recording the CA's response."); + _output.WriteLine($"Cancel response: HTTP {cancelResp.StatusCode}: {RedactForLog(cancelResp.Body)}"); + + var after = await TrackOrderRawAsync(orderId); + _output.WriteLine($"Post-cancel status (fresh GET): HTTP {after.StatusCode}: {RedactForLog(after.Body)}"); + string afterStatus = TryExtractStringField(after.Body, "status"); + + cancelResp.IsSuccessful.Should().BeTrue( + $"[{probeId}] cleanup must succeed to avoid leaving a live order on the sandbox — " + + $"HTTP {cancelResp.StatusCode}: {RedactForLog(cancelResp.Body)}"); + + _output.WriteLine($"[{probeId}] Cancel outcome confirmed — post-cancel status: {afterStatus ?? ""}."); + } + + // --------------------------------------------------------------------------- + // Shared request-body builders + // --------------------------------------------------------------------------- + + private string RequestorName() => _fixture.IsConfigured && !string.IsNullOrWhiteSpace(_fixture.Config.RequestorName) + ? _fixture.Config.RequestorName + : "Keyfactor Test"; + + private string RequestorPhone() => _fixture.IsConfigured + ? CERTInextCAPlugin.ComposeV2Phone(_fixture.Config.RequestorIsdCode, _fixture.Config.RequestorMobileNumber) + : "+10000000000"; + + private V2Requestor BuildRequestor() => new V2Requestor + { + Name = RequestorName(), + Email = _inboxTestEmail, + Phone = RequestorPhone(), + Designation = "IT Administrator" + }; + + private V2TechnicalPointOfContact BuildTechnicalPointOfContact() => new V2TechnicalPointOfContact + { + Name = RequestorName(), + Email = _inboxTestEmail, + Phone = RequestorPhone(), + Designation = "Technical Contact" + }; + + private V2SubscriptionParams BuildSubscription() => new V2SubscriptionParams + { + ValidityYears = 1, + AutoRenew = false, + RenewBeforeDays = 30 + }; + + private V2AgreementParams BuildAgreement() => new V2AgreementParams + { + SignerName = RequestorName(), + SignerIp = "127.0.0.1", + SignerPlace = "Gateway Lab", + Accepted = true + }; + + // --------------------------------------------------------------------------- + // Live catalog resolution (never hard-code a pinned code — issue 0058's own rule for P2, + // applied here to P4/P5 too for the same reason) + // --------------------------------------------------------------------------- + + private CERTInextClient BuildV2Client() => new CERTInextClient(new CERTInextConfig + { + ApiUrl = _v2ApiUrl, + UseV2Api = true, + OAuthClientId = _v2ClientId, + OAuthClientSecret = _v2ClientSecret, + RequestorName = RequestorName(), + RequestorEmail = _inboxTestEmail, + SignerIp = "127.0.0.1", + SignerPlace = "Gateway Lab", + PageSize = 100 + }); + + private async Task ResolveProductCodeByTypeIdAsync(string productTypeId) + { + using CERTInextClient client = BuildV2Client(); + List catalog = await client.GetProductDetailsV2Async(); + return catalog.FirstOrDefault(p => string.Equals(p.ProductTypeId, productTypeId, StringComparison.OrdinalIgnoreCase)) + ?.ProductCode; + } + + // --------------------------------------------------------------------------- + // BouncyCastle CSR generation (repo convention — never System.Security.Cryptography) + // --------------------------------------------------------------------------- + + private static Pkcs10CertificationRequest GenerateCsr(string commonName) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + var keyPair = keyGen.GenerateKeyPair(); + + var subject = new X509Name($"CN={commonName}"); + return new Pkcs10CertificationRequest("SHA256withRSA", subject, keyPair.Public, null, keyPair.Private); + } + + private static string CsrToPem(Pkcs10CertificationRequest csr) => + "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + + /// Headerless Base64 DER — matches the V2 spec's own inline "csr" create-order samples (no PEM markers, no line breaks). + private static string CsrToBase64Der(Pkcs10CertificationRequest csr) => + Convert.ToBase64String(csr.GetEncoded()); + + // --------------------------------------------------------------------------- + // Local raw-HTTP helpers (place/track/submit-csr/non-throwing-cancel) — NOT extracted + // to V2RawProbeHelpers: only the token-fetch and throwing CancelSslOrderRawAsync were + // shared duplicates across files (issue 0058's explicit ask); these are specific to this + // file's non-throwing, raw-status/body-returning needs. + // --------------------------------------------------------------------------- + + private sealed class RawApiResponse + { + public int StatusCode { get; set; } + public string Body { get; set; } + public bool IsSuccessful { get; set; } + } + + private static RawApiResponse ToRawApiResponse(RestResponse resp) + { + string body = resp.Content; + if (string.IsNullOrEmpty(body) && !resp.IsSuccessful) + { + body = resp.ErrorException != null + ? $"" + : $""; + } + + return new RawApiResponse + { + StatusCode = (int)resp.StatusCode, + Body = body, + IsSuccessful = resp.IsSuccessful + }; + } + + /// + /// Raw HTTP POST to /api/certinext/v2/ssl-certificates. When + /// is null, the X-Product-Code header is omitted + /// entirely (not sent empty) — P1's exact probe condition. Does not throw on non-2xx. + /// defaults to ; P1/P2 pass + /// instead (see that field's comment). + /// + private async Task PlaceSslOrderRawAsync( + string productCodeOrNull, string requestJson, TimeSpan? timeoutOverride = null) + { + TimeSpan timeout = timeoutOverride ?? ProbeTimeout; + string accessToken = await V2RawProbeHelpers.GetV2AccessTokenAsync(_v2ApiUrl, _v2ClientId, _v2ClientSecret, timeout); + + using var apiClient = V2RawProbeHelpers.NewApiClient(_v2ApiUrl.TrimEnd('/'), timeout); + var req = new RestRequest(Constants.ApiV2.SslCertificatesPath, Method.Post); + req.AddHeader("Authorization", $"Bearer {accessToken}"); + req.AddHeader("Accept", "application/json"); + if (productCodeOrNull != null) + req.AddHeader("X-Product-Code", productCodeOrNull); + req.AddHeader("Idempotency-Key", Guid.NewGuid().ToString()); + req.AddJsonBody(requestJson); + + var resp = await apiClient.ExecuteAsync(req); + return ToRawApiResponse(resp); + } + + private async Task TrackOrderRawAsync(string orderId) + { + string accessToken = await V2RawProbeHelpers.GetV2AccessTokenAsync(_v2ApiUrl, _v2ClientId, _v2ClientSecret, ProbeTimeout); + + using var apiClient = V2RawProbeHelpers.NewApiClient(_v2ApiUrl.TrimEnd('/'), ProbeTimeout); + var req = new RestRequest($"{Constants.ApiV2.SslCertificatesPath}/{orderId}", Method.Get); + req.AddHeader("Authorization", $"Bearer {accessToken}"); + req.AddHeader("Accept", "application/json"); + + var resp = await apiClient.ExecuteAsync(req); + return ToRawApiResponse(resp); + } + + /// + /// Raw HTTP PUT to /api/certinext/v2/ssl-certificates/{orderId}/csr — the same + /// { "csr", "attested" } body shape as V2SubmitCsrRequest, sent raw (rather than via + /// CERTInextClient.SubmitCsrV2Async, which throws on failure) so a rejection's exact + /// HTTP status and EMS code can be recorded rather than only an exception message. + /// + private async Task SubmitCsrRawAsync(string orderId, string csrPem, bool attested = false) + { + string accessToken = await V2RawProbeHelpers.GetV2AccessTokenAsync(_v2ApiUrl, _v2ClientId, _v2ClientSecret, ProbeTimeout); + + using var apiClient = V2RawProbeHelpers.NewApiClient(_v2ApiUrl.TrimEnd('/'), ProbeTimeout); + var req = new RestRequest($"{Constants.ApiV2.SslCertificatesPath}/{orderId}/csr", Method.Put); + req.AddHeader("Authorization", $"Bearer {accessToken}"); + req.AddHeader("Accept", "application/json"); + req.AddJsonBody(JsonSerializer.Serialize(new V2SubmitCsrRequest { Csr = csrPem, Attested = attested }, GetJsonOptions())); + + var resp = await apiClient.ExecuteAsync(req); + return ToRawApiResponse(resp); + } + + /// + /// Raw HTTP POST to /api/certinext/v2/ssl-certificates/{orderId}/cancel — non-throwing + /// (unlike V2RawProbeHelpers.CancelSslOrderRawAsync, which throws) so cleanup failure can + /// be logged and asserted with full detail rather than only an exception message. + /// + private async Task CancelOrderRawAsync(string orderId, string reason) + { + string accessToken = await V2RawProbeHelpers.GetV2AccessTokenAsync(_v2ApiUrl, _v2ClientId, _v2ClientSecret, ProbeTimeout); + + using var apiClient = V2RawProbeHelpers.NewApiClient(_v2ApiUrl.TrimEnd('/'), ProbeTimeout); + var req = new RestRequest($"{Constants.ApiV2.SslCertificatesPath}/{orderId}/cancel", Method.Post); + req.AddHeader("Authorization", $"Bearer {accessToken}"); + req.AddHeader("Accept", "application/json"); + req.AddJsonBody(new { reason }); + + var resp = await apiClient.ExecuteAsync(req); + return ToRawApiResponse(resp); + } + + // --------------------------------------------------------------------------- + // Parsing / redaction helpers + // --------------------------------------------------------------------------- + + private static JsonSerializerOptions GetJsonOptions() => new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + /// + /// Redacts credentials and personal data (emails/names) before any raw body reaches + /// ITestOutputHelper — issue 0058's logging constraint. logSensitiveRequestData: + /// false always, so the real inbox email this file carries is never written to a + /// log file in the clear, matching the repo-wide default-off PII posture (see + /// CERTInextClient.ApplyLoggingRedaction; reachable here via + /// InternalsVisibleTo("CERTInext.IntegrationTests")). Domain names are never touched by + /// this redaction — only email/other-personal-data fields are — so the DCV/order-shape + /// detail these probes exist to observe is unaffected. + /// + private static string RedactForLog(string body) => + CERTInextClient.ApplyLoggingRedaction(body, logSensitiveRequestData: false); + + private static string TryExtractOrderId(string body) + { + if (string.IsNullOrWhiteSpace(body)) return null; + try + { + using var doc = JsonDocument.Parse(body); + return doc.RootElement.TryGetProperty("orderId", out var el) ? el.GetString() : null; + } + catch (JsonException) + { + return null; + } + } + + private static string TryExtractStringField(string body, string fieldName) + { + if (string.IsNullOrWhiteSpace(body)) return null; + try + { + using var doc = JsonDocument.Parse(body); + return doc.RootElement.TryGetProperty(fieldName, out var el) && el.ValueKind == JsonValueKind.String + ? el.GetString() + : null; + } + catch (JsonException) + { + return null; + } + } + + private static readonly Regex EmsCodeRegex = new Regex(@"\[?EMS-(\d+)\]?", RegexOptions.Compiled); + + private static string TryExtractEmsCode(string body) + { + if (string.IsNullOrWhiteSpace(body)) return null; + var match = EmsCodeRegex.Match(body); + return match.Success ? $"EMS-{match.Groups[1].Value}" : null; + } + + /// + /// Best-effort recursive scan of a raw JSON body for any property whose name contains + /// (case-insensitive), returning "name=value" for + /// each hit. Used where this repo's response DTOs do not model a confirmed field for + /// what a probe needs to check (e.g. no productCode on any order response, no confirmed + /// additionalDomains echo — issues 0042/0058). Never throws; returns an empty list for + /// unparseable or empty bodies. + /// + private static List ScanForKeyValues(string body, string keyNameSubstring) + { + var found = new List(); + if (string.IsNullOrWhiteSpace(body)) return found; + + try + { + using var doc = JsonDocument.Parse(body); + Walk(doc.RootElement, keyNameSubstring, found); + } + catch (JsonException) + { + // Unparseable body — nothing to scan; caller already logs the raw body separately. + } + + return found; + + static void Walk(JsonElement element, string needle, List accumulator) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (JsonProperty prop in element.EnumerateObject()) + { + if (prop.Name.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0) + accumulator.Add($"{prop.Name}={prop.Value}"); + Walk(prop.Value, needle, accumulator); + } + break; + case JsonValueKind.Array: + foreach (JsonElement item in element.EnumerateArray()) + Walk(item, needle, accumulator); + break; + } + } + } + } +} diff --git a/CERTInext.IntegrationTests/V2LifecycleTests.cs b/CERTInext.IntegrationTests/V2LifecycleTests.cs new file mode 100644 index 0000000..d90c198 --- /dev/null +++ b/CERTInext.IntegrationTests/V2LifecycleTests.cs @@ -0,0 +1,777 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// Plugin-level integration tests for the V2 (OAuth2) API path — Tiers 1–3 (no DCV + /// build required). Unlike , which exercises + /// methods directly, these tests drive the full + /// IAnyCAPlugin surface (Enroll, Revoke, GetSingleRecord, + /// Synchronize) the way Keyfactor Command actually calls the plugin. + /// + /// All tests are gated behind CERTINEXT_USE_V2_API=1 plus valid V2 OAuth2 + /// credentials and skip gracefully otherwise. See for the + /// full list of required environment variables. + /// + public class V2LifecycleTests : IClassFixture + { + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + + private readonly string _v2ApiUrl; + private readonly string _v2ClientId; + private readonly string _v2ClientSecret; + private readonly string _v2ProductCode; + private readonly string _v2Domain; + private readonly bool _v2Enabled; + + public V2LifecycleTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + + var env = V2EnvHelper.LoadAndPromote(); + + _v2ApiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL"); + _v2ClientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID"); + _v2ClientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET"); + _v2ProductCode = V2EnvHelper.GetEnv(env, "CERTINEXT_PRODUCT_CODE", "842"); + _v2Domain = V2EnvHelper.GetEnv(env, "CERTINEXT_DCV_DOMAIN", "test.example.com"); + + _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API")) + && !string.IsNullOrWhiteSpace(_v2ApiUrl) + && !string.IsNullOrWhiteSpace(_v2ClientId) + && !string.IsNullOrWhiteSpace(_v2ClientSecret); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + /// + /// Builds a wired for the V2 API. A single + /// now serves both modes (issues/0022 config + /// consolidation) — in V2 mode it is the V2 base URL, and V2 auth reuses + /// /. + /// Deliberately does NOT set any V1-only field (ApiKey/AccountNumber/AuthMode) — proving + /// those are optional when UseV2Api is true is itself part of what these tests exercise + /// (Synchronize now uses V2 /reports/orders, not V1 GetOrderReport). + /// + private CERTInextConfig BuildV2Config(bool dcvEnabled = false, int? pageSize = null, int? syncLookbackHours = null) + { + return new CERTInextConfig + { + ApiUrl = _v2ApiUrl, + UseV2Api = true, + OAuthClientId = _v2ClientId, + OAuthClientSecret = _v2ClientSecret, + + RequestorName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test", + RequestorEmail = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "0000000000", + SignerPlace = "Gateway Lab", + SignerIp = "127.0.0.1", + + PageSize = pageSize ?? 100, + + // Default 72h (Constants.ApiV2.DefaultSyncLookbackHours) is always added on top + // of lastSync regardless of how recent it is — on a busy shared sandbox that + // means every delta-sync test touches several days of orders (each issued row + // costs a live certificate download) unless narrowed here. See issues/0022's + // "V2 sync per-row download cost" note. + V2SyncLookbackHours = syncLookbackHours ?? Constants.ApiV2.DefaultSyncLookbackHours, + + DcvEnabled = dcvEnabled, + DcvPropagationDelaySeconds = 5, + DcvTimeoutMinutes = 3 + }; + } + + /// + /// Constructs a plugin instance wired to a real + /// built from (or a fresh + /// if none is supplied). Uses the two-arg test constructor so no + /// Initialize call is required. + /// + private CERTInextCAPlugin BuildV2Plugin(CERTInextConfig config = null) + { + config ??= BuildV2Config(); + var client = new CERTInextClient(config); + return new CERTInextCAPlugin(client, config); + } + + /// + /// Generates a fresh RSA-2048 PKCS#10 CSR for the given common name using + /// BouncyCastle only. + /// + private static string GenerateCsrPem(string commonName) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + var keyPair = keyGen.GenerateKeyPair(); + + var subject = new X509Name($"CN={commonName}"); + var csr = new Pkcs10CertificationRequest("SHA256withRSA", subject, keyPair.Public, null, keyPair.Private); + + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + /// + /// Runs a full synchronization via the plugin and returns all collected records. + /// + private static async Task> RunSyncAsync( + CERTInextCAPlugin plugin, DateTime? lastSync = null, bool fullSync = true) + { + var buffer = new BlockingCollection(boundedCapacity: 10_000); + var collected = new List(); + + var syncTask = Task.Run(async () => + { + await plugin.Synchronize( + buffer, + lastSync: lastSync, + fullSync: fullSync, + cancelToken: CancellationToken.None); + + if (!buffer.IsAddingCompleted) + buffer.CompleteAdding(); + }); + + foreach (var record in buffer.GetConsumingEnumerable()) + collected.Add(record); + + await syncTask; + return collected; + } + + /// + /// Polls until the order reaches + /// GENERATED or FAILED, or the poll budget is exhausted. + /// + private static async Task WaitForIssuanceAsync( + CERTInextCAPlugin plugin, string caRequestId, int maxPolls = 6, int delaySeconds = 15) + { + AnyCAPluginCertificate record = null; + for (int poll = 1; poll <= maxPolls; poll++) + { + record = await plugin.GetSingleRecord(caRequestId); + if (record?.Status == (int)EndEntityStatus.GENERATED + || record?.Status == (int)EndEntityStatus.FAILED) + break; + if (poll < maxPolls) + await Task.Delay(TimeSpan.FromSeconds(delaySeconds)); + } + return record; + } + + private EnrollmentProductInfo BuildV2ProductInfo() => + new EnrollmentProductInfo + { + ProductID = _v2ProductCode, + ProductParameters = new Dictionary + { + [Constants.EnrollmentParam.ProductCode] = _v2ProductCode, + [Constants.EnrollmentParam.ProfileId] = _v2ProductCode, + } + }; + + /// + /// Resolves the order ID to exercise for tests that need a pre-existing V2 order. + /// Reads only CERTINEXT_V2_ORDER_ID — deliberately does not fall back to an + /// order ID produced by another test in this class, so results do not depend on + /// test run order (see issues/0017, gap G7). + /// + private static string ResolveOrderId() + => Environment.GetEnvironmentVariable("CERTINEXT_V2_ORDER_ID"); + + /// + /// Returns an issued (GENERATED) V2 order to exercise, plus the plugin instance + /// that owns it. Prefers CERTINEXT_V2_ORDER_ID if set; otherwise enrolls a + /// fresh order in this test and polls (bounded) for issuance, so tests using this + /// helper are self-contained and don't depend on env state or another test's run + /// order (V2_TEST_GAP_PLAN.md Phase 1.4b). Skip.Ifs (via ) + /// when no env ID is set and the freshly-enrolled order never reaches GENERATED + /// within the poll budget — sandboxes may require DCV to auto-issue. + /// + private async Task<(string orderId, CERTInextCAPlugin plugin)> EnsureIssuedOrderIdAsync() + { + var plugin = BuildV2Plugin(); + string envOrderId = ResolveOrderId(); + if (!string.IsNullOrWhiteSpace(envOrderId)) + return (envOrderId, plugin); + + var enrollResult = await plugin.Enroll( + csr: GenerateCsrPem(_v2Domain), + subject: $"CN={_v2Domain}", + san: new Dictionary { ["dns"] = new[] { _v2Domain } }, + productInfo: BuildV2ProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + enrollResult.Should().NotBeNull(); + enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace(); + _output.WriteLine( + $"EnsureIssuedOrderIdAsync: no CERTINEXT_V2_ORDER_ID set — enrolled fresh order {enrollResult.CARequestID}."); + + var record = await WaitForIssuanceAsync(plugin, enrollResult.CARequestID); + Skip.If(record?.Status != (int)EndEntityStatus.GENERATED, + $"Freshly-enrolled order '{enrollResult.CARequestID}' did not reach GENERATED within the poll " + + $"budget (status={record?.Status}) — sandbox may require DCV to auto-issue. Set " + + "CERTINEXT_V2_ORDER_ID to a known-issued order to bypass enrollment."); + + return (enrollResult.CARequestID, plugin); + } + + // --------------------------------------------------------------------------- + // Gap 1 — Enroll() via the plugin, V2 path + // --------------------------------------------------------------------------- + + [SkippableFact] + public async Task Enroll_V2_ReturnsCARequestID() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + var plugin = BuildV2Plugin(); + + var result = await plugin.Enroll( + csr: GenerateCsrPem(_v2Domain), + subject: $"CN={_v2Domain}", + san: new Dictionary { ["dns"] = new[] { _v2Domain } }, + productInfo: BuildV2ProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Should().NotBeNull(); + result.CARequestID.Should().NotBeNullOrWhiteSpace( + "V2 Enroll must return a non-empty CARequestID — it is the stable foreign key for all future operations"); + result.Status.Should().NotBe((int)EndEntityStatus.FAILED, + $"V2 Enroll must not FAILED at submission time; message: {result.StatusMessage}"); + + _output.WriteLine($"CARequestID: {result.CARequestID}"); + _output.WriteLine($"Status: {result.Status}"); + _output.WriteLine($"Message: {result.StatusMessage}"); + } + + // --------------------------------------------------------------------------- + // Gap 2 — Revoke() via the plugin, V2 path + // --------------------------------------------------------------------------- + + /// + /// Opt-in cleanup/probe: revokes one explicit, already-issued order through the plugin's + /// V2 Revoke with reason superseded (4), outside Command. Used to clean up lab orders + /// Command never imported and to reproduce an out-of-band CA-side revoke (issues/0049). + /// Gated behind CERTINEXT_REVOKE_ORDER_ID; never retries. + /// + [SkippableFact] + public async Task Revoke_V2_ExplicitOrder_Superseded() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + string orderId = Environment.GetEnvironmentVariable("CERTINEXT_REVOKE_ORDER_ID"); + Skip.If(string.IsNullOrWhiteSpace(orderId), "CERTINEXT_REVOKE_ORDER_ID not set — skipping."); + + var plugin = BuildV2Plugin(); + var before = await plugin.GetSingleRecord(orderId); + _output.WriteLine($"Before: CARequestID={orderId}, Status={before?.Status}"); + Skip.If(before?.Status != (int)EndEntityStatus.GENERATED, + $"Order '{orderId}' is in status {before?.Status} (not GENERATED) — not revoking."); + + int revokeResult = await plugin.Revoke(orderId, hexSerialNumber: string.Empty, revocationReason: 4 /* superseded */); + _output.WriteLine($"Revoke result: {revokeResult}"); + + var after = await plugin.GetSingleRecord(orderId); + _output.WriteLine($"After: Status={after?.Status}, RevocationDate={after?.RevocationDate:o}, RevocationReason={after?.RevocationReason}"); + revokeResult.Should().Be((int)EndEntityStatus.REVOKED); + } + + [SkippableFact] + public async Task Revoke_V2_IssuedOrder_ReturnsRevoked() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + var (orderId, plugin) = await EnsureIssuedOrderIdAsync(); + + var current = await plugin.GetSingleRecord(orderId); + Skip.If(current?.Status != (int)EndEntityStatus.GENERATED, + $"Order '{orderId}' is in status {current?.Status} (not GENERATED) — revocation requires an issued certificate; skipping."); + + int revokeResult; + try + { + revokeResult = await plugin.Revoke(orderId, hexSerialNumber: string.Empty, revocationReason: 1 /* keyCompromise */); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("still being processed")) + { + // Documented sandbox-timing quirk: the CA reports 'issued' via GetSingleRecord + // while still internally finalizing the order, and rejects revoke with 422 + // ("Certificate Request still being processed") in that window (issues/0019). + // Retry once after a short delay before giving up — any other exception (or a + // second failure) must fail the test rather than be swallowed here. + _output.WriteLine($"Revoke rejected as still-processing; retrying once after 15s: {ex.Message}"); + await Task.Delay(TimeSpan.FromSeconds(15)); + try + { + revokeResult = await plugin.Revoke(orderId, hexSerialNumber: string.Empty, revocationReason: 1); + } + catch (InvalidOperationException ex2) when (ex2.Message.Contains("still being processed")) + { + Skip.If(true, + $"Order '{orderId}' tracked as GENERATED but CA rejected revocation twice (sandbox timing): {ex2.Message}"); + return; // unreachable + } + } + + revokeResult.Should().Be((int)EndEntityStatus.REVOKED, + "V2 Revoke must return the REVOKED status code on success"); + } + + // --------------------------------------------------------------------------- + // Gap 3 — GetSingleRecord() via the plugin, V2 path + // --------------------------------------------------------------------------- + + [SkippableFact] + public async Task GetSingleRecord_V2_Plugin_ReturnsDetails() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + string orderId = ResolveOrderId(); + Skip.If(string.IsNullOrWhiteSpace(orderId), + "No V2 order ID available — set CERTINEXT_V2_ORDER_ID to a real V2 order to run this test."); + + var plugin = BuildV2Plugin(); + var record = await plugin.GetSingleRecord(orderId); + + record.Should().NotBeNull("plugin.GetSingleRecord must return a record for a known V2 order"); + record.CARequestID.Should().Be(orderId); + _output.WriteLine($"CARequestID: {record.CARequestID}"); + _output.WriteLine($"Status: {record.Status}"); + _output.WriteLine($"ProductID: {record.ProductID}"); + } + + // --------------------------------------------------------------------------- + // Gap 4 — Enroll -> Synchronize -> Revoke, full V2 lifecycle via the plugin + // --------------------------------------------------------------------------- + + [SkippableFact] + public async Task Enroll_Synchronize_Revoke_V2_FullLifecycle() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + // Narrow lookback (1h) — see issues/0022's "V2 sync per-row download cost" note; + // the plugin's default 72h margin makes an un-narrowed delta sync slow against + // this busy shared sandbox, and the order enrolled below is only seconds old. + var config = BuildV2Config(syncLookbackHours: 1); + var plugin = BuildV2Plugin(config); + + // --- Enroll --- + var enrollResult = await plugin.Enroll( + csr: GenerateCsrPem(_v2Domain), + subject: $"CN={_v2Domain}", + san: new Dictionary { ["dns"] = new[] { _v2Domain } }, + productInfo: BuildV2ProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + enrollResult.Should().NotBeNull(); + enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace(); + enrollResult.Status.Should().NotBe((int)EndEntityStatus.FAILED, + $"V2 Enroll must not FAILED at submission time; message: {enrollResult.StatusMessage}"); + + _output.WriteLine($"Enrolled V2 order {enrollResult.CARequestID}, status={enrollResult.Status}"); + + // --- Synchronize (V2 /reports/orders — issues/0022) --- + // Delta sync (fullSync=false, lastSync=recent) rather than a full historical + // pull — this sandbox account has accumulated 1000+ orders from prior test + // runs, and a full sync of the entire history is unnecessarily slow here; the + // order we just enrolled is recent, so a delta sync (with the configured + // lookback window) is sufficient to prove it surfaces via Synchronize. + var synced = await RunSyncAsync(BuildV2Plugin(config), lastSync: DateTime.UtcNow.AddHours(-1), fullSync: false); + synced.Should().Contain( + r => r.CARequestID == enrollResult.CARequestID, + $"the newly enrolled V2 order '{enrollResult.CARequestID}' must appear in a delta sync " + + "via V2 /reports/orders"); + + var syncedRecord = synced.First(r => r.CARequestID == enrollResult.CARequestID); + _output.WriteLine($"Synced record status: {syncedRecord.Status}"); + + // --- Revoke — only if the sandbox has already auto-issued --- + if (syncedRecord.Status != (int)EndEntityStatus.GENERATED) + { + Skip.If(true, + $"Order '{enrollResult.CARequestID}' is in status {syncedRecord.Status} (not GENERATED) — " + + "sandbox may not auto-issue a V2 order without DCV; skipping revoke step."); + } + + int revokeResult; + try + { + revokeResult = await plugin.Revoke(enrollResult.CARequestID, hexSerialNumber: string.Empty, revocationReason: 1); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("still being processed")) + { + // Documented sandbox-timing quirk: the sandbox has been observed to report an + // order as 'issued' via TrackOrder/GetSingleRecord while still internally + // finalizing it, and reject a revoke attempted in that window with 422 + // "Certificate Request still being processed" (see issues/0019). Retry once + // after a short delay before giving up — any other exception (e.g. the + // camelCase-reason HTTP 400 that 0019 describes) must fail the test rather + // than be swallowed here. + _output.WriteLine($"Revoke rejected as still-processing; retrying once after 15s: {ex.Message}"); + await Task.Delay(TimeSpan.FromSeconds(15)); + try + { + revokeResult = await plugin.Revoke(enrollResult.CARequestID, hexSerialNumber: string.Empty, revocationReason: 1); + } + catch (InvalidOperationException ex2) when (ex2.Message.Contains("still being processed")) + { + Skip.If(true, + $"Order '{enrollResult.CARequestID}' tracked as GENERATED but CA rejected revocation " + + $"twice (sandbox timing): {ex2.Message}"); + return; // unreachable + } + } + + revokeResult.Should().Be((int)EndEntityStatus.REVOKED, + "Revoke must return the REVOKED status code on success"); + } + + // --------------------------------------------------------------------------- + // Gap 9 — GetSingleRecord() cert-body regression, V2 path + // --------------------------------------------------------------------------- + + [SkippableFact] + public async Task GetSingleRecord_V2_IssuedOrder_HasParseableCertBody() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + var (orderId, plugin) = await EnsureIssuedOrderIdAsync(); + var record = await WaitForIssuanceAsync(plugin, orderId, maxPolls: 1); + + Skip.If(record?.Status != (int)EndEntityStatus.GENERATED, + $"Order '{orderId}' is not GENERATED (status={record?.Status}) — skipping cert-body check."); + + record!.Certificate.Should().NotBeNullOrWhiteSpace( + "GetSingleRecord must populate the PEM body for a GENERATED V2 order"); + record.Certificate.Should().StartWith("-----BEGIN CERTIFICATE-----"); + + // record.Certificate may be the leaf cert alone, or the leaf followed by one or + // more chain PEM blocks (AssembleV2CertChain concatenates them) — extract only the + // FIRST block. Naively stripping every BEGIN/END marker and decoding the + // concatenation as one base64 blob breaks as soon as a chain is present, because + // each block's own '=' padding then lands mid-string, which is illegal base64. + var firstBlock = System.Text.RegularExpressions.Regex.Match( + record.Certificate, + @"-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----", + System.Text.RegularExpressions.RegexOptions.Singleline); + firstBlock.Success.Should().BeTrue("the certificate body must contain at least one PEM block"); + + var b64 = firstBlock.Groups[1].Value + .Replace("\r", string.Empty).Replace("\n", string.Empty).Trim(); + + Action parse = () => new Org.BouncyCastle.X509.X509CertificateParser().ReadCertificate(Convert.FromBase64String(b64)); + parse.Should().NotThrow("the issued V2 certificate's leaf PEM block must be parseable"); + } + + // --------------------------------------------------------------------------- + // Gap 10 — GetSingleRecord() across all synced orders, V2-configured plugin + // --------------------------------------------------------------------------- + + /// + /// Runs a delta sync via V2 /reports/orders with a V2-configured (UseV2Api=true) + /// plugin, then calls GetSingleRecord for a sample of the resulting CARequestIDs. + /// All sampled IDs are now V2-native (from the V2 report itself, not a V1 listing), so + /// they are expected to resolve via the V2 family probe; + /// is tolerated only as a defensive allowance (e.g. an order deleted between sync and + /// this call) — this test's real job is to guard against any *other* unhandled exception + /// type escaping GetSingleRecord. + /// + [SkippableFact] + public async Task GetSingleRecord_V2_AllSyncedOrders_DoNotThrow() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + // Narrow lookback (1h) — this sandbox account has 1000+ historical orders, and the + // plugin's default 72h lookback margin is always added on top of lastSync + // regardless of how recent it is, so an un-narrowed delta sync here would touch + // several days of orders. Every issued row costs a live certificate download, and + // family resolution costs a sequential TrackOrder probe when not already known + // (see issues/0022's "V2 sync per-row download cost" note) — an un-narrowed window + // was observed to take several minutes against this shared sandbox. + var plugin = BuildV2Plugin(BuildV2Config(syncLookbackHours: 1)); + var synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddHours(-1), fullSync: false); + synced.Should().NotBeNull(); + synced.Should().NotBeEmpty( + "the delta sync window must return at least one record from this sandbox account to sample " + + "GetSingleRecord against — an empty sync makes the rest of this test vacuous (see gap G6)"); + + var sample = synced.Take(10).ToList(); + _output.WriteLine($"Sampling {sample.Count} of {synced.Count} synced records for GetSingleRecord (V2-configured plugin)."); + + int ok = 0, keyNotFound = 0; + foreach (var rec in sample) + { + try + { + await plugin.GetSingleRecord(rec.CARequestID); + ok++; + } + catch (KeyNotFoundException) + { + // Tolerated defensively (e.g. sandbox timing/deletion) — every sampled ID + // came from the V2 report itself, so this should be rare, not expected. + keyNotFound++; + } + } + + _output.WriteLine($"GetSingleRecord results: {ok} succeeded, {keyNotFound} KeyNotFoundException."); + (ok + keyNotFound).Should().Be(sample.Count, + "every sampled GetSingleRecord call must either succeed or throw the tolerated " + + "KeyNotFoundException — any other exception type must escape this loop and fail the test"); + } + + // --------------------------------------------------------------------------- + // Gap 11 — Synchronize() uses V2 /reports/orders when UseV2Api=true (issues/0022) + // --------------------------------------------------------------------------- + + [SkippableFact] + public async Task Sync_V2_UsesV2ReportsOrders_ReturnsRecords() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + // Narrow lookback (1h) — see the comment in GetSingleRecord_V2_AllSyncedOrders_DoNotThrow + // above for why the plugin's default 72h margin makes an un-narrowed delta sync slow + // against this shared, busy sandbox (issues/0022's "V2 sync per-row download cost"). + var plugin = BuildV2Plugin(BuildV2Config(syncLookbackHours: 1)); + var synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddHours(-1), fullSync: false); + + synced.Should().NotBeNull(); + synced.Should().NotBeEmpty( + "Synchronize must return the account's recent order inventory via V2 /reports/orders " + + "(issues/0022 — Synchronize no longer falls back to V1 GetOrderReport when UseV2Api=true)"); + synced.Should().OnlyContain(r => !string.IsNullOrWhiteSpace(r.CARequestID)); + + _output.WriteLine($"Synchronize (V2 /reports/orders) returned {synced.Count} record(s)."); + foreach (var r in synced.Take(5)) + _output.WriteLine($" CARequestID={r.CARequestID}, Status={r.Status}, ProductID={r.ProductID}"); + } + + /// + /// Hard acceptance criterion (Phase 4 parent plan): Synchronize with + /// UseV2Api=true must succeed and return records with ZERO V1 credentials + /// configured at all — no ApiKey, no AccountNumber, no AuthMode, no V1-shaped ApiUrl. + /// Builds its own config (rather than reusing 's default) so + /// the absence of every V1-only field is explicit and self-evident at the call site. + /// + [SkippableFact] + public async Task Sync_V2_WithZeroV1Credentials_Succeeds() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + var config = new CERTInextConfig + { + ApiUrl = _v2ApiUrl, + UseV2Api = true, + OAuthClientId = _v2ClientId, + OAuthClientSecret = _v2ClientSecret, + RequestorName = "Keyfactor Test", + RequestorEmail = "test@example.com", + SignerPlace = "Gateway Lab", + SignerIp = "127.0.0.1", + PageSize = 100, + // See issues/0022's "V2 sync per-row download cost" note — narrowed to keep + // this test's live API call volume bounded against a busy shared sandbox. + V2SyncLookbackHours = 1 + // Deliberately NOT set: ApiKey, AccountNumber, AuthMode, OAuthTokenUrl — all + // V1-only fields. Their CERTInextConfig defaults (empty string / "AccessKey") + // are never read on this path once UseV2Api is true. + }; + + var client = new CERTInextClient(config); + var plugin = new CERTInextCAPlugin(client, config); + + var synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddHours(-1), fullSync: false); + + synced.Should().NotBeNull(); + _output.WriteLine( + $"Synchronize succeeded with UseV2Api=true and ZERO V1 credentials configured " + + $"(ApiKey/AccountNumber/AuthMode all unset). Returned {synced.Count} record(s)."); + } + + /// + /// Opt-in (walks the sandbox's entire order history — 1000+ orders per the other + /// tests' comments in this class): proves a full sync (fullSync=true, + /// lastSync=null) paginates to completion via V2 /reports/orders without + /// throwing or truncating silently. + /// + [SkippableFact] + public async Task Sync_V2_FullSync_PaginatesEntireHistory() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + Skip.If(string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("CERTINEXT_V2_FULL_SYNC_TEST")), + "CERTINEXT_V2_FULL_SYNC_TEST not set — a full sync walks this sandbox's entire order " + + "history and is opt-in to keep the default .V2 filter fast."); + + var plugin = BuildV2Plugin(); + var synced = await RunSyncAsync(plugin, lastSync: null, fullSync: true); + + synced.Should().NotBeNull(); + synced.Should().NotBeEmpty("a full sync of a non-empty sandbox account must return records"); + _output.WriteLine($"Full sync (V2, entire history) returned {synced.Count} record(s)."); + } + + /// + /// Forces multi-page traversal with a small page size (5) on a delta sync, proving + /// ListOrdersV2Async's pagination is exercised end-to-end through Synchronize + /// against the live sandbox (not just the WireMock-based client unit tests). + /// + [SkippableFact] + public async Task Sync_V2_SmallPageSize_PaginatesAcrossMultiplePages() + { + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + // A narrow (2h) window with pageSize=5 still forces multi-page traversal whenever + // this busy shared sandbox has more than 5 matching orders — no need for a wide + // window (e.g. 30 days), which would also multiply live per-row download calls + // (issues/0022's "V2 sync per-row download cost" note) for no added pagination proof. + var config = BuildV2Config(pageSize: 5, syncLookbackHours: 1); + var plugin = BuildV2Plugin(config); + + var synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddHours(-2), fullSync: false); + + synced.Should().NotBeNull(); + _output.WriteLine( + $"Delta sync (2h window, pageSize=5) returned {synced.Count} record(s) — pageSize=5 " + + "forces multi-page traversal whenever the account has more than 5 matching orders."); + } + } + + /// + /// Shared helper for loading ~/.env_certinext_v2. V2 test classes must read their + /// values from the dictionary returns, never from process env: + /// keys the V1 also reads are deliberately NOT promoted + /// (issue 0017). Used by , V2DcvLifecycleTests, and the + /// other V2 test classes so they don't duplicate env-loading logic. + /// + internal static class V2EnvHelper + { + /// + /// Loads ~/.env_certinext_v2 and returns the merged environment dictionary (V2 file + /// values win over process env). V2-only file keys (e.g. CERTINEXT_CLIENT_ID, + /// CERTINEXT_USE_V2_API) are still promoted into process env; keys in + /// (CERTINEXT_API_URL and the rest) + /// are never written to process env. The V1 fixture lets real env vars override + /// ~/.env_certinext, so promoting the V2 values of those shared names corrupted the + /// V1 fixture of any class constructed later in the same test process (issues 0017, 0044). + /// + public static Dictionary LoadAndPromote() + { + string v2Path = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".env_certinext_v2"); + + var (env, fileKeys) = LoadEnvFile(v2Path); + + foreach (string key in PromotableKeys(fileKeys)) + if (env.TryGetValue(key, out string fv)) + Environment.SetEnvironmentVariable(key, fv); + + return env; + } + + /// + /// The V2-file keys may write into process env: every file + /// key except those the V1 side reads () + /// and the fixture's opt-in-only flags ( + /// — issue 0058). Without the latter exclusion, a value left in ~/.env_certinext_v2 for + /// one of those flags (e.g. CERTINEXT_V2_GAP_PROBES, CERTINEXT_PRIVATE_PKI_LIVE) would be + /// read as unset by the first test class constructed in a run (before this method's + /// promotion step runs), then promoted into real process env, silently arming every + /// later-constructed test class in the same run even though no flag was ever exported in + /// the shell. Exposed internal for direct unit-testing. + /// + internal static List PromotableKeys(IEnumerable fileKeys) + { + var keys = new List(); + foreach (string key in fileKeys) + if (!IntegrationTestFixture.V1EnvKeys.Contains(key) + && !IntegrationTestFixture._optInOnlyFlags.Contains(key)) + keys.Add(key); + return keys; + } + + /// + /// Loads a KEY=VALUE env file and merges with process env vars. File values take + /// priority over process env because the fixture may have already promoted V1 + /// values (e.g. CERTINEXT_API_URL with /emSignHub-API suffix) into process env, + /// and the V2 base URL differs. Returns the merged dict and the set of keys + /// defined in the file. + /// + public static (Dictionary env, HashSet fileKeys) LoadEnvFile(string path) + { + var fileKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (System.Collections.DictionaryEntry de in Environment.GetEnvironmentVariables()) + { + string k = de.Key?.ToString(); + string v = de.Value?.ToString(); + if (!string.IsNullOrEmpty(k)) result[k] = v ?? string.Empty; + } + + if (File.Exists(path)) + { + foreach (string rawLine in File.ReadAllLines(path)) + { + string line = rawLine.Trim(); + if (string.IsNullOrEmpty(line) || line.StartsWith("#")) continue; + + int idx = line.IndexOf('='); + if (idx <= 0) continue; + + string key = line.Substring(0, idx).Trim(); + string val = line.Substring(idx + 1).Trim().Trim('"').Trim('\''); + result[key] = val; + fileKeys.Add(key); + } + } + + return (result, fileKeys); + } + + public static string GetEnv(Dictionary env, string key, string defaultValue = "") + => env.TryGetValue(key, out string v) && !string.IsNullOrWhiteSpace(v) ? v : defaultValue; + } +} diff --git a/CERTInext.IntegrationTests/V2OrderWindowSweepTests.cs b/CERTInext.IntegrationTests/V2OrderWindowSweepTests.cs new file mode 100644 index 0000000..caafd39 --- /dev/null +++ b/CERTInext.IntegrationTests/V2OrderWindowSweepTests.cs @@ -0,0 +1,273 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Opt-in, read-only-by-default sweep over an arbitrary UTC date/time window of the V2 orders +// report (GET /api/certinext/v2/reports/orders). Unlike V2GapProbeTests' own +// Sweep_FindsAndCancelsOrphanedGapProbeOrders (hard-scoped to "today" and matched only on +// domainName starting with "gap-p"), this sweep takes an explicit, caller-supplied window and +// lists every order it finds in it — a general-purpose tool for manually auditing/cleaning up a +// broader date range after a batch of live-API work (e.g. a day's worth of V2 lifecycle tests), +// rather than a probe tied to one issue's naming convention. +// +// Env: +// CERTINEXT_V2_SWEEP_FROM / CERTINEXT_V2_SWEEP_TO — UTC ISO-8601 timestamps, e.g. +// 2026-09-30T00:00:00Z. Both required; the test skips (does not default to any window) if +// either is unset. +// CERTINEXT_V2_SWEEP_CANCEL_IDS — optional, comma-separated V2 order IDs. When unset, this +// test only LISTS orders in the window (orderId, domain, status, productCode, orderDate) — +// fully read-only. When set, it additionally cancels exactly those IDs, but only if each one +// is actually found in the listed window and is not already in a terminal state +// (cancelled/revoked/rejected). No bulk "cancel everything" mode exists here deliberately. +// +// Terminal state is decided by Track Order's own `status` field (via +// CERTInextClient.ResolveAndTrackOrderV2WithFamilyAsync), not the orders report's human-readable +// orderStatus/certificateStatus display strings — matching V2GapProbeTests' own sweep. Exactly +// one cancel attempt per id; never retried, matching every other cleanup/sweep helper in this +// project (V2FullLifecycleTests.CleanupOrderAsync, V2GapProbeTests' sweep, etc.). +// +// The V2 orders report only filters by calendar date (YYYY-MM-DD) server-side (issues/0022 Phase +// 0) — this test requests the covering date range, then re-applies the caller's precise sub-day +// window client-side against each row's own orderDate. +// +// Gating: reuses V2GapProbeTests' existing CERTINEXT_V2_GAP_PROBES=1 opt-in (already present in +// IntegrationTestFixture._optInOnlyFlags, read from the real process environment before +// V2EnvHelper.LoadAndPromote() runs) rather than introducing a new flag — this sweep can cancel +// real sandbox orders the same way that file's own sweep can, so it needs the same explicit +// go/no-go, no more and no less. +// +// Logging: every domain value is passed through CERTInextClient.ApplyLoggingRedaction (same +// default-off PII posture as every other V2 probe in this repo) before being written via +// ITestOutputHelper. +// +// Run (list-only): +// set -a; . ~/.env_certinext; set +a +// export CERTINEXT_V2_GAP_PROBES=1 +// export CERTINEXT_V2_SWEEP_FROM=2026-09-25T00:00:00Z +// export CERTINEXT_V2_SWEEP_TO=2026-10-01T00:00:00Z +// dotnet test CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj -c Release -p:DcvSupport=false \ +// --filter "FullyQualifiedName~Sweep_ListRecentOrders_ByWindow" --logger "console;verbosity=detailed" +// +// Add CERTINEXT_V2_SWEEP_CANCEL_IDS=12345,67890 to also cancel those two specific orders (only +// if each is found in the window and is not already terminal). +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + using System; + using System.Collections.Generic; + using System.Globalization; + using System.Linq; + using System.Threading.Tasks; + using FluentAssertions; + using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; + using Keyfactor.Extensions.CAPlugin.CERTInext.Client; + using Xunit; + using Xunit.Abstractions; + + public class V2OrderWindowSweepTests : IClassFixture + { + private const string OptInFlag = "CERTINEXT_V2_GAP_PROBES"; + + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + + private readonly bool _armed; + private readonly string _v2ApiUrl; + private readonly string _v2ClientId; + private readonly string _v2ClientSecret; + private readonly bool _v2Enabled; + + public V2OrderWindowSweepTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + + // Read the opt-in flag from the real process environment BEFORE promoting the V2 env + // file (mirrors V2GapProbeTests/PrivatePkiV2LiveTests) — a value left in + // ~/.env_certinext_v2 must never arm this file. IntegrationTestFixture's own + // _optInOnlyFlags list already keeps ~/.env_certinext from arming it either. + _armed = Environment.GetEnvironmentVariable(OptInFlag)?.Trim() == "1"; + + var env = V2EnvHelper.LoadAndPromote(); + _v2ApiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL"); + _v2ClientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID"); + _v2ClientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET"); + + _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API")) + && !string.IsNullOrWhiteSpace(_v2ApiUrl) + && !string.IsNullOrWhiteSpace(_v2ClientId) + && !string.IsNullOrWhiteSpace(_v2ClientSecret); + } + + private CERTInextClient BuildV2Client() => new CERTInextClient(new CERTInextConfig + { + ApiUrl = _v2ApiUrl, + UseV2Api = true, + OAuthClientId = _v2ClientId, + OAuthClientSecret = _v2ClientSecret, + RequestorName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test", + RequestorEmail = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com", + SignerIp = "127.0.0.1", + SignerPlace = "Gateway Lab", + PageSize = 100 + }); + + /// + /// Redacts emails/other personal data before any row reaches ITestOutputHelper — same + /// default-off PII posture as every other V2 probe in this repo (see + /// CERTInextClient.ApplyLoggingRedaction; reachable here via + /// InternalsVisibleTo("CERTInext.IntegrationTests")). Domain names themselves are not + /// touched by this redaction. + /// + private static string RedactForLog(string value) => + CERTInextClient.ApplyLoggingRedaction(value, logSensitiveRequestData: false); + + [SkippableFact] + public async Task Sweep_ListRecentOrders_ByWindow_DryRun_ThenCancelExplicitIds() + { + Skip.If(!_armed, + $"{OptInFlag}=1 not set in the real process environment — this sweep can cancel real sandbox " + + "orders when CERTINEXT_V2_SWEEP_CANCEL_IDS is set, and requires the same explicit go/no-go as " + + "V2GapProbeTests' own sweep. Skipping."); + Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping."); + + string fromRaw = Environment.GetEnvironmentVariable("CERTINEXT_V2_SWEEP_FROM"); + string toRaw = Environment.GetEnvironmentVariable("CERTINEXT_V2_SWEEP_TO"); + Skip.If(string.IsNullOrWhiteSpace(fromRaw) || string.IsNullOrWhiteSpace(toRaw), + "CERTINEXT_V2_SWEEP_FROM and CERTINEXT_V2_SWEEP_TO (UTC ISO-8601) must both be set — this sweep " + + "does not default to any particular window. Skipping."); + + const DateTimeStyles utcStyles = DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal; + + if (!DateTime.TryParse(fromRaw, CultureInfo.InvariantCulture, utcStyles, out DateTime fromUtc)) + throw new ArgumentException($"CERTINEXT_V2_SWEEP_FROM='{fromRaw}' is not a parseable UTC ISO-8601 timestamp."); + if (!DateTime.TryParse(toRaw, CultureInfo.InvariantCulture, utcStyles, out DateTime toUtc)) + throw new ArgumentException($"CERTINEXT_V2_SWEEP_TO='{toRaw}' is not a parseable UTC ISO-8601 timestamp."); + if (toUtc <= fromUtc) + throw new ArgumentException($"CERTINEXT_V2_SWEEP_TO ({toUtc:O}) must be after CERTINEXT_V2_SWEEP_FROM ({fromUtc:O})."); + + var cancelIds = (Environment.GetEnvironmentVariable("CERTINEXT_V2_SWEEP_CANCEL_IDS") ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + // The V2 orders report only filters by calendar date (YYYY-MM-DD) server-side — + // request the covering date range, then apply the caller's precise sub-day window + // client-side against each row's own orderDate. + string apiFrom = fromUtc.Date.ToString("yyyy-MM-dd"); + string apiTo = toUtc.Date.AddDays(1).ToString("yyyy-MM-dd"); + + _output.WriteLine("=== V2 order window sweep ==="); + _output.WriteLine($"Window: {fromUtc:O} .. {toUtc:O} (UTC). Report query date range: {apiFrom}..{apiTo}."); + _output.WriteLine(cancelIds.Count > 0 + ? $"Cancel targets (CERTINEXT_V2_SWEEP_CANCEL_IDS): {string.Join(", ", cancelIds)}" + : "No CERTINEXT_V2_SWEEP_CANCEL_IDS set — list-only dry run; nothing will be cancelled."); + + using CERTInextClient client = BuildV2Client(); + + var rowsInWindow = new List(); + int rowsScanned = 0; + + await foreach (var row in client.ListOrdersV2Async(apiFrom, apiTo, pageSize: 100)) + { + rowsScanned++; + + bool parsed = DateTime.TryParse(row.OrderDate, CultureInfo.InvariantCulture, utcStyles, out DateTime rowDate); + + // A row with an unparseable/missing orderDate is kept rather than silently + // dropped — this is a read-only listing, so erring toward showing more (and + // flagging the parse miss) beats erring toward hiding a row the operator + // actually wanted to see. + if (parsed && (rowDate < fromUtc || rowDate > toUtc)) + continue; + + rowsInWindow.Add(row); + + _output.WriteLine( + $"LISTED | OrderId={row.OrderNumber ?? ""} Domain={RedactForLog(row.DomainName)} " + + $"Status={row.OrderStatus ?? ""}/{row.CertificateStatus ?? ""} " + + $"ProductCode={row.ProductCode ?? ""} OrderDate={row.OrderDate ?? ""}" + + (parsed ? string.Empty : " (orderDate unparseable — kept anyway)")); + } + + _output.WriteLine($"Report rows scanned (date-range query): {rowsScanned}. Rows within the precise window: {rowsInWindow.Count}."); + + bool anyCancelFailed = false; + var matchedCancelIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (string targetId in cancelIds) + { + var row = rowsInWindow.FirstOrDefault(r => string.Equals(r.OrderNumber, targetId, StringComparison.OrdinalIgnoreCase)); + if (row == null) + { + _output.WriteLine( + $"SUMMARY | OrderId={targetId} Cancel=SKIPPED (not found in the listed window — " + + "not touching an order outside it)"); + continue; + } + + matchedCancelIds.Add(targetId); + + try + { + var (family, status) = await client.ResolveAndTrackOrderV2WithFamilyAsync(targetId); + + bool terminal = + string.Equals(status.Status, Constants.ApiV2.StatusCancelled, StringComparison.OrdinalIgnoreCase) || + string.Equals(status.Status, Constants.ApiV2.StatusRevoked, StringComparison.OrdinalIgnoreCase) || + string.Equals(status.Status, Constants.ApiV2.StatusRejected, StringComparison.OrdinalIgnoreCase); + + string cancelOutcome; + if (terminal) + { + cancelOutcome = $"SKIPPED (already {status.Status})"; + } + else + { + try + { + var outcome = await client.CancelOrderV2Async( + family, targetId, + "V2 order-window sweep — explicitly listed in CERTINEXT_V2_SWEEP_CANCEL_IDS."); + cancelOutcome = outcome.ToString(); + } + catch (Exception cancelEx) + { + anyCancelFailed = true; + cancelOutcome = $"FAILED ({cancelEx.GetType().Name}: {cancelEx.Message})"; + } + } + + _output.WriteLine( + $"SUMMARY | OrderId={targetId} Domain={RedactForLog(row.DomainName)} " + + $"StatusBefore={status.Status ?? ""} Cancel={cancelOutcome}"); + } + catch (Exception ex) + { + anyCancelFailed = true; + _output.WriteLine( + $"SUMMARY | OrderId={targetId} Domain={RedactForLog(row.DomainName)} " + + $"Cancel=FAILED (could not resolve product family/status: {ex.GetType().Name}: {ex.Message})"); + } + } + + _output.WriteLine(""); + _output.WriteLine( + $"SUMMARY | Sweep complete. RowsScanned={rowsScanned} RowsInWindow={rowsInWindow.Count} " + + $"CancelTargets={cancelIds.Count} CancelsMatched={matchedCancelIds.Count}"); + + anyCancelFailed.Should().BeFalse( + "one or more explicitly-listed orders could not be cancelled (or could not have their " + + "status/family resolved) during the sweep — see the FAILED outcome(s) logged above; this " + + "sweep does not retry a failed cancel automatically."); + } + } +} diff --git a/CERTInext.IntegrationTests/V2RawProbeHelpers.cs b/CERTInext.IntegrationTests/V2RawProbeHelpers.cs new file mode 100644 index 0000000..1898461 --- /dev/null +++ b/CERTInext.IntegrationTests/V2RawProbeHelpers.cs @@ -0,0 +1,94 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Shared raw-HTTP helpers for this project's V2 "raw-body place-then-cancel" triage probes. +// Before this file existed, OrganizationBlockV2ProbeTests.cs had its own private +// GetV2AccessTokenAsync/CancelSslOrderRawAsync pair (token-fetch inlined directly into its +// CancelSslOrderRawAsync), and IdempotencyKeyV2ProbeTests.cs carried a near-identical private +// copy of both as two separate methods. Issue 0058 asked that a third file (V2GapProbeTests.cs) +// reuse rather than triplicate that logic, so both methods below were extracted here and all +// three files now call them. +// +// Behavior for the two pre-existing call sites is unchanged: both methods default to no +// explicit RestSharp timeout (the timeout parameter defaults to null), exactly matching +// what the two original private copies did. Callers that need the longer timeout this repo's +// newer V2 probes use for slow sandbox endpoints (EmailNotificationsV2ProbeTests / +// UccDcvShapeV2ProbeTests / UccPendingSanOrderProbeTests' 120s NewApiClient) pass it +// explicitly via the optional timeout parameter on each method. +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + using System; + using System.Text.Json; + using System.Threading.Tasks; + using RestSharp; + + internal static class V2RawProbeHelpers + { + /// + /// Builds a against . When + /// is null (the default), this is exactly + /// new RestClient(baseUrl) — the framework default timeout the two original + /// private helper copies always used. + /// + public static RestClient NewApiClient(string baseUrl, TimeSpan? timeout = null) => + timeout.HasValue + ? new RestClient(new RestClientOptions(baseUrl) { Timeout = timeout.Value }) + : new RestClient(baseUrl); + + /// + /// Standalone OAuth2 client_credentials token fetch against {apiUrl}/oauth/token. + /// Throws if the token call itself is not successful — a failed token call is always a + /// probe-mechanism failure, never a CA-response finding worth recording. + /// + public static async Task GetV2AccessTokenAsync( + string apiUrl, string clientId, string clientSecret, TimeSpan? timeout = null) + { + string tokenUrl = apiUrl.TrimEnd('/') + "/oauth/token"; + using var tokenClient = NewApiClient(tokenUrl, timeout); + var tokenReq = new RestRequest(string.Empty, Method.Post); + tokenReq.AddHeader("Content-Type", "application/x-www-form-urlencoded"); + tokenReq.AddParameter("grant_type", "client_credentials"); + tokenReq.AddParameter("client_id", clientId); + tokenReq.AddParameter("client_secret", clientSecret); + var tokenResp = await tokenClient.ExecuteAsync(tokenReq); + if (!tokenResp.IsSuccessful || string.IsNullOrWhiteSpace(tokenResp.Content)) + throw new Exception($"Token request failed: {(int)tokenResp.StatusCode}"); + + using var tokenDoc = JsonDocument.Parse(tokenResp.Content); + return tokenDoc.RootElement.GetProperty("access_token").GetString(); + } + + /// + /// Cancels a V2 SSL order via POST {SslCertificatesPath}/{orderId}/cancel. Throws + /// on a non-success response — matches the two original private + /// CancelSslOrderRawAsync copies exactly (both threw on failure; neither returned + /// a raw status/body). + /// + public static async Task CancelSslOrderRawAsync( + string apiUrl, string clientId, string clientSecret, string orderId, string reason, + TimeSpan? timeout = null) + { + string accessToken = await GetV2AccessTokenAsync(apiUrl, clientId, clientSecret, timeout); + + using var apiClient = NewApiClient(apiUrl.TrimEnd('/'), timeout); + var cancelReq = new RestRequest($"{Constants.ApiV2.SslCertificatesPath}/{orderId}/cancel", Method.Post); + cancelReq.AddHeader("Authorization", $"Bearer {accessToken}"); + cancelReq.AddJsonBody(new { reason }); + var cancelResp = await apiClient.ExecuteAsync(cancelReq); + if (!cancelResp.IsSuccessful) + throw new Exception( + $"Cancel request failed: {(int)cancelResp.StatusCode} {cancelResp.Content}"); + } + } +} diff --git a/CERTInext.IntegrationTests/V2ReportProbeTests.cs b/CERTInext.IntegrationTests/V2ReportProbeTests.cs new file mode 100644 index 0000000..8791893 --- /dev/null +++ b/CERTInext.IntegrationTests/V2ReportProbeTests.cs @@ -0,0 +1,677 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Xunit; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + /// + /// Read-only discovery probes against the V2 /reports/orders and + /// /domains endpoints. + /// + /// This is a fact-finding GATE ahead of switching V2-mode Synchronize from the V1 + /// GetOrderReport endpoint to V2 reports. It does not place, revoke, or modify any + /// order or domain. All findings are printed via and + /// must be read from the test's tail output (xUnit buffers it until the test ends). + /// + /// Gated by CERTINEXT_V2_REPORT_PROBE=1 (in addition to the usual + /// CERTINEXT_USE_V2_API=1 + V2 credentials) so it never runs by accident in CI. + /// + /// To run: + /// + /// cd <repo> + /// set -a; . ~/.env_certinext; set +a + /// export CERTINEXT_USE_V2_API=1 CERTINEXT_V2_REPORT_PROBE=1 + /// dotnet test CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj -c Release \ + /// --filter "FullyQualifiedName~V2ReportProbe" \ + /// --logger "console;verbosity=detailed" > /tmp/v2_probe.log 2>&1 + /// + /// Note: the shell must source ONLY ~/.env_certinext (never ~/.env_certinext_v2 — see + /// issue 0017); this class loads ~/.env_certinext_v2 itself, same pattern as V2ApiTests. + /// + public class V2ReportProbeTests : IClassFixture + { + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + private readonly string _v2ApiUrl; + private readonly string _v2ClientId; + private readonly string _v2ClientSecret; + private readonly string _v2Domain; + private readonly string _issuedOrderId; + private readonly bool _v2Enabled; + private readonly bool _probeEnabled; + + public V2ReportProbeTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + + // Load ~/.env_certinext_v2 via the shared helper (issues/0017, gap G14 — one env + // loader, not a private copy per test class), same priority rules as V2ApiTests: + // V2-file-defined keys override whatever the fixture already promoted into + // process env (the V1 CERTINEXT_API_URL differs from the V2 base URL). + var env = V2EnvHelper.LoadAndPromote(); + + _v2ApiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL"); + _v2ClientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID"); + _v2ClientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET"); + _v2Domain = V2EnvHelper.GetEnv(env, "CERTINEXT_DCV_DOMAIN", "test.example.com"); + _issuedOrderId = V2EnvHelper.GetEnv(env, "CERTINEXT_V2_ISSUED_ORDER_ID"); + + _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API")) + && !string.IsNullOrWhiteSpace(_v2ApiUrl) + && !string.IsNullOrWhiteSpace(_v2ClientId) + && !string.IsNullOrWhiteSpace(_v2ClientSecret); + + _probeEnabled = _v2Enabled && !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_V2_REPORT_PROBE")); + } + + // --------------------------------------------------------------------------- + // Probe 1 + 5: report shape, envelope pagination fields, product/serial/status vocab + // --------------------------------------------------------------------------- + + [SkippableFact] + public async Task Probe1_OrdersReport_ShapeAndFieldVocabulary() + { + Skip.IfNot(_probeEnabled, "CERTINEXT_V2_REPORT_PROBE not set (or V2 not enabled) — skipping report probe."); + + using var client = BuildV2Client(); + + _output.WriteLine("=== Probe 1: GET /reports/orders?page=1&size=50 ==="); + var (status, contentType, content) = await client.ProbeV2GetAsync( + "/api/certinext/v2/reports/orders?page=1&size=50"); + + _output.WriteLine($"HTTP {status} (Content-Type: {contentType})"); + + if (status != 200) + { + _output.WriteLine($"FINDING: /reports/orders is NOT live (200). Raw body: {Redact(content)}"); + return; + } + + _output.WriteLine("FINDING: /reports/orders returned 200 — endpoint IS live (contradicts the " + + "plugin's current 501 assumption in CERTInextCAPlugin.cs ~862-868)."); + + using var doc = JsonDocument.Parse(content); + var root = doc.RootElement; + + var envelopeKeys = root.EnumerateObject().Select(p => p.Name).ToList(); + _output.WriteLine($"Envelope top-level keys: [{string.Join(", ", envelopeKeys)}]"); + + foreach (string field in new[] { "page", "size", "totalElements", "totalPages" }) + { + if (root.TryGetProperty(field, out var v)) + _output.WriteLine($" envelope.{field} = {v} (kind={v.ValueKind})"); + else + _output.WriteLine($" envelope.{field} = "); + } + + if (!root.TryGetProperty("content", out var contentArr) || contentArr.ValueKind != JsonValueKind.Array) + { + _output.WriteLine("FINDING: no 'content' array in the envelope — cannot inspect rows."); + return; + } + + int rowCount = contentArr.GetArrayLength(); + _output.WriteLine($"content[] length on this page: {rowCount}"); + + var rows = contentArr.EnumerateArray().Take(3).ToList(); + var distinctStatusValues = new SortedSet(); + var sampleRowRaw = string.Empty; + + for (int i = 0; i < rows.Count; i++) + { + var row = rows[i]; + var rowKeys = row.EnumerateObject().Select(p => $"{p.Name}:{p.Value.ValueKind}").ToList(); + _output.WriteLine($"row[{i}] fields (name:type): [{string.Join(", ", rowKeys)}]"); + if (i == 0) + sampleRowRaw = Redact(row.GetRawText()); + + foreach (var statusField in new[] { "orderStatus", "state", "certificateStatus" }) + if (row.TryGetProperty(statusField, out var sv) && sv.ValueKind == JsonValueKind.String) + distinctStatusValues.Add($"{statusField}={sv.GetString()}"); + } + + _output.WriteLine($"Sample row 0 (redacted): {sampleRowRaw}"); + + // Spec field table vs example body discrepancy (docs/reference/specs/CERTInext API + // v2.postman_collection (1).json, item "Orders Report"): field table documents + // orderStatus/domainName/certificateSerialNumber; the example body instead shows + // state/identifier/account/group/product. Report which is actually live. + bool hasTableFields = rows.Any(r => r.TryGetProperty("orderStatus", out _) || + r.TryGetProperty("domainName", out _) || + r.TryGetProperty("certificateSerialNumber", out _)); + bool hasExampleFields = rows.Any(r => r.TryGetProperty("state", out _) || + r.TryGetProperty("identifier", out _)); + + _output.WriteLine($"FINDING: spec field-table shape present = {hasTableFields}; " + + $"spec example-body shape present = {hasExampleFields}."); + + // Probe 5: does the row carry productCode / serial / cert body link? + bool hasProductCode = rows.Any(r => r.TryGetProperty("productCode", out _) || r.TryGetProperty("product", out _)); + bool hasSerial = rows.Any(r => r.TryGetProperty("certificateSerialNumber", out _)); + bool hasCertLink = rows.Any(r => r.EnumerateObject().Any(p => p.Name.Contains("certificate", StringComparison.OrdinalIgnoreCase) + && p.Name.Contains("link", StringComparison.OrdinalIgnoreCase))); + _output.WriteLine($"FINDING: row carries product/productCode = {hasProductCode}; " + + $"certificateSerialNumber = {hasSerial}; a *Link field naming a cert body = {hasCertLink}."); + + // Probe 5 (continued): collect distinct status vocabulary across up to 3 pages. + var allStatusValues = new SortedSet(distinctStatusValues); + for (int page = 2; page <= 3; page++) + { + var (pStatus, _, pContent) = await client.ProbeV2GetAsync( + $"/api/certinext/v2/reports/orders?page={page}&size=50"); + if (pStatus != 200 || string.IsNullOrWhiteSpace(pContent)) break; + using var pDoc = JsonDocument.Parse(pContent); + if (!pDoc.RootElement.TryGetProperty("content", out var pArr) || pArr.GetArrayLength() == 0) break; + foreach (var row in pArr.EnumerateArray()) + foreach (var statusField in new[] { "orderStatus", "state", "certificateStatus" }) + if (row.TryGetProperty(statusField, out var sv) && sv.ValueKind == JsonValueKind.String) + allStatusValues.Add($"{statusField}={sv.GetString()}"); + } + _output.WriteLine($"FINDING: distinct status values observed (pages 1-3): [{string.Join(", ", allStatusValues)}]"); + } + + // --------------------------------------------------------------------------- + // Probe 2: pagination semantics + // --------------------------------------------------------------------------- + + [SkippableFact] + public async Task Probe2_OrdersReport_Pagination() + { + Skip.IfNot(_probeEnabled, "CERTINEXT_V2_REPORT_PROBE not set (or V2 not enabled) — skipping pagination probe."); + + using var client = BuildV2Client(); + _output.WriteLine("=== Probe 2: pagination semantics ==="); + + foreach (var (label, query) in new[] + { + ("size=100", "/api/certinext/v2/reports/orders?page=1&size=100"), + ("size=101 (over max)", "/api/certinext/v2/reports/orders?page=1&size=101"), + ("page=0", "/api/certinext/v2/reports/orders?page=0&size=10"), + ("page=1 (baseline)", "/api/certinext/v2/reports/orders?page=1&size=10"), + }) + { + var (status, _, content) = await client.ProbeV2GetAsync(query); + string sizeEcho = "n/a", pageEcho = "n/a"; + if (status == 200 && !string.IsNullOrWhiteSpace(content)) + { + using var doc = JsonDocument.Parse(content); + if (doc.RootElement.TryGetProperty("size", out var sv)) sizeEcho = sv.ToString(); + if (doc.RootElement.TryGetProperty("page", out var pv)) pageEcho = pv.ToString(); + } + _output.WriteLine($"FINDING: {label} -> HTTP {status}, echoed size={sizeEcho}, echoed page={pageEcho}, " + + $"bodySnippet={Redact(Truncate(content, 300))}"); + } + } + + // --------------------------------------------------------------------------- + // Probe 3: from/to filter param names + semantics (order date vs issue date) + // --------------------------------------------------------------------------- + + [SkippableFact] + public async Task Probe3_OrdersReport_FromToFilters() + { + Skip.IfNot(_probeEnabled, "CERTINEXT_V2_REPORT_PROBE not set (or V2 not enabled) — skipping from/to probe."); + + using var v2Client = BuildV2Client(); + _output.WriteLine("=== Probe 3: from/to filters ==="); + + // Baseline: unfiltered totalElements. + var (baseStatus, _, baseContent) = await v2Client.ProbeV2GetAsync( + "/api/certinext/v2/reports/orders?page=1&size=1"); + long baseTotal = -1; + if (baseStatus == 200 && !string.IsNullOrWhiteSpace(baseContent)) + { + using var doc = JsonDocument.Parse(baseContent); + if (doc.RootElement.TryGetProperty("totalElements", out var te)) baseTotal = te.GetInt64(); + } + _output.WriteLine($"Baseline (no filter) totalElements = {baseTotal}"); + + // Wide bracket per spec format (YYYY-MM-DD) that should include everything. + var (wideStatus, _, wideContent) = await v2Client.ProbeV2GetAsync( + "/api/certinext/v2/reports/orders?page=1&size=1&from=2000-01-01&to=2099-12-31"); + long wideTotal = -1; + if (wideStatus == 200 && !string.IsNullOrWhiteSpace(wideContent)) + { + using var doc = JsonDocument.Parse(wideContent); + if (doc.RootElement.TryGetProperty("totalElements", out var te)) wideTotal = te.GetInt64(); + } + _output.WriteLine($"FINDING: from=2000-01-01&to=2099-12-31 -> HTTP {wideStatus}, totalElements = {wideTotal} " + + $"(vs baseline {baseTotal}; equal => from/to accepted with YYYY-MM-DD and don't drop rows)."); + + // Narrow bracket in the far past that should exclude everything, to confirm the + // params actually filter (rather than being silently ignored). + var (narrowStatus, _, narrowContent) = await v2Client.ProbeV2GetAsync( + "/api/certinext/v2/reports/orders?page=1&size=1&from=2000-01-01&to=2000-01-02"); + long narrowTotal = -1; + if (narrowStatus == 200 && !string.IsNullOrWhiteSpace(narrowContent)) + { + using var doc = JsonDocument.Parse(narrowContent); + if (doc.RootElement.TryGetProperty("totalElements", out var te)) narrowTotal = te.GetInt64(); + } + _output.WriteLine($"FINDING: from=2000-01-01&to=2000-01-02 -> HTTP {narrowStatus}, totalElements = {narrowTotal} " + + "(if 0, from/to do filter; if unchanged from baseline, they are likely no-ops)."); + + // Empirical order-date vs issue-date distinction: pick an issued order from the V1 + // report whose OrderDate we know, and bracket from/to tightly around that date. + // If the row still appears in a bracket that excludes its (later) issuance date, + // from/to are filtering on order date, not issue date. + if (!_fixture.IsConfigured) + { + _output.WriteLine("V1 fixture not configured — cannot pick a known-orderDate order to " + + "distinguish order-date vs issue-date filtering. Skipping that sub-probe."); + return; + } + + OrderReportSample sample = null; + await foreach (var entry in _fixture.Client.ListOrdersAsync(pageSize: 20)) + { + if (!string.IsNullOrWhiteSpace(entry.OrderNumber) && + !string.IsNullOrWhiteSpace(entry.OrderDate) && + DateTime.TryParse(entry.OrderDate, null, + System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, + out DateTime parsedOrderDate)) + { + sample = new OrderReportSample(entry.OrderNumber, parsedOrderDate); + break; + } + } + + if (sample == null) + { + _output.WriteLine("No V1 order with a parsed OrderDate found in the first page — cannot run the " + + "order-date-vs-issue-date sub-probe."); + return; + } + + string from = sample.OrderDate.ToString("yyyy-MM-dd"); + string to = sample.OrderDate.AddDays(1).ToString("yyyy-MM-dd"); + var (bracketStatus, _, bracketContent) = await v2Client.ProbeV2GetAsync( + $"/api/certinext/v2/reports/orders?page=1&size=50&from={from}&to={to}"); + bool foundInOrderDateBracket = bracketStatus == 200 && + ContainsOrderNumber(bracketContent, sample.OrderNumber); + _output.WriteLine($"FINDING: V1 order {Redact(sample.OrderNumber)} (V1 orderDate={sample.OrderDate:u}) " + + $"bracketed from={from}&to={to} in V2 report -> present = {foundInOrderDateBracket}. " + + "(V1 report has no separate issue-date field to bracket against, so a full " + + "order-date-vs-issue-date distinction could not be made empirically in this pass; " + + "see report notes.)"); + } + + // --------------------------------------------------------------------------- + // Probe 4: orderNumber identity between V1 and V2 + // --------------------------------------------------------------------------- + + [SkippableFact] + public async Task Probe4_OrderNumberIdentity_V1VsV2() + { + Skip.IfNot(_probeEnabled, "CERTINEXT_V2_REPORT_PROBE not set (or V2 not enabled) — skipping identity probe."); + Skip.IfNot(_fixture.IsConfigured, "V1 fixture not configured — cannot compare V1 order numbers."); + + using var v2Client = BuildV2Client(); + _output.WriteLine("=== Probe 4: orderNumber identity (V1 <-> V2) ==="); + + // Collect a handful of V1 order numbers. + var v1OrderNumbers = new List(); + await foreach (var entry in _fixture.Client.ListOrdersAsync(pageSize: 20)) + { + if (!string.IsNullOrWhiteSpace(entry.OrderNumber)) + v1OrderNumbers.Add(entry.OrderNumber); + if (v1OrderNumbers.Count >= 5) break; + } + _output.WriteLine($"Collected {v1OrderNumbers.Count} V1 order numbers to compare."); + + // Pull the V2 report (first few pages, capped) to build a set of V2 orderNumbers. + var v2OrderNumbers = new HashSet(StringComparer.OrdinalIgnoreCase); + for (int page = 1; page <= 5; page++) + { + var (status, _, content) = await v2Client.ProbeV2GetAsync( + $"/api/certinext/v2/reports/orders?page={page}&size=100"); + if (status != 200 || string.IsNullOrWhiteSpace(content)) break; + using var doc = JsonDocument.Parse(content); + if (!doc.RootElement.TryGetProperty("content", out var arr) || arr.GetArrayLength() == 0) break; + foreach (var row in arr.EnumerateArray()) + if (row.TryGetProperty("orderNumber", out var on) && on.ValueKind == JsonValueKind.String) + v2OrderNumbers.Add(on.GetString()); + if (arr.GetArrayLength() < 100) break; // last page + } + _output.WriteLine($"Collected {v2OrderNumbers.Count} distinct orderNumbers across up to 5 V2 report pages."); + + int matched = v1OrderNumbers.Count(n => v2OrderNumbers.Contains(n)); + _output.WriteLine($"FINDING: N compared = {v1OrderNumbers.Count}, N matched by exact orderNumber = {matched}."); + + // Cross-check: do V1 order numbers resolve against the V2 TrackOrder endpoint at all + // (independent of the report), by probing all three V2 product families. + int trackResolved = 0; + foreach (var orderNumber in v1OrderNumbers) + { + bool resolved = false; + foreach (var family in new[] { "ssl-certificates", "private-pki-certificates", "signature-certificates" }) + { + var (status, _, _) = await v2Client.ProbeV2GetAsync($"/api/certinext/v2/{family}/{orderNumber}"); + if (status == 200) { resolved = true; break; } + } + if (resolved) trackResolved++; + } + _output.WriteLine($"FINDING: of {v1OrderNumbers.Count} V1 order numbers, {trackResolved} resolved via " + + "V2 GET /{family}/{orderId} (TrackOrder-equivalent) in any product family."); + + // If an order was placed via V2 (CERTINEXT_V2_ISSUED_ORDER_ID), check whether its ID + // appears in the V2 report and whether it matches the V1 orderNumber format. + if (!string.IsNullOrWhiteSpace(_issuedOrderId)) + { + bool v2OrderInReport = v2OrderNumbers.Contains(_issuedOrderId); + _output.WriteLine($"FINDING: CERTINEXT_V2_ISSUED_ORDER_ID ({Redact(_issuedOrderId)}) present in V2 report = " + + $"{v2OrderInReport}."); + } + else + { + _output.WriteLine("CERTINEXT_V2_ISSUED_ORDER_ID not set — cannot check a V2-placed order's ID " + + "against V1 orderNumber format."); + } + } + + // --------------------------------------------------------------------------- + // Probe 8 (triage 0031): does this account's real order history ever contain + // any of the V2 statuses StatusMapper.V2StatusToRequestDisposition does NOT map + // (pending-organization-verification, pending-documents, pending-approval, + // rejected, expired)? Read-only: uses /reports/orders?status=&size=1 and + // reads only totalElements — never lists or touches order content. Added purely + // for issues/0031 triage; not part of the normal V2 report-probe discovery set. + // --------------------------------------------------------------------------- + + [SkippableFact] + public async Task Probe8_StatusFilter_UnmappedStatusCounts() + { + Skip.IfNot(_probeEnabled, "CERTINEXT_V2_REPORT_PROBE not set (or V2 not enabled) — skipping status-filter probe."); + + using var client = BuildV2Client(); + _output.WriteLine("=== Probe 8 (issues/0031 triage): totalElements per V2 order status, this account ==="); + + // The 6 statuses StatusMapper.V2StatusToRequestDisposition maps today, plus + // the 5 spec-documented statuses (docs/reference/specs/CERTInext API + // v2.postman_collection (1).json, "Orders Report" query param "status") that + // fall through its default arm to FAILED. + string[] mapped = { "issued", "pending-dcv", "pending-csr", "pending-agreement", "revoked", "cancelled" }; + string[] unmapped = { "pending-organization-verification", "pending-documents", "pending-approval", "rejected", "expired" }; + + foreach (string status in mapped.Concat(unmapped)) + { + var (httpStatus, _, content) = await client.ProbeV2GetAsync( + $"/api/certinext/v2/reports/orders?status={Uri.EscapeDataString(status)}&page=1&size=1"); + + long total = -1; + if (httpStatus == 200 && !string.IsNullOrWhiteSpace(content)) + { + using var doc = JsonDocument.Parse(content); + if (doc.RootElement.TryGetProperty("totalElements", out var te)) + total = te.GetInt64(); + } + + string category = unmapped.Contains(status) ? "UNMAPPED (defaults to FAILED today)" : "mapped"; + _output.WriteLine($"FINDING: status={status,-36} HTTP={httpStatus,-3} totalElements={total,-6} [{category}]"); + } + } + + // --------------------------------------------------------------------------- + // Probe 6: List Domains + // --------------------------------------------------------------------------- + + [SkippableFact] + public async Task Probe6_ListDomains_Shape() + { + Skip.IfNot(_probeEnabled, "CERTINEXT_V2_REPORT_PROBE not set (or V2 not enabled) — skipping domains probe."); + + using var client = BuildV2Client(); + _output.WriteLine("=== Probe 6: GET /domains?search=&exactMatch=true ==="); + + string query = $"/api/certinext/v2/domains?search={Uri.EscapeDataString(_v2Domain)}&exactMatch=true"; + var (status, contentType, content) = await client.ProbeV2GetAsync(query); + _output.WriteLine($"HTTP {status} (Content-Type: {contentType})"); + + if (status != 200 || string.IsNullOrWhiteSpace(content)) + { + _output.WriteLine($"FINDING: /domains did not return 200 with a body. Raw: {Redact(content)}"); + return; + } + + using var doc = JsonDocument.Parse(content); + var root = doc.RootElement; + var envelopeKeys = root.EnumerateObject().Select(p => p.Name).ToList(); + _output.WriteLine($"Envelope top-level keys: [{string.Join(", ", envelopeKeys)}]"); + + if (!root.TryGetProperty("content", out var arr) || arr.ValueKind != JsonValueKind.Array || arr.GetArrayLength() == 0) + { + _output.WriteLine($"FINDING: no matching domain row for search={Redact(_v2Domain)}, exactMatch=true."); + return; + } + + var row = arr[0]; + var rowKeys = row.EnumerateObject().Select(p => $"{p.Name}:{p.Value.ValueKind}").ToList(); + _output.WriteLine($"row[0] fields (name:type): [{string.Join(", ", rowKeys)}]"); + _output.WriteLine($"Sample domain row (redacted): {Redact(row.GetRawText())}"); + + foreach (string field in new[] { "domainId", "dcvStatus", "validTill", "domainName", "status" }) + { + if (row.TryGetProperty(field, out var v)) + _output.WriteLine($" domain.{field} = {Redact(v.ToString())}"); + else + _output.WriteLine($" domain.{field} = "); + } + } + + // --------------------------------------------------------------------------- + // Probe 7: catalog/products shape (issue 0025 step 0 — settles which parser + // branch ParseProductDetailsV2Response needs: nested category envelope + // (matches V1's GetProductDetails shape) vs. flat productId rows (the + // Postman example body). Read-only: GET only, no order/domain side effects. + // --------------------------------------------------------------------------- + + [SkippableFact] + public async Task Probe7_CatalogProducts_ShapeAndFieldVocabulary() + { + Skip.IfNot(_probeEnabled, "CERTINEXT_V2_REPORT_PROBE not set (or V2 not enabled) — skipping catalog probe."); + + using var client = BuildV2Client(); + + _output.WriteLine("=== Probe 7: GET /api/certinext/v2/catalog/products ==="); + var (status, contentType, content) = await client.ProbeV2GetAsync("/api/certinext/v2/catalog/products"); + _output.WriteLine($"HTTP {status} (Content-Type: {contentType})"); + InspectCatalogProductsBody(status, content, "(no groupNumber)"); + + // Also probe with ?groupNumber= when configured — the kfclab spec sets GroupNumber + // and V1's GetProductDetails passes it (CERTInextClient.cs:697-700); the V2 catalog + // may default to a different billing group without it. + string groupNumber = _fixture.IsConfigured ? _fixture.GroupNumber : null; + if (!string.IsNullOrWhiteSpace(groupNumber)) + { + _output.WriteLine($"=== Probe 7b: GET /api/certinext/v2/catalog/products?groupNumber={Redact(groupNumber)} ==="); + var (status2, contentType2, content2) = await client.ProbeV2GetAsync( + $"/api/certinext/v2/catalog/products?groupNumber={Uri.EscapeDataString(groupNumber)}"); + _output.WriteLine($"HTTP {status2} (Content-Type: {contentType2})"); + InspectCatalogProductsBody(status2, content2, "(with groupNumber)"); + } + else + { + _output.WriteLine("CERTINEXT_GROUP_NUMBER not set on this fixture — skipping the groupNumber variant."); + } + } + + /// + /// Shared body inspector for Probe7's two calls. Logs the top-level shape (bare array + /// vs. wrapper object) and, for each element, whether it looks like a nested category + /// envelope (own "products" array) or a flat product row (has productCode/productId + /// directly). Does not assert — this is discovery only (issue 0025 step 0). + /// + private void InspectCatalogProductsBody(int status, string content, string label) + { + if (status != 200 || string.IsNullOrWhiteSpace(content)) + { + _output.WriteLine($"FINDING {label}: catalog/products did not return 200 with a body. Raw: {Redact(content)}"); + return; + } + + using var doc = JsonDocument.Parse(content); + var root = doc.RootElement; + + JsonElement arr; + if (root.ValueKind == JsonValueKind.Array) + { + arr = root; + _output.WriteLine($"FINDING {label}: top-level shape = bare array, length={arr.GetArrayLength()}."); + } + else if (root.ValueKind == JsonValueKind.Object) + { + var topKeys = root.EnumerateObject().Select(p => p.Name).ToList(); + _output.WriteLine($"FINDING {label}: top-level shape = object, keys=[{string.Join(", ", topKeys)}]."); + + string foundKey = new[] { "products", "data", "items", "catalog" } + .FirstOrDefault(k => root.TryGetProperty(k, out var e) && e.ValueKind == JsonValueKind.Array); + if (foundKey == null || !root.TryGetProperty(foundKey, out arr)) + { + _output.WriteLine($"FINDING {label}: no known array wrapper property found — cannot inspect rows."); + return; + } + _output.WriteLine($"FINDING {label}: array is under top-level key '{foundKey}', length={arr.GetArrayLength()}."); + } + else + { + _output.WriteLine($"FINDING {label}: unexpected top-level JSON kind {root.ValueKind}."); + return; + } + + var elements = arr.EnumerateArray().Take(3).ToList(); + for (int i = 0; i < elements.Count; i++) + { + var el = elements[i]; + if (el.ValueKind != JsonValueKind.Object) + { + _output.WriteLine($"{label} element[{i}]: non-object kind={el.ValueKind}"); + continue; + } + + var keys = el.EnumerateObject().Select(p => $"{p.Name}:{p.Value.ValueKind}").ToList(); + bool hasNestedProducts = el.TryGetProperty("products", out var nested) && nested.ValueKind == JsonValueKind.Array; + bool hasFlatProductCode = el.TryGetProperty("productCode", out _); + bool hasFlatProductId = el.TryGetProperty("productId", out _); + + _output.WriteLine($"{label} element[{i}] fields (name:type): [{string.Join(", ", keys)}]"); + _output.WriteLine($"{label} element[{i}]: hasNestedProductsArray={hasNestedProducts}, " + + $"hasProductCode={hasFlatProductCode}, hasProductId={hasFlatProductId}"); + + if (hasNestedProducts && nested.GetArrayLength() > 0) + { + var innerKeys = nested[0].EnumerateObject().Select(p => $"{p.Name}:{p.Value.ValueKind}").ToList(); + _output.WriteLine($"{label} element[{i}].products[0] fields: [{string.Join(", ", innerKeys)}]"); + } + + _output.WriteLine($"{label} element[{i}] raw (redacted): {Redact(el.GetRawText())}"); + } + + _output.WriteLine($"FINDING {label}: CONCLUSION — shape is " + + (elements.Any(e => e.ValueKind == JsonValueKind.Object && e.TryGetProperty("products", out _)) + ? "NESTED category envelope (matches V1 GetProductDetails / FlattenProducts)." + : elements.Any(e => e.ValueKind == JsonValueKind.Object && (e.TryGetProperty("productCode", out _) || e.TryGetProperty("productId", out _))) + ? "FLAT product rows." + : "UNRECOGNISED — inspect the raw output above.")); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private sealed class OrderReportSample + { + public OrderReportSample(string orderNumber, DateTime orderDate) + { + OrderNumber = orderNumber; + OrderDate = orderDate; + } + + public string OrderNumber { get; } + public DateTime OrderDate { get; } + } + + private static bool ContainsOrderNumber(string reportJson, string orderNumber) + { + if (string.IsNullOrWhiteSpace(reportJson)) return false; + try + { + using var doc = JsonDocument.Parse(reportJson); + if (!doc.RootElement.TryGetProperty("content", out var arr) || arr.ValueKind != JsonValueKind.Array) + return false; + return arr.EnumerateArray().Any(row => + row.TryGetProperty("orderNumber", out var on) && + on.ValueKind == JsonValueKind.String && + string.Equals(on.GetString(), orderNumber, StringComparison.OrdinalIgnoreCase)); + } + catch (JsonException) + { + return false; + } + } + + private CERTInextClient BuildV2Client() + { + return new CERTInextClient(new CERTInextConfig + { + // A single ApiUrl now serves V2 (issues/0022 config consolidation) — no V1-only + // fields are set here. + ApiUrl = _v2ApiUrl, + UseV2Api = true, + OAuthClientId = _v2ClientId, + OAuthClientSecret = _v2ClientSecret, + RequestorName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Test", + RequestorEmail = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com", + SignerIp = "127.0.0.1", + SignerPlace = "Gateway Lab", + PageSize = 100 + }); + } + + /// + /// Redacts anything that looks like a token/secret/key value before it's written to + /// test output. This is a best-effort scrub of raw JSON bodies for a discovery probe — + /// never log access tokens, client secrets, or API keys. + /// + private static string Redact(string raw) + { + if (string.IsNullOrEmpty(raw)) return raw; + string redacted = raw; + foreach (string key in new[] { "accessToken", "access_token", "clientSecret", "client_secret", "apiKey", "api_key", "authKey", "token" }) + { + redacted = System.Text.RegularExpressions.Regex.Replace( + redacted, + $"\"{System.Text.RegularExpressions.Regex.Escape(key)}\"\\s*:\\s*\"[^\"]*\"", + $"\"{key}\":\"***REDACTED***\"", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + } + return redacted; + } + + private static string Truncate(string value, int maxLength) + { + if (string.IsNullOrEmpty(value) || value.Length <= maxLength) return value; + return value.Substring(0, maxLength) + "...(truncated)"; + } + + } +} diff --git a/CERTInext.Tests/BoundedDcvSyncTests.cs b/CERTInext.Tests/BoundedDcvSyncTests.cs new file mode 100644 index 0000000..96b4e3b --- /dev/null +++ b/CERTInext.Tests/BoundedDcvSyncTests.cs @@ -0,0 +1,124 @@ +using System; +using FluentAssertions; +using Xunit; +using static Keyfactor.Extensions.CAPlugin.CERTInext.CERTInextCAPlugin; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Issue 0002 — unit tests for the DCV-during-sync gate (EvaluateDcvSyncEligibility). + /// Pure decision logic that bounds DCV work per sync pass so a large pending backlog + /// can't make a pass slow. No DCV machinery / network needed. + /// + public class BoundedDcvSyncTests + { + private static readonly DateTime Now = new DateTime(2026, 6, 10, 12, 0, 0, DateTimeKind.Utc); + + // --- Age window --------------------------------------------------------- + + [Fact] + public void RecentOrder_WithinAgeWindow_IsAttempted() + { + var orderDate = Now.AddHours(-1); // 1h old, window 24h + EvaluateDcvSyncEligibility(orderDate, Now, ageWindowHours: 24, attemptedSoFar: 0, perPassCap: 50) + .Should().Be(DcvSyncDecision.Attempt); + } + + [Fact] + public void OldOrder_BeyondAgeWindow_IsSkippedByAge() + { + var orderDate = Now.AddHours(-48); // 48h old, window 24h + EvaluateDcvSyncEligibility(orderDate, Now, ageWindowHours: 24, attemptedSoFar: 0, perPassCap: 50) + .Should().Be(DcvSyncDecision.SkipByAge); + } + + [Fact] + public void OrderExactlyAtAgeBoundary_IsAttempted() + { + var orderDate = Now.AddHours(-24); // exactly 24h, window 24h → still eligible (<=) + EvaluateDcvSyncEligibility(orderDate, Now, ageWindowHours: 24, attemptedSoFar: 0, perPassCap: 50) + .Should().Be(DcvSyncDecision.Attempt); + } + + [Fact] + public void UnknownOrderDate_IsAttempted_NotStarved() + { + EvaluateDcvSyncEligibility(orderDateUtc: null, Now, ageWindowHours: 24, attemptedSoFar: 0, perPassCap: 50) + .Should().Be(DcvSyncDecision.Attempt); + } + + [Fact] + public void AgeWindowDisabled_OldOrderStillAttempted() + { + var orderDate = Now.AddDays(-30); + EvaluateDcvSyncEligibility(orderDate, Now, ageWindowHours: 0, attemptedSoFar: 0, perPassCap: 50) + .Should().Be(DcvSyncDecision.Attempt); + } + + // --- Per-pass cap ------------------------------------------------------- + + [Fact] + public void UnderCap_IsAttempted() + { + EvaluateDcvSyncEligibility(Now, Now, ageWindowHours: 24, attemptedSoFar: 4, perPassCap: 5) + .Should().Be(DcvSyncDecision.Attempt); + } + + [Fact] + public void AtCap_IsSkippedByCap() + { + EvaluateDcvSyncEligibility(Now, Now, ageWindowHours: 24, attemptedSoFar: 5, perPassCap: 5) + .Should().Be(DcvSyncDecision.SkipByCap); + } + + [Fact] + public void CapDisabled_AlwaysAttemptedRegardlessOfCount() + { + EvaluateDcvSyncEligibility(Now, Now, ageWindowHours: 24, attemptedSoFar: 10_000, perPassCap: 0) + .Should().Be(DcvSyncDecision.Attempt); + } + + // --- Precedence --------------------------------------------------------- + + [Fact] + public void AgeSkip_TakesPrecedenceOverCap() + { + // Old order AND at cap → reported as age skip (age checked first). + var orderDate = Now.AddHours(-48); + EvaluateDcvSyncEligibility(orderDate, Now, ageWindowHours: 24, attemptedSoFar: 5, perPassCap: 5) + .Should().Be(DcvSyncDecision.SkipByAge); + } + + // --- Simulated pass: a backlog of old + a few recent, with a small cap --- + + [Fact] + public void SimulatedPass_OnlyRecentOrdersAttempted_AndCapped() + { + // 100 old (out-of-window) + 10 recent; cap 5. Mirrors the Synchronize loop's + // use of the gate: only recent orders are eligible, and at most `cap` are attempted. + const int ageWindow = 24, cap = 5; + int attempted = 0, skippedAge = 0, skippedCap = 0; + + for (int i = 0; i < 100; i++) // old backlog + Tally(EvaluateDcvSyncEligibility(Now.AddHours(-48), Now, ageWindow, attempted, cap), + ref attempted, ref skippedAge, ref skippedCap); + for (int i = 0; i < 10; i++) // recent + Tally(EvaluateDcvSyncEligibility(Now.AddMinutes(-5), Now, ageWindow, attempted, cap), + ref attempted, ref skippedAge, ref skippedCap); + + attempted.Should().Be(5, "only up to the cap of recent orders are attempted"); + skippedAge.Should().Be(100, "the entire old backlog is skipped by the age window"); + skippedCap.Should().Be(5, "recent orders beyond the cap are deferred to a later pass"); + } + + private static void Tally(DcvSyncDecision d, ref int attempted, ref int skippedAge, ref int skippedCap) + { + switch (d) + { + case DcvSyncDecision.Attempt: attempted++; break; + case DcvSyncDecision.SkipByAge: skippedAge++; break; + case DcvSyncDecision.SkipByCap: skippedCap++; break; + } + } + } +} diff --git a/CERTInext.Tests/CERTInext.Tests.csproj b/CERTInext.Tests/CERTInext.Tests.csproj index 39aed9d..fcd3697 100644 --- a/CERTInext.Tests/CERTInext.Tests.csproj +++ b/CERTInext.Tests/CERTInext.Tests.csproj @@ -6,12 +6,25 @@ 12.0 false true + + false + $(DefineConstants);SUPPORTS_DCV + + + + + + + diff --git a/CERTInext.Tests/CERTInextCAPluginAuditLoggingTests.cs b/CERTInext.Tests/CERTInextCAPluginAuditLoggingTests.cs new file mode 100644 index 0000000..58b2072 --- /dev/null +++ b/CERTInext.Tests/CERTInextCAPluginAuditLoggingTests.cs @@ -0,0 +1,182 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.Logging; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Issue 0040: pins the on/off behavior of the "Enrollment attempt started" audit log line in + /// for the LogSensitiveRequestData connector + /// setting. + /// + /// CERTInextCAPlugin._logger is a per-instance field assigned from + /// LogHandler.GetClassLogger<CERTInextCAPlugin>() at construction time (unlike + /// Client.CERTInextClient.Logger, which is a static readonly field resolved once + /// per process — not swappable after the fact). Swapping + /// before constructing a fresh plugin instance is therefore a genuine, narrow capture seam for + /// this one log line. All tests in this class run in the "LogHandlerFactory-NoParallel" + /// collection (sequential within the class by xUnit default; the named collection also blocks + /// any other class opting into it from interleaving) and restore the original factory in a + /// finally block so the global static mutation can't outlive a single test. + /// + [Collection("LogHandlerFactory-NoParallel")] + public class CERTInextCAPluginAuditLoggingTests + { + private sealed class CapturingLoggerProvider : ILoggerProvider + { + public ConcurrentQueue Messages { get; } = new(); + public ILogger CreateLogger(string categoryName) => new CapturingLogger(Messages); + public void Dispose() { } + + private sealed class CapturingLogger : ILogger + { + private readonly ConcurrentQueue _messages; + public CapturingLogger(ConcurrentQueue messages) => _messages = messages; + public IDisposable BeginScope(TState state) => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) + => _messages.Enqueue(formatter(state, exception)); + } + } + + private static Mock NewHappyPathMock() + { + var mock = new Mock(MockBehavior.Loose); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new API.EnrollCertificateResponse + { + Id = "ORD-AUDIT-001", + Status = "issued", + Certificate = MockCertificateData.FakePemCertificate + }); + return mock; + } + + /// + /// Runs once with a freshly-swapped capturing + /// logger factory in place — constructing the plugin only after the swap, so its + /// per-instance _logger field resolves through the capturing factory — and returns + /// every rendered log message the plugin emitted. RequesterName/RequesterEmail are driven + /// through the template parameters that EnrollmentParams.RequesterName/ + /// RequesterEmail read ( / + /// RequesterEmail), matching what the "Enrollment attempt started" line logs. + /// + private static async Task<(ConcurrentQueue Messages, string SubjectMarker)> CaptureEnrollLogMessagesAsync( + bool logSensitiveRequestData, string requesterName, string requesterEmail) + { + var provider = new CapturingLoggerProvider(); + var factory = LoggerFactory.Create(b => b.AddProvider(provider).SetMinimumLevel(LogLevel.Trace)); + + // LogHandler.Factory is a shared static — other test classes construct their own + // CERTInextCAPlugin instances concurrently (xUnit parallelizes across collections by + // default) and, purely by coincidence of timing, some of those may resolve their + // _logger through this same swapped factory while it's active, adding unrelated + // "Enrollment attempt started" lines to provider.Messages. A per-call unique subject + // is the only reliable way to pick this call's own line back out of that noise. + string subjectMarker = "audit-" + Guid.NewGuid().ToString("N"); + try + { + LogHandler.Factory = factory; + + var mock = NewHappyPathMock(); + var config = new CERTInextConfig + { + PickupRetries = 0, + LogSensitiveRequestData = logSensitiveRequestData + }; + // Constructed AFTER the factory swap so its _logger field resolves through it. + var plugin = new CERTInextCAPlugin(mock.Object, config); + + var productInfo = new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = "842", + [Constants.EnrollmentParam.RequesterName] = requesterName, + [Constants.EnrollmentParam.RequesterEmail] = requesterEmail + } + }; + + await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: $"CN={subjectMarker}.example.com", + san: null, + productInfo: productInfo, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + } + finally + { + // LogHandler.Factory is write-only (no getter to save/restore the prior value), + // so reset to the same NullLoggerFactory the class defaults to absent any host + // configuring a real one — matching every other test's ambient (unconfigured) + // logging state. + LogHandler.Factory = Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance; + factory.Dispose(); + } + + return (provider.Messages, subjectMarker); + } + + private static string FindEnrollmentAttemptLine(ConcurrentQueue messages, string subjectMarker) + { + foreach (var m in messages) + { + if (m.Contains("Enrollment attempt started") && m.Contains(subjectMarker)) + return m; + } + return null; + } + + [Fact] + public async Task Enroll_LogSensitiveRequestDataFalse_AuditLineOmitsNameAndMasksEmail() + { + var (messages, marker) = await CaptureEnrollLogMessagesAsync( + logSensitiveRequestData: false, requesterName: "Jane Doe", requesterEmail: "jane.doe@example.com"); + + string line = FindEnrollmentAttemptLine(messages, marker); + line.Should().NotBeNull("the enrollment-attempt audit line must always be logged"); + line.Should().NotContain("Jane Doe", "the requester name must be dropped entirely when the flag is off"); + line.Should().NotContain("RequesterName=", "the RequesterName field itself must be absent from the line, not just blanked"); + line.Should().Contain("j***@example.com", "the requester email must be masked but keep its domain"); + line.Should().NotContain("jane.doe@example.com"); + } + + [Fact] + public async Task Enroll_LogSensitiveRequestDataTrue_AuditLineIncludesNameAndEmailInFull() + { + var (messages, marker) = await CaptureEnrollLogMessagesAsync( + logSensitiveRequestData: true, requesterName: "Jane Doe", requesterEmail: "jane.doe@example.com"); + + string line = FindEnrollmentAttemptLine(messages, marker); + line.Should().NotBeNull("the enrollment-attempt audit line must always be logged"); + line.Should().Contain("Jane Doe", "the requester name is logged in full when the flag is on"); + line.Should().Contain("jane.doe@example.com", "the requester email is logged in full when the flag is on"); + } + } +} diff --git a/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs b/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs index b949293..d577cef 100644 --- a/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs @@ -259,6 +259,60 @@ public async Task RenewOrReissue_CallsRenewApi_WhenCertWithinRenewalWindow() It.IsAny()), Times.Never); } + // --------------------------------------------------------------------------- + // A1d-2: renewal within window carries the template's product code onto the + // RenewCertificateRequest, not just the connector-level DefaultProductCode. + // Regression for issue #26 / local issues/0012. + // --------------------------------------------------------------------------- + + [Fact] + public async Task RenewOrReissue_CallsRenewApi_UsesTemplateProductCode() + { + var clientMock = NewMock(); + var readerMock = NewReaderMock(); + + // Expiry is 30 days in the future, renewal window is 90 days → within window + DateTime expiry = DateTime.UtcNow.AddDays(30); + + readerMock + .Setup(r => r.GetRequestIDBySerialNumber(It.IsAny())) + .ReturnsAsync(MockCertificateData.CertId1); + + readerMock + .Setup(r => r.GetExpirationDateByRequestId(MockCertificateData.CertId1)) + .Returns(expiry); + + clientMock + .Setup(c => c.RenewCertificateAsync( + MockCertificateData.CertId1, + It.Is(r => r.ProfileId == MockCertificateData.ProfileIdClient), + It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedEnrollResponse("cert-renewed-002")); + + var plugin = new CERTInextCAPlugin(clientMock.Object, readerMock.Object); + + // ProfileId is a non-default value distinct from the connector's DefaultProductCode. + var productInfo = MakeProductInfo(profileId: MockCertificateData.ProfileIdClient, extras: new Dictionary + { + ["PriorCertSN"] = "AABBCCDDEEFF", + ["RenewalWindowDays"] = "90" + }); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=test.example.com", + san: null, + productInfo: productInfo, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.RenewOrReissue); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + clientMock.Verify(c => c.RenewCertificateAsync( + MockCertificateData.CertId1, + It.Is(r => r.ProfileId == MockCertificateData.ProfileIdClient), + It.IsAny()), Times.Once); + } + // --------------------------------------------------------------------------- // A1e: PriorCertSN present, cert already expired → new enroll // Semantics: useRenewalApi = expiry > now && expiry <= now + window. @@ -736,6 +790,188 @@ public void Initialize_Succeeds_WithValidApiKeyConfig() act.Should().NotThrow(); } + // --------------------------------------------------------------------------- + // M1 compliance fix: Initialize must enforce the same https-or-loopback rule as + // ValidateCAConnectionInfo on ApiUrl (and, in V1 OAuth mode, OAuthTokenUrl) — a + // connector saved before that rule existed would otherwise sail through on every + // gateway restart and keep sending credentials in cleartext. + // --------------------------------------------------------------------------- + + [Fact] + public void Initialize_Throws_WhenApiUrlIsHttp_NonLoopback() + { + var configProviderMock = new Mock(MockBehavior.Strict); + var certReaderMock = NewReaderMock(); + + configProviderMock.Setup(p => p.CAConnectionData) + .Returns(new Dictionary + { + ["ApiUrl"] = "http://ca.example.com", + ["AuthMode"] = "ApiKey", + ["ApiKey"] = "test-api-key-value", + ["Enabled"] = true + }); + + var plugin = new CERTInextCAPlugin(); + + Action act = () => plugin.Initialize(configProviderMock.Object, certReaderMock.Object); + + act.Should().Throw() + .WithMessage("*ApiUrl*https*"); + } + + [Fact] + public void Initialize_Throws_WhenApiUrlIsHttp_NonLoopback_EvenWithInjectedClient() + { + // _client ??= in Initialize lets tests inject a mock client and skip building a + // real CERTInextClient — but the config validation itself must still run + // unconditionally; an injected client must not bypass the cleartext-credential + // check on the saved config. + var configProviderMock = new Mock(MockBehavior.Strict); + var certReaderMock = NewReaderMock(); + + configProviderMock.Setup(p => p.CAConnectionData) + .Returns(new Dictionary + { + ["ApiUrl"] = "http://ca.example.com", + ["AuthMode"] = "ApiKey", + ["ApiKey"] = "test-api-key-value", + ["Enabled"] = true + }); + + var plugin = new CERTInextCAPlugin(NewMock().Object); + + Action act = () => plugin.Initialize(configProviderMock.Object, certReaderMock.Object); + + act.Should().Throw() + .WithMessage("*ApiUrl*https*"); + } + + [Theory] + [InlineData("http://localhost:8080")] + [InlineData("http://127.0.0.1:8080")] + [InlineData("http://[::1]:8080")] + public void Initialize_Succeeds_WhenApiUrlIsHttp_Loopback(string apiUrl) + { + var configProviderMock = new Mock(MockBehavior.Strict); + var certReaderMock = NewReaderMock(); + + configProviderMock.Setup(p => p.CAConnectionData) + .Returns(new Dictionary + { + ["ApiUrl"] = apiUrl, + ["AuthMode"] = "ApiKey", + ["ApiKey"] = "test-api-key-value", + ["Enabled"] = true + }); + + var plugin = new CERTInextCAPlugin(); + + Action act = () => plugin.Initialize(configProviderMock.Object, certReaderMock.Object); + + act.Should().NotThrow(); + } + + [Fact] + public void Initialize_Succeeds_WhenApiUrlIsHttps() + { + var configProviderMock = new Mock(MockBehavior.Strict); + var certReaderMock = NewReaderMock(); + + configProviderMock.Setup(p => p.CAConnectionData) + .Returns(new Dictionary + { + ["ApiUrl"] = "https://ca.example.com", + ["AuthMode"] = "ApiKey", + ["ApiKey"] = "test-api-key-value", + ["Enabled"] = true + }); + + var plugin = new CERTInextCAPlugin(); + + Action act = () => plugin.Initialize(configProviderMock.Object, certReaderMock.Object); + + act.Should().NotThrow(); + } + + [Fact] + public void Initialize_Throws_WhenOAuthTokenUrlIsHttp_NonLoopback() + { + var configProviderMock = new Mock(MockBehavior.Strict); + var certReaderMock = NewReaderMock(); + + configProviderMock.Setup(p => p.CAConnectionData) + .Returns(new Dictionary + { + ["ApiUrl"] = "https://ca.example.com", + ["AccountNumber"] = "12345", + ["AuthMode"] = "OAuth", + ["OAuthTokenUrl"] = "http://token.example.com", + ["OAuthClientId"] = "my-client", + ["OAuthClientSecret"] = "my-secret", + ["Enabled"] = true + }); + + var plugin = new CERTInextCAPlugin(); + + Action act = () => plugin.Initialize(configProviderMock.Object, certReaderMock.Object); + + act.Should().Throw() + .WithMessage("*OAuthTokenUrl*https*"); + } + + [Fact] + public void Initialize_Succeeds_WhenOAuthTokenUrlIsHttp_Loopback() + { + var configProviderMock = new Mock(MockBehavior.Strict); + var certReaderMock = NewReaderMock(); + + configProviderMock.Setup(p => p.CAConnectionData) + .Returns(new Dictionary + { + ["ApiUrl"] = "https://ca.example.com", + ["AccountNumber"] = "12345", + ["AuthMode"] = "OAuth", + ["OAuthTokenUrl"] = "http://localhost:9999", + ["OAuthClientId"] = "my-client", + ["OAuthClientSecret"] = "my-secret", + ["Enabled"] = true + }); + + var plugin = new CERTInextCAPlugin(); + + Action act = () => plugin.Initialize(configProviderMock.Object, certReaderMock.Object); + + act.Should().NotThrow(); + } + + [Fact] + public void Initialize_DoesNotCheckOAuthTokenUrl_WhenAuthModeIsNotOAuth() + { + // AccessKey mode never reads OAuthTokenUrl (CERTInextClient only builds the OAuth + // authenticator when AuthMode is OAuth/OAuth2) — a stray http OAuthTokenUrl value + // left over in a connector's saved config from a prior AuthMode switch must not + // block startup. + var configProviderMock = new Mock(MockBehavior.Strict); + var certReaderMock = NewReaderMock(); + + configProviderMock.Setup(p => p.CAConnectionData) + .Returns(new Dictionary + { + ["ApiUrl"] = "https://ca.example.com", + ["AuthMode"] = "ApiKey", + ["ApiKey"] = "test-api-key-value", + ["OAuthTokenUrl"] = "http://token.example.com", + ["Enabled"] = true + }); + + var plugin = new CERTInextCAPlugin(); + + Action act = () => plugin.Initialize(configProviderMock.Object, certReaderMock.Object); + + act.Should().NotThrow(); + } + // --------------------------------------------------------------------------- // C3a: Enroll passes ValidityDays, AutoApprove, RequesterName, RequesterEmail, KeyType // --------------------------------------------------------------------------- @@ -772,7 +1008,7 @@ await plugin.Enroll( enrollmentType: EnrollmentType.New); capturedRequest.Should().NotBeNull(); - capturedRequest.ValidityDays.Should().Be(365); + capturedRequest!.ValidityDays.Should().Be(365); capturedRequest.RequesterName.Should().Be("Jane Smith"); capturedRequest.RequesterEmail.Should().Be("jane@example.com"); capturedRequest.KeyType.Should().Be("RSA2048"); @@ -811,7 +1047,7 @@ await plugin.Enroll( capturedRequest.Should().NotBeNull(); // ValidityDays == 0 when parse fails, so request should have null - capturedRequest.ValidityDays.Should().BeNull( + capturedRequest!.ValidityDays.Should().BeNull( "invalid ValidityDays should fall back to null (use profile default)"); } @@ -883,7 +1119,7 @@ await plugin.Enroll( enrollmentType: EnrollmentType.New); capturedRequest.Should().NotBeNull(); - capturedRequest.Sans.Should().NotBeNull(); + capturedRequest!.Sans.Should().NotBeNull(); capturedRequest.Sans.Should().Contain(s => s.Type == "oid", "unknown SAN type should be passed through as-is"); } diff --git a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs new file mode 100644 index 0000000..20219a8 --- /dev/null +++ b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs @@ -0,0 +1,1538 @@ +// Copyright 2024 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// At http://www.apache.org/licenses/LICENSE-2.0 + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Unit tests for the DCV orchestration path inside + /// / + /// . + /// + /// All external dependencies (CERTInext client, DNS validator) are stubbed so + /// no network calls are made. Propagation delay is set to 0 so tests run fast. + /// + public class CERTInextCAPluginDcvTests + { + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private static CERTInextConfig DcvConfig( + bool enabled = true, + int propagationDelaySeconds = 1, + int timeoutMinutes = 1, + int dcvWaitForChallengeSeconds = 0, + int dcvWaitForIssuanceSeconds = 0, + int pickupRetries = 0) => + new CERTInextConfig + { + DcvEnabled = enabled, + DcvPropagationDelaySeconds = propagationDelaySeconds, + DcvTimeoutMinutes = timeoutMinutes, + // Default to 0 so existing tests preserve the pre-polling single-check + // behaviour and run fast. Tests that exercise the new wait paths can opt + // in with a positive value (see WaitsForChallenge_ToAppear / WaitsForIssuance). + DcvWaitForChallengeSeconds = dcvWaitForChallengeSeconds, + DcvWaitForIssuanceSeconds = dcvWaitForIssuanceSeconds, + // Disable the synchronous pickup poll by default (same reasoning as the wait + // budgets above): the DCV path owns issuance for these tests, and a DCV-disabled + // or no-factory case that ends on a pending result must not pay the real pickup + // Task.Delay loop. The dedicated pickup tests live in CERTInextCAPluginTests. + PickupRetries = pickupRetries + }; + + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + private static CERTInextCAPlugin BuildPlugin( + ICERTInextClient client, + IDomainValidatorFactory factory, + CERTInextConfig config = null) => + new CERTInextCAPlugin(client, factory, config ?? DcvConfig()); + + private static EnrollmentProductInfo MakeProductInfo() => + new EnrollmentProductInfo + { + ProductID = MockCertificateData.ProfileIdTls, + ProductParameters = new Dictionary { ["ProfileId"] = MockCertificateData.ProfileIdTls } + }; + + /// + /// Returns a mock client pre-wired for the full happy-path DCV flow: + /// Enroll → TrackOrder (DCV pending) → GetDcv → VerifyDcv → GetCertificate. + /// + private static (Mock mock, FakeDomainValidator validator) HappyPathMocks( + string orderNumber = MockCertificateData.DcvOrderId, + string domain = MockCertificateData.DcvDomain, + string token = MockCertificateData.DcvToken) + { + var mock = NewMock(); + + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = orderNumber, Status = "pending_dcv" }); + + // First call: pending (initial check in PerformDcvIfNeededAsync) + // Subsequent calls: verified (polling in WaitForDcvVerificationAsync) + mock.SetupSequence(c => c.TrackOrderAsync(orderNumber, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvPendingTrackResponse(orderNumber, domain)) + .ReturnsAsync(MockCertificateData.DcvVerifiedTrackResponse(orderNumber, domain)); + + mock.Setup(c => c.GetDcvAsync(orderNumber, domain, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse(token)); + + mock.Setup(c => c.VerifyDcvAsync(orderNumber, domain, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .Returns(Task.CompletedTask); + + mock.Setup(c => c.GetCertificateAsync(orderNumber, It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(orderNumber)); + + var validator = new FakeDomainValidator(); + return (mock, validator); + } + + private static Task Enroll(CERTInextCAPlugin plugin) => + plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: $"CN={MockCertificateData.DcvDomain}", + san: new Dictionary { ["dns"] = new[] { MockCertificateData.DcvDomain } }, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + // --------------------------------------------------------------------------- + // Happy path + // --------------------------------------------------------------------------- + + [Fact] + public async Task Dcv_HappyPath_StagesVerifiesAndCleansUp() + { + var (mock, validator) = HappyPathMocks(); + // Issuance budget > 0 so the post-DCV GetCertificate poll runs and lifts the + // issued cert out of the mock back into the EnrollmentResult. + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), + DcvConfig(dcvWaitForIssuanceSeconds: 10)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + result.Certificate.Should().Contain("BEGIN CERTIFICATE"); + + // Verify Stage was called with the right hostname and token + string expectedHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, MockCertificateData.DcvDomain); + validator.StagedRecords.Should().ContainSingle() + .Which.Should().Be((expectedHostname, MockCertificateData.DcvToken)); + + // Verify Cleanup was called (always, including on success) + validator.CleanedUpKeys.Should().ContainSingle().Which.Should().Be(expectedHostname); + + mock.Verify(c => c.VerifyDcvAsync( + MockCertificateData.DcvOrderId, + MockCertificateData.DcvDomain, + Constants.Dcv.MethodDnsTxt, + It.IsAny()), Times.Once); + + mock.Verify(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Dcv_HappyPath_UsesCustomTxtTemplate() + { + var (mock, validator) = HappyPathMocks(); + // Issuance budget > 0 so the post-DCV GetCertificate poll runs. + var config = DcvConfig(dcvWaitForIssuanceSeconds: 10); + config.DcvTxtRecordTemplate = "dcv-proof.{0}.acme-corp.com"; + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), config); + + await Enroll(plugin); + + string expectedHostname = $"dcv-proof.{MockCertificateData.DcvDomain}.acme-corp.com"; + validator.StagedRecords.Should().ContainSingle().Which.key.Should().Be(expectedHostname); + validator.CleanedUpKeys.Should().ContainSingle().Which.Should().Be(expectedHostname); + } + + // --------------------------------------------------------------------------- + // DCV skipped conditions + // --------------------------------------------------------------------------- + + [Fact] + public async Task Dcv_Skipped_WhenOrderAlreadyIssued() + { + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.CertId1, Status = "issued", Certificate = MockCertificateData.FakePemCertificate, SerialNumber = "0A1B2C" }); + + mock.Setup(c => c.TrackOrderAsync(MockCertificateData.CertId1, It.IsAny())) + .ReturnsAsync(MockCertificateData.AlreadyIssuedTrackResponse()); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + var result = await Enroll(plugin); + + // DCV skipped — order was already issued, result comes from EnrollCertificateAsync directly + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + validator.StagedRecords.Should().BeEmpty("DCV should be skipped for already-issued orders"); + validator.CleanedUpKeys.Should().BeEmpty(); + + mock.Verify(c => c.GetDcvAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Dcv_Skipped_WhenNoDomainVerificationBlock() + { + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" }); + + mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "1", + CertificateStatusId = "1", + DomainVerification = null + } + }); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + // PerformDcvIfNeeded returns false → plugin returns result from EnrollCertificateAsync + var result = await Enroll(plugin); + + validator.StagedRecords.Should().BeEmpty(); + mock.Verify(c => c.GetDcvAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Dcv_SkipsStaging_AndDoesNotIssuancePoll_WhenAllDomainsAlreadyValidated_AndIssuanceBudgetZero() + { + // With DcvWaitForIssuanceSeconds=0 (the test fixture's DcvConfig default), an + // order with DCV already validated short-circuits: no TXT records staged AND + // no post-DCV GetCertificate poll. Lets sync pick up the cert on its own. + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" }); + + // domainVerification.status = "1" (Validated) — no pending work + mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "1", + CertificateStatusId = "1", + DomainVerification = new TrackOrderDomainVerification + { + Status = Constants.Dcv.StatusValidated + } + } + }); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + await Enroll(plugin); + + validator.StagedRecords.Should().BeEmpty(); + mock.Verify(c => c.GetDcvAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + // Issuance budget = 0 means the post-DCV poll short-circuits and GetCertificate + // is never called from this Enroll() path. + mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Dcv_RunsIssuanceWait_WhenDcvAlreadyValidated_AndIssuanceBudgetPositive() + { + // The cached-DCV gap fix: when CERTInext shows DCV already validated (no work + // for the plugin's DNS-TXT staging) AND the admin has set a positive issuance + // budget, the plugin should poll GetCertificate until the cert is generated + // and return the issued result directly from Enroll() — not leave it for sync. + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" }); + + mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "1", + CertificateStatusId = "1", + DomainVerification = new TrackOrderDomainVerification + { + Status = Constants.Dcv.StatusValidated + } + } + }); + + // First post-DCV fetch is still pending; second returns issued. + mock.SetupSequence(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(MockCertificateData.PendingCertRecord(MockCertificateData.DcvOrderId)) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(MockCertificateData.DcvOrderId)); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), + DcvConfig(dcvWaitForIssuanceSeconds: 10)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED, + "the issuance poll must lift the issued cert into the EnrollmentResult, " + + "not let the order fall through to a pending-then-sync round-trip"); + validator.StagedRecords.Should().BeEmpty("no TXT staging is needed when DCV is already validated"); + mock.Verify(c => c.GetDcvAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + mock.Verify(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny()), + Times.AtLeast(2), "plugin should have polled at least twice to see the cert transition to issued"); + } + + [Fact] + public async Task Dcv_Skipped_WhenDcvEnabledFalse() + { + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedEnrollResponse()); + + var validator = new FakeDomainValidator(); + var config = DcvConfig(enabled: false); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), config); + + await Enroll(plugin); + + validator.StagedRecords.Should().BeEmpty("DCV should not run when DcvEnabled=false"); + mock.Verify(c => c.TrackOrderAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // Issue #7 — IDomainValidatorFactory is optional / injected post-construction + // --------------------------------------------------------------------------- + + [Fact] + public async Task Dcv_SilentlyNoOps_WhenNoFactoryInjected_AndDcvEnabledTrue() + { + // Simulates a v3.2 gateway host: plugin instantiated via the parameterless + // public production constructor, DcvEnabled=true in the connector config, + // but no IDomainValidatorFactory was injected via SetDomainValidatorFactory + // (because the host's IAnyCAPlugin assembly doesn't even have that interface). + // Enroll must: + // * NOT throw (no missing-type / null-factory exception), + // * NOT touch the CA's TrackOrder for DCV purposes, + // * return the enrollment result the CA gave us (here: pending). + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.PendingEnrollResponse()); + + // Internal test ctor with factory = null AND DcvEnabled = true. + var plugin = new CERTInextCAPlugin(mock.Object, domainValidatorFactory: null, DcvConfig(enabled: true)); + + var result = await Enroll(plugin); + + result.Should().NotBeNull(); + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION, + "with no factory the CA's pending response must be passed through unchanged"); + mock.Verify(c => c.TrackOrderAsync(It.IsAny(), It.IsAny()), Times.Never, + "EnrollNewAsync must short-circuit the DCV block when _domainValidatorFactory is null"); + } + + [Fact] + public async Task SetDomainValidatorFactory_AfterConstruction_WiresFactoryForSubsequentEnroll() + { + // The v3.3+ gateway path: host instantiates the plugin via the parameterless + // public constructor, resolves an IDomainValidatorFactory from its own + // service container, then calls SetDomainValidatorFactory(factory) before + // Initialize. Subsequent Enroll() calls must use the injected factory. + var (mock, validator) = HappyPathMocks(); + + // Plugin starts with NO factory — proves the setter does the wire-up, not + // some prior constructor parameter. + var plugin = new CERTInextCAPlugin( + mock.Object, + domainValidatorFactory: null, + DcvConfig(dcvWaitForIssuanceSeconds: 10)); + + plugin.SetDomainValidatorFactory(new FakeDomainValidatorFactory(validator)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED, + "the factory injected via SetDomainValidatorFactory must drive DCV end-to-end"); + validator.StagedRecords.Should().NotBeEmpty( + "SetDomainValidatorFactory must populate _domainValidatorFactory so DCV staging runs"); + } + + [Fact] + public async Task SetDomainValidatorFactory_SecondCall_OverridesFirst() + { + // Property-style setter semantics: the most recent SetDomainValidatorFactory + // call wins. Important for gateway hosts that may resolve a fresh factory + // per-initialize cycle. Tested behaviorally — drive Enroll() and assert + // the SECOND factory's validator received the TXT staging call (no reflection + // on internal fields). + var (mock, _) = HappyPathMocks(); + var firstValidator = new FakeDomainValidator(); + var secondValidator = new FakeDomainValidator(); + + var plugin = new CERTInextCAPlugin( + mock.Object, + domainValidatorFactory: null, + DcvConfig(dcvWaitForIssuanceSeconds: 10)); + + // First setter call is ignored by the override; only the second factory's + // validator should ever see traffic. + plugin.SetDomainValidatorFactory(new FakeDomainValidatorFactory(firstValidator)); + plugin.SetDomainValidatorFactory(new FakeDomainValidatorFactory(secondValidator)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + firstValidator.StagedRecords.Should().BeEmpty( + "the first factory must be replaced — its validator should never be called"); + secondValidator.StagedRecords.Should().NotBeEmpty( + "the second SetDomainValidatorFactory call must replace the first; its validator drives DCV"); + } + + // --------------------------------------------------------------------------- + // Cancelled/rejected orders short-circuit even with validated DCV state + // --------------------------------------------------------------------------- + + [Theory] + [InlineData("4")] // OrderStatusId 4 = Order Cancelled + [InlineData("5")] // OrderStatusId 5 = Order Rejected + public async Task Dcv_Skipped_WhenOrderStatusIdIsTerminal_EvenIfDcvValidated(string terminalOrderStatusId) + { + // Regression guard for the cached-DCV path: a cancelled or rejected order + // can still have domainVerification.Status="1" carried over from a prior + // validated round. Without this guard the plugin would return true from + // PerformDcvIfNeededAsync and the caller would spend the full + // DcvWaitForIssuanceSeconds budget polling GetCertificate for a cert that + // is never going to issue. Per audit report B2 on PR #2. + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" }); + + mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = terminalOrderStatusId, + CertificateStatusId = "1", + // Validated DCV state — without the OrderStatusId guard this would + // erroneously trigger the issuance-wait path. + DomainVerification = new TrackOrderDomainVerification + { + Status = Constants.Dcv.StatusValidated + } + } + }); + + var validator = new FakeDomainValidator(); + // Issuance-wait budget > 0 AND pickup ENABLED (pickupRetries > 0) so a wrong-path + // entry would manifest as a GetCertificate call we DON'T expect — this test must + // fail if either the DCV issuance-wait guard OR the synchronous-pickup gate + // (dcvIssuanceWaitRan) regresses and starts polling a cancelled/rejected order. + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), + DcvConfig(dcvWaitForIssuanceSeconds: 10, pickupRetries: 5)); + + await Enroll(plugin); + + mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), + Times.Never, + "Enroll must not enter WaitForIssuanceAfterDcvAsync OR the synchronous pickup poll " + + "when the order is cancelled/rejected, even if DCV happens to be in a 'validated' state"); + validator.StagedRecords.Should().BeEmpty( + "DCV staging must not run for a cancelled/rejected order"); + } + + // --------------------------------------------------------------------------- + // Sync path is single-shot for the DCV challenge wait + // --------------------------------------------------------------------------- + + [Fact] + public async Task SyncDcvRetry_DoesSingleShotTrackOrder_WhenChallengeNotReady() + { + // Sync MUST NOT poll the configured DcvWaitForChallengeSeconds budget per + // pending order — that would scale O(orders × 60s) per cycle and tie up + // gateway threads for minutes per sync. When TrackOrder returns null + // domainVerification, sync exits immediately and lets the next sync cycle + // pick the order up. + var mock = NewMock(); + + // High config budget — would normally drive 6+ polls × 5s waits. The sync + // override of 0 must prevent that. + var config = DcvConfig(dcvWaitForChallengeSeconds: 60); + + mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "1", + CertificateStatusId = "1", + DomainVerification = null + } + }); + + // GetSingleRecord calls GetCertificateAsync first to materialize the record; + // the sync-DCV-retry kicks in afterwards. The pending response keeps the + // retry path engaged so we exercise the override. The assertion below pins + // Times.Exactly(1) on TrackOrderAsync: with override=0, the polling loop + // takes one TrackOrder call, sees domainVerification null, and bails — no + // further polls inside the 60s budget the config nominally allows. + mock.Setup(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(MockCertificateData.PendingCertRecord(MockCertificateData.DcvOrderId)); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), config); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + // GetSingleRecord calls TryRunDcvDuringSyncAsync internally — which is the + // sync-style path with waitForChallengeSecondsOverride=0. + var record = await plugin.GetSingleRecord(MockCertificateData.DcvOrderId); + sw.Stop(); + + record.Should().NotBeNull(); + // The 0-budget single shot must complete well under the 60s config budget. + // Use a generous 10s ceiling to tolerate slow CI hosts; the actual cost is + // ~1 TrackOrder. Without the override we'd be ≥60s. + sw.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(10), + "sync's DCV retry must be single-shot, not poll the configured challenge budget"); + + mock.Verify(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny()), + Times.Exactly(1), + "PerformDcvIfNeededAsync's single-shot challenge check must make exactly ONE " + + "TrackOrder call when waitForChallengeSecondsOverride=0 and the slot is null. " + + "Without the override, the polling loop would issue many more calls within " + + "the 60s budget."); + } + + // --------------------------------------------------------------------------- + // Failure modes + // --------------------------------------------------------------------------- + + [Fact] + public async Task Dcv_SkipsAndDefers_WhenNoProviderForDomain() + { + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" }); + + mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvPendingTrackResponse()); + + mock.Setup(c => c.GetDcvAsync(MockCertificateData.DcvOrderId, MockCertificateData.DcvDomain, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse()); + + // Factory returns null → no DNS provider configured. Regression: this used to throw and + // fail the whole order — including when the "unresolvable" domain was actually just a + // non-DNS Subject CN with no config-level way to prevent the throw (SubmitNonDnsSans only + // filters the SAN list, not the subject). Now it is logged loudly and deferred instead. + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator: null)); + + Func act = () => Enroll(plugin); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task Dcv_SkipsAndDefers_WhenStageValidationFails() + { + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" }); + + mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvPendingTrackResponse()); + + mock.Setup(c => c.GetDcvAsync(MockCertificateData.DcvOrderId, MockCertificateData.DcvDomain, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse()); + + var validator = new FakeDomainValidator { StageSucceeds = false, StageError = "DNS zone not writable" }; + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + Func act = () => Enroll(plugin); + + // Regression: a StageValidation failure used to throw and fail the whole order. Now it + // is logged loudly and the domain is skipped/deferred — this is the only pending domain, + // so nothing gets staged and the order defers to the next sync cycle. + await act.Should().NotThrowAsync(); + + // No VerifyDcv call — nothing was staged to verify + mock.Verify(c => c.VerifyDcvAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Dcv_CleanupAlwaysCalled_EvenWhenVerifyDcvThrows() + { + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" }); + + mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvPendingTrackResponse()); + + mock.Setup(c => c.GetDcvAsync(MockCertificateData.DcvOrderId, MockCertificateData.DcvDomain, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse()); + + mock.Setup(c => c.VerifyDcvAsync(MockCertificateData.DcvOrderId, MockCertificateData.DcvDomain, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ThrowsAsync(new Exception("CERTInext DNS record not found")); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + Func act = () => Enroll(plugin); + + await act.Should().ThrowAsync().WithMessage("*DNS record not found*"); + + // Cleanup must run even when VerifyDcv throws + string expectedHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, MockCertificateData.DcvDomain); + validator.CleanedUpKeys.Should().ContainSingle().Which.Should().Be(expectedHostname); + } + + [Fact] + public async Task Dcv_SkipsAndDefers_WhenGetDcvReturnsNoToken() + { + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" }); + + mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvPendingTrackResponse()); + + mock.Setup(c => c.GetDcvAsync(MockCertificateData.DcvOrderId, MockCertificateData.DcvDomain, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(new GetDcvResponse { DcvDetails = new DcvResponseDetails { Token = null } }); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + Func act = () => Enroll(plugin); + + // Regression: an empty token used to throw and fail the whole order. It is now logged + // loudly (LogError) and the domain is skipped — the order defers to the next sync cycle + // rather than failing Enroll with an order already placed at the CA. + await act.Should().NotThrowAsync(); + validator.StagedRecords.Should().BeEmpty("the only pending domain returned no token, so nothing should have been staged"); + } + + // --------------------------------------------------------------------------- + // EMS-956 tolerance — see analysis/certinext-support-ticket-2026-05-12.md + // --------------------------------------------------------------------------- + + [Fact] + public async Task Dcv_Defers_When_GetDcv_ReturnsEms956() + { + // Simulates the post-pre-vetted-org behaviour: TrackOrder shows a pending DCV + // slot, but CERTInext's GetDcv endpoint still rejects calls with EMS-956 for a + // window after enrollment. Plugin must NOT throw — it must return the pending + // result so the gateway records the order and the sync-retry can pick it up. + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending_dcv" }); + + mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvPendingTrackResponse()); + + mock.Setup(c => c.GetDcvAsync(MockCertificateData.DcvOrderId, MockCertificateData.DcvDomain, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ThrowsAsync(new Exception( + "CERTInext GetDcv failed for order '" + MockCertificateData.DcvOrderId + "': EMS-956 Invalid Request for this API.")); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + // Should NOT throw — must return pending enrollment result so the gateway + // records the order and lets sync-retry recover later. + var result = await Enroll(plugin); + result.Should().NotBeNull(); + + // The DNS provider must not have been touched — staging a TXT record without a + // valid token would be wasted work and could collide with the future retry. + validator.StagedRecords.Should().BeEmpty(); + validator.CleanedUpKeys.Should().BeEmpty(); + + // VerifyDcv must never be called either. + mock.Verify(c => c.VerifyDcvAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Dcv_Defers_When_GetDcv_ReturnsInvalidRequestMessage_WithoutEms956Code() + { + // Tolerance must also match the human-readable phrase, not only the error code, + // because the CERTInext client wraps non-200 responses in a generic Exception + // whose Message is the upstream errorMessage field (sometimes without the code). + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending_dcv" }); + + mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvPendingTrackResponse()); + + mock.Setup(c => c.GetDcvAsync(MockCertificateData.DcvOrderId, MockCertificateData.DcvDomain, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ThrowsAsync(new Exception("Invalid Request for this API")); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + var result = await Enroll(plugin); + result.Should().NotBeNull(); + validator.StagedRecords.Should().BeEmpty(); + } + + [Fact] + public async Task Dcv_SkipsAndDefers_WhenGetDcvFailsWithUnrelatedError() + { + // Regression: this test used to assert the opposite — that a genuine server error (5xx, + // transport, auth) must bubble up and fail the whole enrollment. That is exactly the + // orphaned-order failure mode: GetDcv's live behavior for a non-DNS order-domain is + // unmeasured (see BuildSanList's sandbox-only caveat), so treating any unrecognized + // GetDcv error as fatal risks failing perfectly good co-tenant DNS domains on the same + // order over one domain's transient or CA-side issue, with the enrollment already + // placed at CERTInext and no catch anywhere above this call. The failure is still loud + // (LogError, with the underlying exception) — it just no longer fails the call. + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending_dcv" }); + + mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvPendingTrackResponse()); + + mock.Setup(c => c.GetDcvAsync(MockCertificateData.DcvOrderId, MockCertificateData.DcvDomain, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ThrowsAsync(new Exception("HTTP 500: Internal Server Error")); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + Func act = () => Enroll(plugin); + await act.Should().NotThrowAsync(); + validator.StagedRecords.Should().BeEmpty("the only pending domain's GetDcv call failed, so nothing should have been staged"); + } + + // --------------------------------------------------------------------------- + // DcvWaitForChallengeSeconds — wait for domainVerification to appear + // --------------------------------------------------------------------------- + + [Fact] + public async Task Dcv_WaitsForChallenge_WhenDomainVerificationAppearsLate() + { + // First TrackOrder returns null domainVerification (CERTInext hasn't materialised + // the slot yet), second returns a populated pending slot. With a positive + // DcvWaitForChallengeSeconds the plugin must poll and proceed with DCV, NOT skip. + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending_dcv" }); + + // Sequence: 1st TrackOrder = no DCV slot, 2nd = pending, then verified for the wait poll. + mock.SetupSequence(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "1", + CertificateStatusId = "1", + DomainVerification = null + } + }) + .ReturnsAsync(MockCertificateData.DcvPendingTrackResponse()) + .ReturnsAsync(MockCertificateData.DcvVerifiedTrackResponse()); + + mock.Setup(c => c.GetDcvAsync(MockCertificateData.DcvOrderId, MockCertificateData.DcvDomain, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse()); + mock.Setup(c => c.VerifyDcvAsync(MockCertificateData.DcvOrderId, MockCertificateData.DcvDomain, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(MockCertificateData.DcvOrderId)); + + var validator = new FakeDomainValidator(); + // Both budgets positive so the polling paths exercise end-to-end. + var plugin = BuildPlugin( + mock.Object, + new FakeDomainValidatorFactory(validator), + DcvConfig(dcvWaitForChallengeSeconds: 10, dcvWaitForIssuanceSeconds: 10)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + validator.StagedRecords.Should().NotBeEmpty("DCV must have run after polling found the slot"); + } + + [Fact] + public async Task Dcv_GivesUpWaitingForChallenge_AfterBudgetExpires() + { + // domainVerification stays null forever. With a short positive budget the plugin + // must poll for the budget and then return false (deferred to sync), NOT throw. + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" }); + + mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "1", + CertificateStatusId = "1", + DomainVerification = null + } + }); + + var validator = new FakeDomainValidator(); + // 5-second budget keeps the test fast but tolerates loaded CI hosts where a + // 2-second budget could overshoot to a single poll. + var plugin = BuildPlugin( + mock.Object, + new FakeDomainValidatorFactory(validator), + DcvConfig(dcvWaitForChallengeSeconds: 5)); + + var result = await Enroll(plugin); + + result.Should().NotBeNull(); + validator.StagedRecords.Should().BeEmpty("no DCV slot was ever exposed"); + mock.Verify(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny()), + Times.AtLeast(2), "plugin should have polled at least twice within the 5-second budget"); + } + + // --------------------------------------------------------------------------- + // DcvWaitForIssuanceSeconds — wait for cert PEM after DCV verifies + // --------------------------------------------------------------------------- + + [Fact] + public async Task Dcv_WaitsForIssuance_AfterDcvVerifies() + { + // First post-DCV GetCertificate returns pending; second returns issued. Plugin + // must poll and return the issued result to Enroll(), not the first pending one. + var (mock, validator) = HappyPathMocks(); + + // Override default GetCertificate setup: first pending, then issued. + mock.SetupSequence(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(MockCertificateData.PendingCertRecord(MockCertificateData.DcvOrderId)) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(MockCertificateData.DcvOrderId)); + + var plugin = BuildPlugin( + mock.Object, + new FakeDomainValidatorFactory(validator), + DcvConfig(dcvWaitForIssuanceSeconds: 10)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED, + "post-DCV polling must return the issued status, not the first pending fetch"); + mock.Verify(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny()), + Times.AtLeast(2), "plugin should have polled at least twice for issuance"); + } + + // --------------------------------------------------------------------------- + // Undrainable pending domains must not strand the valid ones on the same order + // --------------------------------------------------------------------------- + + /// Builds a DomainVerificationDetail JsonElement for the given dcvStatus. + private static System.Text.Json.JsonElement DcvDetail(string dcvStatus) => + System.Text.Json.JsonSerializer.SerializeToElement(new DomainVerificationDetail + { + DcvMethod = Constants.Dcv.MethodDnsTxt, + DcvStatus = dcvStatus, + Status = "1" + }); + + /// + /// Builds a TrackOrder response whose domainVerification block lists several pending + /// domains, so tests can mix validatable and unvalidatable keys on one order. + /// + private static TrackOrderResponse DcvPendingTrackResponseMultiDomain( + string orderNumber, params string[] domains) + { + var detail = DcvDetail(Constants.Dcv.StatusPending); + var raw = new Dictionary(); + foreach (string d in domains) + raw[d] = detail; + + return new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "1", + CertificateStatusId = "1", + DomainVerification = new TrackOrderDomainVerification + { + Status = Constants.Dcv.StatusPending, + RawDomainEntries = raw + } + } + }; + } + + /// + /// Builds a TrackOrder response with one already-validated domain (dcvStatus=1) and one + /// still-pending, unresolvable domain (dcvStatus=0) — the shape CERTInext produces when it + /// has cached a prior DCV validation for the CN while a non-DNS SAN on the same order is + /// still outstanding. + /// + private static TrackOrderResponse DcvMixedStatusTrackResponse( + string validatedDomain, string pendingDomain) + { + var validated = DcvDetail(Constants.Dcv.StatusValidated); + var pending = DcvDetail(Constants.Dcv.StatusPending); + + return new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "1", + CertificateStatusId = "1", + DomainVerification = new TrackOrderDomainVerification + { + // Aggregate stays pending because one domain still is — this must not take + // the early "already validated" return at the top of the method. + Status = Constants.Dcv.StatusPending, + RawDomainEntries = new Dictionary + { + [validatedDomain] = validated, + [pendingDomain] = pending + } + } + } + }; + } + + /// + /// Regression for the false invariant behind the round-1 fix's own misconfiguration check: + /// "the CN is always a pending domain too" is untrue whenever CERTInext has cached a prior + /// DCV validation for it (a case this same file's cached-validation branch documents), so a + /// non-DNS SAN sharing the order with an already-validated CN must not throw — it must defer + /// to the next sync cycle exactly like the single-domain case does. + /// + [Fact] + public async Task Dcv_CachedCnPlusUnresolvableSan_DefersWithoutThrowing() + { + const string order = MockCertificateData.DcvOrderId; + const string cn = MockCertificateData.DcvDomain; + const string ip = "192.0.2.10"; + + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = order, Status = "pending" }); + + mock.Setup(c => c.TrackOrderAsync(order, It.IsAny())) + .ReturnsAsync(DcvMixedStatusTrackResponse(validatedDomain: cn, pendingDomain: ip)); + + // The IP clears the FQDN regex and reaches GetDcv, per the sandbox-measured shape. + mock.Setup(c => c.GetDcvAsync(order, ip, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse(MockCertificateData.DcvToken)); + + var validator = new FakeDomainValidator(); + // Resolves for the CN (a real, working DNS provider) but not for the IP literal — the + // scenario that must prove "a provider IS deployed" rather than "nothing is deployed". + var plugin = BuildPlugin( + mock.Object, + new FakeDomainValidatorFactory(validator, resolvableDomain: cn), + DcvConfig()); + + Func act = () => Enroll(plugin); + + await act.Should().NotThrowAsync( + "an unresolvable non-DNS SAN must defer the order to the next sync cycle, not fail " + + "the enrollment — the CN having cached DCV proves a provider is deployed and working, " + + "so this is not the 'nothing is deployed' misconfiguration case"); + + validator.StagedRecords.Should().BeEmpty( + "the only pending domain is unresolvable, so nothing should have been staged"); + } + + /// + /// A non-FQDN pending domain must be skipped, not thrown on. + /// + /// Regression: non-DNS SANs are now submitted to CERTInext, which registers them verbatim + /// as order domains, so an email/URI SAN turns up as a domainVerification key that fails the + /// FQDN check. That check used to throw for the whole order — escaping Enroll (which has no + /// catch) after the order was already placed, so the enrollment failed with an orphaned + /// order and no TXT record was staged for the *valid* domains beside it. Every sync retry + /// re-threw and TryRunDcvDuringSyncAsync swallowed it, so the order could never progress. + /// + [Fact] + public async Task Dcv_NonFqdnPendingDomain_IsSkipped_AndValidDomainStillStaged() + { + const string order = MockCertificateData.DcvOrderId; + const string good = MockCertificateData.DcvDomain; + const string bad = "admin@example.com"; // what an rfc822 SAN comes back as + + var mock = NewMock(); + + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = order, Status = "pending_dcv" }); + + mock.SetupSequence(c => c.TrackOrderAsync(order, It.IsAny())) + .ReturnsAsync(DcvPendingTrackResponseMultiDomain(order, good, bad)) + .ReturnsAsync(MockCertificateData.DcvVerifiedTrackResponse(order, good)); + + // Only the valid domain should ever reach GetDcv/VerifyDcv. MockBehavior.Strict means + // an unexpected call for `bad` fails the test on its own. + mock.Setup(c => c.GetDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse(MockCertificateData.DcvToken)); + mock.Setup(c => c.VerifyDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.GetCertificateAsync(order, It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(order)); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), + DcvConfig(dcvWaitForIssuanceSeconds: 10)); + + // Must not throw — that is the regression. + var result = await Enroll(plugin); + + string expectedHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, good); + validator.StagedRecords.Should().ContainSingle( + "the valid DNS domain must still be staged even though a co-tenant domain is unusable") + .Which.Should().Be((expectedHostname, MockCertificateData.DcvToken)); + + mock.Verify(c => c.GetDcvAsync(order, bad, It.IsAny(), It.IsAny()), + Times.Never, "a non-FQDN domain must never be sent to GetDcv"); + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + } + + /// + /// Regression: the FQDN validation regex used ^...$ , and in .NET's default (non-Multiline) + /// mode $ matches immediately before a single trailing '\n', not only at the true end of the + /// string. A domain value ending in '\n' therefore passed as "valid" and reached several log + /// sinks unsanitized further down this same method — a CWE-117 log-injection route into the + /// DCV audit trail, reachable via any order visible through Synchronize/GetSingleRecord (not + /// just ones this plugin's own Enroll call placed, since TrackOrder's domainVerification keys + /// for an externally-created order are never trimmed by this plugin). The regex now anchors + /// with \A/\z, which are absolute string-start/end regardless of trailing newlines. + /// + [Fact] + public async Task Dcv_DomainWithTrailingNewline_IsRejectedAsInvalid_AndValidDomainStillStaged() + { + const string order = MockCertificateData.DcvOrderId; + const string good = MockCertificateData.DcvDomain; + const string bad = "evil.example.com\n"; + + var mock = NewMock(); + + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = order, Status = "pending_dcv" }); + + mock.SetupSequence(c => c.TrackOrderAsync(order, It.IsAny())) + .ReturnsAsync(DcvPendingTrackResponseMultiDomain(order, good, bad)) + .ReturnsAsync(MockCertificateData.DcvVerifiedTrackResponse(order, good)); + + // MockBehavior.Strict: an unexpected GetDcv call for `bad` fails the test on its own — + // if the regex fix regressed, this domain would reach GetDcv instead of being rejected + // by the FQDN check before the staging loop even starts. + mock.Setup(c => c.GetDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse(MockCertificateData.DcvToken)); + mock.Setup(c => c.VerifyDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.GetCertificateAsync(order, It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(order)); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), + DcvConfig(dcvWaitForIssuanceSeconds: 10)); + + var result = await Enroll(plugin); + + string expectedHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, good); + validator.StagedRecords.Should().ContainSingle( + "the valid domain must still be staged even though a co-tenant domain carries a " + + "trailing newline") + .Which.Should().Be((expectedHostname, MockCertificateData.DcvToken)); + + mock.Verify(c => c.GetDcvAsync(order, bad, It.IsAny(), It.IsAny()), + Times.Never, "a domain with a trailing newline must never be sent to GetDcv"); + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + } + + /// + /// Regression: the generic per-domain catch blocks around GetDcvAsync and StageValidation + /// used to catch OperationCanceledException along with genuine GetDcv/DNS-provider failures, + /// logging and skipping the domain as an ordinary per-domain failure. A cancellation (the + /// shared DcvTimeoutMinutes-bound token expiring mid-loop) is not that — it must propagate to + /// the outer catch instead, which is the only place that logs it correctly and is the + /// intended timeout-handling path documented at the top of this method's DCV timeout setup. + /// + [Fact] + public async Task Dcv_CancellationDuringGetDcv_PropagatesRatherThanBeingSkippedAsPerDomainFailure() + { + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending_dcv" }); + + mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvPendingTrackResponse()); + + mock.Setup(c => c.GetDcvAsync(MockCertificateData.DcvOrderId, MockCertificateData.DcvDomain, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ThrowsAsync(new OperationCanceledException("DCV timeout budget exceeded")); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + Func act = () => Enroll(plugin); + + // Must propagate as a cancellation, not be swallowed and reported as "GetDcv failed" in + // the skipped-domains summary while Enroll completes normally. + await act.Should().ThrowAsync(); + } + + /// + /// A pending domain that resolves no DNS provider (an IP-literal SAN passes the FQDN regex + /// but no zone can match it) must likewise be skipped rather than failing the whole order. + /// + [Fact] + public async Task Dcv_DomainWithNoResolvableValidator_IsSkipped_AndValidDomainStillStaged() + { + const string order = MockCertificateData.DcvOrderId; + const string good = MockCertificateData.DcvDomain; + const string ip = "192.0.2.10"; // what an iPAddress SAN comes back as + + var mock = NewMock(); + + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = order, Status = "pending_dcv" }); + + mock.SetupSequence(c => c.TrackOrderAsync(order, It.IsAny())) + .ReturnsAsync(DcvPendingTrackResponseMultiDomain(order, good, ip)) + .ReturnsAsync(MockCertificateData.DcvVerifiedTrackResponse(order, good)); + + // The IP literal clears the FQDN filter, so GetDcv IS called for it; the dead end is + // that no validator resolves. Stub it so reaching that point is legitimate. + mock.Setup(c => c.GetDcvAsync(order, It.IsAny(), Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse(MockCertificateData.DcvToken)); + mock.Setup(c => c.VerifyDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.GetCertificateAsync(order, It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(order)); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin( + mock.Object, + new FakeDomainValidatorFactory(validator, resolvableDomain: good), + DcvConfig(dcvWaitForIssuanceSeconds: 10)); + + var result = await Enroll(plugin); + + string expectedHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, good); + validator.StagedRecords.Should().ContainSingle( + "only the domain with a resolvable provider should be staged, and it must still be staged") + .Which.Should().Be((expectedHostname, MockCertificateData.DcvToken)); + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + } + + /// + /// Regression: the compensating cleanup call after an early exit from staging (chiefly the + /// shared DcvTimeoutMinutes-bound token firing mid-loop, which is what this scenario + /// simulates via a domain whose GetDcv call raises OperationCanceledException) must not reuse + /// the same token the operation was cancelled by. A cooperative IDomainValidator that forwards + /// its token into its own HTTP calls (the reference CloudflareDomainValidator in this repo + /// does exactly that) would otherwise throw immediately on an already-cancelled token and + /// never even attempt the delete, silently leaving the TXT record published. + /// + /// CancellationToken.None would fix that but removes the cleanup call's timeout bound + /// entirely — a second, adversarially-found regression on top of the first — so the correct + /// fix is a fresh token with its OWN short timeout: not cancelled going in, but still bounded. + /// + [Fact] + public async Task Dcv_CleanupAfterCancellation_UsesAFreshBoundedToken_NotTheAmbientToken() + { + const string order = MockCertificateData.DcvOrderId; + const string good = "a.example.com"; + const string bad = "b.example.com"; + + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = order, Status = "pending_dcv" }); + + mock.Setup(c => c.TrackOrderAsync(order, It.IsAny())) + .ReturnsAsync(DcvPendingTrackResponseMultiDomain(order, good, bad)); + + mock.Setup(c => c.GetDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse("token-a")); + // Domain 'good' is processed first (Dictionary enumeration order matches insertion order + // in practice for the small dictionaries this test builds); 'bad' then throws, driving the + // outer catch's cleanup of the already-staged 'good' entry. + mock.Setup(c => c.GetDcvAsync(order, bad, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ThrowsAsync(new OperationCanceledException("DCV timeout budget exceeded")); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + Func act = () => Enroll(plugin); + await act.Should().ThrowAsync(); + + validator.StagedRecords.Should().ContainSingle( + "'good' must have staged before 'bad' threw, for this test to exercise cleanup at all"); + var cleanupToken = validator.CleanupTokens.Should().ContainSingle( + "the staged entry must go through the cancellation cleanup path exactly once").Subject; + + cleanupToken.IsCancellationRequested.Should().BeFalse( + "cleanup is a best-effort compensating action and must run with its own token, " + + "not the already-cancelled ambient one"); + cleanupToken.CanBeCanceled.Should().BeTrue( + "the cleanup call must still be bounded by its own timeout, not unbounded " + + "(CancellationToken.None) — a hanging DNS-provider call must not block forever"); + } + + /// + /// Regression: the routine, always-runs finally-block cleanup used to iterate staged domains + /// sequentially. Each cleanup call already has its own independent + /// CleanupValidationTimeoutSeconds bound, but running them one after another meant that + /// bound was per-call, not in aggregate — a UCC order with N staged domains could hold the + /// calling request open for up to N x the per-call ceiling if the DNS provider was merely + /// slow (not even hung) on every delete, which can exceed DcvTimeoutMinutes itself for a + /// realistic multi-SAN count. + /// + /// Proven directly via — the number + /// of CleanupValidation calls the validator observed in flight at once — rather than total + /// wall-clock time. 0023: a prior version of this test asserted elapsed time < 4000ms, which + /// failed deterministically (~4801ms) because the surrounding DCV flow carries ~4s of fixed + /// overhead unrelated to cleanup concurrency (DcvConfig's 1s propagation delay plus + /// WaitForDcvVerificationAsync's separate, hardcoded 3s poll interval, + /// Constants.Dcv.SyncPropagationDelaySeconds — not the 1s the old comment assumed), on top of + /// which the (already-concurrent) ~800ms cleanup pushed the total past the threshold. That was + /// a test-design defect present since the test was introduced, not a cleanup regression — the + /// finally block here already runs cleanup via Task.WhenAll. Measuring peak concurrency proves + /// the same thing the wall-clock check intended, without being coupled to unrelated fixed + /// delays elsewhere in the flow. + /// + [Fact] + public async Task Dcv_CleanupOfMultipleDomains_RunsConcurrently_NotSequentially() + { + const string order = MockCertificateData.DcvOrderId; + string[] domains = { "a.example.com", "b.example.com", "c.example.com" }; + var cleanupDelay = TimeSpan.FromMilliseconds(800); + + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = order, Status = "pending_dcv" }); + + var verifiedDetail = DcvDetail(Constants.Dcv.StatusValidated); + var verifiedRaw = new Dictionary(); + foreach (string d in domains) verifiedRaw[d] = verifiedDetail; + + mock.SetupSequence(c => c.TrackOrderAsync(order, It.IsAny())) + .ReturnsAsync(DcvPendingTrackResponseMultiDomain(order, domains)) + .ReturnsAsync(new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "1", + CertificateStatusId = "1", + DomainVerification = new TrackOrderDomainVerification + { + Status = Constants.Dcv.StatusValidated, + RawDomainEntries = verifiedRaw + } + } + }); + + foreach (string d in domains) + { + mock.Setup(c => c.GetDcvAsync(order, d, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse($"token-{d}")); + mock.Setup(c => c.VerifyDcvAsync(order, d, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .Returns(Task.CompletedTask); + } + mock.Setup(c => c.GetCertificateAsync(order, It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(order)); + + var validator = new FakeDomainValidator { CleanupDelay = cleanupDelay }; + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), + DcvConfig(dcvWaitForIssuanceSeconds: 10)); + + await Enroll(plugin); + + validator.CleanedUpKeys.Should().HaveCount(3, "all three staged domains must be cleaned up"); + + // Direct proof of concurrency: all three CleanupValidation calls must have been in + // flight at the same instant. If cleanup ran sequentially, PeakConcurrentCleanups would + // be 1 regardless of how long the whole call took — this assertion doesn't depend on any + // wall-clock budget or on the fixed overhead elsewhere in the DCV flow (see 0023). + validator.PeakConcurrentCleanups.Should().Be(3, + "cleanup for independent domains must run concurrently, not sequentially — " + + "all three CleanupValidation calls should have been in flight at once"); + } + + /// + /// Regression: a StageValidation failure on one domain of a multi-domain order must not + /// leave the TXT records already published for the earlier domains orphaned. Before the + /// fix, the staging loop's throw sites were outside the try/finally that owns cleanup, so + /// this was reachable only by accident (pre-fix, a UCC order's SANs never reached CERTInext + /// at all, so an order rarely had more than one pending domain to stage). Submitting every + /// requested SAN makes multi-domain staging the normal case, so this must hold now. + /// + [Fact] + public async Task Dcv_StageFailureOnSecondDomain_DoesNotAbortTheGoodDomain() + { + const string order = MockCertificateData.DcvOrderId; + const string good = "a.example.com"; + const string bad = "b.example.com"; + + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = order, Status = "pending_dcv" }); + + // First TrackOrder call (inside PerformDcvIfNeededAsync) sees both domains pending; + // the second (WaitForDcvVerificationAsync's poll after staging/VerifyDcv) sees the one + // domain that actually got staged — 'good' — as verified. + mock.SetupSequence(c => c.TrackOrderAsync(order, It.IsAny())) + .ReturnsAsync(DcvPendingTrackResponseMultiDomain(order, good, bad)) + .ReturnsAsync(MockCertificateData.DcvVerifiedTrackResponse(order, good)); + + mock.Setup(c => c.GetDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse("token-a")); + mock.Setup(c => c.GetDcvAsync(order, bad, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse("token-b")); + + mock.Setup(c => c.VerifyDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.GetCertificateAsync(order, It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(order)); + + var validator = new FakeDomainValidator + { + ShouldFail = key => key.Contains(bad, StringComparison.OrdinalIgnoreCase) + }; + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), + DcvConfig(dcvWaitForIssuanceSeconds: 10)); + + Func act = () => Enroll(plugin); + + // Regression: a StageValidation failure on one domain of a multi-domain order must not + // abort the whole order any more — it did before this fix, which both failed the + // enrollment with an orphaned CERTInext order AND (before an earlier round's fix) + // orphaned the 'good' domain's already-published TXT record. Now the bad domain is + // skipped (logged loudly) and the good domain proceeds through the normal DCV lifecycle. + await act.Should().NotThrowAsync(); + + string goodHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, good); + string badHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, bad); + + validator.StagedRecords.Should().ContainSingle( + "only the domain that did not fail to stage should ever have been staged") + .Which.key.Should().Be(goodHostname); + validator.CleanedUpKeys.Should().Contain(goodHostname, + "the good domain completes its normal verify-then-cleanup lifecycle"); + validator.CleanedUpKeys.Should().NotContain(badHostname, + "the bad domain was never staged, so there is nothing to clean up for it"); + } + + // --------------------------------------------------------------------------- + // Wildcard domains — TXT hostname must be derived from the BASE domain + // --------------------------------------------------------------------------- + // + // Live evidence (sandbox, 2026-10-01): a wildcard DV order's TXT host was staged as + // "_emsign-validation.*.dcv-fresh-...scrup.org" — a literal '*' DNS label, which is + // not queryable and left the order stuck pending. CERTInext's own GetDcv/VerifyDcv/ + // TrackOrder calls must still use the original "*."-prefixed domain string (that is what + // Track Order reports back per-domain); only the DNS-side hostname/zone resolution uses + // the base domain. + + /// Builds a verified-status multi-domain TrackOrder response, mirroring + /// but with every listed domain already + /// validated — used to drive WaitForDcvVerificationAsync's post-stage poll. + private static TrackOrderResponse DcvVerifiedTrackResponseMultiDomain( + string orderNumber, params string[] domains) + { + var detail = DcvDetail(Constants.Dcv.StatusValidated); + var raw = new Dictionary(); + foreach (string d in domains) + raw[d] = detail; + + return new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "2", + CertificateStatusId = "24", + DomainVerification = new TrackOrderDomainVerification + { + Status = Constants.Dcv.StatusValidated, + RawDomainEntries = raw + } + } + }; + } + + [Fact] + public async Task Dcv_WildcardDomain_StagesBaseDomainHostname_ButCallsCaWithWildcardDomain() + { + const string order = MockCertificateData.DcvOrderId; + const string baseName = MockCertificateData.DcvDomain; + string wildcard = "*." + baseName; + + var (mock, validator) = HappyPathMocks(orderNumber: order, domain: wildcard); + + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), + DcvConfig(dcvWaitForIssuanceSeconds: 10)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + + // The TXT hostname must be built from the base domain, not the literal "*.example.com" + // — a "*" DNS label is not queryable. + string expectedHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, baseName); + validator.StagedRecords.Should().ContainSingle() + .Which.key.Should().Be(expectedHostname); + validator.StagedRecords.Should().OnlyContain(r => !r.key.Contains('*'), + "a literal '*' DNS label can never be queried by the CA"); + + validator.CleanedUpKeys.Should().ContainSingle().Which.Should().Be(expectedHostname); + + // CERTInext's own API must still see the original wildcard domain string — that is + // what Track Order reports back per-domain. + mock.Verify(c => c.GetDcvAsync(order, wildcard, Constants.Dcv.MethodDnsTxt, It.IsAny()), + Times.Once); + mock.Verify(c => c.VerifyDcvAsync(order, wildcard, Constants.Dcv.MethodDnsTxt, It.IsAny()), + Times.Once); + } + + [Fact] + public async Task Dcv_NonWildcardDomain_HostnameDerivationUnchanged() + { + // Baseline/regression guard: an ordinary (non-wildcard) domain must stage a TXT + // hostname built from the domain exactly as before — StripWildcardPrefix is a no-op + // when there is no leading "*.". + var (mock, validator) = HappyPathMocks(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), + DcvConfig(dcvWaitForIssuanceSeconds: 10)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + string expectedHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, MockCertificateData.DcvDomain); + validator.StagedRecords.Should().ContainSingle() + .Which.Should().Be((expectedHostname, MockCertificateData.DcvToken)); + } + + [Fact] + public async Task Dcv_MultiDomain_ApexAndWildcardShareHostname_StagesOnceAndCleansUpOnce_ButVerifiesBothWithCa() + { + const string order = MockCertificateData.DcvOrderId; + const string apex = MockCertificateData.DcvDomain; + string wildcard = "*." + apex; + + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = order, Status = "pending_dcv" }); + + mock.SetupSequence(c => c.TrackOrderAsync(order, It.IsAny())) + .ReturnsAsync(DcvPendingTrackResponseMultiDomain(order, apex, wildcard)) + .ReturnsAsync(DcvVerifiedTrackResponseMultiDomain(order, apex, wildcard)); + + mock.Setup(c => c.GetDcvAsync(order, apex, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse("token-shared")); + mock.Setup(c => c.GetDcvAsync(order, wildcard, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse("token-shared")); + + mock.Setup(c => c.VerifyDcvAsync(order, apex, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.VerifyDcvAsync(order, wildcard, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .Returns(Task.CompletedTask); + + mock.Setup(c => c.GetCertificateAsync(order, It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(order)); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), + DcvConfig(dcvWaitForIssuanceSeconds: 10)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + + // Both domains collapse to the same base-domain TXT hostname — exactly ONE record + // must be staged (and cleaned up), not two, even though the order lists both the + // apex and its wildcard as separate domain entries. + string expectedHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, apex); + validator.StagedRecords.Should().ContainSingle( + "the apex and wildcard domains share one base-domain TXT hostname") + .Which.key.Should().Be(expectedHostname); + validator.CleanedUpKeys.Should().ContainSingle( + "the shared hostname must be cleaned up exactly once, not once per domain that used it") + .Which.Should().Be(expectedHostname); + + // CERTInext tracks DCV per domain entry, so both the apex and the wildcard still need + // their own CA-side GetDcv/VerifyDcv call even though they share one TXT record. + mock.Verify(c => c.GetDcvAsync(order, apex, Constants.Dcv.MethodDnsTxt, It.IsAny()), Times.Once); + mock.Verify(c => c.GetDcvAsync(order, wildcard, Constants.Dcv.MethodDnsTxt, It.IsAny()), Times.Once); + mock.Verify(c => c.VerifyDcvAsync(order, apex, Constants.Dcv.MethodDnsTxt, It.IsAny()), Times.Once); + mock.Verify(c => c.VerifyDcvAsync(order, wildcard, Constants.Dcv.MethodDnsTxt, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Dcv_MultiDomain_ApexAndWildcardShareHostnameButDifferentTokens_StagesBothAndCleansUpBoth() + { + const string order = MockCertificateData.DcvOrderId; + const string apex = MockCertificateData.DcvDomain; + string wildcard = "*." + apex; + + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = order, Status = "pending_dcv" }); + + mock.SetupSequence(c => c.TrackOrderAsync(order, It.IsAny())) + .ReturnsAsync(DcvPendingTrackResponseMultiDomain(order, apex, wildcard)) + .ReturnsAsync(DcvVerifiedTrackResponseMultiDomain(order, apex, wildcard)); + + mock.Setup(c => c.GetDcvAsync(order, apex, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse("token-apex")); + mock.Setup(c => c.GetDcvAsync(order, wildcard, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse("token-wildcard")); + + mock.Setup(c => c.VerifyDcvAsync(order, apex, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.VerifyDcvAsync(order, wildcard, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .Returns(Task.CompletedTask); + + mock.Setup(c => c.GetCertificateAsync(order, It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(order)); + + var validator = new FakeDomainValidator(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), + DcvConfig(dcvWaitForIssuanceSeconds: 10)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + + // Same base-domain hostname but two different CA tokens: a single record cannot satisfy + // both, so BOTH values must be staged at that hostname and both cleaned up. + string expectedHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, apex); + validator.StagedRecords.Should().HaveCount(2); + validator.StagedRecords.Select(r => r.key).Should().OnlyContain(k => k == expectedHostname); + validator.StagedRecords.Select(r => r.value).Should().BeEquivalentTo(new[] { "token-apex", "token-wildcard" }); + validator.CleanedUpKeys.Should().HaveCount(2); + validator.CleanedUpKeys.Should().OnlyContain(k => k == expectedHostname); + + mock.Verify(c => c.VerifyDcvAsync(order, apex, Constants.Dcv.MethodDnsTxt, It.IsAny()), Times.Once); + mock.Verify(c => c.VerifyDcvAsync(order, wildcard, Constants.Dcv.MethodDnsTxt, It.IsAny()), Times.Once); + } + } +} diff --git a/CERTInext.Tests/CERTInextCAPluginPublicSurfaceTests.cs b/CERTInext.Tests/CERTInextCAPluginPublicSurfaceTests.cs new file mode 100644 index 0000000..2fd1ad1 --- /dev/null +++ b/CERTInext.Tests/CERTInextCAPluginPublicSurfaceTests.cs @@ -0,0 +1,196 @@ +// Copyright 2024 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using System.Linq; +using System.Reflection; +using FluentAssertions; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Pins the gateway-DI-visible public surface of so that + /// regressions which would crash plugin load on older gateway hosts cannot land silently. + /// + /// Background: gateway image 25.4.0 ships + /// Keyfactor.AnyGateway.IAnyCAPlugin v3.2.0.0, which does not define + /// Keyfactor.AnyGateway.Extensions.IDomainValidatorFactory. If any public + /// constructor declares that type as a parameter, the gateway's DI container will fail + /// at RuntimeConstructorInfo.GetParameters() with TypeLoadException 0x80131509 + /// before plugin load can complete (see GitHub issue #7). + /// + /// These tests assert via reflection that the only types reachable from the plugin's + /// public constructor parameter lists are ones present on v3.2 hosts (BCL + + /// pre-3.3 Keyfactor types). + /// + public class CERTInextCAPluginPublicSurfaceTests + { + private static readonly string[] V3Point3OnlyTypeNames = + { + "Keyfactor.AnyGateway.Extensions.IDomainValidatorFactory", + "Keyfactor.AnyGateway.Extensions.IDomainValidator", + "Keyfactor.AnyGateway.Extensions.IDomainValidatorConfigProvider" + }; + + [Fact] + public void NoPublicConstructor_ReferencesV3Point3OnlyTypes() + { + var publicCtors = typeof(CERTInextCAPlugin) + .GetConstructors(BindingFlags.Public | BindingFlags.Instance); + + publicCtors.Should().NotBeEmpty("plugin must have at least one public constructor for the gateway to instantiate"); + + foreach (var ctor in publicCtors) + { + foreach (var param in ctor.GetParameters()) + { + string paramTypeName = param.ParameterType.FullName ?? param.ParameterType.Name; + V3Point3OnlyTypeNames.Should().NotContain(paramTypeName, + $"public constructor parameter '{param.Name}' (type {paramTypeName}) on " + + $"{ctor} would trip TypeLoadException on a gateway whose IAnyCAPlugin " + + $"assembly does not contain that type. Move the constructor to internal " + + $"or remove the parameter — see issue #7."); + } + } + } + + [Fact] + public void NoInstanceField_DeclaredTypeReferencesV3Point3OnlyTypes() + { + // The .NET JIT eagerly resolves the declared types of all instance fields + // when it first compiles ANY method on a class. If an instance field is + // declared with a missing-type-on-this-host type, TypeLoadException fires + // the very first time Initialize / Enroll / Synchronize / anything is + // invoked — independent of whether the field is read on that code path. + // + // Issue #7's original fix patched constructor-signature reflection (the + // DI-container surface). The follow-up comment showed a separate failure + // path where Enroll trips on field-type loading. This test guards against + // a regression of either: field types must use only types the v3.2 host + // ships, with `object` as the typical neutral-typed storage and an `as` + // cast inside method bodies (JIT-lazy) for actual use. + // DeclaredOnly added for symmetry with the nested-type / method tests below + // and to make the "we only check this type, not its base classes" intent + // explicit in the reflection-query shape. + var fields = typeof(CERTInextCAPlugin) + .GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly); + + foreach (var field in fields) + { + string fieldTypeName = field.FieldType.FullName ?? field.FieldType.Name; + V3Point3OnlyTypeNames.Should().NotContain(fieldTypeName, + $"instance field '{field.Name}' (declared type {fieldTypeName}) on " + + $"{field.DeclaringType?.FullName} would trigger TypeLoadException when the JIT " + + $"first compiles any method on the class on a v3.2 gateway host. " + + $"Re-type the field as `object` and cast to the v3.3 type inside method " + + $"bodies — see issue #7 follow-up."); + } + } + + [Fact] + public void NoNestedType_ImplementsV3Point3OnlyInterface() + { + // Nested types declared with a base/interface reference to a v3.3-only + // interface put that interface in the containing class's nested-type + // metadata. CLR class-load behaviour around nested-type interface + // resolution is fragile across .NET versions, so we forbid it outright + // as a belt-and-braces measure. + var nestedTypes = typeof(CERTInextCAPlugin) + .GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic); + + foreach (var nested in nestedTypes) + { + foreach (var iface in nested.GetInterfaces()) + { + string ifaceName = iface.FullName ?? iface.Name; + V3Point3OnlyTypeNames.Should().NotContain(ifaceName, + $"nested type '{nested.FullName}' implements v3.3-only interface " + + $"'{ifaceName}', which would leak into the containing class's " + + $"reflection surface on a v3.2 host. Delete the nested type or " + + $"refactor it to not declare the v3.3 interface in its base list."); + } + } + } + + [Fact] + public void NoPublicMethod_SignatureReferencesV3Point3OnlyTypes() + { + // Reflection-driven hosts (anything calling Type.GetMethods()) eagerly + // resolve return-type and parameter-type metadata on each method. Public + // method signatures must therefore avoid v3.3-only types the same way + // public constructors do. SetDomainValidatorFactory's `object` parameter + // is the safe pattern. + var publicInstanceMethods = typeof(CERTInextCAPlugin) + .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); + + foreach (var method in publicInstanceMethods) + { + // Property accessors get caught here too — that's intentional. + string returnTypeName = method.ReturnType.FullName ?? method.ReturnType.Name; + V3Point3OnlyTypeNames.Should().NotContain(returnTypeName, + $"public method '{method.Name}' returns v3.3-only type '{returnTypeName}'. " + + $"Change the return type to `object` and have callers cast at the use site."); + + foreach (var param in method.GetParameters()) + { + string paramTypeName = param.ParameterType.FullName ?? param.ParameterType.Name; + V3Point3OnlyTypeNames.Should().NotContain(paramTypeName, + $"public method '{method.Name}' parameter '{param.Name}' is " + + $"v3.3-only type '{paramTypeName}'. Change the parameter to `object` " + + $"and cast inside the method body — see SetDomainValidatorFactory."); + } + } + } + + [Fact] + public void ParameterlessConstructor_IsPublic() + { + var parameterlessCtor = typeof(CERTInextCAPlugin) + .GetConstructor(BindingFlags.Public | BindingFlags.Instance, types: System.Type.EmptyTypes); + + parameterlessCtor.Should().NotBeNull( + "older gateway hosts that don't pass any DI parameters need a public no-arg " + + "constructor to fall back to. See issue #7."); + } + + [Fact] + public void SetDomainValidatorFactory_AcceptsObject_NotIDomainValidatorFactory() + { + // The public setter must declare `object` (not the v3.3-only interface) so the + // method's signature does not pull the missing type into the v3.2 host's + // reflection surface. + var method = typeof(CERTInextCAPlugin) + .GetMethod("SetDomainValidatorFactory", BindingFlags.Public | BindingFlags.Instance); + + method.Should().NotBeNull("plugin must expose a public hook for v3.3+ hosts to inject the factory"); + var parameters = method!.GetParameters(); + parameters.Should().ContainSingle(); + parameters[0].ParameterType.Should().Be(typeof(object), + "the parameter must be `object` so SetDomainValidatorFactory's signature is " + + "safe to reflect on a v3.2 host. The body casts to IDomainValidatorFactory " + + "lazily, which only resolves the type if the method is actually called."); + } + + [Fact] + public void SetDomainValidatorFactory_NullArgument_LeavesDcvDisabled() + { + var plugin = new CERTInextCAPlugin(); + plugin.SetDomainValidatorFactory(null); + // No exception, no state change — the plugin behaves as if no factory were available. + } + + [Fact] + public void SetDomainValidatorFactory_NonFactoryArgument_IsIgnored() + { + // Pass something that doesn't implement IDomainValidatorFactory. The `as` cast + // in the setter yields null and the field stays null — no throw. + var plugin = new CERTInextCAPlugin(); + plugin.SetDomainValidatorFactory("not a factory"); + // No assertion needed beyond not throwing. + } + } +} diff --git a/CERTInext.Tests/CERTInextCAPluginRevokeV2AuditLoggingTests.cs b/CERTInext.Tests/CERTInextCAPluginRevokeV2AuditLoggingTests.cs new file mode 100644 index 0000000..141345f --- /dev/null +++ b/CERTInext.Tests/CERTInextCAPluginRevokeV2AuditLoggingTests.cs @@ -0,0 +1,258 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.Logging; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Review finding (A): V2 revoke denials must leave an audit record (CARequestID, product + /// family, HTTP status, EMS code) even though the denial is surfaced via an exception rather + /// than a normal return. Pins the plugin-level ( → + /// internal RevokeV2Async) log lines for: 404 (not found/not revokable), 422 ("not in a + /// revocable state" pre-flight and the general 422 denial), and both outcomes of the + /// unspecified-reason → cessation-of-operation retry. + /// + /// Uses the same -swap capture seam as + /// CERTInextCAPluginAuditLoggingTests (the plugin's _logger is a per-instance + /// field resolved at construction time, unlike CERTInextClient.Logger, which is + /// static readonly and not swappable after first use — see that class's remarks for + /// why client-level V2 log content isn't independently assertable in this harness). + /// + [Collection("LogHandlerFactory-NoParallel")] + public class CERTInextCAPluginRevokeV2AuditLoggingTests + { + private sealed class CapturingLoggerProvider : ILoggerProvider + { + public ConcurrentQueue Messages { get; } = new(); + public ILogger CreateLogger(string categoryName) => new CapturingLogger(Messages); + public void Dispose() { } + + private sealed class CapturingLogger : ILogger + { + private readonly ConcurrentQueue _messages; + public CapturingLogger(ConcurrentQueue messages) => _messages = messages; + public IDisposable BeginScope(TState state) => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) + => _messages.Enqueue(formatter(state, exception)); + } + } + + private static CERTInextConfig V2Config() => new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + AccountNumber = "12345", + AuthMode = "AccessKey", + ApiKey = "v1-key", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + PickupRetries = 0 + }; + + /// + /// Runs plugin.Revoke(orderId, hexSerial, reason) against a capturing logger + /// factory and returns every rendered log line plus whatever the call threw (the revoke + /// is always expected to fail or succeed deterministically per test). + /// + private static async Task<(ConcurrentQueue Messages, Exception Thrown)> CaptureRevokeLogMessagesAsync( + Mock mock, string orderId, uint reason) + { + var provider = new CapturingLoggerProvider(); + var factory = LoggerFactory.Create(b => b.AddProvider(provider).SetMinimumLevel(LogLevel.Trace)); + Exception thrown = null; + try + { + LogHandler.Factory = factory; + var plugin = new CERTInextCAPlugin(mock.Object, V2Config()); + try + { + await plugin.Revoke(orderId, "AABBCC", reason); + } + catch (Exception ex) + { + thrown = ex; + } + } + finally + { + LogHandler.Factory = Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance; + factory.Dispose(); + } + + return (provider.Messages, thrown); + } + + private static string FindLine(ConcurrentQueue messages, string marker, params string[] mustContain) + { + foreach (var m in messages) + { + if (!m.Contains(marker)) continue; + bool allMatch = true; + foreach (var s in mustContain) + { + if (!m.Contains(s)) { allMatch = false; break; } + } + if (allMatch) return m; + } + return null; + } + + // --------------------------------------------------------------------------- + // 404 — "not found or not in a revokable state" + // --------------------------------------------------------------------------- + + [Fact] + public async Task RevokeV2_404Denial_LogsWarningWithAuditFields() + { + string orderId = "audit-404-" + Guid.NewGuid().ToString("N"); + var mock = new Mock(MockBehavior.Loose); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync(orderId, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse { OrderId = orderId, Status = "issued" })); + mock.Setup(c => c.RevokeOrderV2Async( + Constants.ApiV2.FamilySsl, orderId, It.IsAny(), It.IsAny())) + .ThrowsAsync(new System.Collections.Generic.KeyNotFoundException( + $"V2 order '{orderId}' in family '{Constants.ApiV2.FamilySsl}' not found or not in a revokable state. EMS-913")); + + var (messages, thrown) = await CaptureRevokeLogMessagesAsync(mock, orderId, 4u); + + thrown.Should().BeOfType(); + string line = FindLine(messages, orderId, "V2 revocation denied", "HttpStatus=404"); + line.Should().NotBeNull("a 404 revoke denial must be audited with an explicit HTTP status"); + line.Should().Contain("EmsCode=EMS-913"); + line.Should().Contain(Constants.ApiV2.FamilySsl); + } + + // --------------------------------------------------------------------------- + // Not-GENERATED pre-flight rejection + // --------------------------------------------------------------------------- + + [Fact] + public async Task RevokeV2_NotGenerated_LogsErrorWithCurrentStatus() + { + string orderId = "audit-notgen-" + Guid.NewGuid().ToString("N"); + var mock = new Mock(MockBehavior.Loose); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync(orderId, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse { OrderId = orderId, Status = "pending-dcv" })); + + var (messages, thrown) = await CaptureRevokeLogMessagesAsync(mock, orderId, 4u); + + thrown.Should().NotBeNull(); + thrown.Message.Should().Contain("cannot be revoked"); + string line = FindLine(messages, orderId, "not in a revocable state", "Status=pending-dcv"); + line.Should().NotBeNull("a not-GENERATED revoke rejection must be audited with the current CA status"); + line.Should().Contain(Constants.ApiV2.FamilySsl); + + // RevokeOrderV2Async must never have been called for a certificate that was never issued. + mock.Verify(c => c.RevokeOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + // --------------------------------------------------------------------------- + // General 422 denial (not the retry-eligible "Invalid Revoke Reason ID" case) + // --------------------------------------------------------------------------- + + [Fact] + public async Task RevokeV2_Generic422Denial_LogsWarningWithAuditFields() + { + string orderId = "audit-422-" + Guid.NewGuid().ToString("N"); + var mock = new Mock(MockBehavior.Loose); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync(orderId, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse { OrderId = orderId, Status = "issued" })); + mock.Setup(c => c.RevokeOrderV2Async( + Constants.ApiV2.FamilySsl, orderId, It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("V2 revoke rejected. EMS-969 Revoke reason ID missing")); + + var (messages, thrown) = await CaptureRevokeLogMessagesAsync(mock, orderId, 4u); + + thrown.Should().BeOfType(); + thrown.Message.Should().Contain("EMS-969"); + string line = FindLine(messages, orderId, "V2 revocation denied", "HttpStatus=422"); + line.Should().NotBeNull("a non-retry-eligible 422 revoke denial must be audited"); + line.Should().Contain("EmsCode=EMS-969"); + } + + // --------------------------------------------------------------------------- + // Unspecified-reason retry: success and failure outcomes both log. + // --------------------------------------------------------------------------- + + [Fact] + public async Task RevokeV2_UnspecifiedReasonRetry_Success_LogsRetriedTrue() + { + string orderId = "audit-retry-ok-" + Guid.NewGuid().ToString("N"); + var mock = new Mock(MockBehavior.Loose); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync(orderId, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse { OrderId = orderId, Status = "issued" })); + mock.Setup(c => c.RevokeOrderV2Async( + Constants.ApiV2.FamilySsl, orderId, It.IsAny(), It.IsAny())) + .Returns((string family, string id, V2RevokeRequest req, CancellationToken ct) => + { + if (req.Reason == Constants.RevocationReasonV2.Unspecified) + throw new InvalidOperationException("V2 revoke rejected. Unprocessable Entity: Invalid Revoke Reason ID"); + return Task.CompletedTask; + }); + + var (messages, thrown) = await CaptureRevokeLogMessagesAsync(mock, orderId, 0u); + + thrown.Should().BeNull(); + string retryWarn = FindLine(messages, orderId, "retrying once with 'cessation-of-operation'", "HttpStatus=422"); + retryWarn.Should().NotBeNull("the first rejection must be audited before the retry is attempted"); + string completeLine = FindLine(messages, orderId, "V2 revocation complete", "RetriedFromReason=unspecified"); + completeLine.Should().NotBeNull("a successful retry must be reflected in the completion audit line"); + } + + [Fact] + public async Task RevokeV2_UnspecifiedReasonRetry_Failure_LogsErrorAndRethrows() + { + string orderId = "audit-retry-fail-" + Guid.NewGuid().ToString("N"); + var mock = new Mock(MockBehavior.Loose); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync(orderId, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse { OrderId = orderId, Status = "issued" })); + mock.Setup(c => c.RevokeOrderV2Async( + Constants.ApiV2.FamilySsl, orderId, It.IsAny(), It.IsAny())) + .Returns((string family, string id, V2RevokeRequest req, CancellationToken ct) => + { + if (req.Reason == Constants.RevocationReasonV2.Unspecified) + throw new InvalidOperationException("V2 revoke rejected. Unprocessable Entity: Invalid Revoke Reason ID"); + throw new InvalidOperationException("V2 revoke rejected. EMS-931 Order not in issued state"); + }); + + var (messages, thrown) = await CaptureRevokeLogMessagesAsync(mock, orderId, 0u); + + thrown.Should().BeOfType(); + thrown.Message.Should().Contain("EMS-931"); + string retryFailLine = FindLine(messages, orderId, "V2 revocation retry (cessation-of-operation) failed"); + retryFailLine.Should().NotBeNull("a failed retry attempt must leave its own audit record, not just surface via the exception"); + + // The retry failure must not be misreported as a successful completion. + FindLine(messages, orderId, "V2 revocation complete").Should().BeNull(); + } + } +} diff --git a/CERTInext.Tests/CERTInextCAPluginTests.cs b/CERTInext.Tests/CERTInextCAPluginTests.cs index 9b85a66..1053f6d 100644 --- a/CERTInext.Tests/CERTInextCAPluginTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginTests.cs @@ -17,6 +17,9 @@ using Keyfactor.Extensions.CAPlugin.CERTInext.Client; using Keyfactor.PKI.Enums.EJBCA; using Moq; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; using Xunit; namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests @@ -31,8 +34,20 @@ public class CERTInextCAPluginTests // Helpers // --------------------------------------------------------------------------- + // Pickup is disabled by default in the broad fixture (PickupRetries=0) — mirroring how + // DcvConfig defaults its wait budgets to 0 — so tests that don't care about the + // synchronous pickup don't pay its real Task.Delay-based poll. Tests that DO exercise + // pickup opt in via BuildPluginWithPickup. private static CERTInextCAPlugin BuildPlugin(ICERTInextClient client) => - new CERTInextCAPlugin(client); + new CERTInextCAPlugin(client, new CERTInextConfig { PickupRetries = 0 }); + + // Pickup-enabled fixture for the synchronous-pickup tests. PickupDelay is clamped to a + // 1s floor and the loop adds a fixed 5s initial delay, so these tests are intentionally + // a few seconds each. + private static CERTInextCAPlugin BuildPluginWithPickup( + ICERTInextClient client, int retries, int delaySeconds = 1) => + new CERTInextCAPlugin(client, + new CERTInextConfig { PickupRetries = retries, PickupDelayInSeconds = delaySeconds }); private static Mock NewMock() => new Mock(MockBehavior.Strict); @@ -164,6 +179,53 @@ await act.Should().ThrowAsync() .WithMessage("*valid absolute URI*"); } + [Fact] + public async Task ValidateCAConnectionInfo_Throws_WhenApiUrlIsHttp_NonLoopback() + { + var mock = NewMock(); + var plugin = BuildPlugin(mock.Object); + + var info = new Dictionary + { + ["ApiUrl"] = "http://ca.example.com", + ["AuthMode"] = "ApiKey", + ["ApiKey"] = "some-key" + }; + + Func act = () => plugin.ValidateCAConnectionInfo(info); + + await act.Should().ThrowAsync() + .WithMessage("*ApiUrl*https*"); + } + + [Theory] + [InlineData("http://localhost:8080")] + [InlineData("http://127.0.0.1:8080")] + [InlineData("http://[::1]:8080")] + public async Task ValidateCAConnectionInfo_AllowsHttp_ForLoopbackHosts(string apiUrl) + { + // Loopback http is allowed (e.g. a local WireMock/mock server in tests); this test + // only confirms the scheme check doesn't reject it — ApiKey mode fails on the next + // field it's missing (ApiKey), which still proves the ApiUrl check itself passed. + var mock = NewMock(); + var plugin = BuildPlugin(mock.Object); + + var info = new Dictionary + { + ["ApiUrl"] = apiUrl, + ["AuthMode"] = "ApiKey", + ["ApiKey"] = "some-key" + }; + + Func act = () => plugin.ValidateCAConnectionInfo(info); + + // No exception about ApiUrl specifically — any failure must come from the live + // connectivity check (NewMock's PingAsync is unstubbed under MockBehavior.Strict), + // not from the https scheme guard. + var ex = await act.Should().ThrowAsync(); + ex.Which.Message.Should().NotContain("ApiUrl"); + } + [Fact] public async Task ValidateCAConnectionInfo_Throws_WhenApiKeyMissingForApiKeyMode() { @@ -223,6 +285,92 @@ await act.Should().ThrowAsync() .WithMessage("*OAuthTokenUrl*required*"); } + // M2 compliance fix: OAuthTokenUrl receives the OAuth client secret on every token + // refresh (CERTInextClient.GetOrRefreshTokenAsync POSTs it there) — it must be held to + // the same https-or-loopback rule as ApiUrl, not just a non-empty check. + [Fact] + public async Task ValidateCAConnectionInfo_Throws_WhenOAuthTokenUrlIsHttp_NonLoopback() + { + var mock = NewMock(); + var plugin = BuildPlugin(mock.Object); + + var info = new Dictionary + { + ["ApiUrl"] = "https://ca.example.com", + ["AccountNumber"] = "12345", + ["AuthMode"] = "OAuth", + ["OAuthTokenUrl"] = "http://token.example.com", + ["OAuthClientId"] = "my-client", + ["OAuthClientSecret"] = "my-secret" + }; + + Func act = () => plugin.ValidateCAConnectionInfo(info); + + var ex = await act.Should().ThrowAsync(); + ex.Which.Message.Should().Contain("OAuthTokenUrl").And.Contain("https"); + mock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task ValidateCAConnectionInfo_Throws_WhenOAuthTokenUrlIsNotUri() + { + var mock = NewMock(); + var plugin = BuildPlugin(mock.Object); + + var info = new Dictionary + { + ["ApiUrl"] = "https://ca.example.com", + ["AccountNumber"] = "12345", + ["AuthMode"] = "OAuth", + ["OAuthTokenUrl"] = "not-a-url", + ["OAuthClientId"] = "my-client", + ["OAuthClientSecret"] = "my-secret" + }; + + Func act = () => plugin.ValidateCAConnectionInfo(info); + + var ex = await act.Should().ThrowAsync(); + ex.Which.Message.Should().Contain("OAuthTokenUrl").And.Contain("valid absolute URI"); + } + + [Fact] + public async Task ValidateCAConnectionInfo_AllowsHttp_ForLoopbackOAuthTokenUrl() + { + // Full round trip through a loopback WireMock server standing in for both the + // OAuth token endpoint (OAuthTokenUrl is used as-is, no path appended — see + // CERTInextClient.GetOrRefreshTokenAsync) and the V1 ValidateCredentials ping — + // proves http-loopback is accepted end to end, not just by the synchronous guard. + using var server = WireMockServer.Start(); + server + .Given(Request.Create().WithPath("/").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server + .Given(Request.Create().WithPath("/ValidateCredentials").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody("{\"meta\":{\"status\":\"1\"}}")); + + var plugin = BuildPlugin(NewMock().Object); + + var info = new Dictionary + { + ["ApiUrl"] = server.Urls[0] + "/", + ["AccountNumber"] = "12345", + ["AuthMode"] = "OAuth", + ["OAuthTokenUrl"] = server.Urls[0], + ["OAuthClientId"] = "my-client", + ["OAuthClientSecret"] = "my-secret" + }; + + Func act = () => plugin.ValidateCAConnectionInfo(info); + + await act.Should().NotThrowAsync(); + } + [Fact] public async Task ValidateCAConnectionInfo_Throws_WhenAuthModeIsInvalid() { @@ -289,6 +437,71 @@ await act.Should().ThrowAsync() .WithMessage("*ProfileId*required*"); } + // ValidateProductInfo builds its own CERTInextClient from connectionInfo (like + // ValidateCAConnectionInfo) rather than using the Moq-injected client, so these tests + // need a real WireMock server as ApiUrl (issue 0025 plan, correction 3). + + [Fact] + public async Task ValidateProductInfo_V1_Succeeds_WhenProductCodePresent() + { + using var server = WireMockServer.Start(); + server + .Given(Request.Create().WithPath("/GetProductDetails").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetProductDetailsJson())); + + var plugin = BuildPlugin(NewMock().Object); + var productInfo = new EnrollmentProductInfo + { + ProductID = "ssl", + ProductParameters = new Dictionary { ["ProductCode"] = MockCertificateData.ProfileIdTls } + }; + var connInfo = new Dictionary + { + ["ApiUrl"] = server.Urls[0], + ["AuthMode"] = "AccessKey", + ["ApiKey"] = "key", + ["AccountNumber"] = "12345" + }; + + Func act = () => plugin.ValidateProductInfo(productInfo, connInfo); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task ValidateProductInfo_V1_Throws_WhenProductCodeAbsent() + { + using var server = WireMockServer.Start(); + server + .Given(Request.Create().WithPath("/GetProductDetails").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetProductDetailsJson())); + + var plugin = BuildPlugin(NewMock().Object); + var productInfo = new EnrollmentProductInfo + { + ProductID = "ssl", + ProductParameters = new Dictionary { ["ProductCode"] = "999999" } + }; + var connInfo = new Dictionary + { + ["ApiUrl"] = server.Urls[0], + ["AuthMode"] = "AccessKey", + ["ApiKey"] = "key", + ["AccountNumber"] = "12345" + }; + + Func act = () => plugin.ValidateProductInfo(productInfo, connInfo); + + await act.Should().ThrowAsync() + .WithMessage("*not found*"); + } + // --------------------------------------------------------------------------- // Enroll — New // --------------------------------------------------------------------------- @@ -345,6 +558,127 @@ public async Task Enroll_New_ReturnsPendingStatus_WhenCaReturnsPendingApproval() result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); } + [Fact] + public async Task Enroll_New_ReturnsPendingStatus_WhenCaReportsIssuedButBodyMissing() + { + // CERTInext can report an "issued"/auto-approved certificateStatusId before the + // certificate bytes actually exist — the immediate GetCertificate download fails + // and the legacy client returns Status="issued" with Certificate=null. Reporting + // GENERATED with no PEM crashes the gateway framework's PEM parser downstream, so + // the plugin must demote this to pending rather than trust the raw status string. + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(MockCertificateData.AutoApprovedNoBodyEnrollResponse()); + + var plugin = BuildPluginWithPickup(mock.Object, retries: 0); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=test.example.com", + san: null, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + result.Certificate.Should().BeNullOrEmpty(); + } + + // --------------------------------------------------------------------------- + // Synchronous certificate pickup (Sectigo parity) + // --------------------------------------------------------------------------- + + [Fact] + public async Task Pickup_Disabled_WhenPickupRetriesZero_ReturnsPendingWithoutPolling() + { + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.PendingEnrollResponse()); + + var plugin = BuildPluginWithPickup(mock.Object, retries: 0); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, subject: "CN=test.example.com", san: null, + productInfo: MakeProductInfo(), requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), + Times.Never, "PickupRetries=0 must disable the synchronous pickup poll"); + } + + [Fact] + public async Task Pickup_ReturnsIssuedCert_WhenOrderIssuesDuringPoll() + { + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.PendingEnrollResponse()); + // The order finishes issuing by the time we poll: GetCertificate reports issued + PEM. + mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord()); + + var plugin = BuildPluginWithPickup(mock.Object, retries: 2); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, subject: "CN=test.example.com", san: null, + productInfo: MakeProductInfo(), requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + result.Certificate.Should().NotBeNullOrEmpty("a synchronously-picked-up cert must carry its PEM"); + mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), + Times.AtLeastOnce); + } + + [Fact] + public async Task Pickup_SurfacesTerminalStatus_WhenOrderRevokedDuringPoll() + { + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.PendingEnrollResponse()); + mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.RevokedCertRecord()); + + var plugin = BuildPluginWithPickup(mock.Object, retries: 3); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, subject: "CN=test.example.com", san: null, + productInfo: MakeProductInfo(), requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.REVOKED, + "a terminal status observed during pickup is surfaced immediately, not polled to exhaustion"); + } + + [Fact] + public async Task Pickup_ReturnsPending_WhenOrderNeverIssuesWithinBudget() + { + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.PendingEnrollResponse()); + // Every poll still reports pending — the budget is exhausted and Enroll returns the + // pending result for a later sync to complete. + mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.PendingCertRecord()); + + var plugin = BuildPluginWithPickup(mock.Object, retries: 1); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, subject: "CN=test.example.com", san: null, + productInfo: MakeProductInfo(), requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), + Times.AtLeastOnce, "an enabled pickup must actually poll before giving up"); + } + [Fact] public async Task Enroll_New_Throws_WhenProfileIdNotSet() { @@ -685,6 +1019,110 @@ public async Task Synchronize_SkipsFailedCertificates() results[0].CARequestID.Should().Be(MockCertificateData.CertId1); } + // Regression for issue 0001 — Synchronize dropped issued certs because the + // order-report listing (ListCertificatesAsync) carries no PEM body, so the + // synced record had Certificate == null and Command couldn't store it. + [Fact] + public async Task Synchronize_IssuedCertMissingBody_RefetchesFullCertificate() + { + const string id = MockCertificateData.CertId1; + + // Listing entry as the order report produces it: GENERATED status, NO body. + var listingEntry = new LegacyGetCertificateResponse + { + Id = id, + Status = "issued", // → EndEntityStatus.GENERATED + Certificate = null, // order report carries no PEM + ProfileId = MockCertificateData.ProfileIdTls + }; + + var mock = NewMock(); + mock.Setup(c => c.ListCertificatesAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnum(new List { listingEntry })); + // Full fetch returns the PEM body (mirrors the real GetCertificateAsync). + mock.Setup(c => c.GetCertificateAsync(id, It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(id)); + + var plugin = BuildPlugin(mock.Object); + var buffer = new BlockingCollection(10); + + await plugin.Synchronize(buffer, lastSync: null, fullSync: true, cancelToken: CancellationToken.None); + + var results = buffer.ToList(); + results.Should().HaveCount(1); + results[0].CARequestID.Should().Be(id); + results[0].Certificate.Should().Be(MockCertificateData.FakePemCertificate, + "an issued cert must carry the PEM body fetched via GetCertificateAsync, not a null body"); + mock.Verify(c => c.GetCertificateAsync(id, It.IsAny()), Times.Once); + } + + // Guard the N+1 boundary: when the listing already includes a body, Synchronize + // must NOT refetch. The strict mock has no GetCertificateAsync setup, so any call + // would throw and fail this test. + [Fact] + public async Task Synchronize_IssuedCertWithBody_DoesNotRefetch() + { + var mock = NewMock(); + mock.Setup(c => c.ListCertificatesAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnum(new List + { + MockCertificateData.IssuedCertRecord(MockCertificateData.CertId1) // already has a body + })); + + var plugin = BuildPlugin(mock.Object); + var buffer = new BlockingCollection(10); + + await plugin.Synchronize(buffer, lastSync: null, fullSync: true, cancelToken: CancellationToken.None); + + var results = buffer.ToList(); + results.Should().HaveCount(1); + results[0].Certificate.Should().Be(MockCertificateData.FakePemCertificate); + mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + // Regression for issue 0001 (revoked variant) — a cert reported "revoked" during + // sync also arrives from the order report with no body and no revocation detail. + // The refetch must populate the body AND the revocation date, not just the REVOKED + // status. (Complements Synchronize_MapsRevokedCertificates_Correctly, which feeds an + // already-populated entry that doesn't exercise the refetch.) + [Fact] + public async Task Synchronize_RevokedCertMissingBody_RefetchesWithRevocationMetadata() + { + const string id = MockCertificateData.CertId3; + + var listingEntry = new LegacyGetCertificateResponse + { + Id = id, + Status = "revoked", // → EndEntityStatus.REVOKED + Certificate = null, // order report carries neither body nor revocation detail + RevokedAt = null, + ProfileId = MockCertificateData.ProfileIdTls + }; + + var mock = NewMock(); + mock.Setup(c => c.ListCertificatesAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnum(new List { listingEntry })); + mock.Setup(c => c.GetCertificateAsync(id, It.IsAny())) + .ReturnsAsync(MockCertificateData.RevokedCertRecord(id)); // body + RevokedAt + reason + + var plugin = BuildPlugin(mock.Object); + var buffer = new BlockingCollection(10); + + await plugin.Synchronize(buffer, lastSync: null, fullSync: true, cancelToken: CancellationToken.None); + + var results = buffer.ToList(); + results.Should().HaveCount(1); + results[0].CARequestID.Should().Be(id); + results[0].Status.Should().Be((int)EndEntityStatus.REVOKED); + results[0].Certificate.Should().Be(MockCertificateData.FakePemCertificate); + results[0].RevocationDate.Should().NotBeNull( + "a revoked cert must carry its revocation date after the sync refetch, not just REVOKED status"); + mock.Verify(c => c.GetCertificateAsync(id, It.IsAny()), Times.Once); + } + [Fact] public async Task Synchronize_HonoursCancellation() { diff --git a/CERTInext.Tests/CERTInextCAPluginV2DcvTests.cs b/CERTInext.Tests/CERTInextCAPluginV2DcvTests.cs new file mode 100644 index 0000000..f471aef --- /dev/null +++ b/CERTInext.Tests/CERTInextCAPluginV2DcvTests.cs @@ -0,0 +1,1388 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Regression tests for issues/0020: EMS-1080 ("Domain is already verified") from either + /// V2 DCV entry point (GetDcvV2Async or VerifyDcvV2Async) must be treated as + /// DCV already satisfied — skip TXT publish, proceed straight to tracking — not as a + /// failure deferred to the next sync cycle. Driven end-to-end through + /// (V2 path) so the assertions exercise the same + /// code path Command actually calls, using to observe + /// whether a TXT record was ever staged. + /// + public class CERTInextCAPluginV2DcvTests + { + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + private static CERTInextCAPlugin BuildV2DcvPlugin( + ICERTInextClient client, IDomainValidatorFactory factory, string dcvTxtRecordTemplate = null, + int pickupRetries = 0) => + new CERTInextCAPlugin(client, factory, new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + AccountNumber = "12345", + AuthMode = "AccessKey", + ApiKey = "v1-key", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + PickupRetries = pickupRetries, + PickupDelayInSeconds = 1, + DcvEnabled = true, + DcvTimeoutMinutes = 1, + DcvPropagationDelaySeconds = 1, + DcvTxtRecordTemplate = dcvTxtRecordTemplate + }); + + private static EnrollmentProductInfo MakeV2ProductInfo() => + new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(System.StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = "842", + ["ProductFamily"] = "ssl", + ["ProductVariant"] = "dv", + ["DomainName"] = "example.com" + } + }; + + private static Task Enroll(CERTInextCAPlugin plugin) => + plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=example.com", + san: new Dictionary { ["dns"] = new[] { "example.com" } }, + productInfo: MakeV2ProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + private const string OrderId = "ord_ems1080_001"; + + private static V2CreateOrderResponse PlaceOrderResponse() => + new V2CreateOrderResponse { OrderId = OrderId, Status = "pending-dcv" }; + + private static V2OrderStatusResponse PendingDcvStatus() => + new V2OrderStatusResponse { OrderId = OrderId, Status = "pending-dcv", Domain = "example.com" }; + + private static V2OrderStatusResponse IssuedStatus() => + new V2OrderStatusResponse { OrderId = OrderId, Status = "issued", Domain = "example.com" }; + + private static V2CertificateDownloadResponse DownloadResponse() => + new V2CertificateDownloadResponse + { + OrderId = OrderId, + SerialNumber = "AA11BB22", + CertificatePem = MockCertificateData.FakePemCertificate + }; + + // --------------------------------------------------------------------------- + // Regression (issues/0020): GetDcv returns EMS-1080 + // --------------------------------------------------------------------------- + + [Fact] + public async Task PerformDcvV2_GetDcvReturnsEms1080_TreatedAsSatisfied_NoStagingAndProceedsToTracking() + { + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } // non-UCC + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(PlaceOrderResponse()); + + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // 1st call: post-CSR check (pending-dcv). 2nd+: the PerformDcvV2IfNeededAsync poll + // loop and EnrollV2Async's post-DCV re-check both see "issued" immediately. + mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(PendingDcvStatus()) + .ReturnsAsync(IssuedStatus()) + .ReturnsAsync(IssuedStatus()); + + mock.Setup(c => c.GetDcvV2Async(OrderId, It.IsAny(), It.IsAny())) + .ThrowsAsync(new System.Exception( + $"CERTInext V2 API error during 'V2 get DCV challenge'. HTTP 422. " + + "Unprocessable Entity: EMS-1080 Domain is already verified.")); + + mock.Setup(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(DownloadResponse()); + + var validator = new FakeDomainValidator(); + var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED, + "EMS-1080 must be treated as DCV satisfied, not a failure — the order should " + + "proceed to tracking and come back issued"); + validator.StagedRecords.Should().BeEmpty( + "GetDcv returning EMS-1080 means there is no fresh challenge to publish"); + + // VerifyDcv must never be reached — there is nothing to verify when GetDcv itself + // reports the domain is already verified. + mock.Verify(c => c.VerifyDcvV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + // --------------------------------------------------------------------------- + // Regression (issues/0020): VerifyDcv returns EMS-1080 + // --------------------------------------------------------------------------- + + [Fact] + public async Task PerformDcvV2_VerifyDcvReturnsEms1080_TreatedAsSatisfied_ProceedsToTracking() + { + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } // non-UCC + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(PlaceOrderResponse()); + + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(PendingDcvStatus()) + .ReturnsAsync(IssuedStatus()) + .ReturnsAsync(IssuedStatus()); + + // GetDcv succeeds normally and returns a token to publish... + mock.Setup(c => c.GetDcvV2Async(OrderId, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse + { + Token = "dcv-token-abc123", + TokenExpiryDate = "2026-12-31 23:59:59" + }); + + // ...but by the time VerifyDcv is called, the domain became already-verified. + mock.Setup(c => c.VerifyDcvV2Async(OrderId, "example.com", It.IsAny(), It.IsAny())) + .ThrowsAsync(new System.Exception( + $"CERTInext V2 API error during 'V2 verify DCV'. HTTP 422. " + + "Unprocessable Entity: EMS-1080 Domain is already verified.")); + + mock.Setup(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(DownloadResponse()); + + var validator = new FakeDomainValidator(); + var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED, + "EMS-1080 from VerifyDcv must be treated as verified, not a failure — the order " + + "should proceed to tracking and come back issued"); + + // The TXT record was staged (GetDcv succeeded) — this exercises the "verified between + // GetDcv and VerifyDcv" race rather than the GetDcv-level no-op. + validator.StagedRecords.Should().ContainSingle(); + validator.CleanedUpKeys.Should().ContainSingle( + "staged records are always cleaned up, including on the EMS-1080 verify path"); + } + + // --------------------------------------------------------------------------- + // Regression (issues/0037): live GetDcv response shape has no fileNameContent + // --------------------------------------------------------------------------- + + /// + /// Regression (issues/0037): a live fresh-domain GetDcv challenge response was + /// captured as exactly {"tokenExpiryDate":"...","token":"..."} — no + /// orderNumber/domainName/dcvMethod/fileNameContent. + /// Before the fix, modeled fileNameContent + /// instead of token, so this shape deserialized with a null token, which drove + /// PerformDcvV2IfNeededAsync's null-token guard and left the order stuck at + /// EXTERNALVALIDATION forever (no TXT ever staged, no exception, just a returned + /// false). This constructs the response exactly as the fixed DTO now + /// deserializes the real live body, and proves the plugin extracts and publishes the + /// token instead of deferring. + /// + [Fact] + public async Task PerformDcvV2_LiveShapeTokenOnly_StagesTxtRecord_DoesNotHitNullTokenGuard() + { + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } // non-UCC + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(PlaceOrderResponse()); + + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(PendingDcvStatus()) + .ReturnsAsync(IssuedStatus()) + .ReturnsAsync(IssuedStatus()); + + // Real live shape (issues/0037 probe, 2026-09-25): only Token/TokenExpiryDate are + // ever populated — no OrderNumber/DomainName/DcvMethod exist on the DTO anymore. + const string liveToken = "D6026954B9EB7D31E3FE8B2194F07087"; + mock.Setup(c => c.GetDcvV2Async(OrderId, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse + { + Token = liveToken, + TokenExpiryDate = "2026-09-27 15:27:00" + }); + + mock.Setup(c => c.VerifyDcvV2Async(OrderId, "example.com", It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }); + + mock.Setup(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(DownloadResponse()); + + var validator = new FakeDomainValidator(); + var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED, + "the live token-only shape must be extracted and published, not deferred by " + + "the null-token guard"); + + validator.StagedRecords.Should().ContainSingle( + "GetDcv returned a real token, so a TXT record must be staged from it") + .Which.Should().Be(("_emsign-validation.example.com", liveToken), + "the staged value must come from the new Token property, not the removed " + + "FileNameContent property; the hostname uses the default " + + "DcvTxtRecordTemplate (issues/0027 item 5a) since none is configured here"); + + mock.Verify(c => c.VerifyDcvV2Async( + OrderId, "example.com", It.IsAny(), It.IsAny()), + Times.Once); + } + + // --------------------------------------------------------------------------- + // Regression (issues/0027 item 5a): DcvTxtRecordTemplate must be honored by the V2 + // DCV path, not hardcoded to "_emudhra-challenge.{domain}" — mirrors V1's + // PerformDcvIfNeededAsync (config value if set, else Constants.Dcv.DefaultTxtRecordTemplate). + // --------------------------------------------------------------------------- + + [Fact] + public async Task PerformDcvV2_ConfiguredTxtRecordTemplate_IsHonored() + { + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } // non-UCC + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(PlaceOrderResponse()); + + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(PendingDcvStatus()) + .ReturnsAsync(IssuedStatus()) + .ReturnsAsync(IssuedStatus()); + + const string token = "configured-template-token"; + mock.Setup(c => c.GetDcvV2Async(OrderId, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = token, TokenExpiryDate = "2026-12-31 23:59:59" }); + + mock.Setup(c => c.VerifyDcvV2Async(OrderId, "example.com", It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }); + + mock.Setup(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(DownloadResponse()); + + var validator = new FakeDomainValidator(); + var plugin = BuildV2DcvPlugin( + mock.Object, new FakeDomainValidatorFactory(validator), + dcvTxtRecordTemplate: "_custom-dcv-check.{0}"); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + validator.StagedRecords.Should().ContainSingle() + .Which.Should().Be(("_custom-dcv-check.example.com", token), + "the configured DcvTxtRecordTemplate must be used to build the TXT " + + "hostname, not the old hardcoded '_emudhra-challenge' label"); + } + + [Fact] + public async Task PerformDcvV2_UnconfiguredTxtRecordTemplate_UsesV1Default() + { + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } // non-UCC + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(PlaceOrderResponse()); + + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(PendingDcvStatus()) + .ReturnsAsync(IssuedStatus()) + .ReturnsAsync(IssuedStatus()); + + const string token = "default-template-token"; + mock.Setup(c => c.GetDcvV2Async(OrderId, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = token, TokenExpiryDate = "2026-12-31 23:59:59" }); + + mock.Setup(c => c.VerifyDcvV2Async(OrderId, "example.com", It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }); + + mock.Setup(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(DownloadResponse()); + + var validator = new FakeDomainValidator(); + // No dcvTxtRecordTemplate override — must fall back to the same default V1 uses. + var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + await Enroll(plugin); + + validator.StagedRecords.Should().ContainSingle() + .Which.Should().Be(("_emsign-validation.example.com", token), + "with no DcvTxtRecordTemplate configured, V2 must fall back to " + + "Constants.Dcv.DefaultTxtRecordTemplate (the same default V1 uses) rather " + + "than a separate, hardcoded V2 literal"); + } + + // --------------------------------------------------------------------------- + // Issue 0051: the inline DCV path owns the in-call issuance wait — EnrollV2Async must + // not stack a second PickUpEnrolledCertificateV2Async poll on top of it. + // --------------------------------------------------------------------------- + + [Fact] + public async Task EnrollV2_DcvRan_SkipsPickupPoll_EvenThoughPickupIsEnabled() + { + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } // non-UCC + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(PlaceOrderResponse()); + + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // 1st call: post-CSR check (pending-dcv). 2nd: PerformDcvV2IfNeededAsync's own + // tracking poll (step 4) — moves to a *different* pending state so that inner loop + // breaks (DCV steps completed) without the order having actually issued. 3rd: + // EnrollV2Async's post-DCV re-check, observing the same still-pending state. If the + // pickup poll incorrectly ran afterward, a 4th TrackOrderV2Async call would occur. + mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(PendingDcvStatus()) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "pending-organization-verification", Domain = "example.com" }) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "pending-organization-verification", Domain = "example.com" }); + + mock.Setup(c => c.GetDcvV2Async(OrderId, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = "dcv-token-xyz", TokenExpiryDate = "2026-12-31 23:59:59" }); + mock.Setup(c => c.VerifyDcvV2Async(OrderId, "example.com", It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }); + + var validator = new FakeDomainValidator(); + // PickupRetries > 0 and clamped to a fast 1s delay — if the dcvV2Ran gate didn't + // work, this budget is easily enough for the pickup poll to run and this test would + // observe extra TrackOrderV2Async/DownloadCertificateV2Async calls. + var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator), pickupRetries: 5); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION, + "the order never actually issued — DCV ran, but the order is still pending elsewhere"); + result.CARequestID.Should().Be(OrderId); + mock.Verify(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny()), + Times.Exactly(3), + "a pickup poll must not stack on top of the inline DCV wait — no 4th TrackOrderV2Async call"); + mock.Verify(c => c.DownloadCertificateV2Async( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // Issue 0052: a REVOKED disposition discovered on the post-DCV status re-check must be + // mapped to FAILED, not returned as a body-less REVOKED record — mirrors + // EnrollV2_DcvRan_SkipsPickupPoll_EvenThoughPickupIsEnabled above, but the second + // TrackOrderV2Async observation is "revoked" instead of another pending state. + // --------------------------------------------------------------------------- + + [Fact] + public async Task EnrollV2_PostDcvRecheckRevoked_ReturnsFailed_NotBodylessRevoked() + { + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } // non-UCC + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(PlaceOrderResponse()); + + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // 1st call: post-CSR check (pending-dcv), triggers the inline DCV block. 2nd: + // PerformDcvV2SingleDomainAsync's own internal step-4 poll (it calls + // TrackOrderV2Async itself to wait out "pending-dcv" — see its doc comment) observes + // "revoked", which is != "pending-dcv" so that poll loop breaks and DCV reports done. + // 3rd: EnrollV2Async's own post-DCV re-check observes the same revoked status. + mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(PendingDcvStatus()) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "revoked", Domain = "example.com" }) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "revoked", Domain = "example.com" }); + + mock.Setup(c => c.GetDcvV2Async(OrderId, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = "dcv-token-revoked", TokenExpiryDate = "2026-12-31 23:59:59" }); + mock.Setup(c => c.VerifyDcvV2Async(OrderId, "example.com", It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }); + + var validator = new FakeDomainValidator(); + // Pickup enabled to prove the poll is correctly skipped (dcvV2Ran) rather than + // masking the revoked status behind additional polling/downloads. + var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator), pickupRetries: 5); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.FAILED, + "a REVOKED disposition discovered on the post-DCV re-check has no certificate " + + "body and must never be reported as REVOKED (issue 0052)"); + result.Certificate.Should().BeNull(); + result.CARequestID.Should().Be(OrderId); + result.StatusMessage.Should().Contain(OrderId).And.Contain("revoked"); + + mock.Verify(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny()), + Times.Exactly(3), + "the pickup poll must not run on top of the inline DCV wait — no 4th TrackOrderV2Async call"); + mock.Verify(c => c.DownloadCertificateV2Async( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // Synchronize (V2, issues/0022) — DCV-during-sync age-window / per-pass-cap gating. + // Reuses EvaluateDcvSyncEligibility/DcvSyncDecision — same bounds V1 sync uses (issue + // 0002), applied to the V2 /reports/orders path. + // --------------------------------------------------------------------------- + + private static async IAsyncEnumerable AsyncEnumerable(params T[] items) + { + foreach (var item in items) + yield return item; + await Task.CompletedTask; + } + + private static OrderReportEntryV2 PendingDcvRow(string orderNumber, DateTime orderDateUtc) => + new OrderReportEntryV2 + { + OrderNumber = orderNumber, + OrderStatus = "Order Accepted", + CertificateStatus = "Pending for Approver", + DomainName = "example.com", + OrderDate = orderDateUtc.ToString("o") + }; + + [Fact] + public async Task SynchronizeV2_PendingDcvOrder_AgedOutOfWindow_SkipsDcv_EmitsPendingWithoutResolvingFamily() + { + var mock = NewMock(); + var oldRow = PendingDcvRow("ord_old_001", DateTime.UtcNow.AddHours(-48)); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(oldRow)); + + // Age window of 1h — the 48h-old order above is well outside it. + var config = new CERTInextConfig + { + UseV2Api = true, ApiUrl = "https://v2.certinext.io", + OAuthClientId = "c", OAuthClientSecret = "s", + DcvEnabled = true, DcvTimeoutMinutes = 1, DcvPropagationDelaySeconds = 1, + DcvSyncMaxOrderAgeHours = 1, DcvSyncMaxPerPass = 0 + }; + var plugin = new CERTInextCAPlugin(mock.Object, new FakeDomainValidatorFactory(new FakeDomainValidator()), config); + + var buffer = new BlockingCollection(100); + await plugin.Synchronize(buffer, DateTime.UtcNow.AddDays(-1), true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().ContainSingle(r => r.CARequestID == "ord_old_001" + && r.Status == (int)EndEntityStatus.EXTERNALVALIDATION); + + // Aged-out rows must not even resolve a family — that's the point of the age gate. + mock.Verify(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + It.IsAny(), It.IsAny()), Times.Never); + mock.Verify(c => c.GetDcvV2Async( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task SynchronizeV2_PendingDcvOrders_ExceedingPerPassCap_OnlyAttemptsUpToCap() + { + var mock = NewMock(); + var row1 = PendingDcvRow("ord_cap_001", DateTime.UtcNow.AddMinutes(-30)); + var row2 = PendingDcvRow("ord_cap_002", DateTime.UtcNow.AddMinutes(-20)); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row1, row2)); + + // Only the first (cap=1) row should ever have its family resolved. + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync("ord_cap_001", It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = "ord_cap_001", Status = "pending-dcv", Domain = "example.com" + })); + + // Fail fast at GetDcv so PerformDcvV2IfNeededAsync returns false quickly without + // needing the full staging/verify chain mocked — the point of this test is the cap + // gate, not the DCV flow itself. + mock.Setup(c => c.GetDcvV2Async("ord_cap_001", It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("simulated transient GetDcv failure")); + + var config = new CERTInextConfig + { + UseV2Api = true, ApiUrl = "https://v2.certinext.io", + OAuthClientId = "c", OAuthClientSecret = "s", + DcvEnabled = true, DcvTimeoutMinutes = 1, DcvPropagationDelaySeconds = 1, + DcvSyncMaxOrderAgeHours = 0, DcvSyncMaxPerPass = 1 + }; + var plugin = new CERTInextCAPlugin(mock.Object, new FakeDomainValidatorFactory(new FakeDomainValidator()), config); + + var buffer = new BlockingCollection(100); + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().HaveCount(2); + records.Should().OnlyContain(r => r.Status == (int)EndEntityStatus.EXTERNALVALIDATION); + + mock.Verify(c => c.ResolveAndTrackOrderV2WithFamilyAsync("ord_cap_001", It.IsAny()), Times.Once); + mock.Verify(c => c.ResolveAndTrackOrderV2WithFamilyAsync("ord_cap_002", It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // Issue 0042 — UCC additionalDomains SANs never got DCV-validated because the V2 DCV + // machinery only ever drove the order's primary domain. These tests exercise the + // generalized PerformDcvV2MultiDomainAsync path (driven from Track Order's + // verifications.domain.domains[] block) end to end through Enroll/Synchronize, the + // same way the EMS-1080 tests above exercise the single-domain path. + // --------------------------------------------------------------------------- + + private static V2DomainVerificationEntry DomainEntry( + string domain, string dcvStatus, string dcvMethod = null, string verifiedAt = null) => + new V2DomainVerificationEntry + { + Domain = domain, + DomainStatus = "ACTIVE", + DcvStatus = dcvStatus, + DcvMethod = dcvMethod, + VerifiedAt = verifiedAt, + CaaStatus = "SKIPPED" + }; + + private static V2OrderStatusResponse StatusWithDomains(string status, params V2DomainVerificationEntry[] domains) => + new V2OrderStatusResponse + { + OrderId = OrderId, + Status = status, + Domain = domains.FirstOrDefault()?.Domain, + Verifications = new V2Verifications + { + Domain = new V2DomainVerification { Status = "PENDING", Domains = domains.ToList() } + } + }; + + private const string UccPrimary = "example.com"; + private const string UccSanA = "a.pending0042.example.com"; + private const string UccSanB = "b.pending0042.example.com"; + + [Fact] + public async Task PerformDcvV2_Ucc_PrimaryVerified_TwoPendingSans_StagesAndVerifiesOnlyPendingSans_CleansUpAll() + { + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(PlaceOrderResponse()); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // 1st TrackOrder call (post-CSR check): primary already VERIFIED, both SANs PENDING. + // Every call after that (the WaitForDomainsVerifiedV2Async poll, and EnrollV2Async's + // own post-DCV re-check) sees the order fully issued with every domain VERIFIED. + int trackCalls = 0; + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(() => + { + trackCalls++; + return trackCalls == 1 + ? StatusWithDomains("pending-dcv", + DomainEntry(UccPrimary, "VERIFIED", "dns-txt"), + DomainEntry(UccSanA, "PENDING"), + DomainEntry(UccSanB, "PENDING")) + : StatusWithDomains("issued", + DomainEntry(UccPrimary, "VERIFIED", "dns-txt"), + DomainEntry(UccSanA, "VERIFIED", "dns-txt"), + DomainEntry(UccSanB, "VERIFIED", "dns-txt")); + }); + + mock.Setup(c => c.GetDcvV2Async(OrderId, UccSanA, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = "token-a", TokenExpiryDate = "2026-12-31 23:59:59" }); + mock.Setup(c => c.GetDcvV2Async(OrderId, UccSanB, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = "token-b", TokenExpiryDate = "2026-12-31 23:59:59" }); + + mock.Setup(c => c.VerifyDcvV2Async(OrderId, UccSanA, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }); + mock.Setup(c => c.VerifyDcvV2Async(OrderId, UccSanB, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }); + + mock.Setup(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(DownloadResponse()); + + var validator = new FakeDomainValidator(); + var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + + validator.StagedRecords.Select(r => r.key).Should().BeEquivalentTo( + new[] { $"_emsign-validation.{UccSanA}", $"_emsign-validation.{UccSanB}" }, + "only the two pending SANs should be staged — the already-VERIFIED primary must never be re-challenged"); + + validator.CleanedUpKeys.Should().BeEquivalentTo( + new[] { $"_emsign-validation.{UccSanA}", $"_emsign-validation.{UccSanB}" }, + "every staged record must be cleaned up"); + + mock.Verify(c => c.GetDcvV2Async(OrderId, UccPrimary, It.IsAny(), It.IsAny()), Times.Never); + mock.Verify(c => c.VerifyDcvV2Async(OrderId, UccPrimary, It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task PerformDcvV2_Ucc_OneSanVerifyFails_OtherStillVerified_AllCleanedUp_OrderStaysPending() + { + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(PlaceOrderResponse()); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // 1st call: post-CSR, both SANs pending. Every later call (the poll for the one SAN + // that DID verify, and EnrollV2Async's post-DCV re-check): SanA verified, SanB still + // pending — the order legitimately cannot advance further this pass. + int trackCalls = 0; + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(() => + { + trackCalls++; + return trackCalls == 1 + ? StatusWithDomains("pending-dcv", + DomainEntry(UccPrimary, "VERIFIED", "dns-txt"), + DomainEntry(UccSanA, "PENDING"), + DomainEntry(UccSanB, "PENDING")) + : StatusWithDomains("pending-dcv", + DomainEntry(UccPrimary, "VERIFIED", "dns-txt"), + DomainEntry(UccSanA, "VERIFIED", "dns-txt"), + DomainEntry(UccSanB, "PENDING")); + }); + + mock.Setup(c => c.GetDcvV2Async(OrderId, UccSanA, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = "token-a", TokenExpiryDate = "2026-12-31 23:59:59" }); + mock.Setup(c => c.GetDcvV2Async(OrderId, UccSanB, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = "token-b", TokenExpiryDate = "2026-12-31 23:59:59" }); + + mock.Setup(c => c.VerifyDcvV2Async(OrderId, UccSanA, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }); + mock.Setup(c => c.VerifyDcvV2Async(OrderId, UccSanB, It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("simulated transient verify failure for SanB")); + + var validator = new FakeDomainValidator(); + // PickupRetries > 0 (mirrors the 0051 regression test above): confirms dcvV2Ran still + // gates the pickup poll even on the partial-failure multi-domain path. + var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator), pickupRetries: 5); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION, + "SanB never verified, so the order must stay pending — not be treated as failed or issued"); + result.CARequestID.Should().Be(OrderId); + + validator.CleanedUpKeys.Should().BeEquivalentTo( + new[] { $"_emsign-validation.{UccSanA}", $"_emsign-validation.{UccSanB}" }, + "both staged records must be cleaned up regardless of SanB's verify failure"); + + mock.Verify(c => c.VerifyDcvV2Async(OrderId, UccSanA, It.IsAny(), It.IsAny()), Times.Once); + mock.Verify(c => c.VerifyDcvV2Async(OrderId, UccSanB, It.IsAny(), It.IsAny()), Times.Once); + mock.Verify(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny()), + Times.Exactly(3), + "a pickup poll must not stack on top of the inline DCV wait — no 4th TrackOrderV2Async call"); + mock.Verify(c => c.DownloadCertificateV2Async( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task SynchronizeV2_Ucc_OneSanStillPendingFromAnEarlierPass_OnlyThatSanIsProcessed() + { + var mock = NewMock(); + const string syncOrderId = "ord_ucc_sync_001"; + var row = PendingDcvRow(syncOrderId, DateTime.UtcNow.AddMinutes(-10)); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + // Primary and SanA were already verified on an earlier sync pass; only SanB remains. + var pendingStatus = new V2OrderStatusResponse + { + OrderId = syncOrderId, + Status = "pending-dcv", + Domain = UccPrimary, + Verifications = new V2Verifications + { + Domain = new V2DomainVerification + { + Status = "PENDING", + Domains = new List + { + DomainEntry(UccPrimary, "VERIFIED", "dns-txt"), + DomainEntry(UccSanA, "VERIFIED", "dns-txt"), + DomainEntry(UccSanB, "PENDING") + } + } + } + }; + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync(syncOrderId, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, pendingStatus)); + + mock.Setup(c => c.GetDcvV2Async(syncOrderId, UccSanB, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = "token-b", TokenExpiryDate = "2026-12-31 23:59:59" }); + mock.Setup(c => c.VerifyDcvV2Async(syncOrderId, UccSanB, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }); + + var issuedStatus = new V2OrderStatusResponse + { + OrderId = syncOrderId, + Status = "issued", + Domain = UccPrimary, + Verifications = new V2Verifications + { + Domain = new V2DomainVerification + { + Status = "VERIFIED", + Domains = new List + { + DomainEntry(UccPrimary, "VERIFIED", "dns-txt"), + DomainEntry(UccSanA, "VERIFIED", "dns-txt"), + DomainEntry(UccSanB, "VERIFIED", "dns-txt") + } + } + } + }; + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), syncOrderId, It.IsAny())) + .ReturnsAsync(issuedStatus); + mock.Setup(c => c.ResolveAndTrackOrderV2Async(syncOrderId, It.IsAny())) + .ReturnsAsync(issuedStatus); + mock.Setup(c => c.ResolveAndDownloadCertificateV2Async(syncOrderId, It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = syncOrderId, SerialNumber = "AA11BB22", CertificatePem = MockCertificateData.FakePemCertificate + }); + + var config = new CERTInextConfig + { + UseV2Api = true, ApiUrl = "https://v2.certinext.io", + OAuthClientId = "c", OAuthClientSecret = "s", + DcvEnabled = true, DcvTimeoutMinutes = 1, DcvPropagationDelaySeconds = 1, + DcvSyncMaxOrderAgeHours = 0, DcvSyncMaxPerPass = 0 + }; + var validator = new FakeDomainValidator(); + var plugin = new CERTInextCAPlugin(mock.Object, new FakeDomainValidatorFactory(validator), config); + + var buffer = new BlockingCollection(100); + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + buffer.ToArray().Should().ContainSingle(r => r.CARequestID == syncOrderId); + + mock.Verify(c => c.GetDcvV2Async(syncOrderId, UccSanB, It.IsAny(), It.IsAny()), Times.Once); + mock.Verify(c => c.GetDcvV2Async(syncOrderId, UccSanA, It.IsAny(), It.IsAny()), Times.Never); + mock.Verify(c => c.GetDcvV2Async(syncOrderId, UccPrimary, It.IsAny(), It.IsAny()), Times.Never); + mock.Verify(c => c.GetDcvV2Async(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never, + "the no-domain single-domain-fallback overload must not be used once domainEntries is populated"); + + validator.StagedRecords.Should().ContainSingle(); + validator.CleanedUpKeys.Should().ContainSingle(); + } + + [Fact] + public async Task PerformDcvV2_DomainsArrayAbsent_UsesSingleDomainFallback_NeverThePerDomainOverload() + { + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(PlaceOrderResponse()); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // No Verifications block anywhere in this sequence — the pre-0042 shape. + mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(PendingDcvStatus()) + .ReturnsAsync(IssuedStatus()) + .ReturnsAsync(IssuedStatus()); + + mock.Setup(c => c.GetDcvV2Async(OrderId, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = "legacy-token", TokenExpiryDate = "2026-12-31 23:59:59" }); + mock.Setup(c => c.VerifyDcvV2Async(OrderId, "example.com", It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }); + mock.Setup(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(DownloadResponse()); + + var validator = new FakeDomainValidator(); + var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + validator.StagedRecords.Should().ContainSingle(); + + // The 4-argument (per-domain) overload is a distinct method — confirming it was + // never called proves the domainEntries-absent case took the untouched legacy path, + // not the generalized multi-domain one (issue 0042's "keep today's primary-domain + // behaviour exactly" requirement). + mock.Verify(c => c.GetDcvV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task PerformDcvV2_Ucc_Ems1080OnPendingSan_TreatedAsVerified_NotAnError_NoStagingOrVerify() + { + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(PlaceOrderResponse()); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + int trackCalls = 0; + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(() => + { + trackCalls++; + return trackCalls == 1 + ? StatusWithDomains("pending-dcv", + DomainEntry(UccPrimary, "VERIFIED", "dns-txt"), + DomainEntry(UccSanA, "PENDING")) + : StatusWithDomains("issued", + DomainEntry(UccPrimary, "VERIFIED", "dns-txt"), + DomainEntry(UccSanA, "VERIFIED", "dns-txt")); + }); + + // EMS-1080 at GetDcv for the pending SAN — the domain became verified CA-side + // between Track Order reporting it pending and this challenge fetch. + mock.Setup(c => c.GetDcvV2Async(OrderId, UccSanA, It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception( + "CERTInext V2 API error during 'V2 get DCV challenge (per-domain)'. HTTP 422. " + + "Unprocessable Entity: EMS-1080 Domain is already verified.")); + + mock.Setup(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(DownloadResponse()); + + var validator = new FakeDomainValidator(); + var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED, + "EMS-1080 on a pending SAN must be treated as already verified, not a failure"); + validator.StagedRecords.Should().BeEmpty( + "EMS-1080 at GetDcv means there is no fresh challenge to publish for this SAN"); + validator.CleanedUpKeys.Should().BeEmpty("nothing was staged, so there is nothing to clean up"); + + mock.Verify(c => c.VerifyDcvV2Async( + OrderId, UccSanA, It.IsAny(), It.IsAny()), + Times.Never, + "VerifyDcv must never be reached for a domain GetDcv already reported as verified"); + } + + [Fact] + public void V2OrderStatusResponse_Deserializes_PendingSanDomainsArray_WithoutDcvMethod() + { + // Raw shape from issues/0042 "Pending-SAN challenge shape (live)" (order 7465857196, + // 2026-09-28) — pending entries carry no "dcvMethod" key at all; it only appears once + // an entry reaches VERIFIED. + const string rawJson = @"{ + ""orderId"": ""7465857196"", + ""status"": ""pending-approval"", + ""domain"": ""ucc0042-202609290046.dcv-test.scrup.org"", + ""verifications"": { + ""domain"": { + ""status"": ""PENDING"", + ""domains"": [ + {""domain"":""a.pending0042-202609290046.example.com"",""domainStatus"":""ACTIVE"",""dcvStatus"":""PENDING"",""caaStatus"":""SKIPPED""}, + {""domain"":""b.pending0042-202609290046.example.com"",""domainStatus"":""ACTIVE"",""dcvStatus"":""PENDING"",""caaStatus"":""SKIPPED""}, + {""domain"":""ucc0042-202609290046.dcv-test.scrup.org"",""domainStatus"":""ACTIVE"",""dcvMethod"":""dns-txt"",""dcvStatus"":""VERIFIED"",""verifiedAt"":""2026-09-29T00:46:09Z"",""caaStatus"":""PASSED""} + ] + }, + ""empty"": false + } + }"; + + // Null-forgiving here: System.Text.Json's Deserialize is annotated to return + // T?, but a successfully-parsed non-null JSON object (as above) never actually + // produces a null reference — the Should().NotBeNull() below is the runtime + // guarantee backing that, which the compiler's static analysis can't see through. + var result = JsonSerializer.Deserialize(rawJson)!; + + result.Should().NotBeNull(); + result.Verifications.Should().NotBeNull(); + result.Verifications.Domain.Should().NotBeNull(); + result.Verifications.Domain.Status.Should().Be("PENDING"); + result.Verifications.Domain.Domains.Should().HaveCount(3); + + var sanA = result.Verifications.Domain.Domains.Single(d => d.Domain == "a.pending0042-202609290046.example.com"); + sanA.DcvStatus.Should().Be("PENDING"); + sanA.DcvMethod.Should().BeNull("pending entries carry no dcvMethod key at all — absent, not present-but-null-looking"); + sanA.VerifiedAt.Should().BeNull(); + + var verifiedPrimary = result.Verifications.Domain.Domains.Single( + d => d.Domain == "ucc0042-202609290046.dcv-test.scrup.org"); + verifiedPrimary.DcvStatus.Should().Be("VERIFIED"); + verifiedPrimary.DcvMethod.Should().Be("dns-txt"); + verifiedPrimary.VerifiedAt.Should().Be("2026-09-29T00:46:09Z"); + } + + // --------------------------------------------------------------------------- + // Issue 0033 — DCV exists only for the SSL/TLS family. Spec: the DCV endpoints live + // under /ssl-certificates only; Private PKI: "No DCV - your CA trusts you"; Document + // Signer has no DCV step. PerformDcvV2IfNeededAsync's family gate must stop every caller + // (Enroll, GetSingleRecord, Synchronize) from hitting a nonexistent + // /{family}/{orderId}/dcv endpoint for a non-SSL order that is merely pending. + // --------------------------------------------------------------------------- + + private static void SetupDcvCallsSoStrictMockDoesNotThrow(Mock mock) + { + // Set up (rather than leave unset) so a regression would be recorded as an invocation + // and caught by Verify(Times.Never), instead of throwing a MockException that + // PerformDcvV2IfNeededAsync's own error handling might swallow. + mock.Setup(c => c.GetDcvV2Async(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = "should-never-be-fetched" }); + mock.Setup(c => c.GetDcvV2Async(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = "should-never-be-fetched" }); + mock.Setup(c => c.VerifyDcvV2Async(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse()); + } + + private static void VerifyNoDcvCalls(Mock mock, FakeDomainValidator validator) + { + mock.Verify(c => c.GetDcvV2Async(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + mock.Verify(c => c.GetDcvV2Async(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + mock.Verify(c => c.VerifyDcvV2Async(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + validator.StagedRecords.Should().BeEmpty("no TXT record may be published for a non-SSL order"); + } + + [Fact] + public async Task Enroll_V2_PrivatePki_PendingOrder_NeverAttemptsDcv_EvenWithDcvEnabled() + { + const string pkiOrderId = "ord_pki_dcv_001"; + var mock = NewMock(); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = pkiOrderId, Status = "pending-csr" }); + mock.Setup(c => c.SubmitCsrV2Async( + Constants.ApiV2.FamilyPrivatePki, pkiOrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + // Even a (spec-impossible) pending-dcv status, with a domain block, must not trigger DCV. + mock.Setup(c => c.TrackOrderV2Async(Constants.ApiV2.FamilyPrivatePki, pkiOrderId, It.IsAny())) + .ReturnsAsync(StatusWithDomains("pending-dcv", DomainEntry("intranet.acme.local", "PENDING"))); + SetupDcvCallsSoStrictMockDoesNotThrow(mock); + + var validator = new FakeDomainValidator(); + var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=intranet.acme.local", + san: new Dictionary { ["dnsname"] = new[] { "intranet.acme.local" } }, + productInfo: new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(System.StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = "149", + ["ProductFamily"] = "private-pki", + ["ProductVariant"] = "intranet-ssl" + } + }, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + result.CARequestID.Should().Be(pkiOrderId); + VerifyNoDcvCalls(mock, validator); + } + + [Fact] + public async Task GetSingleRecord_V2_PrivatePkiOrder_PendingWithDomain_NeverAttemptsDcv() + { + const string pkiOrderId = "ord_pki_dcv_002"; + var mock = NewMock(); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync(pkiOrderId, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilyPrivatePki, new V2OrderStatusResponse + { + OrderId = pkiOrderId, Status = "pending-dcv", Domain = "intranet.acme.local" + })); + SetupDcvCallsSoStrictMockDoesNotThrow(mock); + + var validator = new FakeDomainValidator(); + var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + var record = await plugin.GetSingleRecord(pkiOrderId); + + record.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + VerifyNoDcvCalls(mock, validator); + } + + [Fact] + public async Task SynchronizeV2_PendingPrivatePkiOrder_ResolvedToPrivatePkiFamily_NeverAttemptsDcv() + { + // A private-pki order in pending-approval surfaces in /reports/orders exactly like a + // pending DV order ("Order Accepted" / "Pending for Approver", with a domainName) — so + // pre-0033 sync resolved its family and then called /private-pki-certificates/{id}/dcv. + var mock = NewMock(); + var row = PendingDcvRow("ord_pki_sync_001", DateTime.UtcNow.AddMinutes(-10)); + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync("ord_pki_sync_001", It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilyPrivatePki, new V2OrderStatusResponse + { + OrderId = "ord_pki_sync_001", Status = "pending-approval" + })); + SetupDcvCallsSoStrictMockDoesNotThrow(mock); + + var validator = new FakeDomainValidator(); + var config = new CERTInextConfig + { + UseV2Api = true, ApiUrl = "https://v2.certinext.io", + OAuthClientId = "c", OAuthClientSecret = "s", + DcvEnabled = true, DcvTimeoutMinutes = 1, DcvPropagationDelaySeconds = 1, + DcvSyncMaxOrderAgeHours = 0, DcvSyncMaxPerPass = 0 + }; + var plugin = new CERTInextCAPlugin(mock.Object, new FakeDomainValidatorFactory(validator), config); + + var buffer = new BlockingCollection(100); + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + buffer.ToArray().Should().ContainSingle(r => r.CARequestID == "ord_pki_sync_001" + && r.Status == (int)EndEntityStatus.EXTERNALVALIDATION); + VerifyNoDcvCalls(mock, validator); + } + + // --------------------------------------------------------------------------- + // Wildcard domains — TXT hostname must be derived from the BASE domain + // --------------------------------------------------------------------------- + // + // Live evidence (sandbox, 2026-10-01): a wildcard V2 DV order's TXT host was staged as + // "_emsign-validation.*.dcv-fresh-...scrup.org" — a literal '*' DNS label, which is + // not queryable and left the order stuck pending. CERTInext's own GetDcvV2/VerifyDcvV2/ + // TrackOrderV2 calls must still use the original "*."-prefixed domain string; only the + // DNS-side hostname/zone resolution uses the base domain. + + [Fact] + public async Task PerformDcvV2_WildcardSingleDomain_StagesBaseDomainHostname_ButCallsCaWithWildcardDomain() + { + const string baseName = "example.com"; + string wildcard = "*." + baseName; + + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(PlaceOrderResponse()); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "pending-dcv", Domain = wildcard }) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "issued", Domain = wildcard }) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "issued", Domain = wildcard }); + + mock.Setup(c => c.GetDcvV2Async(OrderId, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = "wildcard-token", TokenExpiryDate = "2026-12-31 23:59:59" }); + + mock.Setup(c => c.VerifyDcvV2Async(OrderId, wildcard, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }); + + mock.Setup(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(DownloadResponse()); + + var validator = new FakeDomainValidator(); + var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + var productInfo = new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = "842", + ["ProductFamily"] = "ssl", + ["ProductVariant"] = "dv", + ["DomainName"] = wildcard + } + }; + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: $"CN={wildcard}", + san: new Dictionary { ["dns"] = new[] { wildcard } }, + productInfo: productInfo, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + + // The TXT hostname must be built from the base domain, not the literal + // "*.example.com" — a "*" DNS label is not queryable. + validator.StagedRecords.Should().ContainSingle() + .Which.key.Should().Be($"_emsign-validation.{baseName}"); + validator.StagedRecords.Should().OnlyContain(r => !r.key.Contains('*'), + "a literal '*' DNS label can never be queried by the CA"); + + // CERTInext's own API must still see the original wildcard domain string. + mock.Verify(c => c.VerifyDcvV2Async(OrderId, wildcard, It.IsAny(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task PerformDcvV2_Ucc_ApexAndWildcardShareHostname_StagesOnceAndCleansUpOnce_ButVerifiesBothWithCa() + { + const string apex = "example.com"; + string wildcard = "*." + apex; + + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(PlaceOrderResponse()); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // 1st call (post-CSR check): both the apex and its wildcard are pending. Every later + // call (WaitForDomainsVerifiedV2Async's poll, and EnrollV2Async's post-DCV re-check) + // sees the order fully issued with both VERIFIED. + int trackCalls = 0; + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(() => + { + trackCalls++; + return trackCalls == 1 + ? StatusWithDomains("pending-dcv", + DomainEntry(apex, "PENDING"), + DomainEntry(wildcard, "PENDING")) + : StatusWithDomains("issued", + DomainEntry(apex, "VERIFIED", "dns-txt"), + DomainEntry(wildcard, "VERIFIED", "dns-txt")); + }); + + mock.Setup(c => c.GetDcvV2Async(OrderId, apex, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = "token-shared", TokenExpiryDate = "2026-12-31 23:59:59" }); + mock.Setup(c => c.GetDcvV2Async(OrderId, wildcard, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = "token-shared", TokenExpiryDate = "2026-12-31 23:59:59" }); + + mock.Setup(c => c.VerifyDcvV2Async(OrderId, apex, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }); + mock.Setup(c => c.VerifyDcvV2Async(OrderId, wildcard, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }); + + mock.Setup(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(DownloadResponse()); + + var validator = new FakeDomainValidator(); + var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + + // The apex and its wildcard collapse to the same base-domain TXT hostname AND carry the + // same token — exactly ONE record must be staged (and cleaned up), not two. + validator.StagedRecords.Should().ContainSingle( + "the apex and wildcard domains share one base-domain TXT hostname") + .Which.key.Should().Be($"_emsign-validation.{apex}"); + validator.CleanedUpKeys.Should().ContainSingle( + "the shared hostname must be cleaned up exactly once, not once per domain that used it") + .Which.Should().Be($"_emsign-validation.{apex}"); + + // CERTInext tracks DCV per domain entry, so both the apex and the wildcard still need + // their own CA-side GetDcv/VerifyDcv call even though they share one TXT record. + mock.Verify(c => c.GetDcvV2Async(OrderId, apex, It.IsAny(), It.IsAny()), Times.Once); + mock.Verify(c => c.GetDcvV2Async(OrderId, wildcard, It.IsAny(), It.IsAny()), Times.Once); + mock.Verify(c => c.VerifyDcvV2Async(OrderId, apex, It.IsAny(), It.IsAny()), Times.Once); + mock.Verify(c => c.VerifyDcvV2Async(OrderId, wildcard, It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task PerformDcvV2_Ucc_ApexAndWildcardShareHostnameButDifferentTokens_StagesBothAndCleansUpBoth() + { + const string apex = "example.com"; + string wildcard = "*." + apex; + + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(PlaceOrderResponse()); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + int trackCalls = 0; + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(() => + { + trackCalls++; + return trackCalls == 1 + ? StatusWithDomains("pending-dcv", + DomainEntry(apex, "PENDING"), + DomainEntry(wildcard, "PENDING")) + : StatusWithDomains("issued", + DomainEntry(apex, "VERIFIED", "dns-txt"), + DomainEntry(wildcard, "VERIFIED", "dns-txt")); + }); + + mock.Setup(c => c.GetDcvV2Async(OrderId, apex, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = "token-apex", TokenExpiryDate = "2026-12-31 23:59:59" }); + mock.Setup(c => c.GetDcvV2Async(OrderId, wildcard, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvChallengeResponse { Token = "token-wildcard", TokenExpiryDate = "2026-12-31 23:59:59" }); + + mock.Setup(c => c.VerifyDcvV2Async(OrderId, apex, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }); + mock.Setup(c => c.VerifyDcvV2Async(OrderId, wildcard, It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }); + + mock.Setup(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(DownloadResponse()); + + var validator = new FakeDomainValidator(); + var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + + // Same base-domain hostname but two different CA tokens: a single record cannot satisfy + // both, so BOTH values must be staged at that hostname and both cleaned up. + string hostname = $"_emsign-validation.{apex}"; + validator.StagedRecords.Should().HaveCount(2); + validator.StagedRecords.Select(r => r.key).Should().OnlyContain(k => k == hostname); + validator.StagedRecords.Select(r => r.value).Should().BeEquivalentTo(new[] { "token-apex", "token-wildcard" }); + validator.CleanedUpKeys.Should().HaveCount(2); + validator.CleanedUpKeys.Should().OnlyContain(k => k == hostname); + + mock.Verify(c => c.VerifyDcvV2Async(OrderId, apex, It.IsAny(), It.IsAny()), Times.Once); + mock.Verify(c => c.VerifyDcvV2Async(OrderId, wildcard, It.IsAny(), It.IsAny()), Times.Once); + } + } +} diff --git a/CERTInext.Tests/CERTInextCAPluginV2EnrollRevokedTests.cs b/CERTInext.Tests/CERTInextCAPluginV2EnrollRevokedTests.cs new file mode 100644 index 0000000..cdb8174 --- /dev/null +++ b/CERTInext.Tests/CERTInextCAPluginV2EnrollRevokedTests.cs @@ -0,0 +1,251 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Regression coverage for issue 0052: at enroll time the gateway never holds a stored + /// certificate body for a brand-new V2 order, so a REVOKED disposition surfaced from + /// EnrollV2Async — whether observed on the post-CSR-submit status check or during the + /// synchronous pickup poll (PickUpEnrolledCertificateV2Async) — is always body-less. + /// Before the fix, that flowed straight through to 's + /// caller as Status=REVOKED, Certificate=null, which is exactly the shape that poisons + /// the gateway per issue 0049 (RevocationDate never clears; every later revoked-certificate + /// search calls FromDER(null) and 500s). These tests drive the fix end-to-end through + /// (V2 path), mirroring + /// CERTInextCAPluginV2PickupTests's mocking patterns. The DCV-gated variant of this + /// scenario (REVOKED observed on the post-DCV status re-check) lives in + /// CERTInextCAPluginV2DcvTests since it needs a domain validator factory and only + /// compiles when SUPPORTS_DCV is defined. + /// + public class CERTInextCAPluginV2EnrollRevokedTests + { + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + private static CERTInextCAPlugin BuildV2PluginWithPickup( + ICERTInextClient client, int retries, int delaySeconds = 1) => + new CERTInextCAPlugin(client, new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + AccountNumber = "12345", + AuthMode = "AccessKey", + ApiKey = "v1-key", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + PickupRetries = retries, + PickupDelayInSeconds = delaySeconds + }); + + private static EnrollmentProductInfo MakeV2ProductInfo() => + new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = "842", + ["ProductFamily"] = "ssl", + ["ProductVariant"] = "dv", + ["DomainName"] = "example.com" + } + }; + + /// + /// Every V2 enrollment resolves the requested product's productTypeID from the + /// live Catalog to decide UCC-ness — any Strict-mock enroll test must stub this call + /// regardless of whether the test cares about UCC behavior (mirrors StubCatalog in + /// CERTInextCAPluginV2PickupTests.cs). + /// + private static void StubCatalog(Mock mock) => + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } // non-UCC + }); + + private const string OrderId = "ord_0052_revoked_001"; + + private static Task Enroll(CERTInextCAPlugin plugin) => + plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=example.com", + san: new Dictionary(), + productInfo: MakeV2ProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + private static void StubPlaceAndSubmit(Mock mock, string initialStatus) => + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = OrderId, Status = initialStatus }); + + // --------------------------------------------------------------------------- + // REVOKED on the status check immediately after CSR submit -> FAILED + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_RevokedImmediatelyAfterCsrSubmit_ReturnsFailed_NotBodylessRevoked() + { + var mock = NewMock(); + StubCatalog(mock); + StubPlaceAndSubmit(mock, "pending-csr"); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // The very first status check after CSR submission already reports the order as + // revoked — there was never a chance for a certificate body to exist. + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "revoked" }); + + var plugin = BuildV2PluginWithPickup(mock.Object, retries: 3); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.FAILED, + "a REVOKED order observed right after CSR submission has no certificate body and " + + "must never be reported as REVOKED (issue 0052)"); + result.Certificate.Should().BeNull(); + result.CARequestID.Should().Be(OrderId); + result.StatusMessage.Should().Contain(OrderId).And.Contain("revoked"); + + // A terminal (non-EXTERNALVALIDATION) disposition must never enter the pickup poll. + mock.Verify(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny()), + Times.Once, "a terminal REVOKED disposition observed pre-poll must not trigger the pickup poll"); + mock.Verify(c => c.DownloadCertificateV2Async( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // REVOKED discovered during the synchronous pickup poll -> FAILED + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_RevokedDuringPickupPoll_ReturnsFailed_NotBodylessRevoked() + { + var mock = NewMock(); + StubCatalog(mock); + StubPlaceAndSubmit(mock, "pending-csr"); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Post-CSR check is still pending; the first (and only) poll attempt observes the + // order was revoked at the CA before ever issuing. + mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "pending-dcv" }) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "revoked" }); + + var plugin = BuildV2PluginWithPickup(mock.Object, retries: 3); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.FAILED, + "a REVOKED disposition discovered mid-poll has no certificate body and must never " + + "be reported as REVOKED (issue 0052)"); + result.Certificate.Should().BeNull(); + result.CARequestID.Should().Be(OrderId); + result.StatusMessage.Should().Contain(OrderId).And.Contain("revoked"); + + mock.Verify(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny()), + Times.Exactly(2), "the poll must stop at the first terminal observation, not run all 3 retries"); + mock.Verify(c => c.DownloadCertificateV2Async( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // Regression: a genuinely FAILED disposition must pass through unchanged + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_FailedDuringPickupPoll_StaysFailed() + { + var mock = NewMock(); + StubCatalog(mock); + StubPlaceAndSubmit(mock, "pending-csr"); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "pending-dcv" }) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "rejected" }); + + var plugin = BuildV2PluginWithPickup(mock.Object, retries: 3); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.FAILED, + "a genuinely FAILED disposition must pass through the issue-0052 REVOKED->FAILED " + + "normalization unchanged"); + result.CARequestID.Should().Be(OrderId); + } + + // --------------------------------------------------------------------------- + // Regression: a genuinely issued certificate must pass through unchanged + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_IssuedDuringPickupPoll_StaysGenerated_WithCertificate() + { + var mock = NewMock(); + StubCatalog(mock); + StubPlaceAndSubmit(mock, "pending-csr"); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "pending-dcv" }) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "issued" }); + + mock.Setup(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = OrderId, + SerialNumber = "AABBCC", + CertificatePem = MockCertificateData.FakePemCertificate + }); + + var plugin = BuildV2PluginWithPickup(mock.Object, retries: 2); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED, + "a genuinely issued certificate must pass through the issue-0052 REVOKED->FAILED " + + "normalization unchanged"); + result.Certificate.Should().StartWith("-----BEGIN CERTIFICATE-----"); + result.CARequestID.Should().Be(OrderId); + } + } +} diff --git a/CERTInext.Tests/CERTInextCAPluginV2PickupTests.cs b/CERTInext.Tests/CERTInextCAPluginV2PickupTests.cs new file mode 100644 index 0000000..bdfeb99 --- /dev/null +++ b/CERTInext.Tests/CERTInextCAPluginV2PickupTests.cs @@ -0,0 +1,282 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Regression coverage for issue 0051: V2 enrollment had no synchronous certificate-pickup + /// poll analogous to PickUpEnrolledCertificateAsync (V1). These tests exercise + /// PickUpEnrolledCertificateV2Async end-to-end through + /// (V2 path), the same way CERTInextCAPluginTests's "Synchronous certificate pickup" + /// section exercises the V1 method. No domain validator factory is configured here, so the + /// inline DCV block (when compiled) short-circuits immediately (factory null) and never sets + /// dcvV2Ran — DCV-vs-pickup interaction is covered separately in + /// CERTInextCAPluginV2DcvTests. + /// + public class CERTInextCAPluginV2PickupTests + { + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + // PickupDelay is clamped to a 1s floor and the loop adds a fixed 5s initial delay + // (Constants.Pickup.InitialDelaySeconds), so these tests are intentionally a few + // seconds each — mirrors BuildPluginWithPickup in CERTInextCAPluginTests.cs. + private static CERTInextCAPlugin BuildV2PluginWithPickup( + ICERTInextClient client, int retries, int delaySeconds = 1) => + new CERTInextCAPlugin(client, new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + AccountNumber = "12345", + AuthMode = "AccessKey", + ApiKey = "v1-key", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + PickupRetries = retries, + PickupDelayInSeconds = delaySeconds + }); + + private static EnrollmentProductInfo MakeV2ProductInfo() => + new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = "842", + ["ProductFamily"] = "ssl", + ["ProductVariant"] = "dv", + ["DomainName"] = "example.com" + } + }; + + /// + /// Every V2 enrollment resolves the requested product's productTypeID from the + /// live Catalog to decide UCC-ness — any Strict-mock enroll test must stub this call + /// regardless of whether the test cares about UCC behavior (mirrors StubCatalog in + /// CERTInextCAPluginV2Tests.cs). + /// + private static void StubCatalog(Mock mock) => + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } // non-UCC + }); + + private const string OrderId = "ord_pickup_001"; + + private static Task Enroll(CERTInextCAPlugin plugin) => + plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=example.com", + san: new Dictionary(), + productInfo: MakeV2ProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + private static void StubPlaceAndSubmit(Mock mock, string initialStatus) => + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = OrderId, Status = initialStatus }); + + // --------------------------------------------------------------------------- + // pending -> issued on a later poll -> GENERATED + chain + // --------------------------------------------------------------------------- + + [Fact] + public async Task Pickup_ReturnsIssuedCert_WhenOrderIssuesDuringLaterPoll() + { + var mock = NewMock(); + StubCatalog(mock); + StubPlaceAndSubmit(mock, "pending-csr"); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // 1st call: post-CSR check (still pending). 2nd: 1st poll attempt (still pending). + // 3rd: 2nd poll attempt — the order has now issued. + mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "pending-dcv" }) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "pending-dcv" }) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "issued" }); + + mock.Setup(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = OrderId, + SerialNumber = "AABBCC", + CertificatePem = MockCertificateData.FakePemCertificate + }); + + var plugin = BuildV2PluginWithPickup(mock.Object, retries: 2); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + result.CARequestID.Should().Be(OrderId); + result.Certificate.Should().StartWith("-----BEGIN CERTIFICATE-----"); + mock.Verify(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny()), + Times.Exactly(3)); + mock.Verify(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny()), + Times.Once); + } + + // --------------------------------------------------------------------------- + // issued but the first download fails -> retried -> GENERATED + // --------------------------------------------------------------------------- + + [Fact] + public async Task Pickup_RetriesDownload_WhenFirstDownloadAttemptFails() + { + var mock = NewMock(); + StubCatalog(mock); + StubPlaceAndSubmit(mock, "pending-csr"); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Post-CSR check is still pending, so EnrollV2Async's own immediate-download branch + // is never reached — both "issued" observations below come from the pickup poll. + mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "pending-dcv" }) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "issued" }) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "issued" }); + + mock.SetupSequence(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny())) + .ThrowsAsync(new Exception("simulated transient download failure")) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = OrderId, + SerialNumber = "AABBCC", + CertificatePem = MockCertificateData.FakePemCertificate + }); + + var plugin = BuildV2PluginWithPickup(mock.Object, retries: 2); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + result.Certificate.Should().StartWith("-----BEGIN CERTIFICATE-----"); + mock.Verify(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny()), + Times.Exactly(2), "the first download failure must not abort the poll"); + } + + // --------------------------------------------------------------------------- + // still pending at budget -> pending with CARequestID + // --------------------------------------------------------------------------- + + [Fact] + public async Task Pickup_ReturnsPendingWithCARequestID_WhenOrderNeverIssuesWithinBudget() + { + var mock = NewMock(); + StubCatalog(mock); + StubPlaceAndSubmit(mock, "pending-csr"); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Every poll (including the post-CSR check) still reports pending. + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "pending-dcv" }); + + var plugin = BuildV2PluginWithPickup(mock.Object, retries: 1); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + result.CARequestID.Should().Be(OrderId); + mock.Verify(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny()), + Times.AtLeast(2), "an enabled pickup must actually poll before giving up"); + mock.Verify(c => c.DownloadCertificateV2Async( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // PickupRetries=0 -> no polling + // --------------------------------------------------------------------------- + + [Fact] + public async Task Pickup_Disabled_WhenPickupRetriesZero_ReturnsPendingWithoutPolling() + { + var mock = NewMock(); + StubCatalog(mock); + StubPlaceAndSubmit(mock, "pending-csr"); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "pending-dcv" }); + + var plugin = BuildV2PluginWithPickup(mock.Object, retries: 0); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + result.CARequestID.Should().Be(OrderId); + // Only the single post-CSR status check should have run — no pickup polling at all. + mock.Verify(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny()), + Times.Once, "PickupRetries=0 must disable the V2 synchronous pickup poll"); + mock.Verify(c => c.DownloadCertificateV2Async( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // FAILED mid-poll -> returned as-is + // --------------------------------------------------------------------------- + + [Fact] + public async Task Pickup_SurfacesTerminalFailedStatus_WhenOrderRejectedDuringPoll() + { + var mock = NewMock(); + StubCatalog(mock); + StubPlaceAndSubmit(mock, "pending-csr"); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Post-CSR check is pending; the first (and only) poll attempt observes a terminal + // "rejected" status, which StatusMapper.V2StatusToRequestDisposition maps to FAILED. + mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "pending-dcv" }) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "rejected" }); + + var plugin = BuildV2PluginWithPickup(mock.Object, retries: 3); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.FAILED, + "a terminal status observed during pickup is surfaced immediately, not polled to exhaustion"); + result.CARequestID.Should().Be(OrderId); + mock.Verify(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny()), + Times.Exactly(2), "the poll must stop at the first terminal observation, not run all 3 retries"); + } + } +} diff --git a/CERTInext.Tests/CERTInextCAPluginV2Tests.cs b/CERTInext.Tests/CERTInextCAPluginV2Tests.cs new file mode 100644 index 0000000..6ff3a96 --- /dev/null +++ b/CERTInext.Tests/CERTInextCAPluginV2Tests.cs @@ -0,0 +1,2433 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.Extensions.CAPlugin.CERTInext; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Moq-based unit tests that verify V2 dispatch in . + /// All V2 client methods are mocked — no network calls are made. + /// + public class CERTInextCAPluginV2Tests + { + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + private static CERTInextCAPlugin BuildV2Plugin( + ICERTInextClient client, + bool ignoreExpired = false, + string dcvTxtRecordTemplate = null, + string requestorIsdCode = null, + string requestorMobileNumber = null, + ICertificateDataReader certDataReader = null, + string organizationNumber = null, + string defaultProductCode = null) => + new CERTInextCAPlugin(client, new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + AccountNumber = "12345", + AuthMode = "AccessKey", + ApiKey = "v1-key", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + OrganizationNumber = organizationNumber, + DefaultProductCode = defaultProductCode, + PickupRetries = 0, + IgnoreExpired = ignoreExpired, + DcvTxtRecordTemplate = dcvTxtRecordTemplate, + RequestorIsdCode = requestorIsdCode, + RequestorMobileNumber = requestorMobileNumber + }, certDataReader); + + /// + /// Issue 0049: a mock whose + /// returns a date, + /// simulating a gateway row that already holds a certificate body. Tests that exercise + /// revocation-detail/ProductId logic on a REVOKED-with-no-body record use this so the + /// bodyless-REVOKED guard (, + /// exercised directly in Issue0049BodylessRevokedGuardTests) lets the record + /// through unchanged, keeping these tests focused on their own concern. + /// + private static ICertificateDataReader GatewayHoldsBodyReader(DateTime? expiry = null) + { + var mock = new Mock(); + mock.Setup(r => r.GetExpirationDateByRequestId(It.IsAny())) + .Returns(expiry ?? DateTime.UtcNow.AddDays(30)); + return mock.Object; + } + + private static EnrollmentProductInfo MakeV2ProductInfo( + string productCode = "842", + string productFamily = "ssl", + string productVariant = "dv", + string domainName = "example.com") + { + return new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(System.StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = productCode, + ["ProductFamily"] = productFamily, + ["ProductVariant"] = productVariant, + ["DomainName"] = domainName + } + }; + } + + /// + /// Stubs — every V2 enrollment now + /// resolves the requested product's productTypeID from the live Catalog to decide + /// UCC-ness (issues/f3-v2-multi-san-limitation.md), so any Strict-mock enroll test must + /// stub this call regardless of whether the test cares about UCC behavior. + /// + private static void StubCatalog(Mock mock, string productCode, string productTypeId) => + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = productCode, ProductTypeId = productTypeId, Active = true } + }); + + // --------------------------------------------------------------------------- + // Ping routes to V2 + // --------------------------------------------------------------------------- + + [Fact] + public async Task Ping_V2Enabled_CallsPingV2Async() + { + var mock = NewMock(); + mock.Setup(c => c.PingV2Async(It.IsAny())) + .Returns(Task.CompletedTask); + + var plugin = BuildV2Plugin(mock.Object); + await plugin.Ping(); + + mock.Verify(c => c.PingV2Async(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Ping_V2Enabled_DoesNotCallV1Ping() + { + var mock = new Mock(); // Loose — verifying absence + mock.Setup(c => c.PingV2Async(It.IsAny())) + .Returns(Task.CompletedTask); + + var plugin = BuildV2Plugin(mock.Object); + await plugin.Ping(); + + mock.Verify(c => c.PingAsync(It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // Enroll routes to V2 + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_V2Enabled_PlacesV2Order_PendingResult() + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // non-UCC (DV SSL) + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2CreateOrderResponse + { + OrderId = MockCertificateData.V2OrderId1, + RequestId = "req_001", + Status = "pending-dcv" + }); + + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), MockCertificateData.V2OrderId1, + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async( + It.IsAny(), MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = MockCertificateData.V2OrderId1, Status = "pending-dcv" }); + + var plugin = BuildV2Plugin(mock.Object); + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, + "CN=example.com, O=Acme", + new Dictionary(), + MakeV2ProductInfo(), + RequestFormat.PKCS10, + EnrollmentType.New); + + result.CARequestID.Should().Be(MockCertificateData.V2OrderId1); + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + } + + [Fact] + public async Task Enroll_V2Enabled_IssuedImmediately_DownloadsCert() + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // non-UCC (DV SSL) + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2CreateOrderResponse + { + OrderId = MockCertificateData.V2OrderId1, + Status = "issued" + }); + + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), MockCertificateData.V2OrderId1, + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async( + It.IsAny(), MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = MockCertificateData.V2OrderId1, Status = "issued" }); + + mock.Setup(c => c.DownloadCertificateV2Async( + It.IsAny(), MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = MockCertificateData.V2OrderId1, + SerialNumber = "AABBCC", + CertificatePem = MockCertificateData.FakePemCertificate + }); + + var plugin = BuildV2Plugin(mock.Object); + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, + "CN=example.com, O=Acme", + new Dictionary(), + MakeV2ProductInfo(), + RequestFormat.PKCS10, + EnrollmentType.New); + + result.CARequestID.Should().Be(MockCertificateData.V2OrderId1); + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + result.Certificate.Should().StartWith("-----BEGIN CERTIFICATE-----"); + } + + // By design (0021): V2 has no distinct renewal endpoint the plugin uses — CERTInext's + // `/reissue` endpoint exists but is intentionally not called. RenewOrReissue places a + // brand-new order via the same PlaceOrderV2Async path as a fresh enrollment; the prior + // order/certificate is left issued rather than revoked or reused. + [Fact] + public async Task Enroll_V2Enabled_RenewOrReissue_PlacesNewOrderByDesign() + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // non-UCC (DV SSL) + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2CreateOrderResponse + { + OrderId = MockCertificateData.V2OrderId2, + Status = "pending-csr" + }); + + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), MockCertificateData.V2OrderId2, + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async( + It.IsAny(), MockCertificateData.V2OrderId2, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = MockCertificateData.V2OrderId2, Status = "pending-validation" }); + + var plugin = BuildV2Plugin(mock.Object); + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, + "CN=example.com, O=Acme", + new Dictionary(), + MakeV2ProductInfo(), + RequestFormat.PKCS10, + EnrollmentType.RenewOrReissue); + + result.CARequestID.Should().Be(MockCertificateData.V2OrderId2); + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Once); + } + + // --------------------------------------------------------------------------- + // V2 product code resolution when no explicit ProductCode is configured (issue 0036). + // + // Issue 0059 update: these two tests used to leave ProductVariant at its "dv" default + // even though ProductID selects OV/EV SSL, specifically to keep the OV/EV + // organization-block guard (issue 0028) out of scope. Post-0059, ProductVariant is no + // longer independent of ProductID — the plugin now derives/validates it from the product + // — so an OV/EV ProductID with no explicit ProductVariant now legitimately resolves to + // "ov"/"ev" and requires the organization block. These tests now configure + // OrganizationNumber (so that guard is satisfied) and leave ProductVariant unset + // entirely, so the derived value continues to isolate product-code resolution as before. + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_V2_NoExplicitProductCode_ResolvesLiveCodeFromCatalog_NotStaleV1Table() + { + // ProductID "OV SSL" with NO ProductCode/ProfileId override. Pre-fix, this would have + // fallen back to Constants.Products.DefaultProductCodes["OV SSL"] = "842" and sent that + // on the wire — which the live catalog (per this stub) actually maps to DV SSL, not OV + // SSL (issue 0036's silent-misissuance scenario). Post-fix, the code must be resolved + // from the catalog entry whose productTypeID matches OV SSL ("16") — "846" in this + // stub — a different value than the stale table's "842". + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true }, // DV SSL + new ProductDetail { ProductCode = "846", ProductTypeId = "16", Active = true }, // OV SSL + }); + + string capturedProductCode = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback( + (_, code, __, ___) => capturedProductCode = code) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_resolve_001", Status = "pending-dcv" }); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), "ord_resolve_001", It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), "ord_resolve_001", It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = "ord_resolve_001", Status = "pending-dcv" }); + + var plugin = BuildV2Plugin(mock.Object, organizationNumber: "ORG-TEST-001"); + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.OvSsl, + ProductParameters = new Dictionary + { + ["ProductFamily"] = "ssl", + // No explicit ProductVariant — issue 0059 derives "ov" from ProductID. + ["DomainName"] = "example.com" + } + }; + + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, + "CN=example.com, O=Acme", + new Dictionary(), + productInfo, + RequestFormat.PKCS10, + EnrollmentType.New); + + result.CARequestID.Should().Be("ord_resolve_001"); + capturedProductCode.Should().Be("846", + "the resolved code must come from the catalog's OV SSL entry (productTypeID 16), not " + + "Constants.Products.DefaultProductCodes[\"OV SSL\"] (\"842\"), which the live catalog in " + + "this stub actually maps to DV SSL"); + mock.Verify(c => c.GetProductDetailsV2Async(It.IsAny()), Times.Once, + "the catalog fetched for code resolution must be the same call reused for UCC detection, not a second fetch"); + } + + [Fact] + public async Task Enroll_V2_NoExplicitProductCode_NoMatchingCatalogEntry_FailsFastInsteadOfSilentlyProceeding() + { + // No override configured, and the live catalog (stubbed here) has no entry with the + // productTypeID expected for EV SSL ("19") — must fail loudly with a clear message + // rather than silently falling back to a wrong/stale code or ordering an unintended + // product. + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true }, // DV SSL only + }); + + var plugin = BuildV2Plugin(mock.Object, organizationNumber: "ORG-TEST-001"); + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.EvSsl, + ProductParameters = new Dictionary + { + ["ProductFamily"] = "ssl", + // No explicit ProductVariant — issue 0059 derives "ev" from ProductID. + ["DomainName"] = "example.com" + } + }; + + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, + "CN=example.com, O=Acme", + new Dictionary(), + productInfo, + RequestFormat.PKCS10, + EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.StatusMessage.Should().Contain("could not resolve a live product code"); + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // Product-selection ambiguity (sandbox-confirmed): the live catalog can carry MORE + // THAN ONE entry with the same productTypeID — e.g. two type-13 DV SSL entries, "917 + // SSL DV 1 month" (listed first) and "842 DV SSL Certificate". Picking the first match + // (the old behavior) silently ordered "917" on sandbox, which PUT /csr then rejected + // 422 "PFX based certificate orders are not allowed" — failing every ProductId-only DV + // SSL enrollment. No explicit ProductCode + multiple matches must only resolve via the + // connector's DefaultProductCode, or reject naming the candidates. + // --------------------------------------------------------------------------- + + private static Mock StubAmbiguousDvCatalog() + { + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "917", ProductName = "SSL DV 1 month", ProductTypeId = "13", Active = true }, + new ProductDetail { ProductCode = "842", ProductName = "DV SSL Certificate", ProductTypeId = "13", Active = true }, + }); + return mock; + } + + private static EnrollmentProductInfo MakeDvProductInfoNoExplicitCode() => new EnrollmentProductInfo + { + ProductID = Constants.Products.DvSsl, + ProductParameters = new Dictionary + { + ["ProductFamily"] = "ssl", + ["DomainName"] = "example.com" + } + }; + + [Fact] + public async Task Enroll_V2_NoExplicitProductCode_MultipleCatalogMatches_NoDefaultProductCode_RejectsWithCandidates() + { + var mock = StubAmbiguousDvCatalog(); + var plugin = BuildV2Plugin(mock.Object); // no DefaultProductCode configured + + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, + "CN=example.com, O=Acme", + new Dictionary(), + MakeDvProductInfoNoExplicitCode(), + RequestFormat.PKCS10, + EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.StatusMessage.Should().ContainEquivalentOf("multiple CERTInext catalog products match"); + result.StatusMessage.Should().Contain("917").And.Contain("842"); + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never, + "an ambiguous product match must never reach order placement — no silent first-pick"); + } + + [Fact] + public async Task Enroll_V2_NoExplicitProductCode_MultipleCatalogMatches_DefaultProductCodeMatchesOne_Resolves() + { + var mock = StubAmbiguousDvCatalog(); + string capturedProductCode = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback( + (_, code, __, ___) => capturedProductCode = code) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_amb_001", Status = "pending-dcv" }); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), "ord_amb_001", It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), "ord_amb_001", It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = "ord_amb_001", Status = "pending-dcv" }); + + var plugin = BuildV2Plugin(mock.Object, defaultProductCode: "842"); + + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, + "CN=example.com, O=Acme", + new Dictionary(), + MakeDvProductInfoNoExplicitCode(), + RequestFormat.PKCS10, + EnrollmentType.New); + + result.CARequestID.Should().Be("ord_amb_001"); + capturedProductCode.Should().Be("842", + "DefaultProductCode names one of the ambiguous matches, so it must be used instead of rejecting"); + } + + [Fact] + public async Task Enroll_V2_NoExplicitProductCode_MultipleCatalogMatches_DefaultProductCodeIsWrongType_RejectsWithCandidates() + { + // DefaultProductCode is configured, but it names a product of a DIFFERENT + // productTypeID than the one being resolved — must not be treated as a match. + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "917", ProductName = "SSL DV 1 month", ProductTypeId = "13", Active = true }, + new ProductDetail { ProductCode = "842", ProductName = "DV SSL Certificate", ProductTypeId = "13", Active = true }, + new ProductDetail { ProductCode = "846", ProductName = "OV SSL Certificate", ProductTypeId = "16", Active = true }, + }); + var plugin = BuildV2Plugin(mock.Object, defaultProductCode: "846"); // OV, not DV + + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, + "CN=example.com, O=Acme", + new Dictionary(), + MakeDvProductInfoNoExplicitCode(), + RequestFormat.PKCS10, + EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.StatusMessage.Should().ContainEquivalentOf("multiple CERTInext catalog products match"); + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Enroll_V2_NoExplicitProductCode_CatalogFetchFailed_BehaviorUnchanged_RejectsAsNotFound() + { + // A catalog-fetch failure must still fail the same way it did before ambiguity + // handling was added — "could not resolve", not an ambiguity-shaped message. + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ThrowsAsync(new Exception("catalog unreachable")); + + var plugin = BuildV2Plugin(mock.Object); + + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, + "CN=example.com, O=Acme", + new Dictionary(), + MakeDvProductInfoNoExplicitCode(), + RequestFormat.PKCS10, + EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.StatusMessage.Should().Contain("could not resolve a live product code"); + result.StatusMessage.Should().NotContainEquivalentOf("multiple CERTInext catalog products match"); + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // GetSingleRecord routes to V2 + // --------------------------------------------------------------------------- + + [Fact] + public async Task GetSingleRecord_V2Enabled_UsesResolveAndTrack() + { + var mock = NewMock(); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = MockCertificateData.V2OrderId1, + Status = "issued", + ProductVariant = "dv" + })); + + mock.Setup(c => c.ResolveAndDownloadCertificateV2Async( + MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = MockCertificateData.V2OrderId1, + SerialNumber = "AABB", + CertificatePem = MockCertificateData.FakePemCertificate + }); + + var plugin = BuildV2Plugin(mock.Object); + var record = await plugin.GetSingleRecord(MockCertificateData.V2OrderId1); + + record.CARequestID.Should().Be(MockCertificateData.V2OrderId1); + record.Status.Should().Be((int)EndEntityStatus.GENERATED); + record.Certificate.Should().StartWith("-----BEGIN CERTIFICATE-----"); + + mock.Verify(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + MockCertificateData.V2OrderId1, It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetSingleRecord_V2Enabled_DoesNotCallV1GetCertificate() + { + var mock = new Mock(); // Loose + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse { OrderId = "ord_x", Status = "pending-dcv" })); + + var plugin = BuildV2Plugin(mock.Object); + await plugin.GetSingleRecord("ord_x"); + + mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // GetSingleRecord — RevocationDate/RevocationReason (issues/0034) + // --------------------------------------------------------------------------- + + [Fact] + public async Task GetSingleRecord_V2Enabled_Revoked_PopulatesRevocationDateAndReason() + { + var mock = NewMock(); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = MockCertificateData.V2OrderId1, + Status = "revoked", + ProductVariant = "dv", + Revocation = new V2RevocationDetails + { + Status = "Certificate Revoked", + Reason = "cessation-of-operation", + ProcessedAt = new DateTime(2026, 9, 24, 20, 44, 41, DateTimeKind.Utc) + } + })); + + // Issue 0049: this record has no certificate body, so the bodyless-REVOKED guard + // would otherwise downgrade it to FAILED — a reader that reports the gateway + // already holds a body keeps this test focused on revocation-detail population. + var plugin = BuildV2Plugin(mock.Object, certDataReader: GatewayHoldsBodyReader()); + var record = await plugin.GetSingleRecord(MockCertificateData.V2OrderId1); + + record.Status.Should().Be((int)EndEntityStatus.REVOKED); + record.RevocationDate.Should().Be(new DateTime(2026, 9, 24, 20, 44, 41, DateTimeKind.Utc)); + record.RevocationReason.Should().Be(5); // cessation-of-operation + } + + [Fact] + public async Task GetSingleRecord_V2Enabled_NotRevoked_RevocationFieldsDefault() + { + var mock = NewMock(); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = MockCertificateData.V2OrderId1, + Status = "issued", + ProductVariant = "dv" + })); + + mock.Setup(c => c.ResolveAndDownloadCertificateV2Async( + MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = MockCertificateData.V2OrderId1, + SerialNumber = "AABB", + CertificatePem = MockCertificateData.FakePemCertificate + }); + + var plugin = BuildV2Plugin(mock.Object); + var record = await plugin.GetSingleRecord(MockCertificateData.V2OrderId1); + + record.RevocationDate.Should().BeNull(); + record.RevocationReason.Should().Be(0); + } + + // --------------------------------------------------------------------------- + // Revoke routes to V2 + // --------------------------------------------------------------------------- + + [Fact] + public async Task Revoke_V2Enabled_ResolvesAndRevokes() + { + var mock = new Mock(); // Loose + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = MockCertificateData.V2OrderId1, + Status = "issued" + })); + + mock.Setup(c => c.RevokeOrderV2Async( + Constants.ApiV2.FamilySsl, MockCertificateData.V2OrderId1, + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + var plugin = BuildV2Plugin(mock.Object); + var status = await plugin.Revoke(MockCertificateData.V2OrderId1, "AABB", 4u); + + status.Should().Be((int)EndEntityStatus.REVOKED); + } + + [Fact] + public async Task Revoke_V2Enabled_DoesNotCallV1RevokeCertificate() + { + var mock = new Mock(); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse { Status = "issued", OrderId = "ord_x" })); + + mock.Setup(c => c.RevokeOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + var plugin = BuildV2Plugin(mock.Object); + await plugin.Revoke("ord_x", "AA", 1u); + + mock.Verify(c => c.RevokeCertificateAsync( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // Reason code fallback (issues/0026): CERTInext rejects 4 of the 9 spec-documented + // reason values (422 "Invalid Revoke Reason ID"), confirmed live — unspecified (CRL + // 0), ca-compromise (CRL 2), certificate-hold (CRL 6), aa-compromise (CRL 10). The + // plugin retries exactly once with an accepted fallback for each: cessation-of- + // operation for unspecified/certificate-hold, key-compromise for the two + // *-compromise reasons. A reason outside that known-rejected set is never retried. + // --------------------------------------------------------------------------- + + private static Mock SetupRevokeReasonRejectionMock(List seenReasons) + { + var mock = new Mock(); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = MockCertificateData.V2OrderId1, + Status = "issued" + })); + + mock.Setup(c => c.RevokeOrderV2Async( + Constants.ApiV2.FamilySsl, MockCertificateData.V2OrderId1, + It.IsAny(), It.IsAny())) + .Returns((string family, string orderId, V2RevokeRequest req, CancellationToken ct) => + { + seenReasons.Add(req.Reason); + bool firstAttemptRejected = + req.Reason == Constants.RevocationReasonV2.Unspecified || + req.Reason == Constants.RevocationReasonV2.CACompromise || + req.Reason == Constants.RevocationReasonV2.CertificateHold || + req.Reason == Constants.RevocationReasonV2.AACompromise; + if (firstAttemptRejected && seenReasons.Count == 1) + throw new InvalidOperationException("V2 revoke rejected. Unprocessable Entity: Invalid Revoke Reason ID"); + return Task.CompletedTask; + }); + return mock; + } + + [Theory] + // Command's default when no explicit reason is given. + [InlineData(0u, Constants.RevocationReasonV2.Unspecified, Constants.RevocationReasonV2.CessationOfOperation)] + [InlineData(2u, Constants.RevocationReasonV2.CACompromise, Constants.RevocationReasonV2.KeyCompromise)] + [InlineData(6u, Constants.RevocationReasonV2.CertificateHold, Constants.RevocationReasonV2.CessationOfOperation)] + [InlineData(10u, Constants.RevocationReasonV2.AACompromise, Constants.RevocationReasonV2.KeyCompromise)] + public async Task Revoke_V2Enabled_KnownRejectedReason_RetriesWithExpectedFallback( + uint crlReason, string expectedOriginal, string expectedFallback) + { + var seenReasons = new List(); + var mock = SetupRevokeReasonRejectionMock(seenReasons); + + var plugin = BuildV2Plugin(mock.Object); + var status = await plugin.Revoke(MockCertificateData.V2OrderId1, "AABB", crlReason); + + status.Should().Be((int)EndEntityStatus.REVOKED); + seenReasons.Should().Equal(expectedOriginal, expectedFallback); + } + + [Fact] + public async Task Revoke_V2Enabled_RejectedReasonNotInFallbackSet_DoesNotRetry() + { + var mock = new Mock(); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = MockCertificateData.V2OrderId1, + Status = "issued" + })); + + // CRL reason 4 (superseded) maps to "superseded", which issues/0026 confirms is + // actually accepted live — it is not in the known-rejected fallback set, so even + // if the CA somehow rejected it with the same "Invalid Revoke Reason ID" message, + // the plugin must surface the failure as-is rather than retry. + mock.Setup(c => c.RevokeOrderV2Async( + Constants.ApiV2.FamilySsl, MockCertificateData.V2OrderId1, + It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("V2 revoke rejected. Unprocessable Entity: Invalid Revoke Reason ID")); + + var plugin = BuildV2Plugin(mock.Object); + var ex = await Assert.ThrowsAsync( + () => plugin.Revoke(MockCertificateData.V2OrderId1, "AABB", 4u)); + + ex.Message.Should().Contain("Invalid Revoke Reason ID"); + mock.Verify(c => c.RevokeOrderV2Async( + Constants.ApiV2.FamilySsl, MockCertificateData.V2OrderId1, + It.IsAny(), It.IsAny()), Times.Once); + } + + // --------------------------------------------------------------------------- + // Regression (issues/0019): revoke 404 after the family is already resolved + // must not be reported as a family miss. + // --------------------------------------------------------------------------- + + [Fact] + public async Task Revoke_V2Enabled_RevokeReturns404AfterFamilyResolved_ReportsNotRevokable_NotFamilyMiss() + { + var mock = new Mock(); // Loose + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = MockCertificateData.V2OrderId1, + Status = "issued" + })); + + // Order is confirmed to live in the SSL family (TrackOrder above succeeded), + // but the revoke call itself 404s — per spec that means "not revokable", + // not "wrong family". The plugin must not retry other families for it. + mock.Setup(c => c.RevokeOrderV2Async( + Constants.ApiV2.FamilySsl, MockCertificateData.V2OrderId1, + It.IsAny(), It.IsAny())) + .ThrowsAsync(new KeyNotFoundException( + $"V2 order '{MockCertificateData.V2OrderId1}' in family '{Constants.ApiV2.FamilySsl}' " + + "not found or not in a revokable state.")); + + var plugin = BuildV2Plugin(mock.Object); + var ex = await Assert.ThrowsAsync( + () => plugin.Revoke(MockCertificateData.V2OrderId1, "AABB", 4u)); + + ex.Message.Should().Contain("not found or not in a revokable state"); + ex.Message.Should().NotContain("any product family", + "a 404 after the family was already resolved must not be mislabeled as a family miss"); + + // Must not have probed the other two families. + mock.Verify(c => c.RevokeOrderV2Async( + Constants.ApiV2.FamilyPrivatePki, It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + mock.Verify(c => c.RevokeOrderV2Async( + Constants.ApiV2.FamilySignature, It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // Synchronize (V2, issues/0022) — uses V2 /reports/orders, not V1 GetOrderReport. + // --------------------------------------------------------------------------- + + private static OrderReportEntryV2 ReportRow( + string orderNumber, string orderStatus, string certificateStatus, + string domainName = "example.com", string productCode = "842", + string certificateExpiryDate = null) => + new OrderReportEntryV2 + { + OrderNumber = orderNumber, + OrderStatus = orderStatus, + CertificateStatus = certificateStatus, + DomainName = domainName, + ProductCode = productCode, + OrderDate = System.DateTime.UtcNow.AddHours(-1).ToString("o"), + CertificateExpiryDate = certificateExpiryDate + }; + + [Fact] + public async Task Synchronize_V2Enabled_UsesListOrdersV2Async_NotV1ListCertificates() + { + var mock = new Mock(); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable()); + + var plugin = BuildV2Plugin(mock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + mock.Verify(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.AtLeastOnce); + mock.Verify(c => c.ListCertificatesAsync( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Synchronize_V2Enabled_IssuedRow_DownloadsCertificateBody() + { + var mock = new Mock(); + var row = ReportRow("ord_v2sync_001", "Order Fulfilled", "Certificate Downloaded"); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + mock.Setup(c => c.ResolveAndDownloadCertificateV2Async("ord_v2sync_001", It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = "ord_v2sync_001", + SerialNumber = "AABBCC", + CertificatePem = MockCertificateData.FakePemCertificate + }); + + var plugin = BuildV2Plugin(mock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().ContainSingle(); + records[0].CARequestID.Should().Be("ord_v2sync_001"); + records[0].Status.Should().Be((int)EndEntityStatus.GENERATED); + records[0].Certificate.Should().StartWith("-----BEGIN CERTIFICATE-----"); + records[0].ProductID.Should().Be("842"); + + // Recognised report-status strings must not need a live track fallback. + mock.Verify(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Synchronize_V2Enabled_UnrecognizedStatus_FallsBackToLiveTrack() + { + var mock = new Mock(); + // "Something New" is deliberately not in the known display-string vocabulary + // (issues/0022 — the vocabulary is not confirmed exhaustive). + var row = ReportRow("ord_v2sync_002", "Something New", "Also New"); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync("ord_v2sync_002", It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = "ord_v2sync_002", Status = "issued", Domain = "example.com" + })); + + mock.Setup(c => c.ResolveAndDownloadCertificateV2Async("ord_v2sync_002", It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = "ord_v2sync_002", + CertificatePem = MockCertificateData.FakePemCertificate + }); + + var plugin = BuildV2Plugin(mock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().ContainSingle(); + records[0].Status.Should().Be((int)EndEntityStatus.GENERATED, + "an unrecognised report status must fall back to the authoritative live track " + + "call rather than being dropped or guessed"); + + mock.Verify(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + "ord_v2sync_002", It.IsAny()), Times.Once); + } + + [Fact] + public async Task Synchronize_V2Enabled_RevokedRow_EmittedAsRevoked() + { + var mock = new Mock(); + var row = ReportRow("ord_v2sync_003", "Revoked", "Certificate Revoked"); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + // Issue 0049: this row has no certificate body, so the bodyless-REVOKED guard would + // otherwise downgrade/skip it — a reader that reports the gateway already holds a + // body keeps this test focused on "revoked rows never attempt a download". + var plugin = BuildV2Plugin(mock.Object, certDataReader: GatewayHoldsBodyReader()); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().ContainSingle(); + records[0].Status.Should().Be((int)EndEntityStatus.REVOKED); + + // Revoked rows have no body to download. + mock.Verify(c => c.ResolveAndDownloadCertificateV2Async( + It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // Synchronize — RevocationDate/RevocationReason (issues/0034). OrderReportEntryV2 + // carries no revocation reason/date of its own — only a live TrackOrder response's + // nested `revocation` object does, so these fields require a resolved + // V2OrderStatusResponse regardless of which code path got there. + // --------------------------------------------------------------------------- + + [Fact] + public async Task Synchronize_V2Enabled_RevokedRow_ViaDisplayString_PopulatesRevocationDetails() + { + var mock = new Mock(); + // "Revoked"/"Certificate Revoked" resolve via the report's own display-string + // vocabulary (TryMapV2ReportDisplayStatus) with no live track call — the revocation + // detail must be fetched lazily, on top of that, specifically for this row. + var row = ReportRow("ord_v2sync_004", "Revoked", "Certificate Revoked"); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync("ord_v2sync_004", It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = "ord_v2sync_004", + Status = "revoked", + Revocation = new V2RevocationDetails + { + Status = "Certificate Revoked", + Reason = "key-compromise", + ProcessedAt = new System.DateTime(2026, 9, 24, 20, 44, 41, System.DateTimeKind.Utc) + } + })); + + // Issue 0049: no certificate body on this row — a reader that reports the gateway + // already holds a body keeps this test focused on revocation-detail population + // rather than the bodyless-REVOKED guard (covered separately). + var plugin = BuildV2Plugin(mock.Object, certDataReader: GatewayHoldsBodyReader()); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().ContainSingle(); + records[0].Status.Should().Be((int)EndEntityStatus.REVOKED); + records[0].RevocationDate.Should().Be(new System.DateTime(2026, 9, 24, 20, 44, 41, System.DateTimeKind.Utc)); + records[0].RevocationReason.Should().Be(1); // key-compromise + + mock.Verify(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + "ord_v2sync_004", It.IsAny()), Times.Once); + } + + [Fact] + public async Task Synchronize_V2Enabled_RevokedRow_ViaUnresolvedFallback_PopulatesRevocationDetails() + { + var mock = new Mock(); + // Deliberately unrecognized display strings so disposition resolves via the + // unresolved-status fallback, which already performs a live TrackOrder call — + // trackedStatus (and its Revocation) is already populated before the + // revocation-specific lazy-fetch in Synchronize would otherwise need to run one. + var row = ReportRow("ord_v2sync_005", "Something New", "Also New"); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync("ord_v2sync_005", It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = "ord_v2sync_005", + Status = "revoked", + Revocation = new V2RevocationDetails + { + Status = "Certificate Revoked", + Reason = "superseded", + ProcessedAt = new System.DateTime(2026, 1, 2, 3, 4, 5, System.DateTimeKind.Utc) + } + })); + + // Issue 0049: no certificate body on this row — a reader that reports the gateway + // already holds a body keeps this test focused on revocation-detail population + // rather than the bodyless-REVOKED guard (covered separately). + var plugin = BuildV2Plugin(mock.Object, certDataReader: GatewayHoldsBodyReader()); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().ContainSingle(); + records[0].RevocationDate.Should().Be(new System.DateTime(2026, 1, 2, 3, 4, 5, System.DateTimeKind.Utc)); + records[0].RevocationReason.Should().Be(4); // superseded + + // Only the one fallback call — the revocation-specific lazy-fetch must not + // double-call when trackedStatus is already populated. + mock.Verify(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + "ord_v2sync_005", It.IsAny()), Times.Once); + } + + [Fact] + public async Task Synchronize_V2Enabled_NotRevokedRow_RevocationFieldsDefault() + { + var mock = new Mock(); + var row = ReportRow("ord_v2sync_006", "Order Fulfilled", "Certificate Downloaded"); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + mock.Setup(c => c.ResolveAndDownloadCertificateV2Async("ord_v2sync_006", It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = "ord_v2sync_006", + CertificatePem = MockCertificateData.FakePemCertificate + }); + + var plugin = BuildV2Plugin(mock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().ContainSingle(); + records[0].RevocationDate.Should().BeNull(); + records[0].RevocationReason.Should().Be(0); + + // Not revoked — must not incur the revocation-detail lazy-fetch at all. + mock.Verify(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // Synchronize — ProductID preference: report row's ProductCode vs. a lazily- + // fetched trackedStatus.ProductVariant (issues/0035). No new live call is added + // by this preference — it only reads whatever trackedStatus already exists in + // local scope from one of the three pre-existing lazy-fetch branches (unresolved- + // status fallback, DCV attempt, revoked-row lookup). + // --------------------------------------------------------------------------- + + [Fact] + public async Task Synchronize_V2Enabled_ProductId_PrefersReportRowProductCode_OverTrackedStatus() + { + var mock = new Mock(); + // Unrecognised display strings force the unresolved-status fallback, which + // populates trackedStatus (with a *different* ProductVariant) — the report + // row's own non-empty ProductCode must still win. + var row = ReportRow("ord_v2sync_035a", "Something New", "Also New", productCode: "842"); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync("ord_v2sync_035a", It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = "ord_v2sync_035a", + Status = "revoked", + ProductVariant = "ov-ucc" + })); + + // Issue 0049: no certificate body on this row — a reader that reports the gateway + // already holds a body keeps this test focused on ProductID preference rather than + // the bodyless-REVOKED guard (covered separately). + var plugin = BuildV2Plugin(mock.Object, certDataReader: GatewayHoldsBodyReader()); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().ContainSingle(); + records[0].ProductID.Should().Be("842", "the report row's own ProductCode must be " + + "preferred over trackedStatus.ProductVariant whenever it is present"); + + // No extra call beyond the fallback the unresolved status already required. + mock.Verify(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + "ord_v2sync_035a", It.IsAny()), Times.Once); + } + + [Fact] + public async Task Synchronize_V2Enabled_ProductId_FallsBackToTrackedStatusProductVariant_WhenReportRowEmpty() + { + var mock = new Mock(); + var row = ReportRow("ord_v2sync_035b", "Something New", "Also New", productCode: ""); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync("ord_v2sync_035b", It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = "ord_v2sync_035b", + Status = "revoked", + ProductVariant = "ov-ucc" + })); + + // Issue 0049: no certificate body on this row — a reader that reports the gateway + // already holds a body keeps this test focused on ProductID preference rather than + // the bodyless-REVOKED guard (covered separately). + var plugin = BuildV2Plugin(mock.Object, certDataReader: GatewayHoldsBodyReader()); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().ContainSingle(); + records[0].ProductID.Should().Be("ov-ucc", "when the report row's ProductCode is " + + "empty, an already-populated trackedStatus.ProductVariant must be used instead " + + "of leaving the field empty"); + } + + [Fact] + public async Task Synchronize_V2Enabled_ProductId_EmptyWhenReportRowEmptyAndNoTrackedStatusFetched() + { + var mock = new Mock(); + // Recognised display strings resolve disposition without any live track call, + // so trackedStatus is never populated for this row. + var row = ReportRow("ord_v2sync_035c", "Order Fulfilled", "Certificate Downloaded", productCode: ""); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + mock.Setup(c => c.ResolveAndDownloadCertificateV2Async("ord_v2sync_035c", It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = "ord_v2sync_035c", + CertificatePem = MockCertificateData.FakePemCertificate + }); + + var plugin = BuildV2Plugin(mock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().ContainSingle(); + records[0].ProductID.Should().Be(string.Empty, "with no ProductCode and no " + + "trackedStatus fetched for this row, ProductID must stay empty rather than " + + "crash or guess a value"); + + // Confirms trackedStatus really is null here — no fetch was ever made for this row. + mock.Verify(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Synchronize_V2Enabled_ProductId_EmptyWhenTrackedStatusProductVariantAlsoEmpty() + { + var mock = new Mock(); + var row = ReportRow("ord_v2sync_035d", "Something New", "Also New", productCode: ""); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync("ord_v2sync_035d", It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = "ord_v2sync_035d", + Status = "revoked", + ProductVariant = null + })); + + // Issue 0049: no certificate body on this row — a reader that reports the gateway + // already holds a body keeps this test focused on ProductID preference rather than + // the bodyless-REVOKED guard (covered separately). + var plugin = BuildV2Plugin(mock.Object, certDataReader: GatewayHoldsBodyReader()); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().ContainSingle(); + records[0].ProductID.Should().Be(string.Empty, "when both the report row's " + + "ProductCode and trackedStatus.ProductVariant are empty/null, ProductID must " + + "stay empty rather than guess a value"); + } + + [Fact] + public async Task Synchronize_V2Enabled_TerminalStatus_SkippedNotEmitted() + { + var mock = new Mock(); + var row = ReportRow("ord_v2sync_004", "Order Cancelled", null); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + var plugin = BuildV2Plugin(mock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + buffer.ToArray().Should().BeEmpty("terminal/cancelled orders are skipped, not emitted"); + } + + [Fact] + public async Task Synchronize_V2Enabled_IncrementalSync_RequestsLookbackWindow() + { + var mock = new Mock(); + string capturedFrom = null; + var lastSync = new System.DateTime(2026, 6, 15, 12, 0, 0, System.DateTimeKind.Utc); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((from, to, size, ct) => capturedFrom = from) + .Returns(AsyncEnumerable()); + + var plugin = BuildV2Plugin(mock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, lastSync, fullSync: false, CancellationToken.None); + + // Default lookback is 72h (Constants.ApiV2.DefaultSyncLookbackHours) — the requested + // 'from' must be lastSync minus that window, not lastSync itself (issues/0022: the + // from/to filter's order-date-vs-issue-date semantics were not confirmed live). + var expectedFrom = lastSync.AddHours(-Constants.ApiV2.DefaultSyncLookbackHours).ToString("yyyy-MM-dd"); + capturedFrom.Should().Be(expectedFrom); + } + + [Fact] + public async Task Synchronize_V2Enabled_FullSync_RequestsNoFromFilter() + { + var mock = new Mock(); + string capturedFrom = "unset"; + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((from, to, size, ct) => capturedFrom = from) + .Returns(AsyncEnumerable()); + + var plugin = BuildV2Plugin(mock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, System.DateTime.UtcNow, fullSync: true, CancellationToken.None); + + capturedFrom.Should().BeNull("a full sync requests the entire order history, not a bounded window"); + } + + // --------------------------------------------------------------------------- + // Synchronize — IgnoreExpired (issues/0027 item 3). V1's Synchronize skips expired + // certs when IgnoreExpired is configured; SynchronizeV2Async had no equivalent check + // even though the report row (OrderReportEntryV2.CertificateExpiryDate) carries the + // data needed. CertificateExpiryDate is a string whose format isn't confirmed live, + // so an unparseable/missing value must NOT be skipped. + // --------------------------------------------------------------------------- + + [Fact] + public async Task Synchronize_V2Enabled_IgnoreExpired_ExpiredCert_Skipped() + { + var mock = new Mock(); + var row = ReportRow( + "ord_v2sync_expired_001", "Order Fulfilled", "Certificate Downloaded", + certificateExpiryDate: System.DateTime.UtcNow.AddDays(-30).ToString("o")); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + var plugin = BuildV2Plugin(mock.Object, ignoreExpired: true); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + buffer.ToArray().Should().BeEmpty( + "an expired certificate must be skipped entirely when IgnoreExpired=true"); + + // Skipped before any status/download work — no live calls should be made for this row. + mock.Verify(c => c.ResolveAndDownloadCertificateV2Async( + It.IsAny(), It.IsAny()), Times.Never); + mock.Verify(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Synchronize_V2Enabled_IgnoreExpired_NonExpiredCert_Kept() + { + var mock = new Mock(); + var row = ReportRow( + "ord_v2sync_expired_002", "Order Fulfilled", "Certificate Downloaded", + certificateExpiryDate: System.DateTime.UtcNow.AddDays(30).ToString("o")); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + mock.Setup(c => c.ResolveAndDownloadCertificateV2Async("ord_v2sync_expired_002", It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = "ord_v2sync_expired_002", + CertificatePem = MockCertificateData.FakePemCertificate + }); + + var plugin = BuildV2Plugin(mock.Object, ignoreExpired: true); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().ContainSingle( + "a certificate that has not yet expired must still be emitted even with " + + "IgnoreExpired=true"); + records[0].CARequestID.Should().Be("ord_v2sync_expired_002"); + } + + [Fact] + public async Task Synchronize_V2Enabled_IgnoreExpired_UnparseableExpiryDate_NotSkipped() + { + var mock = new Mock(); + var row = ReportRow( + "ord_v2sync_expired_003", "Order Fulfilled", "Certificate Downloaded", + certificateExpiryDate: "not-a-real-date"); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + mock.Setup(c => c.ResolveAndDownloadCertificateV2Async("ord_v2sync_expired_003", It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = "ord_v2sync_expired_003", + CertificatePem = MockCertificateData.FakePemCertificate + }); + + var plugin = BuildV2Plugin(mock.Object, ignoreExpired: true); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + buffer.ToArray().Should().ContainSingle( + "an unparseable CertificateExpiryDate must not be treated as expired — the row " + + "should be emitted, not silently dropped"); + } + + [Fact] + public async Task Synchronize_V2Enabled_IgnoreExpired_MissingExpiryDate_NotSkipped() + { + var mock = new Mock(); + var row = ReportRow( + "ord_v2sync_expired_004", "Order Fulfilled", "Certificate Downloaded", + certificateExpiryDate: null); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + mock.Setup(c => c.ResolveAndDownloadCertificateV2Async("ord_v2sync_expired_004", It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = "ord_v2sync_expired_004", + CertificatePem = MockCertificateData.FakePemCertificate + }); + + var plugin = BuildV2Plugin(mock.Object, ignoreExpired: true); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + buffer.ToArray().Should().ContainSingle( + "a missing CertificateExpiryDate (empty until issuance per the DTO's doc " + + "comment) must not be treated as expired"); + } + + [Fact] + public async Task Synchronize_V2Enabled_IgnoreExpiredFalse_ExpiredCert_NotSkipped() + { + var mock = new Mock(); + var row = ReportRow( + "ord_v2sync_expired_005", "Order Fulfilled", "Certificate Downloaded", + certificateExpiryDate: System.DateTime.UtcNow.AddDays(-30).ToString("o")); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + mock.Setup(c => c.ResolveAndDownloadCertificateV2Async("ord_v2sync_expired_005", It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = "ord_v2sync_expired_005", + CertificatePem = MockCertificateData.FakePemCertificate + }); + + // IgnoreExpired defaults to false — the filter must be opt-in. + var plugin = BuildV2Plugin(mock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + buffer.ToArray().Should().ContainSingle( + "with IgnoreExpired left at its default (false), expired certs must still be emitted"); + } + + // --------------------------------------------------------------------------- + // Chain PEM assembly — Enroll V2 with chainPem + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_V2_WithChainPem_ConcatenatesLeafAndIntermediate() + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // non-UCC (DV SSL) + + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2CreateOrderResponse + { + OrderId = "ord_chain_test", + Status = "issued" + }); + + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), "ord_chain_test", + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async( + It.IsAny(), "ord_chain_test", It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = "ord_chain_test", Status = "issued" }); + + // Download response includes a chain PEM entry + mock.Setup(c => c.DownloadCertificateV2Async( + It.IsAny(), "ord_chain_test", It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = "ord_chain_test", + SerialNumber = "AABBCC", + CertificatePem = MockCertificateData.FakePemCertificate, + ChainPem = new System.Collections.Generic.List + { + MockCertificateData.FakeIntermediatePemCertificate + } + }); + + mock.Setup(c => c.Dispose()); + + mock.Setup(c => c.Dispose()); + + var plugin = BuildV2Plugin(mock.Object); + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, + "CN=example.com", + new Dictionary(), + MakeV2ProductInfo(productVariant: "dv"), + RequestFormat.PKCS10, + EnrollmentType.New); + + result.CARequestID.Should().Be("ord_chain_test"); + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + // Full chain must contain both leaf and intermediate + result.Certificate.Should().Contain("-----BEGIN CERTIFICATE-----"); + result.Certificate.Should().Contain("INTERMEDIATE", + because: "chain PEM from the CA should be appended to the leaf"); + } + + [Fact] + public async Task Enroll_V2_WithoutChainPem_ReturnsCertificatePemOnly() + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // non-UCC (DV SSL) + + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_nochain", Status = "issued" }); + + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), "ord_nochain", + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async( + It.IsAny(), "ord_nochain", It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = "ord_nochain", Status = "issued" }); + + mock.Setup(c => c.DownloadCertificateV2Async( + It.IsAny(), "ord_nochain", It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = "ord_nochain", + CertificatePem = MockCertificateData.FakePemCertificate, + ChainPem = null + }); + + mock.Setup(c => c.Dispose()); + + var plugin = BuildV2Plugin(mock.Object); + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, + "CN=example.com", + new Dictionary(), + MakeV2ProductInfo(productVariant: "dv"), + RequestFormat.PKCS10, + EnrollmentType.New); + + result.Certificate.Should().Be(MockCertificateData.FakePemCertificate, + because: "no chainPem means only the leaf cert is returned"); + } + + // --------------------------------------------------------------------------- + // ValidateCAConnectionInfo — consolidated config (issues/0022). + // + // V2 mode: a single ApiUrl (required in both modes) plus OAuthClientId/OAuthClientSecret. + // V1-only fields (AccountNumber, AuthMode, ApiKey, ...) are NOT required when UseV2Api + // is true. ApiUrlV2/ClientId/ClientSecret no longer exist. + // --------------------------------------------------------------------------- + + [Fact] + public async Task ValidateCAConnectionInfo_V2_Throws_WhenApiUrlMissing() + { + var plugin = BuildV2Plugin(NewMock().Object); + var info = new Dictionary + { + ["UseV2Api"] = true, + ["OAuthClientId"] = "my-client", + ["OAuthClientSecret"] = "my-secret" + // No ApiUrl + }; + + Func act = () => plugin.ValidateCAConnectionInfo(info); + + await act.Should().ThrowAsync() + .WithMessage("*ApiUrl*required*"); + } + + [Fact] + public async Task ValidateCAConnectionInfo_V2_Throws_WhenApiUrlIsNotUri() + { + var plugin = BuildV2Plugin(NewMock().Object); + var info = new Dictionary + { + ["UseV2Api"] = true, + ["ApiUrl"] = "not-a-url", + ["OAuthClientId"] = "my-client", + ["OAuthClientSecret"] = "my-secret" + }; + + Func act = () => plugin.ValidateCAConnectionInfo(info); + + await act.Should().ThrowAsync() + .WithMessage("*ApiUrl*valid absolute URI*"); + } + + [Fact] + public async Task ValidateCAConnectionInfo_V2_Throws_WhenOAuthClientIdMissing() + { + var plugin = BuildV2Plugin(NewMock().Object); + var info = new Dictionary + { + ["UseV2Api"] = true, + ["ApiUrl"] = "https://v2.certinext.io", + // No OAuthClientId + ["OAuthClientSecret"] = "my-secret" + }; + + Func act = () => plugin.ValidateCAConnectionInfo(info); + + await act.Should().ThrowAsync() + .WithMessage("*OAuthClientId*required*"); + } + + [Fact] + public async Task ValidateCAConnectionInfo_V2_Throws_WhenOAuthClientSecretMissing() + { + var plugin = BuildV2Plugin(NewMock().Object); + var info = new Dictionary + { + ["UseV2Api"] = true, + ["ApiUrl"] = "https://v2.certinext.io", + ["OAuthClientId"] = "my-client" + // No OAuthClientSecret + }; + + Func act = () => plugin.ValidateCAConnectionInfo(info); + + await act.Should().ThrowAsync() + .WithMessage("*OAuthClientSecret*required*"); + } + + [Fact] + public async Task ValidateCAConnectionInfo_V2_DoesNotRequireV1Credentials() + { + // No AccountNumber, AuthMode, or ApiKey at all — V1 credentials must be optional + // when UseV2Api is true (issues/0022). Uses a real WireMock server so the live V2 + // ping (the only other thing this method does) succeeds. + using var server = WireMockServer.Start(); + server + .Given(Request.Create().WithPath("/oauth/token").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2TokenResponseJson())); + server + .Given(Request.Create().WithPath("/api/certinext/v2/auth/me").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2AuthMeJson())); + + var plugin = BuildV2Plugin(NewMock().Object); + var info = new Dictionary + { + ["UseV2Api"] = true, + ["ApiUrl"] = server.Urls[0], + ["OAuthClientId"] = "my-client", + ["OAuthClientSecret"] = "my-secret", + ["SignerPlace"] = "New York" // required for V2 (issue 0039) + // No AccountNumber / AuthMode / ApiKey at all. + }; + + Func act = () => plugin.ValidateCAConnectionInfo(info); + + await act.Should().NotThrowAsync( + "V1 credentials (AccountNumber/AuthMode/ApiKey) must not be required when UseV2Api is true"); + } + + [Fact] + public async Task ValidateCAConnectionInfo_V1_UseV2ApiFalse_StillRequiresAccountNumberAndAuthMode() + { + // UseV2Api false (the default/legacy path) — V1 requirements are unchanged, and the + // (now nonexistent) V2-only fields must never appear in the resulting error. + var plugin = BuildV2Plugin(NewMock().Object); + var info = new Dictionary + { + ["ApiUrl"] = "https://v1.certinext.io", + ["UseV2Api"] = false + // No AccountNumber, no AuthMode/ApiKey. + }; + + Func act = () => plugin.ValidateCAConnectionInfo(info); + + var ex = await act.Should().ThrowAsync(); + ex.Which.Message.Should().Contain("AccountNumber") + .And.NotContain("ApiUrlV2").And.NotContain("OAuthClientId").And.NotContain("OAuthClientSecret"); + } + + [Fact] + public async Task ValidateCAConnectionInfo_AllV2FieldsPresent_Passes() + { + using var server = WireMockServer.Start(); + server + .Given(Request.Create().WithPath("/oauth/token").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2TokenResponseJson())); + server + .Given(Request.Create().WithPath("/api/certinext/v2/auth/me").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2AuthMeJson())); + + var plugin = BuildV2Plugin(NewMock().Object); + var info = new Dictionary + { + ["UseV2Api"] = true, + ["ApiUrl"] = server.Urls[0], + ["OAuthClientId"] = "my-client", + ["OAuthClientSecret"] = "my-secret", + ["SignerPlace"] = "New York" // required for V2 (issue 0039) + }; + + Func act = () => plugin.ValidateCAConnectionInfo(info); + + await act.Should().NotThrowAsync( + "ApiUrl and OAuthClientId/OAuthClientSecret are present and valid, and the live V2 ping succeeds"); + } + + // --------------------------------------------------------------------------- + // ValidateProductInfo — V2 (issue 0025). ValidateProductInfo builds its own + // CERTInextClient from connectionInfo rather than using the Moq-injected client (like + // ValidateCAConnectionInfo), so these tests use a real WireMock server as ApiUrl. + // --------------------------------------------------------------------------- + + private static void StubV2TokenAndAuthMe(WireMockServer server) + { + server + .Given(Request.Create().WithPath("/oauth/token").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2TokenResponseJson())); + } + + private static Dictionary BuildV2ConnectionInfo(string apiUrl, string defaultProductCode = null) + { + var info = new Dictionary + { + ["UseV2Api"] = true, + ["ApiUrl"] = apiUrl, + ["OAuthClientId"] = "my-client", + ["OAuthClientSecret"] = "my-secret" + }; + if (!string.IsNullOrWhiteSpace(defaultProductCode)) + info["DefaultProductCode"] = defaultProductCode; + return info; + } + + private static EnrollmentProductInfo BuildProductInfo(string productCode) => new EnrollmentProductInfo + { + ProductID = "ssl", + ProductParameters = new Dictionary { ["ProductCode"] = productCode } + }; + + [Fact] + public async Task ValidateProductInfo_V2_Succeeds_WhenProductCodeInCatalog() + { + using var server = WireMockServer.Start(); + StubV2TokenAndAuthMe(server); + server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetCatalogProductsV2NestedJson())); + + var plugin = BuildV2Plugin(NewMock().Object); + var connInfo = BuildV2ConnectionInfo(server.Urls[0]); + var productInfo = BuildProductInfo(MockCertificateData.ProfileIdTls); + + Func act = () => plugin.ValidateProductInfo(productInfo, connInfo); + + await act.Should().NotThrowAsync(); + + // Regression guard for issue 0025: in V2 mode this must go through the V2 catalog, + // never the V1-only GetProductDetails endpoint. + server.LogEntries.Should().NotContain(e => e.RequestMessage.Path == "/GetProductDetails"); + server.LogEntries.Should().Contain(e => e.RequestMessage.Path == "/api/certinext/v2/catalog/products"); + } + + [Fact] + public async Task ValidateProductInfo_V2_Throws_WhenProductCodeNotInCatalog() + { + using var server = WireMockServer.Start(); + StubV2TokenAndAuthMe(server); + server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetCatalogProductsV2NestedJson())); + + var plugin = BuildV2Plugin(NewMock().Object); + var connInfo = BuildV2ConnectionInfo(server.Urls[0]); + var productInfo = BuildProductInfo("999999"); + + Func act = () => plugin.ValidateProductInfo(productInfo, connInfo); + + await act.Should().ThrowAsync() + .WithMessage("*not found*"); + } + + [Fact] + public async Task ValidateProductInfo_V2_Throws_WhenCatalogEmpty() + { + using var server = WireMockServer.Start(); + StubV2TokenAndAuthMe(server); + server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetCatalogProductsV2EmptyJson())); + + var plugin = BuildV2Plugin(NewMock().Object); + var connInfo = BuildV2ConnectionInfo(server.Urls[0]); + var productInfo = BuildProductInfo(MockCertificateData.ProfileIdTls); + + Func act = () => plugin.ValidateProductInfo(productInfo, connInfo); + + // No soft-accept on an empty/unusable catalog — must match V1's strict behaviour. + await act.Should().ThrowAsync() + .WithMessage("*not found*"); + } + + [Fact] + public async Task ValidateProductInfo_V2_Throws_WhenCatalogReturnsError() + { + using var server = WireMockServer.Start(); + StubV2TokenAndAuthMe(server); + server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(500) + .WithHeader("Content-Type", "application/json") + .WithBody("{\"secretApiKeyLeak\":\"should-not-appear-in-message\"}")); + + var plugin = BuildV2Plugin(NewMock().Object); + var connInfo = BuildV2ConnectionInfo(server.Urls[0]); + var productInfo = BuildProductInfo(MockCertificateData.ProfileIdTls); + + Func act = () => plugin.ValidateProductInfo(productInfo, connInfo); + + var ex = await act.Should().ThrowAsync() + .WithMessage("*Unable to validate*"); + ex.Which.Message.Should().NotContain("secretApiKeyLeak"); + } + + // --------------------------------------------------------------------------- + // productTypeID correctness check (issue 0036) — catches a code that exists in the + // catalog but means a different product than the one selected, for both the + // explicit-override case and the no-override/fallback case. Pre-fix, ValidateProductInfo + // only checked catalog-existence and would have passed all of these. + // --------------------------------------------------------------------------- + + [Fact] + public async Task ValidateProductInfo_V2_Throws_WhenProductCodeExistsButProductTypeIdMismatchesSelectedProduct() + { + // Template selects "OV SSL" (expects productTypeID "16") but overrides ProductCode to + // "842", which the live catalog (stubbed here) resolves to productTypeID "13" = DV + // SSL. The code exists — a pure existence check would pass this — but it means a + // different product than the one selected. + using var server = WireMockServer.Start(); + StubV2TokenAndAuthMe(server); + server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(@"[{""productCode"":""842"",""productName"":""DV SSL Certificate"",""productTypeID"":""13""}]")); + + var plugin = BuildV2Plugin(NewMock().Object); + var connInfo = BuildV2ConnectionInfo(server.Urls[0]); + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.OvSsl, + ProductParameters = new Dictionary { ["ProductCode"] = "842" } + }; + + Func act = () => plugin.ValidateProductInfo(productInfo, connInfo); + + await act.Should().ThrowAsync() + .WithMessage("*does not correspond to the selected product*"); + } + + [Fact] + public async Task ValidateProductInfo_V2_Succeeds_WhenNoExplicitProductCode_ResolvesByProductTypeId() + { + // No ProductCode/ProfileId override configured — must validate via the live catalog's + // productTypeID for the selected ProductId, not via Constants.Products.DefaultProductCodes + // (V1-only; wrong numbering for V2). + using var server = WireMockServer.Start(); + StubV2TokenAndAuthMe(server); + server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(@"[{""productCode"":""846"",""productName"":""OV SSL Certificate"",""productTypeID"":""16""}]")); + + var plugin = BuildV2Plugin(NewMock().Object); + var connInfo = BuildV2ConnectionInfo(server.Urls[0]); + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.OvSsl, + ProductParameters = new Dictionary() + }; + + Func act = () => plugin.ValidateProductInfo(productInfo, connInfo); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task ValidateProductInfo_V2_Throws_WhenNoExplicitProductCode_AndCatalogHasNoMatchingProductTypeId() + { + // No override configured, and the live catalog has no entry with the productTypeID + // expected for EV SSL ("19") — must fail loudly rather than silently pass. + using var server = WireMockServer.Start(); + StubV2TokenAndAuthMe(server); + server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(@"[{""productCode"":""842"",""productName"":""DV SSL Certificate"",""productTypeID"":""13""}]")); + + var plugin = BuildV2Plugin(NewMock().Object); + var connInfo = BuildV2ConnectionInfo(server.Urls[0]); + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.EvSsl, + ProductParameters = new Dictionary() + }; + + Func act = () => plugin.ValidateProductInfo(productInfo, connInfo); + + await act.Should().ThrowAsync() + .WithMessage("*Could not find a CERTInext V2 catalog entry*"); + } + + // --------------------------------------------------------------------------- + // Product-selection ambiguity (sandbox-confirmed: two type-13 DV SSL entries, + // "917 SSL DV 1 month" listed before "842 DV SSL Certificate"). No explicit + // ProductCode + multiple catalog entries sharing the expected productTypeID must not + // silently pick the first match — only resolve via the connector's DefaultProductCode, + // or reject naming the candidates. Mirrors EnrollV2Async's own handling. + // --------------------------------------------------------------------------- + + [Fact] + public async Task ValidateProductInfo_V2_Throws_WhenMultipleCatalogEntriesShareProductTypeId_AndNoDefaultProductCode() + { + using var server = WireMockServer.Start(); + StubV2TokenAndAuthMe(server); + server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(@"[{""productCode"":""917"",""productName"":""SSL DV 1 month"",""productTypeID"":""13""}," + + @"{""productCode"":""842"",""productName"":""DV SSL Certificate"",""productTypeID"":""13""}]")); + + var plugin = BuildV2Plugin(NewMock().Object); + var connInfo = BuildV2ConnectionInfo(server.Urls[0]); // no DefaultProductCode configured + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.DvSsl, + ProductParameters = new Dictionary() + }; + + Func act = () => plugin.ValidateProductInfo(productInfo, connInfo); + + var ex = await act.Should().ThrowAsync() + .WithMessage("*Multiple CERTInext catalog products match*"); + ex.Which.Message.Should().Contain("917").And.Contain("842"); + } + + [Fact] + public async Task ValidateProductInfo_V2_Succeeds_WhenMultipleCatalogEntriesShareProductTypeId_AndDefaultProductCodeMatchesOne() + { + using var server = WireMockServer.Start(); + StubV2TokenAndAuthMe(server); + server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(@"[{""productCode"":""917"",""productName"":""SSL DV 1 month"",""productTypeID"":""13""}," + + @"{""productCode"":""842"",""productName"":""DV SSL Certificate"",""productTypeID"":""13""}]")); + + var plugin = BuildV2Plugin(NewMock().Object); + var connInfo = BuildV2ConnectionInfo(server.Urls[0], defaultProductCode: "842"); + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.DvSsl, + ProductParameters = new Dictionary() + }; + + Func act = () => plugin.ValidateProductInfo(productInfo, connInfo); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task ValidateProductInfo_V2_Throws_WhenMultipleCatalogEntriesShareProductTypeId_AndDefaultProductCodeIsWrongType() + { + // DefaultProductCode is configured, but it names a product of a DIFFERENT + // productTypeID than the one being resolved — must not be treated as a match. + using var server = WireMockServer.Start(); + StubV2TokenAndAuthMe(server); + server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(@"[{""productCode"":""917"",""productName"":""SSL DV 1 month"",""productTypeID"":""13""}," + + @"{""productCode"":""842"",""productName"":""DV SSL Certificate"",""productTypeID"":""13""}," + + @"{""productCode"":""846"",""productName"":""OV SSL Certificate"",""productTypeID"":""16""}]")); + + var plugin = BuildV2Plugin(NewMock().Object); + var connInfo = BuildV2ConnectionInfo(server.Urls[0], defaultProductCode: "846"); // OV, not DV + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.DvSsl, + ProductParameters = new Dictionary() + }; + + Func act = () => plugin.ValidateProductInfo(productInfo, connInfo); + + await act.Should().ThrowAsync() + .WithMessage("*Multiple CERTInext catalog products match*"); + } + + [Fact] + public async Task ValidateProductInfo_V2_Succeeds_WhenExactlyOneCatalogEntryMatchesProductTypeId() + { + // Single match for the productTypeID — must resolve automatically, same as before + // ambiguity handling was added (regression guard for the single-match case). + using var server = WireMockServer.Start(); + StubV2TokenAndAuthMe(server); + server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(@"[{""productCode"":""842"",""productName"":""DV SSL Certificate"",""productTypeID"":""13""}]")); + + var plugin = BuildV2Plugin(NewMock().Object); + var connInfo = BuildV2ConnectionInfo(server.Urls[0]); + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.DvSsl, + ProductParameters = new Dictionary() + }; + + Func act = () => plugin.ValidateProductInfo(productInfo, connInfo); + + await act.Should().NotThrowAsync(); + } + + // --------------------------------------------------------------------------- + // Issue 0049 — bodyless-REVOKED guard. A V2 REVOKED record with no certificate + // body must never reach the gateway buffer / be returned as-is unless + // ICertificateDataReader.GetExpirationDateByRequestId confirms the gateway + // already holds a body for that CARequestID (DecideBodylessRevokedRecord). + // --------------------------------------------------------------------------- + + [Fact] + public async Task Synchronize_Issue0049_RevokedNoBody_GatewayHoldsBody_EmitsBodylessRevoked() + { + var mock = new Mock(); + var row = ReportRow("ord_0049_a", "Revoked", "Certificate Revoked"); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + var readerMock = new Mock(); + readerMock.Setup(r => r.GetExpirationDateByRequestId("ord_0049_a")) + .Returns(DateTime.UtcNow.AddDays(45)); + + var plugin = BuildV2Plugin(mock.Object, certDataReader: readerMock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().ContainSingle( + "the gateway already holds a body for this order, so the revoke must " + + "still propagate as a bodyless REVOKED record") + .Which.Status.Should().Be((int)EndEntityStatus.REVOKED); + records[0].Certificate.Should().BeNullOrEmpty(); + + readerMock.Verify(r => r.GetExpirationDateByRequestId("ord_0049_a"), Times.Once); + } + + [Fact] + public async Task Synchronize_Issue0049_RevokedNoBody_GatewayRowHasNoBody_EmitsFailedNotRevoked() + { + var mock = new Mock(); + var row = ReportRow("ord_0049_b", "Revoked", "Certificate Revoked"); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + var readerMock = new Mock(); + readerMock.Setup(r => r.GetExpirationDateByRequestId("ord_0049_b")) + .Returns((DateTime?)null); // row exists, but the gateway holds no body + + var plugin = BuildV2Plugin(mock.Object, certDataReader: readerMock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().ContainSingle( + "a gateway row with no body must be downgraded to FAILED, never left as a " + + "bodyless REVOKED record") + .Which.Status.Should().Be((int)EndEntityStatus.FAILED); + records[0].Certificate.Should().BeNullOrEmpty(); + records[0].RevocationDate.Should().BeNull(); + records[0].RevocationReason.Should().Be(0); + } + + [Fact] + public async Task Synchronize_Issue0049_RevokedNoBody_NoGatewayRow_SkipsRecordEntirely() + { + var mock = new Mock(); + var row = ReportRow("ord_0049_c", "Revoked", "Certificate Revoked"); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + var readerMock = new Mock(); + readerMock.Setup(r => r.GetExpirationDateByRequestId("ord_0049_c")) + .Throws(new ArgumentException("No certificate/CA request exists for the specified request ID.")); + + var plugin = BuildV2Plugin(mock.Object, certDataReader: readerMock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + buffer.ToArray().Should().BeEmpty( + "the gateway has never seen this order — creating a bodyless REVOKED row would " + + "poison it (RevocationDate is never cleared by the gateway)"); + } + + [Fact] + public async Task Synchronize_Issue0049_RevokedNoBody_ReaderThrowsUnexpectedException_SkipsRecord() + { + var mock = new Mock(); + var row = ReportRow("ord_0049_d", "Revoked", "Certificate Revoked"); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + var readerMock = new Mock(); + readerMock.Setup(r => r.GetExpirationDateByRequestId("ord_0049_d")) + .Throws(new InvalidOperationException("gateway database unavailable")); + + var plugin = BuildV2Plugin(mock.Object, certDataReader: readerMock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + buffer.ToArray().Should().BeEmpty( + "an unexpected reader failure must not risk emitting a bodyless REVOKED record — " + + "the revoke is only delayed to a later sync"); + } + + [Fact] + public async Task Synchronize_Issue0049_RevokedNoBody_NoCertificateDataReaderInjected_SkipsRecord() + { + var mock = new Mock(); + var row = ReportRow("ord_0049_e", "Revoked", "Certificate Revoked"); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + + // No certDataReader supplied — BuildV2Plugin defaults it to null. + var plugin = BuildV2Plugin(mock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + buffer.ToArray().Should().BeEmpty( + "with no reader available to consult, the plugin must not risk creating a " + + "poisoned gateway row"); + } + + [Fact] + public async Task Synchronize_Issue0049_GeneratedRow_NeverConsultsReader() + { + var mock = new Mock(); + var row = ReportRow("ord_0049_f", "Order Fulfilled", "Certificate Downloaded"); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(row)); + mock.Setup(c => c.ResolveAndDownloadCertificateV2Async("ord_0049_f", It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = "ord_0049_f", + CertificatePem = MockCertificateData.FakePemCertificate + }); + + // Strict with no setups — any call at all fails the test. Confirms the issue-0049 + // guard is scoped to REVOKED-with-no-body and never touches the GENERATED path. + var readerMock = new Mock(MockBehavior.Strict); + var plugin = BuildV2Plugin(mock.Object, certDataReader: readerMock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + records.Should().ContainSingle() + .Which.Status.Should().Be((int)EndEntityStatus.GENERATED); + records[0].Certificate.Should().StartWith("-----BEGIN CERTIFICATE-----"); + + readerMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task Synchronize_Issue0049_MixedBatch_NeverEmitsBodylessRevokedWithoutReaderConfirmation() + { + var mock = new Mock(); + var rowHoldsBody = ReportRow("ord_0049_mix_holds", "Revoked", "Certificate Revoked"); + var rowNoBody = ReportRow("ord_0049_mix_nobody", "Revoked", "Certificate Revoked"); + var rowNoRow = ReportRow("ord_0049_mix_norow", "Revoked", "Certificate Revoked"); + + mock.Setup(c => c.ListOrdersV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable(rowHoldsBody, rowNoBody, rowNoRow)); + + var readerMock = new Mock(); + readerMock.Setup(r => r.GetExpirationDateByRequestId("ord_0049_mix_holds")) + .Returns(DateTime.UtcNow.AddDays(60)); + readerMock.Setup(r => r.GetExpirationDateByRequestId("ord_0049_mix_nobody")) + .Returns((DateTime?)null); + readerMock.Setup(r => r.GetExpirationDateByRequestId("ord_0049_mix_norow")) + .Throws(new ArgumentException("no such request id")); + + var plugin = BuildV2Plugin(mock.Object, certDataReader: readerMock.Object); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var records = buffer.ToArray(); + + // Core invariant (issue 0049): no record with Status=REVOKED and no certificate + // body may reach the gateway buffer unless the reader confirmed the gateway + // already holds a body for that specific CARequestID. + records.Should().NotContain(r => + r.Status == (int)EndEntityStatus.REVOKED && string.IsNullOrEmpty(r.Certificate) + && r.CARequestID != "ord_0049_mix_holds"); + + records.Should().ContainSingle(r => r.CARequestID == "ord_0049_mix_holds") + .Which.Status.Should().Be((int)EndEntityStatus.REVOKED); + records.Should().ContainSingle(r => r.CARequestID == "ord_0049_mix_nobody") + .Which.Status.Should().Be((int)EndEntityStatus.FAILED); + records.Should().NotContain(r => r.CARequestID == "ord_0049_mix_norow"); + } + + [Fact] + public async Task GetSingleRecord_Issue0049_RevokedNoBody_GatewayHoldsBody_ReturnsRevoked() + { + var mock = NewMock(); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = MockCertificateData.V2OrderId1, + Status = "revoked", + ProductVariant = "dv" + })); + + var readerMock = new Mock(); + readerMock.Setup(r => r.GetExpirationDateByRequestId(MockCertificateData.V2OrderId1)) + .Returns(DateTime.UtcNow.AddDays(10)); + + var plugin = BuildV2Plugin(mock.Object, certDataReader: readerMock.Object); + var record = await plugin.GetSingleRecord(MockCertificateData.V2OrderId1); + + record.Status.Should().Be((int)EndEntityStatus.REVOKED); + record.Certificate.Should().BeNullOrEmpty(); + } + + [Fact] + public async Task GetSingleRecord_Issue0049_RevokedNoBody_GatewayRowHasNoBody_ReturnsFailed() + { + var mock = NewMock(); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = MockCertificateData.V2OrderId1, + Status = "revoked", + ProductVariant = "dv", + Revocation = new V2RevocationDetails + { + Status = "Certificate Revoked", + Reason = "cessation-of-operation", + ProcessedAt = DateTime.UtcNow + } + })); + + var readerMock = new Mock(); + readerMock.Setup(r => r.GetExpirationDateByRequestId(MockCertificateData.V2OrderId1)) + .Returns((DateTime?)null); + + var plugin = BuildV2Plugin(mock.Object, certDataReader: readerMock.Object); + var record = await plugin.GetSingleRecord(MockCertificateData.V2OrderId1); + + record.Status.Should().Be((int)EndEntityStatus.FAILED); + record.Certificate.Should().BeNullOrEmpty(); + record.RevocationDate.Should().BeNull(); + record.RevocationReason.Should().Be(0); + } + + [Fact] + public async Task GetSingleRecord_Issue0049_RevokedNoBody_NoGatewayRow_ReturnsFailed() + { + var mock = NewMock(); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = MockCertificateData.V2OrderId1, + Status = "revoked" + })); + + var readerMock = new Mock(); + readerMock.Setup(r => r.GetExpirationDateByRequestId(MockCertificateData.V2OrderId1)) + .Throws(new ArgumentException("no such request id")); + + var plugin = BuildV2Plugin(mock.Object, certDataReader: readerMock.Object); + var record = await plugin.GetSingleRecord(MockCertificateData.V2OrderId1); + + record.Should().NotBeNull( + "GetSingleRecord cannot skip — it must return something even when the gateway " + + "has no row for this order"); + record.Status.Should().Be((int)EndEntityStatus.FAILED); + record.Certificate.Should().BeNullOrEmpty(); + record.RevocationDate.Should().BeNull(); + } + + [Fact] + public async Task GetSingleRecord_Issue0049_RevokedNoBody_NoCertificateDataReaderInjected_ReturnsFailed() + { + var mock = NewMock(); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = MockCertificateData.V2OrderId1, + Status = "revoked" + })); + + // No certDataReader supplied — BuildV2Plugin defaults it to null. + var plugin = BuildV2Plugin(mock.Object); + var record = await plugin.GetSingleRecord(MockCertificateData.V2OrderId1); + + record.Status.Should().Be((int)EndEntityStatus.FAILED); + } + + [Fact] + public async Task GetSingleRecord_Issue0049_GeneratedRecord_NeverConsultsReader() + { + var mock = NewMock(); + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync( + MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse + { + OrderId = MockCertificateData.V2OrderId1, + Status = "issued", + ProductVariant = "dv" + })); + mock.Setup(c => c.ResolveAndDownloadCertificateV2Async( + MockCertificateData.V2OrderId1, It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = MockCertificateData.V2OrderId1, + CertificatePem = MockCertificateData.FakePemCertificate + }); + + // Strict with no setups — confirms the issue-0049 guard never touches GENERATED. + var readerMock = new Mock(MockBehavior.Strict); + var plugin = BuildV2Plugin(mock.Object, certDataReader: readerMock.Object); + + var record = await plugin.GetSingleRecord(MockCertificateData.V2OrderId1); + + record.Status.Should().Be((int)EndEntityStatus.GENERATED); + readerMock.VerifyNoOtherCalls(); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private static async IAsyncEnumerable AsyncEnumerable(params T[] items) + { + foreach (var item in items) + yield return item; + await Task.CompletedTask; + } + } +} diff --git a/CERTInext.Tests/CERTInextClientRequestShapeTests.cs b/CERTInext.Tests/CERTInextClientRequestShapeTests.cs new file mode 100644 index 0000000..fd61dfb --- /dev/null +++ b/CERTInext.Tests/CERTInextClientRequestShapeTests.cs @@ -0,0 +1,346 @@ +// Copyright 2024 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using System; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Verifies the JSON body emitted by BuildOrderRequestFromLegacyEnrollRequest + /// against the connector-level config fields that customers can set in the gateway + /// admin UI. Each test: + /// 1. Builds a with specific field combinations, + /// 2. Stubs GenerateOrderSSL + TrackOrder with a happy response, + /// 3. Invokes EnrollCertificateAsync, + /// 4. Reads the captured POST body from WireMock and asserts the shape. + /// + /// These tests pin the behaviour of the configurables documented in README.md → + /// "CA Configuration"; if a future refactor accidentally omits one of them from + /// the SSL order body, the corresponding test fails loudly. + /// + public class CERTInextClientRequestShapeTests : IDisposable + { + private readonly WireMockServer _server; + private readonly string _baseUrl; + + public CERTInextClientRequestShapeTests() + { + _server = WireMockServer.Start(); + _baseUrl = _server.Urls[0]; + } + + public void Dispose() => _server.Stop(); + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private CERTInextClient BuildClient(CERTInextConfig config) + { + config.ApiUrl = _baseUrl; + return new CERTInextClient(config); + } + + private static CERTInextConfig MinimalConfig() => new CERTInextConfig + { + AuthMode = "AccessKey", + ApiKey = "test-key", + AccountNumber = "12345", + RequestorName = "Default Requestor", + RequestorEmail = "default@example.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "5550000000", + SignerPlace = "Austin", + SignerIp = "203.0.113.10", + PageSize = 100 + }; + + private void StubHappyEnroll() + { + _server.Given(Request.Create().WithPath("/GenerateOrderSSL").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GenerateOrderSuccessJson(MockCertificateData.OrderNumber1))); + + _server.Given(Request.Create().WithPath("/TrackOrder").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.TrackOrderIssuedJson(MockCertificateData.OrderNumber1))); + + _server.Given(Request.Create().WithPath("/GetCertificate").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetCertificateSuccessJson())); + } + + private JsonElement CapturedOrderBody() + { + var generateOrderRequests = _server.LogEntries + .Where(e => e.RequestMessage.Path == "/GenerateOrderSSL") + .ToList(); + generateOrderRequests.Should().HaveCount(1, + "exactly one GenerateOrderSSL POST should have been emitted"); + string body = generateOrderRequests[0].RequestMessage.Body; + body.Should().NotBeNullOrEmpty(); + return JsonDocument.Parse(body!).RootElement.GetProperty("orderDetails"); + } + + private static EnrollCertificateRequest BasicEnrollRequest() => new EnrollCertificateRequest + { + ProfileId = "842", + Csr = MockCertificateData.FakeCsrPem, + Subject = "CN=test.example.com", + Comment = "Unit test" + }; + + // ----------------------------------------------------------------------- + // OrganizationNumber → organizationDetails block + // ----------------------------------------------------------------------- + + [Fact] + public async Task OrganizationNumber_Set_EmitsPreVettedOrganizationDetails() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.OrganizationNumber = "9876543210"; + + await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest()); + + var orderDetails = CapturedOrderBody(); + orderDetails.TryGetProperty("organizationDetails", out var orgDetails).Should().BeTrue( + "organizationDetails must be present when OrganizationNumber is configured"); + orgDetails.GetProperty("preVetting").GetString().Should().Be("1", + "preVetting=1 declares the org as already vetted, bypassing the manual queue"); + orgDetails.GetProperty("organizationNumber").GetString().Should().Be("9876543210"); + } + + [Fact] + public async Task OrganizationNumber_Blank_OmitsOrganizationDetailsBlock() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.OrganizationNumber = string.Empty; + + await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest()); + + var orderDetails = CapturedOrderBody(); + orderDetails.TryGetProperty("organizationDetails", out _).Should().BeFalse( + "organizationDetails must be omitted when OrganizationNumber is unset (preserves legacy behavior)"); + } + + // ----------------------------------------------------------------------- + // GroupNumber → delegationInformation block + // ----------------------------------------------------------------------- + + [Fact] + public async Task GroupNumber_Set_EmitsDelegationInformation() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.GroupNumber = "2171775848"; + + await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest()); + + var orderDetails = CapturedOrderBody(); + orderDetails.TryGetProperty("delegationInformation", out var delegation).Should().BeTrue(); + delegation.GetProperty("groupNumber").GetString().Should().Be("2171775848"); + } + + [Fact] + public async Task GroupNumber_Blank_OmitsDelegationInformation() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.GroupNumber = string.Empty; + + await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest()); + + var orderDetails = CapturedOrderBody(); + orderDetails.TryGetProperty("delegationInformation", out _).Should().BeFalse(); + } + + // ----------------------------------------------------------------------- + // technicalPointOfContact — overrides + requestor fallback + // ----------------------------------------------------------------------- + + [Fact] + public async Task TechnicalContact_AllSet_EmitsExplicitValues() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.TechnicalContactName = "Jane Smith"; + cfg.TechnicalContactEmail = "tpc@example.com"; + cfg.TechnicalContactIsdCode = "44"; + cfg.TechnicalContactMobileNumber = "5559999999"; + + await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest()); + + var tpc = CapturedOrderBody().GetProperty("technicalPointOfContact"); + tpc.GetProperty("tpcName").GetString().Should().Be("Jane Smith"); + tpc.GetProperty("tpcEmail").GetString().Should().Be("tpc@example.com"); + tpc.GetProperty("tpcIsdCode").GetString().Should().Be("44"); + tpc.GetProperty("tpcMobileNumber").GetString().Should().Be("5559999999"); + } + + [Fact] + public async Task TechnicalContact_AllBlank_FallsBackToRequestorDefaults() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + // All TechnicalContact* unset → must fall back to Requestor* + cfg.TechnicalContactName = string.Empty; + cfg.TechnicalContactEmail = string.Empty; + cfg.TechnicalContactIsdCode = string.Empty; + cfg.TechnicalContactMobileNumber = string.Empty; + + await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest()); + + var tpc = CapturedOrderBody().GetProperty("technicalPointOfContact"); + tpc.GetProperty("tpcName").GetString().Should().Be(cfg.RequestorName); + tpc.GetProperty("tpcEmail").GetString().Should().Be(cfg.RequestorEmail); + tpc.GetProperty("tpcIsdCode").GetString().Should().Be(cfg.RequestorIsdCode); + tpc.GetProperty("tpcMobileNumber").GetString().Should().Be(cfg.RequestorMobileNumber); + } + + // ----------------------------------------------------------------------- + // SSL order body defaults — AccountingModel / EmailNotifications / + // SubscriptionAutoRenew / SubscriptionRenewCriteriaDays / + // SubscriptionValidityYears / AutoSecureWww + // ----------------------------------------------------------------------- + + [Fact] + public async Task SslBodyDefaults_AreEmitted_FromCustomConnectorValues() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.AccountingModel = "1"; + cfg.EmailNotifications = "1"; + cfg.SubscriptionValidityYears = "2"; + cfg.SubscriptionAutoRenew = "1"; + cfg.SubscriptionRenewCriteriaDays = "60"; + cfg.AutoSecureWww = "1"; + + await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest()); + + var od = CapturedOrderBody(); + od.GetProperty("accountingModel").GetString().Should().Be("1"); + od.GetProperty("emailNotifications").GetString().Should().Be("1"); + + var sub = od.GetProperty("subscriptionDetails"); + sub.GetProperty("validity").GetString().Should().Be("2"); + sub.GetProperty("autoRenew").GetString().Should().Be("1"); + sub.GetProperty("renewCriteria").GetString().Should().Be("60"); + + od.GetProperty("certificateInformation").GetProperty("autoSecureWWW").GetString().Should().Be("1"); + } + + [Fact] + public async Task SslBodyDefaults_AreSafeFallbacks_WhenConfigUntouched() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + // Leave new fields at their CERTInextConfig defaults + + await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest()); + + var od = CapturedOrderBody(); + od.GetProperty("accountingModel").GetString().Should().Be("2"); + od.GetProperty("emailNotifications").GetString().Should().Be("0"); + + var sub = od.GetProperty("subscriptionDetails"); + sub.GetProperty("validity").GetString().Should().Be("1"); + sub.GetProperty("autoRenew").GetString().Should().Be("0"); + sub.GetProperty("renewCriteria").GetString().Should().Be("30"); + + od.GetProperty("certificateInformation").GetProperty("autoSecureWWW").GetString().Should().Be("0"); + } + + // ----------------------------------------------------------------------- + // ValidityDays request-parameter still overrides the connector default + // ----------------------------------------------------------------------- + + [Fact] + public async Task ValidityDays_OnRequest_OverridesConnectorDefault() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.SubscriptionValidityYears = "1"; // connector default = 1 year + + var req = BasicEnrollRequest(); + req.ValidityDays = 730; // 2 years + + await BuildClient(cfg).EnrollCertificateAsync(req); + + CapturedOrderBody().GetProperty("subscriptionDetails") + .GetProperty("validity").GetString().Should().Be("2"); + } + + // ----------------------------------------------------------------------- + // RenewCertificateAsync — productCode resolution (issue #26 / local issues/0012) + // Renewals go out as a fresh GenerateOrderSSL order; the product code must + // come from the template (RenewCertificateRequest.ProfileId) when supplied, + // falling back to the connector's DefaultProductCode only when it is not. + // ----------------------------------------------------------------------- + + [Fact] + public async Task RenewCertificateAsync_ProfileIdSet_UsesTemplateProductCode() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.DefaultProductCode = "connector-default-code"; + + var renewReq = new RenewCertificateRequest + { + Csr = MockCertificateData.FakeCsrPem, + ProfileId = "template-product-code", + ValidityDays = 365, + Comment = "Renewal test" + }; + + await BuildClient(cfg).RenewCertificateAsync(MockCertificateData.OrderNumber1, renewReq); + + CapturedOrderBody().GetProperty("productCode").GetString() + .Should().Be("template-product-code", + "the template's own product code must win over the connector default"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task RenewCertificateAsync_ProfileIdBlank_FallsBackToConnectorDefault(string blankProfileId) + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.DefaultProductCode = "connector-default-code"; + + var renewReq = new RenewCertificateRequest + { + Csr = MockCertificateData.FakeCsrPem, + ProfileId = blankProfileId, + ValidityDays = 365, + Comment = "Renewal test" + }; + + await BuildClient(cfg).RenewCertificateAsync(MockCertificateData.OrderNumber1, renewReq); + + CapturedOrderBody().GetProperty("productCode").GetString() + .Should().Be("connector-default-code", + "a blank ProfileId must fall back to the connector's DefaultProductCode, not an empty string"); + } + } +} diff --git a/CERTInext.Tests/CERTInextClientTests.cs b/CERTInext.Tests/CERTInextClientTests.cs index 243d801..a0ade72 100644 --- a/CERTInext.Tests/CERTInextClientTests.cs +++ b/CERTInext.Tests/CERTInextClientTests.cs @@ -689,7 +689,7 @@ public async Task OAuth_InjectsBearerToken_InAuthorizationHeader() pingRequest.RequestMessage.Headers.Should().ContainKey("Authorization", "OAuth mode must inject the Authorization header on outgoing requests"); - var authHeader = pingRequest.RequestMessage.Headers["Authorization"].FirstOrDefault(); + var authHeader = pingRequest.RequestMessage.Headers!["Authorization"].FirstOrDefault(); authHeader.Should().Be($"Bearer {expectedToken}", "the injected token must match the one returned by the token endpoint"); } @@ -713,7 +713,7 @@ public async Task OAuth_DoesNotInjectBearerToken_InAccessKeyMode() .First(e => e.RequestMessage.Path == "/ValidateCredentials"); // Authorization header must be absent in AccessKey mode - bool hasAuthHeader = pingRequest.RequestMessage.Headers.ContainsKey("Authorization"); + bool hasAuthHeader = pingRequest.RequestMessage.Headers!.ContainsKey("Authorization"); hasAuthHeader.Should().BeFalse( "AccessKey mode authenticates via the authKey field in the JSON body, not an HTTP header"); } @@ -744,5 +744,181 @@ public async Task ExecuteWithRetry_MakesThreeAttempts_WhenServerAlwaysReturns500 pingCallCount.Should().Be(3, "ExecuteWithRetryAsync makes 3 total attempts on persistent 5xx errors"); } + + // --------------------------------------------------------------------------- + // GetDcvAsync — POST /GetDcv + // --------------------------------------------------------------------------- + + [Fact] + public async Task GetDcvAsync_ReturnsToken_WhenServerRespondsOk() + { + const string token = "abc123token"; + _server + .Given(Request.Create().WithPath("/GetDcv").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetDcvSuccessJson(token))); + + var client = BuildClient(); + + var result = await client.GetDcvAsync( + MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt); + + result.Should().NotBeNull(); + result.DcvDetails.Should().NotBeNull(); + result.DcvDetails.Token.Should().Be(token); + _server.LogEntries.Should().Contain(e => e.RequestMessage.Path == "/GetDcv"); + } + + [Fact] + public async Task GetDcvAsync_Throws_WhenMetaStatusIsFailure() + { + _server + .Given(Request.Create().WithPath("/GetDcv").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetDcvFailureJson("EMS-DCV-001", "DCV not available"))); + + var client = BuildClient(); + + Func act = () => client.GetDcvAsync( + MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt); + + await act.Should().ThrowAsync() + .WithMessage("*GetDcv failed*"); + } + + /// + /// Regression: this client is built with ThrowOnAnyError=false, so RestSharp catches a + /// cancelled HttpClient.SendAsync internally and returns a non-throwing, unsuccessful + /// RestResponse instead of propagating OperationCanceledException. Before this fix, + /// ExecuteWithRetryAsync passed that response straight to DeserializeOrThrow, which wrapped + /// it in a plain Exception — indistinguishable from a genuine API failure. A caller such as + /// PerformDcvIfNeededAsync's per-domain "catch (OperationCanceledException) { throw; }" guard + /// (added specifically to stop a DCV timeout from being mislabeled as an ordinary per-domain + /// failure) could never actually see the real cancellation, because it never arrived as + /// OperationCanceledException in the first place — a gap a Moq-level test of the plugin alone + /// cannot expose, since a mock can be told to throw whatever type is asked for. This test + /// exercises the real client against a real (if local) HTTP call, which is the only way to + /// pin the actual failure mode. + /// + [Fact] + public async Task GetDcvAsync_ThrowsOperationCanceled_WhenCancellationTokenIsCancelled() + { + _server + .Given(Request.Create().WithPath("/GetDcv").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetDcvSuccessJson())); + + var client = BuildClient(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Func act = () => client.GetDcvAsync( + MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt, cts.Token); + + await act.Should().ThrowAsync( + "a cancelled token must surface as a genuine cancellation, not get wrapped into a " + + "plain Exception that a caller's cancellation-specific catch clause cannot recognize"); + } + + [Fact] + public async Task GetDcvAsync_Throws_WhenServerReturns401() + { + _server + .Given(Request.Create().WithPath("/GetDcv").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(401) + .WithBody(MockCertificateData.UnauthorizedJson())); + + var client = BuildClient(); + + Func act = () => client.GetDcvAsync( + MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt); + + await act.Should().ThrowAsync() + .WithMessage("*Authentication failure*"); + } + + // --------------------------------------------------------------------------- + // VerifyDcvAsync — POST /VerifyDcv + // --------------------------------------------------------------------------- + + [Fact] + public async Task VerifyDcvAsync_Succeeds_WhenServerRespondsOk() + { + _server + .Given(Request.Create().WithPath("/VerifyDcv").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.VerifyDcvSuccessJson())); + + var client = BuildClient(); + + // Should not throw + await client.VerifyDcvAsync( + MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt); + + _server.LogEntries.Should().Contain(e => e.RequestMessage.Path == "/VerifyDcv"); + } + + [Fact] + public async Task VerifyDcvAsync_Throws_WhenMetaStatusIsFailure() + { + _server + .Given(Request.Create().WithPath("/VerifyDcv").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.VerifyDcvFailureJson("EMS-DCV-002", "DNS record not found"))); + + var client = BuildClient(); + + Func act = () => client.VerifyDcvAsync( + MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt); + + await act.Should().ThrowAsync() + .WithMessage("*DNS record not found*"); + } + + [Fact] + public async Task VerifyDcvAsync_Throws_WhenServerReturns401() + { + _server + .Given(Request.Create().WithPath("/VerifyDcv").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(401) + .WithBody(MockCertificateData.UnauthorizedJson())); + + var client = BuildClient(); + + Func act = () => client.VerifyDcvAsync( + MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt); + + await act.Should().ThrowAsync() + .WithMessage("*Authentication failure*"); + } + + [Fact] + public async Task VerifyDcvAsync_Throws_WhenServerReturns500() + { + _server + .Given(Request.Create().WithPath("/VerifyDcv").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(500) + .WithBody(MockCertificateData.ServerErrorJson())); + + var client = BuildClient(); + + Func act = () => client.VerifyDcvAsync( + MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt); + + await act.Should().ThrowAsync(); + } } } diff --git a/CERTInext.Tests/CERTInextClientV2Tests.cs b/CERTInext.Tests/CERTInextClientV2Tests.cs new file mode 100644 index 0000000..6330af4 --- /dev/null +++ b/CERTInext.Tests/CERTInextClientV2Tests.cs @@ -0,0 +1,1605 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// WireMock-based tests for V2 REST API methods on . + /// A real WireMockServer handles the V2 token endpoint and all V2 REST paths so + /// serialisation, routing, and token caching are fully exercised. + /// + public class CERTInextClientV2Tests : IDisposable + { + private readonly WireMockServer _server; + private readonly string _baseUrl; + + public CERTInextClientV2Tests() + { + _server = WireMockServer.Start(); + _baseUrl = _server.Urls[0]; + } + + public void Dispose() => _server.Stop(); + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private CERTInextClient BuildV2Client(string groupNumber = null) => + new CERTInextClient(new CERTInextConfig + { + // A single ApiUrl now serves both V1 and V2 (issues/0022 config consolidation). + ApiUrl = _baseUrl, + AuthMode = "AccessKey", + ApiKey = "test-v1-key", + AccountNumber = "12345", + UseV2Api = true, + OAuthClientId = "my-v2-client", + OAuthClientSecret = "my-v2-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + PageSize = 100, + // Unset by default (matches CERTInextConfig.GroupNumber's own default of + // string.Empty) — issues/0029 test cases override this explicitly. + GroupNumber = groupNumber ?? string.Empty + }); + + private void StubV2Token(int expiresIn = 3600) + { + _server + .Given(Request.Create() + .WithPath("/oauth/token") + .UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2TokenResponseJson(expiresIn))); + } + + // --------------------------------------------------------------------------- + // Token fetch + // --------------------------------------------------------------------------- + + [Fact] + public async Task PingV2Async_FetchesTokenAndCallsAuthMe() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/auth/me") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2AuthMeJson())); + + using var client = BuildV2Client(); + await client.PingV2Async(); + + // Verify both token and auth/me endpoints were called + _server.LogEntries.Should().Contain(e => e.RequestMessage.Path == "/oauth/token"); + _server.LogEntries.Should().Contain(e => e.RequestMessage.Path == "/api/certinext/v2/auth/me"); + } + + [Fact] + public async Task GetAuthMeV2Async_ReturnsAccountNumber() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/auth/me") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2AuthMeJson("99887766"))); + + using var client = BuildV2Client(); + var result = await client.GetAuthMeV2Async(); + + result.AccountNumber.Should().Be("99887766"); + result.AuthType.Should().Be("oauth2"); + } + + [Fact] + public async Task PingV2Async_TokenCached_OnlyOneFetch() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/auth/me") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2AuthMeJson())); + + using var client = BuildV2Client(); + await client.PingV2Async(); + await client.PingV2Async(); // second call — should reuse cached token + + var tokenCalls = 0; + foreach (var entry in _server.LogEntries) + if (entry.RequestMessage.Path == "/oauth/token") tokenCalls++; + + tokenCalls.Should().Be(1, "token should be cached after the first fetch"); + } + + // --------------------------------------------------------------------------- + // PlaceOrderV2Async + // --------------------------------------------------------------------------- + + [Fact] + public async Task PlaceOrderV2Async_ReturnsPendingOrder() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/ssl-certificates") + .UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(201) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2CreateOrderPendingJson(MockCertificateData.V2OrderId1))); + + using var client = BuildV2Client(); + var result = await client.PlaceOrderV2Async( + Constants.ApiV2.FamilySsl, + "842", + new V2CreateSslOrderRequest + { + ProductVariant = "dv", + Requestor = new V2Requestor { Name = "Test", Email = "t@t.com", Phone = "555", Designation = "IT" }, + Certificate = new V2CertificateParams { Domain = "example.com" }, + Subscription = new V2SubscriptionParams { ValidityYears = 1 }, + Agreement = new V2AgreementParams { SignerName = "Test", SignerIp = "1.2.3.4", SignerPlace = "NY", Accepted = true } + }); + + result.OrderId.Should().Be(MockCertificateData.V2OrderId1); + result.Status.Should().Be("pending-dcv"); + } + + [Fact] + public async Task PlaceOrderV2Async_SetsProductCodeHeader() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/ssl-certificates") + .UsingPost() + .WithHeader("X-Product-Code", "842")) + .RespondWith(Response.Create() + .WithStatusCode(201) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2CreateOrderPendingJson())); + + using var client = BuildV2Client(); + var result = await client.PlaceOrderV2Async( + Constants.ApiV2.FamilySsl, "842", + new V2CreateSslOrderRequest + { + Requestor = new V2Requestor { Name = "T", Email = "t@t.com", Phone = "1", Designation = "IT" }, + Certificate = new V2CertificateParams { Domain = "example.com" }, + Subscription = new V2SubscriptionParams(), + Agreement = new V2AgreementParams { SignerName = "T", SignerIp = "1.1.1.1", SignerPlace = "NY", Accepted = true } + }); + + result.Should().NotBeNull(); + } + + // --------------------------------------------------------------------------- + // Issue 0054 item #4: a null/blank product code must omit X-Product-Code + // entirely (spec: "Optional override" on SSL create — an empty override value + // is not itself valid) rather than sending the header empty. + // --------------------------------------------------------------------------- + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task PlaceOrderV2Async_Ssl_NullOrBlankProductCode_OmitsProductCodeHeader(string productCode) + { + StubV2Token(); + _server + .Given(Request.Create().WithPath("/api/certinext/v2/ssl-certificates").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(201) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2CreateOrderPendingJson())); + + using var client = BuildV2Client(); + await client.PlaceOrderV2Async( + Constants.ApiV2.FamilySsl, productCode, + new V2CreateSslOrderRequest + { + Requestor = new V2Requestor { Name = "T", Email = "t@t.com", Phone = "1", Designation = "IT" }, + Certificate = new V2CertificateParams { Domain = "example.com" }, + Subscription = new V2SubscriptionParams(), + Agreement = new V2AgreementParams { SignerName = "T", SignerIp = "1.1.1.1", SignerPlace = "NY", Accepted = true } + }); + + var entry = _server.LogEntries.Last(e => e.RequestMessage.Path == "/api/certinext/v2/ssl-certificates"); + entry.RequestMessage.Headers.Should().NotContainKey("X-Product-Code", + "a null/blank product code is not a valid header override and must be omitted entirely"); + } + + [Fact] + public async Task PlaceOrderV2Async_Ssl_NonBlankProductCode_StillSendsHeader() + { + StubV2Token(); + _server + .Given(Request.Create().WithPath("/api/certinext/v2/ssl-certificates").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(201) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2CreateOrderPendingJson())); + + using var client = BuildV2Client(); + await client.PlaceOrderV2Async( + Constants.ApiV2.FamilySsl, "842", + new V2CreateSslOrderRequest + { + Requestor = new V2Requestor { Name = "T", Email = "t@t.com", Phone = "1", Designation = "IT" }, + Certificate = new V2CertificateParams { Domain = "example.com" }, + Subscription = new V2SubscriptionParams(), + Agreement = new V2AgreementParams { SignerName = "T", SignerIp = "1.1.1.1", SignerPlace = "NY", Accepted = true } + }); + + var entry = _server.LogEntries.Last(e => e.RequestMessage.Path == "/api/certinext/v2/ssl-certificates"); + entry.RequestMessage.Headers.Should().ContainKey("X-Product-Code") + .WhoseValue.Should().Contain("842"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task PlaceOrderV2Async_PrivatePki_NullOrBlankProductCode_OmitsProductCodeHeader(string productCode) + { + StubV2Token(); + _server + .Given(Request.Create().WithPath("/api/certinext/v2/private-pki-certificates").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(201) + .WithHeader("Content-Type", "application/json") + .WithBody("{\"orderId\":\"ord_pki_002\",\"requestId\":\"req_3\",\"status\":\"pending-csr\"," + + "\"variant\":\"intranet-ssl\",\"hostname\":\"intranet.example.com\"}")); + + using var client = BuildV2Client(); + await client.PlaceOrderV2Async(productCode, new V2CreatePrivatePkiOrderRequest + { + Variant = "intranet-ssl", + Hostname = "intranet.example.com", + Requestor = new V2Requestor { Name = "DevOps", Email = "devops@example.com" }, + Subscription = new V2SubscriptionParams { ValidityYears = 1 } + }); + + var entry = _server.LogEntries.Last(e => e.RequestMessage.Path == "/api/certinext/v2/private-pki-certificates"); + entry.RequestMessage.Headers.Should().NotContainKey("X-Product-Code"); + } + + [Fact] + public async Task PlaceOrderV2Async_Signature_NullProductCode_OmitsProductCodeHeader() + { + StubV2Token(); + _server + .Given(Request.Create().WithPath("/api/certinext/v2/signature-certificates").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(201) + .WithHeader("Content-Type", "application/json") + .WithBody("{\"orderId\":\"ord_sig_002\",\"requestId\":\"req_4\",\"status\":\"pending-documents\"," + + "\"subjectType\":\"natural-person\",\"subjectDisplayName\":\"Test Person\"}")); + + using var client = BuildV2Client(); + await client.PlaceOrderV2Async(null, new V2CreateSignatureOrderRequest + { + SubjectType = "natural-person", + Requestor = new V2Requestor { Name = "Test Person", Email = "test.person@example.com" }, + Subject = new V2SignatureSubject { FirstName = "Test", LastName = "Person", Email = "test.person@example.com" } + }); + + var entry = _server.LogEntries.Last(e => e.RequestMessage.Path == "/api/certinext/v2/signature-certificates"); + entry.RequestMessage.Headers.Should().NotContainKey("X-Product-Code"); + } + + // --------------------------------------------------------------------------- + // V2CertificateParams.AdditionalDomains wire serialization (issues/f3-v2-multi-san-limitation.md) + // --------------------------------------------------------------------------- + + [Fact] + public async Task PlaceOrderV2Async_UccOrder_IncludesAdditionalDomainsInWireBody() + { + StubV2Token(); + _server + .Given(Request.Create().WithPath("/api/certinext/v2/ssl-certificates").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(201) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2CreateOrderPendingJson())); + + using var client = BuildV2Client(); + await client.PlaceOrderV2Async( + Constants.ApiV2.FamilySsl, "844", + new V2CreateSslOrderRequest + { + ProductVariant = "dv", + Requestor = new V2Requestor { Name = "T", Email = "t@t.com", Phone = "1", Designation = "IT" }, + Certificate = new V2CertificateParams + { + Domain = "example.com", + AdditionalDomains = new List { "san1.example.com", "san2.example.com" } + }, + Subscription = new V2SubscriptionParams(), + Agreement = new V2AgreementParams { SignerName = "T", SignerIp = "1.1.1.1", SignerPlace = "NY", Accepted = true } + }); + + string requestBody = _server.LogEntries.Last(e => e.RequestMessage.Path == "/api/certinext/v2/ssl-certificates") + .RequestMessage.Body; + requestBody.Should().Contain("\"additionalDomains\""); + requestBody.Should().Contain("san1.example.com"); + requestBody.Should().Contain("san2.example.com"); + } + + [Fact] + public async Task PlaceOrderV2Async_SingleDomainOrder_OmitsAdditionalDomainsFromWireBody() + { + StubV2Token(); + _server + .Given(Request.Create().WithPath("/api/certinext/v2/ssl-certificates").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(201) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2CreateOrderPendingJson())); + + using var client = BuildV2Client(); + await client.PlaceOrderV2Async( + Constants.ApiV2.FamilySsl, "842", + new V2CreateSslOrderRequest + { + ProductVariant = "dv", + Requestor = new V2Requestor { Name = "T", Email = "t@t.com", Phone = "1", Designation = "IT" }, + Certificate = new V2CertificateParams { Domain = "example.com" }, // AdditionalDomains left null + Subscription = new V2SubscriptionParams(), + Agreement = new V2AgreementParams { SignerName = "T", SignerIp = "1.1.1.1", SignerPlace = "NY", Accepted = true } + }); + + string requestBody = _server.LogEntries.Last(e => e.RequestMessage.Path == "/api/certinext/v2/ssl-certificates") + .RequestMessage.Body; + requestBody.Should().NotContain("additionalDomains", + "single-domain orders must not send additionalDomains at all — preserves the pre-fix wire shape"); + } + + // --------------------------------------------------------------------------- + // Issue 0033: family-specific PlaceOrderV2Async overloads + // --------------------------------------------------------------------------- + + [Fact] + public async Task PlaceOrderV2Async_PrivatePki_PostsPrivatePkiBodyToPrivatePkiPath_WithProductCodeHeader() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/private-pki-certificates") + .UsingPost() + .WithHeader("X-Product-Code", "149")) + .RespondWith(Response.Create() + .WithStatusCode(201) + .WithHeader("Content-Type", "application/json") + .WithBody("{\"orderId\":\"ord_pki_001\",\"requestId\":\"req_1\",\"status\":\"pending-csr\"," + + "\"variant\":\"intranet-ssl\",\"hostname\":\"intranet.acme.local\",\"resolvedProductCode\":\"149\"}")); + + using var client = BuildV2Client(); + var result = await client.PlaceOrderV2Async("149", new V2CreatePrivatePkiOrderRequest + { + Variant = "intranet-ssl", + Hostname = "intranet.acme.local", + AdditionalHosts = new List { "portal.acme.local", "10.0.0.50" }, + Requestor = new V2Requestor { Name = "DevOps Team", Email = "devops@acme.com" }, + Subscription = new V2SubscriptionParams { ValidityYears = 1 } + }); + + result.OrderId.Should().Be("ord_pki_001"); + result.Status.Should().Be("pending-csr"); + + var entry = _server.LogEntries.Last(e => e.RequestMessage.Path == "/api/certinext/v2/private-pki-certificates"); + entry.RequestMessage.Headers.Should().ContainKey("Idempotency-Key"); + entry.RequestMessage.Body.Should().NotBeNullOrEmpty(); + using var body = System.Text.Json.JsonDocument.Parse(entry.RequestMessage.Body ?? string.Empty); + body.RootElement.GetProperty("variant").GetString().Should().Be("intranet-ssl"); + body.RootElement.GetProperty("hostname").GetString().Should().Be("intranet.acme.local"); + body.RootElement.GetProperty("additionalHosts").EnumerateArray().Select(e => e.GetString()) + .Should().Equal("portal.acme.local", "10.0.0.50"); + body.RootElement.TryGetProperty("productVariant", out _).Should().BeFalse(); + body.RootElement.TryGetProperty("certificate", out _).Should().BeFalse(); + body.RootElement.TryGetProperty("agreement", out _).Should().BeFalse(); + _server.LogEntries.Should().NotContain(e => e.RequestMessage.Path == "/api/certinext/v2/ssl-certificates"); + } + + [Fact] + public async Task PlaceOrderV2Async_Signature_PostsSignatureBodyToSignaturePath_WithProductCodeHeader() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/signature-certificates") + .UsingPost() + .WithHeader("X-Product-Code", "819")) + .RespondWith(Response.Create() + .WithStatusCode(201) + .WithHeader("Content-Type", "application/json") + .WithBody("{\"orderId\":\"ord_sig_001\",\"requestId\":\"req_2\",\"status\":\"pending-documents\"," + + "\"subjectType\":\"natural-person\",\"subjectDisplayName\":\"Sarah Johnson\",\"resolvedProductCode\":\"819\"}")); + + using var client = BuildV2Client(); + var result = await client.PlaceOrderV2Async("819", new V2CreateSignatureOrderRequest + { + SubjectType = "natural-person", + Requestor = new V2Requestor { Name = "Sarah Johnson", Email = "sarah.johnson@example.com" }, + Subject = new V2SignatureSubject { FirstName = "Sarah", LastName = "Johnson", Email = "sarah.johnson@example.com" } + }); + + result.OrderId.Should().Be("ord_sig_001"); + + var entry = _server.LogEntries.Last(e => e.RequestMessage.Path == "/api/certinext/v2/signature-certificates"); + entry.RequestMessage.Body.Should().NotBeNullOrEmpty(); + using var body = System.Text.Json.JsonDocument.Parse(entry.RequestMessage.Body ?? string.Empty); + body.RootElement.GetProperty("subjectType").GetString().Should().Be("natural-person"); + body.RootElement.GetProperty("subject").GetProperty("email").GetString().Should().Be("sarah.johnson@example.com"); + } + + [Theory] + [InlineData(Constants.ApiV2.FamilyPrivatePki)] + [InlineData(Constants.ApiV2.FamilySignature)] + public async Task PlaceOrderV2Async_SslBody_ToNonSslFamily_Throws_AndSendsNothing(string family) + { + // Regression (issue 0033): the SSL overload used to substitute any slug into the URL, + // which is how a private-pki/signature template sent the SSL body to the wrong family. + StubV2Token(); + + using var client = BuildV2Client(); + Func act = () => client.PlaceOrderV2Async( + family, "149", + new V2CreateSslOrderRequest + { + Requestor = new V2Requestor { Name = "T", Email = "t@t.com" }, + Certificate = new V2CertificateParams { Domain = "example.com" } + }); + + await act.Should().ThrowAsync().WithMessage($"*{family}*"); + _server.LogEntries.Should().NotContain(e => e.RequestMessage.Path.StartsWith("/api/certinext/v2/"), + "no order request may be sent when the SSL body is aimed at another family"); + } + + // --------------------------------------------------------------------------- + // TrackOrderV2Async + // --------------------------------------------------------------------------- + + [Fact] + public async Task TrackOrderV2Async_Issued_ReturnsIssuedStatus() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2TrackOrderIssuedJson(MockCertificateData.V2OrderId1))); + + using var client = BuildV2Client(); + var result = await client.TrackOrderV2Async(Constants.ApiV2.FamilySsl, MockCertificateData.V2OrderId1); + + result.Status.Should().Be("issued"); + result.OrderId.Should().Be(MockCertificateData.V2OrderId1); + } + + [Fact] + public async Task TrackOrderV2Async_NotFound_ThrowsKeyNotFoundException() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/nonexistent") + .UsingGet()) + .RespondWith(Response.Create().WithStatusCode(404)); + + using var client = BuildV2Client(); + await Assert.ThrowsAsync( + () => client.TrackOrderV2Async(Constants.ApiV2.FamilySsl, "nonexistent")); + } + + // --------------------------------------------------------------------------- + // TrackOrderV2Async — nested `revocation` object (issues/0034) + // --------------------------------------------------------------------------- + + [Fact] + public async Task TrackOrderV2Async_Revoked_DeserializesNestedRevocationObject() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2TrackOrderRevokedJson( + MockCertificateData.V2OrderId1, + reason: "cessation-of-operation", + processedAt: "2026-09-24T20:44:41Z"))); + + using var client = BuildV2Client(); + var result = await client.TrackOrderV2Async(Constants.ApiV2.FamilySsl, MockCertificateData.V2OrderId1); + + result.Status.Should().Be("revoked"); + // issues/0034: `revocation` is a nested object — not flat top-level + // revocationReason/revocationDate properties. + result.Revocation.Should().NotBeNull(); + result.Revocation!.Status.Should().Be("Certificate Revoked"); + result.Revocation.Reason.Should().Be("cessation-of-operation"); + result.Revocation.ProcessedAt.Should().Be( + new DateTime(2026, 9, 24, 20, 44, 41, DateTimeKind.Utc)); + } + + [Fact] + public async Task TrackOrderV2Async_NotRevoked_RevocationIsNull() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2TrackOrderIssuedJson(MockCertificateData.V2OrderId1))); + + using var client = BuildV2Client(); + var result = await client.TrackOrderV2Async(Constants.ApiV2.FamilySsl, MockCertificateData.V2OrderId1); + + // issues/0034: the `revocation` key is absent entirely (not present-but-null) on + // an order that has never been revoked. + result.Revocation.Should().BeNull(); + } + + // --------------------------------------------------------------------------- + // DownloadCertificateV2Async + // --------------------------------------------------------------------------- + + [Fact] + public async Task DownloadCertificateV2Async_ReturnsPem() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}/certificate") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2CertificateDownloadJson(MockCertificateData.V2OrderId1))); + + using var client = BuildV2Client(); + var result = await client.DownloadCertificateV2Async(Constants.ApiV2.FamilySsl, MockCertificateData.V2OrderId1); + + result.CertificatePem.Should().StartWith("-----BEGIN CERTIFICATE-----"); + result.SerialNumber.Should().Be("0A1B2C3D4E5F"); + result.OrderId.Should().Be(MockCertificateData.V2OrderId1); + } + + // --------------------------------------------------------------------------- + // RevokeOrderV2Async + // --------------------------------------------------------------------------- + + [Fact] + public async Task RevokeOrderV2Async_SuccessfulRevoke() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}/revoke") + .UsingPost()) + .RespondWith(Response.Create().WithStatusCode(204)); + + using var client = BuildV2Client(); + // Should not throw + await client.RevokeOrderV2Async( + Constants.ApiV2.FamilySsl, + MockCertificateData.V2OrderId1, + new V2RevokeRequest { Reason = "superseded", Note = "Replaced." }); + } + + [Fact] + public async Task RevokeOrderV2Async_422_ThrowsInvalidOperationException() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}/revoke") + .UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(422) + .WithHeader("Content-Type", "application/problem+json") + .WithBody(MockCertificateData.V2ProblemDetailsJson(422, "Unprocessable Entity", "Order not in issued state", "EMS-931"))); + + using var client = BuildV2Client(); + await Assert.ThrowsAsync( + () => client.RevokeOrderV2Async( + Constants.ApiV2.FamilySsl, + MockCertificateData.V2OrderId1, + new V2RevokeRequest { Reason = "superseded" })); + } + + // --------------------------------------------------------------------------- + // Regression (issues/0019): 422 message reflects the CA's actual detail + // rather than presuming "order not in issued state" for every 422. + // --------------------------------------------------------------------------- + + [Fact] + public async Task RevokeOrderV2Async_422_LabelsByActualDetail_NotHardcodedIssuedStateMessage() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}/revoke") + .UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(422) + .WithHeader("Content-Type", "application/problem+json") + // Observed live sandbox behavior for a revoke attempted while the + // order is still internally finalizing — no EMS code in this detail. + .WithBody(MockCertificateData.V2ProblemDetailsJson( + 422, "Unprocessable Entity", "Certificate Request still being processed"))); + + using var client = BuildV2Client(); + var ex = await Assert.ThrowsAsync( + () => client.RevokeOrderV2Async( + Constants.ApiV2.FamilySsl, + MockCertificateData.V2OrderId1, + new V2RevokeRequest { Reason = "superseded" })); + + ex.Message.Should().Contain("still being processed"); + ex.Message.Should().NotContain("not in issued state", + "the message must reflect the CA's actual detail text, not a hardcoded assumption"); + } + + [Fact] + public async Task RevokeOrderV2Async_422_DistinctEmsCode_LabelsByThatCode() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}/revoke") + .UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(422) + .WithHeader("Content-Type", "application/problem+json") + .WithBody(MockCertificateData.V2ProblemDetailsJson( + 422, "Unprocessable Entity", "EMS-969 Revoke reason ID missing"))); + + using var client = BuildV2Client(); + var ex = await Assert.ThrowsAsync( + () => client.RevokeOrderV2Async( + Constants.ApiV2.FamilySsl, + MockCertificateData.V2OrderId1, + new V2RevokeRequest { Reason = "superseded" })); + + ex.Message.Should().Contain("EMS-969"); + ex.Message.Should().NotContain("not in issued state"); + } + + [Fact] + public async Task RevokeOrderV2Async_404_ThrowsNotFoundOrNotRevokable_NotGenericNotFound() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}/revoke") + .UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(404) + .WithHeader("Content-Type", "application/problem+json") + .WithBody(MockCertificateData.V2ProblemDetailsJson( + 404, "Not Found", "Order not found or not in a revokable state."))); + + using var client = BuildV2Client(); + var ex = await Assert.ThrowsAsync( + () => client.RevokeOrderV2Async( + Constants.ApiV2.FamilySsl, + MockCertificateData.V2OrderId1, + new V2RevokeRequest { Reason = "superseded" })); + + ex.Message.Should().Contain("not found or not in a revokable state"); + } + + // --------------------------------------------------------------------------- + // Product-family resolution + // --------------------------------------------------------------------------- + + [Fact] + public async Task ResolveAndTrackOrderV2Async_FindsOrderInSslFamily() + { + StubV2Token(); + // SSL family returns 404 → should try private-pki... wait, we want to find it in SSL + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2TrackOrderIssuedJson(MockCertificateData.V2OrderId1))); + + using var client = BuildV2Client(); + var result = await client.ResolveAndTrackOrderV2Async(MockCertificateData.V2OrderId1); + + result.OrderId.Should().Be(MockCertificateData.V2OrderId1); + result.Status.Should().Be("issued"); + } + + [Fact] + public async Task ResolveAndTrackOrderV2Async_FindsOrderInPrivatePkiFamily() + { + StubV2Token(); + // SSL → 404, private-pki → 200 + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId2}") + .UsingGet()) + .RespondWith(Response.Create().WithStatusCode(404)); + + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/private-pki-certificates/{MockCertificateData.V2OrderId2}") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2TrackOrderIssuedJson(MockCertificateData.V2OrderId2))); + + using var client = BuildV2Client(); + var result = await client.ResolveAndTrackOrderV2Async(MockCertificateData.V2OrderId2); + + result.OrderId.Should().Be(MockCertificateData.V2OrderId2); + } + + [Fact] + public async Task ResolveAndTrackOrderV2Async_NotInAnyFamily_ThrowsKeyNotFoundException() + { + StubV2Token(); + foreach (var family in new[] { "ssl-certificates", "private-pki-certificates", "signature-certificates" }) + { + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/{family}/ord_missing") + .UsingGet()) + .RespondWith(Response.Create().WithStatusCode(404)); + } + + using var client = BuildV2Client(); + await Assert.ThrowsAsync( + () => client.ResolveAndTrackOrderV2Async("ord_missing")); + } + + // --------------------------------------------------------------------------- + // GetDcvV2Async + // --------------------------------------------------------------------------- + + [Fact] + public async Task GetDcvV2Async_ReturnsChallengeWithToken() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}/dcv") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2DcvChallengeJson("my-dcv-token"))); + + using var client = BuildV2Client(); + var result = await client.GetDcvV2Async(MockCertificateData.V2OrderId1, Constants.ApiV2.FamilySsl); + + result.Token.Should().Be("my-dcv-token"); + result.TokenExpiryDate.Should().Be("2026-12-31 23:59:59"); + } + + /// + /// Regression (issues/0037): the live GetDcv response on a fresh-domain order came + /// back as exactly {"tokenExpiryDate":"...","token":"..."} — a shape that + /// matches neither the spec's worked example (orderNumber/domainName/ + /// dcvMethod/fileNameContent, the shape the DTO originally modeled) nor + /// the spec's prose (method/txtToken). Before the fix, deserializing this + /// body left FileNameContent null (unmapped JSON properties are silently + /// ignored), which drove the plugin's null-token guard and stranded the order at + /// EXTERNALVALIDATION forever. This pins the real field name (token) against + /// the exact live body captured in issues/0037, verbatim. + /// + [Fact] + public async Task GetDcvV2Async_LiveShape_DeserializesTokenField_NotFileNameContent() + { + StubV2Token(); + const string liveBody = @"{""tokenExpiryDate"":""2026-09-27 15:27:00"",""token"":""D6026954B9EB7D31E3FE8B2194F07087""}"; + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}/dcv") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(liveBody)); + + using var client = BuildV2Client(); + var result = await client.GetDcvV2Async(MockCertificateData.V2OrderId1, Constants.ApiV2.FamilySsl); + + result.Should().NotBeNull(); + result.Token.Should().Be("D6026954B9EB7D31E3FE8B2194F07087", + "the live wire field is 'token', not 'fileNameContent' (issues/0037)"); + result.TokenExpiryDate.Should().Be("2026-09-27 15:27:00"); + } + + [Fact] + public async Task GetDcvV2Async_NonSuccess_Throws() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}/dcv") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(400) + .WithHeader("Content-Type", "application/problem+json") + .WithBody(MockCertificateData.V2ProblemDetailsJson(400, "Bad Request", "Order not found"))); + + using var client = BuildV2Client(); + await Assert.ThrowsAsync( + () => client.GetDcvV2Async(MockCertificateData.V2OrderId1, Constants.ApiV2.FamilySsl)); + } + + // --------------------------------------------------------------------------- + // VerifyDcvV2Async + // --------------------------------------------------------------------------- + + [Fact] + public async Task VerifyDcvV2Async_200Ok_ReturnsVerified() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}/dcv/verify") + .UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2DcvVerifySuccessJson())); + + using var client = BuildV2Client(); + var result = await client.VerifyDcvV2Async(MockCertificateData.V2OrderId1, "example.com", Constants.ApiV2.FamilySsl); + + result.OverallStatus.Should().Be("VERIFIED"); + } + + [Fact] + public async Task VerifyDcvV2Async_204NoContent_ReturnsVerified() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}/dcv/verify") + .UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(204)); + + using var client = BuildV2Client(); + var result = await client.VerifyDcvV2Async(MockCertificateData.V2OrderId1, "example.com", Constants.ApiV2.FamilySsl); + + result.OverallStatus.Should().Be("VERIFIED"); + } + + [Fact] + public async Task VerifyDcvV2Async_422_ThrowsInvalidOperationException() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}/dcv/verify") + .UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(422) + .WithHeader("Content-Type", "application/problem+json") + .WithBody(MockCertificateData.V2ProblemDetailsJson(422, "Unprocessable Entity", "DNS record not found"))); + + using var client = BuildV2Client(); + var ex = await Assert.ThrowsAsync( + () => client.VerifyDcvV2Async(MockCertificateData.V2OrderId1, "example.com", Constants.ApiV2.FamilySsl)); + + ex.Message.Should().Contain("DCV verification failed"); + } + + [Fact] + public async Task VerifyDcvV2Async_SendsDnsTxtMethod() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}/dcv/verify") + .UsingPost() + .WithBody(b => b != null && b.Contains("\"dns-txt\""))) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2DcvVerifySuccessJson())); + + using var client = BuildV2Client(); + var result = await client.VerifyDcvV2Async(MockCertificateData.V2OrderId1, "example.com", Constants.ApiV2.FamilySsl); + + result.OverallStatus.Should().Be("VERIFIED"); + } + + // --------------------------------------------------------------------------- + // DownloadCertificateV2Async — chain PEM assembly + // --------------------------------------------------------------------------- + + [Fact] + public async Task DownloadCertificateV2Async_WithChainPem_DeserializesChain() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}/certificate") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2CertificateDownloadWithChainJson(MockCertificateData.V2OrderId1))); + + using var client = BuildV2Client(); + var result = await client.DownloadCertificateV2Async(Constants.ApiV2.FamilySsl, MockCertificateData.V2OrderId1); + + result.CertificatePem.Should().StartWith("-----BEGIN CERTIFICATE-----"); + result.ChainPem.Should().NotBeNullOrEmpty("API returned a chainPem array"); + result.ChainPem.Should().HaveCount(1); + result.ChainPem[0].Should().Contain("INTERMEDIATE"); + } + + [Fact] + public async Task DownloadCertificateV2Async_WithoutChainPem_ChainIsNull() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}/certificate") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2CertificateDownloadJson(MockCertificateData.V2OrderId1))); + + using var client = BuildV2Client(); + var result = await client.DownloadCertificateV2Async(Constants.ApiV2.FamilySsl, MockCertificateData.V2OrderId1); + + result.CertificatePem.Should().StartWith("-----BEGIN CERTIFICATE-----"); + result.ChainPem.Should().BeNullOrEmpty("API did not return chainPem"); + } + + // --------------------------------------------------------------------------- + // Token refresh when expired + // --------------------------------------------------------------------------- + + [Fact] + public async Task Token_CachedAndReused_WhenNotNearExpiry() + { + // Restates PingV2Async_TokenCached_OnlyOneFetch's proof via GetAuthMeV2Async — kept + // as its own case (G11) so cache-reuse and expiry-refetch (below) are each one + // single-purpose test rather than folded into one. + StubV2Token(); + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/auth/me") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2AuthMeJson())); + + using var client = BuildV2Client(); + await client.GetAuthMeV2Async(); + await client.GetAuthMeV2Async(); + + TokenFetchCount(_server).Should().Be(1, "a non-expired cached token must be reused"); + } + + /// + /// G11: a cached token past its early-expiry window must be re-fetched via a fresh + /// client_credentials call — never via refresh_token. Per the V2 spec, + /// refresh tokens are single-use and refreshing invalidates the current access token, so + /// this locks in the client's current (safe) behaviour of only ever using + /// client_credentials. + /// + /// The real cache TTL floors at 30 seconds (Math.Max(expires_in - 60, 30) in + /// GetOrRefreshV2TokenAsync), which is too slow to wait out in a unit test — so this + /// reaches into the private _v2TokenExpiry field via reflection to simulate the + /// passage of time instead of actually waiting. + /// + [Fact] + public async Task Token_RefetchedViaClientCredentials_WhenPastEarlyExpiryWindow() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/auth/me") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2AuthMeJson())); + + using var client = BuildV2Client(); + await client.GetAuthMeV2Async(); + TokenFetchCount(_server).Should().Be(1); + + // Simulate the cached token having entered/passed its early-expiry window. + SetV2TokenExpiry(client, DateTime.UtcNow.AddSeconds(-1)); + + await client.GetAuthMeV2Async(); + TokenFetchCount(_server).Should().Be(2, + "a token past its early-expiry window must be re-fetched, not reused"); + + // Every /oauth/token call must use client_credentials — never refresh_token, even + // though the stubbed token response includes a refresh_token field. + foreach (var entry in _server.LogEntries.Where(e => e.RequestMessage.Path == "/oauth/token")) + { + string body = entry.RequestMessage.Body ?? string.Empty; + body.Should().Contain("grant_type=client_credentials"); + body.Should().NotContain("grant_type=refresh_token", + "refresh tokens are single-use per the V2 spec — the client must never send this grant proactively"); + } + } + + private static int TokenFetchCount(WireMockServer server) => + server.LogEntries.Count(e => e.RequestMessage.Path == "/oauth/token"); + + private static void SetV2TokenExpiry(CERTInextClient client, DateTime value) + { + var field = typeof(CERTInextClient) + .GetField("_v2TokenExpiry", BindingFlags.NonPublic | BindingFlags.Instance); + field.Should().NotBeNull("test relies on CERTInextClient's private _v2TokenExpiry field existing"); + field!.SetValue(client, value); + } + + // --------------------------------------------------------------------------- + // G2: OAuth2 token failure hints (401 invalid_client vs 403 unauthorized_client) + // --------------------------------------------------------------------------- + + [Fact] + public async Task GetOrRefreshV2Token_Throws_DistinctHint_On401InvalidClient() + { + _server + .Given(Request.Create().WithPath("/oauth/token").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(401) + .WithHeader("Content-Type", "application/json") + .WithBody(@"{""error"":""invalid_client"",""error_description"":""Client authentication failed.""}")); + + using var client = BuildV2Client(); + + Func act = () => client.PingV2Async(); + + await act.Should().ThrowAsync() + .WithMessage("*401*") + .Where(ex => ex.Message.Contains("ClientId", StringComparison.OrdinalIgnoreCase) + || ex.Message.Contains("ClientSecret", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task GetOrRefreshV2Token_Throws_DistinctHint_On403UnauthorizedClient() + { + _server + .Given(Request.Create().WithPath("/oauth/token").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(403) + .WithHeader("Content-Type", "application/json") + .WithBody(@"{""error"":""unauthorized_client"",""error_description"":""Key not generated in OAuth mode.""}")); + + using var client = BuildV2Client(); + + Func act = () => client.PingV2Async(); + + await act.Should().ThrowAsync() + .WithMessage("*403*") + .Where(ex => ex.Message.Contains("OAuth mode", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task GetOrRefreshV2Token_401And403_ProduceDifferentMessages() + { + // The two hints must actually differ — otherwise the distinction above is cosmetic. + _server + .Given(Request.Create().WithPath("/oauth/token").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(401) + .WithHeader("Content-Type", "application/json") + .WithBody(@"{""error"":""invalid_client""}")); + using var client401 = BuildV2Client(); + Exception ex401 = null; + try { await client401.PingV2Async(); } catch (Exception ex) { ex401 = ex; } + + _server.Reset(); + _server + .Given(Request.Create().WithPath("/oauth/token").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(403) + .WithHeader("Content-Type", "application/json") + .WithBody(@"{""error"":""unauthorized_client""}")); + using var client403 = BuildV2Client(); + Exception ex403 = null; + try { await client403.PingV2Async(); } catch (Exception ex) { ex403 = ex; } + + ex401.Should().NotBeNull(); + ex403.Should().NotBeNull(); + ex401!.Message.Should().NotBe(ex403!.Message); + } + + [Fact] + public async Task GetOrRefreshV2Token_Throws_WhenTokenResponseLacksAccessToken() + { + _server + .Given(Request.Create().WithPath("/oauth/token").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(@"{""token_type"":""Bearer"",""expires_in"":3600}")); + + using var client = BuildV2Client(); + + Func act = () => client.PingV2Async(); + + await act.Should().ThrowAsync() + .WithMessage("*access_token*"); + } + + // --------------------------------------------------------------------------- + // G12: RFC 7807 field-level errors surfaced in the exception message + // --------------------------------------------------------------------------- + + [Fact] + public async Task ThrowOnV2Failure_IncludesFieldLevelErrors_FromProblemJson() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath($"/api/certinext/v2/ssl-certificates/{MockCertificateData.V2OrderId1}") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(400) + .WithHeader("Content-Type", "application/problem+json") + .WithBody( + @"{""type"":""https://api.certinext.io/errors/validation""," + + @"""title"":""Bad Request"",""status"":400," + + @"""detail"":""Body malformed""," + + @"""errors"":[{""field"":""certificate.domain"",""message"":""must not be blank""}]}")); + + using var client = BuildV2Client(); + + Func act = () => client.TrackOrderV2Async(Constants.ApiV2.FamilySsl, MockCertificateData.V2OrderId1); + + await act.Should().ThrowAsync() + .WithMessage("*certificate.domain*must not be blank*"); + } + + // --------------------------------------------------------------------------- + // ListOrdersV2Async (issues/0022) — V2 /reports/orders page enumeration + // --------------------------------------------------------------------------- + + [Fact] + public async Task ListOrdersV2Async_MultiplePages_EnumeratesAllRowsInOrder() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/reports/orders") + .WithParam("page", "1") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2OrdersReportJson( + page: 1, totalPages: 2, orderNumbers: new[] { "ord_p1_001", "ord_p1_002" }))); + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/reports/orders") + .WithParam("page", "2") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2OrdersReportJson( + page: 2, totalPages: 2, orderNumbers: new[] { "ord_p2_001" }))); + + using var client = BuildV2Client(); + var results = new List(); + await foreach (var row in client.ListOrdersV2Async(pageSize: 2)) + results.Add(row); + + results.Should().HaveCount(3); + results.ConvertAll(r => r.OrderNumber).Should().Equal("ord_p1_001", "ord_p1_002", "ord_p2_001"); + } + + [Fact] + public async Task ListOrdersV2Async_NoRows_ReturnsEmpty() + { + StubV2Token(); + _server + .Given(Request.Create().WithPath("/api/certinext/v2/reports/orders").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2OrdersReportJson(page: 1, totalPages: 1, orderNumbers: Array.Empty()))); + + using var client = BuildV2Client(); + var results = new List(); + await foreach (var row in client.ListOrdersV2Async()) + results.Add(row); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task ListOrdersV2Async_PassesFromAndToAsQueryParams() + { + StubV2Token(); + // Only matches if from/to were actually sent as query params — if the client + // dropped them, WireMock's default (unmatched) 404 response would make + // ThrowOnV2Failure throw, and the test would fail. + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/reports/orders") + .WithParam("from", "2026-01-01") + .WithParam("to", "2026-12-31") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2OrdersReportJson(page: 1, totalPages: 1, orderNumbers: Array.Empty()))); + + using var client = BuildV2Client(); + var results = new List(); + await foreach (var row in client.ListOrdersV2Async(from: "2026-01-01", to: "2026-12-31")) + results.Add(row); + + results.Should().BeEmpty(); + } + + // --------------------------------------------------------------------------- + // ListOrdersV2Async — GroupNumber query param (issues/0029) + // --------------------------------------------------------------------------- + + [Fact] + public async Task ListOrdersV2Async_GroupNumberConfigured_PassesGroupNumberQueryParam() + { + StubV2Token(); + // Only matches if groupNumber was actually sent as a query param — if the client + // dropped it, WireMock's default (unmatched) 404 response would make + // ThrowOnV2Failure throw, and the test would fail. + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/reports/orders") + .WithParam("groupNumber", "GRP-555") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2OrdersReportJson(page: 1, totalPages: 1, orderNumbers: Array.Empty()))); + + using var client = BuildV2Client(groupNumber: "GRP-555"); + var results = new List(); + await foreach (var row in client.ListOrdersV2Async()) + results.Add(row); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task ListOrdersV2Async_GroupNumberBlank_OmitsGroupNumberQueryParam() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/reports/orders") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2OrdersReportJson(page: 1, totalPages: 1, orderNumbers: Array.Empty()))); + + using var client = BuildV2Client(groupNumber: string.Empty); + var results = new List(); + await foreach (var row in client.ListOrdersV2Async()) + results.Add(row); + + results.Should().BeEmpty(); + + string rawQuery = _server.LogEntries + .Last(e => e.RequestMessage.Path == "/api/certinext/v2/reports/orders") + .RequestMessage.RawQuery ?? string.Empty; + rawQuery.Should().NotContain("groupNumber", + "an unconfigured GroupNumber must not appear on the orders-report query string"); + } + + [Fact] + public async Task ListOrdersV2Async_PageSizeOver100_ClampedServerRequest() + { + StubV2Token(); + // Only matches size=100 — if the client sent the raw 500 through, this would 404. + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/reports/orders") + .WithParam("size", "100") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2OrdersReportJson(page: 1, totalPages: 1, orderNumbers: Array.Empty()))); + + using var client = BuildV2Client(); + var results = new List(); + await foreach (var row in client.ListOrdersV2Async(pageSize: 500)) + results.Add(row); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task ListOrdersV2Async_NonSuccessResponse_Throws() + { + StubV2Token(); + _server + .Given(Request.Create().WithPath("/api/certinext/v2/reports/orders").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(500) + .WithHeader("Content-Type", "application/problem+json") + .WithBody(@"{""title"":""Internal Server Error"",""status"":500,""detail"":""boom""}")); + + using var client = BuildV2Client(); + + Func act = async () => + { + await foreach (var _ in client.ListOrdersV2Async()) { } + }; + + await act.Should().ThrowAsync().WithMessage("*V2 list orders*"); + } + + // --------------------------------------------------------------------------- + // GetProductDetailsV2Async / ParseProductDetailsV2Response (issue 0025 / 0016) + // --------------------------------------------------------------------------- + + [Fact] + public async Task GetProductDetailsV2Async_NestedCategoryEnvelope_FlattensProducts() + { + StubV2Token(); + _server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetCatalogProductsV2NestedJson())); + + using var client = BuildV2Client(); + List products = await client.GetProductDetailsV2Async(); + + products.Should().HaveCount(2); + products.Should().ContainSingle(p => p.ProductCode == MockCertificateData.ProfileIdTls + && p.ProductName == "TLS Server" + && p.ProductType == "SSL/TLS Certificates" + && p.ProductTypeId == "13" + && p.Active); + products.Should().ContainSingle(p => p.ProductCode == MockCertificateData.ProfileIdClient); + } + + // --------------------------------------------------------------------------- + // GetProductDetailsV2Async — GroupNumber query param (issues/0029) + // --------------------------------------------------------------------------- + + [Fact] + public async Task GetProductDetailsV2Async_GroupNumberConfigured_PassesGroupNumberQueryParam() + { + StubV2Token(); + // Only matches if groupNumber was actually sent as a query param — if the client + // dropped it, WireMock's default (unmatched) 404 response would make + // ThrowOnV2Failure throw, and the test would fail. + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/catalog/products") + .WithParam("groupNumber", "GRP-555") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetCatalogProductsV2NestedJson())); + + using var client = BuildV2Client(groupNumber: "GRP-555"); + List products = await client.GetProductDetailsV2Async(); + + products.Should().HaveCount(2); + } + + [Fact] + public async Task GetProductDetailsV2Async_GroupNumberBlank_OmitsGroupNumberQueryParam() + { + StubV2Token(); + _server + .Given(Request.Create() + .WithPath("/api/certinext/v2/catalog/products") + .UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetCatalogProductsV2NestedJson())); + + using var client = BuildV2Client(groupNumber: string.Empty); + List products = await client.GetProductDetailsV2Async(); + + products.Should().HaveCount(2); + + string rawQuery = _server.LogEntries + .Last(e => e.RequestMessage.Path == "/api/certinext/v2/catalog/products") + .RequestMessage.RawQuery ?? string.Empty; + rawQuery.Should().NotContain("groupNumber", + "an unconfigured GroupNumber must not appear on the catalog query string"); + } + + // --------------------------------------------------------------------------- + // ProductTypeId flattening (issues/f3-v2-multi-san-limitation.md): productTypeID + // must survive every catalog response shape so EnrollV2Async can detect UCC products. + // --------------------------------------------------------------------------- + + [Fact] + public async Task GetProductDetailsV2Async_NestedCategoryEnvelope_UccProductTypeId_Preserved() + { + StubV2Token(); + _server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(@"{ + ""products"":[ + { ""categoryName"":""SSL/TLS Certificates"", ""categoryID"":""1"", ""currencyType"":""USD"", + ""products"":[ + {""productCode"":""844"",""productName"":""DV SSL Certificate UCC"",""productTypeID"":""15""}, + {""productCode"":""842"",""productName"":""DV SSL Certificate"",""productTypeID"":""13""} + ] + } + ] +}")); + + using var client = BuildV2Client(); + List products = await client.GetProductDetailsV2Async(); + + products.Should().ContainSingle(p => p.ProductCode == "844" && p.ProductTypeId == "15", + "UCC product's productTypeID must survive the nested-category flattening"); + products.Should().ContainSingle(p => p.ProductCode == "842" && p.ProductTypeId == "13"); + } + + [Fact] + public async Task GetProductDetailsV2Async_FlatProductIdRow_UccProductTypeId_Preserved() + { + StubV2Token(); + _server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(@"{ + ""products"":[ + {""productId"":""845"",""productName"":""DV SSL Certificate Wildcard UCC"",""masterProductName"":""DV SSL Certificate Wildcard UCC"",""productTypeID"":""21""} + ] +}")); + + using var client = BuildV2Client(); + List products = await client.GetProductDetailsV2Async(); + + products.Should().ContainSingle(p => p.ProductCode == "845" && p.ProductTypeId == "21", + "UCC product's productTypeID must survive the flat productId row shape"); + } + + [Fact] + public async Task GetProductDetailsV2Async_FlatProductCodeRow_UccProductTypeId_Preserved() + { + StubV2Token(); + _server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(@"[ + {""productCode"":""851"",""productName"":""EV SSL Certificate UCC"",""productType"":""SSL/TLS Certificates"",""productTypeID"":""20"",""active"":true} +]")); + + using var client = BuildV2Client(); + List products = await client.GetProductDetailsV2Async(); + + products.Should().ContainSingle(p => p.ProductCode == "851" && p.ProductTypeId == "20", + "UCC product's productTypeID must survive the flat productCode row shape (direct DTO deserialize)"); + } + + [Fact] + public async Task GetProductDetailsV2Async_FlatProductIdRows_MapsToProductCode() + { + StubV2Token(); + _server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetCatalogProductsV2FlatJson())); + + using var client = BuildV2Client(); + List products = await client.GetProductDetailsV2Async(); + + products.Should().HaveCount(2); + products.Should().Contain(p => p.ProductCode == MockCertificateData.ProfileIdTls && p.Active); + } + + [Fact] + public async Task GetProductDetailsV2Async_BareArray_Parses() + { + StubV2Token(); + _server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetCatalogProductsV2BareArrayJson())); + + using var client = BuildV2Client(); + List products = await client.GetProductDetailsV2Async(); + + products.Should().ContainSingle(p => p.ProductCode == MockCertificateData.ProfileIdTls); + } + + [Fact] + public async Task GetProductDetailsV2Async_EmptyCatalog_ReturnsEmptyList() + { + StubV2Token(); + _server + .Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetCatalogProductsV2EmptyJson())); + + using var client = BuildV2Client(); + List products = await client.GetProductDetailsV2Async(); + + products.Should().BeEmpty(); + } + } +} diff --git a/CERTInext.Tests/EmailNotificationsEnrollmentTests.cs b/CERTInext.Tests/EmailNotificationsEnrollmentTests.cs new file mode 100644 index 0000000..435f260 --- /dev/null +++ b/CERTInext.Tests/EmailNotificationsEnrollmentTests.cs @@ -0,0 +1,299 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Regression tests for issues/0027-v2-request-builder-drops-config-fields.md item 1a: the V2 + /// order body's emailNotifications field was hardcoded "all", ignoring the + /// connector's EmailNotifications config entirely. These tests exercise + /// EnrollV2Async end-to-end (through ) against a + /// Strict mock, plus a direct DTO serialization check for + /// , following the pattern established + /// by V2SubscriptionEnrollmentTests.cs. + /// + /// Mapping under test (user-decided, see issue 0027's 2026-09-28 real-inbox probe and "Fix + /// pass (2026-09-28) — EmailNotifications" section): "1" -> "all", "0" -> "0", + /// blank/whitespace/unset -> null (omitted; CA defaults to "all"), any other value + /// (including the literal "all") fails the enrollment before any CA call. + /// + public class EmailNotificationsEnrollmentTests + { + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + // Default here intentionally matches CERTInextConfig's own property default ("0") — NOT + // a `?? "0"`-style fallback applied to the parameter, which would silently coerce an + // explicitly-passed null (a real Theory case below) back to "0" and defeat that test case. + private static CERTInextCAPlugin BuildV2Plugin( + ICERTInextClient client, + string emailNotifications = "0") => + new CERTInextCAPlugin(client, new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + PickupRetries = 0, + EmailNotifications = emailNotifications + }); + + private static EnrollmentProductInfo MakeV2ProductInfo(string productCode, string productVariant) => + new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = productCode, + ["ProductFamily"] = "ssl", + ["ProductVariant"] = productVariant, + ["DomainName"] = "example.com" + } + }; + + private static void StubCatalog(Mock mock, string productCode, string productTypeId) => + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = productCode, ProductTypeId = productTypeId, Active = true } + }); + + private static void StubHappyOrderPlacement(Mock mock, string orderId) + { + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), orderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), orderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = orderId, Status = "pending-dcv" }); + } + + private static string GenerateCsrPem(string cn) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + AsymmetricCipherKeyPair kp = keyGen.GenerateKeyPair(); + + var csr = new Pkcs10CertificationRequest( + "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, null, kp.Private); + + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + private static async Task<(EnrollmentResult Result, V2CreateSslOrderRequest Captured)> RunEnrollAsync( + Mock mock, CERTInextCAPlugin plugin, string orderId = "ord_email_001") + { + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = orderId, Status = "pending-dcv" }); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), + subject: "CN=example.com", + san: null, + productInfo: MakeV2ProductInfo("842", "dv"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + return (result, captured); + } + + // --------------------------------------------------------------------------- + // EnrollV2Async wiring — valid mapped values + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_V2_EmailNotifications_1_MapsToAll() + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // DV SSL (non-UCC) + StubHappyOrderPlacement(mock, "ord_email_001"); + + var plugin = BuildV2Plugin(mock.Object, emailNotifications: "1"); + + var (result, captured) = await RunEnrollAsync(mock, plugin, "ord_email_001"); + + result.CARequestID.Should().Be("ord_email_001"); + captured.Should().NotBeNull(); + captured!.EmailNotifications.Should().Be("all", + "EmailNotifications=\"1\" must map to the CA's full notification set (\"all\")"); + } + + [Fact] + public async Task Enroll_V2_EmailNotifications_0_StaysZero() + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); + StubHappyOrderPlacement(mock, "ord_email_002"); + + var plugin = BuildV2Plugin(mock.Object, emailNotifications: "0"); + + var (result, captured) = await RunEnrollAsync(mock, plugin, "ord_email_002"); + + result.CARequestID.Should().Be("ord_email_002"); + captured!.EmailNotifications.Should().Be("0", + "EmailNotifications=\"0\" must be forwarded as the literal \"0\" — confirmed live " + + "(2026-09-28) to suppress order-creation emails on V2, the same as V1"); + } + + [Fact] + public async Task Enroll_V2_EmailNotifications_1_IsTrimmedBeforeMapping() + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); + StubHappyOrderPlacement(mock, "ord_email_003"); + + var plugin = BuildV2Plugin(mock.Object, emailNotifications: " 1 "); + + var (result, captured) = await RunEnrollAsync(mock, plugin, "ord_email_003"); + + result.CARequestID.Should().Be("ord_email_003"); + captured!.EmailNotifications.Should().Be("all", + "surrounding whitespace must be trimmed before comparing against \"1\"/\"0\""); + } + + // --------------------------------------------------------------------------- + // EnrollV2Async wiring — blank/unset omits the field entirely + // --------------------------------------------------------------------------- + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Enroll_V2_EmailNotifications_Blank_OmitsField(string configValue) + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); + StubHappyOrderPlacement(mock, "ord_email_004"); + + var plugin = BuildV2Plugin(mock.Object, emailNotifications: configValue); + + var (result, captured) = await RunEnrollAsync(mock, plugin, "ord_email_004"); + + result.CARequestID.Should().Be("ord_email_004"); + captured!.EmailNotifications.Should().BeNull( + "a blank/unset EmailNotifications must leave the field null so it is omitted on the " + + "wire and the CA's own default (\"all\") applies"); + } + + [Fact] + public void V2CreateSslOrderRequest_Serialization_OmitsEmailNotifications_WhenNull() + { + var request = new V2CreateSslOrderRequest + { + ProductVariant = "dv", + EmailNotifications = null, + Certificate = new V2CertificateParams { Domain = "example.com" } + }; + + string json = JsonSerializer.Serialize(request, ClientEquivalentJsonOptions()); + + json.Should().NotContain("emailNotifications", + "the emailNotifications key itself must be absent when unset, not present-but-null, " + + "under the client's actual serializer options"); + } + + [Fact] + public void V2CreateSslOrderRequest_Serialization_IncludesEmailNotifications_WhenSet() + { + var request = new V2CreateSslOrderRequest + { + ProductVariant = "dv", + EmailNotifications = "0", + Certificate = new V2CertificateParams { Domain = "example.com" } + }; + + string json = JsonSerializer.Serialize(request, ClientEquivalentJsonOptions()); + + json.Should().Contain("\"emailNotifications\":\"0\""); + } + + // Mirrors the client's actual GetJsonOptions() (private) — see + // V2SubscriptionEnrollmentTests.ClientEquivalentJsonOptions for the same rationale: plain + // JsonSerializer.Serialize(req) without these options would show "emailNotifications":null + // instead of omitting the key. + private static JsonSerializerOptions ClientEquivalentJsonOptions() => new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + // --------------------------------------------------------------------------- + // EnrollV2Async — invalid EmailNotifications fails fast, before any CA call + // --------------------------------------------------------------------------- + + [Theory] + [InlineData("all")] // deliberately invalid: the user's mapping only accepts "0"/"1"/blank, + // even though "all" is the CA's own wire value for "1" — an admin + // must not be able to bypass the mapping by writing the CA's literal. + [InlineData("yes")] + [InlineData("2")] + public async Task Enroll_V2_EmailNotifications_Invalid_FailsEnrollment_NoHttpCallMade(string configValue) + { + // Strict mock with NO setups at all: if EnrollV2Async made any client call before + // failing validation, Moq would throw a MockException for the unstubbed invocation + // and this test would fail — that, plus the explicit Verify(Times.Never) calls below, + // together confirm zero HTTP requests are sent for an invalid config value. + var mock = NewMock(); + + var plugin = BuildV2Plugin(mock.Object, emailNotifications: configValue); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), + subject: "CN=example.com", + san: null, + productInfo: MakeV2ProductInfo("842", "dv"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.StatusMessage.Should().Contain("EmailNotifications", + "the failure message must name the offending config field"); + + mock.Verify(c => c.GetProductDetailsV2Async(It.IsAny()), Times.Never); + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + } +} diff --git a/CERTInext.Tests/EnrollmentParamsTests.cs b/CERTInext.Tests/EnrollmentParamsTests.cs new file mode 100644 index 0000000..2430cf3 --- /dev/null +++ b/CERTInext.Tests/EnrollmentParamsTests.cs @@ -0,0 +1,103 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections.Generic; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.Models; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Regression coverage for issue 0036: 's V1-only + /// fallback () must remain exactly as it + /// was before the fix — only V2 dispatch (EnrollV2Async / ValidateProductInfo) + /// stopped using it. These tests exercise directly (it is + /// internal; this project has InternalsVisibleTo access) so the V1-unaffected + /// claim is pinned at the unit that both V1 and V2 share, not just re-derived from other + /// tests continuing to pass. + /// + public class EnrollmentParamsTests + { + private static EnrollmentProductInfo MakeProductInfo(string productId, Dictionary parameters = null) => + new EnrollmentProductInfo + { + ProductID = productId, + ProductParameters = parameters ?? new Dictionary() + }; + + [Fact] + public void ProductCode_NoOverride_FallsBackToV1DefaultProductCodesTable_Unchanged() + { + // This is the V1 path's fallback and must be untouched by issue 0036's fix: V1 + // dispatch (EnrollNewAsync/RenewOrReissueAsync) still relies on this exact value. + var ep = new EnrollmentParams(MakeProductInfo(Constants.Products.OvSsl)); + + ep.HasExplicitProductCode.Should().BeFalse(); + ep.ProductCode.Should().Be(Constants.Products.DefaultProductCodes[Constants.Products.OvSsl]); + ep.ProductCode.Should().Be("842", "the V1-era table value must not change as part of the V2 fix"); + ep.ProfileId.Should().Be(ep.ProductCode, "ProfileId remains a pure alias for ProductCode"); + } + + [Theory] + [InlineData(Constants.EnrollmentParam.ProductCode)] + [InlineData(Constants.EnrollmentParam.ProfileId)] + public void ProductCode_ExplicitOverride_TakesPrecedenceOverDefaultTable(string parameterKey) + { + var ep = new EnrollmentParams(MakeProductInfo( + Constants.Products.OvSsl, + new Dictionary { [parameterKey] = "999" })); + + ep.HasExplicitProductCode.Should().BeTrue(); + ep.ProductCode.Should().Be("999"); + } + + [Fact] + public void HasExplicitProductCode_False_ForEveryDefaultProductCodesEntry_MatchesV1FallbackBehavior() + { + // Sanity sweep across all 10 V1 product names: with no override, every one of them + // must report HasExplicitProductCode=false and resolve to the exact + // DefaultProductCodes value — proving the shared getter's V1 behavior is bit-for-bit + // unchanged by the V2 fix. + foreach (var kvp in Constants.Products.DefaultProductCodes) + { + var ep = new EnrollmentParams(MakeProductInfo(kvp.Key)); + + ep.HasExplicitProductCode.Should().BeFalse($"ProductId '{kvp.Key}' has no override configured"); + ep.ProductCode.Should().Be(kvp.Value, $"ProductId '{kvp.Key}' must still resolve via the V1 table"); + } + } + + // Issue 0033: private-pki validation must tell "ProductVariant not set" apart from an + // explicit value, because the getter's SSL-only "dv" default masks the difference. + [Theory] + [InlineData(null, false, "dv")] + [InlineData("", false, "dv")] + [InlineData(" ", false, "dv")] + [InlineData("dv", true, "dv")] + [InlineData(" intranet-ssl ", true, "intranet-ssl")] + public void HasExplicitProductVariant_DistinguishesUnsetFromExplicit(string configured, bool expectedExplicit, string expectedVariant) + { + var parameters = new Dictionary(); + if (configured != null) + parameters[Constants.EnrollmentParam.ProductVariant] = configured; + + var ep = new EnrollmentParams(MakeProductInfo(Constants.Products.DvSsl, parameters)); + + ep.HasExplicitProductVariant.Should().Be(expectedExplicit); + ep.ProductVariant.Should().Be(expectedVariant, "the ProductVariant getter's own default is unchanged"); + } + } +} diff --git a/CERTInext.Tests/ExtractErrorMessageTests.cs b/CERTInext.Tests/ExtractErrorMessageTests.cs new file mode 100644 index 0000000..36129c6 --- /dev/null +++ b/CERTInext.Tests/ExtractErrorMessageTests.cs @@ -0,0 +1,81 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Issue 0044: parser-level coverage for CERTInextClient.ExtractErrorMessage, which + /// builds the V1 non-success exception message. + /// + public class ExtractErrorMessageTests + { + private const string Op = "list orders page 1"; + + [Fact] + public void LiveSpringNotFoundBody_WithStatus_ReturnsGenericMessageWithHttpStatus() + { + CERTInextClient.ExtractErrorMessage(V1NonSuccessResponseTests.LiveSpringNotFoundBody, Op, 404) + .Should().Be("CERTInext returned an unrecognised error body (HTTP 404) for operation 'list orders page 1'. " + + "See gateway logs for details."); + } + + [Fact] + public void LiveSpringNotFoundBody_WithoutStatus_KeepsPreviousMessage() + { + // RevokeOrderAsync still calls the two-argument form; its message must not change. + CERTInextClient.ExtractErrorMessage(V1NonSuccessResponseTests.LiveSpringNotFoundBody, Op) + .Should().Be("CERTInext returned an unrecognised error body for operation 'list orders page 1'. " + + "See gateway logs for details."); + } + + [Theory] + [InlineData("Bad Gateway")] + [InlineData("[]")] + public void NonEnvelopeBody_WithStatus_ReturnsGenericMessageWithHttpStatus(string body) + { + CERTInextClient.ExtractErrorMessage(body, Op, 502) + .Should().StartWith("CERTInext returned an unrecognised error body (HTTP 502) for operation"); + } + + [Fact] + public void MetaEnvelope_WithStatus_IncludesStatusAndCaError() + { + const string body = "{\"meta\":{\"status\":\"0\",\"errorCode\":\"EMS-913\",\"errorMessage\":\"Invalid Account Number\"}}"; + + CERTInextClient.ExtractErrorMessage(body, Op, 500) + .Should().Be("CERTInext error during 'list orders page 1' (HTTP 500): Invalid Account Number [EMS-913]"); + } + + [Fact] + public void LegacyMessageBody_WithStatus_IncludesStatusAndMessage() + { + CERTInextClient.ExtractErrorMessage("{\"message\":\"Service Unavailable\"}", Op, 503) + .Should().Be("CERTInext error during 'list orders page 1' (HTTP 503): Service Unavailable"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void EmptyBody_WithStatus_IncludesStatus(string body) + { + CERTInextClient.ExtractErrorMessage(body, Op, 404) + .Should().Be("CERTInext returned no body (HTTP 404) for operation 'list orders page 1'."); + } + } +} diff --git a/CERTInext.Tests/ExtractSerialFromPemTests.cs b/CERTInext.Tests/ExtractSerialFromPemTests.cs new file mode 100644 index 0000000..4a65b39 --- /dev/null +++ b/CERTInext.Tests/ExtractSerialFromPemTests.cs @@ -0,0 +1,191 @@ +// Copyright 2024 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using System; +using System.Reflection; +using FluentAssertions; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Operators; +using Org.BouncyCastle.Math; +using Org.BouncyCastle.Security; +using Org.BouncyCastle.X509; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Regression tests for the private CERTInextCAPlugin.ExtractSerialFromPem + /// helper, which feeds the audit-log SerialNumber field. After the BouncyCastle + /// migration (replacing X509Certificate2.SerialNumber) we need to pin the + /// format invariants — particularly the leading-zero-byte case where the old BCL + /// behaviour and a naive BigInteger.ToString(16) diverge. + /// + public class ExtractSerialFromPemTests + { + private static string InvokeExtractSerialFromPem(string pem) + { + var method = typeof(CERTInextCAPlugin) + .GetMethod("ExtractSerialFromPem", BindingFlags.NonPublic | BindingFlags.Static); + method.Should().NotBeNull("test pins the format produced by ExtractSerialFromPem"); + return (string)method!.Invoke(null, new object[] { pem })!; + } + + /// + /// Generates a self-signed PEM cert with the specified serial number. Uses + /// BouncyCastle throughout — no BCL crypto — per the project's crypto policy. + /// + private static string GeneratePemWithSerial(BigInteger serial) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + AsymmetricCipherKeyPair keyPair = keyGen.GenerateKeyPair(); + + var subject = new X509Name("CN=test-serial-parity"); + var notBefore = DateTime.UtcNow.AddMinutes(-1); + var notAfter = notBefore.AddDays(1); + + var builder = new X509V3CertificateGenerator(); + builder.SetSerialNumber(serial); + builder.SetIssuerDN(subject); + builder.SetSubjectDN(subject); + builder.SetNotBefore(notBefore); + builder.SetNotAfter(notAfter); + builder.SetPublicKey(keyPair.Public); + + var signerFactory = new Asn1SignatureFactory("SHA256withRSA", keyPair.Private); + X509Certificate cert = builder.Generate(signerFactory); + + return "-----BEGIN CERTIFICATE-----\n" + + Convert.ToBase64String(cert.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE-----"; + } + + [Fact] + public void ExtractSerialFromPem_PreservesLeadingZeroByte() + { + // Serial bytes 0x00 0x0A 0xFF 0xFF as an unsigned big-endian integer = 720895 + // X509Certificate2.SerialNumber would produce "0AFFFF" (sign byte stripped, + // remaining bytes hex-encoded, leading-zero NIBBLE preserved within byte boundary). + // A naive BigInteger.ToString(16) would produce "afff" (a 4-digit hex, dropping + // the leading zero nibble), which mis-correlates with Command's stored serial. + // + // Use a serial that has a leading-zero nibble in its first non-zero byte: + // 0x0A123456 → unsigned hex "0A123456" (8 nibbles). Anything that drops the + // leading zero produces "A123456" (7 nibbles). + var serial = new BigInteger("0A123456", 16); + string pem = GeneratePemWithSerial(serial); + + string result = InvokeExtractSerialFromPem(pem); + + result.Should().Be("0A123456", + "the serial must preserve the leading-zero nibble within its first byte " + + "so audit-log correlation against Command's stored serial succeeds"); + } + + [Fact] + public void ExtractSerialFromPem_NormalSerial_UppercaseHexNoLeadingZero() + { + // Plain mid-range serial; just confirms format is uppercase hex without separators. + var serial = new BigInteger("DEADBEEFCAFE", 16); + string pem = GeneratePemWithSerial(serial); + + string result = InvokeExtractSerialFromPem(pem); + + result.Should().Be("DEADBEEFCAFE"); + } + + [Fact] + public void ExtractSerialFromPem_LongSerial_AllBytesPreservedUppercase() + { + // 20-byte serial (the max CA/B Forum permits). Each byte must be uppercase + // hex, no separators, no leading-zero loss. + var serial = new BigInteger("01020304050607080910111213141516171819FA", 16); + string pem = GeneratePemWithSerial(serial); + + string result = InvokeExtractSerialFromPem(pem); + + result.Should().Be("01020304050607080910111213141516171819FA"); + } + + [Fact] + public void ExtractSerialFromPem_GarbageInput_ReturnsParseError() + { + // Robustness — audit-log path must never throw, only mark the failure. + InvokeExtractSerialFromPem("not a pem") + .Should().Be("(parse-error)"); + } + + [Fact] + public void ExtractSerialFromPem_EmptyBody_ReturnsEmptyPem() + { + InvokeExtractSerialFromPem("-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----") + .Should().Be("(empty-pem)"); + } + + /// + /// Functional coverage for the minimal chain-PEM shape (acceptance criterion: "leaf + + /// intermediate chain PEM -> the leaf's serial"), built the same way V2 enroll/sync + /// assemble it (AssembleV2CertChain: leaf PEM, then each intermediate PEM + /// appended after a newline, each block keeping its own BEGIN/END markers and base64 + /// padding). Whether this specific 2-block combination reproduces the pre-fix + /// "(parse-error)" bug depends on the leaf's DER byte length modulo 3 (whether its + /// base64 body needs '=' padding) — see + /// + /// for the deterministic reproduction of issue 0050 (matches the live gateway log's + /// ChainPemCount=2 evidence). Both must return the leaf's serial, never the + /// intermediate's. + /// + [Fact] + public void ExtractSerialFromPem_LeafPlusIntermediateChainPem_ReturnsLeafSerial() + { + var leafSerial = new BigInteger("7994334872", 10); + var intermediateSerial = new BigInteger("00E0353B0E133906D77D5137E5E5D6A1", 16); + + string leafPem = GeneratePemWithSerial(leafSerial); + string intermediatePem = GeneratePemWithSerial(intermediateSerial); + + // Mirrors CERTInextCAPlugin.AssembleV2CertChain: leaf.TrimEnd() + "\n" + intermediate.TrimEnd(). + string chainPem = leafPem.TrimEnd() + "\n" + intermediatePem.TrimEnd(); + + string result = InvokeExtractSerialFromPem(chainPem); + + result.Should().Be(Convert.ToHexString(leafSerial.ToByteArrayUnsigned()).ToUpperInvariant(), + "the audit log must report the leaf certificate's serial, matching what Command records"); + result.Should().NotBe(Convert.ToHexString(intermediateSerial.ToByteArrayUnsigned()).ToUpperInvariant(), + "the intermediate's serial must never be mistaken for the leaf's"); + } + + /// + /// Regression for issue 0050: leaf + two intermediates (three PEM blocks total), + /// matching the live gateway log evidence (ChainPemCount=2, both V2 reissue + /// enrollments logged "SerialNumber=(parse-error)"). Deterministically reproduces + /// the pre-fix bug — verified by reverting ExtractSerialFromPem to its + /// pre-fix body and confirming this test fails with "(parse-error)" while the other + /// tests in this class still pass, across repeated runs (ruling out flakiness from + /// the fresh RSA key generated per run). + /// + [Fact] + public void ExtractSerialFromPem_LeafPlusTwoIntermediatesChainPem_ReturnsLeafSerial() + { + var leafSerial = new BigInteger("9817499991", 10); + var intermediateSerial1 = new BigInteger("00FEABDFF1B29657D9AF75ABC6CDCAAE", 16); + var intermediateSerial2 = new BigInteger("DEADBEEF", 16); + + string leafPem = GeneratePemWithSerial(leafSerial); + string intermediatePem1 = GeneratePemWithSerial(intermediateSerial1); + string intermediatePem2 = GeneratePemWithSerial(intermediateSerial2); + + string chainPem = leafPem.TrimEnd() + "\n" + intermediatePem1.TrimEnd() + "\n" + intermediatePem2.TrimEnd(); + + string result = InvokeExtractSerialFromPem(chainPem); + + result.Should().Be(Convert.ToHexString(leafSerial.ToByteArrayUnsigned()).ToUpperInvariant()); + } + } +} diff --git a/CERTInext.Tests/FakeDomainValidator.cs b/CERTInext.Tests/FakeDomainValidator.cs new file mode 100644 index 0000000..f8ffeb8 --- /dev/null +++ b/CERTInext.Tests/FakeDomainValidator.cs @@ -0,0 +1,138 @@ +// Copyright 2024 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// At http://www.apache.org/licenses/LICENSE-2.0 + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Keyfactor.AnyGateway.Extensions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// In-memory stub that records staged and cleaned-up DNS TXT entries without + /// making real DNS calls. Configurable success/failure via init properties. + /// + internal sealed class FakeDomainValidator : IDomainValidator + { + /// All (key, value) pairs passed to . + public List<(string key, string value)> StagedRecords { get; } = new(); + + /// All keys passed to . + public List CleanedUpKeys { get; } = new(); + + /// All CancellationTokens passed to . + public List CleanupTokens { get; } = new(); + + /// When false, returns a failure result. + public bool StageSucceeds { get; init; } = true; + + /// + /// When set, overrides on a per-key basis — e.g. + /// key => key.Contains("bad", StringComparison.OrdinalIgnoreCase) to fail only a + /// specific hostname in a multi-domain test while the others still stage successfully. + /// + public Func ShouldFail { get; init; } + + /// Error message returned when a StageValidation call fails. + public string StageError { get; init; } = "Stage failed (test stub)"; + + public void Initialize(IDomainValidatorConfigProvider configProvider) { } + + public Task StageValidation(string key, string value, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + bool fail = ShouldFail?.Invoke(key) ?? !StageSucceeds; + if (!fail) + StagedRecords.Add((key, value)); + + return Task.FromResult(new DomainValidationResult + { + Success = !fail, + ErrorMessage = fail ? StageError : null + }); + } + + /// + /// Artificial delay applied inside before completing — lets + /// tests distinguish "cleanup calls run concurrently" (wall time ~= one delay) from + /// "cleanup calls run sequentially" (wall time ~= N x delay). + /// + public TimeSpan CleanupDelay { get; init; } = TimeSpan.Zero; + + // Cleanup calls can genuinely run concurrently (that's what CleanupDelay exists to prove), + // so the two List fields below need a lock — unlike StagedRecords above, which only ever + // sees synchronously-completing calls in practice. + private readonly object _cleanupLock = new(); + + // Tracks how many CleanupValidation calls were in flight (past the increment below, + // still inside CleanupDelay) at the same time. This is the direct, wall-clock-independent + // proof that cleanup ran concurrently rather than sequentially — see + // Dcv_CleanupOfMultipleDomains_RunsConcurrently_NotSequentially, which asserts on this + // instead of total elapsed time (0023: elapsed time also includes fixed overhead from the + // surrounding DCV flow — propagation delay + verification poll interval — unrelated to + // cleanup concurrency, which made a wall-clock threshold an unreliable proxy). + private int _inFlightCleanups; + + /// + /// The maximum number of calls observed executing + /// concurrently (i.e. inside the artificial ) at once. + /// + public int PeakConcurrentCleanups { get; private set; } + + public async Task CleanupValidation(string key, CancellationToken cancellationToken) + { + int inFlight = Interlocked.Increment(ref _inFlightCleanups); + lock (_cleanupLock) + { + if (inFlight > PeakConcurrentCleanups) + PeakConcurrentCleanups = inFlight; + } + try + { + if (CleanupDelay > TimeSpan.Zero) + await Task.Delay(CleanupDelay, cancellationToken); + } + finally + { + Interlocked.Decrement(ref _inFlightCleanups); + } + lock (_cleanupLock) + { + CleanedUpKeys.Add(key); + CleanupTokens.Add(cancellationToken); + } + return new DomainValidationResult { Success = true }; + } + + public Task ValidateConfiguration(Dictionary configuration) => Task.CompletedTask; + public Dictionary GetDomainValidatorAnnotations() => new(); + public string GetValidationType() => "dns-01"; + } + + /// + /// Factory that returns a single pre-configured for every + /// domain, or only for if set. Pass null as the + /// validator to simulate "no DNS provider configured". + /// + internal sealed class FakeDomainValidatorFactory : IDomainValidatorFactory + { + private readonly IDomainValidator _validator; + private readonly string _resolvableDomain; + + public FakeDomainValidatorFactory(IDomainValidator validator = null, string resolvableDomain = null) + { + _validator = validator; + _resolvableDomain = resolvableDomain; + } + + public IDomainValidator ResolveDomainValidator(string domain, string validationType) => + (_resolvableDomain == null || string.Equals(domain, _resolvableDomain, StringComparison.OrdinalIgnoreCase)) + ? _validator + : null; + + /// The validator this factory returns; exposed for assertions in tests. + public IDomainValidator PrimaryValidator => _validator; + } +} diff --git a/CERTInext.Tests/MaskEmailTests.cs b/CERTInext.Tests/MaskEmailTests.cs new file mode 100644 index 0000000..4d469a0 --- /dev/null +++ b/CERTInext.Tests/MaskEmailTests.cs @@ -0,0 +1,54 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.CERTInext.Models; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Issue 0040: is the shared email-masking helper used + /// by both CERTInextCAPlugin (the enrollment-attempt Information log line) and + /// Client.CERTInextClient (RedactPersonalData) when LogSensitiveRequestData + /// is off. + /// + public class MaskEmailTests + { + [Theory] + [InlineData("jane.doe@example.com", "j***@example.com")] + [InlineData("a@b.co", "a***@b.co")] + [InlineData("Jane.Doe@Example.COM", "J***@Example.COM")] + public void MaskEmail_KeepsFirstCharacterAndDomain(string input, string expected) + { + LogSanitizer.MaskEmail(input).Should().Be(expected); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void MaskEmail_HandlesNullAndEmpty(string input) + { + LogSanitizer.MaskEmail(input).Should().Be(input); + } + + [Theory] + [InlineData("not-an-email")] + [InlineData("@example.com")] + public void MaskEmail_NoUsableLocalPart_FallsBackToFullRedaction(string input) + { + LogSanitizer.MaskEmail(input).Should().Be("***REDACTED***"); + } + } +} diff --git a/CERTInext.Tests/MockCertificateData.cs b/CERTInext.Tests/MockCertificateData.cs index 04903b0..810fce5 100644 --- a/CERTInext.Tests/MockCertificateData.cs +++ b/CERTInext.Tests/MockCertificateData.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 Keyfactor // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. // You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; +using System.Text.Json; using Keyfactor.Extensions.CAPlugin.CERTInext.API; namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests @@ -231,6 +232,45 @@ public static string GetProductDetailsJson() => public static string GetProductDetailsEmptyJson() => $@"{{""meta"":{SuccessMetaJson()},""productDetails"":[]}}"; + // GET /api/certinext/v2/catalog/products — nested category envelope, the shape + // confirmed live against the sandbox account 2026-09-24 (issue 0025 step 0 / issue + // 0016). Same structure as the V1 GetProductDetails category envelope, just under a + // top-level "products" key instead of "productDetails". + public static string GetCatalogProductsV2NestedJson() => + $@"{{ + ""products"":[ + {{ + ""currencyType"":""USD"", + ""categoryName"":""SSL/TLS Certificates"", + ""categoryID"":""3"", + ""products"":[ + {{""productCode"":""{ProfileIdTls}"",""productName"":""TLS Server"",""productTypeID"":""13""}}, + {{""productCode"":""{ProfileIdClient}"",""productName"":""Client Authentication"",""productTypeID"":""14""}} + ] + }} + ] +}}"; + + // Flat shape documented in the Postman "List Products" saved 200 example — kept as a + // fallback branch in the parser even though the live account returns the nested shape. + public static string GetCatalogProductsV2FlatJson() => + $@"{{ + ""products"":[ + {{""productId"":""{ProfileIdTls}"",""productName"":""TLS Server"",""masterProductName"":""TLS Server""}}, + {{""productId"":""{ProfileIdClient}"",""productName"":""Client Authentication"",""masterProductName"":""Client Authentication""}} + ] +}}"; + + // Bare-array shape (no wrapper object) — legacy branch already handled by + // ParseProductDetailsV2Response. + public static string GetCatalogProductsV2BareArrayJson() => + $@"[ + {{""productCode"":""{ProfileIdTls}"",""productName"":""TLS Server"",""productType"":""SSL/TLS Certificates"",""active"":true}} +]"; + + public static string GetCatalogProductsV2EmptyJson() => + $@"{{""products"":[]}}"; + // Generic API failure body (meta.status = "0") public static string ApiFailureJson(string errorCode = "EMS-100", string errorMessage = "An error occurred") => $@"{{""meta"":{FailureMetaJson(errorCode, errorMessage)}}}"; @@ -293,6 +333,20 @@ public static EnrollCertificateResponse PendingEnrollResponse(string id = null) Message = "Awaiting approval." }; + // Reproduces the CERTInext "auto-approved" race: TrackOrder reports a + // certificateStatusId the client legacy-maps to "issued", but the immediate + // GetCertificate download failed (cert bytes not generated yet), so no PEM + // ever arrived. See issue 0009. + public static EnrollCertificateResponse AutoApprovedNoBodyEnrollResponse(string id = null) => + new EnrollCertificateResponse + { + Id = id ?? CertId1, + Status = "issued", + Certificate = null, + ProfileId = ProfileIdTls, + Message = "Order auto-approved." + }; + // ----------------------------------------------------------------------- // GetCertificate response (object helpers — used by Moq-based plugin tests) // These use the legacy inferred type (LegacyGetCertificateResponse). @@ -312,6 +366,20 @@ public static LegacyGetCertificateResponse IssuedCertRecord(string id = null) => Csr = FakeCsrPem }; + /// + /// A LegacyGetCertificateResponse representing an order that is past DCV verification + /// but still has CERTInext-side issuance in progress. Status maps to + /// so post-DCV polling logic continues. + /// + public static LegacyGetCertificateResponse PendingCertRecord(string id = null) => + new LegacyGetCertificateResponse + { + Id = id ?? CertId1, + Status = "pending_approval", // → EXTERNALVALIDATION via StatusMapper + Certificate = null, + SerialNumber = null + }; + public static LegacyGetCertificateResponse RevokedCertRecord(string id = null) => new LegacyGetCertificateResponse { @@ -334,6 +402,125 @@ public static LegacyGetCertificateResponse RevokedCertRecord(string id = null) = public static string OAuth2TokenJson(int expiresIn = 3600) => $@"{{""access_token"":""fake-bearer-token-abc123"",""token_type"":""Bearer"",""expires_in"":{expiresIn}}}"; + // ----------------------------------------------------------------------- + // DCV (domain control validation) + // ----------------------------------------------------------------------- + + public const string DcvOrderId = "ORD-DCV-001"; + public const string DcvDomain = "example.com"; + public const string DcvToken = "abc123dcvtoken"; + + /// + /// Returns a with one pending DNS-TXT domain entry, + /// ready for Moq setups that exercise the DCV orchestration path. + /// + public static TrackOrderResponse DcvPendingTrackResponse( + string orderNumber = DcvOrderId, + string domain = DcvDomain) + { + var detail = JsonSerializer.SerializeToElement(new DomainVerificationDetail + { + DcvMethod = Constants.Dcv.MethodDnsTxt, + DcvStatus = Constants.Dcv.StatusPending, + Status = "1" + }); + + return new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "1", + CertificateStatusId = "1", + DomainVerification = new TrackOrderDomainVerification + { + Status = Constants.Dcv.StatusPending, + RawDomainEntries = new Dictionary { [domain] = detail } + } + } + }; + } + + public static TrackOrderResponse DcvVerifiedTrackResponse( + string orderNumber = DcvOrderId, + string domain = DcvDomain) + { + var detail = JsonSerializer.SerializeToElement(new DomainVerificationDetail + { + DcvMethod = Constants.Dcv.MethodDnsTxt, + DcvStatus = Constants.Dcv.StatusValidated, + Status = "1" + }); + + return new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "2", + CertificateStatusId = "24", + DomainVerification = new TrackOrderDomainVerification + { + Status = Constants.Dcv.StatusValidated, + RawDomainEntries = new Dictionary { [domain] = detail } + } + } + }; + } + + /// + /// Returns a whose order is already in a terminal + /// issued state — DCV should be skipped entirely when this is returned. + /// + public static TrackOrderResponse AlreadyIssuedTrackResponse(string orderNumber = CertId1) => + new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "4", + CertificateStatusId = "9", // CertificateGenerated — maps to GENERATED + } + }; + + /// + /// Returns a containing the TXT token for Moq setups. + /// + public static GetDcvResponse DcvTokenResponse(string token = DcvToken) => + new GetDcvResponse + { + DcvDetails = new DcvResponseDetails { Token = token } + }; + + /// + /// POST /GetDcv — success response containing the TXT record token for DNS DCV. + /// + public static string GetDcvSuccessJson(string token = "abc123token") => + $@"{{ + ""meta"":{SuccessMetaJson()}, + ""dcvDetails"":{{ + ""token"":""{token}"", + ""fileName"":null, + ""fileContent"":null, + ""dcvEmails"":null + }} +}}"; + + /// + /// POST /GetDcv — failure response (bad order or unsupported dcvMethod). + /// + public static string GetDcvFailureJson(string code = "EMS-DCV-001", string msg = "DCV not available for this order") => + $@"{{""meta"":{FailureMetaJson(code, msg)}}}"; + + /// + /// POST /VerifyDcv — success response (meta only, no additional payload). + /// + public static string VerifyDcvSuccessJson() => + $@"{{""meta"":{SuccessMetaJson()}}}"; + + /// + /// POST /VerifyDcv — failure response (TXT record not found). + /// + public static string VerifyDcvFailureJson(string code = "EMS-DCV-002", string msg = "DNS record not found") => + $@"{{""meta"":{FailureMetaJson(code, msg)}}}"; + // ----------------------------------------------------------------------- // Error responses // ----------------------------------------------------------------------- @@ -344,6 +531,104 @@ public static string ServerErrorJson() => public static string UnauthorizedJson() => @"{""error"":""UNAUTHORIZED"",""message"":""Invalid API key."",""statusCode"":401}"; + // ----------------------------------------------------------------------- + // V2 API JSON factories + // ----------------------------------------------------------------------- + + // V2 well-known order IDs + public const string V2OrderId1 = "ord_abc001"; + public const string V2OrderId2 = "ord_abc002"; + + /// Standard OAuth2 client_credentials token response. + public static string V2TokenResponseJson(int expiresIn = 3600) => + $@"{{""access_token"":""eyJhbGciOiJSUzI1NiJ9.test-token"",""token_type"":""Bearer"",""expires_in"":{expiresIn},""refresh_token"":""refresh-opaque-token""}}"; + + /// V2 create order response (status = pending-dcv). + public static string V2CreateOrderPendingJson(string orderId = "ord_abc001") => + $@"{{""orderId"":""{orderId}"",""requestId"":""req_xyz001"",""status"":""pending-dcv"",""_links"":{{""self"":{{""href"":""/api/certinext/v2/ssl-certificates/{orderId}""}}}}}}"; + + /// V2 create order response (status = issued — unlikely on fresh order but usable for testing). + public static string V2CreateOrderIssuedJson(string orderId = "ord_abc001") => + $@"{{""orderId"":""{orderId}"",""requestId"":""req_xyz001"",""status"":""issued"",""_links"":{{""self"":{{""href"":""/api/certinext/v2/ssl-certificates/{orderId}""}}}}}}"; + + /// V2 track order response — pending DCV. + public static string V2TrackOrderPendingJson(string orderId = "ord_abc001") => + $@"{{""orderId"":""{orderId}"",""requestId"":""req_xyz001"",""status"":""pending-dcv"",""productVariant"":""dv"",""domain"":""example.com"",""_links"":{{""self"":{{""href"":""/api/certinext/v2/ssl-certificates/{orderId}""}}}}}}"; + + /// V2 track order response — issued. + public static string V2TrackOrderIssuedJson(string orderId = "ord_abc001") => + $@"{{""orderId"":""{orderId}"",""requestId"":""req_xyz001"",""status"":""issued"",""productVariant"":""dv"",""domain"":""example.com"",""_links"":{{""certificate"":{{""href"":""/api/certinext/v2/ssl-certificates/{orderId}/certificate""}}}}}}"; + + /// + /// V2 track order response — revoked. Nested revocation object shape confirmed + /// live against a real revoked order (issues/0034, 2026-09-25) — NOT the flat + /// revocationReason/revocationDate shape this fixture previously encoded. + /// + public static string V2TrackOrderRevokedJson( + string orderId = "ord_abc001", + string reason = "cessation-of-operation", + string processedAt = "2026-09-24T20:44:41Z") => + $@"{{""orderId"":""{orderId}"",""requestId"":""req_xyz001"",""status"":""revoked"",""productVariant"":""dv"",""domain"":""example.com"",""revocation"":{{""status"":""Certificate Revoked"",""reason"":""{reason}"",""processedAt"":""{processedAt}""}},""_links"":{{}}}}"; + + /// V2 certificate download response (leaf PEM only). + public static string V2CertificateDownloadJson(string orderId = "ord_abc001") => + $@"{{""orderId"":""{orderId}"",""serialNumber"":""0A1B2C3D4E5F"",""subject"":""CN=example.com"",""issuer"":""CN=CERTInext TLS Intermediate"",""notBefore"":""2026-01-01T00:00:00Z"",""notAfter"":""2027-01-01T00:00:00Z"",""certificatePem"":""{EscapeForJson(FakePemCertificate)}""}}"; + + /// V2 auth/me response. + public static string V2AuthMeJson(string accountNumber = "99887766") => + $@"{{""accountNumber"":""{accountNumber}"",""authType"":""oauth2""}}"; + + /// RFC 7807 problem+json error response. + public static string V2ProblemDetailsJson(int status = 403, string title = "Forbidden", string detail = "OAuth2 not enabled", string type = "EMS-2022") => + $@"{{""type"":""{type}"",""title"":""{title}"",""status"":{status},""detail"":""{detail}"",""instance"":null}}"; + + /// + /// V2 DCV challenge response. Matches the confirmed live shape (issues/0037, live + /// probe 2026-09-25): exactly token and tokenExpiryDate — no + /// orderNumber/domainName/dcvMethod/fileNameContent. + /// + public static string V2DcvChallengeJson(string token = "emudhra-dcv-abc123", string tokenExpiryDate = "2026-12-31 23:59:59") => + $@"{{""tokenExpiryDate"":""{tokenExpiryDate}"",""token"":""{token}""}}"; + + /// V2 DCV verify response (success). + public static string V2DcvVerifySuccessJson(string domain = "example.com") => + $@"{{""overallStatus"":""VERIFIED"",""method"":""dns-txt"",""verifiedAt"":""2026-09-21T10:00:00Z""}}"; + + /// V2 DCV verify response (failure). + public static string V2DcvVerifyFailedJson() => + $@"{{""overallStatus"":""FAILED"",""method"":""dns-txt"",""verifiedAt"":null}}"; + + /// V2 certificate download response with chain PEM. + public static string V2CertificateDownloadWithChainJson(string orderId = "ord_abc001") => + $@"{{""orderId"":""{orderId}"",""serialNumber"":""0A1B2C3D4E5F"",""subject"":""CN=example.com"",""issuer"":""CN=CERTInext TLS Intermediate"",""notBefore"":""2026-01-01T00:00:00Z"",""notAfter"":""2027-01-01T00:00:00Z"",""certificatePem"":""{EscapeForJson(FakePemCertificate)}"",""chainPem"":[""{EscapeForJson(FakeIntermediatePemCertificate)}""]}}"; + + public static readonly string FakeIntermediatePemCertificate = + "-----BEGIN CERTIFICATE-----\nMIIBfakeBASE64INTERMEDIATE==\n-----END CERTIFICATE-----"; + + /// + /// V2 /reports/orders page envelope (issues/0022). Rows default to a + /// pending-DCV-shaped display-string pair ("Order Accepted" / "Pending for Approver") — + /// override / for other + /// scenarios. Field names match the live field table confirmed in issues/0022 Phase 0. + /// + public static string V2OrdersReportJson( + int page, int totalPages, string[] orderNumbers, + int size = 50, long? totalElements = null, + string orderStatus = "Order Accepted", string certificateStatus = "Pending for Approver") + { + var rows = new List(); + foreach (string id in orderNumbers) + { + rows.Add( + $@"{{""orderNumber"":""{id}"",""requestNumber"":""{id}-req"",""orderStatus"":""{orderStatus}""," + + $@"""certificateStatus"":""{certificateStatus}"",""domainName"":""example.com""," + + $@"""productCode"":""842"",""orderDate"":""2026-01-01T00:00:00Z""}}"); + } + long total = totalElements ?? orderNumbers.Length; + return $@"{{""content"":[{string.Join(",", rows)}],""page"":{page},""size"":{size}," + + $@"""totalElements"":{total},""totalPages"":{totalPages}}}"; + } + // ----------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------- diff --git a/CERTInext.Tests/RateLimitRetryTests.cs b/CERTInext.Tests/RateLimitRetryTests.cs new file mode 100644 index 0000000..7750073 --- /dev/null +++ b/CERTInext.Tests/RateLimitRetryTests.cs @@ -0,0 +1,64 @@ +// Copyright 2024 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Pure unit tests for the rate-limit-retry helpers in . + /// Behavioral / end-to-end coverage of the retry loop itself lives in the WireMock + /// tests; here we pin the predicate and the backoff schedule. + /// + public class RateLimitRetryTests + { + [Theory] + [InlineData("Inactive Account User.", true)] // exact form from sandbox + [InlineData("inactive account user.", true)] // case-insensitive + [InlineData("INACTIVE ACCOUNT USER", true)] // case + missing period + [InlineData("Some preamble: Inactive Account User. Tail", true)] // embedded substring + [InlineData("Active account user.", false)] // wrong polarity + [InlineData("Account is inactive", false)] // similar phrase, wrong wording + [InlineData("EMS-956 Invalid Request for this API.", false)] // unrelated error + [InlineData("", false)] + [InlineData(null, false)] + public void IsRateLimitSurface_DetectsDocumentedPhraseOnly(string errorMessage, bool expected) + { + CERTInextClient.IsRateLimitSurface(errorMessage).Should().Be(expected); + } + + [Theory] + [InlineData(1, 0.75, 1.25)] // base = 1s, jittered ±25% ⇒ [0.75, 1.25] + [InlineData(2, 1.5, 2.5)] // 2s × jitter + [InlineData(3, 3.0, 5.0)] // 4s × jitter + [InlineData(4, 6.0, 10.0)] // 8s × jitter + [InlineData(5, 12.0, 20.0)] // 16s × jitter + public void ComputeRateLimitBackoffSeconds_ProducesExpectedRange(int attempt, double min, double max) + { + // Run several samples so jitter is exercised; every sample must fall inside + // the documented exponential ± 25% jitter window. + for (int i = 0; i < 50; i++) + { + double waitSeconds = CERTInextClient.ComputeRateLimitBackoffSeconds(attempt); + waitSeconds.Should().BeInRange(min, max, + $"attempt {attempt} sample {i} must fall inside the documented backoff window"); + } + } + + [Fact] + public void ComputeRateLimitBackoffSeconds_ClampsAttemptsBelowOneToOne() + { + // Defensive: passing 0 or negative shouldn't produce zero / negative delay. + CERTInextClient.ComputeRateLimitBackoffSeconds(0) + .Should().BeInRange(0.75, 1.25); + CERTInextClient.ComputeRateLimitBackoffSeconds(-3) + .Should().BeInRange(0.75, 1.25); + } + } +} diff --git a/CERTInext.Tests/RedactCredentialsTests.cs b/CERTInext.Tests/RedactCredentialsTests.cs new file mode 100644 index 0000000..fad3e46 --- /dev/null +++ b/CERTInext.Tests/RedactCredentialsTests.cs @@ -0,0 +1,108 @@ +// Copyright 2024 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Pins the credential-scrubbing pass that runs on + /// every response body before truncation. The CERTInext request meta block + /// includes an authKey SHA-256 digest that is itself a replayable + /// credential under SOX (anyone with one valid (ts, txn, authKey) triple + /// can replay until the timestamp window expires). These tests pin that the + /// scrubber catches both the documented-as-sent fields (authKey) and + /// adjacent credential field names that *could* end up on the wire if a future + /// code path wires them in (client_secret, accessKey, password). + /// See the audit report for commit aab1847. + /// + public class RedactCredentialsTests + { + [Theory] + [InlineData( + "{\"meta\":{\"authKey\":\"deadbeefdeadbeefdeadbeef\",\"ts\":\"2026\"}}", + "{\"meta\":{\"authKey\":\"***REDACTED***\",\"ts\":\"2026\"}}")] + [InlineData( + "{\"client_secret\":\"super-secret-12345\"}", + "{\"client_secret\":\"***REDACTED***\"}")] + [InlineData( + "{\"apiKey\":\"raw-access-key-value\",\"other\":\"keep\"}", + "{\"apiKey\":\"***REDACTED***\",\"other\":\"keep\"}")] + [InlineData( + "{\"accessKey\":\"xxx\",\"password\":\"yyy\"}", + "{\"accessKey\":\"***REDACTED***\",\"password\":\"***REDACTED***\"}")] + public void RedactCredentials_ScrubsJsonCredentialFields(string input, string expected) + { + CERTInextClient.RedactCredentials(input).Should().Be(expected); + } + + [Theory] + [InlineData( + "grant_type=client_credentials&client_secret=super-secret-12345&client_id=public-id", + "grant_type=client_credentials&client_secret=***REDACTED***&client_id=public-id")] + [InlineData( + "authKey=abc123def456", + "authKey=***REDACTED***")] + public void RedactCredentials_ScrubsFormUrlEncodedCredentialFields(string input, string expected) + { + CERTInextClient.RedactCredentials(input).Should().Be(expected); + } + + [Fact] + public void RedactCredentials_ScrubsAuthorizationHeaderLines() + { + string input = + "POST /token HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "Authorization: Bearer ya29.abcdef-secret-token\r\n" + + "Content-Type: application/json\r\n"; + string output = CERTInextClient.RedactCredentials(input); + output.Should().Contain("Authorization: ***REDACTED***"); + output.Should().NotContain("ya29.abcdef-secret-token"); + output.Should().Contain("Host: example.com"); + output.Should().Contain("Content-Type: application/json"); + } + + [Fact] + public void RedactCredentials_PreservesNonCredentialFields() + { + string input = "{\"meta\":{\"ts\":\"2026-05-22\",\"txn\":\"12345\",\"errorMessage\":\"Inactive Account User.\"}}"; + string output = CERTInextClient.RedactCredentials(input); + output.Should().Be(input, "non-credential fields must pass through unchanged"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void RedactCredentials_HandlesNullAndEmpty(string input) + { + // Should not throw and should return the input unchanged (or empty for null). + // The current implementation returns the input as-is for these edge cases. + CERTInextClient.RedactCredentials(input).Should().Be(input); + } + + [Fact] + public void RedactCredentials_CaseInsensitiveFieldNameMatch() + { + // CERTInext historically uses mixed casing (`AuthKey`, `apiKey`, etc.) + // depending on the endpoint. Make sure none slip past the scrubber. + string input = "{\"AuthKey\":\"abc\",\"APIKEY\":\"def\",\"ClientSecret\":\"xyz\"}"; + + string output = CERTInextClient.RedactCredentials(input); + + // ClientSecret isn't currently in the redaction list (only client_secret is), + // and that's intentional — the JSON convention CERTInext uses is the + // snake_case form on the OAuth token endpoint. If we ever observe + // CamelCase variants on the wire, extend the regex. Documented here so + // a future regression review catches the gap. + output.Should().Contain("\"AuthKey\":\"***REDACTED***\""); + output.Should().Contain("\"APIKEY\":\"***REDACTED***\""); + } + } +} diff --git a/CERTInext.Tests/RedactPersonalDataTests.cs b/CERTInext.Tests/RedactPersonalDataTests.cs new file mode 100644 index 0000000..793536f --- /dev/null +++ b/CERTInext.Tests/RedactPersonalDataTests.cs @@ -0,0 +1,741 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.Extensions.CAPlugin.CERTInext.Models; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Issue 0040: requestor personal data (name, email, phone, org contact fields) must not + /// appear in gateway logs unless the connector's LogSensitiveRequestData setting is + /// explicitly turned on. These tests pin and + /// against realistic V1 and V2 order + /// payload JSON, produced by serializing the real request/response models rather than + /// hand-written strings — so a future rename of a JSON property name (which would silently + /// stop the redactor from matching it) fails these tests immediately. + /// + public class RedactPersonalDataTests + { + // Mirrors CERTInextClient.GetJsonOptions() (private), so serialized payloads in these + // tests match the real wire shape (case-insensitive property names, nulls omitted). + private static JsonSerializerOptions ClientEquivalentJsonOptions() => new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + // --------------------------------------------------------------------------- + // V1 GenerateOrderSSL request — requestorInformation / technicalPointOfContact / + // agreementDetails all carry personal data; certificateInformation/orderDetails don't. + // --------------------------------------------------------------------------- + + private static string BuildV1OrderRequestJson() + { + var request = new GenerateOrderSslRequest + { + Meta = new RequestMeta { Ver = "1.0", Ts = "2026-05-22T10:00:00+00:00", Txn = "1234567890", AccountNumber = "9988776655" }, + OrderDetails = new SslOrderDetails + { + ProductCode = "842", + AccountingModel = "2", + SaveAndHold = "0", + EmailNotifications = "0", + RequestorInformation = new RequestorInformation + { + RequestorName = "Jane Doe", + RequestorIsdCode = "1", + RequestorMobileNumber = "5551234567", + RequestorEmail = "jane.doe@example.com", + RequestorDesignation = "IT Administrator" + }, + SubscriptionDetails = new SubscriptionDetails { Validity = "1", AutoRenew = "0", RenewCriteria = "30" }, + CertificateInformation = new CertificateInformation + { + DomainName = "example.com", + AdditionalDomains = new System.Collections.Generic.List { "alt.example.com", "www.example.com" } + }, + AgreementDetails = new AgreementDetails + { + AcceptAgreement = "1", + SignerName = "John Signer", + SignerPlace = "Austin", + SignerIp = "203.0.113.10" + }, + TechnicalPointOfContact = new TechnicalPointOfContact + { + TpcName = "Tech Contact", + TpcEmail = "tech.contact@example.com", + TpcIsdCode = "1", + TpcMobileNumber = "5559876543" + } + } + }; + + return JsonSerializer.Serialize(request, ClientEquivalentJsonOptions()); + } + + [Fact] + public void RedactPersonalData_V1OrderRequest_RemovesAllPersonFields() + { + string input = BuildV1OrderRequestJson(); + string output = CERTInextClient.RedactPersonalData(input); + + output.Should().NotContain("Jane Doe"); + output.Should().NotContain("jane.doe@example.com"); + output.Should().NotContain("5551234567"); + output.Should().NotContain("IT Administrator"); + output.Should().NotContain("John Signer"); + output.Should().NotContain("Austin"); + output.Should().NotContain("203.0.113.10"); + output.Should().NotContain("Tech Contact"); + output.Should().NotContain("tech.contact@example.com"); + output.Should().NotContain("5559876543"); + } + + [Fact] + public void RedactPersonalData_V1OrderRequest_MasksEmailsKeepingDomain() + { + string output = CERTInextClient.RedactPersonalData(BuildV1OrderRequestJson()); + + output.Should().Contain("\"requestorEmail\":\"j***@example.com\""); + output.Should().Contain("\"tpcEmail\":\"t***@example.com\""); + } + + [Fact] + public void RedactPersonalData_V1OrderRequest_PreservesNonPersonalFields() + { + string output = CERTInextClient.RedactPersonalData(BuildV1OrderRequestJson()); + + output.Should().Contain("\"productCode\":\"842\""); + output.Should().Contain("\"domainName\":\"example.com\""); + output.Should().Contain("alt.example.com"); + output.Should().Contain("www.example.com"); + output.Should().Contain("\"accountNumber\":\"9988776655\""); + output.Should().Contain("\"validity\":\"1\""); + } + + [Fact] + public void RedactPersonalData_V1OrderRequest_RedactsRequestorNameToPlaceholder() + { + string output = CERTInextClient.RedactPersonalData(BuildV1OrderRequestJson()); + + output.Should().Contain("\"requestorName\":\"***REDACTED***\""); + output.Should().Contain("\"requestorMobileNumber\":\"***REDACTED***\""); + output.Should().Contain("\"requestorDesignation\":\"***REDACTED***\""); + output.Should().Contain("\"signerName\":\"***REDACTED***\""); + output.Should().Contain("\"signerPlace\":\"***REDACTED***\""); + output.Should().Contain("\"signerIP\":\"***REDACTED***\""); + output.Should().Contain("\"tpcName\":\"***REDACTED***\""); + output.Should().Contain("\"tpcMobileNumber\":\"***REDACTED***\""); + } + + // --------------------------------------------------------------------------- + // V2 order create request — requestor / technicalPointOfContact / agreement all carry + // bare name/email/phone/designation/signerName keys; certificate/subscription don't. + // --------------------------------------------------------------------------- + + private static string BuildV2OrderRequestJson() + { + var request = new V2CreateSslOrderRequest + { + ProductVariant = "ov", + EmailNotifications = "0", + Requestor = new V2Requestor + { + Name = "Jane Doe", + Email = "jane.doe@example.com", + Phone = "+15551234567", + Designation = "IT Administrator" + }, + Organization = new V2OrganizationParams { OrganizationNumber = "1234567", PreVetted = true }, + Certificate = new V2CertificateParams + { + Domain = "example.com", + AdditionalDomains = new System.Collections.Generic.List { "alt.example.com" } + }, + Subscription = new V2SubscriptionParams { ValidityYears = 1, AutoRenew = false }, + Agreement = new V2AgreementParams + { + SignerName = "John Signer", + SignerIp = "203.0.113.10", + SignerPlace = "Austin" + }, + TechnicalPointOfContact = new V2TechnicalPointOfContact + { + Name = "Tech Contact", + Email = "tech.contact@example.com", + Phone = "+15559876543", + Designation = "PKI Manager" + }, + GroupNumber = "2345678901" + }; + + return JsonSerializer.Serialize(request, ClientEquivalentJsonOptions()); + } + + [Fact] + public void RedactPersonalData_V2OrderRequest_RemovesAllPersonFields() + { + string output = CERTInextClient.RedactPersonalData(BuildV2OrderRequestJson()); + + output.Should().NotContain("Jane Doe"); + output.Should().NotContain("jane.doe@example.com"); + output.Should().NotContain("+15551234567"); + output.Should().NotContain("IT Administrator"); + output.Should().NotContain("John Signer"); + output.Should().NotContain("Austin"); + output.Should().NotContain("203.0.113.10"); + output.Should().NotContain("Tech Contact"); + output.Should().NotContain("tech.contact@example.com"); + output.Should().NotContain("+15559876543"); + output.Should().NotContain("PKI Manager"); + } + + [Fact] + public void RedactPersonalData_V2OrderRequest_MasksEmailsKeepingDomain() + { + string output = CERTInextClient.RedactPersonalData(BuildV2OrderRequestJson()); + + // requestor.email ("jane.doe@example.com") and technicalPointOfContact.email + // ("tech.contact@example.com") both use the same bare "email" key but have distinct + // local parts — both must be masked independently (domain kept in each). + output.Should().Contain("\"email\":\"j***@example.com\""); + output.Should().Contain("\"email\":\"t***@example.com\""); + } + + [Fact] + public void RedactPersonalData_V2OrderRequest_PreservesNonPersonalFields() + { + string output = CERTInextClient.RedactPersonalData(BuildV2OrderRequestJson()); + + output.Should().Contain("\"productVariant\":\"ov\""); + output.Should().Contain("\"domain\":\"example.com\""); + output.Should().Contain("alt.example.com"); + output.Should().Contain("\"organizationNumber\":\"1234567\""); + output.Should().Contain("\"groupNumber\":\"2345678901\""); + output.Should().Contain("\"validityYears\":1"); + } + + [Fact] + public void RedactPersonalData_V2OrderRequest_NestedNameFieldsRedactedToPlaceholder() + { + string output = CERTInextClient.RedactPersonalData(BuildV2OrderRequestJson()); + + // Both requestor.name and technicalPointOfContact.name must be caught even though + // they are nested inside different objects using the same bare "name" key. + var nameMatches = System.Text.RegularExpressions.Regex.Matches(output, "\"name\":\"\\*\\*\\*REDACTED\\*\\*\\*\""); + nameMatches.Count.Should().Be(2, "both requestor.name and technicalPointOfContact.name must be redacted"); + + output.Should().Contain("\"phone\":\"***REDACTED***\""); + output.Should().Contain("\"designation\":\"***REDACTED***\""); + output.Should().Contain("\"signerName\":\"***REDACTED***\""); + output.Should().Contain("\"signerIp\":\"***REDACTED***\""); + output.Should().Contain("\"signerPlace\":\"***REDACTED***\""); + } + + // V2 place-order response echoes the agreement as subscriberAgreement with the key + // "signedPlace" (not the request's "signerPlace"), plus an orderedBy contact. Shape taken + // from a live sandbox response (2026-09-29); values here are fictitious. + private const string V2OrderResponseJson = + "{\"orderId\":\"4898663698\",\"status\":\"pending-approval\",\"productVariant\":\"dv\"," + + "\"domain\":\"example.com\",\"resolvedProductCode\":\"842\"," + + "\"requestor\":{\"name\":\"Jane Doe\",\"email\":\"jane.doe@example.com\"}," + + "\"orderedBy\":{\"name\":\"Account Owner\",\"email\":\"owner@example.com\"}," + + "\"subscriberAgreement\":{\"signed\":true,\"signerName\":\"John Signer\"," + + "\"signedAt\":\"2026-09-26T15:58:51Z\",\"signedPlace\":\"Austin\"}}"; + + [Fact] + public void RedactPersonalData_V2OrderResponse_RedactsSubscriberAgreementAndOrderedBy() + { + string output = CERTInextClient.RedactPersonalData(V2OrderResponseJson); + + output.Should().NotContain("Austin"); + output.Should().Contain("\"signedPlace\":\"***REDACTED***\""); + output.Should().NotContain("John Signer"); + output.Should().NotContain("Jane Doe"); + output.Should().NotContain("Account Owner"); + output.Should().Contain("\"email\":\"o***@example.com\""); + output.Should().Contain("\"orderId\":\"4898663698\""); + output.Should().Contain("\"domain\":\"example.com\""); + output.Should().Contain("\"signedAt\":\"2026-09-26T15:58:51Z\""); + } + + // --------------------------------------------------------------------------- + // Whitespace tolerance — a pretty-printed body must redact identically to a compact one. + // --------------------------------------------------------------------------- + + [Fact] + public void RedactPersonalData_TolerantOfWhitespaceAroundKeyValueSeparator() + { + string input = "{\n \"requestor\" : {\n \"name\" : \"Jane Doe\",\n \"email\":\"jane.doe@example.com\"\n }\n}"; + + string output = CERTInextClient.RedactPersonalData(input); + + output.Should().NotContain("Jane Doe"); + output.Should().Contain("j***@example.com"); + } + + // --------------------------------------------------------------------------- + // Edge cases + // --------------------------------------------------------------------------- + + [Theory] + [InlineData(null)] + [InlineData("")] + public void RedactPersonalData_HandlesNullAndEmpty(string input) + { + CERTInextClient.RedactPersonalData(input).Should().Be(input); + } + + [Fact] + public void RedactPersonalData_LeavesAlreadyBlankFieldsUntouched() + { + string input = "{\"requestorName\":\"\",\"requestorEmail\":\"\"}"; + CERTInextClient.RedactPersonalData(input).Should().Be(input, + "there is nothing to redact in an already-blank field"); + } + + [Fact] + public void RedactPersonalData_MalformedEmailValue_FallsBackToFullRedaction() + { + string input = "{\"requestorEmail\":\"not-an-email\"}"; + string output = CERTInextClient.RedactPersonalData(input); + output.Should().Be("{\"requestorEmail\":\"***REDACTED***\"}"); + } + + [Fact] + public void RedactPersonalData_DoesNotTouchUnrelatedNameLikeKeys() + { + // domainName / organizationName end in "Name" but are not the exact key "name" — + // the anchored quote-delimited match must not treat them as substrings of "name". + string input = "{\"domainName\":\"example.com\",\"organizationName\":\"Acme Corp\"}"; + CERTInextClient.RedactPersonalData(input).Should().Be(input); + } + + // --------------------------------------------------------------------------- + // Credentials are always redacted, regardless of RedactPersonalData + // --------------------------------------------------------------------------- + + [Fact] + public void RedactPersonalData_DoesNotRedactCredentials_ThatIsRedactCredentialsJob() + { + // RedactPersonalData is deliberately scoped to person/contact fields only; credential + // scrubbing is RedactCredentials's job and is applied unconditionally by + // ApplyLoggingRedaction regardless of this method. + string input = "{\"authKey\":\"deadbeef\",\"requestorName\":\"Jane Doe\"}"; + string output = CERTInextClient.RedactPersonalData(input); + + output.Should().Contain("deadbeef", "RedactPersonalData alone does not scrub credentials"); + output.Should().NotContain("Jane Doe"); + } + + // --------------------------------------------------------------------------- + // ApplyLoggingRedaction — the flag-gated composition used at every log site + // --------------------------------------------------------------------------- + + [Fact] + public void ApplyLoggingRedaction_FlagOff_RedactsBothCredentialsAndPersonalData() + { + string input = "{\"authKey\":\"deadbeef\",\"requestorName\":\"Jane Doe\",\"requestorEmail\":\"jane.doe@example.com\",\"domainName\":\"example.com\"}"; + + string output = CERTInextClient.ApplyLoggingRedaction(input, logSensitiveRequestData: false); + + output.Should().NotContain("deadbeef"); + output.Should().NotContain("Jane Doe"); + output.Should().Contain("j***@example.com"); + output.Should().Contain("\"domainName\":\"example.com\""); + } + + [Fact] + public void ApplyLoggingRedaction_FlagOn_RedactsCredentialsOnly_LeavesPersonalDataInFull() + { + string input = "{\"authKey\":\"deadbeef\",\"requestorName\":\"Jane Doe\",\"requestorEmail\":\"jane.doe@example.com\",\"domainName\":\"example.com\"}"; + + string output = CERTInextClient.ApplyLoggingRedaction(input, logSensitiveRequestData: true); + + output.Should().NotContain("deadbeef", "credentials must always be redacted, even with the flag on"); + output.Should().Contain("Jane Doe", "personal data is left in full when the flag is on"); + output.Should().Contain("jane.doe@example.com", "personal data is left in full when the flag is on"); + output.Should().Contain("\"domainName\":\"example.com\""); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void ApplyLoggingRedaction_HandlesNullAndEmpty(string input) + { + CERTInextClient.ApplyLoggingRedaction(input, logSensitiveRequestData: false).Should().Be(input); + CERTInextClient.ApplyLoggingRedaction(input, logSensitiveRequestData: true).Should().Be(input); + } + + // --------------------------------------------------------------------------- + // Issue 0033: V2 Private PKI and Document Signer (signature) order bodies. Built from the + // real DTOs so a JSON property rename that would silently defeat the redactor fails here. + // --------------------------------------------------------------------------- + + // Values mirror the spec's "Create - Natural Person" example body. + private static string BuildV2SignatureOrderRequestJson() + { + var request = new V2CreateSignatureOrderRequest + { + SubjectType = "natural-person", + EmailNotifications = "all", + Requestor = new V2Requestor { Name = "Sarah Johnson", Email = "sarah.johnson@example.com", Phone = "+12025551234", Designation = "Document Signer" }, + Subject = new V2SignatureSubject + { + FirstName = "Sarah", + LastName = "Johnson", + Email = "sarah.johnson@example.com", + Phone = "+12025551234", + IdentityDocumentType = "passport", + IdentificationNumber = "X12345678", + StreetAddress1 = "1600 Pennsylvania Avenue NW", + StreetAddress2 = "Apt 7", + Locality = "Washington", + State = "DC", + PostalCode = "20500", + CountryCode = "US" + }, + Subscription = new V2SubscriptionParams { ValidityYears = 1, AutoRenew = false }, + Agreement = new V2AgreementParams { SignerName = "Sarah Johnson", SignerPlace = "Washington, DC", Accepted = true }, + Remarks = "Document Signer - Natural Person, US" + }; + return JsonSerializer.Serialize(request, ClientEquivalentJsonOptions()); + } + + [Fact] + public void RedactPersonalData_V2SignatureOrderRequest_RemovesSubjectPersonFields() + { + string output = CERTInextClient.RedactPersonalData(BuildV2SignatureOrderRequestJson()); + + // Name, identity-document and street-address values must all be gone. + foreach (var raw in new[] + { + "Sarah", "Johnson", "+12025551234", "Document Signer\"", "passport", "X12345678", + "1600 Pennsylvania Avenue NW", "Apt 7", "\"Washington\"", "20500", "Washington, DC" + }) + { + output.Should().NotContain(raw, $"'{raw}' is subject/requestor personal data (issue 0033)"); + } + + // subject.email and requestor.email are masked to their domain, not dropped. + output.Should().NotContain("sarah.johnson@"); + output.Should().Contain("s***@example.com"); + } + + [Fact] + public void RedactPersonalData_V2SignatureOrderRequest_PreservesNonPersonalFields() + { + string output = CERTInextClient.RedactPersonalData(BuildV2SignatureOrderRequestJson()); + + // Deliberately-not-redacted keys: the discriminator, coarse location, and the + // agreement flag carry no personal identity on their own. + output.Should().Contain("\"subjectType\":\"natural-person\""); + output.Should().Contain("\"state\":\"DC\""); + output.Should().Contain("\"countryCode\":\"US\""); + output.Should().Contain("\"accepted\":true"); + } + + [Fact] + public void RedactPersonalData_V2SignatureCreateResponse_RedactsSubjectDisplayName() + { + // Spec "Create - Natural Person" response: subjectDisplayName is the full name for a + // natural / legal person. PlaceOrderV2Async logs this response body at Trace. + string input = "{\"orderId\":\"ord_sig_1\",\"status\":\"pending-documents\",\"subjectType\":\"natural-person\"," + + "\"subjectDisplayName\":\"Sarah Johnson\",\"resolvedProductCode\":\"819\"}"; + + string output = CERTInextClient.RedactPersonalData(input); + + output.Should().NotContain("Sarah Johnson"); + output.Should().Contain("\"subjectDisplayName\":\"***REDACTED***\""); + output.Should().Contain("\"orderId\":\"ord_sig_1\""); + output.Should().Contain("\"resolvedProductCode\":\"819\""); + } + + [Fact] + public void RedactPersonalData_LegalEntitySubject_KeepsOrganizationFields() + { + // Organization identity is not personal data, and "organizationName" is also a V1 + // order/report key for an OV organization — deliberately excluded from the key set. + var request = new V2CreateSignatureOrderRequest + { + SubjectType = "legal-entity", + Requestor = new V2Requestor { Name = "Acme Corporation Compliance", Email = "pki-ops@acme.com" }, + Subject = new V2SignatureSubject + { + OrganizationName = "Acme Corporation", + OrganizationUnit = "Compliance", + BusinessCategory = "Business Entity", + OrganizationIdentificationNumber = "EIN-12-3456789", + Email = "pki-ops@acme.com" + } + }; + string json = JsonSerializer.Serialize(request, ClientEquivalentJsonOptions()); + + string output = CERTInextClient.RedactPersonalData(json); + + output.Should().Contain("\"organizationName\":\"Acme Corporation\""); + output.Should().Contain("\"organizationIdentificationNumber\":\"EIN-12-3456789\""); + output.Should().NotContain("Acme Corporation Compliance", "requestor.name is still personal/contact data"); + output.Should().NotContain("pki-ops@"); + } + + [Fact] + public void ApplyLoggingRedaction_V2SignatureOrderRequest_FlagOn_LeavesSubjectInFull() + { + string output = CERTInextClient.ApplyLoggingRedaction(BuildV2SignatureOrderRequestJson(), logSensitiveRequestData: true); + + output.Should().Contain("\"firstName\":\"Sarah\""); + output.Should().Contain("\"identificationNumber\":\"X12345678\""); + output.Should().Contain("sarah.johnson@example.com"); + } + + [Fact] + public void ApplyLoggingRedaction_V2PrivatePkiOrderRequest_FlagOff_RedactsRequestorAndContact_KeepsHosts() + { + // Private PKI adds no new personal keys (requestor / technicalPointOfContact reuse the + // bare name/email/phone/designation keys); hostname / additionalHosts are diagnostic + // host data and must survive redaction. + var request = new V2CreatePrivatePkiOrderRequest + { + Variant = "intranet-ssl", + Hostname = "intranet.acme.local", + AdditionalHosts = new System.Collections.Generic.List { "portal.acme.local", "10.0.0.50" }, + Requestor = new V2Requestor { Name = "DevOps Team", Email = "devops@acme.com", Phone = "+14155551234", Designation = "Platform Engineering" }, + TechnicalPointOfContact = new V2TechnicalPointOfContact { Name = "Tech Person", Email = "tech@acme.com", Phone = "+14155550000", Designation = "Technical Contact" }, + Subscription = new V2SubscriptionParams { ValidityYears = 1 } + }; + string json = JsonSerializer.Serialize(request, ClientEquivalentJsonOptions()); + + string output = CERTInextClient.ApplyLoggingRedaction(json, logSensitiveRequestData: false); + + foreach (var raw in new[] { "DevOps Team", "devops@", "+14155551234", "Platform Engineering", "Tech Person", "tech@", "+14155550000" }) + output.Should().NotContain(raw); + output.Should().Contain("\"hostname\":\"intranet.acme.local\""); + output.Should().Contain("portal.acme.local"); + output.Should().Contain("10.0.0.50"); + output.Should().Contain("\"variant\":\"intranet-ssl\""); + } + + // --------------------------------------------------------------------------- + // Issue 0040 follow-up: email SANs inside SAN arrays (V1 additionalDomains carries every + // SAN type) and V1 TrackOrder domainVerification keys, which the key/value regex can't reach. + // --------------------------------------------------------------------------- + + private static string BuildV1OrderRequestJsonWithMixedSans(bool indented) + { + var request = new GenerateOrderSslRequest + { + Meta = new RequestMeta { Ver = "1.0", Ts = "2026-05-22T10:00:00+00:00", Txn = "1234567890", AccountNumber = "9988776655" }, + OrderDetails = new SslOrderDetails + { + ProductCode = "844", + RequestorInformation = new RequestorInformation { RequestorName = "Jane Doe", RequestorEmail = "jane.doe@example.com" }, + CertificateInformation = new CertificateInformation + { + DomainName = "example.com", + AdditionalDomains = new System.Collections.Generic.List { "a.example.com", "alice@example.com", "10.0.0.1" } + } + } + }; + var options = ClientEquivalentJsonOptions(); + options.WriteIndented = indented; + return JsonSerializer.Serialize(request, options); + } + + [Fact] + public void ApplyLoggingRedaction_V1AdditionalDomains_FlagOff_MasksOnlyEmailElement() + { + string output = CERTInextClient.ApplyLoggingRedaction(BuildV1OrderRequestJsonWithMixedSans(indented: false), logSensitiveRequestData: false); + + output.Should().NotContain("alice@"); + output.Should().Contain("\"additionalDomains\":[\"a.example.com\",\"a***@example.com\",\"10.0.0.1\"]"); + output.Should().Contain("\"domainName\":\"example.com\""); + output.Should().Contain("j***@example.com", "the existing key/value redaction still runs"); + } + + [Fact] + public void ApplyLoggingRedaction_V1AdditionalDomains_PrettyPrinted_FlagOff_MasksOnlyEmailElement() + { + string input = BuildV1OrderRequestJsonWithMixedSans(indented: true); + input.Should().Contain("\n", "precondition: the body is pretty-printed"); + + string output = CERTInextClient.ApplyLoggingRedaction(input, logSensitiveRequestData: false); + + output.Should().NotContain("alice@"); + output.Should().Contain("\"a***@example.com\""); + output.Should().Contain("\"a.example.com\""); + output.Should().Contain("\"10.0.0.1\""); + // Only the email token changes; the layout of the array is preserved. + string expectedArray = System.Text.RegularExpressions.Regex.Match(input, @"""additionalDomains"":\s*\[[^\]]*\]").Value + .Replace("\"alice@example.com\"", "\"a***@example.com\""); + expectedArray.Should().NotBeEmpty(); + output.Should().Contain(expectedArray); + } + + [Fact] + public void ApplyLoggingRedaction_V1AdditionalDomains_FlagOn_LeavesArrayVerbatim() + { + string input = BuildV1OrderRequestJsonWithMixedSans(indented: false); + + string output = CERTInextClient.ApplyLoggingRedaction(input, logSensitiveRequestData: true); + + output.Should().Be(CERTInextClient.RedactCredentials(input)); + output.Should().Contain("\"additionalDomains\":[\"a.example.com\",\"alice@example.com\",\"10.0.0.1\"]"); + } + + [Fact] + public void RedactPersonalData_HandWrittenWhitespaceInSanArray_MasksEmailAndKeepsLayout() + { + string input = "{ \"additionalDomains\" :\n [ \"a.example.com\" ,\n \"alice@example.com\",\"10.0.0.1\" ] }"; + + string output = CERTInextClient.RedactPersonalData(input); + + output.Should().Be("{ \"additionalDomains\" :\n [ \"a.example.com\" ,\n \"a***@example.com\",\"10.0.0.1\" ] }"); + } + + [Fact] + public void RedactPersonalData_SanArrayKeyMatch_IsCaseInsensitive() + { + CERTInextClient.RedactPersonalData("{\"AdditionalDomains\":[\"alice@example.com\"]}") + .Should().Be("{\"AdditionalDomains\":[\"a***@example.com\"]}"); + } + + [Fact] + public void RedactPersonalData_EscapedEmailElement_IsMasked() + { + // Elements using JSON unicode escapes (backslash-u0040 for '@', backslash-u0069 for 'i') + // must still be detected and masked. + CERTInextClient.RedactPersonalData("{\"additionalDomains\":[\"alice\\u0040example.com\",\"al\\u0069ce@example.com\"]}") + .Should().Be("{\"additionalDomains\":[\"a***@example.com\",\"a***@example.com\"]}"); + } + + [Fact] + public void RedactPersonalData_V2SanArrays_MaskEmailElements_DefenceInDepth() + { + // V2 filters these to DNS/IP before submission; a mis-typed email must still be masked, + // and DNS/IP values must be untouched. + CERTInextClient.RedactPersonalData("{\"certificate\":{\"domain\":\"example.com\",\"additionalDomains\":[\"www.example.com\",\"bob@example.com\"]}}") + .Should().Be("{\"certificate\":{\"domain\":\"example.com\",\"additionalDomains\":[\"www.example.com\",\"b***@example.com\"]}}"); + CERTInextClient.RedactPersonalData("{\"hostname\":\"h.acme.local\",\"additionalHosts\":[\"10.0.0.50\",\"bob@acme.local\",\"::1\"]}") + .Should().Be("{\"hostname\":\"h.acme.local\",\"additionalHosts\":[\"10.0.0.50\",\"b***@acme.local\",\"::1\"]}"); + } + + [Fact] + public void RedactPersonalData_UnrelatedArraysAndValuesWithAt_AreUntouched() + { + // Only the named SAN containers are touched. An '@' in any other array, object key or + // string value is left as it is. + string input = "{\"notifyList\":[\"alice@example.com\"],\"tags\":[\"x@y\"],\"note\":\"ping bob@example.com\"," + + "\"customFields\":{\"owner@example.com\":\"v\"},\"additionalDomains\":[\"a.example.com\"]}"; + + CERTInextClient.RedactPersonalData(input).Should().Be(input); + } + + [Fact] + public void RedactPersonalData_NestedContainersInsideSanArray_AreNotDescendedInto() + { + // Only direct string elements of the array are candidates. + string input = "{\"additionalDomains\":[[\"alice@example.com\"],{\"k\":\"bob@example.com\"},\"carol@example.com\"]}"; + + CERTInextClient.RedactPersonalData(input) + .Should().Be("{\"additionalDomains\":[[\"alice@example.com\"],{\"k\":\"bob@example.com\"},\"c***@example.com\"]}"); + } + + // V1 TrackOrder wire shape per the spec: domainVerification is keyed by domain name, with a + // block-level "status". SanSubmissionProbeTests (finding B) saw an email SAN come back as one + // of these keys. + private const string V1TrackOrderResponseWithEmailDomainKey = + "{\"meta\":{\"status\":\"1\"},\"orderDetails\":{\"orderStatus\":\"Pending\"," + + "\"domainVerification\":{" + + "\"example.com\":{\"dcvMethod\":\"DNS\",\"dcvStatus\":\"1\",\"status\":\"1\",\"verifiedDate\":\"2026-09-01\",\"caaStatus\":\"1\"}," + + "\"san-probe@example.com\":{\"dcvMethod\":\"\",\"dcvStatus\":\"0\",\"status\":\"1\",\"verifiedDate\":\"\",\"caaStatus\":\"1\"}," + + "\"192.0.2.10\":{\"dcvMethod\":\"\",\"dcvStatus\":\"0\",\"status\":\"1\",\"verifiedDate\":\"\",\"caaStatus\":\"1\"}," + + "\"status\":\"0\"}," + + "\"customFields\":{\"owner@example.com\":\"kept\"}}}"; + + [Fact] + public void ApplyLoggingRedaction_V1TrackOrderDomainVerification_FlagOff_MasksEmailKeyOnly() + { + string output = CERTInextClient.ApplyLoggingRedaction(V1TrackOrderResponseWithEmailDomainKey, logSensitiveRequestData: false); + + output.Should().Be(V1TrackOrderResponseWithEmailDomainKey.Replace("\"san-probe@example.com\":", "\"s***@example.com\":")); + + // The masked body still deserializes into the real DTO and keeps the DNS/IP entries. + var parsed = JsonSerializer.Deserialize(output, ClientEquivalentJsonOptions()); + var domainVerification = parsed?.OrderDetails?.DomainVerification; + domainVerification.Should().NotBeNull(); + domainVerification!.GetDomainEntries().Keys.Should().BeEquivalentTo(new[] { "example.com", "s***@example.com", "192.0.2.10" }); + domainVerification.Status.Should().Be("0"); + } + + [Fact] + public void ApplyLoggingRedaction_V1TrackOrderDomainVerification_FlagOn_Verbatim() + { + CERTInextClient.ApplyLoggingRedaction(V1TrackOrderResponseWithEmailDomainKey, logSensitiveRequestData: true) + .Should().Be(V1TrackOrderResponseWithEmailDomainKey); + } + + [Fact] + public void RedactPersonalData_DomainVerificationPrettyPrinted_MasksEmailKey() + { + string input = "{\n \"domainVerification\" : {\n \"alice@example.com\" : { \"dcvStatus\" : \"0\" },\n \"status\" : \"0\"\n }\n}"; + + CERTInextClient.RedactPersonalData(input) + .Should().Be("{\n \"domainVerification\" : {\n \"a***@example.com\" : { \"dcvStatus\" : \"0\" },\n \"status\" : \"0\"\n }\n}"); + } + + [Theory] + [InlineData("{\"additionalDomains\":[\"a.example.com\",\"alice@example.com\",\"bob@ex")] + [InlineData("{\"additionalDomains\":[")] + [InlineData("{\"additionalDomains\":[\"alice@example.com\"")] + [InlineData("{\"domainVerification\":{\"alice@example.com\":{\"dcvStatus\":")] + [InlineData("{\"additionalDomains\":[\"alice@example.com\",,]} trailing @ garbage")] + [InlineData("{not json at all @ }")] + [InlineData("[\"@\"")] + [InlineData("contact admin@example.com")] + [InlineData("additionalDomains=alice@example.com&x=1")] + [InlineData(" ")] + public void RedactPersonalData_MalformedOrTruncatedBody_DoesNotThrow(string input) + { + System.Func act = () => CERTInextClient.RedactPersonalData(input); + act.Should().NotThrow(); + System.Func act2 = () => CERTInextClient.ApplyLoggingRedaction(input, logSensitiveRequestData: false); + act2.Should().NotThrow(); + } + + [Fact] + public void RedactPersonalData_TruncatedBody_MasksElementsSeenBeforeTheFault() + { + // A body cut off mid-array keeps the masks for the complete elements before the cut. + // The partial last element is not a complete token, so it is left as it was. + CERTInextClient.RedactPersonalData("{\"additionalDomains\":[\"a.example.com\",\"alice@example.com\",\"bob@ex") + .Should().Be("{\"additionalDomains\":[\"a.example.com\",\"a***@example.com\",\"bob@ex"); + } + + [Fact] + public void RedactPersonalData_NonJsonBody_IsReturnedUnchanged() + { + string input = "additionalDomains=alice@example.com&x=1"; + CERTInextClient.RedactPersonalData(input).Should().Be(input); + } + } +} diff --git a/CERTInext.Tests/RequestorDesignationEnrollmentTests.cs b/CERTInext.Tests/RequestorDesignationEnrollmentTests.cs new file mode 100644 index 0000000..2e06e47 --- /dev/null +++ b/CERTInext.Tests/RequestorDesignationEnrollmentTests.cs @@ -0,0 +1,392 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Moq; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Regression tests for issues/0027-v2-request-builder-drops-config-fields.md item 5e: the V2 + /// order body's requestor.designation field was hardcoded to "IT Administrator" + /// (the V2 spec's own example value for this Optional free-text field), rather than sourced + /// from a connector config field. V1 never sent requestorDesignation at all — the DTO + /// carried the property, but nothing set it. + /// + /// Covers both paths: + /// - V2 ( → EnrollV2Async): exercised end-to-end + /// against a Strict mock, mirroring + /// V2SubscriptionEnrollmentTests / V2TechnicalContactEnrollmentTests. + /// - V1 ( / + /// ): exercised against a WireMock HTTP + /// stub, mirroring CERTInextClientRequestShapeTests, since the V1 wire shape is built + /// by the concrete client rather than behind . + /// + public class RequestorDesignationEnrollmentTests : IDisposable + { + // --------------------------------------------------------------------------- + // V2 — EnrollV2Async wiring: RequestorDesignation config -> Requestor.Designation + // --------------------------------------------------------------------------- + + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + private static CERTInextConfig BaseV2Config() => new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "5550000000", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + PickupRetries = 0 + }; + + private static CERTInextCAPlugin BuildV2Plugin(ICERTInextClient client, CERTInextConfig config) => + new CERTInextCAPlugin(client, config); + + private static EnrollmentProductInfo MakeV2ProductInfo(string productCode, string productVariant) => + new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = productCode, + ["ProductFamily"] = "ssl", + ["ProductVariant"] = productVariant, + ["DomainName"] = "example.com" + } + }; + + private static void StubCatalog(Mock mock, string productCode, string productTypeId) => + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = productCode, ProductTypeId = productTypeId, Active = true } + }); + + private static void StubHappyOrderPlacement(Mock mock, string orderId) + { + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), orderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), orderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = orderId, Status = "pending-dcv" }); + } + + private static string GenerateCsrPem(string cn) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + AsymmetricCipherKeyPair kp = keyGen.GenerateKeyPair(); + + var csr = new Pkcs10CertificationRequest( + "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, null, kp.Private); + + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + private static async Task RunV2EnrollAndCaptureOrderAsync( + CERTInextConfig config, string orderId = "ord_desig_001") + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // DV SSL (non-UCC) + StubHappyOrderPlacement(mock, orderId); + + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = orderId, Status = "pending-dcv" }); + + var plugin = BuildV2Plugin(mock.Object, config); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), + subject: "CN=example.com", + san: null, + productInfo: MakeV2ProductInfo("842", "dv"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.CARequestID.Should().Be(orderId); + captured.Should().NotBeNull(); + return captured; + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Enroll_V2_RequestorDesignationBlank_SetsDesignationNull(string configValue) + { + var config = BaseV2Config(); + config.RequestorDesignation = configValue; + + var captured = await RunV2EnrollAndCaptureOrderAsync(config, orderId: "ord_desig_002"); + + captured.Requestor.Should().NotBeNull(); + captured.Requestor.Designation.Should().BeNull( + "a blank/unset RequestorDesignation must leave Requestor.Designation null so the " + + "key is omitted from the wire JSON, instead of sending any default designation value"); + } + + [Fact] + public async Task Enroll_V2_RequestorDesignationSet_SendsTrimmedValue() + { + var config = BaseV2Config(); + config.RequestorDesignation = " PKI Manager "; + + var captured = await RunV2EnrollAndCaptureOrderAsync(config, orderId: "ord_desig_003"); + + captured.Requestor.Designation.Should().Be("PKI Manager", + "a configured RequestorDesignation must be forwarded trimmed of surrounding whitespace"); + } + + // --------------------------------------------------------------------------- + // V2 — DTO serialization: requestor.designation key present/absent on the wire + // + // V2Requestor.Designation carries no per-property [JsonIgnore(WhenWritingNull)] of its + // own; it relies solely on the client's global serializer options + // (CERTInextClient.GetJsonOptions, DefaultIgnoreCondition = WhenWritingNull) to omit it + // when null. GetJsonOptions() is private, so this test builds an equivalent + // JsonSerializerOptions inline to verify that global-option omission actually applies to + // this property, rather than relying on plain JsonSerializer.Serialize (whose default + // options do NOT ignore nulls and would show "designation":null instead of omitting it). + // --------------------------------------------------------------------------- + + private static JsonSerializerOptions ClientEquivalentJsonOptions() => new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + [Fact] + public void V2Requestor_Serialization_OmitsDesignation_WhenNull() + { + var requestor = new V2Requestor + { + Name = "Jane Doe", + Email = "jane@example.com", + Phone = "+15550000000", + Designation = null + }; + + string json = JsonSerializer.Serialize(requestor, ClientEquivalentJsonOptions()); + + json.Should().NotContain("designation", + "the designation key itself must be absent when unset, not present-but-null, " + + "under the client's actual serializer options"); + } + + [Fact] + public void V2Requestor_Serialization_IncludesDesignation_WhenSet() + { + var requestor = new V2Requestor + { + Name = "Jane Doe", + Email = "jane@example.com", + Phone = "+15550000000", + Designation = "PKI Manager" + }; + + string json = JsonSerializer.Serialize(requestor, ClientEquivalentJsonOptions()); + + json.Should().Contain("\"designation\":\"PKI Manager\""); + } + + // --------------------------------------------------------------------------- + // V1 — BuildOrderRequestFromLegacyEnrollRequest / RenewCertificateAsync wiring: + // RequestorDesignation config -> requestorInformation.requestorDesignation + // + // Uses a WireMock HTTP stub (mirroring CERTInextClientRequestShapeTests) because the V1 + // wire shape is built inside the concrete CERTInextClient, not behind ICERTInextClient. + // RequestorInformation.RequestorDesignation already carries its own + // [JsonIgnore(Condition = WhenWritingNull)] (API/CertificateRequest.cs), so a blank config + // value is expected to omit the key without needing any client-level options change. + // --------------------------------------------------------------------------- + + private readonly WireMockServer _server = WireMockServer.Start(); + + public void Dispose() => _server.Stop(); + + private CERTInextClient BuildV1Client(CERTInextConfig config) + { + config.ApiUrl = _server.Urls[0]; + return new CERTInextClient(config); + } + + private static CERTInextConfig MinimalV1Config() => new CERTInextConfig + { + AuthMode = "AccessKey", + ApiKey = "test-key", + AccountNumber = "12345", + RequestorName = "Default Requestor", + RequestorEmail = "default@example.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "5550000000", + SignerPlace = "Austin", + SignerIp = "203.0.113.10", + PageSize = 100 + }; + + private void StubHappyEnroll() + { + _server.Given(Request.Create().WithPath("/GenerateOrderSSL").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GenerateOrderSuccessJson(MockCertificateData.OrderNumber1))); + + _server.Given(Request.Create().WithPath("/TrackOrder").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.TrackOrderIssuedJson(MockCertificateData.OrderNumber1))); + + _server.Given(Request.Create().WithPath("/GetCertificate").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetCertificateSuccessJson())); + } + + private JsonElement CapturedGenerateOrderSslBody() + { + var generateOrderRequests = _server.LogEntries + .Where(e => e.RequestMessage.Path == "/GenerateOrderSSL") + .ToList(); + generateOrderRequests.Should().HaveCount(1, + "exactly one GenerateOrderSSL POST should have been emitted"); + string body = generateOrderRequests[0].RequestMessage.Body; + body.Should().NotBeNullOrEmpty(); + return JsonDocument.Parse(body!).RootElement.GetProperty("orderDetails"); + } + + private static EnrollCertificateRequest BasicEnrollRequest() => new EnrollCertificateRequest + { + ProfileId = "842", + Csr = MockCertificateData.FakeCsrPem, + Subject = "CN=test.example.com", + Comment = "Unit test" + }; + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task EnrollCertificateAsync_RequestorDesignationBlank_OmitsFieldFromRequestorInformation(string configValue) + { + StubHappyEnroll(); + var cfg = MinimalV1Config(); + cfg.RequestorDesignation = configValue; + + await BuildV1Client(cfg).EnrollCertificateAsync(BasicEnrollRequest()); + + var requestorInfo = CapturedGenerateOrderSslBody().GetProperty("requestorInformation"); + requestorInfo.TryGetProperty("requestorDesignation", out _).Should().BeFalse( + "a blank/unset RequestorDesignation must omit requestorDesignation from the wire " + + "JSON entirely (V1 never sent this field before this fix, and blank must preserve " + + "that behavior)"); + } + + [Fact] + public async Task EnrollCertificateAsync_RequestorDesignationSet_SendsTrimmedValue() + { + StubHappyEnroll(); + var cfg = MinimalV1Config(); + cfg.RequestorDesignation = " IT Administrator "; + + await BuildV1Client(cfg).EnrollCertificateAsync(BasicEnrollRequest()); + + var requestorInfo = CapturedGenerateOrderSslBody().GetProperty("requestorInformation"); + requestorInfo.GetProperty("requestorDesignation").GetString().Should().Be("IT Administrator", + "a configured RequestorDesignation must be forwarded trimmed of surrounding whitespace"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task RenewCertificateAsync_RequestorDesignationBlank_OmitsFieldFromRequestorInformation(string configValue) + { + StubHappyEnroll(); + var cfg = MinimalV1Config(); + cfg.RequestorDesignation = configValue; + + var renewReq = new RenewCertificateRequest + { + Csr = MockCertificateData.FakeCsrPem, + ProfileId = "842", + ValidityDays = 365, + Comment = "Renewal test" + }; + + await BuildV1Client(cfg).RenewCertificateAsync(MockCertificateData.OrderNumber1, renewReq); + + var requestorInfo = CapturedGenerateOrderSslBody().GetProperty("requestorInformation"); + requestorInfo.TryGetProperty("requestorDesignation", out _).Should().BeFalse( + "a blank/unset RequestorDesignation must omit requestorDesignation from renewal " + + "orders too, mirroring the new-enrollment path"); + } + + [Fact] + public async Task RenewCertificateAsync_RequestorDesignationSet_SendsTrimmedValue() + { + StubHappyEnroll(); + var cfg = MinimalV1Config(); + cfg.RequestorDesignation = " PKI Manager "; + + var renewReq = new RenewCertificateRequest + { + Csr = MockCertificateData.FakeCsrPem, + ProfileId = "842", + ValidityDays = 365, + Comment = "Renewal test" + }; + + await BuildV1Client(cfg).RenewCertificateAsync(MockCertificateData.OrderNumber1, renewReq); + + var requestorInfo = CapturedGenerateOrderSslBody().GetProperty("requestorInformation"); + requestorInfo.GetProperty("requestorDesignation").GetString().Should().Be("PKI Manager", + "a configured RequestorDesignation must be forwarded trimmed of surrounding whitespace " + + "on renewal orders too"); + } + } +} diff --git a/CERTInext.Tests/SanLogMaskingTests.cs b/CERTInext.Tests/SanLogMaskingTests.cs new file mode 100644 index 0000000..2bc2104 --- /dev/null +++ b/CERTInext.Tests/SanLogMaskingTests.cs @@ -0,0 +1,277 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.Extensions.CAPlugin.CERTInext.Models; +using Keyfactor.Logging; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Issue 0040 follow-up: with LogSensitiveRequestData off, email-type SAN values are + /// masked in log lines (); DNS, IP and URI values stay + /// verbatim. With the flag on, everything is logged in full. + /// + public class LogSanitizerFormatSansTests + { + [Theory] + [InlineData("rfc822name")] + [InlineData("RFC822Name")] + [InlineData("rfc822")] + [InlineData("email")] + public void FormatSanValue_FlagOff_EmailType_IsMasked(string type) + => LogSanitizer.FormatSanValue(type, "alice@example.com", false).Should().Be("a***@example.com"); + + [Theory] + [InlineData("rfc822name")] + [InlineData("email")] + [InlineData("otherName")] + [InlineData(null)] + public void FormatSanValue_FlagOn_IsVerbatim(string type) + => LogSanitizer.FormatSanValue(type, "alice@example.com", true).Should().Be("alice@example.com"); + + [Theory] + [InlineData("dnsname", "www.example.com")] + [InlineData("dns", "www.example.com")] + [InlineData("ipaddress", "192.0.2.10")] + [InlineData("ip", "2001:db8::1")] + [InlineData("uri", "https://example.com/path")] + [InlineData("uniformresourceidentifier", "https://user@example.com/")] + public void FormatSanValue_DnsIpUri_VerbatimEitherWay(string type, string value) + { + LogSanitizer.FormatSanValue(type, value, false).Should().Be(value); + LogSanitizer.FormatSanValue(type, value, true).Should().Be(value); + } + + [Theory] + [InlineData("upn")] + [InlineData(null)] + public void FormatSanValue_FlagOff_UnknownTypeWithAt_IsMasked(string type) + => LogSanitizer.FormatSanValue(type, "bob@corp.example.com", false).Should().Be("b***@corp.example.com"); + + [Fact] + public void FormatSanValue_FlagOff_UnknownTypeWithoutAt_IsVerbatim() + => LogSanitizer.FormatSanValue("upn", "host.example.com", false).Should().Be("host.example.com"); + + [Theory] + [InlineData(null)] + [InlineData("")] + public void FormatSanValue_NullOrEmptyValue_ReturnedAsIs(string value) + { + LogSanitizer.FormatSanValue("rfc822name", value, false).Should().Be(value); + LogSanitizer.FormatSanValue(null, value, false).Should().Be(value); + } + + [Fact] + public void FormatSans_Dictionary_FlagOff_MasksOnlyEmail() + { + var san = new Dictionary + { + ["dnsname"] = new[] { "a.example.com", "b.example.com" }, + ["ipaddress"] = new[] { "192.0.2.10" }, + ["rfc822name"] = new[] { "alice@example.com" }, + ["uri"] = new[] { "https://example.com" } + }; + + LogSanitizer.FormatSans(san, false).Should().Be( + "dnsname:a.example.com; dnsname:b.example.com; ipaddress:192.0.2.10; " + + "rfc822name:a***@example.com; uri:https://example.com"); + LogSanitizer.FormatSans(san, true).Should().Contain("rfc822name:alice@example.com"); + } + + [Fact] + public void FormatSans_Dictionary_NullOrEmpty_ReturnsNone() + { + LogSanitizer.FormatSans((Dictionary)null, false).Should().Be("(none)"); + LogSanitizer.FormatSans(new Dictionary(), false).Should().Be("(none)"); + LogSanitizer.FormatSans(new Dictionary { ["dnsname"] = null }, false).Should().Be("(none)"); + } + + [Fact] + public void FormatSans_SanEntries_FlagOff_MasksEmail_SkipsNullEntries() + { + var sans = new List + { + new SanEntry { Type = "dns", Value = "a.example.com" }, + null, + new SanEntry { Type = "email", Value = "alice@example.com" } + }; + + LogSanitizer.FormatSans(sans, false).Should().Be("dns:a.example.com; email:a***@example.com"); + LogSanitizer.FormatSans(sans, true).Should().Be("dns:a.example.com; email:alice@example.com"); + LogSanitizer.FormatSans((IEnumerable)null, false).Should().Be("(none)"); + } + + [Fact] + public void FormatSans_StillStripsControlCharacters() + => LogSanitizer.FormatSans(new Dictionary { ["dnsname"] = new[] { "a.example.com\nforged" } }, false) + .Should().Be("dnsname:a.example.com\\nforged"); + + [Fact] + public void FormatUntypedSans_FlagOff_MasksAtValuesOnly() + { + var values = new[] { "a.example.com", "alice@example.com", "192.0.2.10" }; + LogSanitizer.FormatUntypedSans(values, false).Should().Be("a.example.com; a***@example.com; 192.0.2.10"); + LogSanitizer.FormatUntypedSans(values, true).Should().Be("a.example.com; alice@example.com; 192.0.2.10"); + LogSanitizer.FormatUntypedSans(values, false, ", ").Should().Be("a.example.com, a***@example.com, 192.0.2.10"); + } + + [Fact] + public void FormatUntypedSans_NullOrEmpty_ReturnsNone() + { + LogSanitizer.FormatUntypedSans(null, false).Should().Be("(none)"); + LogSanitizer.FormatUntypedSans(Array.Empty(), false).Should().Be("(none)"); + } + } + + /// + /// Plugin-level log capture for the issue 0040 follow-up: the "Enrollment attempt started" + /// line and BuildSanList's "Resolved N SAN(s)" / "submitted rather than dropped" lines + /// must mask an email SAN with LogSensitiveRequestData off and log it in full with it + /// on. Same swap seam and non-parallel collection as + /// ; only lines carrying this call's unique + /// subject marker are considered. + /// + [Collection("LogHandlerFactory-NoParallel")] + public class SanLogMaskingPluginTests + { + private const string EmailSan = "alice@example.com"; + private const string MaskedEmailSan = "a***@example.com"; + private const string IpSan = "192.0.2.10"; + + private sealed class CapturingLoggerProvider : ILoggerProvider + { + public ConcurrentQueue Messages { get; } = new(); + public ILogger CreateLogger(string categoryName) => new CapturingLogger(Messages); + public void Dispose() { } + + private sealed class CapturingLogger : ILogger + { + private readonly ConcurrentQueue _messages; + public CapturingLogger(ConcurrentQueue messages) => _messages = messages; + public IDisposable BeginScope(TState state) => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) + => _messages.Enqueue(formatter(state, exception)); + } + } + + private static async Task<(List Messages, string Primary, EnrollCertificateRequest Captured)> EnrollV1Async( + bool logSensitiveRequestData) + { + string marker = "sanmask-" + Guid.NewGuid().ToString("N"); + string primary = marker + ".example.com"; + + EnrollCertificateRequest captured = null; + var mock = new Mock(MockBehavior.Loose); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .Callback((req, _) => captured = req) + .ReturnsAsync(new EnrollCertificateResponse + { + Id = "ORD-SANMASK", Status = "issued", Certificate = MockCertificateData.FakePemCertificate + }); + + var productInfo = new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = "842" + } + }; + var san = new Dictionary + { + ["dnsname"] = new[] { primary }, + ["ipaddress"] = new[] { IpSan }, + ["rfc822name"] = new[] { EmailSan } + }; + + var provider = new CapturingLoggerProvider(); + var factory = LoggerFactory.Create(b => b.AddProvider(provider).SetMinimumLevel(LogLevel.Trace)); + try + { + LogHandler.Factory = factory; + // Constructed AFTER the swap so _logger resolves through the capturing factory. + var plugin = new CERTInextCAPlugin(mock.Object, new CERTInextConfig + { + PickupRetries = 0, + LogSensitiveRequestData = logSensitiveRequestData + }); + await plugin.Enroll(MockCertificateData.FakeCsrPem, $"CN={primary}", san, productInfo, + RequestFormat.PKCS10, EnrollmentType.New); + } + finally + { + LogHandler.Factory = Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance; + factory.Dispose(); + } + + return (provider.Messages.Where(m => m != null && m.Contains(marker)).ToList(), primary, captured); + } + + [Fact] + public async Task Enroll_FlagOff_MasksEmailSan_KeepsDnsAndIpVerbatim_WireUnchanged() + { + var (messages, primary, captured) = await EnrollV1Async(logSensitiveRequestData: false); + + var start = messages.Where(m => m.StartsWith("Enrollment attempt started")).ToList(); + start.Should().ContainSingle(); + start[0].Should().Contain($"dnsname:{primary}") + .And.Contain($"ipaddress:{IpSan}") + .And.Contain($"rfc822name:{MaskedEmailSan}") + .And.NotContain(EmailSan); + + var resolved = messages.Where(m => m.StartsWith("Resolved ")).ToList(); + resolved.Should().ContainSingle(); + resolved[0].Should().Contain($"dns:{primary}") + .And.Contain($"ip:{IpSan}") + .And.Contain($"email:{MaskedEmailSan}") + .And.NotContain(EmailSan); + + messages.Should().NotContain(m => m.Contains(EmailSan), + "no plugin log line for this enrollment may carry the unmasked email SAN with the flag off"); + + captured.Should().NotBeNull(); + captured!.Sans.Select(s => s.Value).Should().Contain(EmailSan, + "masking is log-only; the SAN still goes to CERTInext unchanged"); + } + + [Fact] + public async Task Enroll_FlagOn_LogsEmailSanInFull() + { + var (messages, _, captured) = await EnrollV1Async(logSensitiveRequestData: true); + + messages.Where(m => m.StartsWith("Enrollment attempt started")).Should().ContainSingle() + .Which.Should().Contain($"rfc822name:{EmailSan}"); + messages.Where(m => m.StartsWith("Resolved ")).Should().ContainSingle() + .Which.Should().Contain($"email:{EmailSan}"); + messages.Should().NotContain(m => m.Contains(MaskedEmailSan)); + + captured!.Sans.Select(s => s.Value).Should().Contain(EmailSan); + } + } +} diff --git a/CERTInext.Tests/SanSubmissionTests.cs b/CERTInext.Tests/SanSubmissionTests.cs new file mode 100644 index 0000000..c305665 --- /dev/null +++ b/CERTInext.Tests/SanSubmissionTests.cs @@ -0,0 +1,707 @@ +// Copyright 2026 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Moq; +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.Asn1.Pkcs; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Regression tests for UCC SAN submission. + /// + /// The defect these pin down: the AnyCA REST Gateway keys its SAN dictionary + /// dnsname, but MapSanType only recognized dns. Every DNS SAN was + /// therefore typed "dnsname", filtered out by a DNS-only test when building + /// certificateInformation.additionalDomains, and the order reached CERTInext with + /// no additional domains at all — yielding a certificate holding only the CN. Because + /// CERTInext ignores the CSR's subjectAltName extension entirely (measured; see + /// SanSubmissionProbeTests), SANs present on the CSR did not compensate. + /// + /// The end-to-end tests below drive a real against WireMock + /// so they assert on the JSON actually put on the wire, not on an intermediate object. + /// A test that only checked the mapping function would not have caught this bug, since + /// the mapping "worked" — it was the interaction with the downstream filter that lost + /// the names. + /// + public class SanSubmissionTests : IDisposable + { + private readonly WireMockServer _server; + + public SanSubmissionTests() + { + _server = WireMockServer.Start(); + StubHappyEnroll(); + } + + public void Dispose() => _server.Stop(); + + // ----------------------------------------------------------------------- + // Harness + // ----------------------------------------------------------------------- + + private void StubHappyEnroll() + { + _server.Given(Request.Create().WithPath("/GenerateOrderSSL").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GenerateOrderSuccessJson(MockCertificateData.OrderNumber1))); + + _server.Given(Request.Create().WithPath("/TrackOrder").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.TrackOrderIssuedJson(MockCertificateData.OrderNumber1))); + + _server.Given(Request.Create().WithPath("/GetCertificate").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.GetCertificateSuccessJson())); + } + + private CERTInextClient BuildRealClient() => new CERTInextClient(new CERTInextConfig + { + ApiUrl = _server.Urls[0], + AuthMode = "AccessKey", + ApiKey = "test-key", + AccountNumber = "12345", + RequestorName = "Default Requestor", + RequestorEmail = "default@example.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "5550000000", + SignerPlace = "Austin", + SignerIp = "203.0.113.10", + PageSize = 100 + }); + + /// + /// Plugin wired to a real client pointed at WireMock. PickupRetries = 0 is set on + /// the plugin's own config (not the client's) — that is where the synchronous-pickup + /// budget is read, and leaving it at the default would make every test here sit in a + /// polling loop. + /// + private CERTInextCAPlugin BuildPlugin() => + new CERTInextCAPlugin(BuildRealClient(), new CERTInextConfig { PickupRetries = 0 }); + + private static EnrollmentProductInfo MakeProductInfo(string profileId = "842") => + new EnrollmentProductInfo + { + ProductID = profileId, + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProfileId"] = profileId + } + }; + + /// The orderDetails.certificateInformation block actually POSTed. + private JsonElement CapturedCertificateInformation() + { + var posts = _server.LogEntries + .Where(e => e.RequestMessage.Path == "/GenerateOrderSSL") + .ToList(); + posts.Should().HaveCount(1, "exactly one GenerateOrderSSL POST should have been emitted"); + + string body = posts[0].RequestMessage.Body; + body.Should().NotBeNullOrEmpty(); + + return JsonDocument.Parse(body!).RootElement + .GetProperty("orderDetails") + .GetProperty("certificateInformation"); + } + + private static List AdditionalDomains(JsonElement certificateInformation) => + certificateInformation.TryGetProperty("additionalDomains", out var el) + ? el.EnumerateArray().Select(x => x.GetString()).ToList() + : null; + + // ----------------------------------------------------------------------- + // CSR generation (BouncyCastle — project crypto policy) + // ----------------------------------------------------------------------- + + /// + /// Builds a real PKCS#10 CSR for carrying arbitrary + /// in its subjectAltName extension — used to exercise + /// GeneralName types that have no domain-name rendering. + /// + private static string GenerateCsrPemWithGeneralNames(string cn, params GeneralName[] names) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + AsymmetricCipherKeyPair kp = keyGen.GenerateKeyPair(); + + Asn1Set attributes = null; + if (names != null && names.Length > 0) + { + var extGen = new X509ExtensionsGenerator(); + extGen.AddExtension(X509Extensions.SubjectAlternativeName, critical: false, + extValue: new GeneralNames(names)); + + attributes = new DerSet(new AttributePkcs( + PkcsObjectIdentifiers.Pkcs9AtExtensionRequest, + new DerSet(extGen.Generate()))); + } + + var csr = new Pkcs10CertificationRequest( + "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, attributes, kp.Private); + + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + /// + /// Builds a real PKCS#10 CSR for , optionally carrying a + /// subjectAltName extension holding . + /// + private static string GenerateCsrPem(string cn, params string[] dnsSans) => + GenerateCsrPemWithGeneralNames( + cn, (dnsSans ?? Array.Empty()).Select(d => new GeneralName(GeneralName.DnsName, d)).ToArray()); + + // ======================================================================= + // End-to-end: Command's SAN dictionary → the JSON on the wire + // ======================================================================= + + /// + /// THE regression test. "dnsname" is the key the real gateway sends — verified against + /// a customer gateway log: + /// SANs=dnsname:CLAUDIOTEST20.ucsd.edu; dnsname:CLAUDIOTEST20.ad.ucsd.edu + /// Before the fix, additionalDomains was absent from the body entirely. + /// + [Fact] + public async Task GatewayDnsNameKey_ReachesAdditionalDomains() + { + var plugin = BuildPlugin(); + + await plugin.Enroll( + csr: GenerateCsrPem("host.example.com"), + subject: "CN=host.example.com", + san: new Dictionary + { + ["dnsname"] = new[] { "host.example.com", "alt.example.com" } + }, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + var certInfo = CapturedCertificateInformation(); + certInfo.GetProperty("domainName").GetString().Should().Be("host.example.com"); + + AdditionalDomains(certInfo).Should().BeEquivalentTo(new[] { "alt.example.com" }, + "the extra SAN must reach additionalDomains, and the CN must not be repeated there"); + } + + /// + /// The short "dns" spelling must keep working — some callers and older hosts use it. + /// + [Fact] + public async Task ShortDnsKey_StillReachesAdditionalDomains() + { + var plugin = BuildPlugin(); + + await plugin.Enroll( + csr: GenerateCsrPem("host.example.com"), + subject: "CN=host.example.com", + san: new Dictionary + { + ["dns"] = new[] { "alt.example.com" } + }, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + AdditionalDomains(CapturedCertificateInformation()) + .Should().BeEquivalentTo(new[] { "alt.example.com" }); + } + + /// + /// SANs present only on the CSR must still reach additionalDomains. CERTInext does not + /// read the CSR's SAN extension, so if we don't forward these the names never appear + /// on the certificate. + /// + [Fact] + public async Task CsrSans_ReachAdditionalDomains_WhenGatewaySuppliesNone() + { + var plugin = BuildPlugin(); + + await plugin.Enroll( + csr: GenerateCsrPem("host.example.com", "host.example.com", "fromcsr.example.com"), + subject: "CN=host.example.com", + san: null, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + AdditionalDomains(CapturedCertificateInformation()) + .Should().BeEquivalentTo(new[] { "fromcsr.example.com" }); + } + + /// + /// Union, not either/or: names unique to each source survive and the overlap collapses. + /// + [Fact] + public async Task CsrOnlySans_AreIgnored_WhenGatewaySuppliesAnyEntries() + { + // Regression: this test used to assert the CSR was unioned in on top of whatever the + // gateway supplied. Full-review's security lens found that risky: Command's SAN + // dictionary is how an enrollment pattern's SAN policy is expressed, and a signed CSR — + // usually generated by the subscriber's own tooling, not by Command — can legitimately + // carry more names than that policy allows. Unioning them in would re-introduce a name + // the policy excluded. The CSR is now consulted only as a fallback when the gateway + // supplies nothing at all (see CsrSans_ReachAdditionalDomains_WhenGatewaySuppliesNone). + var plugin = BuildPlugin(); + + await plugin.Enroll( + csr: GenerateCsrPem("host.example.com", "host.example.com", "csronly.example.com"), + subject: "CN=host.example.com", + san: new Dictionary + { + ["dnsname"] = new[] { "gatewayonly.example.com" } + }, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + var domains = AdditionalDomains(CapturedCertificateInformation()); + + domains.Should().BeEquivalentTo(new[] { "gatewayonly.example.com" }, + "the gateway supplied a (non-empty) SAN set, so the CSR's own SAN extension must be " + + "ignored entirely, not merged in on top of it"); + } + + /// + /// Regression: the CSR-fallback trigger used to be "the gateway dictionary computed to zero + /// added entries", which cannot distinguish "Command never populated SAN data" (the case the + /// fallback exists for) from "Command's enrollment pattern ran and deliberately computed + /// zero SANs for this request" (an explicit policy decision this plugin must respect). A + /// non-null dictionary whose only key maps to an empty array is the latter — the fallback + /// must not engage, even though it computes to the same "0 SANs added" outcome as a null + /// dictionary would. + /// + [Fact] + public async Task CsrSans_AreIgnored_WhenGatewaySuppliesNonNullDictWithOnlyEmptyValues() + { + var plugin = BuildPlugin(); + + await plugin.Enroll( + csr: GenerateCsrPem("host.example.com", "host.example.com", "csronly.example.com"), + subject: "CN=host.example.com", + san: new Dictionary + { + // Non-null dictionary, but the key maps to no values — computes to zero added + // SANs, same as san == null would, but it must NOT be treated the same way. + ["dnsname"] = Array.Empty() + }, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + AdditionalDomains(CapturedCertificateInformation()).Should().BeNull( + "a non-null gateway SAN dictionary that computes to zero entries must be respected " + + "as Command's own decision, not treated as 'Command supplied nothing' and " + + "backfilled from the CSR"); + } + + /// + /// The CN is already submitted as domainName; repeating it in additionalDomains is + /// suppressed. CERTInext collapses it anyway (measured), so this keeps the body matching + /// what we log rather than relying on undocumented CA-side behaviour. + /// + [Fact] + public async Task Cn_IsNotRepeatedInAdditionalDomains() + { + var plugin = BuildPlugin(); + + await plugin.Enroll( + csr: GenerateCsrPem("host.example.com", "host.example.com"), + subject: "CN=host.example.com", + san: new Dictionary + { + ["dnsname"] = new[] { "host.example.com" } + }, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + var certInfo = CapturedCertificateInformation(); + certInfo.GetProperty("domainName").GetString().Should().Be("host.example.com"); + AdditionalDomains(certInfo).Should().BeNull( + "with the CN as the only SAN there is nothing left to send, so the field is omitted"); + } + + /// + /// Non-DNS SANs are submitted rather than silently discarded. CERTInext accepts them + /// verbatim (measured) and the resulting order cannot pass validation — a visible + /// failure, deliberately preferred over issuing a certificate that quietly lacks names + /// the subscriber requested. + /// + [Fact] + public async Task NonDnsSans_AreSubmitted_NotDropped() + { + var plugin = BuildPlugin(); + + await plugin.Enroll( + csr: GenerateCsrPem("host.example.com"), + subject: "CN=host.example.com", + san: new Dictionary + { + ["dnsname"] = new[] { "alt.example.com" }, + ["ipaddress"] = new[] { "192.0.2.10" }, + ["rfc822name"] = new[] { "admin@example.com" } + }, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + AdditionalDomains(CapturedCertificateInformation()) + .Should().BeEquivalentTo(new[] { "alt.example.com", "192.0.2.10", "admin@example.com" }); + } + + /// + /// Regression for a stale-count bug in BuildSanList's own audit log: with a mixed + /// DNS/non-DNS gateway SAN dictionary and SubmitNonDnsSans=false, the "Resolved N SAN(s)" + /// log line reported the pre-filter gateway count (3) alongside the post-filter total (1) — + /// an arithmetic impossibility ("Resolved 1 ... FromGatewayRequest=3"). There is no log- + /// capture seam in this codebase (ILogger comes from a fixed LogHandler.GetClassLogger() + /// field, not an injectable dependency), so this pins the payload-level data the log line is + /// computed from instead: with the non-DNS entries filtered out, exactly the one DNS name + /// must reach additionalDomains — proving the surviving gateway-sourced count is 1, not the + /// pre-filter 3 the stale log line used to claim. + /// + [Fact] + public async Task MixedGatewaySans_SubmitNonDnsSansFalse_OnlyDnsNameSurvivesFiltering() + { + var plugin = new CERTInextCAPlugin( + BuildRealClient(), + new CERTInextConfig { PickupRetries = 0, SubmitNonDnsSans = false }); + + await plugin.Enroll( + csr: GenerateCsrPem("host.example.com"), + subject: "CN=host.example.com", + san: new Dictionary + { + ["dnsname"] = new[] { "alt.example.com" }, + ["ipaddress"] = new[] { "192.0.2.10" }, + ["rfc822name"] = new[] { "admin@example.com" } + }, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + AdditionalDomains(CapturedCertificateInformation()) + .Should().BeEquivalentTo(new[] { "alt.example.com" }, + "only the DNS entry should survive the SubmitNonDnsSans=false filter, out of " + + "3 the gateway supplied"); + } + + /// + /// A CSR we cannot parse must not break enrollment — the gateway-supplied SANs still go. + /// FakeCsrPem is deliberately truncated, so this also guards the many existing + /// tests that pass it. + /// + [Fact] + public async Task UnparseableCsr_DoesNotBlockGatewaySuppliedSans() + { + var plugin = BuildPlugin(); + + await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=host.example.com", + san: new Dictionary + { + ["dnsname"] = new[] { "alt.example.com" } + }, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + AdditionalDomains(CapturedCertificateInformation()) + .Should().BeEquivalentTo(new[] { "alt.example.com" }); + } + + /// + /// No SANs from either source → the field is omitted rather than emitted as null/empty. + /// + [Fact] + public async Task NoSansAnywhere_OmitsAdditionalDomains() + { + var plugin = BuildPlugin(); + + await plugin.Enroll( + csr: GenerateCsrPem("host.example.com"), + subject: "CN=host.example.com", + san: null, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + AdditionalDomains(CapturedCertificateInformation()).Should().BeNull(); + } + + // ======================================================================= + // GeneralName types with no domain-name rendering + // ======================================================================= + + /// + /// A UPN otherName and a directoryName must NOT be submitted. + /// + /// Regression: GeneralNameToValue's default branch returned BouncyCastle's ASN.1 + /// stringification, so a Windows-generated CSR carrying a UPN otherName put + /// "[1.3.6.1.4.1.311.20.2.3, [CONTEXT 0]svc@corp.example.com]" into additionalDomains as if + /// it were a domain name — breaking orders that previously succeeded, and contradicting the + /// method's own doc comment. These types cannot become a certificate SAN via a domain-name + /// field at all, which is why they are skipped (with a Warning) rather than submitted the way + /// well-formed IP/email/URI SANs are. + /// + [Fact] + public async Task CsrOtherNameAndDirectoryName_AreNotSubmittedAsDomains() + { + // UPN otherName, as emitted by Windows/AD certificate tooling. + var upn = new GeneralName(GeneralName.OtherName, new DerSequence( + new DerObjectIdentifier("1.3.6.1.4.1.311.20.2.3"), + new DerTaggedObject(true, 0, new DerUtf8String("svc@corp.example.com")))); + + var directoryName = new GeneralName( + GeneralName.DirectoryName, new X509Name("CN=host.example.com,O=Acme")); + + var plugin = BuildPlugin(); + + await plugin.Enroll( + csr: GenerateCsrPemWithGeneralNames( + "host.example.com", + new GeneralName(GeneralName.DnsName, "alt.example.com"), + upn, + directoryName), + subject: "CN=host.example.com", + san: null, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + var domains = AdditionalDomains(CapturedCertificateInformation()); + + domains.Should().BeEquivalentTo(new[] { "alt.example.com" }, + "only the renderable DNS name may be submitted"); + domains.Should().NotContain(d => d.Contains("1.3.6.1.4.1.311.20.2.3"), + "an otherName must never be submitted as an ASN.1 dump"); + domains.Should().NotContain(d => d.Contains("CONTEXT"), + "BouncyCastle ASN.1 debris must never reach the wire"); + domains.Should().NotContain(d => d.StartsWith("CN=", StringComparison.OrdinalIgnoreCase), + "a directoryName must never be submitted as a domain"); + } + + // ======================================================================= + // Log-injection hardening (CWE-117) + // ======================================================================= + + /// + /// SAN values reach the log from the CSR and from Command's SAN dictionary — i.e. from the + /// requester. Structured message templates stop format-string abuse but not embedded + /// newlines, so a value carrying CRLF could forge audit records in the very log lines added + /// to make the submitted SAN set auditable. LogSanitizer is internal (not private) and + /// shared between the plugin and the client, so this is a direct call, not reflection. + /// + [Theory] + [InlineData("evil.example.com\r\nINFO forged record", "evil.example.com\\r\\nINFO forged record")] + [InlineData("a\nb", "a\\nb")] + [InlineData("a\tb", "a\\tb")] + [InlineData("plain.example.com", "plain.example.com")] + [InlineData("", "")] + [InlineData(null, null)] + public void SanitizeForLog_NeutralizesControlCharacters(string input, string expected) + { + var actual = Keyfactor.Extensions.CAPlugin.CERTInext.Models.LogSanitizer.Strip(input); + + actual.Should().Be(expected); + } + + /// + /// A CRLF-bearing SAN must not break enrollment, and the value is still submitted verbatim — + /// the scrub is a logging concern and deliberately does not mutate the payload sent to the CA. + /// + [Fact] + public async Task SanValueWithCrLf_DoesNotBreakEnrollment() + { + var plugin = BuildPlugin(); + + await plugin.Enroll( + csr: GenerateCsrPem("host.example.com"), + subject: "CN=host.example.com", + san: new Dictionary + { + ["dnsname"] = new[] { "alt.example.com\r\nforged log line" } + }, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + AdditionalDomains(CapturedCertificateInformation()) + .Should().ContainSingle().Which.Should().Contain("alt.example.com"); + } + + // ======================================================================= + // SubmitNonDnsSans escape hatch + // ======================================================================= + + /// + /// Submitting non-DNS SANs flips affected enrollments from "issues, silently missing the + /// name" to "parks pending". SubmitNonDnsSans=false restores the pre-1.0.1 behaviour so an + /// upgraded host has a way back that isn't a plugin downgrade. + /// + [Fact] + public async Task SubmitNonDnsSansFalse_SubmitsDnsNamesOnly() + { + var plugin = new CERTInextCAPlugin( + BuildRealClient(), + new CERTInextConfig { PickupRetries = 0, SubmitNonDnsSans = false }); + + await plugin.Enroll( + csr: GenerateCsrPem("host.example.com"), + subject: "CN=host.example.com", + san: new Dictionary + { + ["dnsname"] = new[] { "alt.example.com" }, + ["ipaddress"] = new[] { "192.0.2.10" }, + ["rfc822name"] = new[] { "admin@example.com" } + }, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + AdditionalDomains(CapturedCertificateInformation()) + .Should().BeEquivalentTo(new[] { "alt.example.com" }, + "with the switch off, only DNS names are submitted"); + } + + /// + /// The switch defaults to true, so the documented default behaviour is pinned independently + /// of any test that sets it explicitly. + /// + [Fact] + public void SubmitNonDnsSans_DefaultsToTrue() + { + new CERTInextConfig().SubmitNonDnsSans.Should().BeTrue(); + } + + /// + /// Regression for a self-contradicting audit record: BuildSanList used to log "N SAN(s) + /// ... have been added to the order" for CSR-fallback entries, then filter exactly those + /// entries back out two lines later when SubmitNonDnsSans is false — a false claim in the + /// same call. The fix reordered the method to filter first and log the final result, which + /// this test exercises functionally: with the gateway supplying nothing (so the CSR fallback + /// engages) and a non-DNS CSR SAN present, SubmitNonDnsSans=false must still result in that + /// name being genuinely absent from the wire, not merely mis-described in the log. + /// + [Fact] + public async Task CsrFallbackNonDnsSan_IsExcluded_WhenSubmitNonDnsSansFalse() + { + var plugin = new CERTInextCAPlugin( + BuildRealClient(), + new CERTInextConfig { PickupRetries = 0, SubmitNonDnsSans = false }); + + await plugin.Enroll( + csr: GenerateCsrPemWithGeneralNames( + "host.example.com", + new GeneralName(GeneralName.DnsName, "host.example.com"), + new GeneralName(GeneralName.DnsName, "alt.example.com"), + new GeneralName(GeneralName.Rfc822Name, "admin@example.com")), + subject: "CN=host.example.com", + san: null, + productInfo: MakeProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + AdditionalDomains(CapturedCertificateInformation()) + .Should().BeEquivalentTo(new[] { "alt.example.com" }, + "the CSR-fallback email SAN must be genuinely absent from the order, not just " + + "misreported as present"); + } + + // ======================================================================= + // Renew path — previously submitted no SANs at all + // ======================================================================= + + /// + /// A renewal that goes through the CERTInext renew API must carry the same domain set + /// as a new enrollment, and must take its primary domain from the subject's CN rather + /// than from the prior order's requestor name. + /// + [Fact] + public async Task RenewalRequest_CarriesSubjectAndSans() + { + var clientMock = new Mock(MockBehavior.Loose); + RenewCertificateRequest captured = null; + + clientMock + .Setup(c => c.RenewCertificateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback((_, req, __) => captured = req) + .ReturnsAsync(MockCertificateData.IssuedEnrollResponse()); + + var readerMock = new Mock(MockBehavior.Loose); + readerMock + .Setup(r => r.GetRequestIDBySerialNumber(It.IsAny())) + .ReturnsAsync("PRIOR-ORDER-1"); + + // The renewal-window decision reads expiry from the data reader, not from the CA. + // Put the prior cert 10 days out so it lands inside the 30-day window below and the + // renew API path is actually taken. + readerMock + .Setup(r => r.GetExpirationDateByRequestId(It.IsAny())) + .Returns(DateTime.UtcNow.AddDays(10)); + + var plugin = new CERTInextCAPlugin(clientMock.Object, readerMock.Object); + + var productInfo = MakeProductInfo(); + productInfo.ProductParameters["PriorCertSN"] = "AABBCCDDEEFF"; + productInfo.ProductParameters["RenewalWindowDays"] = "30"; + + await plugin.Enroll( + csr: GenerateCsrPem("host.example.com", "host.example.com", "alt.example.com"), + subject: "CN=host.example.com", + san: new Dictionary + { + ["dnsname"] = new[] { "host.example.com", "alt.example.com" } + }, + productInfo: productInfo, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.Renew); + + captured.Should().NotBeNull("the renew API path should have been taken"); + + // Bind to a local so the compiler's null-flow analysis is satisfied — a + // FluentAssertions NotBeNull() does not narrow the nullable reference. + RenewCertificateRequest renewReq = captured!; + + renewReq.Subject.Should().Be("CN=host.example.com", + "without the subject the renewal order has no usable primary domain"); + renewReq.Sans.Should().NotBeNull("renewals previously dropped every SAN"); + renewReq.Sans.Select(s => s.Value) + .Should().BeEquivalentTo(new[] { "host.example.com", "alt.example.com" }); + } + } +} diff --git a/CERTInext.Tests/SensitiveRequestDataConfigTests.cs b/CERTInext.Tests/SensitiveRequestDataConfigTests.cs new file mode 100644 index 0000000..3dfd462 --- /dev/null +++ b/CERTInext.Tests/SensitiveRequestDataConfigTests.cs @@ -0,0 +1,78 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using FluentAssertions; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Issue 0040: LogSensitiveRequestData is a new opt-in CA connector setting, off by + /// default, that gates whether requestor personal data and full CA request/response bodies + /// are written to gateway logs. + /// + public class SensitiveRequestDataConfigTests + { + [Fact] + public void CERTInextConfig_DefaultsToFalse() + { + new CERTInextConfig().LogSensitiveRequestData.Should().BeFalse( + "sensitive-data logging must be opt-in, not opt-out"); + } + + [Fact] + public void GetCAConnectorAnnotations_ContainsLogSensitiveRequestData() + { + var annotations = CERTInextCAPluginConfig.GetCAConnectorAnnotations(); + + annotations.Should().ContainKey(Constants.Config.LogSensitiveRequestData); + + var annotation = annotations[Constants.Config.LogSensitiveRequestData]; + annotation.Type.Should().Be("Boolean"); + annotation.DefaultValue.Should().Be(false); + annotation.Comments.Should().ContainAll("name", "email", "phone", + "temporary", "Credentials"); + } + + [Fact] + public void Constants_LogSensitiveRequestData_MatchesJsonPropertyName() + { + // The Dictionary key used by the Command UI/connector config must match the + // [JsonPropertyName] on CERTInextConfig for the round-trip through + // JsonSerializer.Serialize(configProvider.CAConnectionData) / + // JsonSerializer.Deserialize in Initialize() to work. + Constants.Config.LogSensitiveRequestData.Should().Be("LogSensitiveRequestData"); + } + + [Fact] + public void CERTInextConfig_DeserializesLogSensitiveRequestData_WhenTrue() + { + string json = "{\"LogSensitiveRequestData\": true}"; + var config = System.Text.Json.JsonSerializer.Deserialize(json); + + config.Should().NotBeNull(); + config!.LogSensitiveRequestData.Should().BeTrue(); + } + + [Fact] + public void CERTInextConfig_DeserializesLogSensitiveRequestData_OmittedField_DefaultsFalse() + { + string json = "{\"ApiUrl\": \"https://ca.example.com\"}"; + var config = System.Text.Json.JsonSerializer.Deserialize(json); + + config.Should().NotBeNull(); + config!.LogSensitiveRequestData.Should().BeFalse(); + } + } +} diff --git a/CERTInext.Tests/StatusMapperV2Tests.cs b/CERTInext.Tests/StatusMapperV2Tests.cs new file mode 100644 index 0000000..9b3d866 --- /dev/null +++ b/CERTInext.Tests/StatusMapperV2Tests.cs @@ -0,0 +1,206 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections.Generic; +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.CERTInext.Models; +using Keyfactor.PKI.Enums.EJBCA; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Unit tests for the V2-specific mapping methods on . + /// + public class StatusMapperV2Tests + { + // --------------------------------------------------------------------------- + // V2StatusToRequestDisposition + // --------------------------------------------------------------------------- + + // All 11 status values documented by the V2 spec's `/reports/orders` `status` + // filter (issues/0031) — every one must map explicitly, not fall through the + // "unmapped" default arm, even where the resulting disposition (FAILED) is the + // same as the default's. `unknown-future`/empty/null exercise the true default + // arm below. `expired` is a deliberate GENERATED mapping (see test below), not a + // default-arm case. + [Theory] + [InlineData("issued", (int)EndEntityStatus.GENERATED)] + [InlineData("ISSUED", (int)EndEntityStatus.GENERATED)] // case-insensitive + [InlineData("pending-dcv", (int)EndEntityStatus.EXTERNALVALIDATION)] + [InlineData("pending-csr", (int)EndEntityStatus.EXTERNALVALIDATION)] + [InlineData("pending-agreement", (int)EndEntityStatus.EXTERNALVALIDATION)] + [InlineData("pending-organization-verification", (int)EndEntityStatus.EXTERNALVALIDATION)] + [InlineData("pending-documents", (int)EndEntityStatus.EXTERNALVALIDATION)] + [InlineData("pending-approval", (int)EndEntityStatus.EXTERNALVALIDATION)] + [InlineData("revoked", (int)EndEntityStatus.REVOKED)] + [InlineData("cancelled", (int)EndEntityStatus.FAILED)] + [InlineData("rejected", (int)EndEntityStatus.FAILED)] + // Expired-but-not-revoked certs remain issued inventory — mirrors V1's + // ToRequestDisposition convention and the sync/report path's own "expired" case. + [InlineData("expired", (int)EndEntityStatus.GENERATED)] + // Issue 0039: the spec-documented `unknown` means "may still be live" — pending, not FAILED. + [InlineData("unknown", (int)EndEntityStatus.EXTERNALVALIDATION)] + [InlineData("UNKNOWN", (int)EndEntityStatus.EXTERNALVALIDATION)] // case-insensitive + [InlineData("Unknown", (int)EndEntityStatus.EXTERNALVALIDATION)] + public void V2StatusToRequestDisposition_MapsCorrectly(string v2Status, int expectedDisposition) + { + StatusMapper.V2StatusToRequestDisposition(v2Status).Should().Be(expectedDisposition); + } + + // --------------------------------------------------------------------------- + // Regression (issues/0031): a status string that is NOT one of the 11 spec + // values must still degrade gracefully to FAILED via the default arm, rather + // than throwing or being silently treated as "still pending". This is the + // "truly unrecognized" case, distinct from the deliberate FAILED mappings + // (cancelled/rejected) tested above. + // --------------------------------------------------------------------------- + + [Theory] + [InlineData("unknown-future")] + [InlineData("not-a-real-status")] // issue 0039: garbage still defaults to FAILED, unlike `unknown` + [InlineData("")] + [InlineData(null)] + public void V2StatusToRequestDisposition_UnrecognizedStatus_DefaultsToFailed(string v2Status) + { + StatusMapper.V2StatusToRequestDisposition(v2Status).Should().Be((int)EndEntityStatus.FAILED); + } + + // --------------------------------------------------------------------------- + // ToV2RevocationReason + // --------------------------------------------------------------------------- + + [Theory] + [InlineData(0u, Constants.RevocationReasonV2.Unspecified)] + [InlineData(1u, Constants.RevocationReasonV2.KeyCompromise)] + [InlineData(2u, Constants.RevocationReasonV2.CACompromise)] + [InlineData(3u, Constants.RevocationReasonV2.AffiliationChanged)] + [InlineData(4u, Constants.RevocationReasonV2.Superseded)] + [InlineData(5u, Constants.RevocationReasonV2.CessationOfOperation)] + [InlineData(6u, Constants.RevocationReasonV2.CertificateHold)] + [InlineData(8u, Constants.RevocationReasonV2.Unspecified)] // removeFromCRL: CRL-only, not a valid revoke reason + [InlineData(9u, Constants.RevocationReasonV2.PrivilegeWithdrawn)] + [InlineData(10u, Constants.RevocationReasonV2.AACompromise)] + [InlineData(99u, Constants.RevocationReasonV2.Unspecified)] // unknown → unspecified + public void ToV2RevocationReason_MapsCorrectly(uint crlReason, string expectedV2Reason) + { + StatusMapper.ToV2RevocationReason(crlReason).Should().Be(expectedV2Reason); + } + + // --------------------------------------------------------------------------- + // Round-trip: ToV2RevocationReason never returns null or empty + // --------------------------------------------------------------------------- + + [Theory] + [InlineData(0u), InlineData(1u), InlineData(3u), InlineData(4u), InlineData(5u), InlineData(9u)] + public void ToV2RevocationReason_NeverReturnsNullOrEmpty(uint crlReason) + { + StatusMapper.ToV2RevocationReason(crlReason).Should().NotBeNullOrEmpty(); + } + + // --------------------------------------------------------------------------- + // Regression (issues/0019): every CRL reason code Keyfactor Command can send + // to IAnyCAPlugin.Revoke must map to a value in the V2 spec's kebab-case + // `reason` enum (docs/reference/specs/CERTInext API v2.postman_collection.json, + // "Revoke Certificate"), never to a camelCase string that would get HTTP 400. + // --------------------------------------------------------------------------- + + /// + /// The V2 spec's `reason` enum, hardcoded from the spec text rather than from + /// so this test still catches a + /// future accidental edit to that class drifting away from the spec. + /// + private static readonly HashSet SpecRevocationReasonEnum = new() + { + "unspecified", + "key-compromise", + "ca-compromise", + "affiliation-changed", + "superseded", + "cessation-of-operation", + "certificate-hold", + "privilege-withdrawn", + "aa-compromise", + }; + + // RFC 5280 CRLReason codes that Keyfactor Command can pass through to + // IAnyCAPlugin.Revoke's revocationReason parameter (0-10, minus the two + // codes RFC 5280 never assigns: 7 and, for a *request* reason, 8 + // (removeFromCRL is CRL-only) is still exercised here to prove it degrades + // safely to "unspecified" rather than to an invalid string). + [Theory] + [InlineData(0u)] + [InlineData(1u)] + [InlineData(2u)] + [InlineData(3u)] + [InlineData(4u)] + [InlineData(5u)] + [InlineData(6u)] + [InlineData(8u)] + [InlineData(9u)] + [InlineData(10u)] + public void ToV2RevocationReason_EveryCrlCode_MapsToASpecEnumValue(uint crlReason) + { + string v2Reason = StatusMapper.ToV2RevocationReason(crlReason); + + SpecRevocationReasonEnum.Should().Contain(v2Reason, + $"CRL reason code {crlReason} mapped to '{v2Reason}', which is not one of the V2 spec's " + + "kebab-case reason values — sending it would get HTTP 400 (issues/0019)."); + } + + // --------------------------------------------------------------------------- + // V2RevocationReasonToCrlCode (issues/0034) — the inverse of ToV2RevocationReason, + // used to populate AnyCAPluginCertificate.RevocationReason from a Track Order + // response's nested revocation.reason string. + // --------------------------------------------------------------------------- + + [Theory] + [InlineData(Constants.RevocationReasonV2.Unspecified, 0)] + [InlineData(Constants.RevocationReasonV2.KeyCompromise, 1)] + [InlineData(Constants.RevocationReasonV2.CACompromise, 2)] + [InlineData(Constants.RevocationReasonV2.AffiliationChanged, 3)] + [InlineData(Constants.RevocationReasonV2.Superseded, 4)] + [InlineData(Constants.RevocationReasonV2.CessationOfOperation, 5)] + [InlineData(Constants.RevocationReasonV2.CertificateHold, 6)] + [InlineData(Constants.RevocationReasonV2.PrivilegeWithdrawn, 9)] + [InlineData(Constants.RevocationReasonV2.AACompromise, 10)] + [InlineData("KEY-COMPROMISE", 1)] // case-insensitive + [InlineData("not-a-real-reason", 0)] // unrecognized → unspecified + [InlineData(null, 0)] // absent/null → unspecified + public void V2RevocationReasonToCrlCode_MapsCorrectly(string v2Reason, int expectedCrlCode) + { + StatusMapper.V2RevocationReasonToCrlCode(v2Reason).Should().Be(expectedCrlCode); + } + + // Round-trip: every CRL code ToV2RevocationReason can produce must map back to the + // same code through V2RevocationReasonToCrlCode (the codes ToV2RevocationReason never + // emits — 7, and CRL-only 8 — are out of scope, matching ToV2RevocationReason's own + // documented behavior of mapping "no V2 equivalent" codes to "unspecified"). + [Theory] + [InlineData(0u)] + [InlineData(1u)] + [InlineData(2u)] + [InlineData(3u)] + [InlineData(4u)] + [InlineData(5u)] + [InlineData(6u)] + [InlineData(9u)] + [InlineData(10u)] + public void V2RevocationReasonToCrlCode_RoundTripsWithToV2RevocationReason(uint crlReason) + { + string v2Reason = StatusMapper.ToV2RevocationReason(crlReason); + StatusMapper.V2RevocationReasonToCrlCode(v2Reason).Should().Be((int)crlReason); + } + } +} diff --git a/CERTInext.Tests/TESTING.md b/CERTInext.Tests/TESTING.md index b703d61..51a1827 100644 --- a/CERTInext.Tests/TESTING.md +++ b/CERTInext.Tests/TESTING.md @@ -1,27 +1,41 @@ -# CERTInext CA Plugin — Test Suite Reference +# CERTInext CA Plugin — Unit Test Suite Reference ## Overview -There are two test classes in this project, each targeting a different layer of the plugin: +The `CERTInext.Tests` project contains unit and contract tests for the CERTInext AnyCA Gateway +REST plugin. No external services are required — all HTTP I/O is handled in-process by WireMock.Net +or replaced by Moq strict mocks. -**`CERTInextClientTests`** tests the HTTP client (`CERTInextClient`) in isolation. It uses [WireMock.Net](https://github.com/WireMock-Net/WireMock.Net) to start a real in-process HTTP server on a random port, then directs the client at that server. RestSharp makes actual HTTP calls, so JSON serialization, request routing, header construction, OAuth2 token fetching, and pagination logic are all exercised end-to-end against real network I/O (loopback only). +The project is split into several focused test classes: -**`CERTInextCAPluginTests`** tests the `CERTInextCAPlugin` class — the Keyfactor `IAnyCAPlugin` implementation. It replaces `ICERTInextClient` with a [Moq](https://github.com/moq/moq4) strict mock so no network calls are made. The focus is on plugin-level logic: argument validation, status mapping, enrollment type routing, revocation reason translation, and synchronization behavior. +| Class | Layer under test | Isolation technique | +|---|---|---| +| `CERTInextClientTests` | `CERTInextClient` HTTP transport | WireMock.Net (real loopback HTTP) | +| `CERTInextClientRequestShapeTests` | `CERTInextClient` request body construction | WireMock.Net | +| `CERTInextCAPluginTests` | `CERTInextCAPlugin` IAnyCAPlugin logic | Moq strict mock of `ICERTInextClient` | +| `CERTInextCAPluginCoverageTests` | Additional plugin logic paths | Moq strict mock | +| `CERTInextCAPluginPublicSurfaceTests` | Binary-compat / no-DCV surface contract | Reflection only | +| `BoundedDcvSyncTests` | DCV sync age/cap filter logic | Pure unit (no I/O) | +| `RateLimitRetryTests` | Rate-limit back-off helpers | Pure unit (no I/O) | +| `ExtractSerialFromPemTests` | PEM serial-number extraction | Pure unit (no I/O) | +| `RedactCredentialsTests` | Log credential-redaction helper | Pure unit (no I/O) | -The split keeps concerns separate. If a test fails in `CERTInextClientTests`, the bug is in HTTP transport or serialization. If it fails in `CERTInextCAPluginTests`, the bug is in plugin logic. +If a test fails in `CERTInextClientTests` or `CERTInextClientRequestShapeTests`, the bug is in +HTTP transport or request serialisation. If it fails in `CERTInextCAPluginTests` or +`CERTInextCAPluginCoverageTests`, the bug is in plugin logic. --- ## Running the Tests **Prerequisites:** -- .NET SDK 8.0 or later +- .NET 8 or .NET 10 SDK - NuGet packages restored (`dotnet restore`) -- No external services required — WireMock runs in-process +- No external services required **Run all tests:** ```bash -dotnet test +dotnet test CERTInext.Tests/ ``` **Run a single test class:** @@ -35,259 +49,372 @@ dotnet test --filter "FullyQualifiedName~CERTInextCAPluginTests" dotnet test --filter "DisplayName~OAuth2_TokenIsCached" ``` -Each `CERTInextClientTests` instance starts a fresh `WireMockServer` in its constructor and stops it in `Dispose()`, so tests are isolated and can run in parallel without port conflicts. +Each `CERTInextClientTests` instance starts a fresh `WireMockServer` in its constructor and +stops it in `Dispose()`, so tests are isolated and can run in parallel without port conflicts. + +--- + +## Authentication model + +The real CERTInext API uses HTTP POST for **all** endpoints. There is no Authorization header +for AccessKey mode. Instead, every request body includes a `meta` block containing: + +- `authKey` — `SHA256(accessKey + requestTs + requestTxnId)` (lowercase hex) +- `ts` — ISO 8601 timestamp +- `txn` — unique transaction UUID + +The raw access key is never transmitted — only the derived hash is sent. + +`AuthMode` accepted values: +- `AccessKey` (primary) — HMAC signed body +- `OAuth` (alternative) — bearer token via client credentials flow +- `ApiKey`, `AccessKeyLegacy`, `OAuthLegacy` — legacy aliases accepted for backward compatibility --- ## CERTInextClientTests -The test class implements `IDisposable`. A `WireMockServer` is started on a random available port in the constructor. All tests build a `CERTInextClient` pointed at `_server.Urls[0]`. +The test class implements `IDisposable`. A `WireMockServer` is started on a random available port +in the constructor. All tests build a `CERTInextClient` pointed at `_server.Urls[0]`. Two helper methods build clients: -- `BuildClient(authMode, apiKey)` — builds an ApiKey-authenticated client (default: `authMode="ApiKey"`, `apiKey="test-key"`) -- `BuildOAuthClient(tokenUrl)` — builds an OAuth2 client with `client_id="my-client"` and `client_secret="my-secret"` +- `BuildClient(authMode, apiKey)` — builds an AccessKey-authenticated client + (defaults: `authMode="AccessKey"`, `apiKey="test-key"`, `accountNumber="12345"`) +- `BuildOAuthClient(tokenUrl)` — builds an OAuth client with `client_id="my-client"`, + `client_secret="my-secret"` -### PingAsync +### PingAsync — POST /ValidateCredentials | Test | Stub | Assertion | |------|------|-----------| -| `PingAsync_ReturnsHealthy_WhenServerRespondsOk` | `GET /api/v1/health` → 200, `{"status":"ok","version":"2.1.0"}` | Does not throw; WireMock log contains a request to `/api/v1/health` | -| `PingAsync_Throws_When500Returned` | `GET /api/v1/health` → 500, server error body | Throws an `Exception` with message containing `"health check failed"` | +| `PingAsync_ReturnsHealthy_WhenServerRespondsOk` | `POST /ValidateCredentials` → 200, success meta | Does not throw; WireMock log contains a request to `/ValidateCredentials` | +| `PingAsync_Throws_When500Returned` | `POST /ValidateCredentials` → 500, server error body | Throws `Exception` with message containing `"health check failed"` | +| `PingAsync_Throws_WhenMetaStatusIsFailure` | `POST /ValidateCredentials` → 200, failure meta (`EMS-001`, `"Invalid credentials"`) | Throws `Exception` with message containing `"credential validation failed"` | -### API Key Authentication +### OAuth2 Token Fetch, Caching, and Injection | Test | Stub | Assertion | |------|------|-----------| -| `PingAsync_SendsApiKeyHeader_WhenAuthModeIsApiKey` | `GET /api/v1/health` matched only when header `X-API-Key: super-secret-key` is present → 200 | WireMock records exactly one matched request, confirming the header was sent with the correct value | - -This test verifies the header matching at the WireMock level: if the client sends the wrong header name or value, WireMock finds no matching stub and the request fails. +| `OAuth2_FetchesToken_BeforeFirstApiCall` | `POST /oauth/token` → token JSON; `POST /ValidateCredentials` → 200 | Log contains both `/oauth/token` and `/ValidateCredentials` | +| `OAuth2_TokenIsCached_SecondCallDoesNotRefetch` | Same stubs | `PingAsync` called twice; `/oauth/token` appears exactly once; `/ValidateCredentials` appears twice | +| `OAuth_InjectsBearerToken_InAuthorizationHeader` | Token endpoint → `fake-bearer-token-abc123`; `/ValidateCredentials` → 200 | WireMock log entry for `/ValidateCredentials` carries `Authorization: Bearer fake-bearer-token-abc123` | +| `OAuth_DoesNotInjectBearerToken_InAccessKeyMode` | `/ValidateCredentials` → 200 | WireMock log entry has no `Authorization` header | -### OAuth2 Token Fetch, Caching, and Header Injection +### Retry logic | Test | Stub | Assertion | |------|------|-----------| -| `OAuth2_FetchesToken_BeforeFirstApiCall` | `POST /oauth/token` → 200, token JSON; `POST /ValidateCredentials` → 200 | Log entries contain both `/oauth/token` and `/ValidateCredentials` | -| `OAuth2_TokenIsCached_SecondCallDoesNotRefetch` | Same as above | `PingAsync` called twice; `/oauth/token` appears exactly once in log; `/ValidateCredentials` appears twice | -| `OAuth_InjectsBearerToken_InAuthorizationHeader` | Token endpoint → `fake-bearer-token-abc123`; ValidateCredentials → 200 | WireMock log for `/ValidateCredentials` contains header `Authorization: Bearer fake-bearer-token-abc123` | -| `OAuth_DoesNotInjectBearerToken_InAccessKeyMode` | `/ValidateCredentials` → 200 | WireMock log entry for `/ValidateCredentials` has no `Authorization` header | - -The `OAuth_InjectsBearerToken_InAuthorizationHeader` test is the P1-A regression test. Before the fix, `CERTInextClient` stored the token in a `[ThreadStatic]` field that was never injected into actual requests. The fix replaces this with a `CERTInextOAuthAuthenticator : AuthenticatorBase` subclass that injects the header per-request via RestSharp's authenticator interface. +| `ExecuteWithRetry_MakesThreeAttempts_WhenServerAlwaysReturns500` | `/ValidateCredentials` always → 500 | `PingAsync` throws; WireMock log has exactly 3 requests (3 total attempts, 4xx are not retried) | -### Retry Logic +### EnrollCertificateAsync — POST /GenerateOrderSSL | Test | Stub | Assertion | |------|------|-----------| -| `ExecuteWithRetry_MakesThreeAttempts_WhenServerAlwaysReturns500` | `/ValidateCredentials` always → 500 | `PingAsync` throws; WireMock log contains exactly 3 requests to `/ValidateCredentials` | +| `EnrollCertificateAsync_ReturnsCertificate_WhenServerIssues` | `POST /GenerateOrderSSL` → 200, success meta + `orderDetails.orderNumber="ORD-AAA-111"` | Result not null; `OrderNumber == "ORD-AAA-111"` | +| `EnrollCertificateAsync_ReturnsPending_WhenServerReturnsPendingApproval` | `POST /GenerateOrderSSL` → 200, pending response | Status maps to pending | +| `EnrollCertificateAsync_Throws_WhenGenerateOrderFails` | `POST /GenerateOrderSSL` → 200, failure meta (EMS-918) | Throws `Exception` containing the API error message | +| `EnrollCertificateAsync_Throws_When5xxReturned` | `POST /GenerateOrderSSL` → 500 | Throws `Exception` | +| `EnrollCertificateAsync_Throws_When401Returned` | `POST /GenerateOrderSSL` → 401 | Throws `Exception` | -`ExecuteWithRetryAsync` retries on HTTP 5xx (and network-level failures) for up to `maxAttempts=3` total attempts. 4xx responses are not retried. +### GetCertificateAsync — POST /GetCertificate -### EnrollCertificateAsync +| Test | Stub | Assertion | +|------|------|-----------| +| `GetCertificateAsync_ReturnsCertificate_WhenFound` | `POST /GetCertificate` → 200, PEM in `certificateDetails.endEntityCertificate` | PEM contains `"BEGIN CERTIFICATE"`; serial `"0A1B2C3D4E5F"` | +| `GetCertificateAsync_ThrowsKeyNotFound_WhenOrderNotFound` | `POST /GetCertificate` → 200, failure meta (EMS-not-found) | Throws `KeyNotFoundException` | + +### RevokeCertificateAsync — POST /RevokeOrder | Test | Stub | Assertion | |------|------|-----------| -| `EnrollCertificateAsync_ReturnsCertificate_WhenServerIssues` | `POST /api/v1/certificates` → 200, enroll response with `status="issued"`, cert PEM, `id=CertId1` | Result is not null; `Id == CertId1`; `Status == "issued"`; `Certificate` contains `"BEGIN CERTIFICATE"` | -| `EnrollCertificateAsync_ReturnsPending_WhenServerReturnsPendingApproval` | `POST /api/v1/certificates` → 200, `{"status":"pending_approval","certificate":null,...}` | `Status == "pending_approval"`; `Certificate` is null | -| `EnrollCertificateAsync_Throws_When4xxReturned` | `POST /api/v1/certificates` → 400, `{"error":"BAD_REQUEST","message":"Invalid CSR."}` | Throws `Exception` with message containing `"Invalid CSR"` | -| `EnrollCertificateAsync_Throws_When5xxReturned` | `POST /api/v1/certificates` → 500, server error body | Throws `Exception` (any type) | -| `EnrollCertificateAsync_Throws_When401Returned` | `POST /api/v1/certificates` → 401, unauthorized body | Throws `Exception` (any type) | +| `RevokeCertificateAsync_Succeeds_When200Returned` | `POST /RevokeOrder` → 200, success meta | Does not throw | +| `RevokeCertificateAsync_Throws_WhenServerReturnsFailure` | `POST /RevokeOrder` → 200, failure meta | Throws `Exception` | + +### RenewCertificateAsync — POST /GenerateOrderSSL -### GetCertificateAsync +CERTInext has no dedicated renewal endpoint. `RenewCertificateAsync` submits a new +`GenerateOrderSSL` order. The test verifies that the correct endpoint and body are used. | Test | Stub | Assertion | |------|------|-----------| -| `GetCertificateAsync_ReturnsCertificate_WhenFound` | `GET /api/v1/certificates/{CertId1}` → 200, full certificate JSON | Result is not null; `Id == CertId1`; `Status == "issued"`; `Certificate` contains `"BEGIN CERTIFICATE"` | -| `GetCertificateAsync_ThrowsKeyNotFound_When404Returned` | `GET /api/v1/certificates/nonexistent-id` → 404, not-found error body | Throws `KeyNotFoundException` | +| `RenewCertificateAsync_ReturnsNewCertificate_OnSuccess` | `POST /GenerateOrderSSL` → 200, success with new order number | New order number returned | + +### ListCertificatesAsync — POST /GetOrderReport (paginated) -### RevokeCertificateAsync +`ListCertificatesAsync` is an `IAsyncEnumerable` that paginates +`GetOrderReport`. Pagination stops when the returned page is empty or all pages are fetched. | Test | Stub | Assertion | |------|------|-----------| -| `RevokeCertificateAsync_Succeeds_When200Returned` | `POST /api/v1/certificates/{CertId1}/revoke` → 200, `{"success":true,...}` | Does not throw | -| `RevokeCertificateAsync_Throws_When4xxReturned` | `POST /api/v1/certificates/{CertId1}/revoke` → 409, `{"error":"ALREADY_REVOKED",...}` | Throws `Exception` with message containing `"revoke certificate"` | +| `ListCertificatesAsync_ReturnsSinglePage_WhenOnlyOnePage` | `POST /GetOrderReport` → single-page with `ORD-AAA-111` | Enumeration yields exactly 1 item | +| `ListCertificatesAsync_IteratesMultiplePages` | Two pages: page 1 (`ORD-AAA-111`), page 2 (`ORD-BBB-222`) | Enumeration yields 2 items; both order numbers present | +| `ListCertificatesAsync_StopsWhenEmptyPageReturned` | `POST /GetOrderReport` → empty `ordersArray` | Enumeration yields 0 items | +| `ListCertificatesAsync_RespectsIssuedAfterFilter` | Any request with `issuedAfter` parameter → single-page | Enumeration yields 1 item; `issuedAfter` key present in the request log | -### RenewCertificateAsync +### GetProfilesAsync — POST /GetProductDetails | Test | Stub | Assertion | |------|------|-----------| -| `RenewCertificateAsync_ReturnsNewCertificate_OnSuccess` | `POST /api/v1/certificates/{CertId1}/renew` → 200, renew response with `id="cert-renewed-001"` | `Id == "cert-renewed-001"`; `Status == "issued"`; `Certificate` contains `"BEGIN CERTIFICATE"` | +| `GetProfilesAsync_ReturnsProfiles_WhenServerResponds` | `POST /GetProductDetails` → two products in nested category envelope | Result has 2 items; `ProfileIdTls` and `ProfileIdClient` present; all `Active == true` | +| `GetProfilesAsync_ReturnsEmptyList_WhenNoProductsReturned` | `POST /GetProductDetails` → empty `productDetails` array | Result is empty | -### ListCertificatesAsync +### GetProductDetailsV2Async — GET /api/certinext/v2/catalog/products (issue 0025 / 0016, `CERTInextClientV2Tests`) -`ListCertificatesAsync` is an `IAsyncEnumerable` that pages through results using a `page` query parameter, stopping when the returned page is empty or the last page has been fetched. +The live sandbox account returns the SAME nested category envelope as V1's `GetProductDetails` +(confirmed 2026-09-24), just under a top-level `"products"` key. `ParseProductDetailsV2Response` +flattens each shape into `ProductDetail`; the flat `productId`/bare-array shapes are kept as +fallback branches for other accounts/API versions. | Test | Stub | Assertion | |------|------|-----------| -| `ListCertificatesAsync_ReturnsSinglePage_WhenOnlyOnePage` | `GET /api/v1/certificates?page=1` → 200, single-page list with one cert (`CertId1`) | Enumeration yields exactly 1 item with `Id == CertId1` | -| `ListCertificatesAsync_IteratesMultiplePages` | `GET /api/v1/certificates?page=1` → page 1 of 2 (`CertId1`); `GET /api/v1/certificates?page=2` → page 2 of 2 (`CertId2`) | Enumeration yields 2 items; both `CertId1` and `CertId2` are present | -| `ListCertificatesAsync_StopsWhenEmptyPageReturned` | `GET /api/v1/certificates?page=1` → 200, `{"data":[],"pagination":{"total":0,...}}` | Enumeration yields 0 items | -| `ListCertificatesAsync_RespectsIssuedAfterFilter` | Any `GET /api/v1/certificates` request that includes an `issuedAfter` query parameter → 200, single-page list | Enumeration yields 1 item; WireMock log entry for the first request has an `issuedAfter` key in its query string | +| `GetProductDetailsV2Async_NestedCategoryEnvelope_FlattensProducts` | `GET catalog/products` → nested category envelope | 2 products; `ProductCode`/`ProductName`/`ProductType` populated, `Active == true` | +| `GetProductDetailsV2Async_FlatProductIdRows_MapsToProductCode` | `GET catalog/products` → flat `productId` rows | `productId` mapped to `ProductCode` | +| `GetProductDetailsV2Async_BareArray_Parses` | `GET catalog/products` → bare JSON array | Parses without a wrapper object | +| `GetProductDetailsV2Async_EmptyCatalog_ReturnsEmptyList` | `GET catalog/products` → `{"products":[]}` | Returns an empty list (no throw) | + +### ValidateProductInfo — `CERTInextCAPluginTests` (V1) / `CERTInextCAPluginV2Tests` (V2), issue 0025 + +`ValidateProductInfo` builds its own `CERTInextClient` from `connectionInfo` (ignoring the +Moq-injected client), so these tests use a real WireMock server as `ApiUrl`. -### GetProfilesAsync +| Test | Mode | Stub | Assertion | +|------|------|------|-----------| +| `ValidateProductInfo_V1_Succeeds_WhenProductCodePresent` | V1 | `POST /GetProductDetails` → nested envelope containing the code | Does not throw | +| `ValidateProductInfo_V1_Throws_WhenProductCodeAbsent` | V1 | Same stub, unknown code | Throws `AnyCAValidationException` `*not found*` | +| `ValidateProductInfo_V2_Succeeds_WhenProductCodeInCatalog` | V2 | `GET catalog/products` → nested envelope containing the code | Does not throw; no request ever hits `/GetProductDetails` | +| `ValidateProductInfo_V2_Throws_WhenProductCodeNotInCatalog` | V2 | Same stub, unknown code | Throws `AnyCAValidationException` `*not found*` | +| `ValidateProductInfo_V2_Throws_WhenCatalogEmpty` | V2 | `GET catalog/products` → `{"products":[]}` | Throws `*not found*` — no soft-accept, matches V1 | +| `ValidateProductInfo_V2_Throws_WhenCatalogReturnsError` | V2 | `GET catalog/products` → HTTP 500 | Throws `*Unable to validate*`; message excludes the response body | + +### DCV endpoints | Test | Stub | Assertion | |------|------|-----------| -| `GetProfilesAsync_ReturnsProfiles_WhenServerResponds` | `GET /api/v1/profiles` → 200, two-profile JSON (`ProfileIdTls`, `ProfileIdClient`, both active) | Result has 2 items; both profile IDs present; all have `Active == true` | -| `GetProfilesAsync_ReturnsEmptyList_WhenDataIsEmpty` | `GET /api/v1/profiles` → 200, `{"data":[]}` | Result is empty | +| `GetDcvAsync_ReturnsToken_WhenServerRespondsOk` | `POST /GetDcv` → 200, `dcvDetails.token="abc123token"` | Returns token string | +| `GetDcvAsync_Throws_WhenMetaStatusIsFailure` | `POST /GetDcv` → 200, failure meta | Throws `Exception` | +| `GetDcvAsync_Throws_WhenServerReturns401` | `POST /GetDcv` → 401 | Throws `Exception` | +| `VerifyDcvAsync_Succeeds_WhenServerRespondsOk` | `POST /VerifyDcv` → 200, success meta | Does not throw | +| `VerifyDcvAsync_Throws_WhenMetaStatusIsFailure` | `POST /VerifyDcv` → 200, failure meta | Throws `Exception` | +| `VerifyDcvAsync_Throws_WhenServerReturns401` | `POST /VerifyDcv` → 401 | Throws `Exception` | +| `VerifyDcvAsync_Throws_WhenServerReturns500` | `POST /VerifyDcv` → 500 | Throws `Exception` | + +--- + +## CERTInextClientRequestShapeTests + +Uses WireMock to verify that the `GenerateOrderSSL` request body includes or omits optional +blocks depending on connector configuration. + +| Test | Assertion | +|------|-----------| +| `OrganizationNumber_Set_EmitsPreVettedOrganizationDetails` | Body includes `organizationDetails.preVetting="1"` and the configured `organizationNumber` | +| `OrganizationNumber_Blank_OmitsOrganizationDetailsBlock` | Body omits `organizationDetails` entirely | +| `GroupNumber_Set_EmitsDelegationInformation` | Body includes `delegationInformation.groupNumber` | +| `GroupNumber_Blank_OmitsDelegationInformation` | Body omits `delegationInformation` | +| `TechnicalContact_AllSet_EmitsExplicitValues` | Body includes `technicalPointOfContact` with the configured values | +| `TechnicalContact_AllBlank_FallsBackToRequestorDefaults` | Body includes `technicalPointOfContact` fields derived from `RequestorName`/`RequestorEmail` | +| `SslBodyDefaults_AreEmitted_FromCustomConnectorValues` | Custom connector-level defaults appear in the order body | +| `SslBodyDefaults_AreSafeFallbacks_WhenConfigUntouched` | Default values are emitted without throwing when optional config fields are omitted | +| `ValidityDays_OnRequest_OverridesConnectorDefault` | `ValidityDays` template parameter overrides the connector `SubscriptionValidityYears` | --- ## CERTInextCAPluginTests -The plugin is constructed by passing an `ICERTInextClient` mock directly: `new CERTInextCAPlugin(client)`. Moq is configured with `MockBehavior.Strict`, so any call to a method that has no setup will throw, making unexpected client calls immediately visible. +The plugin is constructed with `new CERTInextCAPlugin(client)` where `client` is a Moq strict +mock of `ICERTInextClient`. Any call to an unset-up method throws immediately, making unexpected +client calls visible. -Two local helpers are used across tests: -- `MakeProductInfo(profileId, extras)` — builds an `EnrollmentProductInfo` with `ProductID` and a `ProductParameters` dictionary containing `"ProfileId"` -- `AsyncEnum(items)` — wraps a `List` as an `IAsyncEnumerable` for use in `ListCertificatesAsync` mock setups +Two helpers are used across tests: +- `MakeProductInfo(profileId, extras)` — builds an `EnrollmentProductInfo` with `ProfileId` in + `ProductParameters` +- `AsyncEnum(items)` — wraps a list as `IAsyncEnumerable` ### Ping | Test | Mock setup | Assertion | |------|-----------|-----------| | `Ping_Succeeds_WhenClientPingAsyncDoesNotThrow` | `PingAsync` returns `Task.CompletedTask` | Does not throw; `PingAsync` called exactly once | -| `Ping_Rethrows_WhenClientPingThrows` | `PingAsync` throws `Exception("Connection refused")` | Throws `Exception` with message matching `"*CERTInext*Connection refused*"` — verifies the plugin wraps the error with context | -| `Ping_SkipsConnectivityTest_WhenConnectorIsDisabled` | Strict mock with no setups; `CERTInextConfig.Enabled = false` | Does not throw; no client method is called (verified via `VerifyNoOtherCalls()`) | +| `Ping_Rethrows_WhenClientPingThrows` | `PingAsync` throws `Exception("Connection refused")` | Throws `Exception` with message matching `"*CERTInext*Connection refused*"` | +| `Ping_SkipsConnectivityTest_WhenConnectorIsDisabled` | Strict mock, no setups; `CERTInextConfig.Enabled = false` | Does not throw; no client method called (verified via `VerifyNoOtherCalls()`) | ### GetProductIds | Test | Mock setup | Assertion | |------|-----------|-----------| -| `GetProductIds_ReturnsStaticProductList` | No mock calls expected (strict mock verifies this) | Returns 10 items; contains `DV SSL`, `OV SSL`, `EV SSL`; no client method is called | - -`GetProductIds()` returns a hardcoded static list rather than making a live API call. This is intentional: `IAnyCAPlugin.GetProductIds()` is synchronous (calling `GetAwaiter().GetResult()` risks deadlock), and the Keyfactor integration-manifest tooling requires a known list at reflection time. The `VerifyNoOtherCalls()` assertion on the strict mock confirms no API call is made. - -### ValidateCAConnectionInfo - -The plugin validates the connection info dictionary before any API calls are made. - -| Test | Input | Assertion | -|------|-------|-----------| -| `ValidateCAConnectionInfo_Throws_WhenApiUrlMissing` | `AuthMode="ApiKey"`, `ApiKey` set, no `ApiUrl` | Throws `AnyCAValidationException` with message matching `"*ApiUrl*required*"` | -| `ValidateCAConnectionInfo_Throws_WhenApiUrlIsNotUri` | `ApiUrl="not-a-url"` | Throws `AnyCAValidationException` with message matching `"*valid absolute URI*"` | -| `ValidateCAConnectionInfo_Throws_WhenApiKeyMissingForApiKeyMode` | `ApiUrl` set, `AuthMode="ApiKey"`, no `ApiKey` | Throws `AnyCAValidationException` with message matching `"*ApiKey*required*"` | -| `ValidateCAConnectionInfo_Throws_WhenBasicCredentialsMissing` | `ApiUrl` set, `AuthMode="Basic"`, no `Username` or `Password` | Throws `AnyCAValidationException` with message matching `"*Username*required*"` | -| `ValidateCAConnectionInfo_Throws_WhenOAuth2FieldsMissing` | `ApiUrl` set, `AuthMode="OAuth2"`, no token URL, client ID, or secret | Throws `AnyCAValidationException` with message matching `"*OAuth2TokenUrl*required*"` | -| `ValidateCAConnectionInfo_Throws_WhenAuthModeIsInvalid` | `ApiUrl` set, `AuthMode="CertificateBased"` | Throws `AnyCAValidationException` with message matching `"*AuthMode*must be one of*"` | -| `ValidateCAConnectionInfo_SkipsValidation_WhenDisabled` | `Enabled=false`, nothing else set | Does not throw; no calls made to the mock client | - -### ValidateProductInfo +| `GetProductIds_ReturnsStaticProductList` | No mock calls expected | Returns 10 items including `DV SSL`, `OV SSL`, `EV SSL`; no client method called | -| Test | Input | Assertion | -|------|-------|-----------| -| `ValidateProductInfo_Throws_WhenProfileIdMissing` | `ProductID = string.Empty`, valid connection info | Throws `AnyCAValidationException` with message matching `"*ProfileId*required*"` | +`GetProductIds()` returns a hardcoded static list — no API call is made. The strict mock's +`VerifyNoOtherCalls()` confirms this. ### Enroll -The `Enroll` method accepts an `EnrollmentType` parameter. `New` and `Reissue` both route to `EnrollCertificateAsync`. `RenewOrReissue` routes to `RenewCertificateAsync` when `PriorCertSN` is present in `ProductParameters`, and falls back to `EnrollCertificateAsync` when it is not. +The `Enroll` method selects a path based on `EnrollmentType`. Both `New` and `Reissue` submit a +new `GenerateOrderSSL` order. `RenewOrReissue` also submits `GenerateOrderSSL` (CERTInext has +no dedicated renewal endpoint) but applies the renewal-window check to determine how Command +tracks the old→new certificate relationship. | Test | EnrollmentType | Mock setup | Assertion | |------|---------------|-----------|-----------| -| `Enroll_New_CallsEnrollAsync_AndReturnsIssuedResult` | `New` | `EnrollCertificateAsync` (matching `ProfileId == ProfileIdTls`) returns `IssuedEnrollResponse()` | `CARequestID == CertId1`; `Status == EndEntityStatus.GENERATED`; `Certificate` contains `"BEGIN CERTIFICATE"`; client called once | -| `Enroll_New_ReturnsPendingStatus_WhenCaReturnsPendingApproval` | `New` | `EnrollCertificateAsync` returns `PendingEnrollResponse()` | `Status == EndEntityStatus.EXTERNALVALIDATION` | -| `Enroll_New_Throws_WhenProfileIdNotSet` | `New` | No setup (strict mock — any unexpected call throws) | Throws `Exception` with message matching `"*ProfileId*required*"` before calling the client | -| `Enroll_Reissue_AlsoCallsEnrollAsync` | `Reissue` | `EnrollCertificateAsync` returns `IssuedEnrollResponse()` | `Status == EndEntityStatus.GENERATED`; `EnrollCertificateAsync` called once | -| `Enroll_Renew_FallsBackToNewEnroll_WhenNoPriorCertSn` | `RenewOrReissue` | `EnrollCertificateAsync` returns `IssuedEnrollResponse()` | `CARequestID == CertId1`; `EnrollCertificateAsync` called once; `RenewCertificateAsync` never called | +| `Enroll_New_CallsEnrollAsync_AndReturnsIssuedResult` | `New` | `PlaceOrderAsync` returns `ORD-AAA-111` | `CARequestID == "ORD-AAA-111"`; `Status == GENERATED` | +| `Enroll_New_ReturnsPendingStatus_WhenCaReturnsPendingApproval` | `New` | `PlaceOrderAsync` → pending status | `Status == EXTERNALVALIDATION` | +| `Enroll_New_Throws_WhenProfileIdNotSet` | `New` | Strict mock — no setups | Throws before calling the client | +| `Enroll_Reissue_AlsoCallsEnrollAsync` | `Reissue` | `PlaceOrderAsync` returns issued | `Status == GENERATED`; called once | +| `Enroll_Renew_FallsBackToNewEnroll_WhenNoPriorCertSn` | `RenewOrReissue` | `PlaceOrderAsync` returns issued | `CARequestID == "ORD-AAA-111"`; no dedicated renew call | ### GetSingleRecord | Test | Mock setup | Assertion | |------|-----------|-----------| -| `GetSingleRecord_ReturnsMappedCertificate_ForIssuedCert` | `GetCertificateAsync(CertId1)` returns `IssuedCertRecord()` | `CARequestID == CertId1`; `Status == EndEntityStatus.GENERATED`; `Certificate` contains `"BEGIN CERTIFICATE"`; `ProductID == ProfileIdTls` | -| `GetSingleRecord_ReturnsMappedCertificate_ForRevokedCert` | `GetCertificateAsync(CertId3)` returns `RevokedCertRecord()` | `Status == EndEntityStatus.REVOKED`; `RevocationDate` is not null; `RevocationReason == 1` (keyCompromise) | -| `GetSingleRecord_Rethrows_WhenCertNotFound` | `GetCertificateAsync("no-such-id")` throws `KeyNotFoundException` | Rethrows `KeyNotFoundException` | +| `GetSingleRecord_ReturnsMappedCertificate_ForIssuedCert` | `TrackOrderAsync("ORD-AAA-111")` returns issued track response; `GetCertificateAsync` returns PEM | `Status == GENERATED`; PEM present; `ProductID == ProfileIdTls` | +| `GetSingleRecord_ReturnsMappedCertificate_ForRevokedCert` | `TrackOrderAsync("ORD-CCC-333")` returns revoked response | `Status == REVOKED`; `RevocationDate` non-null; `RevocationReason == 1` | +| `GetSingleRecord_Rethrows_WhenCertNotFound` | Client throws `KeyNotFoundException` | Rethrows `KeyNotFoundException` | ### Revoke -The plugin looks up the certificate first to check whether it is already revoked, then calls `RevokeCertificateAsync` only if it is not. CRL reason codes (integers) are mapped to string values expected by the CERTInext API. +The plugin checks the current certificate status before calling `RevokeOrder`. CRL reason codes +(integers) are mapped to CERTInext string values. | Test | Mock setup | Assertion | |------|-----------|-----------| -| `Revoke_CallsRevokeCertificateAsync_AndReturnsRevokedStatus` | `GetCertificateAsync(CertId1)` returns issued cert; `RevokeCertificateAsync(CertId1, ...)` returns `Task.CompletedTask` | Returns `EndEntityStatus.REVOKED`; `RevokeCertificateAsync` called once with `Reason == "keyCompromise"` (CRL code 1) | -| `Revoke_ReturnsAlreadyRevoked_WhenCertAlreadyRevoked` | `GetCertificateAsync(CertId3)` returns revoked cert | Returns `EndEntityStatus.REVOKED`; `RevokeCertificateAsync` never called | -| `Revoke_MapsAllCrlReasonCodes` | For each reason code 0–5: `GetCertificateAsync` returns issued cert; `RevokeCertificateAsync` matched only when `Reason` equals the expected string | Verifies the complete mapping: `0→"unspecified"`, `1→"keyCompromise"`, `2→"caCompromise"`, `3→"affiliationChanged"`, `4→"superseded"`, `5→"cessationOfOperation"` | +| `Revoke_CallsRevokeCertificateAsync_AndReturnsRevokedStatus` | `TrackOrderAsync` returns issued cert; `RevokeOrderAsync` returns `Task.CompletedTask` | Returns `REVOKED`; `RevokeOrderAsync` called once with correct reason string | +| `Revoke_ReturnsAlreadyRevoked_WhenCertAlreadyRevoked` | `TrackOrderAsync` returns revoked cert | Returns `REVOKED`; `RevokeOrderAsync` never called | +| `Revoke_MapsAllCrlReasonCodes` | Per reason code 0–5 and beyond | Verifies mapping: `0→"unspecified"`, `1→"keyCompromise"`, `2→"caCompromise"`, `3→"affiliationChanged"`, `4→"superseded"`, `5→"cessationOfOperation"`, extended codes also covered by `CERTInextCAPluginCoverageTests` | ### Synchronize -`Synchronize` iterates `ListCertificatesAsync` and adds mapped `AnyCAPluginCertificate` objects to a `BlockingCollection`. A full sync passes `null` as `issuedAfter`; a delta sync passes the `lastSync` timestamp. Certificates with a status that cannot be mapped (e.g., `"failed"`) are skipped. +`Synchronize` iterates `ListOrdersAsync` and posts mapped `AnyCAPluginCertificate` objects to a +`BlockingCollection`. Full sync passes `null` as `issuedAfter`; delta sync passes `lastSync`. | Test | Mock setup | Assertion | |------|-----------|-----------| -| `Synchronize_FullSync_AddsAllCertsToBuffer` | `ListCertificatesAsync(null, ...)` returns two issued certs (`CertId1`, `CertId2`) | Buffer contains 2 items; both IDs present | -| `Synchronize_DeltaSync_PassesLastSyncFilter` | `ListCertificatesAsync` captures the `issuedAfter` argument and returns one cert | Captured `issuedAfter` equals the `lastSync` value passed to `Synchronize` | -| `Synchronize_FullSync_PassesNullIssuedAfter` | `ListCertificatesAsync` captures `issuedAfter` and returns empty | Even when `lastSync` is non-null, `fullSync: true` causes `issuedAfter` to be passed as `null` | -| `Synchronize_SkipsFailedCertificates` | `ListCertificatesAsync` returns one issued cert and one cert with `status="failed"` and `Certificate=null` | Buffer contains exactly 1 item (`CertId1`); the failed cert is dropped | -| `Synchronize_HonoursCancellation` | Custom async enumerable that yields one cert, cancels the `CancellationTokenSource`, then calls `ct.ThrowIfCancellationRequested()` before yielding a second | Throws `OperationCanceledException` | -| `Synchronize_MapsRevokedCertificates_Correctly` | `ListCertificatesAsync` returns one revoked cert (`CertId3`) | Buffer contains 1 item; `Status == EndEntityStatus.REVOKED`; `RevocationDate` is not null | -| `Synchronize_CallsCompleteAdding_OnNormalExit` | `ListCertificatesAsync` returns empty | `buffer.IsAddingCompleted == true` after `Synchronize` returns normally | -| `Synchronize_CallsCompleteAdding_OnCancellation` | Custom async enumerable that cancels mid-iteration | `buffer.IsAddingCompleted == true` even after `OperationCanceledException` is thrown | +| `Synchronize_FullSync_AddsAllCertsToBuffer` | `ListOrdersAsync(null, ...)` returns two issued orders | Buffer contains 2 items; both order numbers present | +| `Synchronize_DeltaSync_PassesLastSyncFilter` | `ListOrdersAsync` captures `issuedAfter` | Captured value equals `lastSync` | +| `Synchronize_FullSync_PassesNullIssuedAfter` | `ListOrdersAsync` captures `issuedAfter` | Even when `lastSync` is non-null, `fullSync:true` forces `issuedAfter=null` | +| `Synchronize_SkipsFailedCertificates` | Returns one issued + one with unknown/failed status | Buffer contains exactly 1 item | +| `Synchronize_HonoursCancellation` | Async enumerable that cancels mid-iteration | Throws `OperationCanceledException` | +| `Synchronize_MapsRevokedCertificates_Correctly` | Returns one revoked record | Buffer item `Status == REVOKED`; `RevocationDate` non-null | +| `Synchronize_CallsCompleteAdding_OnNormalExit` | Returns empty | `buffer.IsAddingCompleted == true` | +| `Synchronize_CallsCompleteAdding_OnCancellation` | Cancels mid-iteration | `buffer.IsAddingCompleted == true` even after `OperationCanceledException` | + +**Note on `CompleteAdding`:** `Synchronize` calls `blockingBuffer.CompleteAdding()` in a `finally` +block. Tests must not call `buffer.CompleteAdding()` themselves — doing so after the plugin has +already called it throws `InvalidOperationException`. -**Note on `CompleteAdding`:** `Synchronize` calls `blockingBuffer.CompleteAdding()` in a `finally` block. Tests must NOT call `buffer.CompleteAdding()` themselves — doing so after the plugin has already called it throws `InvalidOperationException`. +--- -### RenewalWindowDays — P2-C semantic +## CERTInextCAPluginPublicSurfaceTests -`RenewalWindowDays` controls whether a `RenewOrReissue` enrollment uses the CERTInext renew API or falls back to a fresh order. The semantics are "Option A — window before expiry": +Reflection-based contract tests that verify the no-DCV build does not expose any public types, +fields, methods, or constructors that reference `IDomainValidatorFactory` or other IAnyCAPlugin +3.3-only types. These tests ensure the default build loads cleanly on AnyCA Gateway 25.5.x hosts. -``` -useRenewalApi = expiry > DateTime.UtcNow && expiry <= DateTime.UtcNow.AddDays(RenewalWindowDays) -``` +| Test | What it checks | +|------|---------------| +| `NoPublicConstructor_ReferencesV3Point3OnlyTypes` | No public constructor has a parameter typed as a 3.3-only interface | +| `NoInstanceField_DeclaredTypeReferencesV3Point3OnlyTypes` | No public or private instance field is typed as a 3.3-only type | +| `NoNestedType_ImplementsV3Point3OnlyInterface` | No nested type implements a 3.3-only interface | +| `NoPublicMethod_SignatureReferencesV3Point3OnlyTypes` | No public method has a parameter or return type referencing 3.3-only types | +| `ParameterlessConstructor_IsPublic` | The plugin has a public parameterless constructor (required by the gateway host for reflection-based instantiation) | +| `SetDomainValidatorFactory_AcceptsObject_NotIDomainValidatorFactory` | The DCV injection method accepts `object`, not the 3.3-only `IDomainValidatorFactory`, so the method signature loads on 3.2 hosts | +| `SetDomainValidatorFactory_NullArgument_LeavesDcvDisabled` | Passing `null` does not enable DCV | +| `SetDomainValidatorFactory_NonFactoryArgument_IsIgnored` | Passing a non-factory object does not enable DCV | + +--- -| Test | Expiry | Window | Expected path | -|------|--------|--------|---------------| -| `RenewOrReissue_UsesRenewApi_WhenCertExpiresWithinWindow` | now + 30 days | 90 days | Renewal API | -| `RenewOrReissue_UsesNewEnroll_WhenCertExpiresOutsideWindow` | now + 120 days | 90 days | New enroll (too early) | -| `RenewOrReissue_UsesNewEnroll_WhenCertAlreadyExpired` | now − 5 days | 90 days | New enroll (graceful degradation) | +## BoundedDcvSyncTests + +Pure unit tests for the age-window and per-pass cap logic in `TryRunDcvDuringSyncAsync`. No +network I/O. Verifies that: +- Orders within the configured age window are attempted +- Orders older than the window are skipped (to avoid retrying abandoned orders indefinitely) +- Orders at the exact age boundary are attempted +- Orders with unknown dates are attempted (not starved) +- Age window of 0 disables the filter +- The per-pass cap skips orders once the cap is reached +- Cap of 0 disables the cap +- Age skip takes precedence over the cap check + +--- + +## RateLimitRetryTests + +Pure unit tests for the `IsRateLimitSurface` and `ComputeRateLimitBackoffSeconds` helpers: +- `IsRateLimitSurface` recognises the documented CERTInext rate-limit error phrase and rejects + unrelated strings +- `ComputeRateLimitBackoffSeconds` produces a result within the expected jittered range for each + attempt number +- Attempt values below 1 are clamped to 1 --- ## MockCertificateData -`MockCertificateData` is a static internal class shared by both test suites. It provides two types of output: +`MockCertificateData` is a static internal class shared across test suites. It provides realistic +fake CERTInext API response objects and JSON payloads. -- **Object helpers** — return typed API response objects for use in Moq setups -- **JSON helpers** — return raw JSON strings for use in WireMock stubs +The real CERTInext API uses HTTP POST for all endpoints and wraps every response in a `meta` +block with `status: "1"` (success) or `status: "0"` (failure). ### Constants | Constant | Value | Used for | |----------|-------|---------| | `FakePemCertificate` | PEM block starting with `-----BEGIN CERTIFICATE-----` | Certificate body in all responses | -| `FakeCsrPem` | PEM block starting with `-----BEGIN CERTIFICATE REQUEST-----` | CSR body in enroll and renew requests | -| `CertId1` | `"cert-aaa-111"` | Default issued certificate ID | -| `CertId2` | `"cert-bbb-222"` | Second certificate ID (pagination, delta sync) | -| `CertId3` | `"cert-ccc-333"` | Default revoked certificate ID | -| `ProfileIdTls` | `"tls-server"` | TLS server profile | -| `ProfileIdClient` | `"client-auth"` | Client authentication profile | - -### Object helpers (Moq) +| `FakeCsrPem` | PEM block starting with `-----BEGIN CERTIFICATE REQUEST-----` | CSR body in enroll requests | +| `OrderNumber1` | `"ORD-AAA-111"` | Primary order number (also aliased as `CertId1`) | +| `OrderNumber2` | `"ORD-BBB-222"` | Second order number (also aliased as `CertId2`) | +| `OrderNumber3` | `"ORD-CCC-333"` | Revoked order number (also aliased as `CertId3`) | +| `ProfileIdTls` | `"tls-server"` | TLS server product code placeholder | +| `ProfileIdClient` | `"client-auth"` | Client auth product code placeholder | + +`CertId1/2/3` are backward-compatibility aliases for `OrderNumber1/2/3`. + +### JSON helpers (WireMock stubs) + +| Method | Endpoint | Notes | +|--------|----------|-------| +| `ValidateCredentialsSuccessJson()` | `POST /ValidateCredentials` | Success meta only | +| `ValidateCredentialsFailureJson(code, msg)` | `POST /ValidateCredentials` | Failure meta | +| `GenerateOrderSuccessJson(orderNumber)` | `POST /GenerateOrderSSL` | Includes `orderDetails.orderNumber` | +| `TrackOrderIssuedJson(orderNumber)` | `POST /TrackOrder` | `certificateStatusId="9"` (GENERATED) | +| `TrackOrderPendingJson(orderNumber)` | `POST /TrackOrder` | `certificateStatusId="1"` (SetupPending) | +| `TrackOrderRevokedJson(orderNumber)` | `POST /TrackOrder` | `certificateStatusId="22"`, revocation details present | +| `GetCertificateSuccessJson()` | `POST /GetCertificate` | PEM in `certificateDetails.endEntityCertificate`; serial `"0A1B2C3D4E5F"` | +| `RevokeSuccessJson()` | `POST /RevokeOrder` | Success meta only | +| `OrderReportSinglePageJson()` | `POST /GetOrderReport` | One entry, `ORD-AAA-111` | +| `OrderReportPageJson(orderNumbers, total, pages, current)` | `POST /GetOrderReport` | Multi-entry paginated response | +| `OrderReportEmptyJson()` | `POST /GetOrderReport` | Empty `ordersArray`, `noOfPages=0` | +| `GetProductDetailsJson()` | `POST /GetProductDetails` | Nested category envelope with two products | +| `GetProductDetailsEmptyJson()` | `POST /GetProductDetails` | Empty `productDetails` array | +| `GetCatalogProductsV2NestedJson()` | `GET catalog/products` | Nested category envelope (live sandbox shape, confirmed 2026-09-24) | +| `GetCatalogProductsV2FlatJson()` | `GET catalog/products` | Flat `productId` rows (Postman spec example shape, fallback branch) | +| `GetCatalogProductsV2BareArrayJson()` | `GET catalog/products` | Bare JSON array, no wrapper object | +| `GetCatalogProductsV2EmptyJson()` | `GET catalog/products` | `{"products":[]}` | +| `ApiFailureJson(code, msg)` | Any endpoint | Generic `meta.status="0"` failure | +| `GetDcvSuccessJson(token)` | `POST /GetDcv` | `dcvDetails.token` | +| `GetDcvFailureJson(code, msg)` | `POST /GetDcv` | Failure meta | +| `VerifyDcvSuccessJson()` | `POST /VerifyDcv` | Success meta only | +| `VerifyDcvFailureJson(code, msg)` | `POST /VerifyDcv` | Failure meta | +| `OAuth2TokenJson(expiresIn)` | OAuth token endpoint | `access_token="fake-bearer-token-abc123"` | +| `ServerErrorJson()` | Any | Generic 500 error body (not meta-wrapped) | +| `UnauthorizedJson()` | Any | Generic 401 error body (not meta-wrapped) | + +### Object helpers (Moq setups) | Method | Returns | |--------|---------| | `ActiveProfiles()` | Two `ProfileInfo` objects, both `Active=true`: `ProfileIdTls` and `ProfileIdClient` | | `MixedProfiles()` | Three `ProfileInfo` objects: `ProfileIdTls` (active), `"legacy-profile"` (inactive), `ProfileIdClient` (active) | -| `IssuedEnrollResponse(id)` | `EnrollCertificateResponse` with `Status="issued"`, `FakePemCertificate`, `SerialNumber="0A1B2C3D4E5F"` | +| `IssuedEnrollResponse(id)` | `EnrollCertificateResponse` with `Status="issued"`, PEM, `SerialNumber="0A1B2C3D4E5F"` | | `PendingEnrollResponse(id)` | `EnrollCertificateResponse` with `Status="pending_approval"`, `Certificate=null` | -| `IssuedCertRecord(id)` | `GetCertificateResponse` with `Status="issued"`, `FakePemCertificate`, `ProfileId=ProfileIdTls`, issued 2024-06-01, expires 2025-06-01 | -| `RevokedCertRecord(id)` | `GetCertificateResponse` with `Status="revoked"`, `RevokedAt=2024-03-15`, `RevocationReason="keyCompromise"` | - -### JSON helpers (WireMock) - -| Method | Returns | -|--------|---------| -| `EnrollResponseJson(id, status)` | Enroll response JSON with `status="issued"` and `FakePemCertificate` escaped for JSON | -| `PendingEnrollResponseJson(id)` | Enroll response JSON with `status="pending_approval"` and `certificate:null` | -| `GetCertificateJson(id, status)` | Single certificate JSON including SANs, subject, CSR, and revocation fields | -| `RevokedCertificateJson(id)` | Certificate JSON with `status="revoked"` and revocation fields populated | -| `SinglePageListJson(id)` | Paginated list JSON: one cert on page 1 of 1 | -| `TwoPageListJson(page)` | Paginated list JSON: call with `page=1` or `page=2` to get the respective page of a two-page result set | -| `RevokeSuccessJson()` | `{"success":true,"message":"Certificate revoked successfully."}` | -| `RenewResponseJson(newId)` | Renew response JSON with a new certificate ID | -| `HealthOkJson()` | `{"status":"ok","version":"2.1.0"}` | -| `OAuth2TokenJson(expiresIn)` | OAuth2 token response with `access_token="fake-bearer-token-abc123"` | -| `ProfilesJson(profiles)` | Profiles list JSON; defaults to `ActiveProfiles()` if no argument passed | -| `NotFoundErrorJson(id)` | 404 error body with the given ID in the message | -| `ServerErrorJson()` | Generic 500 error body | -| `UnauthorizedJson()` | 401 error body | - -`EscapeForJson` is a private helper used internally to embed `FakePemCertificate` and `FakeCsrPem` (which contain newlines and no special JSON escaping) inside JSON string values. +| `IssuedCertRecord(id)` | `LegacyGetCertificateResponse` with `Status="issued"`, PEM, `ProfileId=ProfileIdTls` | +| `PendingCertRecord(id)` | `LegacyGetCertificateResponse` with `Status="pending_approval"`, no certificate — maps to `EXTERNALVALIDATION` | +| `RevokedCertRecord(id)` | `LegacyGetCertificateResponse` with `Status="revoked"`, `RevokedAt`, `RevocationReason="keyCompromise"` | +| `DcvPendingTrackResponse(orderNumber, domain)` | `TrackOrderResponse` with one DNS-TXT entry at `dcvStatus="0"` (pending) | +| `DcvVerifiedTrackResponse(orderNumber, domain)` | `TrackOrderResponse` with DNS-TXT entry at `dcvStatus="1"` (validated) | +| `AlreadyIssuedTrackResponse(orderNumber)` | `TrackOrderResponse` with `certificateStatusId="9"` (GENERATED) — DCV should be skipped | +| `DcvTokenResponse(token)` | `GetDcvResponse` with `DcvDetails.Token` set | --- @@ -295,20 +422,23 @@ useRenewalApi = expiry > DateTime.UtcNow && expiry <= DateTime.UtcNow.AddDays(Re ### Which suite to add to -- **Add to `CERTInextClientTests`** when testing HTTP-level behavior: a new endpoint, a new error status code, authentication header details, query parameter serialization, or any behavior where the actual request sent over the wire matters. -- **Add to `CERTInextCAPluginTests`** when testing plugin logic: a new enrollment type, a new validation rule, a new status mapping, or how the plugin responds to specific client return values or exceptions. +- **`CERTInextClientTests`** — when testing HTTP-level behaviour: a new endpoint, error status + code, authentication header detail, body serialisation, or query parameter. +- **`CERTInextClientRequestShapeTests`** — when verifying that the request body includes or omits + specific JSON blocks based on connector configuration. +- **`CERTInextCAPluginTests` / `CERTInextCAPluginCoverageTests`** — when testing plugin logic: a + new enrollment type, validation rule, status mapping, or response to specific client return values. ### Adding a new WireMock stub -1. Register a stub in the test body using the existing pattern: +1. Register a stub in the test body: ```csharp _server - .Given(Request.Create().WithPath("/api/v1/your-endpoint").UsingGet()) + .Given(Request.Create().WithPath("/YourEndpoint").UsingPost()) .RespondWith(Response.Create() .WithStatusCode(200) .WithHeader("Content-Type", "application/json") - .WithBody(@"{""yourField"":""yourValue""}")); + .WithBody(MockCertificateData.YourResponseJson())); ``` -2. If the response shape is reused across tests, add a JSON helper to `MockCertificateData` following the same `string YourResponseJson(...)` convention. -3. If you need a typed object for a Moq setup that mirrors the new JSON, add a corresponding object helper (e.g., `YourResponse()`) that returns a populated API response object. -4. Verify request details (headers, query parameters, body) by inspecting `_server.LogEntries` after the call, following the pattern in `PingAsync_SendsApiKeyHeader_WhenAuthModeIsApiKey` and `ListCertificatesAsync_RespectsIssuedAfterFilter`. +2. Add a `YourResponseJson(...)` JSON helper to `MockCertificateData` if the shape is reused. +3. Verify request details by inspecting `_server.LogEntries` after the call. diff --git a/CERTInext.Tests/V1NonSuccessResponseTests.cs b/CERTInext.Tests/V1NonSuccessResponseTests.cs new file mode 100644 index 0000000..7d4c248 --- /dev/null +++ b/CERTInext.Tests/V1NonSuccessResponseTests.cs @@ -0,0 +1,101 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Issue 0044: a V1 call that gets a non-2xx response whose body is not a CERTInext envelope + /// must surface the HTTP status in the exception, not just "unrecognised error body". + /// + /// The body below is the exact one captured live on 2026-09-29 when the V1 GetOrderReport + /// call was sent to the V2 base URL (ApiUrl without /emSignHub-API/): the host's default + /// Spring Boot 404 body, with no meta and no message. + /// + public class V1NonSuccessResponseTests : IDisposable + { + internal const string LiveSpringNotFoundBody = + "{\"timestamp\":\"2026-09-29T16:05:05.736+00:00\",\"status\":404,\"error\":\"Not Found\",\"path\":\"/GetOrderReport\"}"; + + private readonly WireMockServer _server; + + public V1NonSuccessResponseTests() + { + _server = WireMockServer.Start(); + } + + public void Dispose() + { + _server.Stop(); + } + + private CERTInextClient BuildClient() => + new CERTInextClient(new CERTInextConfig + { + ApiUrl = _server.Urls[0], + AuthMode = "AccessKey", + ApiKey = "test-key", + AccountNumber = "12345", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + PageSize = 100 + }); + + private void StubNotFound(string path) => + _server + .Given(Request.Create().WithPath(path).UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(404) + .WithHeader("Content-Type", "application/json") + .WithBody(LiveSpringNotFoundBody)); + + [Fact] + public async Task ListOrdersAsync_SpringNotFoundBody_ThrowsWithHttpStatus() + { + StubNotFound("/GetOrderReport"); + var client = BuildClient(); + + Func act = async () => + { + await foreach (var _ in client.ListOrdersAsync(pageSize: 5)) + { + } + }; + + await act.Should().ThrowAsync() + .WithMessage("CERTInext returned an unrecognised error body (HTTP 404) for operation 'list orders page 1'. See gateway logs for details."); + } + + [Fact] + public async Task GetProductDetailsAsync_SpringNotFoundBody_ThrowsWithHttpStatus() + { + // Same shared DeserializeOrThrow path, different caller. + StubNotFound("/GetProductDetails"); + var client = BuildClient(); + + Func act = () => client.GetProductDetailsAsync(); + + await act.Should().ThrowAsync() + .WithMessage("*unrecognised error body (HTTP 404) for operation 'get product details'*"); + } + } +} diff --git a/CERTInext.Tests/V2CsrTransportFailureTests.cs b/CERTInext.Tests/V2CsrTransportFailureTests.cs new file mode 100644 index 0000000..232a528 --- /dev/null +++ b/CERTInext.Tests/V2CsrTransportFailureTests.cs @@ -0,0 +1,215 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Review finding (B): a transport-level failure or timeout from SubmitCsrV2Async does + /// not tell whether CERTInext actually received + /// the CSR — only that no successful response was seen. Cancelling unconditionally (the old + /// behavior, still covered for a *definitive* CA rejection in + /// V2OrphanedOrderCancelTests) can orphan an order the CA genuinely accepted. + /// + /// Covers the three ambiguous-failure branches: still pending-csr after tracking (cancel, same + /// as before), progressed past pending-csr (continue the normal flow, no cancel), and tracking + /// itself failing (return pending without cancelling). Also pins the pure classification logic + /// in . + /// + public class V2CsrTransportFailureTests + { + private const string OrderId = "ord_transport_001"; + private const string Family = Constants.ApiV2.FamilySsl; + + // Mirrors ThrowOnV2Failure's generic fallback shape for a response that never arrived + // (RestSharp's ThrowOnAnyError=false swallows the transport failure into a non-successful + // response with the default HttpStatusCode, which is 0). + private const string TransportFailureText = + "CERTInext V2 API error during 'V2 submit CSR'. HTTP 0. See gateway logs for raw response."; + + private static CERTInextConfig BaseConfig() => new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "DevOps Team", + RequestorEmail = "devops@acme.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "4155551234", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + PickupRetries = 0 + }; + + private static EnrollmentProductInfo SslProductInfo() => new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = "842", + ["ProductFamily"] = "ssl", + ["ProductVariant"] = "dv", + ["DomainName"] = "example.com" + } + }; + + private static Mock MockPlacingOrder() + { + var mock = new Mock(MockBehavior.Strict); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = OrderId, Status = "pending-csr" }); + return mock; + } + + private static Task EnrollAsync(ICERTInextClient client) => + new CERTInextCAPlugin(client, BaseConfig()).Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=example.com", + san: new Dictionary(), + productInfo: SslProductInfo(), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + // --------------------------------------------------------------------------- + // Still pending-csr after tracking -> cancel (same outcome as a definitive rejection). + // --------------------------------------------------------------------------- + + [Fact] + public async Task TransportFailure_StillPendingCsrAfterTracking_Cancels_ReturnsFailed() + { + var mock = MockPlacingOrder(); + mock.Setup(c => c.SubmitCsrV2Async(Family, OrderId, It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception(TransportFailureText)); + mock.Setup(c => c.TrackOrderV2Async(Family, OrderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = Constants.ApiV2.StatusPendingCsr }); + mock.Setup(c => c.CancelOrderV2Async(Family, OrderId, It.IsAny(), It.IsAny())) + .ReturnsAsync(V2CancelOrderOutcome.Cancelled); + + var result = await EnrollAsync(mock.Object); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.CARequestID.Should().Be(OrderId); + result.StatusMessage.Should().Contain("The orphaned order was cancelled."); + mock.Verify(c => c.TrackOrderV2Async(Family, OrderId, It.IsAny()), Times.Once); + mock.Verify(c => c.CancelOrderV2Async( + Family, OrderId, CERTInextCAPlugin.OrphanedOrderCancelReason, It.IsAny()), Times.Once); + mock.Verify(c => c.SubmitCsrV2Async(Family, OrderId, It.IsAny(), It.IsAny()), + Times.Once, "the CSR submit is never retried"); + } + + // --------------------------------------------------------------------------- + // Progressed past pending-csr -> continue the normal flow, do not cancel a valid order. + // --------------------------------------------------------------------------- + + [Fact] + public async Task TransportFailure_ProgressedPastPendingCsr_DoesNotCancel_ContinuesNormalFlow() + { + var mock = MockPlacingOrder(); + mock.Setup(c => c.SubmitCsrV2Async(Family, OrderId, It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception(TransportFailureText)); + // CERTInext actually received the CSR despite the transport error on our side — the + // order has already moved on to pending-approval. + mock.Setup(c => c.TrackOrderV2Async(Family, OrderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = Constants.ApiV2.StatusPendingApproval }); + + var result = await EnrollAsync(mock.Object); + + result.Status.Should().NotBe((int)EndEntityStatus.FAILED, + "the CSR was actually accepted — this must not be reported as a failed enrollment"); + result.CARequestID.Should().Be(OrderId); + mock.Verify(c => c.CancelOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + mock.Verify(c => c.TrackOrderV2Async(Family, OrderId, It.IsAny()), Times.Once); + mock.Verify(c => c.SubmitCsrV2Async(Family, OrderId, It.IsAny(), It.IsAny()), + Times.Once, "the CSR submit is never retried"); + } + + // --------------------------------------------------------------------------- + // Tracking itself fails -> don't cancel; return pending with the known orderId. + // --------------------------------------------------------------------------- + + [Fact] + public async Task TransportFailure_TrackingAlsoFails_DoesNotCancel_ReturnsPendingWithOrderId() + { + var mock = MockPlacingOrder(); + mock.Setup(c => c.SubmitCsrV2Async(Family, OrderId, It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception(TransportFailureText)); + mock.Setup(c => c.TrackOrderV2Async(Family, OrderId, It.IsAny())) + .ThrowsAsync(new Exception("CERTInext V2 API error during 'V2 track order'. HTTP 0. See gateway logs for raw response.")); + + var result = await EnrollAsync(mock.Object); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + result.CARequestID.Should().Be(OrderId); + result.Certificate.Should().BeNull(); + result.StatusMessage.Should().Contain("sync will resolve"); + mock.Verify(c => c.CancelOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // Pure classification logic. + // --------------------------------------------------------------------------- + + [Theory] + [InlineData("CERTInext V2 API error during 'V2 submit CSR'. HTTP 0. See gateway logs for raw response.", true)] + [InlineData("CERTInext V2 API error during 'V2 submit CSR'. HTTP 503. Service unavailable.", true)] + [InlineData("Some unrecognized failure shape with no HTTP status at all.", true)] + [InlineData("CERTInext V2 API error during 'V2 submit CSR'. HTTP 400. CSR rejected.", false)] + [InlineData("CERTInext V2 API error during 'V2 submit CSR'. HTTP 404. Order not found.", false)] + [InlineData("CERTInext V2 API error during 'V2 submit CSR'. HTTP 499. Client closed request.", false)] + public void IsTransportLevelCsrFailure_ClassifiesByParsedHttpStatus(string message, bool expectedTransportLevel) + { + CERTInextCAPlugin.IsTransportLevelCsrFailure(new Exception(message)).Should().Be(expectedTransportLevel); + } + + [Fact] + public void IsTransportLevelCsrFailure_OperationCanceledException_IsTransportLevel() + { + CERTInextCAPlugin.IsTransportLevelCsrFailure(new OperationCanceledException()).Should().BeTrue(); + } + + [Fact] + public void IsTransportLevelCsrFailure_HttpRequestException_IsTransportLevel() + { + CERTInextCAPlugin.IsTransportLevelCsrFailure(new HttpRequestException("connection refused")).Should().BeTrue(); + } + + [Fact] + public void IsTransportLevelCsrFailure_TimeoutException_IsTransportLevel() + { + CERTInextCAPlugin.IsTransportLevelCsrFailure(new TimeoutException()).Should().BeTrue(); + } + } +} diff --git a/CERTInext.Tests/V2FamilyOrderRequestSerializationTests.cs b/CERTInext.Tests/V2FamilyOrderRequestSerializationTests.cs new file mode 100644 index 0000000..5b4bd9f --- /dev/null +++ b/CERTInext.Tests/V2FamilyOrderRequestSerializationTests.cs @@ -0,0 +1,385 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Issue 0033: wire-shape tests for the Private PKI and Document Signer create-order DTOs, + /// checked against the request bodies in the V2 spec + /// (docs/reference/specs/CERTInext API v2.postman_collection (1).json). Each spec + /// example body is copied verbatim below (named after its Postman request); the DTO is + /// populated with the same values, serialized with the client's serializer options, and + /// compared structurally (key names, nesting, values — not whitespace or key order). + /// + public class V2FamilyOrderRequestSerializationTests + { + // Mirrors CERTInextClient.GetJsonOptions() (private). + private static JsonSerializerOptions ClientEquivalentJsonOptions() => new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private static string Serialize(T value) => JsonSerializer.Serialize(value, ClientEquivalentJsonOptions()); + + /// Order-insensitive canonical form of a JSON document (object keys sorted). + private static string Canonical(string json) + { + using var doc = JsonDocument.Parse(json); + var sb = new StringBuilder(); + WriteCanonical(doc.RootElement, sb); + return sb.ToString(); + } + + private static void WriteCanonical(JsonElement e, StringBuilder sb) + { + switch (e.ValueKind) + { + case JsonValueKind.Object: + sb.Append('{'); + bool first = true; + foreach (var p in e.EnumerateObject().OrderBy(p => p.Name, System.StringComparer.Ordinal)) + { + if (!first) sb.Append(','); + first = false; + sb.Append(JsonSerializer.Serialize(p.Name)).Append(':'); + WriteCanonical(p.Value, sb); + } + sb.Append('}'); + break; + case JsonValueKind.Array: + sb.Append('['); + bool firstItem = true; + foreach (var item in e.EnumerateArray()) + { + if (!firstItem) sb.Append(','); + firstItem = false; + WriteCanonical(item, sb); + } + sb.Append(']'); + break; + case JsonValueKind.String: + sb.Append(JsonSerializer.Serialize(e.GetString())); + break; + default: + sb.Append(e.GetRawText()); + break; + } + } + + // --------------------------------------------------------------------------- + // Private PKI — spec "Private PKI Certificates" -> "Create - Intranet SSL" + // --------------------------------------------------------------------------- + + private const string SpecCreateIntranetSslBody = """ + { + "variant": "intranet-ssl", + "hostname": "intranet.acme.local", + "additionalHosts": [ + "portal.acme.local", + "reports.acme.local", + "10.0.0.50" + ], + "emailNotifications": "all", + "subscription": { "validityYears": 1 }, + "requestor": { + "name": "DevOps Team", + "email": "devops@acme.com", + "phone": "+14155551234", + "designation": "Platform Engineering" + } + } + """; + + [Fact] + public void PrivatePki_CreateIntranetSslSpecExample_SerializesToTheSpecBody() + { + var request = new V2CreatePrivatePkiOrderRequest + { + Variant = "intranet-ssl", + Hostname = "intranet.acme.local", + AdditionalHosts = new List { "portal.acme.local", "reports.acme.local", "10.0.0.50" }, + EmailNotifications = "all", + Subscription = new V2SubscriptionParams { ValidityYears = 1 }, + Requestor = new V2Requestor + { + Name = "DevOps Team", Email = "devops@acme.com", Phone = "+14155551234", Designation = "Platform Engineering" + } + }; + + // The only difference from the spec example is subscription.autoRenew, which the + // shared V2SubscriptionParams always writes (spec: "Optional (default ON)" — the plugin + // always sends the connector's explicit choice, exactly as for SSL). + string expected = SpecCreateIntranetSslBody.Replace( + "\"subscription\": { \"validityYears\": 1 }", + "\"subscription\": { \"validityYears\": 1, \"autoRenew\": false }"); + + Canonical(Serialize(request)).Should().Be(Canonical(expected)); + } + + [Fact] + public void PrivatePki_OptionalBlocks_AreOmittedWhenNull() + { + var request = new V2CreatePrivatePkiOrderRequest + { + Variant = "igtf-host", + Hostname = "compute01.hpc.example.edu", + Requestor = new V2Requestor { Name = "HPC Operations", Email = "hpc-ops@example.edu" } + }; + + using var doc = JsonDocument.Parse(Serialize(request)); + doc.RootElement.EnumerateObject().Select(p => p.Name).Should().BeEquivalentTo( + new[] { "variant", "requestor", "hostname" }, + "only the spec's strictly-mandatory fields remain when nothing optional is set"); + } + + // --------------------------------------------------------------------------- + // Document Signer — spec "Document Signer Certificates" create examples + // --------------------------------------------------------------------------- + + private const string SpecCreateNaturalPersonBody = """ + { + "subjectType": "natural-person", + "emailNotifications": "all", + "requestor": { + "name": "Sarah Johnson", + "email": "sarah.johnson@example.com", + "phone": "+12025551234", + "designation": "Document Signer" + }, + "subject": { + "firstName": "Sarah", + "lastName": "Johnson", + "email": "sarah.johnson@example.com", + "phone": "+12025551234", + "identityDocumentType": "passport", + "identificationNumber": "X12345678", + "streetAddress1": "1600 Pennsylvania Avenue NW", + "locality": "Washington", + "state": "DC", + "postalCode": "20500", + "countryCode": "US" + }, + "subscription": { "validityYears": 1, "autoRenew": false }, + "agreement": { + "signerName": "Sarah Johnson", + "signerPlace": "Washington, DC", + "accepted": true + }, + "remarks": "Document Signer - Natural Person, US" + } + """; + + [Fact] + public void Signature_CreateNaturalPersonSpecExample_SerializesToExactlyTheSpecBody() + { + var request = new V2CreateSignatureOrderRequest + { + SubjectType = "natural-person", + EmailNotifications = "all", + Requestor = new V2Requestor + { + Name = "Sarah Johnson", Email = "sarah.johnson@example.com", Phone = "+12025551234", Designation = "Document Signer" + }, + Subject = new V2SignatureSubject + { + FirstName = "Sarah", + LastName = "Johnson", + Email = "sarah.johnson@example.com", + Phone = "+12025551234", + IdentityDocumentType = "passport", + IdentificationNumber = "X12345678", + StreetAddress1 = "1600 Pennsylvania Avenue NW", + Locality = "Washington", + State = "DC", + PostalCode = "20500", + CountryCode = "US" + }, + Subscription = new V2SubscriptionParams { ValidityYears = 1, AutoRenew = false }, + // signerIp deliberately null: not in the signature field table, and the spec's + // Accept Agreement note says it "will be ignored" if sent. + Agreement = new V2AgreementParams { SignerName = "Sarah Johnson", SignerPlace = "Washington, DC", Accepted = true }, + Remarks = "Document Signer - Natural Person, US" + }; + + Canonical(Serialize(request)).Should().Be(Canonical(SpecCreateNaturalPersonBody)); + } + + private const string SpecCreateLegalPersonBody = """ + { + "subjectType": "legal-person", + "emailNotifications": "all", + "requestor": { + "name": "Michael Chen", + "email": "michael.chen@acme.com", + "phone": "+14155551234", + "designation": "VP Engineering" + }, + "subject": { + "firstName": "Michael", + "lastName": "Chen", + "email": "michael.chen@acme.com", + "phone": "+14155551234", + "designation": "VP Engineering", + "organizationName": "Acme Corporation", + "organizationUnit": "Engineering", + "organizationIdentificationNumber": "EIN-12-3456789", + "identityDocumentType": "passport", + "identificationNumber": "P98765432", + "streetAddress1": "500 Market Street", + "streetAddress2": "Suite 300", + "locality": "San Francisco", + "state": "CA", + "postalCode": "94105", + "countryCode": "US" + }, + "subscription": { "validityYears": 1, "autoRenew": false }, + "agreement": { + "signerName": "Michael Chen", + "signerPlace": "San Francisco, CA", + "accepted": true + }, + "remarks": "Document Signer - Legal Person, employee of Acme Corp" + } + """; + + [Fact] + public void Signature_CreateLegalPersonSpecExample_SerializesToExactlyTheSpecBody() + { + var request = new V2CreateSignatureOrderRequest + { + SubjectType = "legal-person", + EmailNotifications = "all", + Requestor = new V2Requestor + { + Name = "Michael Chen", Email = "michael.chen@acme.com", Phone = "+14155551234", Designation = "VP Engineering" + }, + Subject = new V2SignatureSubject + { + FirstName = "Michael", + LastName = "Chen", + Email = "michael.chen@acme.com", + Phone = "+14155551234", + Designation = "VP Engineering", + OrganizationName = "Acme Corporation", + OrganizationUnit = "Engineering", + OrganizationIdentificationNumber = "EIN-12-3456789", + IdentityDocumentType = "passport", + IdentificationNumber = "P98765432", + StreetAddress1 = "500 Market Street", + StreetAddress2 = "Suite 300", + Locality = "San Francisco", + State = "CA", + PostalCode = "94105", + CountryCode = "US" + }, + Subscription = new V2SubscriptionParams { ValidityYears = 1, AutoRenew = false }, + Agreement = new V2AgreementParams { SignerName = "Michael Chen", SignerPlace = "San Francisco, CA", Accepted = true }, + Remarks = "Document Signer - Legal Person, employee of Acme Corp" + }; + + Canonical(Serialize(request)).Should().Be(Canonical(SpecCreateLegalPersonBody)); + } + + private const string SpecCreateLegalEntityBody = """ + { + "subjectType": "legal-entity", + "emailNotifications": "all", + "requestor": { + "name": "Acme Corporation Compliance", + "email": "pki-ops@acme.com", + "phone": "+14155551234", + "designation": "PKI Operations" + }, + "subject": { + "organizationName": "Acme Corporation", + "organizationUnit": "Compliance", + "businessCategory": "Business Entity", + "organizationIdentificationNumber": "EIN-12-3456789", + "email": "pki-ops@acme.com", + "phone": "+14155551234", + "streetAddress1": "500 Market Street", + "streetAddress2": "Suite 300", + "locality": "San Francisco", + "state": "CA", + "postalCode": "94105", + "countryCode": "US" + }, + "subscription": { "validityYears": 1, "autoRenew": false }, + "agreement": { + "signerName": "Acme Corp PKI Operations", + "signerPlace": "San Francisco, CA", + "accepted": true + }, + "remarks": "Document Signer - Legal Entity (org-only subject)" + } + """; + + [Fact] + public void Signature_CreateLegalEntitySpecExample_SerializesToExactlyTheSpecBody_WithNoPersonNameKeys() + { + var request = new V2CreateSignatureOrderRequest + { + SubjectType = "legal-entity", + EmailNotifications = "all", + Requestor = new V2Requestor + { + Name = "Acme Corporation Compliance", Email = "pki-ops@acme.com", Phone = "+14155551234", Designation = "PKI Operations" + }, + Subject = new V2SignatureSubject + { + OrganizationName = "Acme Corporation", + OrganizationUnit = "Compliance", + BusinessCategory = "Business Entity", + OrganizationIdentificationNumber = "EIN-12-3456789", + Email = "pki-ops@acme.com", + Phone = "+14155551234", + StreetAddress1 = "500 Market Street", + StreetAddress2 = "Suite 300", + Locality = "San Francisco", + State = "CA", + PostalCode = "94105", + CountryCode = "US" + }, + Subscription = new V2SubscriptionParams { ValidityYears = 1, AutoRenew = false }, + Agreement = new V2AgreementParams { SignerName = "Acme Corp PKI Operations", SignerPlace = "San Francisco, CA", Accepted = true }, + Remarks = "Document Signer - Legal Entity (org-only subject)" + }; + + string json = Serialize(request); + + Canonical(json).Should().Be(Canonical(SpecCreateLegalEntityBody)); + json.Should().NotContain("firstName").And.NotContain("lastName", + "subject.firstName/lastName are conditional on natural-/legal-person and must be omitted, not sent null"); + } + + [Fact] + public void Signature_SubjectEmail_IsAlwaysWritten_EvenWhenOtherSubjectFieldsAreAbsent() + { + // subject.email is the one strictly-mandatory subject field ("400 if missing"). + var json = Serialize(new V2SignatureSubject { Email = "signer@example.com" }); + + Canonical(json).Should().Be(Canonical("{\"email\":\"signer@example.com\"}")); + } + } +} diff --git a/CERTInext.Tests/V2GroupNumberEnrollmentTests.cs b/CERTInext.Tests/V2GroupNumberEnrollmentTests.cs new file mode 100644 index 0000000..f97f7ba --- /dev/null +++ b/CERTInext.Tests/V2GroupNumberEnrollmentTests.cs @@ -0,0 +1,211 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Moq; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Regression tests for issues/0029-v2-groupnumber-not-sent.md: the V2 order body never + /// carried the connector's configured GroupNumber (V1's DelegationInformation + /// .GroupNumber equivalent), so V2 orders always billed to the account's default group + /// regardless of the connector setting. These tests exercise EnrollV2Async end-to-end + /// (through ) against a Strict + /// mock, plus direct DTO serialization checks for the new + /// property. + /// + public class V2GroupNumberEnrollmentTests + { + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + private static CERTInextCAPlugin BuildV2Plugin(ICERTInextClient client, string groupNumber = "") => + new CERTInextCAPlugin(client, new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + GroupNumber = groupNumber, + PickupRetries = 0 + }); + + private static EnrollmentProductInfo MakeV2ProductInfo(string productCode, string productVariant) => + new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = productCode, + ["ProductFamily"] = "ssl", + ["ProductVariant"] = productVariant, + ["DomainName"] = "example.com" + } + }; + + private static void StubCatalog(Mock mock, string productCode, string productTypeId) => + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = productCode, ProductTypeId = productTypeId, Active = true } + }); + + private static void StubHappyOrderPlacement(Mock mock, string orderId) + { + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), orderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), orderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = orderId, Status = "pending-dcv" }); + } + + private static string GenerateCsrPem(string cn) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + AsymmetricCipherKeyPair kp = keyGen.GenerateKeyPair(); + + var csr = new Pkcs10CertificationRequest( + "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, null, kp.Private); + + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + // --------------------------------------------------------------------------- + // EnrollV2Async wiring — GroupNumber populated / omitted on the order body + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_V2_GroupNumberConfigured_PopulatesGroupNumberOnOrderBody() + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // DV SSL (non-UCC) + StubHappyOrderPlacement(mock, "ord_grp_001"); + + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_grp_001", Status = "pending-dcv" }); + + var plugin = BuildV2Plugin(mock.Object, groupNumber: "GRP-12345"); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), + subject: "CN=example.com", + san: null, + productInfo: MakeV2ProductInfo("842", "dv"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.CARequestID.Should().Be("ord_grp_001"); + captured.Should().NotBeNull(); + captured!.GroupNumber.Should().Be("GRP-12345", + "a configured GroupNumber must be forwarded to the V2 order body, mirroring V1's " + + "DelegationInformation.GroupNumber"); + } + + [Fact] + public async Task Enroll_V2_GroupNumberBlank_OmitsGroupNumberFromOrderBody() + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); + StubHappyOrderPlacement(mock, "ord_grp_002"); + + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_grp_002", Status = "pending-dcv" }); + + var plugin = BuildV2Plugin(mock.Object, groupNumber: string.Empty); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), + subject: "CN=example.com", + san: null, + productInfo: MakeV2ProductInfo("842", "dv"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.CARequestID.Should().Be("ord_grp_002"); + captured.Should().NotBeNull(); + captured!.GroupNumber.Should().BeNull( + "an unconfigured GroupNumber must be omitted (null), not sent as an empty string — " + + "the account's default billing group should apply, same as V1's fallback behavior"); + } + + // --------------------------------------------------------------------------- + // DTO serialization — groupNumber key present/absent on the wire + // --------------------------------------------------------------------------- + + [Fact] + public void V2CreateSslOrderRequest_Serialization_OmitsGroupNumber_WhenNull() + { + var req = new V2CreateSslOrderRequest + { + ProductVariant = "dv", + Requestor = new V2Requestor { Name = "Jane Doe", Email = "jane@example.com" }, + Certificate = new V2CertificateParams { Domain = "example.com" }, + GroupNumber = null + }; + + string json = JsonSerializer.Serialize(req); + + json.Should().NotContain("groupNumber", + "the groupNumber key itself must be absent when unset, not present-but-null"); + } + + [Fact] + public void V2CreateSslOrderRequest_Serialization_IncludesGroupNumber_WhenSet() + { + var req = new V2CreateSslOrderRequest + { + ProductVariant = "dv", + Requestor = new V2Requestor { Name = "Jane Doe", Email = "jane@example.com" }, + Certificate = new V2CertificateParams { Domain = "example.com" }, + GroupNumber = "GRP-999" + }; + + string json = JsonSerializer.Serialize(req); + + json.Should().Contain("\"groupNumber\":\"GRP-999\""); + } + } +} diff --git a/CERTInext.Tests/V2OrderStatusResponseTests.cs b/CERTInext.Tests/V2OrderStatusResponseTests.cs new file mode 100644 index 0000000..bf59a18 --- /dev/null +++ b/CERTInext.Tests/V2OrderStatusResponseTests.cs @@ -0,0 +1,79 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Text.Json; +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Pure DTO deserialization tests for 's nested + /// revocation object (issues/0034). No HTTP layer involved — these assert directly + /// against , isolated from the client and plugin code that + /// consumes this type. + /// + public class V2OrderStatusResponseTests + { + /// + /// Raw Track Order response body captured live against a real revoked SSL order + /// (order 6758681362, family ssl-certificates, 2026-09-25 — see + /// issues/0034-v2-revocation-date-reason-dto-mismatch.md's "Live probe findings" + /// section). Verbatim except for whitespace; unmapped fields (requestor, orderedBy, + /// subscriberAgreement, subscription, verifications) are expected to be ignored by + /// System.Text.Json's default unmapped-member handling. + /// + private const string LiveRevokedTrackOrderJson = + @"{""orderId"":""6758681362"",""requestId"":""1279754567"",""status"":""revoked"",""orderState"":""Order Accepted"",""certificateState"":""Certificate Revoked"",""productVariant"":""dv"",""domain"":""nt2-20260924204408874594000.dcv-test.scrup.org"",""expiresAt"":""2026-12-23T20:44:23Z"",""requestor"":{""name"":""Keyfactor Plugin Test"",""email"":""plugin-test@keyfactor.com"",""phone"":""+0000000000"",""designation"":""Plugin Test""},""orderedBy"":{""name"":""Sean"",""email"":""sbailey@keyfactor.com""},""csrSubmitted"":true,""subscriberAgreement"":{""signed"":true,""signerName"":""Keyfactor Plugin Test"",""signedAt"":""2026-09-25T14:18:14Z"",""signedPlace"":""Gateway Lab""},""subscription"":{""validityYears"":1,""endDate"":""2027-09-24T20:44:27Z"",""status"":""active""},""revocation"":{""status"":""Certificate Revoked"",""reason"":""cessation-of-operation"",""processedAt"":""2026-09-24T20:44:41Z""},""verifications"":{""domain"":{""status"":""VERIFIED"",""domains"":[{""domain"":""nt2-20260924204408874594000.dcv-test.scrup.org"",""domainStatus"":""ACTIVE"",""dcvMethod"":""dns-txt"",""dcvStatus"":""VERIFIED"",""verifiedAt"":""2026-09-24T20:44:13Z"",""caaStatus"":""PASSED""}]},""empty"":false}}"; + + [Fact] + public void Deserialize_LiveRevokedOrderBody_PopulatesNestedRevocationObject() + { + var result = JsonSerializer.Deserialize(LiveRevokedTrackOrderJson); + + result.Should().NotBeNull(); + result!.OrderId.Should().Be("6758681362"); + result.Status.Should().Be("revoked"); + result.ProductVariant.Should().Be("dv"); + result.Domain.Should().Be("nt2-20260924204408874594000.dcv-test.scrup.org"); + + // issues/0034: `revocation` is a nested object — status/reason/processedAt — not + // flat top-level revocationReason/revocationDate properties. + result.Revocation.Should().NotBeNull(); + result.Revocation!.Status.Should().Be("Certificate Revoked"); + result.Revocation.Reason.Should().Be("cessation-of-operation", + "the wire reason is RFC 5280-style hyphenated, not V1's camelCase convention"); + result.Revocation.ProcessedAt.Should().Be( + new DateTime(2026, 9, 24, 20, 44, 41, DateTimeKind.Utc), + "processedAt is standard ISO 8601 UTC and should bind directly with no custom converter"); + } + + [Fact] + public void Deserialize_NotRevokedOrderBody_RevocationIsNull() + { + // The `revocation` key is absent entirely when an order has never been revoked + // (confirmed live, issues/0034) — not present-but-null. + const string issuedJson = + @"{""orderId"":""ord_abc001"",""requestId"":""req_xyz001"",""status"":""issued"",""productVariant"":""dv"",""domain"":""example.com""}"; + + var result = JsonSerializer.Deserialize(issuedJson); + + result.Should().NotBeNull(); + result!.Status.Should().Be("issued"); + result.Revocation.Should().BeNull(); + } + } +} diff --git a/CERTInext.Tests/V2OrganizationEnrollmentTests.cs b/CERTInext.Tests/V2OrganizationEnrollmentTests.cs new file mode 100644 index 0000000..addad73 --- /dev/null +++ b/CERTInext.Tests/V2OrganizationEnrollmentTests.cs @@ -0,0 +1,260 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Regression tests for issues/0028-v2-organizationnumber-not-sent.md: the V2 + /// organization block was never populated for any product variant, and CERTInext + /// hard-rejects an OV/EV order that omits it (live-confirmed HTTP 422 + /// [EMS-1180] Organization Name cannot be empty). These tests exercise + /// EnrollV2Async end-to-end (through ) against a + /// Strict mock, plus direct DTO serialization checks for the + /// new block on . + /// + public class V2OrganizationEnrollmentTests + { + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + private static CERTInextCAPlugin BuildV2Plugin(ICERTInextClient client, string organizationNumber = "") => + new CERTInextCAPlugin(client, new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + OrganizationNumber = organizationNumber, + PickupRetries = 0 + }); + + // Issue 0059: ProductVariant must agree with ProductId (the plugin now derives/validates + // one from the other), so callers pass both explicitly rather than this helper hardcoding + // a single ProductID ("OV SSL") for every variant under test. + private static EnrollmentProductInfo MakeV2ProductInfo(string productId, string productCode, string productVariant) => + new EnrollmentProductInfo + { + ProductID = productId, + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = productCode, + ["ProductFamily"] = "ssl", + ["ProductVariant"] = productVariant, + ["DomainName"] = "example.com" + } + }; + + private static void StubCatalog(Mock mock, string productCode, string productTypeId) => + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = productCode, ProductTypeId = productTypeId, Active = true } + }); + + private static void StubHappyOrderPlacement(Mock mock, string orderId) + { + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), orderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), orderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = orderId, Status = "pending-organization-verification" }); + } + + private static string GenerateCsrPem(string cn) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + AsymmetricCipherKeyPair kp = keyGen.GenerateKeyPair(); + + var csr = new Pkcs10CertificationRequest( + "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, null, kp.Private); + + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + // --------------------------------------------------------------------------- + // EnrollV2Async wiring — organization block populated / omitted per productVariant + // --------------------------------------------------------------------------- + + [Theory] + [InlineData("ov", Constants.Products.OvSsl, "846", "16")] + [InlineData("ev", Constants.Products.EvSsl, "847", "19")] + [InlineData("OV", Constants.Products.OvSsl, "846", "16")] + [InlineData("Ev", Constants.Products.EvSsl, "847", "19")] + public async Task Enroll_V2_OvOrEvProduct_PopulatesOrganizationBlockFromConfig( + string productVariant, string productId, string catalogCode, string catalogTypeId) + { + var mock = NewMock(); + StubCatalog(mock, catalogCode, catalogTypeId); + StubHappyOrderPlacement(mock, "ord_org_001"); + + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_org_001", Status = "pending-organization-verification" }); + + var plugin = BuildV2Plugin(mock.Object, organizationNumber: "ORG-12345"); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), + subject: "CN=example.com", + san: null, + productInfo: MakeV2ProductInfo(productId, catalogCode, productVariant), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.CARequestID.Should().Be("ord_org_001"); + captured.Should().NotBeNull(); + captured!.Organization.Should().NotBeNull( + "organization is mandatory for OV/EV orders per the V2 spec's field table"); + captured.Organization.OrganizationNumber.Should().Be("ORG-12345"); + captured.Organization.PreVetted.Should().BeTrue(); + } + + [Fact] + public async Task Enroll_V2_DvProduct_OmitsOrganizationBlock_EvenWhenOrganizationNumberConfigured() + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // DV SSL (non-UCC) + StubHappyOrderPlacement(mock, "ord_dv_001"); + + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_dv_001", Status = "pending-dcv" }); + + // OrganizationNumber IS configured — a DV order must still omit the block per spec + // ("organization | Conditional - Mandatory for OV / EV", not DV). + var plugin = BuildV2Plugin(mock.Object, organizationNumber: "ORG-12345"); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), + subject: "CN=example.com", + san: null, + productInfo: MakeV2ProductInfo(Constants.Products.DvSsl, "842", "dv"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.CARequestID.Should().Be("ord_dv_001"); + captured.Should().NotBeNull(); + captured!.Organization.Should().BeNull( + "DV orders must not send an organization block even when OrganizationNumber is configured"); + } + + [Theory] + [InlineData("ov", Constants.Products.OvSsl)] + [InlineData("ev", Constants.Products.EvSsl)] + public async Task Enroll_V2_OvOrEvProduct_MissingOrganizationNumber_FailsFastWithoutCallingCa( + string productVariant, string productId) + { + // Strict mock with NOTHING stubbed: proves the guard fires before any catalog lookup + // or order-placement call — mirrors the CSR-SAN-count guard's own Strict-mock test. + var mock = NewMock(); + var plugin = BuildV2Plugin(mock.Object, organizationNumber: string.Empty); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), + subject: "CN=example.com", + san: null, + productInfo: MakeV2ProductInfo(productId, "846", productVariant), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.StatusMessage.Should().Contain("OrganizationNumber"); + + mock.Verify(c => c.GetProductDetailsV2Async(It.IsAny()), Times.Never, + "the OrganizationNumber guard must reject before any catalog lookup is attempted"); + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // DTO serialization — organization block shape on the wire + // --------------------------------------------------------------------------- + + [Fact] + public void V2CreateSslOrderRequest_Serialization_IncludesOrganizationBlock_ForOvEv() + { + var req = new V2CreateSslOrderRequest + { + ProductVariant = "ov", + Requestor = new V2Requestor { Name = "Jane Doe", Email = "jane@example.com" }, + Organization = new V2OrganizationParams + { + OrganizationNumber = "ORG-999", + PreVetted = true + }, + Certificate = new V2CertificateParams { Domain = "example.com" } + }; + + string json = JsonSerializer.Serialize(req); + + json.Should().Contain("\"organization\""); + json.Should().Contain("\"organizationNumber\":\"ORG-999\""); + json.Should().Contain("\"preVetted\":true"); + // preVettingToken is optional and unset here — must be omitted, not sent as null. + json.Should().NotContain("preVettingToken"); + } + + [Fact] + public void V2CreateSslOrderRequest_Serialization_OmitsOrganizationBlock_ForDv() + { + var req = new V2CreateSslOrderRequest + { + ProductVariant = "dv", + Requestor = new V2Requestor { Name = "Jane Doe", Email = "jane@example.com" }, + Organization = null, + Certificate = new V2CertificateParams { Domain = "example.com" } + }; + + string json = JsonSerializer.Serialize(req); + + json.Should().NotContain("\"organization\"", + "the organization key itself (not just its sub-fields) must be absent for DV orders, " + + "not present-but-empty — an empty/placeholder block risks a different CA-side 422"); + } + } +} diff --git a/CERTInext.Tests/V2OrphanedOrderCancelTests.cs b/CERTInext.Tests/V2OrphanedOrderCancelTests.cs new file mode 100644 index 0000000..0c715f3 --- /dev/null +++ b/CERTInext.Tests/V2OrphanedOrderCancelTests.cs @@ -0,0 +1,332 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Issue 0039: when SubmitCsrV2Async throws after the V2 order was placed, the order + /// sits at pending-csr and Command never learns its ID. EnrollV2Async must make + /// exactly one best-effort CancelOrderV2Async call for that order's family and return + /// FAILED with the orderId — never throwing, never retrying. + /// + public class V2OrphanedOrderCancelTests + { + private const string OrderId = "ord_orphan_001"; + private const string CsrFailureText = "CERTInext V2 API error during 'V2 submit CSR'. HTTP 400. CSR rejected."; + + private static CERTInextConfig BaseConfig() => new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "DevOps Team", + RequestorEmail = "devops@acme.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "4155551234", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + PickupRetries = 0 + }; + + private static EnrollmentProductInfo SslProductInfo() => new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = "842", + ["ProductFamily"] = "ssl", + ["ProductVariant"] = "dv", + ["DomainName"] = "example.com" + } + }; + + private static EnrollmentProductInfo PrivatePkiProductInfo() => new EnrollmentProductInfo + { + ProductID = Constants.Products.DvSsl, + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductFamily"] = "private-pki", + ["ProductVariant"] = "intranet-ssl", + ["ProductCode"] = "149", + ["DomainName"] = "intranet.acme.local" + } + }; + + /// Strict mock that places an order in . + private static Mock MockPlacingOrder(string family) + { + var mock = new Mock(MockBehavior.Strict); + if (family == Constants.ApiV2.FamilyPrivatePki) + { + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = OrderId, Status = "pending-csr" }); + } + else + { + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = OrderId, Status = "pending-csr" }); + } + return mock; + } + + private static void SubmitCsrThrows(Mock mock, string family) => + mock.Setup(c => c.SubmitCsrV2Async(family, OrderId, It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception(CsrFailureText)); + + private static Task EnrollAsync(ICERTInextClient client, EnrollmentProductInfo productInfo) => + new CERTInextCAPlugin(client, BaseConfig()).Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=example.com", + san: new Dictionary(), + productInfo: productInfo, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + // --------------------------------------------------------------------------- + // Plugin: EnrollV2Async + // --------------------------------------------------------------------------- + + public static IEnumerable Families() => new[] + { + new object[] { Constants.ApiV2.FamilySsl }, + new object[] { Constants.ApiV2.FamilyPrivatePki } + }; + + private static EnrollmentProductInfo ProductInfoFor(string family) => + family == Constants.ApiV2.FamilyPrivatePki ? PrivatePkiProductInfo() : SslProductInfo(); + + [Theory] + [MemberData(nameof(Families))] + public async Task Enroll_SubmitCsrThrows_CancelsOnceInSameFamily_ReturnsFailedWithOrderId(string family) + { + var mock = MockPlacingOrder(family); + SubmitCsrThrows(mock, family); + mock.Setup(c => c.CancelOrderV2Async(family, OrderId, It.IsAny(), It.IsAny())) + .ReturnsAsync(V2CancelOrderOutcome.Cancelled); + + var result = await EnrollAsync(mock.Object, ProductInfoFor(family)); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.CARequestID.Should().Be(OrderId); + result.Certificate.Should().BeNull(); + result.StatusMessage.Should().Contain("CSR submission failed") + .And.Contain("CSR rejected") + .And.Contain("The orphaned order was cancelled."); + + mock.Verify(c => c.CancelOrderV2Async( + family, OrderId, CERTInextCAPlugin.OrphanedOrderCancelReason, It.IsAny()), Times.Once); + mock.Verify(c => c.CancelOrderV2Async( + It.Is(f => f != family), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + mock.Verify(c => c.SubmitCsrV2Async(family, OrderId, It.IsAny(), It.IsAny()), + Times.Once, "the CSR submit is never retried"); + // Strict mock: no TrackOrderV2Async / DownloadCertificateV2Async setup, so any + // post-CSR call would have thrown out of Enroll. + } + + [Fact] + public void OrphanedOrderCancelReason_IsNonEmpty_AndCarriesNoExceptionDetail() + { + CERTInextCAPlugin.OrphanedOrderCancelReason.Should().NotBeNullOrWhiteSpace("an empty reason is rejected with EMS-984"); + CERTInextCAPlugin.OrphanedOrderCancelReason.Should().NotContain("CSR rejected"); + } + + [Fact] + public async Task Enroll_SubmitCsrThrows_CancelReturns422_StillFailed_SaysNotCancelled() + { + var mock = MockPlacingOrder(Constants.ApiV2.FamilySsl); + SubmitCsrThrows(mock, Constants.ApiV2.FamilySsl); + mock.Setup(c => c.CancelOrderV2Async(Constants.ApiV2.FamilySsl, OrderId, It.IsAny(), It.IsAny())) + .ReturnsAsync(V2CancelOrderOutcome.AlreadyTerminal); + + var result = await EnrollAsync(mock.Object, SslProductInfo()); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.CARequestID.Should().Be(OrderId); + result.StatusMessage.Should().Contain("NOT cancelled").And.Contain("422"); + result.StatusMessage.Should().NotContain("The orphaned order was cancelled."); + mock.Verify(c => c.CancelOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Theory] + [MemberData(nameof(Families))] + public async Task Enroll_SubmitCsrThrows_CancelThrows_StillFailed_DoesNotThrow_NoRetry(string family) + { + var mock = MockPlacingOrder(family); + SubmitCsrThrows(mock, family); + mock.Setup(c => c.CancelOrderV2Async(family, OrderId, It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("CERTInext V2 API error during 'V2 cancel order'. HTTP 500.")); + + EnrollmentResult result = null; + Func act = async () => result = await EnrollAsync(mock.Object, ProductInfoFor(family)); + await act.Should().NotThrowAsync(); + + result.Should().NotBeNull(); + result!.Status.Should().Be((int)EndEntityStatus.FAILED); + result.CARequestID.Should().Be(OrderId); + result.StatusMessage.Should().Contain("CSR submission failed") + .And.Contain("NOT cancelled") + .And.Contain("Cancel it manually"); + mock.Verify(c => c.CancelOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once, "the cancel is best-effort and never retried"); + } + + [Theory] + [MemberData(nameof(Families))] + public async Task Enroll_SubmitCsrSucceeds_NeverCancels(string family) + { + var mock = MockPlacingOrder(family); + mock.Setup(c => c.SubmitCsrV2Async(family, OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async(family, OrderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "pending-approval" }); + + var result = await EnrollAsync(mock.Object, ProductInfoFor(family)); + + result.CARequestID.Should().Be(OrderId); + result.Status.Should().NotBe((int)EndEntityStatus.FAILED); + mock.Verify(c => c.CancelOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + } + + /// + /// WireMock coverage for (issue 0039): spec + /// "Cancel Order" is POST /api/certinext/v2/{family}/:orderId/cancel with body + /// { "reason": ... }; 204 = cancelled, 422 = already in a terminal state. + /// + public class CERTInextClientCancelOrderV2Tests : IDisposable + { + private const string OrderId = "ord_cancel_001"; + private readonly WireMockServer _server; + + public CERTInextClientCancelOrderV2Tests() + { + _server = WireMockServer.Start(); + _server + .Given(Request.Create().WithPath("/oauth/token").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2TokenResponseJson(3600))); + } + + public void Dispose() => _server.Stop(); + + private CERTInextClient BuildClient() => new CERTInextClient(new CERTInextConfig + { + ApiUrl = _server.Urls[0], + AuthMode = "AccessKey", + ApiKey = "test-v1-key", + AccountNumber = "12345", + UseV2Api = true, + OAuthClientId = "my-v2-client", + OAuthClientSecret = "my-v2-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + PageSize = 100, + GroupNumber = string.Empty + }); + + [Theory] + [InlineData(Constants.ApiV2.FamilySsl, Constants.ApiV2.SslCertificatesPath)] + [InlineData(Constants.ApiV2.FamilyPrivatePki, Constants.ApiV2.PrivatePkiCertificatesPath)] + [InlineData(Constants.ApiV2.FamilySignature, Constants.ApiV2.SignatureCertificatesPath)] + public async Task CancelOrderV2Async_204_PostsReasonToFamilyPath_ReturnsCancelled(string family, string familyPath) + { + string path = $"{familyPath}/{OrderId}/cancel"; + _server + .Given(Request.Create().WithPath(path).UsingPost()) + .RespondWith(Response.Create().WithStatusCode(204)); + + using var client = BuildClient(); + var outcome = await client.CancelOrderV2Async(family, OrderId, "Keyfactor test reason."); + + outcome.Should().Be(V2CancelOrderOutcome.Cancelled); + var entry = _server.LogEntries.Single(e => e.RequestMessage.Path == path); + entry.RequestMessage.Method.Should().Be("POST"); + using var body = JsonDocument.Parse(entry.RequestMessage.Body ?? "{}"); + body.RootElement.GetProperty("reason").GetString().Should().Be("Keyfactor test reason."); + entry.RequestMessage.Headers.Should().ContainKey("Idempotency-Key"); + } + + [Fact] + public async Task CancelOrderV2Async_422_ReturnsAlreadyTerminal_DoesNotThrow() + { + _server + .Given(Request.Create().WithPath($"{Constants.ApiV2.SslCertificatesPath}/{OrderId}/cancel").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(422) + .WithHeader("Content-Type", "application/problem+json") + .WithBody(MockCertificateData.V2ProblemDetailsJson( + 422, "Unprocessable Entity", "Order is already issued; use POST /{orderId}/revoke instead of /cancel."))); + + using var client = BuildClient(); + var outcome = await client.CancelOrderV2Async(Constants.ApiV2.FamilySsl, OrderId, "reason"); + + outcome.Should().Be(V2CancelOrderOutcome.AlreadyTerminal); + } + + [Fact] + public async Task CancelOrderV2Async_500_Throws() + { + _server + .Given(Request.Create().WithPath($"{Constants.ApiV2.SslCertificatesPath}/{OrderId}/cancel").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(500)); + + using var client = BuildClient(); + await Assert.ThrowsAsync( + () => client.CancelOrderV2Async(Constants.ApiV2.FamilySsl, OrderId, "reason")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task CancelOrderV2Async_BlankReason_ThrowsBeforeAnyHttpCall(string reason) + { + using var client = BuildClient(); + await Assert.ThrowsAsync( + () => client.CancelOrderV2Async(Constants.ApiV2.FamilySsl, OrderId, reason)); + _server.LogEntries.Should().BeEmpty(); + } + } +} diff --git a/CERTInext.Tests/V2PrivatePkiEnrollmentTests.cs b/CERTInext.Tests/V2PrivatePkiEnrollmentTests.cs new file mode 100644 index 0000000..c8d028d --- /dev/null +++ b/CERTInext.Tests/V2PrivatePkiEnrollmentTests.cs @@ -0,0 +1,601 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using Org.BouncyCastle.Asn1.Pkcs; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Regression tests for issue 0033: EnrollV2Async used to build the SSL/TLS create + /// body () for every product family. A + /// ProductFamily=private-pki template must now place a + /// (spec: variant, hostname, + /// additionalHosts; no organization / certificate / agreement block) through the + /// Private PKI client overload, with IP SANs carried into additionalHosts; a + /// ProductFamily=signature template must fail fast with no CA call (open design + /// decision). Driven end-to-end through against a Strict + /// mock, plus WireMock-backed + /// coverage. + /// + public class V2PrivatePkiEnrollmentTests + { + private const string OrderId = "ord_pki_001"; + private const string Hostname = "intranet.acme.local"; + + // Spec "Private PKI Certificates" field table — every key the create body may carry. + private static readonly HashSet SpecPrivatePkiTopLevelKeys = new HashSet + { + "variant", "caProfileId", "masterProductId", "saveAsDraft", "requestId", "emailNotifications", + "groupNumber", "requestor", "hostname", "additionalHosts", "subscription", "csr", "remarks", + "tags", "customFields", "technicalPointOfContact" + }; + + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + private static CERTInextConfig BaseConfig() => new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "DevOps Team", + RequestorEmail = "devops@acme.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "4155551234", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + PickupRetries = 0 + }; + + private static CERTInextCAPlugin BuildV2Plugin(ICERTInextClient client, CERTInextConfig config = null) => + new CERTInextCAPlugin(client, config ?? BaseConfig()); + + private static EnrollmentProductInfo MakePrivatePkiProductInfo( + string variant = "intranet-ssl", string productCode = "149", string domainName = Hostname) + { + var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductFamily"] = "private-pki" + }; + if (variant != null) parameters["ProductVariant"] = variant; + if (productCode != null) parameters["ProductCode"] = productCode; + if (domainName != null) parameters["DomainName"] = domainName; + + // GetProductIds() only advertises SSL/TLS product names, so a real private-pki + // template is necessarily attached to one of them. + return new EnrollmentProductInfo { ProductID = Constants.Products.DvSsl, ProductParameters = parameters }; + } + + private static JsonSerializerOptions ClientEquivalentJsonOptions() => new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + /// + /// Stubs the Private PKI placement + CSR submit + track (+ download when issued) and + /// returns an accessor for the captured create body. + /// + private static Func StubPrivatePkiOrder( + Mock mock, string trackStatus = "pending-approval") + { + V2CreatePrivatePkiOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, req, __) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = OrderId, Status = "pending-csr" }); + + mock.Setup(c => c.SubmitCsrV2Async( + Constants.ApiV2.FamilyPrivatePki, OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.Setup(c => c.TrackOrderV2Async(Constants.ApiV2.FamilyPrivatePki, OrderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = trackStatus }); + + if (trackStatus == "issued") + { + mock.Setup(c => c.DownloadCertificateV2Async(Constants.ApiV2.FamilyPrivatePki, OrderId, It.IsAny())) + .ReturnsAsync(new V2CertificateDownloadResponse + { + OrderId = OrderId, + SerialNumber = "0A1B2C", + CertificatePem = MockCertificateData.FakePemCertificate + }); + } + + return () => captured; + } + + private static Task EnrollAsync( + CERTInextCAPlugin plugin, + EnrollmentProductInfo productInfo, + Dictionary san, + string csr = null) => + plugin.Enroll( + csr: csr ?? MockCertificateData.FakeCsrPem, + subject: $"CN={Hostname}", + san: san, + productInfo: productInfo, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + // BouncyCastle only (project crypto policy). DNS and IP SANs in the extensionRequest. + private static string GenerateCsrPem(string cn, string[] dnsSans, string[] ipSans) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + AsymmetricCipherKeyPair kp = keyGen.GenerateKeyPair(); + + var names = (dnsSans ?? Array.Empty()).Select(d => new GeneralName(GeneralName.DnsName, d)) + .Concat((ipSans ?? Array.Empty()).Select(ip => new GeneralName(GeneralName.IPAddress, ip))) + .ToArray(); + + Org.BouncyCastle.Asn1.Asn1Set attributes = null; + if (names.Length > 0) + { + var extGen = new X509ExtensionsGenerator(); + extGen.AddExtension(X509Extensions.SubjectAlternativeName, critical: false, + extValue: new GeneralNames(names)); + attributes = new Org.BouncyCastle.Asn1.DerSet(new AttributePkcs( + PkcsObjectIdentifiers.Pkcs9AtExtensionRequest, + new Org.BouncyCastle.Asn1.DerSet(extGen.Generate()))); + } + + var csr = new Pkcs10CertificationRequest( + "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, attributes, kp.Private); + + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + // --------------------------------------------------------------------------- + // Body shape + routing + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_V2_PrivatePki_PlacesPrivatePkiBody_ThroughPrivatePkiOverload_NotTheSslBody() + { + var mock = NewMock(); + var captured = StubPrivatePkiOrder(mock, trackStatus: "issued"); + + var config = BaseConfig(); + config.GroupNumber = "GRP-9"; + config.EmailNotifications = "1"; + config.RequestorDesignation = "Platform Engineering"; + config.SubscriptionAutoRenew = "1"; + config.SubscriptionRenewCriteriaDays = "20"; + // OV/EV-only and agreement-only settings must have no effect on a Private PKI body. + config.OrganizationNumber = "ORG-001"; + config.AutoSecureWww = "1"; + + var result = await EnrollAsync(BuildV2Plugin(mock.Object, config), MakePrivatePkiProductInfo(), + new Dictionary { ["dnsname"] = new[] { Hostname, "portal.acme.local" } }); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + result.CARequestID.Should().Be(OrderId); + + var req = captured(); + req.Should().NotBeNull("a private-pki enrollment must use the Private PKI PlaceOrderV2Async overload"); + req.Variant.Should().Be("intranet-ssl"); + req.Hostname.Should().Be(Hostname); + req.AdditionalHosts.Should().Equal("portal.acme.local"); + req.EmailNotifications.Should().Be("all"); + req.GroupNumber.Should().Be("GRP-9"); + req.Requestor.Name.Should().Be("DevOps Team"); + req.Requestor.Email.Should().Be("devops@acme.com"); + req.Requestor.Phone.Should().Be("+14155551234"); + req.Requestor.Designation.Should().Be("Platform Engineering"); + req.Subscription.ValidityYears.Should().Be(1); + req.Subscription.AutoRenew.Should().BeTrue(); + req.Subscription.RenewBeforeDays.Should().Be(20); + req.TechnicalPointOfContact.Name.Should().Be("DevOps Team", "blank TechnicalContact* falls back to Requestor*, same as SSL"); + req.TechnicalPointOfContact.Designation.Should().Be(Constants.ApiV2.DefaultTechnicalContactDesignation); + + // The SSL overload and the catalog lookup (SSL UCC detection only) are never touched. + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + mock.Verify(c => c.GetProductDetailsV2Async(It.IsAny()), Times.Never); + mock.Verify(c => c.PlaceOrderV2Async("149", It.IsAny(), It.IsAny()), + Times.Once, "the explicit ProductCode is sent as X-Product-Code as-is"); + + // CSR submit / track / download all hit the private-pki family. + mock.Verify(c => c.SubmitCsrV2Async(Constants.ApiV2.FamilyPrivatePki, OrderId, It.IsAny(), It.IsAny()), Times.Once); + mock.Verify(c => c.DownloadCertificateV2Async(Constants.ApiV2.FamilyPrivatePki, OrderId, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Enroll_V2_PrivatePki_SerializedBody_UsesOnlySpecFieldNames_AndNoSslBlocks() + { + var mock = NewMock(); + var captured = StubPrivatePkiOrder(mock); + var config = BaseConfig(); + config.GroupNumber = "GRP-9"; + config.EmailNotifications = "1"; + config.RequestorDesignation = "Platform Engineering"; + + await EnrollAsync(BuildV2Plugin(mock.Object, config), MakePrivatePkiProductInfo(), + new Dictionary + { + ["dnsname"] = new[] { Hostname, "portal.acme.local" }, + ["ipaddress"] = new[] { "10.0.0.50" } + }); + + string json = JsonSerializer.Serialize(captured(), ClientEquivalentJsonOptions()); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var keys = root.EnumerateObject().Select(p => p.Name).ToList(); + + keys.Should().BeEquivalentTo(new[] + { + "variant", "emailNotifications", "groupNumber", "requestor", "hostname", "additionalHosts", + "subscription", "remarks", "technicalPointOfContact" + }); + keys.Should().OnlyContain(k => SpecPrivatePkiTopLevelKeys.Contains(k), + "every key must come from the spec's Private PKI field table"); + keys.Should().NotContain(new[] { "productVariant", "certificate", "organization", "agreement", "domain" }, + "Private PKI has no DCV, no organization block, and no Subscriber Agreement (spec)"); + + root.GetProperty("variant").GetString().Should().Be("intranet-ssl"); + root.GetProperty("hostname").GetString().Should().Be(Hostname); + root.GetProperty("additionalHosts").EnumerateArray().Select(e => e.GetString()) + .Should().Equal("portal.acme.local", "10.0.0.50"); + root.GetProperty("requestor").EnumerateObject().Select(p => p.Name) + .Should().BeEquivalentTo(new[] { "name", "email", "phone", "designation" }); + root.GetProperty("technicalPointOfContact").EnumerateObject().Select(p => p.Name) + .Should().BeEquivalentTo(new[] { "name", "email", "phone", "designation" }); + root.GetProperty("subscription").GetProperty("validityYears").GetInt32().Should().Be(1); + } + + [Fact] + public async Task Enroll_V2_PrivatePki_VariantMatchIsCaseInsensitive_AndSentInSpecCase() + { + var mock = NewMock(); + var captured = StubPrivatePkiOrder(mock); + + await EnrollAsync(BuildV2Plugin(mock.Object), MakePrivatePkiProductInfo(variant: " IGTF-Host "), + new Dictionary { ["dnsname"] = new[] { Hostname } }); + + captured().Variant.Should().Be(Constants.ApiV2.PrivatePkiVariantIgtfHost); + } + + [Fact] + public async Task Enroll_V2_PrivatePki_HostnameFallsBackToSubjectCn_WhenDomainNameUnset() + { + var mock = NewMock(); + var captured = StubPrivatePkiOrder(mock); + + await EnrollAsync(BuildV2Plugin(mock.Object), MakePrivatePkiProductInfo(domainName: null), + new Dictionary { ["dnsname"] = new[] { Hostname } }); + + captured().Hostname.Should().Be(Hostname, "spec: \"hostname - primary CN\""); + } + + [Fact] + public async Task Enroll_V2_PrivatePki_PendingApproval_ReturnsPendingWithOrderId() + { + var mock = NewMock(); + StubPrivatePkiOrder(mock, trackStatus: "pending-approval"); + + var result = await EnrollAsync(BuildV2Plugin(mock.Object), MakePrivatePkiProductInfo(), + new Dictionary { ["dnsname"] = new[] { Hostname } }); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + result.CARequestID.Should().Be(OrderId, "the order was placed; sync must be able to find it"); + } + + // --------------------------------------------------------------------------- + // SAN mapping -> additionalHosts + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_V2_PrivatePki_AdditionalHosts_CarriesDnsAndIpSans_ExcludesPrimaryDuplicatesAndNonHostTypes() + { + var mock = NewMock(); + var captured = StubPrivatePkiOrder(mock); + + await EnrollAsync(BuildV2Plugin(mock.Object), MakePrivatePkiProductInfo(), + new Dictionary + { + ["dnsname"] = new[] { Hostname, "portal.acme.local", "PORTAL.acme.local" }, + ["ipaddress"] = new[] { "10.0.0.50", "fd00::50" }, + ["rfc822name"] = new[] { "ops@acme.com" }, + ["uri"] = new[] { "https://intranet.acme.local/" } + }); + + captured().AdditionalHosts.Should().Equal( + new[] { "portal.acme.local", "10.0.0.50", "fd00::50" }, + "additionalHosts is a 'SAN list (DNS names or IPv4 / IPv6)' per the spec: IPs are kept, the " + + "primary hostname is not repeated, duplicates collapse, and email/URI SANs are excluded"); + } + + [Fact] + public async Task Enroll_V2_PrivatePki_CsrFallback_CarriesIpSansFromCsr_WhenGatewaySanDictionaryIsNull() + { + var mock = NewMock(); + var captured = StubPrivatePkiOrder(mock); + string csr = GenerateCsrPem(Hostname, + dnsSans: new[] { Hostname, "reports.acme.local" }, + ipSans: new[] { "10.0.0.60" }); + + await EnrollAsync(BuildV2Plugin(mock.Object), MakePrivatePkiProductInfo(), san: null, csr: csr); + + captured().AdditionalHosts.Should().Equal( + new[] { "reports.acme.local", "10.0.0.60" }, + "with no gateway SAN dictionary the CSR's SANs are used, IP SAN rendered as text"); + } + + [Fact] + public async Task Enroll_V2_PrivatePki_MultiSanCsr_IsNotRejectedBySslSingleDomainGuard() + { + // The same CSR on a non-UCC SSL product is rejected before any order is placed (issue + // f3/0047 guard). Private PKI's additionalHosts is multi-entry for every variant. + var mock = NewMock(); + var captured = StubPrivatePkiOrder(mock); + string csr = GenerateCsrPem(Hostname, + dnsSans: new[] { Hostname, "portal.acme.local", "reports.acme.local" }, + ipSans: null); + + var result = await EnrollAsync(BuildV2Plugin(mock.Object), MakePrivatePkiProductInfo(), + new Dictionary { ["dnsname"] = new[] { Hostname, "portal.acme.local", "reports.acme.local" } }, + csr); + + result.Status.Should().NotBe((int)EndEntityStatus.FAILED); + captured().AdditionalHosts.Should().Equal("portal.acme.local", "reports.acme.local"); + } + + [Fact] + public async Task Enroll_V2_PrivatePki_SubmitNonDnsSansFalse_StillSubmitsIpSans() + { + var mock = NewMock(); + var captured = StubPrivatePkiOrder(mock); + var config = BaseConfig(); + config.SubmitNonDnsSans = false; + + await EnrollAsync(BuildV2Plugin(mock.Object, config), MakePrivatePkiProductInfo(), + new Dictionary + { + ["dnsname"] = new[] { Hostname }, + ["ipaddress"] = new[] { "10.0.0.50" } + }); + + captured().AdditionalHosts.Should().Equal(new[] { "10.0.0.50" }, + "IP literals are native to additionalHosts, so the V1-era SubmitNonDnsSans switch is not consulted"); + } + + [Fact] + public async Task Enroll_V2_PrivatePki_NoAdditionalSans_OmitsAdditionalHostsFromWireBody() + { + var mock = NewMock(); + var captured = StubPrivatePkiOrder(mock); + + await EnrollAsync(BuildV2Plugin(mock.Object), MakePrivatePkiProductInfo(), + new Dictionary { ["dnsname"] = new[] { Hostname } }); + + captured().AdditionalHosts.Should().BeNull(); + JsonSerializer.Serialize(captured(), ClientEquivalentJsonOptions()) + .Should().NotContain("additionalHosts"); + } + + // --------------------------------------------------------------------------- + // Fail-fast validation (no CA call of any kind) + // --------------------------------------------------------------------------- + + [Theory] + [InlineData(null)] // not set -> SSL-only "dv" default + [InlineData("dv")] + [InlineData("ov")] + [InlineData("igtf-personal")] // appears only in the spec's create-*response* echo enum + public async Task Enroll_V2_PrivatePki_InvalidOrMissingVariant_FailsFastWithoutAnyCaCall(string variant) + { + var mock = NewMock(); + + var result = await EnrollAsync(BuildV2Plugin(mock.Object), MakePrivatePkiProductInfo(variant: variant), + new Dictionary { ["dnsname"] = new[] { Hostname } }); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.CARequestID.Should().BeEmpty(); + result.StatusMessage.Should().Contain("ProductVariant") + .And.Contain("intranet-ssl").And.Contain("igtf-host"); + mock.Invocations.Should().BeEmpty("validation must happen before any CA call"); + } + + [Fact] + public async Task Enroll_V2_PrivatePki_NoExplicitProductCode_FailsFastWithoutAnyCaCall() + { + // Without an override, the SSL ProductTypeIdsV2 table would resolve the attached SSL + // product (DV SSL) — i.e. send an SSL product code to the Private PKI endpoint. + var mock = NewMock(); + + var result = await EnrollAsync(BuildV2Plugin(mock.Object), MakePrivatePkiProductInfo(productCode: null), + new Dictionary { ["dnsname"] = new[] { Hostname } }); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.StatusMessage.Should().Contain("ProductCode"); + mock.Invocations.Should().BeEmpty("validation must happen before any CA call"); + } + + // --------------------------------------------------------------------------- + // signature (Document Signer): open design decision -> fail fast + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_V2_Signature_FailsFastWithoutAnyCaCall_InsteadOfSendingTheSslBody() + { + var mock = NewMock(); + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.DvSsl, + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductFamily"] = "signature", + ["ProductCode"] = "819" + } + }; + + var result = await BuildV2Plugin(mock.Object).Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=Sarah Johnson", + san: new Dictionary(), + productInfo: productInfo, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.CARequestID.Should().BeEmpty(); + result.StatusMessage.Should().Contain("signature").And.Contain("not yet supported"); + mock.Invocations.Should().BeEmpty( + "pre-0033 this sent the SSL body to /signature-certificates; now nothing is sent at all"); + } + + // --------------------------------------------------------------------------- + // ValidateProductInfo (builds its own CERTInextClient -> WireMock) + // --------------------------------------------------------------------------- + + private static void StubToken(WireMockServer server) => + server.Given(Request.Create().WithPath("/oauth/token").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2TokenResponseJson())); + + private static void StubCatalog(WireMockServer server, string productCode, string productTypeId) => + server.Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody($"[{{\"productCode\":\"{productCode}\",\"productName\":\"Test Product\",\"productTypeID\":\"{productTypeId}\"}}]")); + + private static Dictionary V2ConnectionInfo(string apiUrl) => new Dictionary + { + ["UseV2Api"] = true, + ["ApiUrl"] = apiUrl, + ["OAuthClientId"] = "my-client", + ["OAuthClientSecret"] = "my-secret" + }; + + [Fact] + public async Task ValidateProductInfo_V2_PrivatePki_Succeeds_WhenExplicitCodeIsAPrivatePkiProduct() + { + // Regression: pre-0033 this threw "does not correspond to the selected product", + // because the SSL ProductId (DV SSL -> productTypeID 13) was cross-checked against a + // Private PKI code (productTypeID 39), so no private-pki template could ever be saved. + using var server = WireMockServer.Start(); + StubToken(server); + StubCatalog(server, "149", Constants.ApiV2.PrivatePkiProductTypeId); + + Func act = () => BuildV2Plugin(NewMock().Object) + .ValidateProductInfo(MakePrivatePkiProductInfo(), V2ConnectionInfo(server.Urls[0])); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task ValidateProductInfo_V2_PrivatePki_Throws_WhenExplicitCodeIsNotAPrivatePkiProduct() + { + using var server = WireMockServer.Start(); + StubToken(server); + StubCatalog(server, "842", "13"); // DV SSL + + Func act = () => BuildV2Plugin(NewMock().Object) + .ValidateProductInfo(MakePrivatePkiProductInfo(productCode: "842"), V2ConnectionInfo(server.Urls[0])); + + await act.Should().ThrowAsync().WithMessage("*not a Private PKI product*"); + } + + [Fact] + public async Task ValidateProductInfo_V2_PrivatePki_Throws_WhenCodeNotInCatalog() + { + using var server = WireMockServer.Start(); + StubToken(server); + StubCatalog(server, "100", Constants.ApiV2.PrivatePkiProductTypeId); + + Func act = () => BuildV2Plugin(NewMock().Object) + .ValidateProductInfo(MakePrivatePkiProductInfo(productCode: "149"), V2ConnectionInfo(server.Urls[0])); + + await act.Should().ThrowAsync().WithMessage("*not found*"); + } + + [Theory] + [InlineData(null, "149", "ProductVariant")] + [InlineData("dv", "149", "ProductVariant")] + [InlineData("intranet-ssl", null, "ProductCode")] + public async Task ValidateProductInfo_V2_PrivatePki_RejectsBadTemplateParams_BeforeAnyCatalogCall( + string variant, string productCode, string expectedField) + { + using var server = WireMockServer.Start(); + StubToken(server); + StubCatalog(server, "149", Constants.ApiV2.PrivatePkiProductTypeId); + + Func act = () => BuildV2Plugin(NewMock().Object) + .ValidateProductInfo(MakePrivatePkiProductInfo(variant: variant, productCode: productCode), + V2ConnectionInfo(server.Urls[0])); + + await act.Should().ThrowAsync().WithMessage($"*{expectedField}*"); + server.LogEntries.Should().NotContain(e => e.RequestMessage.Path == "/api/certinext/v2/catalog/products"); + } + + // --------------------------------------------------------------------------- + // Shared validation helper + // --------------------------------------------------------------------------- + + [Theory] + [InlineData("intranet-ssl", "149", "intranet-ssl")] + [InlineData("IGTF-HOST", "149", "igtf-host")] + public void ValidatePrivatePkiEnrollmentParams_ValidParams_ReturnsNull_AndNormalizesVariant( + string variant, string productCode, string expectedVariant) + { + var ep = new Models.EnrollmentParams(MakePrivatePkiProductInfo(variant: variant, productCode: productCode)); + + CERTInextCAPlugin.ValidatePrivatePkiEnrollmentParams(ep, out string normalized).Should().BeNull(); + normalized.Should().Be(expectedVariant); + } + + [Fact] + public void ValidatePrivatePkiEnrollmentParams_UnsetVariant_SaysNotSetRatherThanEchoingTheSslDefault() + { + var ep = new Models.EnrollmentParams(MakePrivatePkiProductInfo(variant: null)); + + string error = CERTInextCAPlugin.ValidatePrivatePkiEnrollmentParams(ep, out string normalized); + + error.Should().Contain("not set"); + normalized.Should().BeNull(); + } + } +} diff --git a/CERTInext.Tests/V2ProductVariantDerivationTests.cs b/CERTInext.Tests/V2ProductVariantDerivationTests.cs new file mode 100644 index 0000000..096712e --- /dev/null +++ b/CERTInext.Tests/V2ProductVariantDerivationTests.cs @@ -0,0 +1,451 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Regression tests for issues/0059-v2-productvariant-not-derived-from-product.md: the V2 + /// SSL create body's productVariant defaulted to "dv" regardless of the selected + /// product, so an OV/EV template with no explicit ProductVariant enrollment parameter + /// sent a DV-shaped body — skipping the mandatory OV/EV organization block (issue 0028) + /// — while an explicit-but-contradictory override (e.g. "dv" configured for "OV SSL") passed + /// through unchecked. Covers directly, + /// end-to-end through , and through + /// (template-save-time rejection). + /// + public class V2ProductVariantDerivationTests + { + // --------------------------------------------------------------------------- + // ResolveSslProductVariant — pure unit tests, no CA calls + // --------------------------------------------------------------------------- + + [Theory] + [InlineData(Constants.Products.DvSsl, "dv")] + [InlineData(Constants.Products.DvSslWildcard, "dv")] + [InlineData(Constants.Products.OvSsl, "ov")] + [InlineData(Constants.Products.OvSslWildcard, "ov")] + [InlineData(Constants.Products.EvSsl, "ev")] + [InlineData(Constants.Products.EvSslUcc, "ev")] + public void ResolveSslProductVariant_NoExplicitVariant_DerivesFromProduct(string productId, string expectedVariant) + { + var ep = new Models.EnrollmentParams(new EnrollmentProductInfo + { + ProductID = productId, + ProductParameters = new Dictionary() + }); + + string error = CERTInextCAPlugin.ResolveSslProductVariant(ep, out string resolved); + + error.Should().BeNull(); + resolved.Should().Be(expectedVariant); + } + + [Theory] + [InlineData(Constants.Products.OvSsl, "OV", "ov")] + [InlineData(Constants.Products.EvSsl, "Ev", "ev")] + [InlineData(Constants.Products.DvSsl, "DV", "dv")] + public void ResolveSslProductVariant_ExplicitVariant_AgreesWithProduct_ReturnsItLowercased( + string productId, string explicitVariant, string expectedResolved) + { + var ep = new Models.EnrollmentParams(new EnrollmentProductInfo + { + ProductID = productId, + ProductParameters = new Dictionary { ["ProductVariant"] = explicitVariant } + }); + + string error = CERTInextCAPlugin.ResolveSslProductVariant(ep, out string resolved); + + error.Should().BeNull(); + resolved.Should().Be(expectedResolved); + } + + [Fact] + public void ResolveSslProductVariant_ExplicitVariant_ContradictsProduct_ReturnsActionableError() + { + // The exact bug scenario from issue 0059: an OV product with the SSL-only "dv" + // default explicitly configured. + var ep = new Models.EnrollmentParams(new EnrollmentProductInfo + { + ProductID = Constants.Products.OvSsl, + ProductParameters = new Dictionary { ["ProductVariant"] = "dv" } + }); + + string error = CERTInextCAPlugin.ResolveSslProductVariant(ep, out string resolved); + + resolved.Should().BeNull(); + error.Should().NotBeNull(); + error.Should().Contain(Constants.EnrollmentParam.ProductVariant) + .And.Contain("'dv'") + .And.Contain(Constants.Products.OvSsl) + .And.Contain("'ov'"); + } + + [Fact] + public void ResolveSslProductVariant_ExplicitVariant_ContradictsProduct_EvVsDv() + { + var ep = new Models.EnrollmentParams(new EnrollmentProductInfo + { + ProductID = Constants.Products.DvSsl, + ProductParameters = new Dictionary { ["ProductVariant"] = "ev" } + }); + + string error = CERTInextCAPlugin.ResolveSslProductVariant(ep, out string resolved); + + resolved.Should().BeNull(); + error.Should().Contain("'ev'").And.Contain(Constants.Products.DvSsl).And.Contain("'dv'"); + } + + [Fact] + public void ResolveSslProductVariant_UnmappedProductId_NoExplicitVariant_KeepsCurrentSslDefault() + { + // No entry in Constants.Products.ProductVariantsV2 for this ProductId (none of the 10 + // real SSL products are named this) — issue 0059 says: don't invent a mapping, keep + // pre-fix behavior (the SSL-only "dv" default) rather than guessing. + var ep = new Models.EnrollmentParams(new EnrollmentProductInfo + { + ProductID = "Some Unmapped Product", + ProductParameters = new Dictionary() + }); + + string error = CERTInextCAPlugin.ResolveSslProductVariant(ep, out string resolved); + + error.Should().BeNull(); + resolved.Should().Be("dv"); + } + + [Fact] + public void ResolveSslProductVariant_UnmappedProductId_ExplicitVariant_NotCrossChecked() + { + // Same "don't invent a mapping" rule applied to the explicit-override path: with no + // authoritative product->variant mapping, an explicit override is passed through + // rather than rejected against a guess. + var ep = new Models.EnrollmentParams(new EnrollmentProductInfo + { + ProductID = "Some Unmapped Product", + ProductParameters = new Dictionary { ["ProductVariant"] = "ev" } + }); + + string error = CERTInextCAPlugin.ResolveSslProductVariant(ep, out string resolved); + + error.Should().BeNull(); + resolved.Should().Be("ev"); + } + + // --------------------------------------------------------------------------- + // Enroll_V2 — end-to-end through CERTInextCAPlugin.Enroll + // --------------------------------------------------------------------------- + + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + private static CERTInextCAPlugin BuildV2Plugin(ICERTInextClient client, string organizationNumber = null) => + new CERTInextCAPlugin(client, new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + OrganizationNumber = organizationNumber, + PickupRetries = 0 + }); + + private static EnrollmentProductInfo MakeProductInfo(string productId, string productVariant = null) => + new EnrollmentProductInfo + { + ProductID = productId, + ProductParameters = productVariant == null + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductFamily"] = "ssl", + ["DomainName"] = "example.com" + } + : new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductFamily"] = "ssl", + ["ProductVariant"] = productVariant, + ["DomainName"] = "example.com" + } + }; + + private static void StubCatalog(Mock mock, string productCode, string productTypeId) => + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = productCode, ProductTypeId = productTypeId, Active = true } + }); + + private static void StubHappyOrderPlacement(Mock mock, string orderId, string status = "pending-dcv") + { + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), orderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), orderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = orderId, Status = status }); + } + + [Fact] + public async Task Enroll_V2_OvProduct_NoExplicitProductVariant_SendsOvAndOrganizationBlock() + { + var mock = NewMock(); + StubCatalog(mock, "846", "16"); // OV SSL, non-UCC + StubHappyOrderPlacement(mock, "ord_0059_ov", "pending-organization-verification"); + + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_0059_ov", Status = "pending-organization-verification" }); + + var plugin = BuildV2Plugin(mock.Object, organizationNumber: "ORG-0059"); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=example.com", + san: new Dictionary(), + productInfo: MakeProductInfo(Constants.Products.OvSsl), // no ProductVariant set + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.CARequestID.Should().Be("ord_0059_ov"); + captured.Should().NotBeNull(); + captured!.ProductVariant.Should().Be("ov", + "with no explicit ProductVariant, the wire value must be derived from ProductID 'OV SSL'"); + captured.Organization.Should().NotBeNull( + "deriving 'ov' must engage the same OV/EV organization-block requirement as an explicit 'ov'"); + captured.Organization.OrganizationNumber.Should().Be("ORG-0059"); + } + + [Fact] + public async Task Enroll_V2_EvProduct_NoExplicitProductVariant_SendsEvAndOrganizationBlock() + { + var mock = NewMock(); + StubCatalog(mock, "847", "19"); // EV SSL, non-UCC + StubHappyOrderPlacement(mock, "ord_0059_ev", "pending-organization-verification"); + + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_0059_ev", Status = "pending-organization-verification" }); + + var plugin = BuildV2Plugin(mock.Object, organizationNumber: "ORG-0059"); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=example.com", + san: new Dictionary(), + productInfo: MakeProductInfo(Constants.Products.EvSsl), // no ProductVariant set + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.CARequestID.Should().Be("ord_0059_ev"); + captured!.ProductVariant.Should().Be("ev"); + captured.Organization.Should().NotBeNull(); + } + + [Fact] + public async Task Enroll_V2_DvProduct_NoExplicitProductVariant_StaysDv_NoOrganizationBlock() + { + // Regression guard: the DV default path (the overwhelming majority of existing + // templates) must be completely unaffected by 0059. + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // DV SSL, non-UCC + StubHappyOrderPlacement(mock, "ord_0059_dv"); + + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_0059_dv", Status = "pending-dcv" }); + + // Deliberately no OrganizationNumber configured — a DV order must not need it. + var plugin = BuildV2Plugin(mock.Object, organizationNumber: null); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=example.com", + san: new Dictionary(), + productInfo: MakeProductInfo(Constants.Products.DvSsl), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.CARequestID.Should().Be("ord_0059_dv"); + captured!.ProductVariant.Should().Be("dv"); + captured.Organization.Should().BeNull(); + } + + [Theory] + [InlineData(Constants.Products.OvSsl, "dv")] + [InlineData(Constants.Products.DvSsl, "ov")] + [InlineData(Constants.Products.EvSsl, "dv")] + public async Task Enroll_V2_ExplicitProductVariantContradictsProduct_FailsFastWithoutAnyCaCall( + string productId, string contradictingVariant) + { + // Strict mock with NOTHING stubbed: the mismatch must be rejected before any CA call + // at all (no catalog lookup, no order placement) — mirrors the private-pki variant + // guard's own Strict-mock test. + var mock = NewMock(); + var plugin = BuildV2Plugin(mock.Object, organizationNumber: "ORG-0059"); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=example.com", + san: new Dictionary(), + productInfo: MakeProductInfo(productId, contradictingVariant), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.CARequestID.Should().BeEmpty(); + result.StatusMessage.Should().Contain(Constants.EnrollmentParam.ProductVariant); + mock.Invocations.Should().BeEmpty("the mismatch must be rejected before any CA call"); + } + + // --------------------------------------------------------------------------- + // ValidateProductInfo — rejected at template save, before any catalog call + // --------------------------------------------------------------------------- + + private static void StubToken(WireMockServer server) => + server.Given(Request.Create().WithPath("/oauth/token").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2TokenResponseJson())); + + private static void StubCatalogServer(WireMockServer server, string productCode, string productTypeId) => + server.Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody($"[{{\"productCode\":\"{productCode}\",\"productName\":\"Test Product\",\"productTypeID\":\"{productTypeId}\"}}]")); + + private static Dictionary V2ConnectionInfo(string apiUrl) => new Dictionary + { + ["UseV2Api"] = true, + ["ApiUrl"] = apiUrl, + ["OAuthClientId"] = "my-client", + ["OAuthClientSecret"] = "my-secret" + }; + + [Fact] + public async Task ValidateProductInfo_V2_Ssl_NoExplicitProductVariant_Succeeds() + { + using var server = WireMockServer.Start(); + StubToken(server); + StubCatalogServer(server, "846", "16"); // OV SSL + + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.OvSsl, + ProductParameters = new Dictionary() + }; + + Func act = () => BuildV2Plugin(NewMock().Object) + .ValidateProductInfo(productInfo, V2ConnectionInfo(server.Urls[0])); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task ValidateProductInfo_V2_Ssl_ExplicitProductVariantMatchesProduct_Succeeds() + { + using var server = WireMockServer.Start(); + StubToken(server); + StubCatalogServer(server, "846", "16"); // OV SSL + + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.OvSsl, + ProductParameters = new Dictionary { ["ProductVariant"] = "ov" } + }; + + Func act = () => BuildV2Plugin(NewMock().Object) + .ValidateProductInfo(productInfo, V2ConnectionInfo(server.Urls[0])); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task ValidateProductInfo_V2_Ssl_ExplicitProductVariantContradictsProduct_ThrowsBeforeAnyCatalogCall() + { + using var server = WireMockServer.Start(); + StubToken(server); + StubCatalogServer(server, "846", "16"); // OV SSL — never reached + + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.OvSsl, + ProductParameters = new Dictionary { ["ProductVariant"] = "dv" } + }; + + Func act = () => BuildV2Plugin(NewMock().Object) + .ValidateProductInfo(productInfo, V2ConnectionInfo(server.Urls[0])); + + await act.Should().ThrowAsync() + .WithMessage($"*{Constants.EnrollmentParam.ProductVariant}*"); + server.LogEntries.Should().NotContain(e => e.RequestMessage.Path == "/api/certinext/v2/catalog/products", + "the ProductVariant/ProductId mismatch must be rejected before any catalog call"); + } + + [Fact] + public async Task ValidateProductInfo_V2_PrivatePki_NotAffectedByThisSslCheck() + { + // Sanity: a private-pki template's own ProductVariant (intranet-ssl/igtf-host) must + // never be run through the SSL cross-check this issue adds — it's checked by + // ValidatePrivatePkiEnrollmentParams instead, unaffected here. + using var server = WireMockServer.Start(); + StubToken(server); + StubCatalogServer(server, "149", Constants.ApiV2.PrivatePkiProductTypeId); + + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.DvSsl, + ProductParameters = new Dictionary + { + ["ProductFamily"] = "private-pki", + ["ProductVariant"] = "intranet-ssl", + ["ProductCode"] = "149" + } + }; + + Func act = () => BuildV2Plugin(NewMock().Object) + .ValidateProductInfo(productInfo, V2ConnectionInfo(server.Urls[0])); + + await act.Should().NotThrowAsync(); + } + } +} diff --git a/CERTInext.Tests/V2SignerPlaceRequiredTests.cs b/CERTInext.Tests/V2SignerPlaceRequiredTests.cs new file mode 100644 index 0000000..e69fe4a --- /dev/null +++ b/CERTInext.Tests/V2SignerPlaceRequiredTests.cs @@ -0,0 +1,272 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Regression tests for issue 0039 (signerPlace): the V2 spec marks SSL create + /// agreement.signerPlace "Conditional - required if `agreement` sent", and the plugin + /// always sends agreement on V2 SSL orders but used to drop a blank signerPlace. + /// V2 connectors now require SignerPlace in + /// (before any network call), and EnrollV2Async fails fast for an SSL order whose + /// resolved signer place is blank. V1 and V2 Private PKI (no agreement) are unaffected. + /// + public class V2SignerPlaceRequiredTests + { + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + private static Dictionary V2ConnectionInfo(string apiUrl, object signerPlace) + { + var info = new Dictionary + { + ["UseV2Api"] = true, + ["ApiUrl"] = apiUrl, + ["OAuthClientId"] = "my-client", + ["OAuthClientSecret"] = "my-secret" + }; + if (signerPlace != null) info["SignerPlace"] = signerPlace; + return info; + } + + private static CERTInextConfig V2Config(string signerPlace) => new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + SignerIp = "1.2.3.4", + SignerPlace = signerPlace, + PickupRetries = 0 + }; + + private static EnrollmentProductInfo SslProductInfo(string templateSignerPlace = null) + { + var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = "842", + ["ProductFamily"] = "ssl", + ["ProductVariant"] = "dv", + ["DomainName"] = "example.com" + }; + if (templateSignerPlace != null) parameters["SignerPlace"] = templateSignerPlace; + return new EnrollmentProductInfo { ProductID = Constants.Products.DvSsl, ProductParameters = parameters }; + } + + // --------------------------------------------------------------------------- + // ValidateCAConnectionInfo + // --------------------------------------------------------------------------- + + [Theory] + [InlineData(null)] // key absent + [InlineData("")] + [InlineData(" ")] + public async Task ValidateCAConnectionInfo_V2_BlankSignerPlace_RejectedWithoutAnyHttpCall(string signerPlace) + { + // ApiUrl points at a live WireMock server with no stubs: if validation reached the + // connectivity test, the server would record the token request. + using var server = WireMockServer.Start(); + var plugin = new CERTInextCAPlugin(NewMock().Object, V2Config("New York")); + + Func act = () => plugin.ValidateCAConnectionInfo(V2ConnectionInfo(server.Urls[0], signerPlace)); + + var ex = await act.Should().ThrowAsync(); + ex.Which.Message.Should().Contain("'SignerPlace' is required when UseV2Api is true") + .And.Contain("Subscriber Agreement"); + server.LogEntries.Should().BeEmpty("the SignerPlace check must run before any network call"); + } + + [Fact] + public async Task ValidateCAConnectionInfo_V2_SignerPlaceSet_Passes() + { + using var server = WireMockServer.Start(); + server + .Given(Request.Create().WithPath("/oauth/token").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2TokenResponseJson())); + server + .Given(Request.Create().WithPath("/api/certinext/v2/auth/me").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.V2AuthMeJson())); + + var plugin = new CERTInextCAPlugin(NewMock().Object, V2Config("New York")); + + Func act = () => plugin.ValidateCAConnectionInfo(V2ConnectionInfo(server.Urls[0], "San Francisco, CA")); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task ValidateCAConnectionInfo_V1_BlankSignerPlace_NotReportedAsError() + { + // V1 config that fails for an unrelated reason (no AccountNumber) with SignerPlace + // blank: the aggregated error list must not name SignerPlace — V1 is unchanged. + var plugin = new CERTInextCAPlugin(NewMock().Object, V2Config("New York")); + var info = new Dictionary + { + ["ApiUrl"] = "https://v1.certinext.io/emSignHub-API/", + ["UseV2Api"] = false, + ["SignerPlace"] = "" + }; + + Func act = () => plugin.ValidateCAConnectionInfo(info); + + var ex = await act.Should().ThrowAsync(); + ex.Which.Message.Should().Contain("AccountNumber").And.NotContain("SignerPlace"); + } + + [Fact] + public async Task ValidateCAConnectionInfo_V1_BlankSignerPlace_Passes() + { + using var server = WireMockServer.Start(); + server + .Given(Request.Create().WithPath("/ValidateCredentials").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody("{\"meta\":{\"status\":\"1\"}}")); + + var plugin = new CERTInextCAPlugin(NewMock().Object, V2Config("New York")); + var info = new Dictionary + { + ["ApiUrl"] = server.Urls[0] + "/", + ["UseV2Api"] = false, + ["AccountNumber"] = "12345", + ["AuthMode"] = "AccessKey", + ["ApiKey"] = "v1-key", + ["SignerPlace"] = "" + }; + + Func act = () => plugin.ValidateCAConnectionInfo(info); + + await act.Should().NotThrowAsync("V1 does not require SignerPlace"); + } + + // --------------------------------------------------------------------------- + // EnrollV2Async defence in depth + // --------------------------------------------------------------------------- + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task Enroll_V2Ssl_BlankResolvedSignerPlace_FailsFastWithNoCaCall(string connectorSignerPlace) + { + // Strict mock with no setups: any client call (catalog, PlaceOrder, ...) throws. + var mock = NewMock(); + var plugin = new CERTInextCAPlugin(mock.Object, V2Config(connectorSignerPlace)); + + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, "CN=example.com", new Dictionary(), + SslProductInfo(), RequestFormat.PKCS10, EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.CARequestID.Should().BeEmpty(); + result.StatusMessage.Should().Contain("requires a signer place") + .And.Contain("No order was placed"); + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + mock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task Enroll_V2Ssl_TemplateSignerPlaceOverridesBlankConnector_SendsTemplateValue() + { + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } + }); + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_sp_001", Status = "pending-csr" }); + mock.Setup(c => c.SubmitCsrV2Async(It.IsAny(), "ord_sp_001", It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), "ord_sp_001", It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = "ord_sp_001", Status = "pending-approval" }); + + var plugin = new CERTInextCAPlugin(mock.Object, V2Config("")); + + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, "CN=example.com", new Dictionary(), + SslProductInfo(templateSignerPlace: "Austin, TX"), RequestFormat.PKCS10, EnrollmentType.New); + + result.Status.Should().NotBe((int)EndEntityStatus.FAILED); + captured.Should().NotBeNull(); + captured!.Agreement.SignerPlace.Should().Be("Austin, TX"); + } + + [Fact] + public async Task Enroll_V2PrivatePki_BlankSignerPlace_StillPlacesOrder() + { + // Private PKI has no Subscriber Agreement, so a blank SignerPlace must not block it. + var mock = NewMock(); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_pki_sp", Status = "pending-csr" }); + mock.Setup(c => c.SubmitCsrV2Async( + Constants.ApiV2.FamilyPrivatePki, "ord_pki_sp", It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async(Constants.ApiV2.FamilyPrivatePki, "ord_pki_sp", It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = "ord_pki_sp", Status = "pending-approval" }); + + var plugin = new CERTInextCAPlugin(mock.Object, V2Config("")); + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.DvSsl, + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductFamily"] = "private-pki", + ["ProductVariant"] = "intranet-ssl", + ["ProductCode"] = "149", + ["DomainName"] = "intranet.acme.local" + } + }; + + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, "CN=intranet.acme.local", new Dictionary(), + productInfo, RequestFormat.PKCS10, EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + result.CARequestID.Should().Be("ord_pki_sp"); + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + } +} diff --git a/CERTInext.Tests/V2SslOrderBodyGoldenTests.cs b/CERTInext.Tests/V2SslOrderBodyGoldenTests.cs new file mode 100644 index 0000000..8eaca28 --- /dev/null +++ b/CERTInext.Tests/V2SslOrderBodyGoldenTests.cs @@ -0,0 +1,200 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Moq; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Issue 0033 regression guard: branching EnrollV2Async on product family must not + /// change a single byte of the SSL/TLS create-order body. Each test drives a full V2 SSL + /// enrollment against a Strict mock, captures the handed + /// to the client, serializes it with the client's own serializer options, and compares the + /// result to a golden JSON string. The golden strings were confirmed to match the output of + /// the pre-0033 code (commit 6c12174) — this file compiles and passes unchanged against it. + /// + public class V2SslOrderBodyGoldenTests + { + // Mirrors CERTInextClient.GetJsonOptions() (private) — the options PlaceOrderV2Async + // uses to produce the actual wire body. + private static JsonSerializerOptions ClientEquivalentJsonOptions() => new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private static async Task<(string Json, string FamilySlug, string ProductCode)> CaptureSslBodyAsync( + CERTInextConfig config, + EnrollmentProductInfo productInfo, + string catalogCode, + string catalogTypeId, + Dictionary san, + string subject) + { + const string orderId = "ord_golden_001"; + var mock = new Mock(MockBehavior.Strict); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = catalogCode, ProductTypeId = catalogTypeId, Active = true } + }); + + V2CreateSslOrderRequest captured = null; + string capturedSlug = null; + string capturedCode = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((slug, code, req, _) => + { + capturedSlug = slug; + capturedCode = code; + captured = req; + }) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = orderId, Status = "pending-dcv" }); + mock.Setup(c => c.SubmitCsrV2Async(It.IsAny(), orderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), orderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = orderId, Status = "pending-dcv" }); + + var plugin = new CERTInextCAPlugin(mock.Object, config); + await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: subject, + san: san, + productInfo: productInfo, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + captured.Should().NotBeNull("the SSL enrollment must reach PlaceOrderV2Async"); + return (JsonSerializer.Serialize(captured, ClientEquivalentJsonOptions()), capturedSlug, capturedCode); + } + + [Fact] + public async Task SslOvUccOrder_AllOptionalConfigSet_WireBodyIsByteIdenticalToPre0033() + { + var config = new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "5551234567", + RequestorDesignation = "PKI Admin", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + OrganizationNumber = "ORG-001", + GroupNumber = "GRP-9", + EmailNotifications = "1", + SubscriptionAutoRenew = "1", + SubscriptionRenewCriteriaDays = "15", + AutoSecureWww = "1", + TechnicalContactName = "Tech Person", + TechnicalContactEmail = "tech@example.com", + TechnicalContactIsdCode = "44", + TechnicalContactMobileNumber = "7700900000", + PickupRetries = 0 + }; + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.OvSslUcc, + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = "848", + ["ProductFamily"] = "ssl", + ["ProductVariant"] = "ov", + ["DomainName"] = "example.com", + ["ValidityYears"] = "2" + } + }; + + var (json, slug, code) = await CaptureSslBodyAsync( + config, productInfo, "848", "18", + new Dictionary { ["dnsname"] = new[] { "example.com", "san1.example.com", "san2.example.com" } }, + "CN=example.com"); + + slug.Should().Be(Constants.ApiV2.FamilySsl); + code.Should().Be("848"); + json.Should().Be( + "{\"productVariant\":\"ov\",\"emailNotifications\":\"all\"," + + "\"requestor\":{\"name\":\"Test User\",\"email\":\"test@example.com\",\"phone\":\"\\u002B15551234567\",\"designation\":\"PKI Admin\"}," + + "\"organization\":{\"organizationNumber\":\"ORG-001\",\"preVetted\":true}," + + "\"certificate\":{\"domain\":\"example.com\",\"autoSecureWww\":true,\"additionalDomains\":[\"san1.example.com\",\"san2.example.com\"]}," + + "\"subscription\":{\"validityYears\":2,\"autoRenew\":true,\"renewBeforeDays\":15}," + + "\"agreement\":{\"signerName\":\"Test User\",\"signerIp\":\"1.2.3.4\",\"signerPlace\":\"New York\",\"accepted\":true}," + + "\"technicalPointOfContact\":{\"name\":\"Tech Person\",\"email\":\"tech@example.com\",\"phone\":\"\\u002B447700900000\",\"designation\":\"Technical Contact\"}," + + "\"remarks\":\"Issued via Keyfactor Command AnyCA REST Gateway.\",\"groupNumber\":\"GRP-9\"}"); + } + + [Fact] + public async Task SslDvOrder_DefaultConfig_WireBodyIsByteIdenticalToPre0033() + { + var config = new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + // Issue 0039: SignerPlace is now required for V2 SSL orders (spec: agreement.signerPlace + // "Conditional - required if `agreement` sent"), so the "default config" fixture sets it + // and the expected agreement block below now carries signerPlace. + SignerPlace = "Austin", + PickupRetries = 0 + }; + // No ProductFamily / ProductVariant / DomainName: exercises the ssl + dv defaults and + // the CN-derived domain. + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.DvSsl, + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = "842" + } + }; + + var (json, slug, code) = await CaptureSslBodyAsync( + config, productInfo, "842", "13", + new Dictionary { ["dnsname"] = new[] { "example.com" } }, + "CN=example.com"); + + slug.Should().Be(Constants.ApiV2.FamilySsl); + code.Should().Be("842"); + json.Should().Be( + "{\"productVariant\":\"dv\",\"emailNotifications\":\"0\"," + + "\"requestor\":{\"name\":\"Test User\",\"email\":\"test@example.com\",\"phone\":\"\"}," + + "\"certificate\":{\"domain\":\"example.com\",\"autoSecureWww\":false}," + + "\"subscription\":{\"validityYears\":1,\"autoRenew\":false,\"renewBeforeDays\":30}," + + "\"agreement\":{\"signerName\":\"Test User\",\"signerPlace\":\"Austin\",\"accepted\":true}," + + "\"technicalPointOfContact\":{\"name\":\"Test User\",\"email\":\"test@example.com\",\"phone\":\"\",\"designation\":\"Technical Contact\"}," + + "\"remarks\":\"Issued via Keyfactor Command AnyCA REST Gateway.\"}"); + } + } +} diff --git a/CERTInext.Tests/V2SubscriptionEnrollmentTests.cs b/CERTInext.Tests/V2SubscriptionEnrollmentTests.cs new file mode 100644 index 0000000..c190a46 --- /dev/null +++ b/CERTInext.Tests/V2SubscriptionEnrollmentTests.cs @@ -0,0 +1,324 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Regression tests for issues/0027-v2-request-builder-drops-config-fields.md items 2a/2b: the + /// V2 order body's subscription block hardcoded autoRenew=false and + /// renewBeforeDays=30, ignoring the connector's SubscriptionAutoRenew/ + /// SubscriptionRenewCriteriaDays config entirely. These tests exercise + /// EnrollV2Async end-to-end (through ) against a + /// Strict mock, plus direct DTO serialization checks for + /// . + /// + public class V2SubscriptionEnrollmentTests + { + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + // Defaults here intentionally match CERTInextConfig's own property defaults ("0"/"30") — + // NOT a `?? "30"`-style fallback applied to the parameter, which would silently coerce an + // explicitly-passed null (a real Theory case below) back to "30" and defeat that test case. + private static CERTInextCAPlugin BuildV2Plugin( + ICERTInextClient client, + string subscriptionAutoRenew = "0", + string subscriptionRenewCriteriaDays = "30") => + new CERTInextCAPlugin(client, new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + PickupRetries = 0, + SubscriptionAutoRenew = subscriptionAutoRenew, + SubscriptionRenewCriteriaDays = subscriptionRenewCriteriaDays + }); + + private static EnrollmentProductInfo MakeV2ProductInfo(string productCode, string productVariant) => + new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = productCode, + ["ProductFamily"] = "ssl", + ["ProductVariant"] = productVariant, + ["DomainName"] = "example.com" + } + }; + + private static void StubCatalog(Mock mock, string productCode, string productTypeId) => + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = productCode, ProductTypeId = productTypeId, Active = true } + }); + + private static void StubHappyOrderPlacement(Mock mock, string orderId) + { + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), orderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), orderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = orderId, Status = "pending-dcv" }); + } + + private static string GenerateCsrPem(string cn) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + AsymmetricCipherKeyPair kp = keyGen.GenerateKeyPair(); + + var csr = new Pkcs10CertificationRequest( + "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, null, kp.Private); + + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + private static async Task<(EnrollmentResult Result, V2CreateSslOrderRequest Captured)> RunEnrollAsync( + Mock mock, CERTInextCAPlugin plugin, string orderId = "ord_sub_001") + { + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = orderId, Status = "pending-dcv" }); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), + subject: "CN=example.com", + san: null, + productInfo: MakeV2ProductInfo("842", "dv"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + return (result, captured); + } + + // --------------------------------------------------------------------------- + // EnrollV2Async wiring — SubscriptionAutoRenew -> Subscription.AutoRenew + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_V2_SubscriptionAutoRenew_1_SetsAutoRenewTrue() + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // DV SSL (non-UCC) + StubHappyOrderPlacement(mock, "ord_sub_001"); + + var plugin = BuildV2Plugin(mock.Object, subscriptionAutoRenew: "1"); + + var (result, captured) = await RunEnrollAsync(mock, plugin); + + result.CARequestID.Should().Be("ord_sub_001"); + captured.Should().NotBeNull(); + captured!.Subscription.Should().NotBeNull(); + captured.Subscription.AutoRenew.Should().BeTrue( + "SubscriptionAutoRenew=\"1\" must set Subscription.AutoRenew=true, the same bare " + + "\"1\"-means-true comparison AutoSecureWww already uses"); + } + + [Theory] + [InlineData("0")] + [InlineData("")] + [InlineData("yes")] + [InlineData("true")] + public async Task Enroll_V2_SubscriptionAutoRenew_NonOneValues_SetsAutoRenewFalse(string configValue) + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); + StubHappyOrderPlacement(mock, "ord_sub_002"); + + var plugin = BuildV2Plugin(mock.Object, subscriptionAutoRenew: configValue); + + var (result, captured) = await RunEnrollAsync(mock, plugin, "ord_sub_002"); + + result.CARequestID.Should().Be("ord_sub_002"); + captured!.Subscription.AutoRenew.Should().BeFalse( + $"only the literal value \"1\" should set AutoRenew=true — '{configValue}' must not"); + } + + // --------------------------------------------------------------------------- + // EnrollV2Async wiring — SubscriptionRenewCriteriaDays -> Subscription.RenewBeforeDays + // --------------------------------------------------------------------------- + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Enroll_V2_SubscriptionRenewCriteriaDays_Blank_OmitsRenewBeforeDays(string configValue) + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); + StubHappyOrderPlacement(mock, "ord_sub_003"); + + var plugin = BuildV2Plugin(mock.Object, subscriptionRenewCriteriaDays: configValue); + + var (result, captured) = await RunEnrollAsync(mock, plugin, "ord_sub_003"); + + result.CARequestID.Should().Be("ord_sub_003"); + captured!.Subscription.RenewBeforeDays.Should().BeNull( + "a blank/unset SubscriptionRenewCriteriaDays must leave RenewBeforeDays null so the " + + "field is omitted and the CA's documented default of 30 applies"); + } + + [Fact] + public async Task Enroll_V2_SubscriptionRenewCriteriaDays_ValidValue_SetsRenewBeforeDays() + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); + StubHappyOrderPlacement(mock, "ord_sub_004"); + + var plugin = BuildV2Plugin(mock.Object, subscriptionRenewCriteriaDays: "45"); + + var (result, captured) = await RunEnrollAsync(mock, plugin, "ord_sub_004"); + + result.CARequestID.Should().Be("ord_sub_004"); + captured!.Subscription.RenewBeforeDays.Should().Be(45, + "a configured SubscriptionRenewCriteriaDays must be forwarded to RenewBeforeDays " + + "as-is, independent of the AutoRenew value"); + } + + [Fact] + public async Task Enroll_V2_SubscriptionRenewCriteriaDays_ZeroIsValid_SetsRenewBeforeDaysZero() + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); + StubHappyOrderPlacement(mock, "ord_sub_005"); + + var plugin = BuildV2Plugin(mock.Object, subscriptionRenewCriteriaDays: "0"); + + var (result, captured) = await RunEnrollAsync(mock, plugin, "ord_sub_005"); + + result.CARequestID.Should().Be("ord_sub_005"); + captured!.Subscription.RenewBeforeDays.Should().Be(0, + "zero is a valid non-negative integer and must be sent as-is, not treated as blank"); + } + + // --------------------------------------------------------------------------- + // EnrollV2Async — bad SubscriptionRenewCriteriaDays fails fast, before any CA call + // --------------------------------------------------------------------------- + + [Theory] + [InlineData("abc")] + [InlineData("-1")] + public async Task Enroll_V2_SubscriptionRenewCriteriaDays_Invalid_FailsEnrollment_NoHttpCallMade(string configValue) + { + // Strict mock with NO setups at all: if EnrollV2Async made any client call before + // failing validation, Moq would throw a MockException for the unstubbed invocation + // and this test would fail — that, plus the explicit Verify(Times.Never) calls below, + // together confirm zero HTTP requests are sent for an invalid config value. + var mock = NewMock(); + + var plugin = BuildV2Plugin(mock.Object, subscriptionRenewCriteriaDays: configValue); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), + subject: "CN=example.com", + san: null, + productInfo: MakeV2ProductInfo("842", "dv"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.StatusMessage.Should().Contain("SubscriptionRenewCriteriaDays", + "the failure message must name the offending config field"); + + mock.Verify(c => c.GetProductDetailsV2Async(It.IsAny()), Times.Never); + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + // --------------------------------------------------------------------------- + // DTO serialization — renewBeforeDays key present/absent on the wire + // + // Unlike GroupNumber/PreVettingToken (which carry their own per-property + // [JsonIgnore(Condition = WhenWritingNull)]), RenewBeforeDays relies solely on the + // client's global serializer options (CERTInextClient.GetJsonOptions, + // DefaultIgnoreCondition = WhenWritingNull) to omit it when null — by design, per issue + // 0027's fix. GetJsonOptions() is private, so these tests build an equivalent + // JsonSerializerOptions inline to verify that global-option omission actually works for + // this property, rather than relying on plain JsonSerializer.Serialize(req) (whose default + // options do NOT ignore nulls and would show "renewBeforeDays":null instead of omitting it). + // --------------------------------------------------------------------------- + + private static JsonSerializerOptions ClientEquivalentJsonOptions() => new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + [Fact] + public void V2SubscriptionParams_Serialization_OmitsRenewBeforeDays_WhenNull() + { + var subscription = new V2SubscriptionParams + { + ValidityYears = 1, + AutoRenew = false, + RenewBeforeDays = null + }; + + string json = JsonSerializer.Serialize(subscription, ClientEquivalentJsonOptions()); + + json.Should().NotContain("renewBeforeDays", + "the renewBeforeDays key itself must be absent when unset, not present-but-null, " + + "under the client's actual serializer options"); + } + + [Fact] + public void V2SubscriptionParams_Serialization_IncludesRenewBeforeDays_WhenSet() + { + var subscription = new V2SubscriptionParams + { + ValidityYears = 1, + AutoRenew = true, + RenewBeforeDays = 45 + }; + + string json = JsonSerializer.Serialize(subscription, ClientEquivalentJsonOptions()); + + json.Should().Contain("\"renewBeforeDays\":45"); + } + } +} diff --git a/CERTInext.Tests/V2TechnicalContactEnrollmentTests.cs b/CERTInext.Tests/V2TechnicalContactEnrollmentTests.cs new file mode 100644 index 0000000..7ad939e --- /dev/null +++ b/CERTInext.Tests/V2TechnicalContactEnrollmentTests.cs @@ -0,0 +1,306 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Moq; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Regression tests for issues/0030-v2-technical-contact-not-sent.md: the V2 SSL order body + /// never carried a technicalPointOfContact block, while V1's equivalent + /// (TechnicalPointOfContact) has always populated one from the connector's + /// TechnicalContact* config fields (falling back to the corresponding + /// Requestor* value when blank). These tests exercise EnrollV2Async end-to-end + /// (through ) against a Strict + /// mock, plus direct DTO serialization checks for the new + /// property and the new + /// ISD-code + mobile-number composition helper. + /// + public class V2TechnicalContactEnrollmentTests + { + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + private static CERTInextConfig BaseConfig() => new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "5550000000", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + PickupRetries = 0 + }; + + private static CERTInextCAPlugin BuildV2Plugin(ICERTInextClient client, CERTInextConfig config) => + new CERTInextCAPlugin(client, config); + + private static EnrollmentProductInfo MakeV2ProductInfo(string productCode, string productVariant) => + new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = productCode, + ["ProductFamily"] = "ssl", + ["ProductVariant"] = productVariant, + ["DomainName"] = "example.com" + } + }; + + private static void StubCatalog(Mock mock, string productCode, string productTypeId) => + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = productCode, ProductTypeId = productTypeId, Active = true } + }); + + private static void StubHappyOrderPlacement(Mock mock, string orderId) + { + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), orderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), orderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = orderId, Status = "pending-dcv" }); + } + + private static string GenerateCsrPem(string cn) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + AsymmetricCipherKeyPair kp = keyGen.GenerateKeyPair(); + + var csr = new Pkcs10CertificationRequest( + "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, null, kp.Private); + + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + private static async Task RunEnrollAndCaptureOrderAsync( + CERTInextConfig config, string orderId = "ord_tpc_001") + { + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // DV SSL (non-UCC) + StubHappyOrderPlacement(mock, orderId); + + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = orderId, Status = "pending-dcv" }); + + var plugin = BuildV2Plugin(mock.Object, config); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), + subject: "CN=example.com", + san: null, + productInfo: MakeV2ProductInfo("842", "dv"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.CARequestID.Should().Be(orderId); + captured.Should().NotBeNull(); + return captured; + } + + // --------------------------------------------------------------------------- + // EnrollV2Async wiring — technicalPointOfContact populated on the order body + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_V2_TechnicalContactConfigured_PopulatesTechnicalPointOfContactOnOrderBody() + { + var config = BaseConfig(); + config.TechnicalContactName = "TPoC Name"; + config.TechnicalContactEmail = "tpoc@example.com"; + config.TechnicalContactIsdCode = "44"; + config.TechnicalContactMobileNumber = "7911123456"; + + var captured = await RunEnrollAndCaptureOrderAsync(config); + + captured.TechnicalPointOfContact.Should().NotBeNull( + "the technicalPointOfContact block must always be populated, even though every " + + "subfield is spec-Optional"); + captured.TechnicalPointOfContact.Name.Should().Be("TPoC Name"); + captured.TechnicalPointOfContact.Email.Should().Be("tpoc@example.com"); + captured.TechnicalPointOfContact.Phone.Should().Be("+447911123456", + "the configured TechnicalContactIsdCode and TechnicalContactMobileNumber must be " + + "combined into a single E.164-style phone value for the V2 shape"); + captured.TechnicalPointOfContact.Designation.Should().Be("Technical Contact"); + } + + [Fact] + public async Task Enroll_V2_TechnicalContactBlank_FallsBackToRequestorValues() + { + var config = BaseConfig(); + // TechnicalContact* fields left at their default (blank) values. + + var captured = await RunEnrollAndCaptureOrderAsync(config, orderId: "ord_tpc_002"); + + captured.TechnicalPointOfContact.Should().NotBeNull( + "V1's fallback-to-Requestor* semantics mean the block is populated, never omitted, " + + "even when no TechnicalContact* field is configured"); + captured.TechnicalPointOfContact.Name.Should().Be(config.RequestorName, + "a blank TechnicalContactName must fall back to RequestorName, mirroring V1"); + captured.TechnicalPointOfContact.Email.Should().Be(config.RequestorEmail, + "a blank TechnicalContactEmail must fall back to RequestorEmail, mirroring V1"); + captured.TechnicalPointOfContact.Phone.Should().Be("+15550000000", + "a blank TechnicalContactIsdCode/TechnicalContactMobileNumber must fall back to " + + "RequestorIsdCode/RequestorMobileNumber, mirroring V1, composed into one phone value"); + captured.TechnicalPointOfContact.Designation.Should().Be("Technical Contact"); + } + + [Fact] + public async Task Enroll_V2_TechnicalContactPartiallyConfigured_FallsBackFieldByField() + { + var config = BaseConfig(); + // Only name is overridden; email/isd/mobile stay blank and must each fall back + // independently to their own Requestor* counterpart (matching V1's per-field, not + // all-or-nothing, fallback semantics). + config.TechnicalContactName = "Override Name Only"; + + var captured = await RunEnrollAndCaptureOrderAsync(config, orderId: "ord_tpc_003"); + + captured.TechnicalPointOfContact.Name.Should().Be("Override Name Only"); + captured.TechnicalPointOfContact.Email.Should().Be(config.RequestorEmail); + captured.TechnicalPointOfContact.Phone.Should().Be("+15550000000"); + } + + // --------------------------------------------------------------------------- + // Requestor.Phone — ISD-code + mobile-number composition (issues/0027 item 5b). + // Before the fix, Requestor.Phone sent the raw RequestorMobileNumber only, with + // RequestorIsdCode never combined in — unlike TechnicalPointOfContact.Phone above, + // which already used ComposeV2Phone. Requestor.Phone must compose the same way. + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_V2_RequestorPhone_ComposesIsdAndMobile() + { + var config = BaseConfig(); + // BaseConfig: RequestorIsdCode="1", RequestorMobileNumber="5550000000". + + var captured = await RunEnrollAndCaptureOrderAsync(config, orderId: "ord_req_phone_001"); + + captured.Requestor.Should().NotBeNull(); + captured.Requestor.Phone.Should().Be("+15550000000", + "Requestor.Phone must combine RequestorIsdCode and RequestorMobileNumber the same " + + "way TechnicalPointOfContact.Phone already does, via ComposeV2Phone"); + } + + [Fact] + public async Task Enroll_V2_RequestorPhone_UsesConfiguredIsdCode_NotDefault() + { + var config = BaseConfig(); + config.RequestorIsdCode = "44"; + config.RequestorMobileNumber = "7911123456"; + + var captured = await RunEnrollAndCaptureOrderAsync(config, orderId: "ord_req_phone_002"); + + captured.Requestor.Phone.Should().Be("+447911123456", + "a non-default RequestorIsdCode must be reflected in Requestor.Phone, not just " + + "the TechnicalPointOfContact fallback path"); + } + + [Fact] + public async Task Enroll_V2_RequestorPhone_BlankIsdCode_FallsBackToDefault() + { + var config = BaseConfig(); + config.RequestorIsdCode = ""; + config.RequestorMobileNumber = "5550000000"; + + var captured = await RunEnrollAndCaptureOrderAsync(config, orderId: "ord_req_phone_003"); + + captured.Requestor.Phone.Should().Be("+15550000000", + "a blank RequestorIsdCode must fall back to the same default ('1') used " + + "elsewhere in EnrollV2Async, not an unprefixed raw mobile number"); + } + + // --------------------------------------------------------------------------- + // DTO serialization — technicalPointOfContact key/shape on the wire + // --------------------------------------------------------------------------- + + [Fact] + public void V2CreateSslOrderRequest_Serialization_IncludesTechnicalPointOfContact_WhenSet() + { + var req = new V2CreateSslOrderRequest + { + ProductVariant = "dv", + Requestor = new V2Requestor { Name = "Jane Doe", Email = "jane@example.com" }, + Certificate = new V2CertificateParams { Domain = "example.com" }, + TechnicalPointOfContact = new V2TechnicalPointOfContact + { + Name = "TPoC Name", + Email = "tpoc@example.com", + Phone = "+447911123456", + Designation = "Technical Contact" + } + }; + + string json = JsonSerializer.Serialize(req); + + // System.Text.Json escapes '+' as + by default, so the phone value is checked + // by parsing the JSON rather than substring-matching the raw serialized text (which + // would never contain a literal '+'). + using var doc = JsonDocument.Parse(json); + var tpc = doc.RootElement.GetProperty("technicalPointOfContact"); + tpc.GetProperty("name").GetString().Should().Be("TPoC Name"); + tpc.GetProperty("email").GetString().Should().Be("tpoc@example.com"); + tpc.GetProperty("phone").GetString().Should().Be("+447911123456"); + tpc.GetProperty("designation").GetString().Should().Be("Technical Contact"); + } + + // --------------------------------------------------------------------------- + // ComposeV2Phone — ISD-code + mobile-number composition helper + // --------------------------------------------------------------------------- + + [Theory] + [InlineData("1", "5550000000", "+15550000000")] + [InlineData("44", "7911123456", "+447911123456")] + [InlineData("+1", "5550000000", "+15550000000")] + [InlineData("", "5550000000", "5550000000")] + [InlineData(null, "5550000000", "5550000000")] + [InlineData("1", "", "")] + [InlineData("1", null, "")] + [InlineData(null, null, "")] + public void ComposeV2Phone_ComposesExpectedValue(string isdCode, string mobileNumber, string expected) + { + CERTInextCAPlugin.ComposeV2Phone(isdCode, mobileNumber).Should().Be(expected); + } + } +} diff --git a/CERTInext.Tests/V2UccEnrollmentTests.cs b/CERTInext.Tests/V2UccEnrollmentTests.cs new file mode 100644 index 0000000..b33299a --- /dev/null +++ b/CERTInext.Tests/V2UccEnrollmentTests.cs @@ -0,0 +1,447 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using Org.BouncyCastle.Asn1.Pkcs; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Regression tests for issues/f3-v2-multi-san-limitation.md: V2 UCC (multi-SAN) order-create + /// support. (V2 path) is driven end-to-end against a + /// Strict mock so the assertions exercise + /// EnrollV2Async's actual UCC-detection and additionalDomains-population logic, + /// not a re-implementation of it. + /// + public class V2UccEnrollmentTests + { + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + private static CERTInextCAPlugin BuildV2Plugin(ICERTInextClient client) => + new CERTInextCAPlugin(client, new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + PickupRetries = 0 + }); + + private static EnrollmentProductInfo MakeV2ProductInfo(string productCode) => + new EnrollmentProductInfo + { + ProductID = "DV SSL UCC", + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = productCode, + ["ProductFamily"] = "ssl", + ["ProductVariant"] = "dv", + ["DomainName"] = "example.com" + } + }; + + private static void StubCatalog(Mock mock, string productCode, string productTypeId) => + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = productCode, ProductTypeId = productTypeId, Active = true } + }); + + private static void StubHappyOrderPlacement(Mock mock, string orderId = "ord_ucc_001") + { + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = orderId, Status = "pending-dcv" }); + + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), orderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), orderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = orderId, Status = "pending-dcv" }); + } + + // --------------------------------------------------------------------------- + // CSR generation (BouncyCastle — project crypto policy). Mirrors SanSubmissionTests' + // helper of the same shape; duplicated locally per this repo's existing convention of + // small per-test-file helpers (see NewMock()/BuildV2Plugin() duplicated across the V2 + // test files) rather than sharing test infrastructure across files. + // --------------------------------------------------------------------------- + + private static string GenerateCsrPem(string cn, params string[] dnsSans) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + AsymmetricCipherKeyPair kp = keyGen.GenerateKeyPair(); + + Org.BouncyCastle.Asn1.Asn1Set attributes = null; + if (dnsSans != null && dnsSans.Length > 0) + { + var names = dnsSans.Select(d => new GeneralName(GeneralName.DnsName, d)).ToArray(); + var extGen = new X509ExtensionsGenerator(); + extGen.AddExtension(X509Extensions.SubjectAlternativeName, critical: false, + extValue: new GeneralNames(names)); + + attributes = new Org.BouncyCastle.Asn1.DerSet(new AttributePkcs( + PkcsObjectIdentifiers.Pkcs9AtExtensionRequest, + new Org.BouncyCastle.Asn1.DerSet(extGen.Generate()))); + } + + var csr = new Pkcs10CertificationRequest( + "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, attributes, kp.Private); + + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + // --------------------------------------------------------------------------- + // UCC detection + additionalDomains population + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_V2_UccProduct_PopulatesAdditionalDomainsFromGatewaySanDictionary() + { + var mock = NewMock(); + StubCatalog(mock, "844", "15"); // DV SSL Certificate UCC + StubHappyOrderPlacement(mock); + + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_ucc_001", Status = "pending-dcv" }); + + var plugin = BuildV2Plugin(mock.Object); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), // CSR carries ONLY the primary domain — spec requirement for UCC + subject: "CN=example.com", + san: new Dictionary + { + ["dns"] = new[] { "example.com", "san1.example.com", "san2.example.com" } + }, + productInfo: MakeV2ProductInfo("844"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.CARequestID.Should().Be("ord_ucc_001"); + captured.Should().NotBeNull(); + V2CreateSslOrderRequest req = captured!; + req.Certificate.Domain.Should().Be("example.com"); + req.Certificate.AdditionalDomains.Should().BeEquivalentTo( + new[] { "san1.example.com", "san2.example.com" }, + "the primary domain must not be repeated in additionalDomains, and the SAN dictionary " + + "(not the CSR) is the source for a UCC order's additional domains"); + } + + [Fact] + public async Task Enroll_V2_NonUccProduct_SanDictionaryCarriesExtras_StillFailsFastWithNoPlaceOrderCall() + { + // Issue 0061: the CSR alone carries only the primary domain, so the pre-0061 guard + // (which looked at the CSR only) did not trigger, and the SAN dictionary's extra + // domain silently vanished — a non-UCC order never sends additionalDomains at all, so + // there was nowhere for it to go. The guard must now consider the SAN dictionary too + // and reject, the same way it already rejects CSR-borne extras + // (Enroll_V2_NonUccProduct_CsrCarriesExtraSans_StillFailsFastWithNoPlaceOrderCall). + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // DV SSL (non-UCC) + + var plugin = BuildV2Plugin(mock.Object); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), + subject: "CN=example.com", + san: new Dictionary + { + ["dns"] = new[] { "example.com", "san1.example.com" } + }, + productInfo: MakeV2ProductInfo("842"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.StatusMessage.Should().Contain("1 SAN(s) beyond"); + result.StatusMessage.Should().Contain("single domain"); + + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never, + "a rejected non-UCC request must never reach order placement, whether the extra " + + "SAN came from the CSR or the SAN dictionary"); + } + + [Fact] + public async Task Enroll_V2_NonUccProduct_SanDictionaryHasOnlyPrimary_ButCsrCarriesExtraSan_StillFailsFastWithNoPlaceOrderCall() + { + // Regression for a union-vs-fallback bug introduced while first fixing issue 0061: a + // non-null SAN dictionary that carries only the primary domain must not make the + // guard defer to the dictionary and skip the CSR. SubmitCsrV2Async sends the CSR to + // CERTInext verbatim regardless of what the SAN dictionary contains, so a + // CSR-embedded extra domain still reaches the CA even when the dictionary is + // single-domain — the guard must reject this exactly like the CSR-only case + // (Enroll_V2_NonUccProduct_CsrCarriesExtraSans_StillFailsFastWithNoPlaceOrderCall). + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // DV SSL (non-UCC) + + var plugin = BuildV2Plugin(mock.Object); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com", "example.com", "other.example.com"), + subject: "CN=example.com", + san: new Dictionary { ["dns"] = new[] { "example.com" } }, + productInfo: MakeV2ProductInfo("842"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.StatusMessage.Should().Contain("1 SAN(s) beyond"); + result.StatusMessage.Should().Contain("single domain"); + + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never, + "a non-null SAN dictionary carrying only the primary domain must not make the " + + "guard defer to it and miss a CSR-embedded extra SAN"); + } + + [Fact] + public async Task Enroll_V2_NonUccProduct_SanDictionaryHasOnlyPrimaryAndWwwVariant_Allowed() + { + // The guard's existing primary-domain / www. allowance must still apply when + // those names arrive via the SAN dictionary rather than the CSR. + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // DV SSL (non-UCC) + + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_nonucc_002", Status = "pending-dcv" }); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), "ord_nonucc_002", It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), "ord_nonucc_002", It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = "ord_nonucc_002", Status = "pending-dcv" }); + + var plugin = BuildV2Plugin(mock.Object); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), + subject: "CN=example.com", + san: new Dictionary + { + ["dns"] = new[] { "example.com", "www.example.com" } + }, + productInfo: MakeV2ProductInfo("842"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.CARequestID.Should().Be("ord_nonucc_002"); + captured.Should().NotBeNull(); + captured!.Certificate.AdditionalDomains.Should().BeNull( + "non-UCC V2 products must keep the single-domain wire shape even when the " + + "dictionary only ever carried allowed names"); + } + + [Fact] + public async Task Enroll_V2_NonUccProduct_SanDictionaryHasOnlyNonDnsExtras_Allowed() + { + // dnsOnly semantics (issue 0046): a non-DNS SAN dictionary entry can never appear in + // additionalDomains and must not trip the reject guard either. + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // DV SSL (non-UCC) + + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_nonucc_003", Status = "pending-dcv" }); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), "ord_nonucc_003", It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), "ord_nonucc_003", It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = "ord_nonucc_003", Status = "pending-dcv" }); + + var plugin = BuildV2Plugin(mock.Object); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), + subject: "CN=example.com", + san: new Dictionary + { + ["dns"] = new[] { "example.com" }, + ["rfc822name"] = new[] { "admin@example.com" } + }, + productInfo: MakeV2ProductInfo("842"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.CARequestID.Should().Be("ord_nonucc_003"); + captured.Should().NotBeNull(); + captured!.Certificate.AdditionalDomains.Should().BeNull( + "a non-DNS dictionary entry must not trip the non-UCC reject guard"); + } + + [Fact] + public async Task Enroll_V2_UccProduct_CsrCarriesExtraSans_OrderIsPlacedWithSansAsAdditionalDomains() + { + // Issue 0047: the CSR-SAN-count guard used to run unconditionally before UCC + // detection, so a real UCC CSR enrollment (the CSR itself carries the extra DNS + // SANs, as Command actually builds it for CSR-based enrollments — confirmed live + // 2026-09-28) was always rejected before any CA order was placed. UCC products must + // now be exempt: the guard should not fire, and BuildSanList's own CSR fallback + // (triggered here via san: null) should carry those same CSR SANs into + // additionalDomains, exactly like the gateway-SAN-dictionary case already covered by + // Enroll_V2_UccProduct_PopulatesAdditionalDomainsFromGatewaySanDictionary above. + var mock = NewMock(); + StubCatalog(mock, "844", "15"); // DV SSL Certificate UCC + + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_ucc_002", Status = "pending-dcv" }); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), "ord_ucc_002", It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), "ord_ucc_002", It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = "ord_ucc_002", Status = "pending-dcv" }); + + var plugin = BuildV2Plugin(mock.Object); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com", "example.com", "extra1.example.com", "extra2.example.com"), + subject: "CN=example.com", + san: null, // no gateway SAN dictionary — BuildSanList falls back to the CSR's own SANs + productInfo: MakeV2ProductInfo("844"), // UCC product code + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION, + "the order should proceed to CA placement rather than fail fast"); + result.CARequestID.Should().Be("ord_ucc_002"); + captured.Should().NotBeNull(); + V2CreateSslOrderRequest req = captured!; + req.Certificate.Domain.Should().Be("example.com"); + req.Certificate.AdditionalDomains.Should().BeEquivalentTo( + new[] { "extra1.example.com", "extra2.example.com" }, + "a UCC product's CSR-embedded extra SANs must flow into additionalDomains instead of " + + "tripping the single-domain guard"); + } + + [Fact] + public async Task Enroll_V2_NonUccProduct_CsrCarriesExtraSans_StillFailsFastWithNoPlaceOrderCall() + { + // Counterpart to the UCC case above: a non-UCC product with the same multi-SAN CSR + // must keep the pre-0047 fail-fast behavior — FAILED, no order placed — even though + // the guard now necessarily runs after the (live) catalog lookup that determines + // UCC-ness, rather than before it. + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // DV SSL (non-UCC) + + var plugin = BuildV2Plugin(mock.Object); + + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com", "example.com", "extra1.example.com", "extra2.example.com"), + subject: "CN=example.com", + san: null, + productInfo: MakeV2ProductInfo("842"), // non-UCC product code + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.StatusMessage.Should().Contain("2 SAN(s) beyond"); + result.StatusMessage.Should().Contain("single domain"); + result.StatusMessage.Should().NotContain("The V2 API only supports single-domain certificates", + "the message must no longer claim V2 is single-domain-only in general — it's only true for non-UCC products"); + + mock.Verify(c => c.GetProductDetailsV2Async(It.IsAny()), Times.Once, + "UCC-ness can only be known after the catalog lookup, so the guard now runs after it"); + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never, + "a rejected non-UCC request must never reach order placement"); + } + + [Fact] + public async Task Enroll_V2_CatalogLookupFails_TreatedAsNonUcc_EnrollmentStillSucceeds() + { + var mock = NewMock(); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ThrowsAsync(new Exception("simulated transient catalog failure")); + + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_fallback_001", Status = "pending-dcv" }); + + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), "ord_fallback_001", It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), "ord_fallback_001", It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = "ord_fallback_001", Status = "pending-dcv" }); + + var plugin = BuildV2Plugin(mock.Object); + + // Issue 0061: a catalog-lookup failure fails UCC-ness safe to false, so a non-empty + // SAN dictionary extra here would now trip the (now dictionary-aware) non-UCC reject + // guard instead of exercising this test's actual intent. Single-domain SAN data keeps + // the test focused on the catalog-lookup fallback it's named for. + var result = await plugin.Enroll( + csr: GenerateCsrPem("example.com"), + subject: "CN=example.com", + san: new Dictionary { ["dns"] = new[] { "example.com" } }, + productInfo: MakeV2ProductInfo("844"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.CARequestID.Should().Be("ord_fallback_001", + "a catalog lookup failure must not block enrollment"); + captured.Should().NotBeNull(); + V2CreateSslOrderRequest req = captured!; + req.Certificate.AdditionalDomains.Should().BeNull( + "on a catalog failure, UCC-ness must fail safe to false rather than guess"); + } + } +} diff --git a/CERTInext.Tests/V2UccNonDnsSanLoggingTests.cs b/CERTInext.Tests/V2UccNonDnsSanLoggingTests.cs new file mode 100644 index 0000000..37beffd --- /dev/null +++ b/CERTInext.Tests/V2UccNonDnsSanLoggingTests.cs @@ -0,0 +1,320 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.Logging; +using Keyfactor.PKI.Enums.EJBCA; +using Microsoft.Extensions.Logging; +using Moq; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Regression tests for issue 0046: the V2 SSL UCC path reused BuildSanList, whose + /// V1-worded warning claimed non-DNS SANs "are submitted rather than dropped on purpose" even + /// though EnrollV2Async always strips them from additionalDomains. The V2 path + /// must now log its own "excluded from V2 additionalDomains" message (SAN types only, no + /// values), V1 must keep its original wording and wire behaviour, and private-pki must be + /// unaffected. + /// + /// Log capture: CERTInextCAPlugin._logger is a per-instance field resolved from + /// at construction, so swapping the factory before building + /// the plugin captures its messages (same seam as , + /// and the same non-parallel collection). Other test classes may log through the swapped + /// factory concurrently, so assertions only consider messages carrying this call's unique + /// subject marker. + /// + [Collection("LogHandlerFactory-NoParallel")] + public class V2UccNonDnsSanLoggingTests + { + private const string V1SubmittedWording = "submitted rather than dropped"; + private const string V1DroppedWording = "DROPPED because SubmitNonDnsSans is false"; + private const string V2ExcludedWording = "non-DNS SAN(s) excluded from V2 additionalDomains"; + private const string EmailSan = "admin@example.com"; + + private sealed class CapturingLoggerProvider : ILoggerProvider + { + public ConcurrentQueue Messages { get; } = new(); + public ILogger CreateLogger(string categoryName) => new CapturingLogger(Messages); + public void Dispose() { } + + private sealed class CapturingLogger : ILogger + { + private readonly ConcurrentQueue _messages; + public CapturingLogger(ConcurrentQueue messages) => _messages = messages; + public IDisposable BeginScope(TState state) => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) + => _messages.Enqueue(formatter(state, exception)); + } + } + + /// + /// Builds the plugin (after swapping ), runs + /// , and returns every captured message mentioning + /// . + /// + private static async Task> CaptureAsync( + string marker, Func buildPlugin, Func enroll) + { + var provider = new CapturingLoggerProvider(); + var factory = LoggerFactory.Create(b => b.AddProvider(provider).SetMinimumLevel(LogLevel.Trace)); + try + { + LogHandler.Factory = factory; + var plugin = buildPlugin(); // constructed AFTER the swap so _logger resolves through it + await enroll(plugin); + } + finally + { + LogHandler.Factory = Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance; + factory.Dispose(); + } + + return provider.Messages.Where(m => m != null && m.Contains(marker)).ToList(); + } + + private static string NewMarker() => "sanlog-" + Guid.NewGuid().ToString("N"); + + // BouncyCastle only (project crypto policy). CN only — UCC CSRs carry only the primary domain. + private static string GenerateCsrPem(string cn) + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + AsymmetricCipherKeyPair kp = keyGen.GenerateKeyPair(); + var csr = new Pkcs10CertificationRequest( + "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, null, kp.Private); + return "-----BEGIN CERTIFICATE REQUEST-----\n" + + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE REQUEST-----"; + } + + private static Dictionary MixedSans(string primary) => new() + { + ["dnsname"] = new[] { primary, "san1." + primary }, + ["ipaddress"] = new[] { "192.0.2.10" }, + ["rfc822name"] = new[] { EmailSan } + }; + + // --------------------------------------------------------------------------- + // V2 SSL UCC + // --------------------------------------------------------------------------- + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Enroll_V2_Ucc_NonDnsSans_LogsV2ExclusionNotV1Wording_WireStaysDnsOnly(bool submitNonDnsSans) + { + string marker = NewMarker(); + string primary = marker + ".example.com"; + + var mock = new Mock(MockBehavior.Strict); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "844", ProductTypeId = "15", Active = true } // DV SSL UCC + }); + V2CreateSslOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((_, __, req, ___) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_0046", Status = "pending-dcv" }); + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), "ord_0046", It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), "ord_0046", It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = "ord_0046", Status = "pending-dcv" }); + + var config = new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + PickupRetries = 0, + SubmitNonDnsSans = submitNonDnsSans + }; + var productInfo = new EnrollmentProductInfo + { + ProductID = "DV SSL UCC", + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = "844", + ["ProductFamily"] = "ssl", + ["ProductVariant"] = "dv", + ["DomainName"] = primary + } + }; + + var messages = await CaptureAsync(marker, + () => new CERTInextCAPlugin(mock.Object, config), + p => p.Enroll(GenerateCsrPem(primary), $"CN={primary}", MixedSans(primary), productInfo, + RequestFormat.PKCS10, EnrollmentType.New)); + + // Wire unchanged: additionalDomains stays DNS-only regardless of SubmitNonDnsSans. + captured.Should().NotBeNull(); + captured!.Certificate.AdditionalDomains.Should().Equal(new[] { "san1." + primary }); + + messages.Should().NotContain(m => m.Contains(V1SubmittedWording), + "V2 additionalDomains never carries non-DNS SANs, so the V1 'submitted' claim is false here"); + messages.Should().NotContain(m => m.Contains(V1DroppedWording), + "SubmitNonDnsSans is a V1 switch and does not decide the V2 outcome"); + + var v2 = messages.Where(m => m.Contains(V2ExcludedWording)).ToList(); + v2.Should().ContainSingle(); + v2[0].Should().StartWith("EnrollV2Async: 2 non-DNS SAN(s) excluded from V2 additionalDomains"); + v2[0].Should().Contain("Types=[ip, email]"); + + // Scoped to the SAN-resolution log sites this path owns. The Enroll-wide "Enrollment + // attempt started" audit line (shared with V1) logs the raw SAN dictionary and is + // out of scope for issue 0046. + v2[0].Should().NotContain(EmailSan, + "an email SAN value is personal data; the V2 exclusion message logs SAN types only"); + messages.Where(m => m.StartsWith("Resolved ")).Should().ContainSingle() + .Which.Should().NotContain(EmailSan, + "in DNS-only mode the resolved-SAN audit line describes only what V2 submits"); + } + + // --------------------------------------------------------------------------- + // V1 — original wording and wire behaviour preserved + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_V1_NonDnsSans_StillLogsSubmittedWording_AndSubmitsThem() + { + string marker = NewMarker(); + string primary = marker + ".example.com"; + + EnrollCertificateRequest captured = null; + var mock = new Mock(MockBehavior.Loose); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .Callback((req, _) => captured = req) + .ReturnsAsync(new EnrollCertificateResponse + { + Id = "ORD-0046", Status = "issued", Certificate = MockCertificateData.FakePemCertificate + }); + + var productInfo = new EnrollmentProductInfo + { + ProductID = "DV SSL", + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = "842" + } + }; + + var messages = await CaptureAsync(marker, + () => new CERTInextCAPlugin(mock.Object, new CERTInextConfig { PickupRetries = 0 }), + p => p.Enroll(GenerateCsrPem(primary), $"CN={primary}", MixedSans(primary), productInfo, + RequestFormat.PKCS10, EnrollmentType.New)); + + captured.Should().NotBeNull(); + captured!.Sans.Select(s => s.Value).Should().Contain(new[] { "192.0.2.10", EmailSan }, + "V1 submits non-DNS SANs on purpose when SubmitNonDnsSans is true (the default)"); + + messages.Should().Contain(m => m.Contains(V1SubmittedWording)); + messages.Should().NotContain(m => m.Contains(V2ExcludedWording)); + } + + // --------------------------------------------------------------------------- + // Private PKI — unaffected + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_V2_PrivatePki_IpSansStillInAdditionalHosts_NoSslSanWording() + { + string marker = NewMarker(); + const string hostname = "intranet.acme.local"; + + var mock = new Mock(MockBehavior.Strict); + V2CreatePrivatePkiOrderRequest captured = null; + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, req, __) => captured = req) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = "ord_pki_0046", Status = "pending-csr" }); + mock.Setup(c => c.SubmitCsrV2Async( + Constants.ApiV2.FamilyPrivatePki, "ord_pki_0046", It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async(Constants.ApiV2.FamilyPrivatePki, "ord_pki_0046", It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = "ord_pki_0046", Status = "pending-approval" }); + + var config = new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "DevOps Team", + RequestorEmail = "devops@acme.com", + RequestorIsdCode = "1", + RequestorMobileNumber = "4155551234", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + PickupRetries = 0, + SubmitNonDnsSans = false + }; + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.DvSsl, + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductFamily"] = "private-pki", + ["ProductVariant"] = "intranet-ssl", + ["ProductCode"] = "149", + ["DomainName"] = hostname + } + }; + + var messages = await CaptureAsync(marker, + () => new CERTInextCAPlugin(mock.Object, config), + p => p.Enroll(MockCertificateData.FakeCsrPem, $"CN={hostname}, OU={marker}", + new Dictionary + { + ["dnsname"] = new[] { hostname }, + ["ipaddress"] = new[] { "10.0.0.50" } + }, + productInfo, RequestFormat.PKCS10, EnrollmentType.New)); + + captured.Should().NotBeNull(); + captured!.AdditionalHosts.Should().Equal(new[] { "10.0.0.50" }, + "private-pki carries IP SANs natively and never consults SubmitNonDnsSans"); + + messages.Should().NotContain(m => m.Contains(V2ExcludedWording)); + messages.Should().NotContain(m => m.Contains(V1SubmittedWording)); + messages.Should().NotContain(m => m.Contains(V1DroppedWording)); + } + } +} diff --git a/CERTInext.Tests/V2UnknownStatusTests.cs b/CERTInext.Tests/V2UnknownStatusTests.cs new file mode 100644 index 0000000..b1ef94d --- /dev/null +++ b/CERTInext.Tests/V2UnknownStatusTests.cs @@ -0,0 +1,108 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Regression tests for issue 0039 (`unknown` status): the V2 spec lists `unknown` among + /// its documented order statuses, but StatusMapper.V2StatusToRequestDisposition sent + /// it to FAILED, so a possibly-live order was reported to Command as failed. It now maps to + /// EXTERNALVALIDATION; these tests cover the enroll and single-record callers end to end. + /// + public class V2UnknownStatusTests + { + private const string OrderId = "ord_unknown_001"; + + private static CERTInextConfig V2Config() => new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + PickupRetries = 0 + }; + + [Fact] + public async Task Enroll_V2_PostCsrStatusUnknown_ReturnsPendingWithOrderId() + { + var mock = new Mock(MockBehavior.Strict); + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true } + }); + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = OrderId, Status = "pending-csr" }); + mock.Setup(c => c.SubmitCsrV2Async(It.IsAny(), OrderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = OrderId, Status = "unknown" }); + + var plugin = new CERTInextCAPlugin(mock.Object, V2Config()); + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.DvSsl, + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = "842", + ["ProductFamily"] = "ssl", + ["ProductVariant"] = "dv", + ["DomainName"] = "example.com" + } + }; + + var result = await plugin.Enroll( + MockCertificateData.FakeCsrPem, "CN=example.com", new Dictionary(), + productInfo, RequestFormat.PKCS10, EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION, + "a spec-documented `unknown` order may still be live and must not be reported as FAILED"); + result.CARequestID.Should().Be(OrderId); + } + + [Fact] + public async Task GetSingleRecord_V2_StatusUnknown_ReturnsPendingRecord() + { + var mock = new Mock(); // Loose: only the resolve/track result matters + mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync(OrderId, It.IsAny())) + .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse { OrderId = OrderId, Status = "unknown" })); + + var plugin = new CERTInextCAPlugin(mock.Object, V2Config()); + var record = await plugin.GetSingleRecord(OrderId); + + record.CARequestID.Should().Be(OrderId); + record.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + mock.Verify(c => c.ResolveAndDownloadCertificateV2Async(It.IsAny(), It.IsAny()), + Times.Never, "a pending order has no certificate to download"); + } + } +} diff --git a/CERTInext.Tests/V2WildcardEnrollmentTests.cs b/CERTInext.Tests/V2WildcardEnrollmentTests.cs new file mode 100644 index 0000000..9744106 --- /dev/null +++ b/CERTInext.Tests/V2WildcardEnrollmentTests.cs @@ -0,0 +1,216 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Regression tests for the non-UCC V2 single-domain SAN guard's wildcard-apex exemption: + /// a wildcard product's CSR/SAN dictionary routinely also carries the bare apex alongside + /// the wildcard domain itself (e.g. "example.com" alongside "*.example.com"), and the guard + /// must not reject that apex as an "extra SAN" the way it would for any other non-UCC + /// product. Only the apex is exempt — any other extra SAN is still rejected, and non-wildcard + /// products are unaffected. + /// + /// NOTE: these tests only confirm the guard's accept/reject decision. What is actually sent + /// to the CA for the apex (whether additionalDomains needs it, or CERTInext handles it + /// automatically for a wildcard product) is unverified live and unchanged by this fix — see + /// the comment in EnrollV2Async above the guard. + /// + public class V2WildcardEnrollmentTests + { + private static Mock NewMock() => + new Mock(MockBehavior.Strict); + + private static CERTInextCAPlugin BuildV2Plugin(ICERTInextClient client, string organizationNumber = null) => + new CERTInextCAPlugin(client, new CERTInextConfig + { + UseV2Api = true, + ApiUrl = "https://v2.certinext.io", + OAuthClientId = "my-client", + OAuthClientSecret = "my-secret", + RequestorName = "Test User", + RequestorEmail = "test@example.com", + SignerIp = "1.2.3.4", + SignerPlace = "New York", + OrganizationNumber = organizationNumber, + PickupRetries = 0 + }); + + private static EnrollmentProductInfo MakeWildcardProductInfo( + string productId, string productCode, string domainName) => + new EnrollmentProductInfo + { + ProductID = productId, + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = productCode, + ["ProductFamily"] = "ssl", + // No explicit ProductVariant — issue 0059 derives it from ProductID + // ("dv"/"ov"), avoiding a mismatch reject for the OV wildcard test. + ["DomainName"] = domainName + } + }; + + private static void StubCatalog(Mock mock, string productCode, string productTypeId) => + mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny())) + .ReturnsAsync(new List + { + new ProductDetail { ProductCode = productCode, ProductTypeId = productTypeId, Active = true } + }); + + private static void StubHappyOrderPlacement(Mock mock, string orderId = "ord_wc_001") + { + mock.Setup(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(new V2CreateOrderResponse { OrderId = orderId, Status = "pending-dcv" }); + + mock.Setup(c => c.SubmitCsrV2Async( + It.IsAny(), orderId, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), orderId, It.IsAny())) + .ReturnsAsync(new V2OrderStatusResponse { OrderId = orderId, Status = "pending-dcv" }); + } + + [Fact] + public async Task Enroll_V2_DvWildcard_ApexInSanDictionary_IsNotRejected() + { + var mock = NewMock(); + StubCatalog(mock, "839", "14"); // DV SSL Wildcard + StubHappyOrderPlacement(mock); + + var plugin = BuildV2Plugin(mock.Object); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=*.example.com", + san: new Dictionary + { + // Apex alongside the wildcard domain — routine for a wildcard CSR. + ["dns"] = new[] { "*.example.com", "example.com" } + }, + productInfo: MakeWildcardProductInfo(Constants.Products.DvSslWildcard, "839", "*.example.com"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION, + "the apex must be exempted from the single-domain guard for a wildcard product, " + + "not rejected as an extra SAN"); + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Enroll_V2_OvWildcard_ApexInSanDictionary_IsNotRejected() + { + var mock = NewMock(); + StubCatalog(mock, "843", "17"); // OV SSL Wildcard + StubHappyOrderPlacement(mock); + + // OV requires an organization block (issue 0028). + var plugin = BuildV2Plugin(mock.Object, organizationNumber: "ORG-TEST-001"); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=*.example.com", + san: new Dictionary + { + ["dns"] = new[] { "*.example.com", "example.com" } + }, + productInfo: MakeWildcardProductInfo(Constants.Products.OvSslWildcard, "843", "*.example.com"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Enroll_V2_DvWildcard_NonApexExtraSan_StillRejected_WithoutSuggestingUcc() + { + var mock = NewMock(); + StubCatalog(mock, "839", "14"); // DV SSL Wildcard + + var plugin = BuildV2Plugin(mock.Object); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=*.example.com", + san: new Dictionary + { + // The apex is exempt, but an unrelated extra domain is not. + ["dns"] = new[] { "*.example.com", "example.com", "other.example.com" } + }, + productInfo: MakeWildcardProductInfo(Constants.Products.DvSslWildcard, "839", "*.example.com"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.StatusMessage.Should().Contain("1 SAN(s) beyond"); + result.StatusMessage.Should().NotContain("UCC", + "a wildcard enrollment rejection must not suggest buying a UCC product"); + + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Enroll_V2_NonWildcardProduct_ApexNotExempt_StillRejected_AndSuggestsUcc() + { + // Sanity check that the exemption is wildcard-specific: a non-wildcard, non-UCC + // product's "extra" SAN set is unaffected by this fix, and its rejection message + // still offers the UCC suggestion (only wildcard products' wording changes). + var mock = NewMock(); + StubCatalog(mock, "842", "13"); // DV SSL (non-wildcard, non-UCC) + + var plugin = BuildV2Plugin(mock.Object); + + var result = await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=example.com", + san: new Dictionary + { + ["dns"] = new[] { "example.com", "other.example.com" } + }, + productInfo: MakeWildcardProductInfo(Constants.Products.DvSsl, "842", "example.com"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Status.Should().Be((int)EndEntityStatus.FAILED); + result.StatusMessage.Should().Contain("UCC"); + + mock.Verify(c => c.PlaceOrderV2Async( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + } +} diff --git a/CERTInext/API/CertificateRequest.cs b/CERTInext/API/CertificateRequest.cs index 2db42c6..29c26cb 100644 --- a/CERTInext/API/CertificateRequest.cs +++ b/CERTInext/API/CertificateRequest.cs @@ -142,6 +142,14 @@ public class SslOrderDetails [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public OrganizationDetails OrganizationDetails { get; set; } + [JsonPropertyName("delegationInformation")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DelegationInformation DelegationInformation { get; set; } + + [JsonPropertyName("technicalPointOfContact")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TechnicalPointOfContact TechnicalPointOfContact { get; set; } + [JsonPropertyName("additionalInformation")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public AdditionalInformation AdditionalInformation { get; set; } @@ -224,6 +232,39 @@ public class OrganizationDetails public string OrganizationNumber { get; set; } } + /// + /// Routes the order to a specific account group within CERTInext. Required by many + /// accounts even though the V1 docs list it as optional — without it, orders may be + /// placed against the default group and queued for additional review. + /// + public class DelegationInformation + { + [JsonPropertyName("groupNumber")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string GroupNumber { get; set; } + } + + /// + /// Technical point of contact metadata sent with SSL orders. CERTInext uses these + /// fields as the secondary contact for issuance-related notifications. When omitted, + /// some product configurations queue the order in Pending System RA waiting + /// for the field to be populated manually. + /// + public class TechnicalPointOfContact + { + [JsonPropertyName("tpcName")] + public string TpcName { get; set; } + + [JsonPropertyName("tpcEmail")] + public string TpcEmail { get; set; } + + [JsonPropertyName("tpcIsdCode")] + public string TpcIsdCode { get; set; } = "1"; + + [JsonPropertyName("tpcMobileNumber")] + public string TpcMobileNumber { get; set; } + } + public class AdditionalInformation { [JsonPropertyName("remarks")] @@ -288,6 +329,87 @@ public class TrackOrderDetails public string OrderNumber { get; set; } } + // --------------------------------------------------------------------------- + // GetDcv — POST {baseURL}GetDcv + // Retrieves Domain Control Validation token / file content / approver emails + // for a given (orderNumber, domainName, dcvMethod) tuple. + // + // The CERTInext V1 spec defines this body as wrapped in a "dcvDetails" block. + // Note: the Postman example for GetDcv uses "orderDetails" instead — this is + // an example typo; the inline spec, the response body, and the VerifyDcv body + // all use "dcvDetails" consistently. + // --------------------------------------------------------------------------- + + /// + /// Request body for POST {baseURL}GetDcv. + /// Returns DCV instructions (token / file / approver emails) for one domain + /// in the given order. + /// + public class GetDcvRequest + { + [JsonPropertyName("meta")] + public RequestMeta Meta { get; set; } + + [JsonPropertyName("dcvDetails")] + public DcvRequestDetails DcvDetails { get; set; } + } + + /// + /// Common request body for both GetDcv and VerifyDcv — both endpoints take the + /// same set of identification fields. is only set on + /// VerifyDcv requests when = email (3). + /// + public class DcvRequestDetails + { + /// Registered requestor email associated with the order. + [JsonPropertyName("requestorEmail")] + public string RequestorEmail { get; set; } + + /// Order number returned by GenerateOrderSSL. + [JsonPropertyName("orderNumber")] + public string OrderNumber { get; set; } + + /// Domain to retrieve / verify DCV for. + [JsonPropertyName("domainName")] + public string DomainName { get; set; } + + /// + /// DCV method (numeric string per CERTInext V1 spec): + /// "1" = DNS TXT record, "2" = HTTP file, "3" = email approver. + /// See . + /// + [JsonPropertyName("dcvMethod")] + public string DcvMethod { get; set; } + + /// + /// Approver email address. Required (and only used) on VerifyDcv when + /// is "3" (email). Must be one of the + /// dcvEmails returned by GetDcv. + /// + [JsonPropertyName("dcvEmail")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string DcvEmail { get; set; } + } + + // --------------------------------------------------------------------------- + // VerifyDcv — POST {baseURL}VerifyDcv + // Triggers CERTInext to verify the DCV record placed by the customer. + // --------------------------------------------------------------------------- + + /// + /// Request body for POST {baseURL}VerifyDcv. + /// Tells CERTInext to attempt domain verification using the previously + /// supplied DCV details. Reuses . + /// + public class VerifyDcvRequest + { + [JsonPropertyName("meta")] + public RequestMeta Meta { get; set; } + + [JsonPropertyName("dcvDetails")] + public DcvRequestDetails DcvDetails { get; set; } + } + // --------------------------------------------------------------------------- // GetCertificate — POST {baseURL}GetCertificate // Downloads the issued certificate for a fulfilled order. @@ -448,6 +570,10 @@ public class EnrollCertificateRequest [JsonPropertyName("csr")] public string Csr { get; set; } + [JsonPropertyName("validityYears")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? ValidityYears { get; set; } + [JsonPropertyName("validityDays")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? ValidityDays { get; set; } @@ -500,6 +626,36 @@ public class RenewCertificateRequest [JsonPropertyName("csr")] public string Csr { get; set; } + /// + /// Distinguished name of the certificate being renewed. Supplies the renewal order's + /// primary domain via its CN — without it the renewal falls back to the prior order's + /// requestor name, which is not a domain at all. + /// + [JsonPropertyName("subject")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Subject { get; set; } + + /// + /// Template/enrollment product code to submit the renewal order under. Without it, the + /// renewal falls back to the connector-level default product code, which is often unset — + /// leaving renewals to go out under an empty product code regardless of the template used. + /// + [JsonPropertyName("profileId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string ProfileId { get; set; } + + /// + /// SANs to carry onto the renewal order. Renewals previously submitted none, so a + /// renewed UCC certificate came back holding only its primary domain. + /// + [JsonPropertyName("sans")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public System.Collections.Generic.List Sans { get; set; } + + [JsonPropertyName("validityYears")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? ValidityYears { get; set; } + [JsonPropertyName("validityDays")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? ValidityDays { get; set; } diff --git a/CERTInext/API/CertificateResponse.cs b/CERTInext/API/CertificateResponse.cs index 0f3ca67..2f05fdc 100644 --- a/CERTInext/API/CertificateResponse.cs +++ b/CERTInext/API/CertificateResponse.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 Keyfactor // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. // You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, @@ -6,6 +6,7 @@ // and limitations under the License. using System.Collections.Generic; +using System.Text.Json; using System.Text.Json.Serialization; namespace Keyfactor.Extensions.CAPlugin.CERTInext.API @@ -151,10 +152,100 @@ public class TrackOrderResponseDetails [JsonPropertyName("revocationDetails")] public TrackOrderRevocationDetails RevocationDetails { get; set; } + /// + /// Per-domain DCV state plus a top-level status field. The wire + /// shape mixes typed and dynamic keys: { "<Domain Name>": { ... }, + /// "status": "..." }, so domain entries are surfaced via + /// . + /// + [JsonPropertyName("domainVerification")] + public TrackOrderDomainVerification DomainVerification { get; set; } + [JsonPropertyName("csr")] public string Csr { get; set; } } + /// + /// domainVerification block from TrackOrder. Wire shape is heterogeneous: + /// a known status field at the top level alongside one entry per domain + /// keyed by domain name. The per-domain entries are captured via + /// and exposed through + /// . + /// + public class TrackOrderDomainVerification + { + /// Block-level status. Documented values mirror . + [JsonPropertyName("status")] + public string Status { get; set; } + + /// + /// Raw per-domain entries as parsed from the response. Keys are the domain + /// names exactly as returned by CERTInext. Use + /// for typed access. + /// + [JsonExtensionData] + public Dictionary RawDomainEntries { get; set; } + + /// + /// Returns a typed dictionary of domain → , + /// skipping entries that fail to deserialize (e.g. unexpected scalar values). + /// Returns an empty dictionary if no per-domain entries were present. + /// + public Dictionary GetDomainEntries() + { + var result = new Dictionary(); + if (RawDomainEntries == null) return result; + + foreach (var kv in RawDomainEntries) + { + if (kv.Value.ValueKind != JsonValueKind.Object) continue; + try + { + var detail = kv.Value.Deserialize(); + if (detail != null) result[kv.Key] = detail; + } + catch (JsonException) + { + // ignore: entry shape unexpected, skip rather than failing the whole TrackOrder + } + } + + return result; + } + } + + /// + /// Per-domain DCV detail inside . + /// + public class DomainVerificationDetail + { + /// DCV method used / requested for this domain (typically the human label). + [JsonPropertyName("dcvMethod")] + public string DcvMethod { get; set; } + + /// + /// DCV completion status: "0"=Pending, "1"=Validated, "2"=Rejected. + /// See . + /// + [JsonPropertyName("dcvStatus")] + public string DcvStatus { get; set; } + + /// Domain status: "1"=Active, "2"=Inactive, "3"=Expired. + [JsonPropertyName("status")] + public string Status { get; set; } + + /// Timestamp at which the domain was successfully verified (when applicable). + [JsonPropertyName("verifiedDate")] + public string VerifiedDate { get; set; } + + /// + /// CAA check status: "1"=emSign authorized or no CAA present, + /// "2"=Authorization required, "3"=Authorization pending. + /// + [JsonPropertyName("caaStatus")] + public string CaaStatus { get; set; } + } + public class TrackOrderRequestorInfo { [JsonPropertyName("requestorName")] @@ -189,6 +280,84 @@ public class TrackOrderRevocationDetails public string RevokeRequestStatus { get; set; } } + // --------------------------------------------------------------------------- + // GetDcv response — POST {baseURL}GetDcv + // + // Per the V1 spec the dcvDetails block contains different fields depending + // on the dcvMethod that was requested: + // dcvMethod=1 (DNS TXT) → token populated + // dcvMethod=2 (HTTP) → fileName + fileContent populated + // dcvMethod=3 (email) → dcvEmails populated + // + // The TXT record HOSTNAME for dcvMethod=1 is NOT returned by this endpoint. + // The CERTInext V1 documentation does not specify the convention. The plugin + // uses Constants.Dcv.DefaultTxtRecordTemplate ("_emsign-validation.{0}") by + // default, overridable via the DcvTxtRecordTemplate connector config field. + // --------------------------------------------------------------------------- + + /// + /// Response from POST {baseURL}GetDcv. + /// + public class GetDcvResponse + { + [JsonPropertyName("meta")] + public ResponseMeta Meta { get; set; } + + [JsonPropertyName("dcvDetails")] + public DcvResponseDetails DcvDetails { get; set; } + } + + /// + /// DCV instructions returned by GetDcv. Field population depends on the + /// requested dcvMethod (see class-level remarks on ). + /// + public class DcvResponseDetails + { + /// + /// Token / target address value to publish for DNS TXT-based DCV + /// (dcvMethod = 1). Empty for other methods. + /// + [JsonPropertyName("token")] + public string Token { get; set; } + + /// + /// File name to host under /.well-known/pki-validation/ for HTTP + /// DCV (dcvMethod = 2). Empty for other methods. + /// + [JsonPropertyName("fileName")] + public string FileName { get; set; } + + /// + /// File body to serve at the well-known path for HTTP DCV (dcvMethod = 2). + /// Empty for other methods. + /// + [JsonPropertyName("fileContent")] + public string FileContent { get; set; } + + /// + /// CA/B Forum approved approver email candidates for email DCV + /// (dcvMethod = 3). Empty for other methods. + /// + [JsonPropertyName("dcvEmails")] + public List DcvEmails { get; set; } + } + + // --------------------------------------------------------------------------- + // VerifyDcv response — POST {baseURL}VerifyDcv + // Body contains only the meta block (success/failure status). + // --------------------------------------------------------------------------- + + /// + /// Response from POST {baseURL}VerifyDcv. Body is meta-only; the actual + /// per-domain verification status is observed via subsequent TrackOrder + /// calls (see ). + /// + public class VerifyDcvResponse + { + [JsonPropertyName("meta")] + public ResponseMeta Meta { get; set; } + } + // --------------------------------------------------------------------------- // GetCertificate response — POST {baseURL}GetCertificate // --------------------------------------------------------------------------- @@ -418,6 +587,7 @@ public List FlattenProducts() ProductCode = p.ProductCode, ProductName = p.ProductName, ProductType = cat.CategoryName, + ProductTypeId = p.ProductTypeId, Active = true // API does not return an active flag at this level }); } @@ -489,6 +659,15 @@ public class ProductDetail [JsonPropertyName("productType")] public string ProductType { get; set; } + /// + /// Numeric product type ID from the wire (), + /// e.g. "13" for DV SSL. UCC (multi-SAN) family values are 15/18/20/21/22 — see + /// issues/f3-v2-multi-san-limitation.md. Not populated by every parse path (only set + /// where the source shape actually carries a productTypeID field). + /// + [JsonPropertyName("productTypeID")] + public string ProductTypeId { get; set; } + /// /// Always true for products returned by the API — the API only /// returns products that are available on the account. @@ -602,6 +781,14 @@ public class LegacyGetCertificateResponse [JsonPropertyName("expiresAt")] public System.DateTime? ExpiresAt { get; set; } + /// + /// Order placement date parsed from orderDate in the order report. Distinct from + /// (a pending order has no issuance date) — used to bound + /// DCV-during-sync to recently-placed orders (issue 0002). + /// + [JsonPropertyName("orderDate")] + public System.DateTime? OrderDate { get; set; } + /// Revocation date parsed from revokeProcessedDate in TrackOrder revocationDetails. [JsonPropertyName("revokedAt")] public System.DateTime? RevokedAt { get; set; } diff --git a/CERTInext/API/V2/CertificateRequestV2.cs b/CERTInext/API/V2/CertificateRequestV2.cs new file mode 100644 index 0000000..2bf0e18 --- /dev/null +++ b/CERTInext/API/V2/CertificateRequestV2.cs @@ -0,0 +1,525 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Text.Json.Serialization; +using System.Text.Json; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.API.V2 +{ + // --------------------------------------------------------------------------- + // V2 REST API — Request DTOs + // + // Auth: POST {ApiUrl}/oauth/token (form-encoded client_credentials; ApiUrl is the V2 base + // URL when UseV2Api=true — issues/0022 config consolidation) + // Product code: X-Product-Code header (not in body) + // Idempotency: Idempotency-Key header sent on order-create/revoke, but the spec doesn't + // document it for those endpoints and only says "parsed today, enforced in a future release" + // for the endpoints (Verify DCV, Domains) it does document it on — see issue 0032. + // --------------------------------------------------------------------------- + + /// + /// Requestor information block sent with every V2 order. + /// + public class V2Requestor + { + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("email")] + public string Email { get; set; } + + [JsonPropertyName("phone")] + public string Phone { get; set; } + + [JsonPropertyName("designation")] + public string Designation { get; set; } + } + + /// + /// Certificate parameters block for V2 SSL orders. + /// + public class V2CertificateParams + { + [JsonPropertyName("domain")] + public string Domain { get; set; } + + [JsonPropertyName("autoSecureWww")] + public bool AutoSecureWww { get; set; } = false; + + /// + /// SAN list for UCC (multi-SAN) product variants — DV/OV/EV UCC and DV/OV Wildcard UCC + /// (Catalog productTypeID 15/18/20/21/22). Each entry must be a valid FQDN + /// (wildcards allowed only for the Wildcard UCC variants). Per the V2 spec's Submit CSR + /// guidance, these SANs come from the order, not the CSR — the CSR must carry only the + /// primary domain in CN for UCC orders. Omitted from the wire body for non-UCC products + /// (single-domain orders are unaffected). See issues/f3-v2-multi-san-limitation.md. + /// + [JsonPropertyName("additionalDomains")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public System.Collections.Generic.List AdditionalDomains { get; set; } + } + + /// + /// Organization block for V2 SSL orders. Per the V2 spec's field table (SSL/TLS + /// Certificates folder description), this block is "Conditional — Mandatory for OV / EV" + /// and every OV/EV create example in the spec sends exactly these three fields. Live- + /// confirmed (issue 0028): submitting an OV order with no organization block gets + /// HTTP 422 [EMS-1180] Organization Name cannot be empty — CERTInext resolves the + /// certificate's organization name server-side from organizationNumber, so an + /// absent/empty block leaves it with nothing to resolve. There is no separate + /// "organization name" field to send; supplying a valid, pre-vetted + /// organizationNumber is what the CA needs. + /// + public class V2OrganizationParams + { + [JsonPropertyName("organizationNumber")] + public string OrganizationNumber { get; set; } + + /// + /// Re-uses an existing vetted organization instead of queuing the order for manual + /// vetting. Mirrors the V1 OrganizationDetails.PreVetting="1" semantics — sent + /// as JSON true whenever OrganizationNumber is configured. + /// + [JsonPropertyName("preVetted")] + public bool PreVetted { get; set; } = true; + + /// + /// Optional per spec (re-vetting flow token). Not currently surfaced as plugin config; + /// omitted from the wire body when null/empty. + /// + [JsonPropertyName("preVettingToken")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string PreVettingToken { get; set; } + } + + /// + /// Subscription parameters block for V2 orders (validity, auto-renewal). Per the V2 spec, + /// omitting this block entirely defaults auto-renew to ON (1-year, 30-day window) at the CA, + /// so EnrollV2Async always sends it, driving / + /// from the connector's SubscriptionAutoRenew/ + /// SubscriptionRenewCriteriaDays config (issue 0027 item 2a/2b). + /// + public class V2SubscriptionParams + { + [JsonPropertyName("validityYears")] + public int ValidityYears { get; set; } = 1; + + [JsonPropertyName("autoRenew")] + public bool AutoRenew { get; set; } = false; + + /// + /// Days before expiry CERTInext auto-renews; only meaningful when + /// is true. Null when the connector's SubscriptionRenewCriteriaDays is blank/unset — the + /// client's global JSON serializer options (CERTInextClient.GetJsonOptions, + /// DefaultIgnoreCondition = WhenWritingNull) omit the field from the wire in that + /// case, letting the CA fall back to its documented default of 30. + /// + [JsonPropertyName("renewBeforeDays")] + public int? RenewBeforeDays { get; set; } + } + + /// + /// Technical point-of-contact block for V2 orders. Per the V2 spec's field table (SSL/TLS + /// Certificates folder description — confirmed identical for the Document Signer and + /// Private PKI folders; and + /// reuse this type, see issue 0033), all four + /// subfields are documented Optional. Unlike + /// V1's , + /// which sends ISD code and mobile number as two separate fields + /// (tpcIsdCode/tpcMobileNumber), the V2 shape has a single phone field — + /// composed from the connector's ISD-code + mobile-number config pair by + /// . + /// Despite being spec-Optional, EnrollV2Async always populates this block (never omits + /// it), mirroring V1's fallback-to-Requestor* defaulting so a blank connector config never + /// results in a silently-blank contact. See issues/0030-v2-technical-contact-not-sent.md. + /// + public class V2TechnicalPointOfContact + { + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("email")] + public string Email { get; set; } + + [JsonPropertyName("phone")] + public string Phone { get; set; } + + [JsonPropertyName("designation")] + public string Designation { get; set; } + } + + /// + /// Subscriber agreement block required for V2 SSL orders. + /// + public class V2AgreementParams + { + [JsonPropertyName("signerName")] + public string SignerName { get; set; } + + /// Optional in V2. Omitted from serialisation when null or empty. + [JsonPropertyName("signerIp")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string SignerIp { get; set; } + + /// Optional in V2. Omitted from serialisation when null or empty. + [JsonPropertyName("signerPlace")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string SignerPlace { get; set; } + + [JsonPropertyName("accepted")] + public bool Accepted { get; set; } = true; + } + + /// + /// Request body for POST /api/certinext/v2/ssl-certificates. + /// Product code is sent as the X-Product-Code header (not in this body). + /// + public class V2CreateSslOrderRequest + { + [JsonPropertyName("productVariant")] + public string ProductVariant { get; set; } = "dv"; + + /// + /// "all" = full notification set, "0" = silent, null = omitted (CA defaults to "all"). + /// See + /// for the connector config mapping (issue 0027 item 1a). No default here — relies solely + /// on the client's global DefaultIgnoreCondition = WhenWritingNull serializer option + /// to omit the key when null, the same pattern + /// uses. + /// + [JsonPropertyName("emailNotifications")] + public string EmailNotifications { get; set; } + + [JsonPropertyName("requestor")] + public V2Requestor Requestor { get; set; } + + /// + /// Mandatory for OV/EV, omitted entirely for DV (per spec, "Conditional — Mandatory + /// for OV / EV"). + /// leaves this null for DV orders rather than sending an empty/placeholder block that + /// could itself trigger a different validation error. + /// + [JsonPropertyName("organization")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public V2OrganizationParams Organization { get; set; } + + [JsonPropertyName("certificate")] + public V2CertificateParams Certificate { get; set; } + + [JsonPropertyName("subscription")] + public V2SubscriptionParams Subscription { get; set; } + + [JsonPropertyName("agreement")] + public V2AgreementParams Agreement { get; set; } + + /// + /// Optional per spec, but always populated by EnrollV2Async — see + /// for the fallback/composition rules. + /// + [JsonPropertyName("technicalPointOfContact")] + public V2TechnicalPointOfContact TechnicalPointOfContact { get; set; } + + [JsonPropertyName("remarks")] + public string Remarks { get; set; } + + /// + /// Optional billing group to attribute this order to. Mirrors V1's + /// — + /// omitted entirely (rather than sent empty) when the connector has no + /// GroupNumber configured, so the order falls back to the account's default group. + /// + [JsonPropertyName("groupNumber")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string GroupNumber { get; set; } + } + + /// + /// Request body for POST /api/certinext/v2/private-pki-certificates (issue 0033). Modelled + /// from the V2 spec's "Private PKI Certificates" folder field table ("Field requirements (in + /// body order)"): variant, requestor.name, requestor.email and + /// hostname are strictly mandatory ("400 if missing"); everything else is Optional. + /// Per the spec, "Private PKI has no DCV, no organization block, and no Subscriber + /// Agreement" — so, unlike , there is no + /// productVariant, organization, certificate or agreement block + /// here. SANs go in , which (unlike SSL's FQDN-only + /// additionalDomains) accepts IP literals. + /// + /// Only the fields EnrollV2Async populates are modelled. Spec-Optional fields the + /// plugin has no source for (caProfileId, masterProductId — both "derived from + /// X-Product-Code" — saveAsDraft, requestId, csr, tags, + /// customFields) are deliberately omitted, matching how the SSL DTO treats its own + /// unused optional fields. The CSR is submitted by the separate Submit CSR call, per the + /// spec's Private PKI workflow (Create -> Submit CSR -> Track -> Download). + /// Product code is sent as the X-Product-Code header (not in this body). + /// + public class V2CreatePrivatePkiOrderRequest + { + /// + /// Mandatory. Spec enum for create: intranet-ssl / igtf-host + /// (). + /// + [JsonPropertyName("variant")] + public string Variant { get; set; } + + /// + /// Optional (spec default all). Same connector mapping as + /// ; null is omitted on the wire. + /// + [JsonPropertyName("emailNotifications")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string EmailNotifications { get; set; } + + /// Optional. Omitted when the connector has no GroupNumber configured. + [JsonPropertyName("groupNumber")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string GroupNumber { get; set; } + + /// Mandatory (name + email strictly mandatory; phone/designation optional). + [JsonPropertyName("requestor")] + public V2Requestor Requestor { get; set; } + + /// Mandatory. Spec: "hostname - primary CN". + [JsonPropertyName("hostname")] + public string Hostname { get; set; } + + /// + /// Optional. Spec: "additionalHosts[] - SAN list (DNS names or IPv4 / IPv6)". + /// Omitted from the wire body when null (no additional SANs). + /// + [JsonPropertyName("additionalHosts")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public System.Collections.Generic.List AdditionalHosts { get; set; } + + /// Optional (autoRenew defaults ON at the CA when omitted — always sent, as for SSL). + [JsonPropertyName("subscription")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public V2SubscriptionParams Subscription { get; set; } + + [JsonPropertyName("remarks")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Remarks { get; set; } + + /// Optional per spec; populated with the same fallbacks the SSL body uses. + [JsonPropertyName("technicalPointOfContact")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public V2TechnicalPointOfContact TechnicalPointOfContact { get; set; } + } + + /// + /// subject block of a V2 Document Signer (signature) order (issue 0033). Modelled from + /// the V2 spec's "Document Signer Certificates" folder field table. Only + /// is strictly mandatory ("400 if missing"); the rest are Optional or Conditional on + /// subjectType: + /// - firstName / lastName: "required for natural-person / legal-person" + /// - organizationName: "required for legal-person / legal-entity" + /// - organizationIdentificationNumber: "typically required for legal-entity" + /// - businessCategory: "legal-entity" + /// - countryCode: "Optional (ISO 3166-1 alpha-2)" + /// Every non-mandatory field is omitted from the wire when null so a legal-entity body never + /// carries empty person-name keys (and vice versa). + /// + public class V2SignatureSubject + { + [JsonPropertyName("firstName")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string FirstName { get; set; } + + [JsonPropertyName("lastName")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string LastName { get; set; } + + /// Mandatory for every subjectType. + [JsonPropertyName("email")] + public string Email { get; set; } + + [JsonPropertyName("phone")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Phone { get; set; } + + [JsonPropertyName("designation")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Designation { get; set; } + + [JsonPropertyName("organizationName")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string OrganizationName { get; set; } + + [JsonPropertyName("organizationUnit")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string OrganizationUnit { get; set; } + + [JsonPropertyName("organizationIdentificationNumber")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string OrganizationIdentificationNumber { get; set; } + + /// Spec examples: Business Entity | Government | Non-Commercial Entity. + [JsonPropertyName("businessCategory")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string BusinessCategory { get; set; } + + /// Spec examples: passport | driving-license | national-id. + [JsonPropertyName("identityDocumentType")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string IdentityDocumentType { get; set; } + + [JsonPropertyName("identificationNumber")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string IdentificationNumber { get; set; } + + [JsonPropertyName("streetAddress1")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string StreetAddress1 { get; set; } + + [JsonPropertyName("streetAddress2")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string StreetAddress2 { get; set; } + + [JsonPropertyName("locality")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Locality { get; set; } + + [JsonPropertyName("state")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string State { get; set; } + + [JsonPropertyName("postalCode")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string PostalCode { get; set; } + + [JsonPropertyName("countryCode")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string CountryCode { get; set; } + } + + /// + /// Request body for POST /api/certinext/v2/signature-certificates (issue 0033). Modelled from + /// the V2 spec's "Document Signer Certificates" folder field table. Strictly mandatory ("400 + /// if missing"): subjectType, requestor.name, requestor.email, + /// subject.email; "The subject.* fields beyond email vary by subjectType - + /// the backend applies stricter per-type rules." + /// + /// Not yet wired into EnrollV2Async. The body shape is fully determined by the + /// spec, but several of its mandatory/conditional values (subjectType, + /// subject.email, the per-type subject name/organization fields) have no settled + /// source in the Command enrollment inputs yet — see issue 0033. Until that is decided, + /// EnrollV2Async fails a ProductFamily=signature enrollment fast instead of + /// sending any body. The DTO and its client overload exist so that wiring is a pure + /// source-mapping change. + /// + /// reuses (the spec's signature and + /// SSL agreement tables are identical: signerName, signerPlace, + /// accepted). Leave null for this family — + /// the spec's signature Accept Agreement note says "Do not send signerIp in the body - + /// it will be ignored", and the create field table does not list it. + /// Product code is sent as the X-Product-Code header (not in this body). + /// + public class V2CreateSignatureOrderRequest + { + /// + /// Mandatory. Spec enum: natural-person / legal-person / legal-entity + /// (). + /// + [JsonPropertyName("subjectType")] + public string SubjectType { get; set; } + + [JsonPropertyName("emailNotifications")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string EmailNotifications { get; set; } + + [JsonPropertyName("groupNumber")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string GroupNumber { get; set; } + + /// Mandatory (name + email strictly mandatory). + [JsonPropertyName("requestor")] + public V2Requestor Requestor { get; set; } + + /// Mandatory; see for the per-type rules. + [JsonPropertyName("subject")] + public V2SignatureSubject Subject { get; set; } + + [JsonPropertyName("subscription")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public V2SubscriptionParams Subscription { get; set; } + + /// Spec: "Optional - required before issuance". + [JsonPropertyName("agreement")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public V2AgreementParams Agreement { get; set; } + + [JsonPropertyName("remarks")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Remarks { get; set; } + + [JsonPropertyName("technicalPointOfContact")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public V2TechnicalPointOfContact TechnicalPointOfContact { get; set; } + } + + /// + /// Request body for PUT /api/certinext/v2/{family}-certificates/{orderId}/csr. The spec + /// documents the identical { "csr", "attested" } body for the SSL/TLS, Private PKI + /// and Document Signer families (issue 0033), so one shape serves all three. + /// + public class V2SubmitCsrRequest + { + [JsonPropertyName("csr")] + public string Csr { get; set; } + + [JsonPropertyName("attested")] + public bool Attested { get; set; } = false; + } + + /// + /// Request body for POST /api/certinext/v2/{family}-certificates/{orderId}/revoke. + /// + public class V2RevokeRequest + { + /// + /// RFC 5280 string reason, kebab-case per the V2 spec. Valid values: unspecified, + /// key-compromise, ca-compromise, affiliation-changed, superseded, + /// cessation-of-operation, certificate-hold, privilege-withdrawn (plus + /// aa-compromise on the signature-certificates / private-pki-certificates + /// endpoints). Sending camelCase gets HTTP 400 — see issues/0019. + /// + [JsonPropertyName("reason")] + public string Reason { get; set; } = "unspecified"; + + [JsonPropertyName("note")] + public string Note { get; set; } + } + + /// + /// Request body for POST /api/certinext/v2/{family}-certificates/{orderId}/cancel + /// ("Cancel Order"). The SSL spec entry marks reason as "required free-text. + /// Persisted in the audit log"; an empty reason is rejected with EMS-984 (issue 0039). + /// + public class V2CancelOrderRequest + { + [JsonPropertyName("reason")] + public string Reason { get; set; } + } + + /// + /// Outcome of a V2 Cancel Order call that did not throw (issue 0039). + /// + public enum V2CancelOrderOutcome + { + /// HTTP 2xx (spec: 204 No Content) — the order is cancelled. + Cancelled, + + /// HTTP 422 — spec: "order already in a terminal state"; nothing was cancelled. + AlreadyTerminal + } +} diff --git a/CERTInext/API/V2/CertificateResponseV2.cs b/CERTInext/API/V2/CertificateResponseV2.cs new file mode 100644 index 0000000..db8502f --- /dev/null +++ b/CERTInext/API/V2/CertificateResponseV2.cs @@ -0,0 +1,506 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.API.V2 +{ + // --------------------------------------------------------------------------- + // V2 REST API — Response DTOs + // --------------------------------------------------------------------------- + + /// + /// Standard OAuth2 client_credentials token response (flat shape — no tokenDetails wrapper). + /// POST {ApiUrl}/oauth/token with form-encoded body (ApiUrl is the V2 base URL when + /// UseV2Api=true — issues/0022 config consolidation). + /// + public class V2TokenResponse + { + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } + + [JsonPropertyName("token_type")] + public string TokenType { get; set; } + + /// Lifetime in seconds. Typically 3600 (1 hour). + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; set; } = 3600; + + [JsonPropertyName("refresh_token")] + public string RefreshToken { get; set; } + } + + /// + /// HATEOAS link object present in V2 responses. + /// + public class V2Link + { + [JsonPropertyName("href")] + public string Href { get; set; } + } + + /// + /// _links map returned by V2 order responses. + /// Known keys: self, dcv, csr, agreement, certificate, cancel, revoke. + /// + public class V2Links + { + [JsonPropertyName("self")] + public V2Link Self { get; set; } + + [JsonPropertyName("dcv")] + public V2Link Dcv { get; set; } + + [JsonPropertyName("csr")] + public V2Link Csr { get; set; } + + [JsonPropertyName("agreement")] + public V2Link Agreement { get; set; } + + [JsonPropertyName("certificate")] + public V2Link Certificate { get; set; } + + [JsonPropertyName("cancel")] + public V2Link Cancel { get; set; } + + [JsonPropertyName("revoke")] + public V2Link Revoke { get; set; } + } + + /// + /// Response body for POST /api/certinext/v2/{family}-certificates (201 Created). + /// + public class V2CreateOrderResponse + { + [JsonPropertyName("orderId")] + public string OrderId { get; set; } + + [JsonPropertyName("requestId")] + public string RequestId { get; set; } + + /// + /// Initial order status. Typically "pending-dcv" for SSL DV orders. + /// + [JsonPropertyName("status")] + public string Status { get; set; } + + [JsonPropertyName("_links")] + public V2Links Links { get; set; } + } + + /// + /// Response body for GET /api/certinext/v2/{family}-certificates/{orderId}. + /// Contains lifecycle status only — serial number and validity dates are NOT present; + /// those are only in the Download Certificate response. + /// + public class V2OrderStatusResponse + { + [JsonPropertyName("orderId")] + public string OrderId { get; set; } + + [JsonPropertyName("requestId")] + public string RequestId { get; set; } + + /// Current order status string (e.g. "pending-dcv", "issued", "revoked"). + [JsonPropertyName("status")] + public string Status { get; set; } + + [JsonPropertyName("productVariant")] + public string ProductVariant { get; set; } + + [JsonPropertyName("domain")] + public string Domain { get; set; } + + [JsonPropertyName("_links")] + public V2Links Links { get; set; } + + /// + /// Populated only when is "revoked" (issues/0034 — confirmed live + /// against a real revoked SSL order, 2026-09-25). Absent entirely from the wire — not + /// present-but-null — when the order has never been revoked, which + /// System.Text.Json deserializes as a null + /// reference with no special handling required. + /// + [JsonPropertyName("revocation")] + public V2RevocationDetails Revocation { get; set; } + + /// ISO 8601 timestamp when the certificate was issued. Present when status = "issued". + [JsonPropertyName("issuedAt")] + public string IssuedAt { get; set; } + + /// ISO 8601 timestamp when the certificate expires. Present when status = "issued". + [JsonPropertyName("expiresAt")] + public string ExpiresAt { get; set; } + + /// + /// Per-domain DCV/CAA verification detail (issue 0042). Present on UCC orders whose + /// additional SANs each carry their own DCV state; confirmed live 2026-09-28 (order + /// 7465857196). Absent entirely on older/simpler response shapes — callers must treat + /// a null / + /// the same as "no per-domain detail available" and fall back to the single top-level + /// field. + /// + [JsonPropertyName("verifications")] + public V2Verifications Verifications { get; set; } + } + + /// + /// Top-level verifications object on the V2 Track Order response (issue 0042). + /// Only the domain sub-block is modeled — that is the only one this plugin's DCV + /// automation drives. + /// + public class V2Verifications + { + [JsonPropertyName("domain")] + public V2DomainVerification Domain { get; set; } + } + + /// + /// verifications.domain block (issue 0042). is an aggregate that + /// is NOT reliable for driving DCV decisions — confirmed live 2026-09-28 that it stayed + /// "PENDING" even after the parent order was cancelled and every per-domain + /// had already flipped to REJECTED. Use it + /// for logging only; always decide per-domain from . + /// + public class V2DomainVerification + { + /// Aggregate status (e.g. "PENDING"). Logging only — see class remarks. + [JsonPropertyName("status")] + public string Status { get; set; } + + /// Per-domain verification entries — one per domain on the order (primary + any + /// UCC additional SANs). + [JsonPropertyName("domains")] + public List Domains { get; set; } + } + + /// + /// A single entry in verifications.domain.domains[] (issue 0042). Confirmed live + /// 2026-09-28, order 7465857196: + /// {"domain":"a.pending....example.com","domainStatus":"ACTIVE","dcvStatus":"PENDING","caaStatus":"SKIPPED"} + /// for a still-pending SAN, versus + /// {"domain":"...","domainStatus":"ACTIVE","dcvMethod":"dns-txt","dcvStatus":"VERIFIED","verifiedAt":"...","caaStatus":"PASSED"} + /// once verified. and are absent entirely + /// (not present-but-null) on a pending entry — both are nullable here for exactly that + /// reason; a fix must not assume is populated before treating an + /// entry as needing DNS-01 DCV. + /// + public class V2DomainVerificationEntry + { + [JsonPropertyName("domain")] + public string Domain { get; set; } + + [JsonPropertyName("domainStatus")] + public string DomainStatus { get; set; } + + /// Absent on the wire until reaches VERIFIED — see class + /// remarks. This plugin only ever drives dns-txt DCV, so a null/absent value here is + /// treated as "use DNS-01", never as an unknown/unsupported method. + [JsonPropertyName("dcvMethod")] + public string DcvMethod { get; set; } + + /// PENDING / VERIFIED / REJECTED (see + /// DcvStatus* constants). Drive all per-domain DCV decisions from this field, never from + /// the aggregate . + [JsonPropertyName("dcvStatus")] + public string DcvStatus { get; set; } + + /// ISO 8601 timestamp. Absent until reaches VERIFIED. + [JsonPropertyName("verifiedAt")] + public string VerifiedAt { get; set; } + + [JsonPropertyName("caaStatus")] + public string CaaStatus { get; set; } + } + + /// + /// Nested revocation object on the V2 Track Order response (issues/0034). Confirmed + /// live against a real revoked SSL order (2026-09-25): + /// {"status":"Certificate Revoked","reason":"cessation-of-operation","processedAt":"2026-09-24T20:44:41Z"} + /// + public class V2RevocationDetails + { + /// + /// Human-readable revocation engine status (e.g. "Certificate Revoked"), mirroring the + /// outer certificateState field on the same response. Not a distinct enum worth + /// modeling separately from . + /// + [JsonPropertyName("status")] + public string Status { get; set; } + + /// + /// RFC 5280 reason name, hyphenated on the wire (e.g. "cessation-of-operation", + /// "key-compromise") — NOT V1's camelCase convention. See + /// for the known values and + /// Models.StatusMapper.V2RevocationReasonToCrlCode for the reverse mapping back + /// to an RFC 5280 CRL reason code. + /// + [JsonPropertyName("reason")] + public string Reason { get; set; } + + /// Effective revocation time (CA-recorded). RFC 3339 / ISO 8601 UTC. + [JsonPropertyName("processedAt")] + public DateTime? ProcessedAt { get; set; } + } + + /// + /// Response body for GET /api/certinext/v2/{family}-certificates/{orderId}/certificate. + /// Returns the leaf certificate and, when present, intermediate chain PEM strings. + /// + public class V2CertificateDownloadResponse + { + [JsonPropertyName("orderId")] + public string OrderId { get; set; } + + [JsonPropertyName("serialNumber")] + public string SerialNumber { get; set; } + + [JsonPropertyName("subject")] + public string Subject { get; set; } + + [JsonPropertyName("issuer")] + public string Issuer { get; set; } + + [JsonPropertyName("notBefore")] + public DateTime? NotBefore { get; set; } + + [JsonPropertyName("notAfter")] + public DateTime? NotAfter { get; set; } + + /// PEM-encoded leaf certificate. + [JsonPropertyName("certificatePem")] + public string CertificatePem { get; set; } + + /// + /// Array of intermediate PEM strings returned alongside the leaf cert. + /// May be null or empty when the CA does not include chain in the response. + /// + [JsonPropertyName("chainPem")] + public List ChainPem { get; set; } + } + + /// + /// Response body for GET /api/certinext/v2/ssl-certificates/{orderId}/dcv. + /// Returns the DCV challenge token needed to publish a DNS TXT record. + /// + /// Confirmed live shape (issues/0037, live probe 2026-09-25): exactly two fields — + /// {"tokenExpiryDate": "...", "token": "..."}. This matches neither the spec's + /// own worked example for this endpoint (orderNumber/domainName/ + /// dcvMethod/fileNameContent, which this DTO originally modeled) nor the + /// spec's prose for the same endpoint (method/txtToken). There is no + /// orderNumber, domainName, or method field on the wire, so none are + /// modeled here: + /// - order id and domain name are already known from the local order-placement + /// context before DCV is ever attempted (see call sites of + /// ), so they don't need + /// to be echoed back by this response. + /// - the V2 DCV path only ever performs DNS-TXT validation — the hostname + /// (_emudhra-challenge.{domain}) and validator ("dns-01") are both hardcoded + /// in , which never reads a + /// method from this response even in the pre-fix DTO — so no method field is needed. + /// + public class V2DcvChallengeResponse + { + /// Value to publish as the DNS TXT record. + [JsonPropertyName("token")] + public string Token { get; set; } + + [JsonPropertyName("tokenExpiryDate")] + public string TokenExpiryDate { get; set; } + } + + /// + /// Request body for POST /api/certinext/v2/ssl-certificates/{orderId}/dcv/verify. + /// + public class V2DcvVerifyRequest + { + [JsonPropertyName("domain")] + public string Domain { get; set; } + + /// "dns-txt" for DNS TXT record validation. + [JsonPropertyName("method")] + public string Method { get; set; } + } + + /// + /// Response body for POST /api/certinext/v2/ssl-certificates/{orderId}/dcv/verify. + /// 200 OK with this body, or 204 No Content, both indicate success. + /// 422 with overallStatus="FAILED" indicates verification failure. + /// + public class V2DcvVerifyResponse + { + /// "VERIFIED" on success, "FAILED" on failure. + [JsonPropertyName("overallStatus")] + public string OverallStatus { get; set; } + + [JsonPropertyName("method")] + public string Method { get; set; } + + [JsonPropertyName("verifiedAt")] + public string VerifiedAt { get; set; } + } + + /// + /// RFC 7807 Problem Details error response from the V2 API. + /// Content-Type: application/problem+json + /// + public class V2ProblemDetails + { + [JsonPropertyName("type")] + public string Type { get; set; } + + [JsonPropertyName("title")] + public string Title { get; set; } + + [JsonPropertyName("status")] + public int Status { get; set; } + + [JsonPropertyName("detail")] + public string Detail { get; set; } + + [JsonPropertyName("instance")] + public string Instance { get; set; } + + /// Field-level validation errors (optional). + [JsonPropertyName("errors")] + public List Errors { get; set; } + } + + /// + /// A single field-level validation error from RFC 7807 errors array. + /// + public class V2FieldError + { + [JsonPropertyName("field")] + public string Field { get; set; } + + [JsonPropertyName("message")] + public string Message { get; set; } + } + + /// + /// Response body for GET /api/certinext/v2/auth/me. + /// Used as the V2 connectivity/ping check. + /// + public class V2AuthMeResponse + { + [JsonPropertyName("accountNumber")] + public string AccountNumber { get; set; } + + [JsonPropertyName("authType")] + public string AuthType { get; set; } + } + + /// + /// Spring-style page envelope for GET /api/certinext/v2/reports/orders (issues/0022, + /// Phase 0 live probe — confirmed live 2026-09-23; the spec's example body is stale, + /// its field table is what's actually returned). + /// + public class V2OrdersReportResponse + { + [JsonPropertyName("content")] + public List Content { get; set; } + + /// 1-based page index (mirrors the request's page query param). + [JsonPropertyName("page")] + public int Page { get; set; } + + [JsonPropertyName("size")] + public int Size { get; set; } + + [JsonPropertyName("totalElements")] + public long TotalElements { get; set; } + + [JsonPropertyName("totalPages")] + public int TotalPages { get; set; } + } + + /// + /// A single row from the V2 /reports/orders "content" array. Field names match the live + /// field table confirmed in issues/0022 Phase 0 (NOT the spec's stale example body, which + /// uses different field names — state/identifier/account/group/product). + /// + /// orderStatus/certificateStatus are human-readable display strings (e.g. "Order Accepted", + /// "Certificate Downloaded") — NOT the V2 `status` enum used by TrackOrder + /// (see and + /// Keyfactor.Extensions.CAPlugin.CERTInext.Models.StatusMapper.V2StatusToRequestDisposition). + /// See CERTInextCAPlugin.MapV2ReportStatusToDisposition for how these display strings + /// are mapped, and issues/0022 for the vocabulary observed so far (not confirmed exhaustive). + /// + public class OrderReportEntryV2 + { + [JsonPropertyName("orderNumber")] + public string OrderNumber { get; set; } + + /// Most-recent request identifier on the order (reissues create new requests). + [JsonPropertyName("requestNumber")] + public string RequestNumber { get; set; } + + /// Human-readable order state, e.g. "Order Accepted", "Order Fulfilled". + [JsonPropertyName("orderStatus")] + public string OrderStatus { get; set; } + + /// Human-readable request/certificate state, e.g. "Pending for Approver", "Certificate Downloaded". + [JsonPropertyName("certificateStatus")] + public string CertificateStatus { get; set; } + + /// Hex serial assigned by the CA. Empty until issuance. + [JsonPropertyName("certificateSerialNumber")] + public string CertificateSerialNumber { get; set; } + + /// Certificate notAfter. Empty until issuance. Kept as string — format not confirmed live. + [JsonPropertyName("certificateExpiryDate")] + public string CertificateExpiryDate { get; set; } + + /// Issuing CA's CN. Empty until issuance. + [JsonPropertyName("issuerCA")] + public string IssuerCa { get; set; } + + /// + /// Catalog product code. Often empty on report rows (issues/0016) — do not rely on this + /// for family resolution; use ResolveAndTrackOrderV2WithFamilyAsync instead. + /// + [JsonPropertyName("productCode")] + public string ProductCode { get; set; } + + /// Primary CN for SSL/TLS orders. Empty for non-SSL families. + [JsonPropertyName("domainName")] + public string DomainName { get; set; } + + [JsonPropertyName("groupNumber")] + public string GroupNumber { get; set; } + + /// Order creation time (UTC). Kept as string and parsed defensively by the caller — + /// mirrors the V1 pattern. + [JsonPropertyName("orderDate")] + public string OrderDate { get; set; } + + [JsonPropertyName("organizationName")] + public string OrganizationName { get; set; } + + [JsonPropertyName("countryName")] + public string CountryName { get; set; } + + [JsonPropertyName("originator")] + public string Originator { get; set; } + + [JsonPropertyName("tags")] + public List Tags { get; set; } + + [JsonPropertyName("customFields")] + public List CustomFields { get; set; } + } +} diff --git a/CERTInext/CERTInext.csproj b/CERTInext/CERTInext.csproj index b25bd55..f683ea8 100644 --- a/CERTInext/CERTInext.csproj +++ b/CERTInext/CERTInext.csproj @@ -1,25 +1,44 @@ - net8.0 + net8.0;net10.0 Keyfactor.Extensions.CAPlugin.CERTInext CERTInextCAPlugin disable warnings 12.0 + + false + $(DefineConstants);SUPPORTS_DCV true - + + + - + + - @@ -32,5 +51,8 @@ <_Parameter1>CERTInext.Tests + + <_Parameter1>CERTInext.IntegrationTests + diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index 1ca2770..ec1afc8 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 Keyfactor // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. // You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, @@ -14,11 +14,15 @@ using System.Threading.Tasks; using Keyfactor.AnyGateway.Extensions; using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; using Keyfactor.Extensions.CAPlugin.CERTInext.Client; using Keyfactor.Extensions.CAPlugin.CERTInext.Models; using Keyfactor.Logging; using Keyfactor.PKI.Enums.EJBCA; using Microsoft.Extensions.Logging; +#if SUPPORTS_DCV +using IDomainValidatorFactory = Keyfactor.AnyGateway.Extensions.IDomainValidatorFactory; +#endif namespace Keyfactor.Extensions.CAPlugin.CERTInext { @@ -34,25 +38,79 @@ public class CERTInextCAPlugin : IAnyCAPlugin, IDisposable private CERTInextConfig _config; private ICERTInextClient _client; private ICertificateDataReader _certificateDataReader; + // Typed as `object` — NOT `IDomainValidatorFactory` — so the .NET JIT does not + // eagerly resolve the v3.3-only IDomainValidatorFactory type when it compiles + // any method on this class. Resolving an instance field's declared type is + // part of the JIT's per-class metadata load, distinct from constructor-signature + // reflection (which we already protected in the issue #7 first pass). On a + // gateway host whose IAnyCAPlugin assembly is v3.2.0.0 (no IDomainValidatorFactory), + // declaring the field with the missing type causes TypeLoadException the first + // time ANY instance method on the class is compiled — typically Initialize. + // + // Reads of this field perform an `as IDomainValidatorFactory` cast inside method + // bodies (see DomainValidatorFactory below). Casts in method bodies are JIT-lazy + // per-method, so the type is only resolved on hosts that actually have it. + // + // `volatile` because the field is written by SetDomainValidatorFactory and read + // by EnrollNewAsync / TryRunDcvDuringSyncAsync, which can run on different threads. + // See GitHub issue #7 for the full reasoning. + // On the no-DCV build (IAnyCAPlugin 3.2.0, SUPPORTS_DCV undefined) this field is + // intentionally never assigned — its assignment sites (the factory ctor and + // SetDomainValidatorFactory) are fenced out, so it stays null and the Initialize + // DCV-wiring check reports "not wired". Suppress CS0649 for that case; on the + // SUPPORTS_DCV build it is assigned normally and the pragma is a no-op. +#pragma warning disable CS0649 + private volatile object _domainValidatorFactory; +#pragma warning restore CS0649 + + /// + /// Returns the injected when one is + /// available, or null when DCV is not wired up. The cast is inside this + /// property body (and therefore JIT-lazy) so the missing-type case on a v3.2 + /// gateway host stays compileable and never triggers TypeLoadException + /// at runtime. All read sites in this class go through this property. + /// +#if SUPPORTS_DCV + private IDomainValidatorFactory DomainValidatorFactory => + _domainValidatorFactory as IDomainValidatorFactory; +#endif // True when the client was passed in via a test-injection constructor and therefore // should not be disposed by this class (the test owns the mock's lifetime). private bool _clientWasInjected; + // Guards against concurrent DCV attempts on the same order — two overlapping sync + // cycles, or a sync overlapping with a GetSingleRecord refresh, must not both try + // to stage TXT records for the same order. The value byte is unused; this is a set. + private readonly ConcurrentDictionary _dcvInFlight = new(); + // --------------------------------------------------------------------------- // Constructors // --------------------------------------------------------------------------- - /// Production constructor — called by the gateway framework via reflection. + /// + /// Production constructor — the only public constructor the gateway DI container + /// sees. Deliberately parameterless to ensure plugin load succeeds on gateway + /// versions whose Keyfactor.AnyGateway.IAnyCAPlugin assembly does not + /// contain (e.g. 25.4.0 ships v3.2.0.0). + /// + /// If the host gateway exposes an instance + /// it should be injected via after + /// construction. When no factory is provided, DCV silently no-ops and orders + /// are returned in their pending state for the gateway to advance on the next + /// sync cycle. + /// + /// See . + /// public CERTInextCAPlugin() { } /// - /// Test-injection constructor — pass a mock - /// to avoid real network calls in unit tests. A default configuration is - /// supplied so that methods that read _config do not null-fault when - /// has not been called. + /// Internal constructor used by unit and integration tests to inject a mock + /// and bypass network I/O. A default + /// is supplied so callers that don't invoke + /// can still read _config. /// - public CERTInextCAPlugin(ICERTInextClient client) + internal CERTInextCAPlugin(ICERTInextClient client) { _client = client; _clientWasInjected = true; @@ -60,11 +118,11 @@ public CERTInextCAPlugin(ICERTInextClient client) } /// - /// Test-injection constructor — pass both a mock + /// Internal test-injection constructor — pass a mock /// and a mock for tests that exercise /// RenewOrReissue logic that reads prior certificate data from Command's database. /// - public CERTInextCAPlugin(ICERTInextClient client, ICertificateDataReader certDataReader) + internal CERTInextCAPlugin(ICERTInextClient client, ICertificateDataReader certDataReader) { _client = client; _clientWasInjected = true; @@ -73,16 +131,75 @@ public CERTInextCAPlugin(ICERTInextClient client, ICertificateDataReader certDat } /// - /// Test-injection constructor — pass both a mock + /// Internal test-injection constructor — pass a mock /// and a specific for tests that need to override - /// configuration fields such as IgnoreExpired. + /// configuration fields such as IgnoreExpired. + /// is optional (defaults to null) and lets V2 tests exercise the issue-0049 + /// bodyless-REVOKED guard, which consults . + /// + internal CERTInextCAPlugin(ICERTInextClient client, CERTInextConfig config, ICertificateDataReader certDataReader = null) + { + _client = client; + _clientWasInjected = true; + _config = config ?? new CERTInextConfig(); + _certificateDataReader = certDataReader; + } + + /// + /// Internal test-injection constructor — pass a mock client, a domain validator + /// factory, and an optional config for unit-testing the DCV orchestration path. + /// + /// This constructor is internal (rather than public) because the + /// gateway DI container's constructor-discovery reflection on a v3.2 host would + /// trip 's missing-type load if this signature + /// were exposed publicly. Tests in CERTInext.Tests / + /// CERTInext.IntegrationTests can still reach it via + /// [InternalsVisibleTo]. See issue #7. /// - public CERTInextCAPlugin(ICERTInextClient client, CERTInextConfig config) +#if SUPPORTS_DCV + internal CERTInextCAPlugin(ICERTInextClient client, IDomainValidatorFactory domainValidatorFactory, CERTInextConfig config = null) { _client = client; _clientWasInjected = true; + _domainValidatorFactory = domainValidatorFactory; _config = config ?? new CERTInextConfig(); } +#endif + + /// + /// Injects an after construction. Intended + /// for gateway hosts that can resolve the factory from their own service container + /// and want DCV enabled — they should call this between new CERTInextCAPlugin() + /// and . + /// + /// Accepts rather than + /// so the public method signature does not pull the v3.3-only type into the type's + /// reflection surface on older gateways. When the supplied value is not an + /// , DCV is left disabled. + /// + public void SetDomainValidatorFactory(object factory) + { +#if SUPPORTS_DCV + var typed = factory as IDomainValidatorFactory; + // SOX change-management / SOC2 CC6.1: log every factory injection so an auditor + // can confirm which DNS provider plugin is being used to publish TXT records. + // A bad-faith host could otherwise swap the factory mid-lifecycle with no trail. + // We deliberately do NOT log the factory instance itself — only its type — to + // avoid serialising any state it may carry. + _logger.LogInformation( + "Domain validator factory set on CERTInext plugin. " + + "OfferedType={OfferedType}, Accepted={Accepted}", + factory?.GetType().FullName ?? "(null)", typed != null); + _domainValidatorFactory = typed; +#else + // DCV is not supported on this build (IAnyCAPlugin 3.2.0 — no IDomainValidatorFactory). + // Accept the call for host compatibility but leave DCV disabled. See issue 0003. + _logger.LogInformation( + "Domain validator factory offered but DCV is not supported on this build " + + "(IAnyCAPlugin 3.2.0). OfferedType={OfferedType}", + factory?.GetType().FullName ?? "(null)"); +#endif + } // --------------------------------------------------------------------------- // IDisposable @@ -108,7 +225,7 @@ public void Dispose() /// public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader) { - _logger.MethodEntry(LogLevel.Trace); + _logger.MethodEntry(LogLevel.Debug); _certificateDataReader = certificateDataReader; @@ -116,6 +233,34 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa _config = JsonSerializer.Deserialize(rawConfig) ?? throw new InvalidOperationException("Failed to deserialize CERTInext plugin configuration."); + // Compliance gap fix: ValidateCAConnectionInfo enforces https-or-loopback on ApiUrl + // (and, in V1 OAuth mode, OAuthTokenUrl) at connection-test time, but a connector + // saved before that enforcement existed would otherwise sail straight through here + // on every gateway restart and keep sending its API key / OAuth client secret in + // cleartext. Re-check the config itself (not just the client we're about to build) + // before any client is built or used — this applies even when a test has already + // injected a mock client via `_client ??=` below, because the gap is in the saved + // config, not in which ICERTInextClient instance ends up talking to it. + void EnsureValidUrl(string fieldName, string url, string requiredSuffix) + { + string error = string.IsNullOrWhiteSpace(url) + ? $"'{fieldName}' is required{requiredSuffix}." + : ValidateHttpsOrLoopbackUrl(fieldName, url); + if (error == null) + return; + + _logger.LogError( + "CERTInext plugin initialization failed — invalid configuration. {Error}", error); + throw new InvalidOperationException(error); + } + + EnsureValidUrl(Constants.Config.ApiUrl, _config.ApiUrl, string.Empty); + + string authModeUpper = (_config.AuthMode ?? string.Empty).Trim().ToUpperInvariant(); + bool isV1OAuth = !_config.UseV2Api && (authModeUpper == "OAUTH" || authModeUpper == "OAUTH2"); + if (isV1OAuth) + EnsureValidUrl(Constants.Config.OAuth2TokenUrl, _config.OAuth2TokenUrl, " when AuthMode is 'OAuth'"); + // Only create a real client if one wasn't injected (test scenario) _client ??= new CERTInextClient(_config); @@ -127,6 +272,13 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa bool hasClientId = !string.IsNullOrWhiteSpace(_config.OAuth2ClientId); bool hasClientSecret= !string.IsNullOrWhiteSpace(_config.OAuth2ClientSecret); bool hasTokenUrl = !string.IsNullOrWhiteSpace(_config.OAuth2TokenUrl); + bool hasOrganizationNumber = !string.IsNullOrWhiteSpace(_config.OrganizationNumber); + bool hasDefaultProductCode = !string.IsNullOrWhiteSpace(_config.DefaultProductCode); + bool hasGroupNumber = !string.IsNullOrWhiteSpace(_config.GroupNumber); + + int effectivePickupRetries = _config.GetEffectivePickupRetries(); + int effectivePickupDelay = _config.GetEffectivePickupDelaySeconds(); + string preVettingMode = hasOrganizationNumber ? "1 (use pre-vetted org)" : "omitted (no org configured)"; _logger.LogInformation( "CERTInext plugin initialized. " + @@ -134,13 +286,64 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa "ApiKeyPresent={ApiKeyPresent}, UsernamePresent={UsernamePresent}, " + "PasswordPresent={PasswordPresent}, OAuth2ClientIdPresent={OAuth2ClientIdPresent}, " + "OAuth2ClientSecretPresent={OAuth2ClientSecretPresent}, OAuth2TokenUrlPresent={OAuth2TokenUrlPresent}, " + - "PageSize={PageSize}, IgnoreExpired={IgnoreExpired}", + "OrganizationNumber={OrganizationNumber}, PreVetting={PreVetting}, " + + "DefaultProductCode={DefaultProductCode}, GroupNumber={GroupNumber}, " + + "AccountingModel={AccountingModel}, EmailNotifications={EmailNotifications}, " + + "AutoSecureWww={AutoSecureWww}, ValidityYears={ValidityYears}, " + + "AutoRenew={AutoRenew}, RenewCriteriaDays={RenewCriteriaDays}, " + + "PageSize={PageSize}, IgnoreExpired={IgnoreExpired}, SubmitNonDnsSans={SubmitNonDnsSans}, " + + "PickupRetries={PickupRetries}, PickupDelay={PickupDelay}, " + + "DcvEnabled={DcvEnabled}, DcvTxtRecordTemplate={DcvTxtRecordTemplate}, " + + "DcvPropagationDelaySeconds={DcvPropagationDelay}, DcvTimeoutMinutes={DcvTimeout}, " + + "DcvWaitForChallengeSeconds={DcvWaitChallenge}, DcvWaitForIssuanceSeconds={DcvWaitIssuance}, " + + "DomainValidatorFactoryInjected={FactoryInjected}, LogSensitiveRequestData={LogSensitiveRequestData}", _config.ApiUrl, _config.AuthMode, _config.Enabled, hasApiKey, hasUsername, hasPassword, hasClientId, hasClientSecret, hasTokenUrl, - _config.PageSize, _config.IgnoreExpired); - _logger.MethodExit(LogLevel.Trace); + hasOrganizationNumber ? _config.OrganizationNumber : "(not configured)", preVettingMode, + hasDefaultProductCode ? _config.DefaultProductCode : "(not configured)", + hasGroupNumber ? _config.GroupNumber : "(not configured)", + string.IsNullOrWhiteSpace(_config.AccountingModel) ? "2 (default)" : _config.AccountingModel, + string.IsNullOrWhiteSpace(_config.EmailNotifications) ? "0 (default)" : _config.EmailNotifications, + string.IsNullOrWhiteSpace(_config.AutoSecureWww) ? "0 (default)" : _config.AutoSecureWww, + string.IsNullOrWhiteSpace(_config.SubscriptionValidityYears) ? "1 (default)" : _config.SubscriptionValidityYears, + string.IsNullOrWhiteSpace(_config.SubscriptionAutoRenew) ? "0 (default)" : _config.SubscriptionAutoRenew, + string.IsNullOrWhiteSpace(_config.SubscriptionRenewCriteriaDays) ? "30 (default)" : _config.SubscriptionRenewCriteriaDays, + _config.PageSize, _config.IgnoreExpired, _config.SubmitNonDnsSans, + effectivePickupRetries, effectivePickupDelay, + _config.DcvEnabled, _config.DcvTxtRecordTemplate, + _config.DcvPropagationDelaySeconds, _config.DcvTimeoutMinutes, + _config.DcvWaitForChallengeSeconds, _config.DcvWaitForIssuanceSeconds, + _domainValidatorFactory != null, _config.LogSensitiveRequestData); + + // SOC2 CC7.1: surface silent functional downgrades. If DCV is enabled in + // config but no factory was injected (e.g. v3.2 gateway host), DCV will be + // skipped at runtime. The operator should know that on every restart. + if (_config.DcvEnabled && _domainValidatorFactory == null) + { + _logger.LogWarning( + "DcvEnabled=true but no IDomainValidatorFactory has been injected — " + + "DCV will be silently skipped for every enrollment. This usually means the " + + "gateway host is on a release that does not provide IDomainValidatorFactory " + + "(see GitHub issue #7). Install a DNS provider plugin and upgrade to a " + + "gateway image that supplies the factory, or set DcvEnabled=false to clear " + + "this warning."); + } + + // Issue 0040 audit trail: this is the one place that records sensitive-data logging + // was switched on, so a reviewer scanning gateway logs can see exactly when it started + // (and, from the absence of a corresponding line on a later restart, when it stopped). + if (_config.LogSensitiveRequestData) + { + _logger.LogWarning( + "LogSensitiveRequestData=true — this CERTInext connector will write requestor " + + "personal data (name, email, phone, and other organization contact details) and " + + "full CA request/response payloads to the gateway logs. This is intended for " + + "temporary use while verifying a new deployment; turn it back off once " + + "verification is complete."); + } + _logger.MethodExit(LogLevel.Debug); } // --------------------------------------------------------------------------- @@ -188,37 +391,46 @@ public List GetProductIds() /// public async Task Ping() { - _logger.MethodEntry(LogLevel.Trace); + _logger.MethodEntry(LogLevel.Debug); if (!_config.Enabled) { _logger.LogWarning("CERTInext connector is disabled — skipping connectivity test."); - _logger.MethodExit(LogLevel.Trace); + _logger.MethodExit(LogLevel.Debug); return; } try { - await _client.PingAsync(); - // SOC2 CC9.2: connectivity confirmation is a security-relevant event; must be - // at Information so it survives production log filters. - _logger.LogInformation("CERTInext ping successful. ApiUrl={ApiUrl}", _config.ApiUrl); + if (_config.UseV2Api) + { + await _client.PingV2Async(); + _logger.LogInformation("CERTInext V2 ping successful. ApiUrl={ApiUrl}", _config.ApiUrl); + } + else + { + await _client.PingAsync(); + // SOC2 CC9.2: connectivity confirmation is a security-relevant event; must be + // at Information so it survives production log filters. + _logger.LogInformation("CERTInext ping successful. ApiUrl={ApiUrl}", _config.ApiUrl); + } } catch (Exception ex) { - _logger.LogError(ex, "CERTInext ping failed. ApiUrl={ApiUrl}", _config.ApiUrl); - throw new Exception($"Unable to reach CERTInext at {_config.ApiUrl}: {ex.Message}", ex); + string url = _config.ApiUrl; + _logger.LogError(ex, "CERTInext ping failed. Url={Url}, UseV2Api={UseV2Api}", url, _config.UseV2Api); + throw new Exception($"Unable to reach CERTInext at {url}: {ex.Message}", ex); } finally { - _logger.MethodExit(LogLevel.Trace); + _logger.MethodExit(LogLevel.Debug); } } /// public async Task ValidateCAConnectionInfo(Dictionary connectionInfo) { - _logger.MethodEntry(LogLevel.Trace); + _logger.MethodEntry(LogLevel.Debug); // SOX CC6.1 / SOC2 CC6.1: log the access attempt so that every configuration // change event is traceable in the audit trail. @@ -236,50 +448,102 @@ public async Task ValidateCAConnectionInfo(Dictionary connection _logger.LogWarning( "CA connection validation skipped — connector is disabled. ApiUrl={ApiUrl}", attemptedApiUrl); - _logger.MethodExit(LogLevel.Trace); + _logger.MethodExit(LogLevel.Debug); return; } var errors = new List(); - // ApiUrl and AccountNumber are always required + // ApiUrl is always required in both modes — its meaning follows UseV2Api (V1 base + // URL incl. /emSignHub-API vs the bare V2 host). See issues/0022 config consolidation. string apiUrl = GetStringValue(connectionInfo, Constants.Config.ApiUrl); if (string.IsNullOrWhiteSpace(apiUrl)) errors.Add($"'{Constants.Config.ApiUrl}' is required."); - else if (!Uri.TryCreate(apiUrl, UriKind.Absolute, out _)) - errors.Add($"'{Constants.Config.ApiUrl}' is not a valid absolute URI."); - - string accountNumber = GetStringValue(connectionInfo, Constants.Config.AccountNumber); - if (string.IsNullOrWhiteSpace(accountNumber)) - errors.Add($"'{Constants.Config.AccountNumber}' is required."); - - // Auth mode — validate the required credentials for the chosen mode - string authMode = GetStringValue(connectionInfo, Constants.Config.AuthMode, Constants.Config.AuthModeAccessKey); - switch (authMode.ToUpperInvariant()) - { - case "ACCESSKEY": - case "APIKEY": // legacy alias - string apiKey = GetStringValue(connectionInfo, Constants.Config.ApiKey); - if (string.IsNullOrWhiteSpace(apiKey)) - errors.Add($"'{Constants.Config.ApiKey}' is required when AuthMode is 'AccessKey'."); - break; + else + { + // The OAuth client secret (V2) / API key (V1) is sent to this URL on every + // request; http would transmit it in cleartext. http is allowed only for + // loopback hosts (localhost/127.0.0.1/::1) so local mock-server tests keep + // working without a real TLS endpoint. Shared with the OAuthTokenUrl check + // below and with Initialize's config-time enforcement of the same rule. + string apiUrlError = ValidateHttpsOrLoopbackUrl(Constants.Config.ApiUrl, apiUrl); + if (apiUrlError != null) + errors.Add(apiUrlError); + } - case "OAUTH": - case "OAUTH2": - string tokenUrl = GetStringValue(connectionInfo, Constants.Config.OAuth2TokenUrl); - string clientId = GetStringValue(connectionInfo, Constants.Config.OAuth2ClientId); - string clientSecret = GetStringValue(connectionInfo, Constants.Config.OAuth2ClientSecret); - if (string.IsNullOrWhiteSpace(tokenUrl)) - errors.Add($"'{Constants.Config.OAuth2TokenUrl}' is required when AuthMode is 'OAuth'."); - if (string.IsNullOrWhiteSpace(clientId)) - errors.Add($"'{Constants.Config.OAuth2ClientId}' is required when AuthMode is 'OAuth'."); - if (string.IsNullOrWhiteSpace(clientSecret)) - errors.Add($"'{Constants.Config.OAuth2ClientSecret}' is required when AuthMode is 'OAuth'."); - break; + bool useV2 = connectionInfo.TryGetValue(Constants.ConfigV2.UseV2Api, out object v2Obj) + && v2Obj is bool v2Bool && v2Bool; - default: - errors.Add($"'{Constants.Config.AuthMode}' must be one of: AccessKey, OAuth. Got: '{authMode}'."); - break; + if (useV2) + { + // V2 mode: OAuth2 client_credentials against {ApiUrl}/oauth/token, reusing the + // same OAuthClientId/OAuthClientSecret fields V1's AuthMode=OAuth uses. V1-only + // credentials (AccountNumber, AuthMode, ApiKey, ...) are NOT required here — the + // V1 AuthMode switch below is skipped entirely (issues/0022). + string oauthClientId = GetStringValue(connectionInfo, Constants.Config.OAuthClientId); + string oauthClientSecret = GetStringValue(connectionInfo, Constants.Config.OAuthClientSecret); + + if (string.IsNullOrWhiteSpace(oauthClientId)) + errors.Add($"'{Constants.Config.OAuthClientId}' is required when UseV2Api is true."); + + if (string.IsNullOrWhiteSpace(oauthClientSecret)) + errors.Add($"'{Constants.Config.OAuthClientSecret}' is required when UseV2Api is true."); + + // Issue 0039: every V2 SSL create order sends an `agreement` block, and the V2 spec + // marks agreement.signerPlace "Conditional - required if `agreement` sent". Required + // at the connector level (user decision) even though a per-template SignerPlace + // enrollment parameter can override it — EnrollV2Async also fails fast if the + // resolved value is blank. + string signerPlace = GetStringValue(connectionInfo, Constants.Config.SignerPlace); + if (string.IsNullOrWhiteSpace(signerPlace)) + errors.Add($"'{Constants.Config.SignerPlace}' is required when UseV2Api is true — the CERTInext " + + "V2 Subscriber Agreement sent with every SSL order requires the signing place " + + "(city/location, e.g. 'San Francisco, CA')."); + } + else + { + // V1 mode: AccountNumber is always required, plus whatever the selected AuthMode needs. + string accountNumber = GetStringValue(connectionInfo, Constants.Config.AccountNumber); + if (string.IsNullOrWhiteSpace(accountNumber)) + errors.Add($"'{Constants.Config.AccountNumber}' is required."); + + string authMode = GetStringValue(connectionInfo, Constants.Config.AuthMode, Constants.Config.AuthModeAccessKey); + switch (authMode.ToUpperInvariant()) + { + case "ACCESSKEY": + case "APIKEY": // legacy alias + string apiKey = GetStringValue(connectionInfo, Constants.Config.ApiKey); + if (string.IsNullOrWhiteSpace(apiKey)) + errors.Add($"'{Constants.Config.ApiKey}' is required when AuthMode is 'AccessKey'."); + break; + + case "OAUTH": + case "OAUTH2": + string tokenUrl = GetStringValue(connectionInfo, Constants.Config.OAuth2TokenUrl); + string clientId = GetStringValue(connectionInfo, Constants.Config.OAuth2ClientId); + string clientSecret = GetStringValue(connectionInfo, Constants.Config.OAuth2ClientSecret); + if (string.IsNullOrWhiteSpace(tokenUrl)) + errors.Add($"'{Constants.Config.OAuth2TokenUrl}' is required when AuthMode is 'OAuth'."); + else + { + // The OAuth client secret is POSTed to this URL on every token + // refresh (CERTInextClient.GetOrRefreshTokenAsync) — same cleartext- + // credential exposure as ApiUrl, so it gets the same https-or-loopback + // rule. + string tokenUrlError = ValidateHttpsOrLoopbackUrl(Constants.Config.OAuth2TokenUrl, tokenUrl); + if (tokenUrlError != null) + errors.Add(tokenUrlError); + } + if (string.IsNullOrWhiteSpace(clientId)) + errors.Add($"'{Constants.Config.OAuth2ClientId}' is required when AuthMode is 'OAuth'."); + if (string.IsNullOrWhiteSpace(clientSecret)) + errors.Add($"'{Constants.Config.OAuth2ClientSecret}' is required when AuthMode is 'OAuth'."); + break; + + default: + errors.Add($"'{Constants.Config.AuthMode}' must be one of: AccessKey, OAuth. Got: '{authMode}'."); + break; + } } if (errors.Any()) @@ -292,14 +556,21 @@ public async Task ValidateCAConnectionInfo(Dictionary connection } // Attempt a live connectivity test using the supplied credentials + CERTInextConfig tempConfig = null; + CERTInextClient tempClient = null; try { // Build a transient config from the supplied connectionInfo so we don't // rely on the already-initialized _client (which may hold stale creds) string rawConfig = JsonSerializer.Serialize(connectionInfo); - var tempConfig = JsonSerializer.Deserialize(rawConfig); - var tempClient = new CERTInextClient(tempConfig); - await tempClient.PingAsync(); + tempConfig = JsonSerializer.Deserialize(rawConfig) + ?? throw new InvalidOperationException("Failed to deserialize connection info."); + tempClient = new CERTInextClient(tempConfig); + + if (tempConfig.UseV2Api) + await tempClient.PingV2Async(); + else + await tempClient.PingAsync(); } catch (Exception ex) { @@ -308,8 +579,8 @@ public async Task ValidateCAConnectionInfo(Dictionary connection _logger.LogError( ex, "CA connection validation failed — live connectivity test unsuccessful. " + - "ApiUrl={ApiUrl}, AuthMode={AuthMode}", - attemptedApiUrl, attemptedAuthMode); + "ApiUrl={ApiUrl}, UseV2Api={UseV2Api}, AuthMode={AuthMode}", + attemptedApiUrl, tempConfig?.UseV2Api ?? false, attemptedAuthMode); // The inner exception message is NOT forwarded to the AnyCAValidationException // because it may contain HTTP response bodies or header fragments from the @@ -318,29 +589,41 @@ public async Task ValidateCAConnectionInfo(Dictionary connection "Successfully parsed configuration, but could not connect to CERTInext. " + "See gateway logs for details."); } + finally + { + // SOC2 CC6.1 best-effort credential scrubbing: blank out the secret fields + // on the transient config so they aren't reachable from the still-rooted + // tempClient instance after this method returns. Not a hard guarantee + // (the .NET runtime may have already copied them elsewhere) but removes + // the most obvious post-validation reference chain. + if (tempConfig != null) + { + tempConfig.ApiKey = string.Empty; + tempConfig.OAuthClientSecret = string.Empty; + tempConfig.Password = string.Empty; + } + tempClient?.Dispose(); + } _logger.LogInformation( "CA connection validation succeeded. ApiUrl={ApiUrl}, AuthMode={AuthMode}", attemptedApiUrl, attemptedAuthMode); - _logger.MethodExit(LogLevel.Trace); + _logger.MethodExit(LogLevel.Debug); } /// public async Task ValidateProductInfo(EnrollmentProductInfo productInfo, Dictionary connectionInfo) { - _logger.MethodEntry(LogLevel.Trace); + _logger.MethodEntry(LogLevel.Debug); string rawConfig = JsonSerializer.Serialize(connectionInfo); - var tempConfig = JsonSerializer.Deserialize(rawConfig); - var tempClient = new CERTInextClient(tempConfig); + var tempConfig = JsonSerializer.Deserialize(rawConfig) + ?? throw new InvalidOperationException("Failed to deserialize connection info."); + bool useV2 = tempConfig.UseV2Api; var params_ = new EnrollmentParams(productInfo); string profileId = params_.ProfileId; - _logger.LogInformation( - "Product/profile validation attempt started. ProfileId={ProfileId}, ProductID={ProductID}", - profileId, productInfo?.ProductID); - if (string.IsNullOrWhiteSpace(profileId)) { _logger.LogWarning( @@ -350,20 +633,228 @@ public async Task ValidateProductInfo(EnrollmentProductInfo productInfo, Diction $"Template parameter '{Constants.EnrollmentParam.ProfileId}' is required but was not set."); } + _logger.LogInformation( + "Product/profile validation attempt started. ProfileId={ProfileId}, ProductID={ProductID}, UseV2Api={UseV2Api}", + profileId, productInfo?.ProductID, useV2); + + bool isPrivatePki = useV2 + && string.Equals(params_.ProductFamilySlug, Constants.ApiV2.FamilyPrivatePki, StringComparison.Ordinal); + // Issue 0059: gate the SSL-only ProductVariant cross-check below on the SSL family + // specifically (not just "!isPrivatePki") so a signature (Document Signer) template — + // which has no productVariant concept — is left unaffected, same as before this fix. + bool isSsl = useV2 + && string.Equals(params_.ProductFamilySlug, Constants.ApiV2.FamilySsl, StringComparison.Ordinal); + + var tempClient = new CERTInextClient(tempConfig); + try { - var profiles = await tempClient.GetProfilesAsync(); - bool found = profiles.Any(p => - string.Equals(p.Id, profileId, StringComparison.OrdinalIgnoreCase)); + // Issue 0033: a V2 private-pki template needs a Private PKI ProductVariant and an + // explicit ProductCode — checked before any catalog call, with the same rules + // EnrollV2Async enforces, so a template that can never enroll is rejected at save + // time. Inside the try so the finally block's credential scrubbing still runs. + if (isPrivatePki) + { + string pkiConfigError = ValidatePrivatePkiEnrollmentParams(params_, out _); + if (pkiConfigError != null) + { + _logger.LogWarning( + "Product/profile validation failed — {Reason} ProductID={ProductID}", + pkiConfigError, params_.ProductId); + throw new AnyCAValidationException(pkiConfigError); + } + } + + // Issue 0059: an SSL template's explicit ProductVariant must agree with the + // product it's paired with — checked before any catalog call, same fail-fast + // placement as the private-pki check above, so a template that would silently + // send a DV-shaped body for an OV/EV product (or vice versa) is rejected at save + // time instead of at enroll. + if (isSsl) + { + string variantError = ResolveSslProductVariant(params_, out _); + if (variantError != null) + { + _logger.LogWarning( + "Product/profile validation failed — {Reason} ProductID={ProductID}", + variantError, params_.ProductId); + throw new AnyCAValidationException(variantError); + } + } + + // V2 catalog validation mirrors ValidateCAConnectionInfo's UseV2Api branch + // (issues/0025): GetProfilesAsync/GetProductDetailsAsync are V1-only and 404 + // against a V2-shaped ApiUrl. There is no soft-accept difference between modes + // — an empty/unusable catalog is treated as "not found", same as V1. + List availableIds; + bool found; + + if (useV2) + { + var products = await tempClient.GetProductDetailsV2Async(); + availableIds = products.Select(p => p.ProductCode).ToList(); + + if (isPrivatePki) + { + // Issue 0033: the SSL ProductId -> productTypeID cross-check below would + // always reject a Private PKI code (GetProductIds only advertises SSL/TLS + // product names). Check the code against the spec's Private PKI + // productTypeID instead ("39" | Private PKI | Private PKI (8)). + var matchedProduct = products.FirstOrDefault(p => + string.Equals(p.ProductCode, profileId, StringComparison.OrdinalIgnoreCase)); + + if (matchedProduct == null) + { + var available = string.Join(", ", availableIds); + _logger.LogWarning( + "Product/profile validation failed — configured private-pki ProductCode '{ProfileId}' " + + "was not found in the CERTInext V2 catalog. AvailableCount={AvailableCount}", + profileId, availableIds.Count); + throw new AnyCAValidationException( + $"ProductCode '{profileId}' was not found in the CERTInext V2 catalog. " + + $"Available codes: {available}"); + } + + if (!string.Equals(matchedProduct.ProductTypeId, Constants.ApiV2.PrivatePkiProductTypeId, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning( + "Product/profile validation failed — configured ProductCode '{ProfileId}' exists in the " + + "CERTInext V2 catalog, but its productTypeID ('{ActualTypeId}') is not the Private PKI " + + "productTypeID ('{ExpectedTypeId}') while ProductFamily is 'private-pki'.", + profileId, matchedProduct.ProductTypeId, Constants.ApiV2.PrivatePkiProductTypeId); + throw new AnyCAValidationException( + $"ProductCode '{profileId}' exists in the CERTInext catalog, but it is not a Private PKI " + + "product, and the template's ProductFamily is 'private-pki'. Set ProductCode to a Private " + + "PKI product code from your account's catalog, or correct ProductFamily."); + } + + found = true; + } + else if (params_.HasExplicitProductCode) + { + // Explicit override: the code must exist in the catalog AND the matched + // catalog entry's productTypeID must actually correspond to the selected + // ProductId — not just "does this code exist as *some* product" (issue + // 0036: a code can exist and still mean a different, wrong-assurance-level + // product than the one the administrator selected). + var matchedProduct = products.FirstOrDefault(p => + string.Equals(p.ProductCode, profileId, StringComparison.OrdinalIgnoreCase)); + + if (matchedProduct == null) + { + var available = string.Join(", ", availableIds); + _logger.LogWarning( + "Product/profile validation failed — configured ProductCode '{ProfileId}' was not " + + "found in the CERTInext V2 catalog. ProductID={ProductID}, AvailableCount={AvailableCount}", + profileId, params_.ProductId, availableIds.Count); + throw new AnyCAValidationException( + $"ProductCode '{profileId}' was not found in the CERTInext V2 catalog. " + + $"Available codes: {available}"); + } + + if (Constants.Products.ProductTypeIdsV2.TryGetValue(params_.ProductId ?? string.Empty, out string expectedTypeId) + && !string.Equals(matchedProduct.ProductTypeId, expectedTypeId, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning( + "Product/profile validation failed — configured ProductCode '{ProfileId}' exists in " + + "the CERTInext V2 catalog, but its catalog productTypeID ('{ActualTypeId}') does not " + + "match the selected ProductID '{ProductID}' (expected productTypeID '{ExpectedTypeId}'). " + + "This template would order a different product than the one selected.", + profileId, matchedProduct.ProductTypeId, params_.ProductId, expectedTypeId); + throw new AnyCAValidationException( + $"ProductCode '{profileId}' exists in the CERTInext catalog, but it does not " + + $"correspond to the selected product '{params_.ProductId}'. Verify the ProductCode " + + "override is correct for this product, or remove the override to let the plugin " + + "resolve it automatically from the catalog."); + } + + found = true; + } + else + { + // No explicit override: resolve/validate by matching the live catalog's + // productTypeID for the selected ProductId — do NOT fall back to + // Constants.Products.DefaultProductCodes (V1-era numbering that does not + // match the live V2 catalog, issue 0036). + if (!Constants.Products.ProductTypeIdsV2.TryGetValue(params_.ProductId ?? string.Empty, out string expectedTypeId)) + { + _logger.LogWarning( + "Product/profile validation failed — no productTypeID mapping is defined for " + + "ProductID '{ProductID}'.", params_.ProductId); + throw new AnyCAValidationException( + $"No V2 productTypeID mapping is defined for ProductID '{params_.ProductId}'. " + + "Set the ProductCode template parameter explicitly, or contact support to add a " + + "mapping for this product."); + } + + // Mirrors EnrollV2Async's own ambiguity handling (so a template is + // rejected/flagged at save time, not only discovered at enroll time): + // the live catalog can carry MORE THAN ONE entry with this productTypeID + // (sandbox-confirmed for type 13/DV SSL). Resolve automatically only when + // exactly one match exists, or when the connector's DefaultProductCode + // names one of several matches; otherwise reject with the candidates + // listed. + var matchingProducts = products + .Where(p => string.Equals(p.ProductTypeId, expectedTypeId, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (matchingProducts.Count == 0) + { + _logger.LogWarning( + "Product/profile validation failed — no CERTInext V2 catalog entry has " + + "productTypeID '{ExpectedTypeId}' for ProductID '{ProductID}'. AvailableCount={AvailableCount}", + expectedTypeId, params_.ProductId, availableIds.Count); + throw new AnyCAValidationException( + $"Could not find a CERTInext V2 catalog entry for product '{params_.ProductId}' " + + $"(expected productTypeID '{expectedTypeId}'). Verify the account is entitled to " + + "this product."); + } + + if (matchingProducts.Count > 1) + { + bool resolvedByDefault = matchingProducts.Any(p => + !string.IsNullOrWhiteSpace(tempConfig.DefaultProductCode) && + string.Equals(p.ProductCode, tempConfig.DefaultProductCode, StringComparison.OrdinalIgnoreCase)); + + if (!resolvedByDefault) + { + string candidates = string.Join(", ", + matchingProducts.Select(p => $"{p.ProductCode} ('{p.ProductName}')")); + _logger.LogWarning( + "Product/profile validation failed — multiple CERTInext V2 catalog entries " + + "share productTypeID '{ExpectedTypeId}' for ProductID '{ProductID}', and none " + + "match the configured DefaultProductCode ('{DefaultProductCode}'). " + + "Candidates=[{Candidates}]", + expectedTypeId, params_.ProductId, + string.IsNullOrWhiteSpace(tempConfig.DefaultProductCode) ? "(not set)" : tempConfig.DefaultProductCode, + candidates); + throw new AnyCAValidationException( + $"Multiple CERTInext catalog products match ProductID '{params_.ProductId}' " + + $"(productTypeID '{expectedTypeId}'): {candidates}. Set the ProductCode " + + "template parameter explicitly, or set the CA connector's DefaultProductCode " + + "to one of these codes, to disambiguate."); + } + } + + found = true; + } + } + else + { + var profiles = await tempClient.GetProfilesAsync(); + availableIds = profiles.Select(p => p.Id).ToList(); + found = profiles.Any(p => + string.Equals(p.Id, profileId, StringComparison.OrdinalIgnoreCase)); + } if (!found) { - var available = string.Join(", ", profiles.Select(p => p.Id)); + var available = string.Join(", ", availableIds); // SOC2 CC7.2: log profile probe misses at Warning to support anomaly detection. _logger.LogWarning( "Product/profile validation failed — ProfileId not found in CERTInext. " + - "ProfileId={ProfileId}, AvailableCount={AvailableCount}", - profileId, profiles.Count); + "ProfileId={ProfileId}, AvailableCount={AvailableCount}, UseV2Api={UseV2Api}", + profileId, availableIds.Count, useV2); throw new AnyCAValidationException( $"Profile '{profileId}' was not found in CERTInext. " + $"Available profiles: {available}"); @@ -385,9 +876,22 @@ public async Task ValidateProductInfo(EnrollmentProductInfo productInfo, Diction $"Unable to validate profile '{profileId}' against CERTInext. " + "See gateway logs for details."); } + finally + { + // SOC2 CC6.1 best-effort credential scrubbing (see ValidateCAConnectionInfo). + if (tempConfig != null) + { + tempConfig.ApiKey = string.Empty; + tempConfig.OAuthClientSecret = string.Empty; + tempConfig.Password = string.Empty; + } + tempClient?.Dispose(); + } - _logger.LogInformation("Product/profile validation succeeded. ProfileId={ProfileId}", profileId); - _logger.MethodExit(LogLevel.Trace); + _logger.LogInformation( + "Product/profile validation succeeded. ProfileId={ProfileId}, UseV2Api={UseV2Api}", + profileId, useV2); + _logger.MethodExit(LogLevel.Debug); } // --------------------------------------------------------------------------- @@ -403,53 +907,79 @@ public async Task Enroll( RequestFormat requestFormat, EnrollmentType enrollmentType) { - _logger.MethodEntry(LogLevel.Trace); + _logger.MethodEntry(LogLevel.Debug); var ep = new EnrollmentParams(productInfo); // SOX / SOC2 CC7.3: log the enrollment attempt with full identifying context // so the event is independently auditable before any API call is made. - string sanSummary = san != null && san.Count > 0 - ? string.Join("; ", san.SelectMany(kvp => (kvp.Value ?? Array.Empty()) - .Select(v => $"{kvp.Key}:{v}"))) - : "(none)"; + // Issue 0040 follow-up: email-type SAN values are personal data, masked unless + // LogSensitiveRequestData is on; DNS/IP/URI values stay verbatim as audit fields. + string sanSummary = LogSanitizer.FormatSans(san, _config.LogSensitiveRequestData); - _logger.LogInformation( - "Enrollment attempt started. " + - "EnrollmentType={EnrollmentType}, Subject={Subject}, " + - "ProfileId={ProfileId}, SANs={SANs}, " + - "RequesterName={RequesterName}, RequesterEmail={RequesterEmail}", - enrollmentType, subject, - ep.ProfileId, sanSummary, - ep.RequesterName, ep.RequesterEmail); + // Issue 0040: RequesterName/RequesterEmail are personal data belonging to whoever + // placed the order. Off by default (LogSensitiveRequestData=false) — the name is + // dropped from the line entirely and the email is masked to keep only its domain. + // On, this is the pre-0040 behaviour: both fields logged in full, for deployment + // verification. + if (_config.LogSensitiveRequestData) + { + _logger.LogInformation( + "Enrollment attempt started. " + + "EnrollmentType={EnrollmentType}, RequestFormat={RequestFormat}, Subject={Subject}, " + + "ProfileId={ProfileId}, SANs={SANs}, " + + "RequesterName={RequesterName}, RequesterEmail={RequesterEmail}", + enrollmentType, requestFormat, LogSanitizer.Strip(subject), + ep.ProfileId, sanSummary, + LogSanitizer.Strip(ep.RequesterName), LogSanitizer.Strip(ep.RequesterEmail)); + } + else + { + _logger.LogInformation( + "Enrollment attempt started. " + + "EnrollmentType={EnrollmentType}, RequestFormat={RequestFormat}, Subject={Subject}, " + + "ProfileId={ProfileId}, SANs={SANs}, " + + "RequesterEmail={RequesterEmail}", + enrollmentType, requestFormat, LogSanitizer.Strip(subject), + ep.ProfileId, sanSummary, + LogSanitizer.MaskEmail(LogSanitizer.Strip(ep.RequesterEmail))); + } if (string.IsNullOrWhiteSpace(ep.ProfileId)) { _logger.LogError( "Enrollment rejected — ProfileId parameter is missing. Subject={Subject}, EnrollmentType={EnrollmentType}", - subject, enrollmentType); + LogSanitizer.Strip(subject), enrollmentType); throw new Exception($"Template parameter '{Constants.EnrollmentParam.ProfileId}' is required."); } EnrollmentResult result; - switch (enrollmentType) + if (_config.UseV2Api) { - case EnrollmentType.New: - case EnrollmentType.Reissue: - result = await EnrollNewAsync(csr, subject, san, ep); - break; + // V2 path: all enrollment types go through EnrollV2Async + result = await EnrollV2Async(csr, subject, san, ep, enrollmentType); + } + else + { + switch (enrollmentType) + { + case EnrollmentType.New: + case EnrollmentType.Reissue: + result = await EnrollNewAsync(csr, subject, san, ep); + break; - case EnrollmentType.Renew: - case EnrollmentType.RenewOrReissue: - result = await RenewOrReissueAsync(csr, subject, san, productInfo, ep); - break; + case EnrollmentType.Renew: + case EnrollmentType.RenewOrReissue: + result = await RenewOrReissueAsync(csr, subject, san, productInfo, ep); + break; - default: - _logger.LogError( - "Enrollment rejected — unsupported enrollment type. EnrollmentType={EnrollmentType}, Subject={Subject}", - enrollmentType, subject); - throw new NotSupportedException($"Enrollment type '{enrollmentType}' is not supported."); + default: + _logger.LogError( + "Enrollment rejected — unsupported enrollment type. EnrollmentType={EnrollmentType}, Subject={Subject}", + enrollmentType, LogSanitizer.Strip(subject)); + throw new NotSupportedException($"Enrollment type '{enrollmentType}' is not supported."); + } } // SOX: the completion log must include the CA-assigned identifier, serial number, @@ -460,8 +990,8 @@ public async Task Enroll( "SerialNumber={SerialNumber}, Subject={Subject}, ProfileId={ProfileId}", enrollmentType, result.CARequestID, result.Status, result.Certificate != null ? ExtractSerialFromPem(result.Certificate) : "(pending)", - subject, ep.ProfileId); - _logger.MethodExit(LogLevel.Trace); + LogSanitizer.Strip(subject), ep.ProfileId); + _logger.MethodExit(LogLevel.Debug); return result; } @@ -472,12 +1002,39 @@ public async Task Enroll( /// public async Task GetSingleRecord(string caRequestID) { - _logger.MethodEntry(LogLevel.Trace); - _logger.LogInformation("GetSingleRecord started. CARequestID={Id}", caRequestID); + _logger.MethodEntry(LogLevel.Debug); + _logger.LogInformation("GetSingleRecord started. CARequestID={Id}, UseV2Api={UseV2Api}", caRequestID, _config.UseV2Api); + + if (_config.UseV2Api) + return await GetSingleRecordV2Async(caRequestID); try { var cert = await _client.GetCertificateAsync(caRequestID); + + // Mirror the deferred-DCV behavior of Synchronize: if the order is still in + // a pending state, try to advance it through DCV before returning. This lets + // a manual single-record refresh unstick an order whose DCV challenge was + // only exposed after enrollment returned. + int status = StatusMapper.ToRequestDisposition(cert.Status); + if (status == (int)EndEntityStatus.EXTERNALVALIDATION) + { + bool dcvDone = await TryRunDcvDuringSyncAsync(caRequestID, CancellationToken.None); + if (dcvDone) + { + try + { + cert = await _client.GetCertificateAsync(caRequestID); + } + catch (Exception refetchEx) + { + _logger.LogWarning(refetchEx, + "Single-record DCV completed but post-DCV refetch failed. CARequestID={Id}", + caRequestID); + } + } + } + var record = MapToAnyCAPluginCertificate(cert); // SOC2 CC7.3: certificate retrieval is a security-relevant read operation; @@ -485,7 +1042,7 @@ public async Task GetSingleRecord(string caRequestID) _logger.LogInformation( "GetSingleRecord complete. CARequestID={Id}, Status={Status}, SerialNumber={Serial}", caRequestID, cert.Status, cert.SerialNumber ?? "(none)"); - _logger.MethodExit(LogLevel.Trace); + _logger.MethodExit(LogLevel.Debug); return record; } catch (KeyNotFoundException) @@ -507,17 +1064,25 @@ public async Task GetSingleRecord(string caRequestID) /// public async Task Revoke(string caRequestID, string hexSerialNumber, uint revocationReason) { - _logger.MethodEntry(LogLevel.Trace); + _logger.MethodEntry(LogLevel.Debug); + + if (_config.UseV2Api) + return await RevokeV2Async(caRequestID, hexSerialNumber, revocationReason); string reasonString = StatusMapper.ToRevocationReason(revocationReason); // SOX: log the revocation attempt before any state change so the intent is - // recorded even if the API call subsequently fails. + // recorded even if the API call subsequently fails. Include ManagedThreadId + // so revoke events can be correlated against the gateway-supplied + // RequestingUser scope when the host enriches Keyfactor.Logging with it + // (segregation-of-duties evidence — SOX CC1.3 / SOC2 CC1.4). _logger.LogInformation( "Revocation attempt started. " + "CARequestID={Id}, HexSerialNumber={Serial}, " + - "ReasonCode={ReasonCode}, ReasonString={ReasonString}", - caRequestID, hexSerialNumber, revocationReason, reasonString); + "ReasonCode={ReasonCode}, ReasonString={ReasonString}, " + + "ManagedThreadId={ThreadId}", + caRequestID, hexSerialNumber, revocationReason, reasonString, + System.Environment.CurrentManagedThreadId); // Verify the certificate is in a revocable state before calling the API LegacyGetCertificateResponse current; @@ -541,7 +1106,7 @@ public async Task Revoke(string caRequestID, string hexSerialNumber, uint r _logger.LogWarning( "Revocation skipped — certificate is already revoked. " + "CARequestID={Id}, HexSerialNumber={Serial}, Subject={Subject}", - caRequestID, hexSerialNumber, current.Subject); + caRequestID, hexSerialNumber, LogSanitizer.Strip(current.Subject)); return (int)EndEntityStatus.REVOKED; } @@ -570,9 +1135,9 @@ public async Task Revoke(string caRequestID, string hexSerialNumber, uint r "Revocation complete. " + "CARequestID={Id}, HexSerialNumber={Serial}, Subject={Subject}, " + "ReasonCode={ReasonCode}, ReasonString={ReasonString}", - caRequestID, hexSerialNumber, current.Subject, + caRequestID, hexSerialNumber, LogSanitizer.Strip(current.Subject), revocationReason, reasonString); - _logger.MethodExit(LogLevel.Trace); + _logger.MethodExit(LogLevel.Debug); return (int)EndEntityStatus.REVOKED; } @@ -587,18 +1152,42 @@ public async Task Synchronize( bool fullSync, CancellationToken cancelToken) { - _logger.MethodEntry(LogLevel.Trace); + _logger.MethodEntry(LogLevel.Debug); + + if (_config.UseV2Api) + { + // V2 mode routes Synchronize through V2 /reports/orders (issues/0022) — the V1 + // GetOrderReport path below is never used, and V1 credentials are optional. + await SynchronizeV2Async(blockingBuffer, lastSync, fullSync, cancelToken); + _logger.MethodExit(LogLevel.Debug); + return; + } DateTime? issuedAfter = fullSync ? (DateTime?)null : lastSync; _logger.LogInformation( - "Starting CERTInext synchronization. FullSync={FullSync}, IssuedAfter={IssuedAfter}", - fullSync, issuedAfter?.ToString("O") ?? "none"); + "Starting CERTInext synchronization. FullSync={FullSync}, IssuedAfter={IssuedAfter}, UseV2Api={UseV2Api}", + fullSync, issuedAfter?.ToString("O") ?? "none", _config.UseV2Api); int synced = 0; int skipped = 0; + int skippedWithBody = 0; // skipped records that nonetheless carried a cert body (should be 0) int errors = 0; +#if SUPPORTS_DCV + // DCV-during-sync only actually runs when DCV is enabled AND a DNS provider factory was + // injected by the host. On a gateway that doesn't supply one (e.g. IAnyCAPlugin 3.2.0 + // hosts), DCV cannot run even on a DCV-capable build — so don't run the gate or report + // attempt counts that would imply it did (issue 0003). Bounds apply only when operational. + bool dcvOperational = _config.DcvEnabled && _domainValidatorFactory != null; + int ageWindowHours = _config.DcvSyncMaxOrderAgeHours; // 0 = no age filter + int perPassCap = _config.DcvSyncMaxPerPass; // 0 = no cap + int dcvAttempted = 0, dcvSkippedAge = 0, dcvSkippedCap = 0; +#endif + + // Emit-side accounting (issue 0003): what the plugin hands to the gateway buffer. + int emittedGeneratedWithBody = 0, emittedGeneratedNoBody = 0, emittedRevoked = 0, emittedPending = 0; + try { await foreach (var cert in _client.ListCertificatesAsync( @@ -606,32 +1195,173 @@ public async Task Synchronize( { cancelToken.ThrowIfCancellationRequested(); + // Local copy so we can replace it with a post-DCV refetch below + var current = cert; + try { // Skip expired certificates when IgnoreExpired is configured if (_config.IgnoreExpired - && cert.ExpiresAt.HasValue - && cert.ExpiresAt.Value < DateTime.UtcNow) + && current.ExpiresAt.HasValue + && current.ExpiresAt.Value < DateTime.UtcNow) { _logger.LogTrace( "Skipping expired certificate '{Id}' (expires {ExpiresAt:u}).", - cert.Id, cert.ExpiresAt.Value); + current.Id, current.ExpiresAt.Value); + if (!string.IsNullOrWhiteSpace(current.Certificate)) skippedWithBody++; skipped++; continue; } + int status = StatusMapper.ToRequestDisposition(current.Status); + + // Per-record trace so a sync pass is fully reconstructable from logs + // (info-level only emits start/summary). Enable Trace on this category. + _logger.LogTrace( + "Sync: processing order Id={Id}, listedStatus='{Listed}', mappedStatus={Status}, " + + "orderDate={OrderDate}, bodyInListing={HasBody}", + current.Id, current.Status, status, + current.OrderDate?.ToString("o") ?? "(none)", + !string.IsNullOrWhiteSpace(current.Certificate)); + + // Deferred DCV: pending orders (EXTERNALVALIDATION) often need DCV driven + // forward during sync — CERTInext parks fresh orders and exposes the DCV + // challenge minutes after enrollment, and scans are the only place that gets + // picked back up. But attempting DCV for EVERY pending order on EVERY pass is + // O(pending) and pathologically slow with a large/abandoned backlog (issue + // 0002). Bound it: only recently-placed orders are eligible (age window), and + // at most N per pass (cap). Aged-out / over-cap orders are emitted as pending + // and revisited on a later pass (the per-minute incremental scan keeps recent + // orders moving). Unknown order age → treat as eligible so we never starve a + // legitimately-new order. +#if SUPPORTS_DCV + if (dcvOperational && status == (int)EndEntityStatus.EXTERNALVALIDATION) + { + var decision = EvaluateDcvSyncEligibility( + current.OrderDate, DateTime.UtcNow, ageWindowHours, dcvAttempted, perPassCap); + + _logger.LogTrace( + "Sync DCV gate: Id={Id}, decision={Decision}, orderDate={OrderDate}, " + + "ageWindowHours={Age}, attemptedSoFar={Attempted}, perPassCap={Cap}", + current.Id, decision, current.OrderDate?.ToString("o") ?? "(none)", + ageWindowHours, dcvAttempted, perPassCap); + + if (decision == DcvSyncDecision.SkipByAge) + { + // Issue 0003 / SOC1 completeness: an order past the age window is no + // longer advanced by sync (it only ages further), so record its + // identity at Information — not just the aggregate count — so an + // auditor can see which orders were left parked, and when. + _logger.LogInformation( + "Sync: pending DV order aged out of the DCV-during-sync window and will " + + "not be advanced. CARequestID={Id}, OrderDate={OrderDate}, AgeWindowHours={Age}.", + current.Id, current.OrderDate?.ToString("o") ?? "(none)", ageWindowHours); + dcvSkippedAge++; + } + else if (decision == DcvSyncDecision.SkipByCap) + { + dcvSkippedCap++; + } + else + { + dcvAttempted++; + bool dcvDone = await TryRunDcvDuringSyncAsync( + current.Id, cancelToken, fastSync: true); + if (dcvDone) + { + try + { + current = await _client.GetCertificateAsync(current.Id, cancelToken); + status = StatusMapper.ToRequestDisposition(current.Status); + } + catch (Exception refetchEx) + { + _logger.LogWarning(refetchEx, + "Sync DCV completed but post-DCV refetch failed. Id={Id}", current.Id); + } + } + } + } +#endif + // Skip failed/rejected/cancelled certificates — they have no cert body - int status = StatusMapper.ToRequestDisposition(cert.Status); if (status == (int)EndEntityStatus.FAILED) { _logger.LogTrace( "Skipping certificate '{Id}' with terminal failure status '{Status}'.", - cert.Id, cert.Status); + current.Id, current.Status); + if (!string.IsNullOrWhiteSpace(current.Certificate)) skippedWithBody++; skipped++; continue; } - var record = MapToAnyCAPluginCertificate(cert); + // The order-report listing (ListCertificatesAsync) does NOT include the + // certificate body, so an already-issued order arrives here with + // current.Certificate == null. Command cannot store a record without a + // body, so issued certs were being silently dropped from sync. Refetch the + // full certificate (PEM included) for issued/revoked orders whose body is + // missing — this mirrors GetSingleRecord and the DCV-completed branch above. + // Pending (EXTERNALVALIDATION) records legitimately have no body yet and are + // left as-is. + if (string.IsNullOrWhiteSpace(current.Certificate) + && (status == (int)EndEntityStatus.GENERATED + || status == (int)EndEntityStatus.REVOKED)) + { + _logger.LogDebug( + "Sync: issued/revoked order Id={Id} has no body in the listing — refetching full certificate.", + current.Id); + // The order-report listing carries metadata (Subject/DomainName, + // ProfileId/ProductCode, OrderDate) that GetCertificateAsync (TrackOrder + + // DownloadCertificate) does NOT return. The refetch replaces `current` + // wholesale, so carry that listing metadata across or the emitted record + // loses its Subject and ProductID (ProductID feeds the Command template). + var listed = current; + try + { + current = await _client.GetCertificateAsync(current.Id, cancelToken); + current.Subject = string.IsNullOrWhiteSpace(current.Subject) ? listed.Subject : current.Subject; + current.ProfileId = string.IsNullOrWhiteSpace(current.ProfileId) ? listed.ProfileId : current.ProfileId; + current.OrderDate ??= listed.OrderDate; + status = StatusMapper.ToRequestDisposition(current.Status); + _logger.LogDebug( + "Sync: refetched order Id={Id} — status={Status}, certBytes={Bytes}, subject={Subject}.", + current.Id, status, current.Certificate?.Length ?? 0, + LogSanitizer.Strip(current.Subject)); + } + catch (Exception fetchEx) + { + _logger.LogWarning(fetchEx, + "Sync: failed to fetch certificate body for issued order '{Id}'; " + + "emitting metadata-only record.", current.Id); + } + } + + var record = MapToAnyCAPluginCertificate(current); + + // Emit-side observability (issue 0003): account for what the plugin hands to + // the gateway buffer, broken down by status and whether a cert body is present. + // This is the boundary the plugin owns — if these counts show issued records + // emitted WITH bodies but the gateway DB lacks them, the gap is gateway-side + // persistence, not the plugin. Per-record detail is at Debug; the aggregate is + // logged at Information in the completion summary below. + bool recordHasBody = !string.IsNullOrWhiteSpace(record.Certificate); + if (record.Status == (int)EndEntityStatus.GENERATED) + { + if (recordHasBody) emittedGeneratedWithBody++; else emittedGeneratedNoBody++; + } + else if (record.Status == (int)EndEntityStatus.REVOKED) + { + emittedRevoked++; + } + else if (record.Status == (int)EndEntityStatus.EXTERNALVALIDATION) + { + emittedPending++; + } + _logger.LogDebug( + "Sync emit: CARequestID={Id}, Status={Status}, CertBytes={CertBytes}, Subject={Subject}", + record.CARequestID, record.Status, record.Certificate?.Length ?? 0, + LogSanitizer.Strip(current.Subject)); + blockingBuffer.Add(record, cancelToken); synced++; } @@ -649,12 +1379,43 @@ public async Task Synchronize( { _logger.LogError(ex, "Error processing certificate '{Id}' during synchronization.", cert.Id); errors++; + + // SOC1 completeness/accuracy: a sync that hits an error-rate cliff + // must report a failure, not silently 'complete' with zero useful + // records. Abort if we have at least 50 records' worth of evidence + // AND more than 25% of all records seen so far are errors. + int totalSeen = synced + skipped + errors; + if (totalSeen >= 50 && errors > totalSeen / 4) + { + _logger.LogError( + "CERTInext synchronization aborted — error rate ({Errors}/{Total}) " + + "exceeded 25% threshold. Likely CA-side outage; will retry on next sync cycle.", + errors, totalSeen); + throw new Exception( + $"CERTInext synchronization aborted after {errors}/{totalSeen} records failed " + + "(>25% error rate). See gateway logs for the underlying CA errors."); + } } } + // Build the DCV-during-sync clause for the ACTUAL runtime state so the summary + // never implies DCV ran when it couldn't (issue 0003 / SOC2 CC7.3 accuracy). + string dcvClause; +#if SUPPORTS_DCV + if (dcvOperational) + dcvClause = $"DCV-during-sync: Attempted={dcvAttempted}, SkippedByAge={dcvSkippedAge} (>{ageWindowHours}h), SkippedByCap={dcvSkippedCap} (cap={perPassCap})."; + else + dcvClause = $"DCV-during-sync: not active (DcvEnabled={_config.DcvEnabled}, DnsProviderInjected={_domainValidatorFactory != null}) — pending orders left as EXTERNALVALIDATION."; +#else + dcvClause = "DCV-during-sync: not supported on this build (IAnyCAPlugin 3.2.0)."; +#endif _logger.LogInformation( - "CERTInext synchronization complete. Synced={Synced}, Skipped={Skipped}, Errors={Errors}", - synced, skipped, errors); + "CERTInext synchronization complete. Synced={Synced}, Skipped={Skipped} (withBody={SkippedWithBody}), " + + "Errors={Errors}. Emitted to gateway buffer: GeneratedWithBody={GenWithBody}, " + + "GeneratedNoBody={GenNoBody}, Revoked={Revoked}, Pending={Pending}. {DcvClause}", + synced, skipped, skippedWithBody, errors, + emittedGeneratedWithBody, emittedGeneratedNoBody, emittedRevoked, emittedPending, + dcvClause); } catch (OperationCanceledException) { @@ -669,165 +1430,4384 @@ public async Task Synchronize( blockingBuffer.CompleteAdding(); } - _logger.MethodExit(LogLevel.Trace); + _logger.MethodExit(LogLevel.Debug); } // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- + /// The DCV-during-sync gate outcome for a single pending order (issue 0002). + internal enum DcvSyncDecision { Attempt, SkipByAge, SkipByCap } + /// - /// Handles New and Reissue enrollment flows by submitting a fresh certificate - /// request to CERTInext. + /// Decides whether to attempt DCV completion for a pending order during a sync pass, + /// bounding the work so a large pending backlog can't make sync slow (issue 0002). + /// Pure/stateless so it is unit-testable without the DCV machinery. + /// + /// Rules (checked in order): + /// - Age: when > 0, only orders placed within that + /// window are eligible. A missing is treated as eligible + /// so a legitimately-new order is never starved by unknown age. + /// - Cap: when > 0, at most that many orders are attempted + /// per pass; once reaches it, the rest are deferred. + /// A value of 0 for either bound disables that bound. /// - private async Task EnrollNewAsync( - string csr, - string subject, - Dictionary san, - EnrollmentParams ep) + internal static DcvSyncDecision EvaluateDcvSyncEligibility( + DateTime? orderDateUtc, DateTime nowUtc, int ageWindowHours, int attemptedSoFar, int perPassCap) { - var enrollReq = new EnrollCertificateRequest - { - ProfileId = ep.ProfileId, - Csr = csr, - ValidityDays = ep.ValidityDays > 0 ? ep.ValidityDays : (int?)null, - Subject = subject, - Sans = BuildSanList(san), - RequesterName = string.IsNullOrWhiteSpace(ep.RequesterName) ? null : ep.RequesterName, - RequesterEmail = string.IsNullOrWhiteSpace(ep.RequesterEmail) ? null : ep.RequesterEmail, - KeyType = string.IsNullOrWhiteSpace(ep.KeyType) ? null : ep.KeyType, - Comment = "Issued via Keyfactor Command AnyCA REST Gateway." - }; + bool eligibleByAge = ageWindowHours <= 0 + || !orderDateUtc.HasValue + || (nowUtc - orderDateUtc.Value).TotalHours <= ageWindowHours; + if (!eligibleByAge) + return DcvSyncDecision.SkipByAge; - var enrollResp = await _client.EnrollCertificateAsync(enrollReq); + bool eligibleByCap = perPassCap <= 0 || attemptedSoFar < perPassCap; + if (!eligibleByCap) + return DcvSyncDecision.SkipByCap; - return BuildEnrollmentResult(enrollResp, ep.AutoApprove); + return DcvSyncDecision.Attempt; } + // --------------------------------------------------------------------------- + // V2 API private helpers — only called when _config.UseV2Api is true + // --------------------------------------------------------------------------- + /// - /// Handles Renew and RenewOrReissue enrollment flows. - /// Determines whether to renew (API call on existing ID) or fall back to new - /// issuance depending on the certificate's current state. + /// Composes a single E.164-style phone value from separate ISD-code + mobile-number + /// config fields, for V2 request shapes that carry one phone field (e.g. + /// ) rather than V1's separate + /// IsdCode/MobileNumber pair. Returns an empty string when + /// is blank (nothing to compose); returns the bare + /// mobile number when is blank (never invents a code). + /// Internal + static for direct unit testing (no live precedent existed for this exact + /// composition to mirror — see issues/0030-v2-technical-contact-not-sent.md). /// - private async Task RenewOrReissueAsync( - string csr, - string subject, - Dictionary san, - EnrollmentProductInfo productInfo, - EnrollmentParams ep) + internal static string ComposeV2Phone(string isdCode, string mobileNumber) { - // Retrieve the prior certificate serial number from the product parameters. - // Command injects "PriorCertSN" for renewal flows. - string priorCertSn = null; - productInfo.ProductParameters?.TryGetValue("PriorCertSN", out priorCertSn); + if (string.IsNullOrWhiteSpace(mobileNumber)) + return string.Empty; + if (string.IsNullOrWhiteSpace(isdCode)) + return mobileNumber.Trim(); - if (string.IsNullOrWhiteSpace(priorCertSn)) + return "+" + isdCode.Trim().TrimStart('+') + mobileNumber.Trim(); + } + + /// + /// Issue 0033: validates the template parameters a V2 private-pki order needs + /// beyond the SSL ones, before any CA call. Shared by (fail + /// fast with a FAILED result) and (reject at template + /// save time). Returns null when valid, else an actionable message naming the field. + /// + /// - ProductVariant must be one of the spec's create-body variant values + /// (). Its SSL-only "dv" default is + /// rejected rather than silently mapped to a Private PKI variant. + /// - ProductCode must be set explicitly. only advertises + /// SSL/TLS product names, so would + /// resolve an SSL product code for a private-pki template; and per the spec's Product + /// Codes reference, "Private PKI codes vary per customer catalog". + /// + internal static string ValidatePrivatePkiEnrollmentParams(EnrollmentParams ep, out string normalizedVariant) + { + normalizedVariant = null; + + string variant = ep.ProductVariant?.Trim(); + if (string.IsNullOrEmpty(variant) || !Constants.ApiV2.PrivatePkiVariants.Contains(variant)) { - // SOC2 CC7.2: log policy-relevant decisions at Information so they survive - // production log filters and are available for anomaly detection. - _logger.LogInformation( - "Renewal/reissue has no PriorCertSN — treating as new enrollment. Subject={Subject}", - subject); - return await EnrollNewAsync(csr, subject, san, ep); + string configured = ep.HasExplicitProductVariant ? $"'{variant}'" : "not set (defaults to the SSL-only 'dv')"; + return $"ProductFamily 'private-pki' requires the '{Constants.EnrollmentParam.ProductVariant}' " + + $"template parameter to be one of: {Constants.ApiV2.PrivatePkiVariantIntranetSsl}, " + + $"{Constants.ApiV2.PrivatePkiVariantIgtfHost}. Current value: {configured}."; } - // Resolve the CARequestID for the prior certificate - string priorCaRequestId; - try + if (!ep.HasExplicitProductCode) + { + return $"ProductFamily 'private-pki' requires the '{Constants.EnrollmentParam.ProductCode}' " + + "template parameter to be set explicitly to your account's Private PKI catalog product " + + "code (Private PKI codes vary per customer catalog and cannot be resolved from the " + + "selected SSL/TLS product name)."; + } + + normalizedVariant = variant.ToLowerInvariant(); + return null; + } + + /// + /// Issue 0059: resolves the SSL family's productVariant value to send, and + /// validates an explicit template override against the product it's paired with. + /// Previously, defaulted to "dv" independent + /// of , so an OV/EV product with no explicit + /// ProductVariant sent productVariant:"dv" — which skips the mandatory OV/EV + /// organization block (see isOvOrEv in and issue + /// 0028) — silently ordering a DV-shaped body for an OV/EV product. + /// + /// Derivation source: , keyed by + /// ProductId (the same productTypeID assurance-level grouping + /// documents). SSL-only — callers must + /// not invoke this for private-pki (see ) + /// or signature (not yet supported for enrollment) families. + /// + /// Returns null and sets when valid: either the + /// template's explicit value (when it agrees with the derived variant, or no mapping + /// exists for this ProductId — current pre-0059 behavior is kept rather than guessing), or + /// the value derived from ProductId when no explicit override is configured. Returns an + /// actionable error message ( = null) when an + /// explicit override contradicts the product's derived variant. + /// + internal static string ResolveSslProductVariant(EnrollmentParams ep, out string resolvedVariant) + { + bool hasMapping = Constants.Products.ProductVariantsV2.TryGetValue( + ep.ProductId ?? string.Empty, out string derivedVariant); + + if (!ep.HasExplicitProductVariant) + { + // No override configured — derive from the product when we have an authoritative + // mapping; otherwise fall back to the current (pre-0059) default rather than + // inventing a mapping for a product this table doesn't cover. + resolvedVariant = hasMapping ? derivedVariant : ep.ProductVariant; + return null; + } + + string explicitVariant = ep.ProductVariant.Trim(); + if (hasMapping && !string.Equals(explicitVariant, derivedVariant, StringComparison.OrdinalIgnoreCase)) + { + resolvedVariant = null; + return $"Template parameter '{Constants.EnrollmentParam.ProductVariant}' is set to " + + $"'{explicitVariant}', but the selected product '{ep.ProductId}' is " + + $"'{derivedVariant}'. Set '{Constants.EnrollmentParam.ProductVariant}' to " + + $"'{derivedVariant}' (or remove the override to let the plugin derive it " + + $"automatically from the product), or select a product whose assurance " + + $"level matches '{explicitVariant}'."; + } + + resolvedVariant = explicitVariant.ToLowerInvariant(); + return null; + } + + /// + /// Dispatches all enrollment types through the V2 REST API. + /// + private async Task EnrollV2Async( + string csr, + string subject, + Dictionary san, + EnrollmentParams ep, + EnrollmentType enrollmentType) + { + _logger.MethodEntry(LogLevel.Debug); + _logger.LogInformation( + "EnrollV2Async started. EnrollmentType={EnrollmentType}, ProductFamily={Family}, ProductVariant={Variant}, " + + "ProductId={ProductId}, HasExplicitProductCode={HasExplicit}, ConfiguredProductCode={Code}", + enrollmentType, ep.ProductFamilySlug, ep.ProductVariant, ep.ProductId, ep.HasExplicitProductCode, + ep.HasExplicitProductCode ? ep.ProductCode : "(resolved from catalog)"); + + // Issue 0033: the create-order body is family-specific. Previously every family got + // the SSL body (productVariant/certificate/agreement...), which is not the Private PKI + // or Document Signer shape at all. ssl (and any unrecognized ProductFamily, which + // ProductFamilySlug already maps to ssl) keeps the exact pre-0033 flow below. + bool isPrivatePki = string.Equals(ep.ProductFamilySlug, Constants.ApiV2.FamilyPrivatePki, StringComparison.Ordinal); + + // Document Signer (signature): the body DTO exists (V2CreateSignatureOrderRequest), + // but its mandatory subjectType / subject.email and the per-subject-type subject + // name/organization fields have no settled source in the Command enrollment inputs + // yet — an open design decision on issue 0033. Fail fast with a clear message, before + // any CA call, rather than guessing those values or sending the wrong-family SSL body + // (which the CA rejects anyway: subjectType and subject.email are "400 if missing"). + if (string.Equals(ep.ProductFamilySlug, Constants.ApiV2.FamilySignature, StringComparison.Ordinal)) + { + _logger.LogWarning( + "EnrollV2Async rejected a ProductFamily=signature (Document Signer) order — V2 Document " + + "Signer enrollment is not yet supported by this plugin version. EnrollmentType={EnrollmentType}, " + + "ProductId={ProductId}", + enrollmentType, ep.ProductId); + _logger.MethodExit(LogLevel.Debug); + return new EnrollmentResult + { + CARequestID = string.Empty, + Certificate = null, + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "V2 enrollment rejected: ProductFamily 'signature' (Document Signer) is not yet " + + "supported for enrollment by this plugin version. A Document Signer order needs " + + "signer-subject details (subjectType, subject email, and per-subject-type name or " + + "organization fields) that the plugin does not yet take from the enrollment request. " + + "No order was placed." + }; + } + + string privatePkiVariant = null; + if (isPrivatePki) + { + string pkiConfigError = ValidatePrivatePkiEnrollmentParams(ep, out privatePkiVariant); + if (pkiConfigError != null) + { + _logger.LogWarning( + "EnrollV2Async rejected a private-pki order before any CA call: {Reason}", pkiConfigError); + _logger.MethodExit(LogLevel.Debug); + return new EnrollmentResult + { + CARequestID = string.Empty, + Certificate = null, + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "V2 enrollment rejected: " + pkiConfigError + }; + } + } + + // Issue 0059: the SSL family's productVariant must reflect the actual product ordered + // — not the template's independent (and possibly wrong/stale) ProductVariant value. + // Resolve/validate before any CA call, same fail-fast placement as the private-pki + // check above. Private PKI (checked above) doesn't use this — its variant enum is + // unrelated (intranet-ssl/igtf-host). + string sslProductVariant = ep.ProductVariant; + if (!isPrivatePki) + { + string variantError = ResolveSslProductVariant(ep, out sslProductVariant); + if (variantError != null) + { + _logger.LogWarning( + "EnrollV2Async rejected an SSL order before any CA call: {Reason}", variantError); + _logger.MethodExit(LogLevel.Debug); + return new EnrollmentResult + { + CARequestID = string.Empty, + Certificate = null, + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "V2 enrollment rejected: " + variantError + }; + } + } + + // Issue 0039: the SSL create body always carries an `agreement` block, and the V2 spec + // marks agreement.signerPlace "Conditional - required if `agreement` sent". Resolved + // per-template SignerPlace -> connector SignerPlace (which ValidateCAConnectionInfo + // already requires); fail fast here too, before any CA call, in case the connector was + // saved before that check existed. Private PKI sends no agreement, so it is exempt. + string signerPlace = string.IsNullOrWhiteSpace(ep.SignerPlace) ? _config.SignerPlace : ep.SignerPlace; + if (!isPrivatePki && string.IsNullOrWhiteSpace(signerPlace)) + { + _logger.LogWarning( + "EnrollV2Async rejected an SSL order before any CA call — no SignerPlace is configured " + + "(neither the template's SignerPlace enrollment parameter nor the CA connector's SignerPlace), " + + "but the V2 Subscriber Agreement requires it. EnrollmentType={EnrollmentType}, ProductId={ProductId}", + enrollmentType, ep.ProductId); + _logger.MethodExit(LogLevel.Debug); + return new EnrollmentResult + { + CARequestID = string.Empty, + Certificate = null, + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "V2 enrollment rejected: the CERTInext V2 Subscriber Agreement requires a signer " + + "place, but SignerPlace is blank on both the CA connector and the template. Set " + + "SignerPlace on the CA connector (or the template's SignerPlace enrollment " + + "parameter) and retry. No order was placed." + }; + } + + // Derive the primary domain from subject CN (for private-pki this is the order's + // `hostname` — spec: "hostname - primary CN" — sourced the same way). + string domain = ep.DomainName; + if (string.IsNullOrWhiteSpace(domain)) + domain = ExtractCnFromSubject(subject); + if (string.IsNullOrWhiteSpace(domain)) + throw new Exception(isPrivatePki + ? "Cannot determine the primary hostname for V2 private-pki order — set the DomainName enrollment parameter or ensure the CSR subject has a CN." + : "Cannot determine primary domain for V2 order — set the DomainName enrollment parameter or ensure the CSR subject has a CN."); + + // organization is "Conditional — Mandatory for OV / EV" per the V2 spec's SSL field + // table; every OV/EV create example in the spec sends it, and it is omitted entirely + // for DV. CERTInext hard-rejects an OV/EV order with no organization block (HTTP 422 + // EMS-1180 "Organization Name cannot be empty" — confirmed live), so fail fast with a + // clear message here — before any catalog/order-placement call — rather than + // sending an incomplete block and letting the CA surface that opaque error. See + // issues/0028-v2-organizationnumber-not-sent.md. + // SSL-only: Private PKI "has no DCV, no organization block, and no Subscriber + // Agreement" per the spec (issue 0033), and its ProductVariant is intranet-ssl/igtf-host. + bool isOvOrEv = !isPrivatePki + && (string.Equals(sslProductVariant, Constants.ApiV2.ProductVariantOv, StringComparison.OrdinalIgnoreCase) + || string.Equals(sslProductVariant, Constants.ApiV2.ProductVariantEv, StringComparison.OrdinalIgnoreCase)); + + V2OrganizationParams organization = null; + if (isOvOrEv) + { + if (string.IsNullOrWhiteSpace(_config.OrganizationNumber)) + { + _logger.LogWarning( + "EnrollV2Async rejected a '{Variant}' order for domain '{Domain}' — the CA " + + "connector's OrganizationNumber is not configured, but an organization block is " + + "mandatory for OV/EV orders under the V2 API.", + sslProductVariant, LogSanitizer.Strip(domain)); + _logger.MethodExit(LogLevel.Debug); + return new EnrollmentResult + { + CARequestID = string.Empty, + Certificate = null, + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"V2 enrollment rejected: product variant '{sslProductVariant}' requires " + + "a pre-vetted organization, but the CA connector's OrganizationNumber " + + "setting is empty. Set OrganizationNumber on the CA connector before " + + "enrolling OV/EV certificates via the V2 API." + }; + } + + organization = new V2OrganizationParams + { + OrganizationNumber = _config.OrganizationNumber, + PreVetted = true + }; + } + + // SubscriptionAutoRenew / SubscriptionRenewCriteriaDays — CA connector config that + // feeds the Subscription block below. AutoRenew uses the same bare "1"-means-true + // comparison AutoSecureWww already uses elsewhere in this method. RenewBeforeDays is + // validated up front — before any CA call — so a bad value fails the enrollment with + // a clear message rather than surfacing as an opaque CA-side error later, matching the + // fail-fast convention the OrganizationNumber check above already established. Blank/ + // unset stays null (omitted on the wire; the client's global WhenWritingNull option + // means the CA falls back to its documented default of 30). See issue 0027 item 2a/2b. + bool subscriptionAutoRenew = _config.SubscriptionAutoRenew == "1"; + int? subscriptionRenewBeforeDays = null; + if (!string.IsNullOrWhiteSpace(_config.SubscriptionRenewCriteriaDays)) + { + if (!int.TryParse(_config.SubscriptionRenewCriteriaDays, out int parsedRenewDays) || parsedRenewDays < 0) + { + _logger.LogError( + "EnrollV2Async rejected an order — the CA connector's SubscriptionRenewCriteriaDays " + + "value ('{Value}') is not a valid non-negative integer.", + _config.SubscriptionRenewCriteriaDays); + _logger.MethodExit(LogLevel.Debug); + return new EnrollmentResult + { + CARequestID = string.Empty, + Certificate = null, + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"V2 enrollment rejected: the CA connector's SubscriptionRenewCriteriaDays " + + $"setting ('{_config.SubscriptionRenewCriteriaDays}') must be a non-negative " + + "integer (or blank to use the CA's default of 30). Fix the CA connector " + + "configuration and retry." + }; + } + + subscriptionRenewBeforeDays = parsedRenewDays; + } + + // EmailNotifications — issue 0027 item 1a: previously hardcoded "all" on every V2 + // order, ignoring the connector's EmailNotifications config entirely. The connector + // field uses V1's "0"/"1" vocabulary and defaults to "0". A real-inbox probe + // (2026-09-28, see issue 0027) confirmed V2 honors "0" by suppressing the + // order-creation emails — it does not silently coerce back to "all" — so the mapping + // below is user-decided, not a guess: "1" -> "all" (full notification set), "0" -> + // "0" (silent, matching V1's own wire vocabulary), blank/unset -> null (omitted; the + // client's global WhenWritingNull option drops the key and the CA falls back to its + // documented default of "all", NOT silent — this is the one place V1 and V2 defaults + // diverge for a blank config value, since V1's own fallback always sends "0"). Any + // other value fails fast, before any CA call, mirroring the + // SubscriptionRenewCriteriaDays check above. + string emailNotifications; + string configEmailNotifications = _config.EmailNotifications?.Trim(); + if (string.IsNullOrEmpty(configEmailNotifications)) + { + emailNotifications = null; + } + else if (configEmailNotifications == "1") + { + emailNotifications = "all"; + } + else if (configEmailNotifications == "0") + { + emailNotifications = "0"; + } + else + { + _logger.LogError( + "EnrollV2Async rejected an order — the CA connector's EmailNotifications value " + + "('{Value}') is not one of the supported values.", + _config.EmailNotifications); + _logger.MethodExit(LogLevel.Debug); + return new EnrollmentResult + { + CARequestID = string.Empty, + Certificate = null, + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"V2 enrollment rejected: the CA connector's EmailNotifications " + + $"setting ('{_config.EmailNotifications}') must be \"0\", \"1\", or " + + "blank. Fix the CA connector configuration and retry." + }; + } + + // Resolve the real V2 product code (and UCC-ness) from the live Catalog response. + // Fetched once here and reused for both purposes — no second catalog call. + // + // Explicit override (ProductCode/ProfileId set on the template): trust the + // administrator's value as-is; the catalog is only consulted for UCC detection, and a + // catalog lookup failure there must not block enrollment — fall back to non-UCC + // (single-domain) behavior, the strictly-safer failure mode: the CSR-SAN-count guard + // below still runs against that non-UCC assumption, so a surplus-SAN CSR is rejected + // rather than silently accepted. See issues/f3-v2-multi-san-limitation.md and + // issues/0047-v2-multi-san-guard-blocks-ucc-orders.md. + // + // No explicit override: the code must NOT fall back to + // Constants.Products.DefaultProductCodes (V1-era numbering that does not match the + // live V2 catalog — issue 0036, e.g. its "842" is OV SSL but the live V2 catalog's + // "842" is DV SSL). Instead resolve the live product code by matching the catalog + // entry whose productTypeID equals the stable numeric type ID for ep.ProductId + // (Constants.Products.ProductTypeIdsV2) — productTypeID is a small CERTInext-documented + // enum, unlike productCode (wrong table) or productName (spelling varies by + // account/catalog version — see issue 0036 triage). Here, a catalog failure or an + // unresolvable mapping MUST fail the enrollment loudly: there is no safe fallback code + // to send on the wire. + // + // Private PKI (issue 0033): the explicit ProductCode is required (validated above) and + // trusted as-is, exactly like the SSL explicit-override case; the catalog is not + // fetched at all because its only use on that path — SSL UCC detection — does not + // apply (ValidateProductInfo checks the code's productTypeID at template save time). + string productCode; + bool isUccProduct = false; + bool isWildcardProduct = false; + List catalog = null; + if (!isPrivatePki) + { + try + { + catalog = await _client.GetProductDetailsV2Async(); + } + catch (Exception catalogEx) + { + _logger.LogWarning(catalogEx, + "EnrollV2Async: could not fetch the live V2 product catalog. ProductId={ProductId}, " + + "HasExplicitProductCode={HasExplicit}", ep.ProductId, ep.HasExplicitProductCode); + } + } + + if (isPrivatePki) + { + productCode = ep.ProductCode; + } + else if (ep.HasExplicitProductCode) + { + productCode = ep.ProductCode; + + var matchedProduct = catalog?.FirstOrDefault(p => + string.Equals(p.ProductCode, productCode, StringComparison.OrdinalIgnoreCase)); + isUccProduct = matchedProduct != null + && !string.IsNullOrWhiteSpace(matchedProduct.ProductTypeId) + && Constants.ApiV2.UccProductTypeIds.Contains(matchedProduct.ProductTypeId); + isWildcardProduct = matchedProduct != null + && !string.IsNullOrWhiteSpace(matchedProduct.ProductTypeId) + && Constants.ApiV2.WildcardProductTypeIds.Contains(matchedProduct.ProductTypeId); + } + else + { + if (!Constants.Products.ProductTypeIdsV2.TryGetValue(ep.ProductId ?? string.Empty, out string expectedTypeId)) + { + _logger.LogError( + "EnrollV2Async rejected an order for ProductId={ProductId} — no productTypeID mapping " + + "is defined for this product name, and no explicit ProductCode override is configured.", + ep.ProductId); + _logger.MethodExit(LogLevel.Debug); + return new EnrollmentResult + { + CARequestID = string.Empty, + Certificate = null, + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"V2 enrollment rejected: no productTypeID mapping is defined for " + + $"ProductID '{ep.ProductId}'. Set the ProductCode template parameter " + + "explicitly, or contact support to add a mapping for this product." + }; + } + + // Sandbox-confirmed: the live catalog can carry MORE THAN ONE entry with the + // same productTypeID (e.g. two type-13 DV SSL entries, "917 SSL DV 1 month" + // and "842 DV SSL Certificate") — picking the catalog's first-listed match + // (the old behavior) is not safe: on sandbox it silently ordered "917", which + // PUT /csr then rejected 422 "PFX based certificate orders are not allowed", + // failing every ProductId-only DV SSL enrollment. When more than one catalog + // entry shares the expected productTypeID, only resolve automatically if the + // connector's DefaultProductCode names one of them; otherwise reject with a + // clear message listing the candidates rather than guessing. + var matchingProducts = (catalog ?? new List()) + .Where(p => string.Equals(p.ProductTypeId, expectedTypeId, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + ProductDetail matchedProduct = null; + if (matchingProducts.Count == 1) + { + matchedProduct = matchingProducts[0]; + } + else if (matchingProducts.Count > 1) + { + matchedProduct = matchingProducts.FirstOrDefault(p => + !string.IsNullOrWhiteSpace(_config.DefaultProductCode) && + string.Equals(p.ProductCode, _config.DefaultProductCode, StringComparison.OrdinalIgnoreCase)); + + if (matchedProduct == null) + { + string candidates = string.Join(", ", + matchingProducts.Select(p => $"{p.ProductCode} ('{p.ProductName}')")); + _logger.LogWarning( + "EnrollV2Async rejected an order — multiple CERTInext V2 catalog entries share " + + "productTypeID '{ExpectedTypeId}' for ProductId={ProductId}, and none match the " + + "configured DefaultProductCode ('{DefaultProductCode}'). Candidates=[{Candidates}]", + expectedTypeId, ep.ProductId, + string.IsNullOrWhiteSpace(_config.DefaultProductCode) ? "(not set)" : _config.DefaultProductCode, + candidates); + _logger.MethodExit(LogLevel.Debug); + return new EnrollmentResult + { + CARequestID = string.Empty, + Certificate = null, + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"V2 enrollment rejected: multiple CERTInext catalog products match " + + $"ProductID '{ep.ProductId}' (productTypeID '{expectedTypeId}'): " + + $"{candidates}. Set the ProductCode template parameter explicitly, " + + "or set the CA connector's DefaultProductCode to one of these codes, " + + "to disambiguate." + }; + } + } + + if (matchedProduct == null || string.IsNullOrWhiteSpace(matchedProduct.ProductCode)) + { + _logger.LogError( + "EnrollV2Async: could not resolve a live V2 product code for ProductId={ProductId} " + + "(expected catalog productTypeID='{ExpectedTypeId}'). CatalogFetchSucceeded={CatalogOk}", + ep.ProductId, expectedTypeId, catalog != null); + _logger.MethodExit(LogLevel.Debug); + return new EnrollmentResult + { + CARequestID = string.Empty, + Certificate = null, + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"V2 enrollment rejected: could not resolve a live product code for " + + $"ProductID '{ep.ProductId}' from the CERTInext catalog (expected " + + $"productTypeID '{expectedTypeId}'). Verify the account is entitled to " + + "this product, or set the ProductCode template parameter explicitly." + }; + } + + productCode = matchedProduct.ProductCode; + isUccProduct = Constants.ApiV2.UccProductTypeIds.Contains(expectedTypeId); + isWildcardProduct = Constants.ApiV2.WildcardProductTypeIds.Contains(expectedTypeId); + + _logger.LogInformation( + "EnrollV2Async: resolved V2 product code from live catalog. ProductId={ProductId}, " + + "ProductTypeId={ProductTypeId}, ResolvedProductCode={ProductCode}", + ep.ProductId, expectedTypeId, productCode); + } + + // Non-UCC V2 products support only a single domain plus autoSecureWww's www. + // variant; any other DNS SAN — from the CSR OR Command's SAN dictionary — would be + // silently dropped (issue 0061) or cause a CA-side rejection, so fail fast with a + // clear message rather than letting the CA return an opaque error, or the extra + // names vanish with no order-side signal at all. UCC (multi-domain) products are + // exempt — their extra SANs are the expected input and are carried into + // additionalDomains below instead. This check necessarily runs after UCC detection + // above (which needs the live catalog lookup to know isUccProduct), not before it, + // but it still runs before PlaceOrderV2Async, so a rejected non-UCC request never + // places a CA order. See issues/f3-v2-multi-san-limitation.md, + // issues/0047-v2-multi-san-guard-blocks-ucc-orders.md and + // issues/0061-v2-san-dictionary-extras-silently-dropped.md. + // + // Issue 0061: looking at the CSR alone missed the case where Command's SAN + // dictionary carries the extras and the CSR itself carries only the primary domain — + // a non-UCC order never sends additionalDomains at all, so those dictionary-only + // extras had nowhere to go and were dropped with no warning. + // + // This guard deliberately takes the UNION of the CSR's own SANs and the SAN + // dictionary — NOT CollectRequestedSanEntries'/BuildSanList's "fallback, not union" + // rule (dictionary wins when non-null; CSR consulted only when the dictionary is + // null). That rule exists to decide what additionalDomains/additionalHosts actually + // send to the CA, where the CSR's own subjectAltName extension is otherwise ignored. + // Here it is wrong: SubmitCsrV2Async submits the CSR to CERTInext verbatim regardless + // of what the SAN dictionary contains, so a CSR-embedded extra domain still reaches + // the CA even when Command also supplied a (single-domain) SAN dictionary. Deferring + // to the dictionary alone whenever it is non-null would let that CSR-embedded extra + // slip past this guard unrejected — a regression of the pre-0061 CSR-extras reject + // (issues/f3-v2-multi-san-limitation.md, issues/0047-v2-multi-san-guard-blocks-ucc-orders.md) + // for any enrollment that also happens to pass a SAN dictionary. + // + // SSL-only (issue 0033): a private-pki order carries its SANs in additionalHosts, + // which the spec defines as a multi-entry "SAN list (DNS names or IPv4 / IPv6)" for + // every Private PKI variant, so the single-domain restriction does not apply. + // + // Wildcard apex exemption: a non-UCC wildcard product's `domain` is the literal + // wildcard value (e.g. "*.example.com"), and a wildcard CSR/SAN dictionary + // routinely also carries the bare apex ("example.com") alongside it — rejecting + // that apex as an "extra SAN" would make every ordinary wildcard CSR unenrollable + // via this guard. Exempt exactly the apex (the wildcard domain with its leading + // "*." stripped) for wildcard products (productTypeID 14/17, + // Constants.ApiV2.WildcardProductTypeIds) — any other extra SAN is still rejected. + // NOTE (unverified): this only widens what the guard *accepts*; it does not change + // what is sent to the CA for the apex — additionalDomains is still not populated + // for non-UCC products below, exactly as before this fix. Whether CERTInext's V2 + // order create needs the apex added to additionalDomains (or handles it + // automatically for a wildcard product) has not been confirmed live and is left + // to the principal to verify. + if (!isPrivatePki && !isUccProduct) + { + string wildcardApexDomain = isWildcardProduct && domain != null + && domain.StartsWith("*.", StringComparison.Ordinal) + ? StripWildcardPrefix(domain) + : null; + + var csrSanEntries = ExtractSanEntriesFromCsr(csr, out _); + // CollectRequestedSanEntries(san, csr: null, ...) yields exactly the SAN + // dictionary's own (MapSanType-normalized) entries: when san is non-null the + // CSR-fallback branch never runs, and when san is null there is nothing to + // collect (csr: null makes the fallback branch's own CSR read a no-op) — the + // real CSR is already covered by csrSanEntries above, so no double-count. + var dictSanEntries = CollectRequestedSanEntries(san, csr: null, out _, out _); + var extraSans = csrSanEntries + .Concat(dictSanEntries) + .Where(s => string.Equals(s.Type, "dns", StringComparison.OrdinalIgnoreCase)) + .Select(s => s.Value?.ToLowerInvariant()) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Where(v => !string.Equals(v, domain, StringComparison.OrdinalIgnoreCase)) + .Where(v => !string.Equals(v, "www." + domain, StringComparison.OrdinalIgnoreCase)) + .Where(v => wildcardApexDomain == null || !string.Equals(v, wildcardApexDomain, StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (extraSans.Count > 0) + { + _logger.LogWarning( + "EnrollV2Async rejected multi-SAN order on domain '{Domain}'. " + + "This product does not support additional domains via the V2 API " + + "(autoSecureWww covers www.); only UCC (multi-domain) products may carry " + + "extra SANs, whether requested via the CSR or Command's SAN dictionary. " + + "ExtraSans=[{ExtraSans}]", + LogSanitizer.Strip(domain), + LogSanitizer.Strip(string.Join(", ", extraSans))); + _logger.MethodExit(LogLevel.Debug); + // Wildcard products get their own wording: unlike a plain single-domain + // product, a wildcard product cannot be made to accept extra SANs by + // switching to a UCC product on this same domain, so the rejection must not + // suggest that. + string statusMessage = isWildcardProduct + ? $"V2 enrollment rejected: {extraSans.Count} SAN(s) beyond the primary wildcard " + + $"domain ('{domain}') and its www. variant were requested (via the CSR and/or " + + "Command's SAN dictionary). Only the primary wildcard domain and its bare apex " + + "are supported via the V2 API for this product. Resubmit with a CSR/SAN set " + + "carrying only those, and no other SANs." + : $"V2 enrollment rejected: {extraSans.Count} SAN(s) beyond the primary domain " + + $"('{domain}') and its www. variant were requested (via the CSR and/or Command's " + + "SAN dictionary). This product only supports a single domain via the V2 API " + + "(UCC/multi-domain products are the exception). Resubmit with a single-domain " + + "CSR and no additional SANs, or enroll against a UCC product if additional " + + "domains are required."; + return new EnrollmentResult + { + CARequestID = string.Empty, + Certificate = null, + Status = (int)EndEntityStatus.FAILED, + StatusMessage = statusMessage + }; + } + } + + // For UCC products, additionalDomains is populated from the same SAN source the V1 + // path uses for BuildAdditionalDomains (CERTInextClient.cs) — the gateway-supplied SAN + // dictionary, falling back to CSR SANs only when Command supplied no SAN dictionary at + // all (BuildSanList's own fallback rule). additionalDomains is a domain-name-only field + // per the V2 spec, so non-DNS SAN types are excluded (and logged) rather than submitted. + // BuildSanList runs in DNS-only mode here (issue 0046): V1's SubmitNonDnsSans switch + // and its "submitted rather than dropped" wording do not apply to this field, so the + // exclusion is logged below instead — SAN types only, since a value (e.g. an email + // address) may be personal data. + List additionalDomains = null; + if (isUccProduct) + { + var resolvedSans = BuildSanList(san, csr, subject, dnsOnly: true, out var nonDnsSans); + if (nonDnsSans.Count > 0) + { + _logger.LogWarning( + "EnrollV2Async: {Count} non-DNS SAN(s) excluded from V2 additionalDomains " + + "(FQDNs only) — they will NOT appear on the issued certificate. " + + "Types=[{Types}], Subject={Subject}", + nonDnsSans.Count, + string.Join(", ", nonDnsSans.Select(s => s.Type).Distinct(StringComparer.OrdinalIgnoreCase)), + LogSanitizer.Strip(subject)); + } + + additionalDomains = resolvedSans? + .Where(s => string.Equals(s.Type, "dns", StringComparison.OrdinalIgnoreCase)) + .Select(s => s.Value?.Trim()) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Where(v => !string.Equals(v, domain, StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + if (additionalDomains != null && additionalDomains.Count == 0) + additionalDomains = null; + + _logger.LogInformation( + "EnrollV2Async: resolved UCC product. ProductCode={ProductCode}, AdditionalDomainCount={Count}", + productCode, additionalDomains?.Count ?? 0); + } + + // Private PKI additionalHosts (issue 0033): same SAN source rule as the UCC path + // above, but DNS names AND IP addresses are both native here (spec: "SAN list (DNS + // names or IPv4 / IPv6)"; the Intranet SSL example sends "10.0.0.50"). + List additionalHosts = null; + if (isPrivatePki) + { + additionalHosts = BuildPrivatePkiAdditionalHosts(san, csr, subject, domain); + _logger.LogInformation( + "EnrollV2Async: private-pki order. Variant={Variant}, ProductCode={ProductCode}, " + + "Hostname={Hostname}, AdditionalHostCount={Count}", + privatePkiVariant, productCode, LogSanitizer.Strip(domain), additionalHosts?.Count ?? 0); + } + + string requestorName = string.IsNullOrWhiteSpace(ep.RequesterName) ? _config.RequestorName : ep.RequesterName; + string requestorEmail = string.IsNullOrWhiteSpace(ep.RequesterEmail) ? _config.RequestorEmail : ep.RequesterEmail; + string signerName = string.IsNullOrWhiteSpace(ep.SignerName) ? requestorName : ep.SignerName; + string signerIp = string.IsNullOrWhiteSpace(ep.SignerIp) ? _config.SignerIp : ep.SignerIp; + // signerPlace is resolved (and required for SSL) near the top of this method. + int validityYears = ep.ValidityYears > 0 ? ep.ValidityYears + : (int.TryParse(_config.SubscriptionValidityYears, out int cfgYears) && cfgYears > 0 ? cfgYears : 1); + + // requestorIsd/requestorMobile are the same Requestor* defaults V1 falls back to for + // its own TechnicalPointOfContact (CERTInextClient.cs BuildOrderRequestFromLegacyEnrollRequest). + // Also used directly for this order's own Requestor.Phone (below) via ComposeV2Phone — + // issue 0027 item 5b: Requestor.Phone previously sent the raw mobile number only, with + // RequestorIsdCode never combined in. Fixed to reuse the same ComposeV2Phone helper + // TechnicalPointOfContact.Phone already uses (issue 0030). + string requestorIsd = string.IsNullOrWhiteSpace(_config.RequestorIsdCode) ? "1" : _config.RequestorIsdCode; + string requestorMobile = _config.RequestorMobileNumber ?? string.Empty; + + // Requestor.Designation — issue 0027 item 5e: previously hardcoded "IT Administrator" + // (the V2 spec's own example value for this Optional free-text field). Now sourced from + // the RequestorDesignation config field; blank/unset leaves this null so the property is + // omitted from the wire JSON entirely (relies on the client's global + // DefaultIgnoreCondition = WhenWritingNull, CERTInextClient.GetJsonOptions()), rather than + // sending any default designation value. + string requestorDesignation = string.IsNullOrWhiteSpace(_config.RequestorDesignation) + ? null + : _config.RequestorDesignation.Trim(); + + // technicalPointOfContact — each field falls back to the requestor default when its + // TechnicalContact* counterpart is blank, mirroring V1's BuildOrderRequestFromLegacyEnrollRequest + // (CERTInextClient.cs:2335-2341). See issues/0030-v2-technical-contact-not-sent.md. + string technicalContactName = string.IsNullOrWhiteSpace(_config.TechnicalContactName) ? requestorName : _config.TechnicalContactName; + string technicalContactEmail = string.IsNullOrWhiteSpace(_config.TechnicalContactEmail) ? requestorEmail : _config.TechnicalContactEmail; + string technicalContactIsd = string.IsNullOrWhiteSpace(_config.TechnicalContactIsdCode) ? requestorIsd : _config.TechnicalContactIsdCode; + string technicalContactMobile = string.IsNullOrWhiteSpace(_config.TechnicalContactMobileNumber) ? requestorMobile : _config.TechnicalContactMobileNumber; + + // Blocks shared verbatim by every family's create body — the spec's requestor, + // subscription and technicalPointOfContact field tables are identical for the SSL/TLS + // and Private PKI folders (issue 0033). + var requestor = new V2Requestor + { + Name = requestorName, + Email = requestorEmail, + Phone = ComposeV2Phone(requestorIsd, requestorMobile), + Designation = requestorDesignation + }; + var subscription = new V2SubscriptionParams + { + ValidityYears = validityYears, + AutoRenew = subscriptionAutoRenew, + RenewBeforeDays = subscriptionRenewBeforeDays + }; + // Always populated (never omitted), even though the spec marks every subfield + // Optional — mirrors V1's fallback-to-Requestor* TechnicalPointOfContact + // defaulting rather than leaving the block blank. See issue 0030. + var technicalPointOfContact = new V2TechnicalPointOfContact + { + Name = technicalContactName, + Email = technicalContactEmail, + Phone = ComposeV2Phone(technicalContactIsd, technicalContactMobile), + Designation = Constants.ApiV2.DefaultTechnicalContactDesignation + }; + const string remarks = "Issued via Keyfactor Command AnyCA REST Gateway."; + // Mirrors V1's DelegationInformation.GroupNumber — omit when unconfigured so the + // order falls back to the account's default billing group (issue 0029). + string groupNumber = string.IsNullOrWhiteSpace(_config.GroupNumber) ? null : _config.GroupNumber; + + V2CreateOrderResponse createResp; + if (isPrivatePki) + { + // Spec "Private PKI Certificates" body: no productVariant / organization / + // certificate / agreement blocks — "Private PKI has no DCV, no organization + // block, and no Subscriber Agreement". + var privatePkiReq = new V2CreatePrivatePkiOrderRequest + { + Variant = privatePkiVariant, + EmailNotifications = emailNotifications, + GroupNumber = groupNumber, + Requestor = requestor, + Hostname = domain, + AdditionalHosts = additionalHosts, + Subscription = subscription, + Remarks = remarks, + TechnicalPointOfContact = technicalPointOfContact + }; + createResp = await _client.PlaceOrderV2Async(productCode, privatePkiReq); + } + else + { + var orderReq = new V2CreateSslOrderRequest + { + ProductVariant = sslProductVariant, + EmailNotifications = emailNotifications, + Requestor = requestor, + Organization = organization, + Certificate = new V2CertificateParams + { + Domain = domain, + AutoSecureWww = _config.AutoSecureWww == "1", + AdditionalDomains = additionalDomains + }, + Subscription = subscription, + Agreement = new V2AgreementParams + { + SignerName = signerName, + SignerIp = string.IsNullOrWhiteSpace(signerIp) ? null : signerIp, + SignerPlace = string.IsNullOrWhiteSpace(signerPlace) ? null : signerPlace, + Accepted = true + }, + TechnicalPointOfContact = technicalPointOfContact, + Remarks = remarks, + GroupNumber = groupNumber + }; + createResp = await _client.PlaceOrderV2Async(ep.ProductFamilySlug, productCode, orderReq); + } + string orderId = createResp.OrderId; + + _logger.LogInformation( + "V2 order placed. OrderId={OrderId}, Status={Status}, EnrollmentType={EnrollmentType}", + orderId, createResp.Status, enrollmentType); + + // The V2 API creates the order in 'pending-csr' and requires a separate PUT to submit + // the CSR before the order can progress to validation or issuance. + // Issue 0039 / review finding (B): if Submit CSR throws, the order already exists at + // pending-csr and Command would otherwise never learn its ID. A *definitive* CA + // rejection (a real HTTP 4xx response body) keeps the original single-best-effort- + // cancel-and-FAILED behavior. But a transport-level failure or timeout tells us + // nothing about whether CERTInext actually received and recorded the CSR — cancelling + // unconditionally on any exception here can orphan a CSR the CA genuinely accepted. + // For that ambiguous case, track the order first and only cancel if it is still + // pending-csr; if tracking itself fails, don't cancel — return pending so sync resolves + // it later. + V2OrderStatusResponse postCsrStatus = null; + int disposition = 0; + // Raw CA status string behind the current `disposition` value — kept in step with it + // (reassigned everywhere `disposition` is recomputed from a fresh TrackOrderV2Async + // call) purely so a terminal REVOKED result can be logged/reported with the CA's own + // status text (issue 0052), not just Command's mapped disposition. + string lastKnownCaStatus = null; + bool csrStatusResolvedAfterFailure = false; + try + { + await _client.SubmitCsrV2Async(ep.ProductFamilySlug, orderId, csr); + } + catch (Exception csrEx) + { + if (!IsTransportLevelCsrFailure(csrEx)) + { + // Definitive CA rejection (a real 4xx response) — unchanged behavior. + var orphanResult = await CancelOrphanedV2OrderAfterCsrFailureAsync( + ep.ProductFamilySlug, orderId, csrEx); + _logger.MethodExit(LogLevel.Debug); + return orphanResult; + } + + _logger.LogWarning(csrEx, + "V2 SubmitCsrV2Async failed with a transport-level or timeout error for order " + + "{OrderId}; tracking the order before deciding whether to cancel it (CERTInext " + + "may have already accepted the CSR).", orderId); + + V2OrderStatusResponse trackedAfterCsrFailure; + try + { + trackedAfterCsrFailure = await _client.TrackOrderV2Async(ep.ProductFamilySlug, orderId); + } + catch (Exception trackEx) + { + // Tracking itself failed — we still don't know whether the CSR landed. Do not + // cancel (that risks orphaning an order CERTInext may have already advanced); + // return pending with the known orderId so the next sync resolves it. + _logger.LogWarning(trackEx, + "V2 TrackOrderV2Async also failed while resolving an ambiguous CSR-submit " + + "failure for order {OrderId}; returning pending without cancelling.", orderId); + _logger.MethodExit(LogLevel.Debug); + return new EnrollmentResult + { + CARequestID = orderId, + Certificate = null, + Status = (int)EndEntityStatus.EXTERNALVALIDATION, + StatusMessage = $"V2 order {orderId} placed; CSR submission result is unknown " + + "after a transport error and the follow-up status check also " + + "failed — sync will resolve." + }; + } + + if (string.Equals(trackedAfterCsrFailure.Status, Constants.ApiV2.StatusPendingCsr, + StringComparison.OrdinalIgnoreCase)) + { + // Confirmed: the CSR never landed. Cancel exactly as before. + var orphanResult = await CancelOrphanedV2OrderAfterCsrFailureAsync( + ep.ProductFamilySlug, orderId, csrEx); + _logger.MethodExit(LogLevel.Debug); + return orphanResult; + } + + // The order progressed past pending-csr — CERTInext did receive the CSR despite the + // transport error on our side. Continue the normal post-CSR flow below instead of + // cancelling a valid order. + _logger.LogInformation( + "V2 CSR submission actually succeeded despite a transport-level error — order " + + "{OrderId} progressed to status {Status}. Continuing normal post-CSR flow.", + orderId, trackedAfterCsrFailure.Status); + postCsrStatus = trackedAfterCsrFailure; + disposition = StatusMapper.V2StatusToRequestDisposition(trackedAfterCsrFailure.Status); + lastKnownCaStatus = trackedAfterCsrFailure.Status; + csrStatusResolvedAfterFailure = true; + } + + if (!csrStatusResolvedAfterFailure) + { + _logger.LogInformation("V2 CSR submitted. OrderId={OrderId}", orderId); + + // Re-read status after CSR submission — the order advances past pending-csr. + // Guard: if TrackOrderV2Async fails transiently here the order is already + // placed and the CSR submitted; return pending with the known orderId so Command + // has a CARequestID and the next sync can resolve the status. + try + { + postCsrStatus = await _client.TrackOrderV2Async(ep.ProductFamilySlug, orderId); + disposition = StatusMapper.V2StatusToRequestDisposition(postCsrStatus.Status); + lastKnownCaStatus = postCsrStatus.Status; + } + catch (Exception trackEx) + { + _logger.LogWarning(trackEx, + "V2 TrackOrderV2Async failed after CSR submission for order {OrderId}; " + + "returning pending so sync can pick it up.", orderId); + _logger.MethodExit(LogLevel.Debug); + return new EnrollmentResult + { + CARequestID = orderId, + Certificate = null, + Status = (int)EndEntityStatus.EXTERNALVALIDATION, + StatusMessage = "V2 order placed and CSR submitted; status check failed transiently — sync will resolve." + }; + } + } + + // Whether the inline DCV block below took ownership of the in-call issuance wait for + // this order (issue 0051). Declared outside the #if so both build flavors compile the + // pickup-gate call below the same way (it simply stays false on the no-DCV build). + // Mirrors dcvIssuanceWaitRan in EnrollNewAsync, but the condition is derived + // differently: V2's outer gate here is only "order landed in pending-dcv" — whether + // DCV is actually configured/enabled is checked *inside* PerformDcvV2IfNeededAsync, not + // at this call site — so we can't pre-set the flag before calling it (that would also + // catch the DCV-disabled case and wrongly skip pickup for every V2 order). Instead this + // is set from PerformDcvV2IfNeededAsync's own return contract ("true when DCV steps were + // executed, false when cleanly skipped"), which is the one source of truth for whether + // an in-call wait was actually spent. + bool dcvV2Ran = false; + +#if SUPPORTS_DCV + // Attempt DCV inline when the order lands in pending-dcv and DCV is configured + if (disposition == (int)EndEntityStatus.EXTERNALVALIDATION) + { + int timeoutMinutes = _config.GetEffectiveDcvTimeoutMinutes(); + using var dcvCts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + dcvCts.CancelAfter(TimeSpan.FromMinutes(timeoutMinutes)); + try + { + bool dcvDone = await PerformDcvV2IfNeededAsync( + orderId, domain, ep.ProductFamilySlug, dcvCts.Token, + postCsrStatus?.Verifications?.Domain?.Domains); + dcvV2Ran = dcvDone; + if (dcvDone) + { + // Re-check status after DCV completes + var tracked = await _client.TrackOrderV2Async(ep.ProductFamilySlug, orderId); + disposition = StatusMapper.V2StatusToRequestDisposition(tracked.Status); + lastKnownCaStatus = tracked.Status; + _logger.LogInformation( + "V2 DCV completed inline for order {OrderId}. Post-DCV status={Status}", + orderId, tracked.Status); + } + } + catch (Exception dcvEx) + { + // Every early-exit path inside PerformDcvV2IfNeededAsync (not configured, no + // domain, in-flight elsewhere, GetDcv/staging/verify failure) is caught + // internally and returns false rather than throwing — the only way an + // exception reaches here is VerifyDcvV2Async failing *after* the TXT record + // was already staged and the propagation delay already spent. Real in-call + // wait time was consumed, so still treat this as "DCV ran" and defer to the + // next sync rather than stacking a pickup poll on top. + dcvV2Ran = true; + _logger.LogWarning(dcvEx, + "V2 inline DCV attempt failed for order {OrderId}; order will remain pending for sync.", + orderId); + } + } +#endif + + // If the order issued immediately, download the certificate + if (disposition == (int)EndEntityStatus.GENERATED) + { + try + { + var certResp = await _client.DownloadCertificateV2Async(ep.ProductFamilySlug, orderId); + string fullChain = AssembleV2CertChain(certResp); + _logger.LogInformation( + "V2 certificate downloaded immediately. OrderId={OrderId}, SerialNumber={Serial}, ChainPemCount={ChainCount}", + orderId, certResp.SerialNumber, certResp.ChainPem?.Count ?? 0); + _logger.MethodExit(LogLevel.Debug); + return new EnrollmentResult + { + CARequestID = orderId, + Certificate = fullChain, + Status = (int)EndEntityStatus.GENERATED, + StatusMessage = "Certificate issued via V2 API." + }; + } + catch (Exception dlEx) + { + _logger.LogWarning(dlEx, + "V2 order is 'issued' but certificate download failed — returning pending. OrderId={OrderId}", + orderId); + } + } + + // Synchronous certificate pickup (V2 parity with the V1/Sectigo pickup poll, issue + // 0051): poll for the issued certificate so a fast-issuing DV order returns GENERATED + // + PEM in this same call instead of waiting for the next synchronization. No-op when + // the inline DCV block above already ran an in-call wait for this order (dcvV2Ran). + var pendingResult = new EnrollmentResult + { + CARequestID = orderId, + Certificate = null, + Status = disposition == (int)EndEntityStatus.GENERATED + ? (int)EndEntityStatus.EXTERNALVALIDATION + : disposition, + StatusMessage = $"V2 order placed. Status={createResp.Status}" + }; + var (pickedUpResult, observedCaStatus) = await PickUpEnrolledCertificateV2Async( + pendingResult, orderId, ep.ProductFamilySlug, dcvV2Ran, lastKnownCaStatus); + + // Issue 0052: normalize a body-less REVOKED disposition to FAILED once here, right + // before returning — every path above that can surface REVOKED (the post-CSR-submit + // status check, the post-DCV status re-check, and PickUpEnrolledCertificateV2Async's + // own poll) funnels through pickedUpResult, so a single check here covers all of them + // rather than patching each branch individually. See NormalizeV2RevokedEnrollResult. + var finalResult = NormalizeV2RevokedEnrollResult(pickedUpResult, orderId, observedCaStatus); + + _logger.MethodExit(LogLevel.Debug); + return finalResult; + } + + /// + /// Review finding (B): true when (thrown by SubmitCsrV2Async) + /// represents a transport-level failure, timeout, or cancellation — i.e. whether CERTInext + /// actually received and recorded the CSR is unknown — rather than a definitive CA-side + /// rejection (a real HTTP 4xx response CERTInext returned after processing the request). + /// CERTInextClient is built with ThrowOnAnyError=false, so a connection + /// failure/timeout never received a response and instead renders through + /// ThrowOnV2Failure's generic fallback as "... HTTP 0. ..." — 0 is the + /// RestSharp default when no response arrived. + /// A 5xx or any message shape this cannot parse is treated the same conservative way (not + /// a confirmed rejection): a CA server error or an unrecognized failure does not confirm + /// the CSR was rejected, so the safer default is to track the order rather than assume. + /// Only a parsed 4xx status is treated as definitive. + /// + internal static bool IsTransportLevelCsrFailure(Exception ex) + { + if (ex == null) return true; + if (ex is OperationCanceledException) return true; + if (ex is System.Net.Http.HttpRequestException) return true; + if (ex is TimeoutException) return true; + + var m = System.Text.RegularExpressions.Regex.Match(ex.Message ?? string.Empty, @"HTTP (\d+)\."); + if (!m.Success) return true; + int status = int.Parse(m.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture); + return status < 400 || status >= 500; + } + + /// + /// Cancel reason sent to CERTInext when Submit CSR fails after the order was created + /// (issue 0039). Deliberately fixed text: the CSR exception may carry CA response detail, + /// and the reason is persisted in the CA's audit log. + /// + internal const string OrphanedOrderCancelReason = + "Keyfactor gateway: CSR submission failed; cancelling orphaned order."; + + /// + /// Issue 0039: calls this when SubmitCsrV2Async throws + /// after the order was placed. Makes exactly one best-effort CancelOrderV2Async + /// call (never retried, never rethrown) and returns a FAILED result that carries the + /// orderId and says whether the orphaned order was cancelled. + /// + private async Task CancelOrphanedV2OrderAfterCsrFailureAsync( + string familySlug, string orderId, Exception csrEx) + { + _logger.MethodEntry(LogLevel.Trace); + // The CSR exception message can carry CA response detail (problem+json "detail"), + // so it is redacted like any other CA body and capped at 500 characters. + string csrFailure = RedactAndCapForLog(csrEx.Message); + + string cancelOutcome; + string cancelMessage; + try + { + var outcome = await _client.CancelOrderV2Async(familySlug, orderId, OrphanedOrderCancelReason); + if (outcome == V2CancelOrderOutcome.Cancelled) + { + cancelOutcome = "cancelled"; + cancelMessage = "The orphaned order was cancelled."; + } + else + { + cancelOutcome = "not cancelled (HTTP 422: order already in a terminal state)"; + cancelMessage = "The orphaned order was NOT cancelled: the CA reported it is already in a " + + "terminal state (HTTP 422). Check the order in the CERTInext portal."; + } + } + catch (Exception cancelEx) + { + cancelOutcome = $"not cancelled (cancel failed: {cancelEx.GetType().Name}: " + + $"{RedactAndCapForLog(cancelEx.Message)})"; + cancelMessage = "The orphaned order was NOT cancelled: the cancel request failed. " + + "Cancel it manually in the CERTInext portal. See gateway logs for details."; + } + + _logger.LogWarning( + "V2 CSR submission failed after the order was placed. OrderId={OrderId}, Family={Family}, " + + "CsrFailure={CsrFailure}, CancelOutcome={CancelOutcome}", + orderId, familySlug, csrFailure, cancelOutcome); + + _logger.MethodExit(LogLevel.Trace); + return new EnrollmentResult + { + CARequestID = orderId, + Certificate = null, + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"V2 CSR submission failed for order {orderId}: {csrFailure} {cancelMessage}" + }; + } + + private string RedactAndCapForLog(string message) + { + string redacted = CERTInextClient.ApplyLoggingRedaction(message ?? string.Empty, _config?.LogSensitiveRequestData ?? false); + return redacted.Length <= 500 ? redacted : redacted.Substring(0, 500) + "…"; + } + + /// + /// Issue 0052: at enroll time the gateway never holds a stored certificate body for a + /// brand-new order — unlike the Synchronize/GetSingleRecord 0049 guard (), there is no "gateway already holds a body" case + /// for to fall back to. A REVOKED disposition surfaced from V2 + /// enrollment — whether observed on the post-CSR-submit status check, the post-DCV status + /// re-check, or 's own poll — is therefore + /// always body-less. Persisting that as a REVOKED row with Certificate = null is + /// exactly what poisons the gateway per issue 0049 (RevocationDate never clears, and every + /// later revoked-certificate search calls FromDER(null) and 500s). Mapped to FAILED here, + /// once, on the final result is about to return, rather than in + /// each branch that can produce a REVOKED disposition. + /// + private EnrollmentResult NormalizeV2RevokedEnrollResult(EnrollmentResult result, string orderId, string observedCaStatus) + { + if (result == null || result.Status != (int)EndEntityStatus.REVOKED) + return result; + + // SOC2 audit trail: the log must show the CA reported revoked and the plugin reported + // FAILED, with enough context (order id + raw CA status) to reconstruct why. + _logger.LogWarning( + "V2 enroll observed order '{OrderId}' as revoked at the CA (raw status='{CaStatus}') " + + "before a certificate was ever delivered. Mapping the enrollment result to FAILED " + + "instead of a body-less REVOKED record — Enroll has no stored certificate body to " + + "fall back on, unlike the Synchronize/GetSingleRecord 0049 guard.", + orderId, string.IsNullOrWhiteSpace(observedCaStatus) ? "(unknown)" : observedCaStatus); + + string statusSuffix = string.IsNullOrWhiteSpace(observedCaStatus) + ? string.Empty + : $" (CA status '{observedCaStatus}')"; + + return new EnrollmentResult + { + CARequestID = result.CARequestID, + Certificate = null, + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"Order '{orderId}' was revoked at the CA{statusSuffix} before a " + + "certificate could be delivered; reporting enrollment failure rather " + + "than a certificate revocation with no certificate body." + }; + } + + /// + /// Outcome of — what a V2 caller should do + /// with a REVOKED disposition that has no downloadable certificate body (issue 0049). + /// + private enum BodylessRevokedDecision + { + /// Gateway already holds a body for this order — emit REVOKED with no + /// body, exactly as before (this is how out-of-band CA revokes propagate). + EmitRevoked, + /// Gateway has a row but no body — downgrade to FAILED so the gateway + /// never persists a REVOKED row with Certificate = null. + EmitFailed, + /// Gateway has no row at all (or the reader can't be consulted) — + /// the caller should not create a brand-new poisoned row. + Skip + } + + /// + /// Issue 0049: the CERTInext V2 CA refuses to serve a revoked certificate's body + /// (422 EMS-1165), so a V2 order whose first gateway sighting is already revoked has + /// no body to attach. Emitting that as a body-less REVOKED record is what poisons the + /// gateway: it persists the row with RevocationDate set and Certificate = + /// null, never clears the date afterward, and its own revoked-certificate search + /// then calls FromDER(null) on every future scan of this CA — a 500 that blocks + /// *all* Command sync for the CA, not just this record. + /// + /// A body-less REVOKED record is safe — and required, to propagate out-of-band CA + /// revokes — only when the gateway already holds a certificate body for this + /// CARequestID (confirmed live, issue 0049 evidence log: the gateway keeps the stored + /// body and applies status/date/reason on top of it). is the per-order probe + /// for that: it returns the stored cert's NotAfter when a body is held, null when + /// the row exists but has no body, and throws when there + /// is no row at all. + /// + private BodylessRevokedDecision DecideBodylessRevokedRecord(string caRequestId) + { + if (_certificateDataReader == null) + { + _logger.LogWarning( + "V2: no ICertificateDataReader available to check whether the gateway holds a " + + "certificate body for revoked order '{Id}' — skipping this cycle rather than risk " + + "poisoning a fresh gateway row with a body-less REVOKED record.", caRequestId); + return BodylessRevokedDecision.Skip; + } + + try + { + DateTime? expiry = _certificateDataReader.GetExpirationDateByRequestId(caRequestId); + if (expiry.HasValue) + { + _logger.LogDebug( + "V2: gateway already holds a certificate body for revoked order '{Id}' " + + "(expires {Expiry:O}) — emitting body-less REVOKED to propagate the revocation.", + caRequestId, expiry.Value); + return BodylessRevokedDecision.EmitRevoked; + } + + _logger.LogInformation( + "V2: gateway has a row for revoked order '{Id}' with no certificate body — " + + "emitting FAILED instead of a body-less REVOKED record to avoid poisoning the " + + "gateway's revoked-certificate search.", caRequestId); + return BodylessRevokedDecision.EmitFailed; + } + catch (ArgumentException) + { + _logger.LogInformation( + "V2: gateway has no row at all for revoked order '{Id}' — skipping rather than " + + "create a body-less REVOKED row the gateway can never un-poison.", caRequestId); + return BodylessRevokedDecision.Skip; + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "V2: failed to determine whether the gateway holds a certificate body for revoked " + + "order '{Id}' — skipping this cycle rather than risk emitting a body-less REVOKED " + + "record. The revocation will be retried on a later sync/single-record call.", caRequestId); + return BodylessRevokedDecision.Skip; + } + } + + /// + /// Retrieves a single certificate record via the V2 REST API. + /// + private async Task GetSingleRecordV2Async(string caRequestID) + { + _logger.MethodEntry(LogLevel.Debug); + + try + { + // Use the family-aware resolve so DCV (if needed) hits the correct endpoint for + // non-SSL families (private-pki, signature). ResolveAndTrackOrderV2Async discards + // the family; here we keep it to thread through PerformDcvV2IfNeededAsync. + var (resolvedFamily, statusResp) = await _client.ResolveAndTrackOrderV2WithFamilyAsync(caRequestID); + int disposition = StatusMapper.V2StatusToRequestDisposition(statusResp.Status); + +#if SUPPORTS_DCV + // Mirror V1 GetSingleRecord: attempt DCV on pending-dcv orders so a manual + // single-record refresh can unstick an order whose DCV wasn't completed at enroll + // time. issue 0042: a UCC order's own domainEntries[] can carry pending SANs even + // when the top-level Domain field is populated (the common case) or, defensively, + // if it were ever blank — either is enough to attempt DCV. + var pendingDomainEntries = statusResp.Verifications?.Domain?.Domains; + if (disposition == (int)EndEntityStatus.EXTERNALVALIDATION + && (!string.IsNullOrWhiteSpace(statusResp.Domain) || (pendingDomainEntries?.Count ?? 0) > 0)) + { + int timeoutMinutes = _config.GetEffectiveDcvTimeoutMinutes(); + using var dcvCts = new CancellationTokenSource(TimeSpan.FromMinutes(timeoutMinutes)); + try + { + bool dcvDone = await PerformDcvV2IfNeededAsync( + caRequestID, statusResp.Domain, resolvedFamily, dcvCts.Token, pendingDomainEntries); + if (dcvDone) + { + statusResp = await _client.ResolveAndTrackOrderV2Async(caRequestID); + disposition = StatusMapper.V2StatusToRequestDisposition(statusResp.Status); + } + } + catch (Exception dcvEx) + { + _logger.LogWarning(dcvEx, + "V2 GetSingleRecord: DCV attempt failed for order {Id}.", caRequestID); + } + } +#endif + + string certPem = null; + if (disposition == (int)EndEntityStatus.GENERATED) + { + if (!string.IsNullOrWhiteSpace(statusResp.ExpiresAt)) + _logger.LogDebug( + "V2 order expiry from status response. CARequestID={Id}, ExpiresAt={ExpiresAt}", + caRequestID, statusResp.ExpiresAt); + + try + { + var certResp = await _client.ResolveAndDownloadCertificateV2Async(caRequestID); + certPem = AssembleV2CertChain(certResp); + } + catch (Exception dlEx) + { + _logger.LogWarning(dlEx, + "V2 GetSingleRecord: order is issued but certificate download failed. CARequestID={Id}", + caRequestID); + } + } + + // Issue 0049: a REVOKED disposition with no certificate body must never be + // returned as-is unless the gateway already holds a body for this order — see + // DecideBodylessRevokedRecord. GetSingleRecord can't skip (it must return + // something), so both the no-row and can't-tell cases fall through to FAILED. + int effectiveDisposition = disposition; + DateTime? effectiveRevocationDate = statusResp.Revocation?.ProcessedAt; + int effectiveRevocationReason = statusResp.Revocation != null + ? StatusMapper.V2RevocationReasonToCrlCode(statusResp.Revocation.Reason) + : 0; + + if (disposition == (int)EndEntityStatus.REVOKED && string.IsNullOrWhiteSpace(certPem)) + { + var decision = DecideBodylessRevokedRecord(caRequestID); + if (decision != BodylessRevokedDecision.EmitRevoked) + { + effectiveDisposition = (int)EndEntityStatus.FAILED; + effectiveRevocationDate = null; + effectiveRevocationReason = 0; + } + } + + _logger.LogInformation( + "GetSingleRecordV2 complete. CARequestID={Id}, V2Status={Status}, Disposition={Disposition}", + caRequestID, statusResp.Status, effectiveDisposition); + _logger.MethodExit(LogLevel.Debug); + + return new AnyCAPluginCertificate + { + CARequestID = caRequestID, + Certificate = certPem, + Status = effectiveDisposition, + ProductID = statusResp.ProductVariant ?? string.Empty, + RevocationDate = effectiveRevocationDate, + RevocationReason = effectiveRevocationReason + }; + } + catch (KeyNotFoundException) + { + _logger.LogWarning("V2: Certificate not found. CARequestID={Id}", caRequestID); + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "V2: Error retrieving certificate. CARequestID={Id}", caRequestID); + throw; + } + } + + /// + /// Synchronizes certificates via the V2 /reports/orders endpoint (issues/0022). + /// Called from when _config.UseV2Api is true; V1 + /// credentials are not required on this path. + /// + /// Lookback window (incremental sync): live probing of from/to could not + /// determine whether the filter brackets order-placement date or issuance date + /// (issues/0022). Rather than depend on that, an incremental pass requests + /// from = lastSync - V2SyncLookbackHours (default 72h) so an order created before + /// lastSync but issued afterward (e.g. a slow-DCV order) still surfaces. + /// + private async Task SynchronizeV2Async( + BlockingCollection blockingBuffer, + DateTime? lastSync, + bool fullSync, + CancellationToken cancelToken) + { + _logger.MethodEntry(LogLevel.Debug); + + string from = null; + if (!fullSync && lastSync.HasValue) + { + int lookbackHours = _config.V2SyncLookbackHours > 0 + ? _config.V2SyncLookbackHours + : Constants.ApiV2.DefaultSyncLookbackHours; + DateTime effectiveFrom = lastSync.Value.ToUniversalTime().AddHours(-lookbackHours); + from = effectiveFrom.ToString("yyyy-MM-dd"); + } + + _logger.LogInformation( + "Starting CERTInext V2 synchronization. FullSync={FullSync}, LastSync={LastSync}, " + + "LookbackFrom={From}, UseV2Api=true", + fullSync, lastSync?.ToString("O") ?? "none", from ?? "(none — full history)"); + + int synced = 0; + int skipped = 0; + int errors = 0; + int unresolvedStatusFallbacks = 0; // report rows whose display strings needed a live track call + + // Emit-side accounting (mirrors the V1 path, issue 0003). + int emittedGeneratedWithBody = 0, emittedGeneratedNoBody = 0, emittedRevoked = 0, emittedPending = 0; + // Issue 0049: bodyless-REVOKED guard decisions — see DecideBodylessRevokedRecord. + int emittedFailedFromBodylessRevoked = 0, skippedBodylessRevoked = 0; + +#if SUPPORTS_DCV + bool dcvOperational = _config.DcvEnabled && _domainValidatorFactory != null; + int ageWindowHours = _config.DcvSyncMaxOrderAgeHours; + int perPassCap = _config.DcvSyncMaxPerPass; + int dcvAttempted = 0, dcvSkippedAge = 0, dcvSkippedCap = 0; +#endif + + try + { + await foreach (var row in _client.ListOrdersV2Async(from, null, _config.PageSize, cancelToken)) + { + cancelToken.ThrowIfCancellationRequested(); + + try + { + DateTime? orderDateUtc = null; + if (DateTime.TryParse( + row.OrderDate, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, + out DateTime parsedOrderDate)) + orderDateUtc = parsedOrderDate; + + // Skip expired certificates when IgnoreExpired is configured — mirrors the + // V1 check above (issues/0027, item 3). CertificateExpiryDate is a string + // whose format isn't confirmed live (OrderReportEntryV2 doc comment), so an + // unparseable or missing value must NOT be skipped — only a value that + // parses cleanly and is actually in the past is treated as expired. + DateTime? certExpiryUtc = null; + if (!string.IsNullOrWhiteSpace(row.CertificateExpiryDate) + && DateTime.TryParse( + row.CertificateExpiryDate, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, + out DateTime parsedExpiry)) + certExpiryUtc = parsedExpiry; + + if (_config.IgnoreExpired + && certExpiryUtc.HasValue + && certExpiryUtc.Value < DateTime.UtcNow) + { + _logger.LogTrace( + "V2 sync: skipping expired certificate '{Id}' (expires {ExpiresAt:u}).", + row.OrderNumber, certExpiryUtc.Value); + skipped++; + continue; + } + + int? disposition = MapV2ReportStatusToDisposition(row.OrderStatus, row.CertificateStatus); + string resolvedFamily = null; + V2OrderStatusResponse trackedStatus = null; + + if (disposition == null) + { + // Unrecognised orderStatus/certificateStatus combination — the display- + // string vocabulary observed so far (issues/0022) is NOT confirmed + // exhaustive. Don't guess: fall back to a live TrackOrder call, which + // returns the authoritative V2 `status` enum via StatusMapper. + unresolvedStatusFallbacks++; + _logger.LogDebug( + "V2 sync: unrecognised report status — falling back to live track. " + + "Id={Id}, OrderStatus={OrderStatus}, CertificateStatus={CertificateStatus}", + row.OrderNumber, row.OrderStatus, row.CertificateStatus); + (resolvedFamily, trackedStatus) = + await _client.ResolveAndTrackOrderV2WithFamilyAsync(row.OrderNumber, cancelToken); + disposition = StatusMapper.V2StatusToRequestDisposition(trackedStatus.Status); + } + + if (disposition == (int)EndEntityStatus.FAILED) + { + _logger.LogTrace( + "V2 sync: skipping order '{Id}' with terminal status. OrderStatus={OrderStatus}, " + + "CertificateStatus={CertificateStatus}", + row.OrderNumber, row.OrderStatus, row.CertificateStatus); + skipped++; + continue; + } + +#if SUPPORTS_DCV + if (dcvOperational && disposition == (int)EndEntityStatus.EXTERNALVALIDATION) + { + var decision = EvaluateDcvSyncEligibility( + orderDateUtc, DateTime.UtcNow, ageWindowHours, dcvAttempted, perPassCap); + + _logger.LogTrace( + "V2 sync DCV gate: Id={Id}, decision={Decision}, orderDate={OrderDate}, " + + "ageWindowHours={Age}, attemptedSoFar={Attempted}, perPassCap={Cap}", + row.OrderNumber, decision, orderDateUtc?.ToString("o") ?? "(none)", + ageWindowHours, dcvAttempted, perPassCap); + + if (decision == DcvSyncDecision.SkipByAge) + { + _logger.LogInformation( + "V2 sync: pending DV order aged out of the DCV-during-sync window and will " + + "not be advanced. CARequestID={Id}, OrderDate={OrderDate}, AgeWindowHours={Age}.", + row.OrderNumber, orderDateUtc?.ToString("o") ?? "(none)", ageWindowHours); + dcvSkippedAge++; + } + else if (decision == DcvSyncDecision.SkipByCap) + { + dcvSkippedCap++; + } + else + { + dcvAttempted++; + + // Resolve family lazily — only for rows actually attempting DCV. + // Report rows carry no family, and productCode is often empty + // (issue 0016), so this costs one extra call per attempted row, + // not per row in the page (per the task's cost concern). + if (resolvedFamily == null) + { + (resolvedFamily, trackedStatus) = await _client.ResolveAndTrackOrderV2WithFamilyAsync( + row.OrderNumber, cancelToken); + } + + string domain = !string.IsNullOrWhiteSpace(trackedStatus?.Domain) + ? trackedStatus.Domain + : row.DomainName; + + if (!string.IsNullOrWhiteSpace(domain)) + { + int timeoutMinutes = _config.GetEffectiveDcvTimeoutMinutes(); + using var dcvCts = CancellationTokenSource.CreateLinkedTokenSource(cancelToken); + dcvCts.CancelAfter(TimeSpan.FromMinutes(timeoutMinutes)); + try + { + bool dcvDone = await PerformDcvV2IfNeededAsync( + row.OrderNumber, domain, resolvedFamily, dcvCts.Token, + trackedStatus?.Verifications?.Domain?.Domains); + if (dcvDone) + { + trackedStatus = await _client.ResolveAndTrackOrderV2Async(row.OrderNumber, cancelToken); + disposition = StatusMapper.V2StatusToRequestDisposition(trackedStatus.Status); + } + } + catch (Exception dcvEx) + { + _logger.LogWarning(dcvEx, + "V2 sync: DCV attempt failed for order {Id}.", row.OrderNumber); + } + } + else + { + _logger.LogWarning( + "V2 sync: no domain available for pending-dcv order {Id} — cannot drive DCV.", + row.OrderNumber); + } + } + } +#endif + + // Report rows carry no revocation reason/date (OrderReportEntryV2 has no + // such fields, issues/0034) — only a live TrackOrder response's nested + // `revocation` object does. Resolve lazily, mirroring the DCV branch's + // "only for rows that actually need it" pattern above: this extra call is + // scoped to revoked rows whose disposition came from the report's display + // strings alone. trackedStatus is already populated (with .Revocation, if + // any) when disposition instead came from the unresolved-status fallback. + if (disposition == (int)EndEntityStatus.REVOKED && trackedStatus == null) + { + try + { + (resolvedFamily, trackedStatus) = await _client.ResolveAndTrackOrderV2WithFamilyAsync( + row.OrderNumber, cancelToken); + } + catch (Exception revEx) + { + _logger.LogWarning(revEx, + "V2 sync: failed to fetch revocation detail for revoked order '{Id}' — " + + "emitting without RevocationDate/RevocationReason.", row.OrderNumber); + } + } + + string certPem = null; + if (disposition == (int)EndEntityStatus.GENERATED) + { + try + { + var certResp = await _client.ResolveAndDownloadCertificateV2Async(row.OrderNumber, cancelToken); + certPem = AssembleV2CertChain(certResp); + } + catch (Exception dlEx) + { + _logger.LogWarning(dlEx, + "V2 sync: order '{Id}' is issued but certificate download failed — emitting " + + "metadata-only record.", row.OrderNumber); + } + } + + // Prefer the report row's own ProductCode (matches V1's "trust the + // listing" precedent, MapToAnyCAPluginCertificate). Only when that's + // empty (issue 0016), fall back to whatever trackedStatus already exists + // in local scope from one of the lazy TrackOrder fetches above + // (unresolved-status fallback, DCV attempt, or revoked-row lookup) — never + // fetch just to backfill this field (issue 0035). + string productId = !string.IsNullOrWhiteSpace(row.ProductCode) + ? row.ProductCode + : (trackedStatus?.ProductVariant ?? string.Empty); + + var record = new AnyCAPluginCertificate + { + CARequestID = row.OrderNumber, + Certificate = certPem, + Status = disposition.Value, + ProductID = productId, + RevocationDate = trackedStatus?.Revocation?.ProcessedAt, + RevocationReason = trackedStatus?.Revocation != null + ? StatusMapper.V2RevocationReasonToCrlCode(trackedStatus.Revocation.Reason) + : 0 + }; + + bool recordHasBody = !string.IsNullOrWhiteSpace(record.Certificate); + + // Issue 0049: never hand the gateway buffer a REVOKED record with no + // certificate body unless the gateway already holds one for this order — + // see DecideBodylessRevokedRecord's doc comment. "Skip" means this row is + // dropped entirely (not added to blockingBuffer) rather than risk creating + // a row the gateway can never un-poison. + if (record.Status == (int)EndEntityStatus.REVOKED && !recordHasBody) + { + var decision = DecideBodylessRevokedRecord(record.CARequestID); + if (decision == BodylessRevokedDecision.Skip) + { + skipped++; + skippedBodylessRevoked++; + continue; + } + if (decision == BodylessRevokedDecision.EmitFailed) + { + record.Status = (int)EndEntityStatus.FAILED; + record.RevocationDate = null; + record.RevocationReason = 0; + emittedFailedFromBodylessRevoked++; + } + // EmitRevoked falls through — record stays REVOKED with no body, + // exactly as before (propagates an out-of-band CA revoke). + } + + if (record.Status == (int)EndEntityStatus.GENERATED) + { + if (recordHasBody) emittedGeneratedWithBody++; else emittedGeneratedNoBody++; + } + else if (record.Status == (int)EndEntityStatus.REVOKED) + { + emittedRevoked++; + } + else if (record.Status == (int)EndEntityStatus.EXTERNALVALIDATION) + { + emittedPending++; + } + + _logger.LogDebug( + "V2 sync emit: CARequestID={Id}, Status={Status}, CertBytes={CertBytes}, " + + "OrderStatus={OrderStatus}, CertificateStatus={CertificateStatus}", + record.CARequestID, record.Status, record.Certificate?.Length ?? 0, + row.OrderStatus, row.CertificateStatus); + + blockingBuffer.Add(record, cancelToken); + synced++; + } + catch (OperationCanceledException) + { + _logger.LogWarning( + "CERTInext V2 synchronization cancelled by caller. FullSync={FullSync}, Synced={Synced}, " + + "Skipped={Skipped}, Errors={Errors}", + fullSync, synced, skipped, errors); + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing V2 order '{Id}' during synchronization.", row.OrderNumber); + errors++; + + // SOC1 completeness/accuracy: abort on an error-rate cliff rather than + // silently 'completing' with mostly-failed records (mirrors the V1 gate). + int totalSeen = synced + skipped + errors; + if (totalSeen >= 50 && errors > totalSeen / 4) + { + _logger.LogError( + "CERTInext V2 synchronization aborted — error rate ({Errors}/{Total}) exceeded " + + "25% threshold. Likely CA-side outage; will retry on next sync cycle.", + errors, totalSeen); + throw new Exception( + $"CERTInext V2 synchronization aborted after {errors}/{totalSeen} records failed " + + "(>25% error rate). See gateway logs for the underlying CA errors."); + } + } + } + + string dcvClause; +#if SUPPORTS_DCV + if (dcvOperational) + dcvClause = $"DCV-during-sync: Attempted={dcvAttempted}, SkippedByAge={dcvSkippedAge} (>{ageWindowHours}h), SkippedByCap={dcvSkippedCap} (cap={perPassCap})."; + else + dcvClause = $"DCV-during-sync: not active (DcvEnabled={_config.DcvEnabled}, DnsProviderInjected={_domainValidatorFactory != null}) — pending orders left as EXTERNALVALIDATION."; +#else + dcvClause = "DCV-during-sync: not supported on this build (IAnyCAPlugin 3.2.0)."; +#endif + _logger.LogInformation( + "CERTInext V2 synchronization complete. Synced={Synced}, Skipped={Skipped}, Errors={Errors}, " + + "UnresolvedStatusFallbacks={Fallbacks}. Emitted to gateway buffer: GeneratedWithBody={GenWithBody}, " + + "GeneratedNoBody={GenNoBody}, Revoked={Revoked}, Pending={Pending}, " + + "FailedFromBodylessRevoked={FailedFromBodylessRevoked}. " + + "BodylessRevokedSkipped={BodylessRevokedSkipped} (issue 0049 guard). {DcvClause}", + synced, skipped, errors, unresolvedStatusFallbacks, + emittedGeneratedWithBody, emittedGeneratedNoBody, emittedRevoked, emittedPending, + emittedFailedFromBodylessRevoked, skippedBodylessRevoked, + dcvClause); + } + catch (OperationCanceledException) + { + _logger.LogWarning("CERTInext V2 synchronization was cancelled."); + throw; + } + finally + { + blockingBuffer.CompleteAdding(); + } + + _logger.MethodExit(LogLevel.Debug); + } + + /// + /// Maps a V2 /reports/orders row's human-readable orderStatus/ + /// certificateStatus display strings to an + /// disposition (issues/0022). These are display strings (e.g. "Order Accepted", + /// "Certificate Downloaded") — NOT the V2 status enum used by TrackOrder (see + /// for that). The vocabulary + /// below combines what was actually observed live in Phase 0 (orderStatus "Order + /// Accepted"/"Order Fulfilled"; certificateStatus "Pending for Approver"/"Certificate + /// Downloaded") with additional values the spec's field-table prose names ("Approved by + /// System", "Issued", "Certificate Generated") that have not yet been confirmed live. + /// + /// Returns null for anything not confidently recognised so the caller falls back + /// to a live TrackOrder call rather than guessing — the vocabulary is explicitly NOT + /// confirmed exhaustive, and silently misclassifying a row (e.g. treating a still-pending + /// order as issued, or dropping a row that is actually revoked) would be worse than the + /// cost of an extra API call. + /// + internal static int? MapV2ReportStatusToDisposition(string orderStatus, string certificateStatus) + { + // certificateStatus is the more specific signal when present — prefer it. + if (TryMapV2ReportDisplayStatus(certificateStatus, out int certDisposition)) + return certDisposition; + + if (TryMapV2ReportDisplayStatus(orderStatus, out int orderDisposition)) + return orderDisposition; + + return null; + } + + private static bool TryMapV2ReportDisplayStatus(string status, out int disposition) + { + disposition = default; + if (string.IsNullOrWhiteSpace(status)) + return false; + + switch (status.Trim().ToLowerInvariant()) + { + // Issued — certificate exists and is downloadable. + case "certificate downloaded": + case "certificate generated": + case "order fulfilled": + case "issued": + disposition = (int)EndEntityStatus.GENERATED; + return true; + + // Pending — somewhere in the approval/DCV/issuance workflow. + case "order accepted": + case "pending for approver": + case "approved by system": + disposition = (int)EndEntityStatus.EXTERNALVALIDATION; + return true; + + // Revoked. + case "revoked": + case "certificate revoked": + disposition = (int)EndEntityStatus.REVOKED; + return true; + + // Expired-but-not-revoked certs remain visible in inventory as GENERATED — + // mirrors StatusMapper.ToRequestDisposition's V1 convention. + case "expired": + disposition = (int)EndEntityStatus.GENERATED; + return true; + + // Terminal failure — never issued, or explicitly cancelled/rejected. + case "rejected": + case "cancelled": + case "certificate rejected": + case "order rejected": + case "order cancelled": + disposition = (int)EndEntityStatus.FAILED; + return true; + + default: + return false; + } + } + + /// + /// Assembles a full PEM chain from a V2 certificate download response. + /// Concatenates the leaf certificatePem and any intermediate PEM strings + /// in chainPem (when present) in leaf-first order, matching the V1 chain format. + /// + private static string AssembleV2CertChain(V2CertificateDownloadResponse certResp) + { + if (certResp?.ChainPem == null || certResp.ChainPem.Count == 0) + return certResp?.CertificatePem; + + var sb = new System.Text.StringBuilder(); + if (string.IsNullOrWhiteSpace(certResp.CertificatePem)) + throw new InvalidOperationException( + $"V2 certificate download for order '{certResp?.OrderId}' returned a null or empty leaf " + + "certificate PEM while a chain PEM is present; cannot assemble a valid chain without the leaf."); + sb.Append(certResp.CertificatePem.TrimEnd()); + foreach (var intermediate in certResp.ChainPem) + { + if (string.IsNullOrWhiteSpace(intermediate)) continue; + sb.AppendLine(); + sb.Append(intermediate.TrimEnd()); + } + return sb.ToString(); + } + + /// + /// Maps a V2 revoke reason that CERTInext rejects live with "Invalid Revoke Reason + /// ID" to an accepted fallback, or null if + /// is not one of the known-rejected values. Per issues/0026's live-confirmed 9-value + /// matrix, only key-compromise, affiliation-changed, superseded, + /// cessation-of-operation, and privilege-withdrawn are actually accepted + /// (the same restriction V1's has + /// always documented) — unspecified, ca-compromise, + /// certificate-hold, and aa-compromise are all rejected. + /// key-compromise is the fallback for the two *-compromise reasons (closest + /// semantic match); cessation-of-operation is the fallback for + /// unspecified and certificate-hold, neither of which implies an actual + /// key compromise — key-compromise there would misrepresent the revoke and + /// trigger the spec's own BR 4.9.1.1 24-hour CRL-turnaround obligation for no reason. + /// + private static string ResolveRejectedRevokeReasonFallback(string rejectedV2Reason) => + rejectedV2Reason switch + { + Constants.RevocationReasonV2.Unspecified => Constants.RevocationReasonV2.CessationOfOperation, + Constants.RevocationReasonV2.CertificateHold => Constants.RevocationReasonV2.CessationOfOperation, + Constants.RevocationReasonV2.CACompromise => Constants.RevocationReasonV2.KeyCompromise, + Constants.RevocationReasonV2.AACompromise => Constants.RevocationReasonV2.KeyCompromise, + _ => null + }; + + /// + /// Revokes a certificate via the V2 REST API. If CERTInext rejects the resolved + /// reason with its "Invalid Revoke Reason ID" 422 and the reason is one of the four + /// known-rejected values (), retries + /// exactly once with an accepted fallback — see issues/0026 for the live-confirmed + /// accepted/rejected reason matrix. Any other revoke failure (including a 422 for a + /// reason not in that set) is surfaced as-is, with no retry. + /// + private async Task RevokeV2Async(string caRequestID, string hexSerialNumber, uint revocationReason) + { + _logger.MethodEntry(LogLevel.Debug); + + string v2Reason = StatusMapper.ToV2RevocationReason(revocationReason); + + _logger.LogInformation( + "Revocation V2 attempt started. CARequestID={Id}, HexSerialNumber={Serial}, " + + "ReasonCode={ReasonCode}, V2Reason={V2Reason}", + caRequestID, hexSerialNumber, revocationReason, v2Reason); + + // Pre-flight: resolve which product family owns this order (via TrackOrder, + // which probes families and 404s cleanly per-family) and verify it is revocable. + // This resolves the family definitively *before* we ever call revoke, so a 404 + // from RevokeOrderV2Async below is unambiguous — issues/0019: revoke's own 404 + // means "not found or not revokable" (per spec), and probing multiple families + // on a revoke 404 previously produced a misleading "not found in any product + // family" for orders that legitimately exist but simply aren't revokable yet. + V2OrderStatusResponse currentStatus; + string resolvedFamily; + try + { + (resolvedFamily, currentStatus) = await _client.ResolveAndTrackOrderV2WithFamilyAsync(caRequestID); + } + catch (Exception ex) + { + _logger.LogError(ex, + "V2 revocation pre-flight failed. CARequestID={Id}", + caRequestID); + throw; + } + + int disposition = StatusMapper.V2StatusToRequestDisposition(currentStatus.Status); + if (disposition == (int)EndEntityStatus.REVOKED) + { + _logger.LogWarning( + "V2 revocation skipped — already revoked. CARequestID={Id}", + caRequestID); + _logger.MethodExit(LogLevel.Debug); + return (int)EndEntityStatus.REVOKED; + } + + if (disposition != (int)EndEntityStatus.GENERATED) + { + // Compliance finding: a revoke denial must leave an audit record (SOX/SOC2 + // who/what/when/outcome) — mirrors the V1 "Revocation rejected" LogError above. + _logger.LogError( + "V2 revocation rejected — certificate is not in a revocable state. " + + "CARequestID={Id}, Family={Family}, CurrentStatus={Status}", + caRequestID, resolvedFamily, currentStatus.Status); + throw new Exception( + $"V2 certificate '{caRequestID}' cannot be revoked: current status is '{currentStatus.Status}'. " + + "Only issued certificates may be revoked."); + } + + var revokeReq = new V2RevokeRequest + { + Reason = v2Reason, + Note = $"Revoked via Keyfactor Command. CRL reason code: {revocationReason} ({v2Reason})." + }; + + string retriedFromReason = null; + try + { + await _client.RevokeOrderV2Async(resolvedFamily, caRequestID, revokeReq); + } + catch (KeyNotFoundException knf) + { + // Compliance finding: audit the denial (CARequestID, family, HTTP status, EMS + // code) before converting/rethrowing — the client already logged the raw + // HTTP/body; this adds the plugin-level who/what/outcome context. + _logger.LogWarning( + "V2 revocation denied — order not found or not in a revokable state. " + + "CARequestID={Id}, Family={Family}, HttpStatus={HttpStatus}, EmsCode={EmsCode}", + caRequestID, resolvedFamily, 404, ExtractEmsCode(knf.Message) ?? "(none)"); + // We already confirmed the order lives in `resolvedFamily` via TrackOrder + // above, so a 404 here is the spec's other documented meaning — "not in a + // revokable state" — not a genuine family miss. Surface that plainly + // instead of retrying other families. + throw new InvalidOperationException( + $"V2 order '{caRequestID}' (family '{resolvedFamily}') could not be revoked: " + + $"CERTInext reports it as not found or not in a revokable state. {knf.Message}"); + } + catch (InvalidOperationException ioe) when ( + ResolveRejectedRevokeReasonFallback(v2Reason) != null && + ioe.Message.IndexOf("Invalid Revoke Reason ID", StringComparison.OrdinalIgnoreCase) >= 0) + { + // CERTInext's V2 API rejects several of the spec-documented reason values + // outright (422 "Invalid Revoke Reason ID") — confirmed live, independent of + // this plugin (issues/0026). Only 5 of the 9 spec-documented reason strings + // are actually accepted: key-compromise, affiliation-changed, superseded, + // cessation-of-operation, privilege-withdrawn — the same restriction V1's + // ToRevocationReasonId has always been documented against. Retry exactly once + // with the accepted fallback ResolveRejectedRevokeReasonFallback resolved for + // this reason, rather than failing outright on what may be Command's default + // revoke call. + string originalReason = v2Reason; + string fallbackReason = ResolveRejectedRevokeReasonFallback(v2Reason); + _logger.LogWarning( + "V2 revoke rejected reason '{OriginalReason}' as invalid (CARequestID={Id}, Family={Family}, " + + "HttpStatus={HttpStatus}, EmsCode={EmsCode}); retrying once with '{FallbackReason}' " + + "per issues/0026.", + originalReason, caRequestID, resolvedFamily, 422, ExtractEmsCode(ioe.Message) ?? "(none)", + fallbackReason); + v2Reason = fallbackReason; + revokeReq = new V2RevokeRequest + { + // CERTInext's "note" field silently rejects a semicolon with the same + // "Invalid Revoke Remarks" 422 (found live while building this retry — + // comma/period/slash/parens are all fine; only ';' triggers it — see + // issues/0026). Avoid semicolons in this string. + Reason = v2Reason, + Note = $"Revoked via Command, reason {originalReason} rejected, retried as {fallbackReason} (see issues/0026)." + }; + retriedFromReason = originalReason; + try + { + await _client.RevokeOrderV2Async(resolvedFamily, caRequestID, revokeReq); + } + catch (Exception retryEx) + { + // Compliance finding: the retry attempt is itself a revoke call against the + // CA and must leave an audit record on failure, not just succeed silently or + // vanish into the caller's exception. + _logger.LogError(retryEx, + "V2 revocation retry ({FallbackReason}) failed. CARequestID={Id}, Family={Family}", + fallbackReason, caRequestID, resolvedFamily); + throw; + } + } + catch (InvalidOperationException ioe) + { + // Any 422 not matched by the retry-eligible case above (e.g. a distinct EMS + // code/detail, or a reason not in the known-rejected set) — audit the denial + // and rethrow unchanged. Never more than the one retry above. + _logger.LogWarning( + "V2 revocation denied. CARequestID={Id}, Family={Family}, HttpStatus={HttpStatus}, " + + "EmsCode={EmsCode}, Detail={Detail}", + caRequestID, resolvedFamily, 422, ExtractEmsCode(ioe.Message) ?? "(none)", ioe.Message); + throw; + } + + _logger.LogInformation( + "V2 revocation complete. CARequestID={Id}, HexSerialNumber={Serial}, V2Reason={V2Reason}, " + + "Family={Family}, RetriedFromReason={RetriedFromReason}", + caRequestID, hexSerialNumber, v2Reason, resolvedFamily, retriedFromReason ?? "(none)"); + _logger.MethodExit(LogLevel.Debug); + return (int)EndEntityStatus.REVOKED; + } + + // --------------------------------------------------------------------------- + // V2 private utility + // --------------------------------------------------------------------------- + + /// + /// Pulls the first EMS-NNN code out of an exception message, for audit log + /// lines that want the CA's own error code as a discrete field rather than only the + /// free-text detail. Returns null when no code is present (e.g. a CERTInext + /// detail with no EMS code, such as "Certificate Request still being processed"). + /// + private static string ExtractEmsCode(string message) + { + if (string.IsNullOrEmpty(message)) return null; + var m = System.Text.RegularExpressions.Regex.Match(message, @"\bEMS-\d+\b"); + return m.Success ? m.Value : null; + } + + private static string ExtractCnFromSubject(string subject) + { + if (string.IsNullOrWhiteSpace(subject)) return null; + // subject format: "CN=example.com, O=Org, ..." + foreach (var part in subject.Split(',')) + { + var trimmed = part.Trim(); + if (trimmed.StartsWith("CN=", StringComparison.OrdinalIgnoreCase)) + return trimmed.Substring(3).Trim(); + } + return null; + } + + // --------------------------------------------------------------------------- + // V1 private helpers + // --------------------------------------------------------------------------- + + /// + /// Handles New and Reissue enrollment flows by submitting a fresh certificate + /// request to CERTInext. + /// + private async Task EnrollNewAsync( + string csr, + string subject, + Dictionary san, + EnrollmentParams ep) + { + _logger.MethodEntry(LogLevel.Debug); + var enrollReq = new EnrollCertificateRequest + { + ProfileId = ep.ProfileId, + Csr = csr, + ValidityYears = ep.ValidityYears > 0 ? ep.ValidityYears : (int?)null, + ValidityDays = ep.ValidityDays > 0 ? ep.ValidityDays : (int?)null, + Subject = subject, + Sans = BuildSanList(san, csr, subject), + RequesterName = string.IsNullOrWhiteSpace(ep.RequesterName) ? null : ep.RequesterName, + RequesterEmail = string.IsNullOrWhiteSpace(ep.RequesterEmail) ? null : ep.RequesterEmail, + KeyType = string.IsNullOrWhiteSpace(ep.KeyType) ? null : ep.KeyType, + Comment = "Issued via Keyfactor Command AnyCA REST Gateway." + }; + + var enrollResp = await _client.EnrollCertificateAsync(enrollReq); + + // Whether the DCV block below took ownership of the in-call issuance wait for this + // order. Declared outside the #if so both build flavors compile the pickup gate the + // same way (it simply stays false on the no-DCV build). When true, the synchronous + // pickup poll is skipped: on the DCV build the DCV path already owns the issuance + // decision — it either ran WaitForIssuanceAfterDcvAsync itself, deferred to another + // in-flight caller, or determined the order is terminal / not yet validated — so a + // second stacked poll would either double the wait or burn the budget polling an + // order that can never issue in-call (regression guard: a cancelled/rejected order + // must not be re-polled here after DCV already short-circuited it). + bool dcvIssuanceWaitRan = false; + +#if SUPPORTS_DCV + // DCV: run domain validation if enabled, the factory was injected, and the + // order was accepted (not immediately failed). + string orderNumber = enrollResp.Id; + if (_domainValidatorFactory != null && _config.DcvEnabled && !string.IsNullOrEmpty(orderNumber)) + { + // DCV owns the in-call issuance wait for this order from here on: every exit from + // this block (duplicate in-flight, DCV-validated + issuance poll, terminal order, + // or challenge-not-yet-exposed) is a decision the pickup poll must not second-guess. + // Set before any await so it holds on every path out of the block. + // + // This is intentionally coarse — keyed on "the DCV subsystem engaged for this order", + // not on "a DCV wait is actively running". The one case it over-defers is an order + // whose pending domains are all assigned to a non-DNS-01 method (HTTP/email): DCV does + // no work, yet pickup is skipped. That is an accepted trade: this plugin only drives + // DNS-01, so such orders depend on out-of-band validation and would not issue within + // the ~55s pickup window anyway — the next sync completes them. Distinguishing that + // sub-case from the terminal/cancelled case (which MUST skip pickup) would require a + // richer PerformDcvIfNeededAsync result and risk re-opening the terminal-order regression. + dcvIssuanceWaitRan = true; + + // SOX CC7.3: bound the entire DCV flow with a hard timeout so a stuck + // DNS provider or extreme propagation delay cannot hold a gateway worker + // thread indefinitely. Configurable via DcvTimeoutMinutes (config or + // CERTINEXT_DCV_TIMEOUT_MINUTES env var); defaults to 10 minutes. + // Log the resolved limit so an auditor can confirm the configured ceiling. + int dcvTimeoutMinutes = _config.GetEffectiveDcvTimeoutMinutes(); + _logger.LogInformation( + "Starting DCV for order {OrderNumber}. DcvTimeoutMinutes={Timeout}", + orderNumber, dcvTimeoutMinutes); + using var dcvCts = new CancellationTokenSource(TimeSpan.FromMinutes(dcvTimeoutMinutes)); + + // Reserve the in-flight slot before running DCV so that any concurrent + // Synchronize / GetSingleRecord cycle won't try to stage TXT records for the + // same order from the sync-driven retry path. If something else already has + // the slot (the only realistic case: a duplicate Enroll for the same order + // ID), skip our own attempt and fall through to the pending result — the + // other caller will produce the same outcome and we shouldn't double-stage. + bool reserved = _dcvInFlight.TryAdd(orderNumber, 0); + if (!reserved) + { + _logger.LogInformation( + "DCV is already in flight for order {OrderNumber}; Enroll will skip its own DCV attempt " + + "and return the pending enroll response. The other caller will drive issuance.", + orderNumber); + } + else + { + try + { + bool dcvDone = await PerformDcvIfNeededAsync(orderNumber, dcvCts.Token); + if (dcvDone) + { + // Poll GetCertificate until CERTInext finishes generating the cert OR the + // issuance budget expires. CERTInext issuance is async — DCV may verify + // but the cert PEM isn't immediately available. Without this poll, Enroll + // returns a pending result and the cert is picked up on the next sync cycle, + // which is undesirable when the whole thing completes in under a minute. + var postDcv = await WaitForIssuanceAfterDcvAsync(orderNumber, dcvCts.Token); + if (postDcv != null) + { + return BuildEnrollmentResult(new EnrollCertificateResponse + { + Id = postDcv.Id, + Status = postDcv.Status, + Certificate = postDcv.Certificate, + SerialNumber = postDcv.SerialNumber, + Message = $"Post-DCV status: {postDcv.Status}." + }, ep.AutoApprove); + } + } + } + finally + { + _dcvInFlight.TryRemove(orderNumber, out _); + } + } + } +#endif + + // Synchronous certificate pickup (Sectigo-parity): poll for the issued certificate so + // a fast-issuing order returns GENERATED + PEM in this same call. No-op for the + // already-issued/failed case and for OV/EV orders that CERTInext issues asynchronously + // — those fall back to the pending result and are imported by the next sync. + var newResult = BuildEnrollmentResult(enrollResp, ep.AutoApprove); + newResult = await PickUpEnrolledCertificateAsync(newResult, enrollResp.Id, dcvIssuanceWaitRan); + + _logger.MethodExit(LogLevel.Debug); + return newResult; + } + + /// + /// Handles Renew and RenewOrReissue enrollment flows. + /// Determines whether to renew (API call on existing ID) or fall back to new + /// issuance depending on the certificate's current state. + /// + private async Task RenewOrReissueAsync( + string csr, + string subject, + Dictionary san, + EnrollmentProductInfo productInfo, + EnrollmentParams ep) + { + // Retrieve the prior certificate serial number from the product parameters. + // Command injects "PriorCertSN" for renewal flows. + string priorCertSn = null; + productInfo.ProductParameters?.TryGetValue("PriorCertSN", out priorCertSn); + + // SOC2 CC6.1: a renewal/reissue read against the gateway's certificate + // inventory is a logical-access event and must be logged at Information. + _logger.LogInformation( + "Renewal/reissue probe — read PriorCertSN from EnrollmentProductInfo. " + + "Subject={Subject}, PriorCertSN={PriorCertSN}, RenewalWindowDays={WindowDays}", + LogSanitizer.Strip(subject), string.IsNullOrWhiteSpace(priorCertSn) ? "(none)" : priorCertSn, + ep.RenewalWindowDays); + + if (string.IsNullOrWhiteSpace(priorCertSn)) + { + // SOC2 CC7.2: log policy-relevant decisions at Information so they survive + // production log filters and are available for anomaly detection. + _logger.LogInformation( + "Renewal/reissue has no PriorCertSN — treating as new enrollment. Subject={Subject}", + LogSanitizer.Strip(subject)); + return await EnrollNewAsync(csr, subject, san, ep); + } + + // Resolve the CARequestID for the prior certificate + string priorCaRequestId; + try { priorCaRequestId = await _certificateDataReader.GetRequestIDBySerialNumber(priorCertSn); } - catch (Exception ex) + catch (Exception ex) + { + _logger.LogWarning(ex, + "Could not resolve CARequestID for serial '{SN}'. Falling back to new enrollment.", priorCertSn); + return await EnrollNewAsync(csr, subject, san, ep); + } + + if (string.IsNullOrWhiteSpace(priorCaRequestId)) + { + _logger.LogInformation( + "CARequestID for serial '{SN}' is empty — falling back to new enrollment. Subject={Subject}", + priorCertSn, LogSanitizer.Strip(subject)); + return await EnrollNewAsync(csr, subject, san, ep); + } + + // Determine whether this is within the renewal window. + // + // Semantics (Option A — "window before expiry"): + // useRenewalApi = true when the cert expires within the next RenewalWindowDays. + // useRenewalApi = false when the cert expires further away than that (too early → reissue). + // useRenewalApi = false when the cert is already expired (graceful degradation → new order). + // + // This matches operator expectation: "renew when within N days of expiry". + // Certs expiring far in the future should be reissued, not renewed via the CA's + // renew endpoint (which may assume near-expiry context on its side). + bool useRenewalApi = false; + try + { + DateTime? expiry = _certificateDataReader.GetExpirationDateByRequestId(priorCaRequestId); + if (expiry.HasValue) + { + DateTime now = DateTime.UtcNow; + DateTime renewalWindowEnd = now.AddDays(ep.RenewalWindowDays); + // Renew only if the cert is not yet expired AND expires within the window. + useRenewalApi = expiry.Value > now && expiry.Value <= renewalWindowEnd; + + // SOX CC6.2 / SOC2 CC7.2: the renewal window evaluation is a security-relevant + // policy decision (determines whether an existing CA record is reused). Logged + // at Information so it survives production log filters and is not suppressible + // by log-level configuration. + _logger.LogInformation( + "Renewal window evaluation complete. " + + "PriorCARequestID={PriorId}, CertExpiry={Expiry:O}, " + + "RenewalWindowEnd={WindowEnd:O}, RenewalWindowDays={Window}, UseRenewalApi={Use}", + priorCaRequestId, expiry.Value, renewalWindowEnd, ep.RenewalWindowDays, useRenewalApi); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Could not determine expiry for '{Id}'. Defaulting to new enrollment.", priorCaRequestId); + } + + if (useRenewalApi) + { + // SOX / SOC2 CC7.3: log the renewal attempt at Information so the intent is + // captured before the API call, enabling reconstruction if the call fails. + _logger.LogInformation( + "Renewal via CERTInext renew API started. " + + "PriorCARequestID={PriorId}, Subject={Subject}, ProfileId={ProfileId}", + priorCaRequestId, LogSanitizer.Strip(subject), ep.ProfileId); + + var renewReq = new RenewCertificateRequest + { + Csr = csr, + // Renewals go out as a fresh CERTInext order, so they need the same domain + // set as a new enrollment — otherwise a renewed UCC certificate comes back + // holding only its primary domain. + Subject = subject, + Sans = BuildSanList(san, csr, subject), + ProfileId = ep.ProductCode, + ValidityYears = ep.ValidityYears > 0 ? ep.ValidityYears : (int?)null, + ValidityDays = ep.ValidityDays > 0 ? ep.ValidityDays : (int?)null, + RequesterName = string.IsNullOrWhiteSpace(ep.RequesterName) ? null : ep.RequesterName, + RequesterEmail = string.IsNullOrWhiteSpace(ep.RequesterEmail) ? null : ep.RequesterEmail, + Comment = $"Renewed via Keyfactor Command. Prior ID: {priorCaRequestId}." + }; + + var renewResp = await _client.RenewCertificateAsync(priorCaRequestId, renewReq); + var renewResult = BuildEnrollmentResult(renewResp, ep.AutoApprove); + + // SOX: log the renewal outcome so the new certificate ID and status are + // independently recorded (the outer Enroll method also logs, but this + // ensures the renew path is auditable if the result is further transformed). + _logger.LogInformation( + "Renewal via CERTInext renew API complete. " + + "PriorCARequestID={PriorId}, NewCARequestID={NewId}, Status={Status}", + priorCaRequestId, renewResult.CARequestID, renewResult.Status); + + // Synchronous certificate pickup (Sectigo-parity), same as the new-enrollment path. + // The renew path never runs an in-call DCV issuance wait, so pickup always applies. + renewResult = await PickUpEnrolledCertificateAsync(renewResult, renewResp.Id, dcvIssuanceWaitRan: false); + + return renewResult; + } + else + { + _logger.LogInformation( + "Certificate '{Id}' is outside the renewal window ({Window} days) — issuing new certificate. Subject={Subject}", + priorCaRequestId, ep.RenewalWindowDays, LogSanitizer.Strip(subject)); + return await EnrollNewAsync(csr, subject, san, ep); + } + } + + // --------------------------------------------------------------------------- + // DCV helpers + // --------------------------------------------------------------------------- + + /// + /// True when a GetDcv failure is the CERTInext-side "DCV slot is exposed in + /// TrackOrder but the endpoint won't accept calls yet" condition. Observed as the + /// API error EMS-956 "Invalid Request for this API" for several hours after + /// enrollment — see analysis/certinext-support-ticket-2026-05-12.md. + /// + /// Detection is intentionally narrow: + /// * If the message contains the literal code EMS-956, treat it as the + /// known not-ready condition. + /// * Otherwise, only fall back to the human-readable phrase match when *no other* + /// EMS-NNN code is present. Without that guard, an upstream proxy or WAF + /// returning a 4xx whose body happens to contain "Invalid Request for this API …" + /// plus a different CERTInext code (e.g. EMS-401) would be silently deferred, + /// masking a real authentication or input-validation failure. + /// + private static bool IsDcvNotYetReady(Exception ex) + { + if (ex == null) return false; + string msg = ex.Message ?? string.Empty; + if (msg.IndexOf("EMS-956", StringComparison.OrdinalIgnoreCase) >= 0) + return true; + bool hasPhrase = msg.IndexOf("Invalid Request for this API", StringComparison.OrdinalIgnoreCase) >= 0; + bool hasOtherEmsCode = System.Text.RegularExpressions.Regex.IsMatch(msg, @"\bEMS-\d+\b"); + return hasPhrase && !hasOtherEmsCode; + } + + /// + /// Strips a leading wildcard label ("*.") so a domain can be used to pick a DNS + /// zone / and to build the + /// DCV TXT record hostname. A literal "*" is not a queryable DNS label, so staging + /// a record at e.g. _emsign-validation.*.example.com for a wildcard domain can never + /// be seen by the CA — confirmed live 2026-10-01 (a *.dcv-fresh-<ts>... order + /// stayed pending with a literal-asterisk TXT host staged). Callers must keep using the + /// ORIGINAL domain string (including "*.") for every CERTInext API call + /// (GetDcv/VerifyDcv/TrackOrder) — that is what the CA itself tracks and reports back + /// per-domain; only the DNS-side hostname/zone-resolution inputs use the base domain. + /// + /// Whether CERTInext's own DCV actually accepts a base-domain TXT record as proof for a + /// wildcard domain entry is UNVERIFIED against the live API as of this change — pending + /// the principal's live run. + /// + private static string StripWildcardPrefix(string domain) + { + if (string.IsNullOrEmpty(domain)) + return domain; + return domain.StartsWith("*.", StringComparison.Ordinal) + ? domain.Substring(2) + : domain; + } + + // (`DomainValidatorConfigProvider` nested helper removed — it declared an + // implementation of `Keyfactor.AnyGateway.Extensions.IDomainValidatorConfigProvider`, + // a v3.3-only interface, but the type was never instantiated anywhere in the + // plugin. Keeping a nested type whose base list references a missing assembly + // type is a hazard for CLR class-load on v3.2 hosts (see issue #7). Dead code + // that costs nothing to remove.) + + /// + /// Best-effort DCV retry for an order that may still be pending validation. + /// + /// Called from Synchronize and GetSingleRecord so that orders which CERTInext placed + /// into "Pending for Approver"/"Pending System RA" between enrollment and the next + /// gateway cycle (when domainVerification was still null at enroll time) can be + /// driven forward through DCV. Wraps with: + /// * a per-order in-flight guard so overlapping sync cycles or a sync+single + /// refresh do not double-stage TXT records, + /// * a bounded DCV timeout linked to the caller's cancellation token, + /// * swallowing of non-cancellation exceptions so a single bad order does not + /// halt a 12-hour sync — the order will be retried on the next cycle. + /// + /// Uses a single-shot challenge check (waitForChallengeSeconds=0) by default + /// because sync runs periodically: if CERTInext hasn't yet exposed the DCV slot for + /// this order, the next sync cycle will pick it up. Waiting per-order during sync + /// scales poorly — a single pending order's 60s budget becomes minutes of wasted + /// gateway thread time across an account with many orders. See PR #2 discussion. + /// + /// Returns true when DCV actually executed (or DCV is already complete), + /// false when skipped. + /// + private async Task TryRunDcvDuringSyncAsync(string orderNumber, CancellationToken ct, bool fastSync = false) + { + _logger.MethodEntry(LogLevel.Debug); +#if SUPPORTS_DCV + if (_domainValidatorFactory == null || !_config.DcvEnabled || string.IsNullOrEmpty(orderNumber)) + return false; + + if (!_dcvInFlight.TryAdd(orderNumber, 0)) + { + // SOC2 CC7.2: concurrent DCV-attempt collisions are security-relevant + // (they indicate either a normal overlap of two sync cycles OR an attempt + // to interleave operations on the same order). Log at Information so the + // event appears in production logs without verbose-debug being enabled. + _logger.LogInformation( + "DCV already in flight for order {OrderNumber}; skipping concurrent attempt.", + orderNumber); + return false; + } + + try + { + int timeoutMinutes = _config.GetEffectiveDcvTimeoutMinutes(); + using var dcvCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + dcvCts.CancelAfter(TimeSpan.FromMinutes(timeoutMinutes)); + + _logger.LogInformation( + "Attempting deferred DCV during sync/refresh (single-shot challenge check). " + + "OrderNumber={OrderNumber}, DcvTimeoutMinutes={Timeout}", + orderNumber, timeoutMinutes); + + return await PerformDcvIfNeededAsync(orderNumber, dcvCts.Token, + waitForChallengeSecondsOverride: 0, + propagationDelaySecondsOverride: fastSync ? Constants.Dcv.SyncPropagationDelaySeconds : (int?)null); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Deferred DCV attempt failed for order {OrderNumber}. Order will be retried on the next sync cycle.", + orderNumber); + return false; + } + finally + { + _dcvInFlight.TryRemove(orderNumber, out _); + } +#else + // DCV is not supported on this build (IAnyCAPlugin 3.2.0). No-op: pending orders + // are reported as EXTERNALVALIDATION and not advanced during sync. See issue 0003. + await Task.CompletedTask; + return false; +#endif + } + + /// + /// Runs DNS DCV for any domains on that are still pending + /// validation. Returns true when DCV steps were executed, false when + /// skipped (order already issued, no pending domains, or factory not available). + /// + /// Rule: if the order is already issued we never attempt DCV — it would be a no-op + /// at best and could confuse the CA at worst. + /// + /// lets the sync path force a + /// single-shot challenge check (pass 0) so a sync cycle doesn't spend up to + /// DcvWaitForChallengeSeconds per pending order waiting for CERTInext to + /// expose the DCV slot — sync runs periodically, so unexposed orders are picked up + /// on the next cycle instead. Enroll passes null to keep the full configured + /// budget (user-visible latency benefits from a one-shot end-to-end finish). + /// +#if SUPPORTS_DCV + private async Task PerformDcvIfNeededAsync( + string orderNumber, + CancellationToken ct, + int? waitForChallengeSecondsOverride = null, + int? propagationDelaySecondsOverride = null) + { + // Poll TrackOrder until CERTInext exposes the DCV challenge (domainVerification + // populated) OR the cert reaches a terminal state OR the wait budget expires. + // Under concurrent enrollment load CERTInext sometimes takes a few seconds to + // materialize the slot after GenerateOrderSSL returns — without this wait a + // race-condition order skips DCV entirely and waits for the next sync cycle. + int waitBudgetSeconds = waitForChallengeSecondsOverride + ?? _config.GetEffectiveDcvWaitForChallengeSeconds(); + // Challenge-wait poll interval is clamped to [1s, 5s] so it's responsive even + // when an admin has set DcvPropagationDelaySeconds high for slow zones (that + // setting governs how long we wait *after* publishing a TXT record, which is a + // different, slower concern than how often we re-check TrackOrder here). + int challengePollSeconds = Math.Max(1, Math.Min(5, _config.DcvPropagationDelaySeconds > 0 ? _config.DcvPropagationDelaySeconds : 5)); + var waitDeadline = DateTime.UtcNow.AddSeconds(Math.Max(0, waitBudgetSeconds)); + + TrackOrderResponse track = null; + API.TrackOrderDomainVerification domainVerification = null; + int pollAttempts = 0; + + while (true) + { + pollAttempts++; + ct.ThrowIfCancellationRequested(); + track = await _client.TrackOrderAsync(orderNumber, ct); + + // Skip DCV entirely if the certificate is already issued or revoked + if (track.OrderDetails != null + && int.TryParse(track.OrderDetails.CertificateStatusId, out int certStatusId)) + { + int disposition = StatusMapper.CertificateStatusIdToRequestDisposition(certStatusId); + if (disposition == (int)EndEntityStatus.GENERATED || disposition == (int)EndEntityStatus.REVOKED) + { + _logger.LogDebug( + "DCV skipped — order {OrderNumber} is already in terminal state (certificateStatusId={Status}).", + orderNumber, certStatusId); + return false; + } + } + + // Skip if the order itself reached a terminal failure state. Without this + // the cached-DCV path below could still return true on a cancelled order + // (domainVerification.Status = "1" survives the cancellation), sending the + // caller into a wasted DcvWaitForIssuanceSeconds-long GetCertificate poll + // that can never resolve. OrderStatusId 4 = cancelled, 5 = rejected. + if (track.OrderDetails?.OrderStatusId is "4" or "5") + { + _logger.LogDebug( + "DCV skipped — order {OrderNumber} is cancelled/rejected " + + "(orderStatusId={OrderStatus}).", + orderNumber, track.OrderDetails.OrderStatusId); + return false; + } + + domainVerification = track.OrderDetails?.DomainVerification; + if (domainVerification != null) + break; + + // domainVerification still null — sleep and retry if we have budget left. + if (waitBudgetSeconds <= 0 || DateTime.UtcNow >= waitDeadline) + { + _logger.LogInformation( + "DCV challenge not exposed by CERTInext within {Budget}s for order {OrderNumber} " + + "(attempted {Attempts} TrackOrder polls). Deferring to next sync cycle.", + waitBudgetSeconds, orderNumber, pollAttempts); + return false; + } + + try + { + await Task.Delay(TimeSpan.FromSeconds(challengePollSeconds), ct); + } + catch (OperationCanceledException) + { + // Rethrow if the gateway-level token is cancelled so shutdown is not blocked; + // only swallow an internal timeout (e.g. a per-poll deadline CTS). + ct.ThrowIfCancellationRequested(); + return false; + } + } + + // If DCV is already validated CERTInext-side, the plugin has no DCV work to + // do — but CERTInext's certificate generation may still be in flight (this + // happens when CERTInext has cached a prior DCV validation for the parent + // domain). Return true so the caller can run the issuance poll and pick up + // the cert directly from Enroll() instead of leaving it for the next sync. + // + // Treat "DCV done" as EITHER the overall aggregate Status flipping to "1" + // OR every individual per-domain dcvStatus being "1" — observed in the wild + // that the per-domain field can flip before the parent aggregate. + var allDomainEntries = domainVerification.GetDomainEntries(); + bool aggregateValidated = string.Equals( + domainVerification.Status, Constants.Dcv.StatusValidated, StringComparison.Ordinal); + bool everyDomainValidated = allDomainEntries.Count > 0 + && allDomainEntries.All(kvp => string.Equals( + kvp.Value?.DcvStatus, Constants.Dcv.StatusValidated, StringComparison.Ordinal)); + if (aggregateValidated || everyDomainValidated) + { + _logger.LogInformation( + "DCV is already validated for order {OrderNumber} " + + "(aggregateStatus={Aggregate}, perDomainAllValidated={PerDomain}). " + + "Skipping DNS-TXT staging; caller may run the issuance poll.", + orderNumber, aggregateValidated, everyDomainValidated); + return true; + } + + // Include domains that are pending DCV and either have no method set yet, + // or are already assigned to DNS TXT (numeric "1" from API or label from TrackOrder). + // Domains assigned to HTTP or email DCV are excluded — we must not override them. + var pendingDomains = domainVerification.GetDomainEntries() + .Where(kvp => + { + if (!string.Equals(kvp.Value?.DcvStatus, Constants.Dcv.StatusPending, StringComparison.Ordinal)) + return false; + string method = kvp.Value?.DcvMethod ?? string.Empty; + return string.IsNullOrEmpty(method) + || string.Equals(method, Constants.Dcv.MethodDnsTxt, StringComparison.Ordinal) + || string.Equals(method, Constants.Dcv.MethodDnsTxtLabel, StringComparison.OrdinalIgnoreCase); + }) + .ToList(); + + // SOX CC6.1: validate domain names before passing them to the DNS provider plugin + // or the CERTInext API. A malformed domain (empty, whitespace, or containing + // characters outside the FQDN alphabet) could cause log injection or unexpected + // DNS plugin behaviour. Invalid entries are rejected loudly — LogError, so the + // condition is visible in the audit trail — but they are EXCLUDED rather than + // thrown on. + // + // Throwing here would fail the whole order: the exception escapes Enroll (which has + // no catch) after the order was already placed at the CA, so the enrollment reports + // failure with an orphaned order, and no TXT record is staged for the *valid* domains + // on the same order. Worse, it is unrecoverable — every later Synchronize / + // GetSingleRecord retry re-enters here, hits the same undrainable domain, and + // TryRunDcvDuringSyncAsync swallows the exception and returns false, so the order sits + // at EXTERNALVALIDATION forever. + // + // This is reachable in normal operation now that non-DNS SANs are submitted to + // CERTInext (see BuildSanList): the CA registers an email/URI SAN verbatim as an order + // domain, and that key is not an FQDN. One such SAN must not strand the DNS names + // alongside it. Same principle the EMS-956 branch below states explicitly: do not throw + // out of DCV for a condition that leaves the order legitimately pending. + var invalidDomains = new List(); + var validPendingDomains = new List>(); + + foreach (var entry in pendingDomains) + { + string domain = entry.Key; + + // Allow standard FQDN characters plus wildcard prefix (*.example.com). + // + // \A/\z, not ^/$: in .NET's default (non-Multiline) mode, $ matches immediately + // before a single trailing '\n', not only at the true end of the string — so + // "evil.com\n" passes a ^...$ version of this regex. \A and \z are absolute + // start/end-of-string anchors regardless of RegexOptions, so a value with any + // trailing control character is correctly rejected here rather than reaching the + // unsanitized-looking-safe domain this validation exists to guarantee. + bool valid = !string.IsNullOrWhiteSpace(domain) + && System.Text.RegularExpressions.Regex.IsMatch( + domain, @"\A(\*\.)?[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?\z"); + + if (valid) + validPendingDomains.Add(entry); + else + invalidDomains.Add(string.IsNullOrWhiteSpace(domain) ? "(blank)" : domain); + } + + if (invalidDomains.Count > 0) + { + _logger.LogError( + "{Count} domain(s) on order {OrderNumber} are not valid FQDNs and cannot be DNS-01 validated: " + + "[{Domains}]. They are skipped so the remaining {ValidCount} domain(s) can still be validated. " + + "This order cannot be issued by CERTInext until these are removed — they usually come from a " + + "non-DNS SAN (IP address, email, URI) that was requested on the enrollment.", + // An email SAN submitted to V1 comes back verbatim as an order domain; mask it + // unless LogSensitiveRequestData is on (issue 0040 follow-up). + invalidDomains.Count, orderNumber, + LogSanitizer.FormatUntypedSans(invalidDomains, _config.LogSensitiveRequestData, ", "), + validPendingDomains.Count); + } + + pendingDomains = validPendingDomains; + + if (pendingDomains.Count == 0) + return false; + + _logger.LogInformation( + "DCV required for order {OrderNumber}. Pending DNS TXT domains: [{Domains}]", + orderNumber, string.Join(", ", pendingDomains.Select(x => x.Key))); + + var stagedValidations = new List<(string domain, string hostname, Keyfactor.AnyGateway.Extensions.IDomainValidator validator)>(); + + // Every domain that got through staging this pass — whether it freshly published a + // TXT record or shared an already-staged hostname with a sibling (see + // stagedHostnames below). Drives the per-domain CERTInext Verify calls; kept separate + // from stagedValidations (which holds only ONE entry per unique hostname) so a + // wildcard/apex pair sharing a base-domain hostname still each get their own CA-side + // VerifyDcv, without staging — or cleaning up — the shared TXT record twice. + var verifyDomains = new List(); + + // TXT hostname -> the first domain that staged it this pass. A UCC order can list + // both "example.com" and "*.example.com"; StripWildcardPrefix collapses both to the + // same base-domain hostname, so the second domain to reach it must reuse the + // already-staged record instead of publishing (and later cleaning up) a duplicate. + var stagedHostnames = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Domains this pass could not stage, with why — purely for the summary LogError after + // the loop. Every failure mode below is loud (its own LogError, sanitized) before being + // skipped, so nothing here is silent; this list just avoids repeating that detail twice. + var skippedDomains = new List<(string domain, string reason)>(); + + // Set instead of an immediate `return false` inside the loop below, so a not-yet-ready + // deferral goes through the same cleanup as every other exit path — see the try/catch + // around the loop. + bool deferToNextSyncCycle = false; + + // Removes whatever TXT records were already published before an early exit from the + // staging loop. Nothing else in this method cleans up mid-loop: the try/finally further + // down only runs once every pending domain has been staged, so without this, an early + // exit orphans every TXT record already published for the earlier domains in the *same* + // order — permanently, since nothing else in the codebase calls CleanupValidation for + // them. Kept even though every per-domain failure below is now skip-and-continue rather + // than throw: it is the safety net for a genuinely unexpected exception (cancellation, a + // bug, a validator implementation that throws instead of returning a failure result). + // + // Shares its per-entry cleanup logic with the try/finally's own cleanup loop further + // down via CleanupOneStagedValidation — the two call sites differ only in when they run + // (an early exit here vs. always-run-at-the-end there), not in what "clean up one TXT + // record" means. + async Task CleanupPartialStagingAsync() + { + // Concurrent, not sequential: each cleanup call already has its own independent + // CleanupValidationTimeoutSeconds bound (see CleanupOneStagedValidationAsync), but + // running them one after another meant that bound was per-call, not in aggregate — a + // UCC order with N staged domains could hold the calling request open for up to + // N × CleanupValidationTimeoutSeconds if the DNS provider was merely slow (not even + // hung) on every delete, which can exceed DcvTimeoutMinutes itself and defeats the + // "entire DCV flow is hard-timeout-bounded" guarantee for exactly the multi-SAN case + // this diff exists to support. Running them concurrently bounds the wall-clock time + // for the whole batch to the slowest single call, regardless of domain count — these + // are independent per-domain operations (different hostnames/records) with no shared + // mutable state, so there is nothing for concurrent execution to race on. + await Task.WhenAll(stagedValidations.Select(entry => + CleanupOneStagedValidationAsync(entry, " after an early exit from DCV staging"))); + } + + // Shared by CleanupPartialStagingAsync above and the try/finally's own cleanup loop + // below — both mean "remove one already-published TXT record", just at different times + // (an early exit vs. always-run-at-the-end). `context` distinguishes the two in the log + // text without duplicating the try/catch/log structure itself. + async Task CleanupOneStagedValidationAsync( + (string domain, string hostname, Keyfactor.AnyGateway.Extensions.IDomainValidator validator) entry, + string context) + { + var (domain, hostname, validator) = entry; + try + { + // A fresh, independently-bounded token — deliberately neither `ct` nor + // CancellationToken.None. + // + // Not `ct`: this is a best-effort compensating action — removing a TXT record we + // already published — and it must run regardless of WHY we are cleaning up, + // including the case where `ct` itself is the reason (the dominant real trigger + // for the early-exit call site is the shared DcvTimeoutMinutes-bound token firing + // mid-loop, which means `ct` is guaranteed already cancelled there). A + // cooperative IDomainValidator that forwards its token into its own HTTP calls — + // the reference CloudflareDomainValidator in this repo does exactly that — would + // throw immediately on an already-cancelled token and never even attempt the + // delete, silently leaving the record published with only a Warning logged. + // + // Not CancellationToken.None either: this method's own SOX CC7.3 guarantee is + // that the whole DCV flow is hard-timeout-bounded so a stuck DNS provider cannot + // hold a gateway worker thread indefinitely. That bound has to come from + // somewhere for THIS call too — including the routine, always-runs finally-block + // cleanup on the ordinary successful-DCV path, which was never cancellation- + // related to begin with and would otherwise hang forever on a DNS provider + // plugin whose underlying network call stalls. + using var cleanupCts = new CancellationTokenSource( + TimeSpan.FromSeconds(Constants.Dcv.CleanupValidationTimeoutSeconds)); + await validator.CleanupValidation(hostname, cleanupCts.Token); + _logger.LogInformation( + "DNS TXT record cleaned up{Context}. Domain={Domain}, Hostname={Hostname}", + context, LogSanitizer.Strip(domain), LogSanitizer.Strip(hostname)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Failed to clean up DNS TXT record{Context}. Domain={Domain}, Hostname={Hostname}. " + + "May require manual removal.", + context, LogSanitizer.Strip(domain), LogSanitizer.Strip(hostname)); + } + } + + try + { + // Stage DNS TXT records for all pending domains. Every failure below is scoped to + // the one domain that hit it — logged loudly (LogError, so the audit trail carries + // the reason before the domain is dropped) and skipped, never thrown. A throw here + // would abort the WHOLE order after Enroll already placed it at the CA — Enroll has + // no catch around this call, so the exception would escape as a failed enrollment + // with an orphaned CERTInext order, and TryRunDcvDuringSyncAsync would swallow the + // same exception on every later sync retry, leaving the order stuck at + // EXTERNALVALIDATION forever. That is worse than parking the order pending with a + // clear log entry, for EVERY failure shape here — not just the ones distinguishable + // as "bad input" — because nothing downstream ever gets to see or act on the + // exception anyway. This directly caused three real regressions across the first two + // rounds of fixing this file: a GetDcv error or an empty token for a non-DNS SAN + // (submitted on purpose — see BuildSanList) aborted co-tenant DNS domains on the same + // order; a StageValidation failure on domain N+1 orphaned domain N's TXT record; and + // a misconfiguration-detection throw fired on an ordinary non-DNS Subject CN, which + // no setting could prevent since SubmitNonDnsSans only filters the SAN list, not the + // subject. There is no longer a "this must still throw" case in this loop at all. + foreach (var (domain, _) in pendingDomains) + { + GetDcvResponse dcvResp; + try + { + dcvResp = await _client.GetDcvAsync(orderNumber, domain, Constants.Dcv.MethodDnsTxt, ct); + } + catch (Exception ex) when (IsDcvNotYetReady(ex)) + { + // CERTInext occasionally exposes the DCV slot in TrackOrder (so + // domainVerification is populated and dcvStatus="0") before the GetDcv + // endpoint will accept calls for that order — observed as EMS-956 + // "Invalid Request for this API" for several hours after enrollment. This is + // an order-readiness condition, not a per-domain one, so unlike every other + // case in this loop it defers the whole pass rather than skipping one domain. + _logger.LogInformation( + "GetDcv not yet accepting calls for order {OrderNumber} domain {Domain} ({Error}). " + + "Deferring DCV to the next sync cycle.", + orderNumber, LogSanitizer.Strip(domain), ex.Message); + deferToNextSyncCycle = true; + break; + } + catch (OperationCanceledException) + { + // The shared, DcvTimeoutMinutes-bound cancellation firing mid-loop. This is + // NOT a per-domain CA/DNS-provider failure — it must not be caught by the + // generic clause below, which would mislabel it as "GetDcv failed" for + // whichever domain happened to be in flight and send an operator chasing the + // wrong cause. Propagate to the outer catch, which logs and cleans up. + throw; + } + catch (Exception ex) + { + // Any other GetDcv failure — genuinely unmeasured against the live API for a + // non-DNS order-domain, which is exactly why this must not be allowed to fail + // the whole order on a guess. Skip just this domain. + _logger.LogError(ex, + "GetDcv failed for order {OrderNumber} domain {Domain}; skipping this domain so the " + + "rest of the order can still be validated.", orderNumber, LogSanitizer.Strip(domain)); + skippedDomains.Add((domain, "GetDcv failed")); + continue; + } + + string token = dcvResp.DcvDetails?.Token; + if (string.IsNullOrWhiteSpace(token)) + { + _logger.LogError( + "GetDcv returned no token for order {OrderNumber} domain {Domain}; skipping this " + + "domain so the rest of the order can still be validated.", + orderNumber, LogSanitizer.Strip(domain)); + skippedDomains.Add((domain, "no DCV token returned")); + continue; + } + + string template = string.IsNullOrWhiteSpace(_config.DcvTxtRecordTemplate) + ? Constants.Dcv.DefaultTxtRecordTemplate + : _config.DcvTxtRecordTemplate; + // DCV publishes/looks up the TXT record under the BASE domain — a wildcard's + // "*." label is not a queryable DNS name. CERTInext's own GetDcv/VerifyDcv + // calls above/below still use the original `domain` (including "*."), since + // that is what Track Order reports back per-domain. + string baseDomain = StripWildcardPrefix(domain); + string hostname = string.Format(template, baseDomain); + + if (stagedHostnames.TryGetValue(hostname + "|" + token, out string sharedWithDomain)) + { + // Sibling domain (e.g. the wildcard/apex pair of the same base domain) + // already staged this exact TXT hostname this pass — reuse it instead of + // publishing a second record at the same name. This domain still gets its + // own CA-side GetDcv/VerifyDcv (CERTInext tracks DCV per domain entry); it + // just doesn't need its own TXT record. + _logger.LogInformation( + "DCV hostname {Hostname} for domain {Domain} on order {OrderNumber} is already " + + "staged (shared with {SharedWith}); reusing it instead of publishing a second " + + "TXT record.", + LogSanitizer.Strip(hostname), LogSanitizer.Strip(domain), orderNumber, + LogSanitizer.Strip(sharedWithDomain)); + verifyDomains.Add(domain); + continue; + } + + var validator = DomainValidatorFactory.ResolveDomainValidator(baseDomain, "dns-01"); + if (validator == null) + { + // The canonical case: an IP-literal SAN (or a non-DNS Subject CN) satisfies + // the FQDN regex above but no DNS zone can ever match it. + _logger.LogError( + "No DNS provider plugin resolved for domain '{Domain}' on order {OrderNumber}; " + + "skipping this domain so the rest of the order can still be validated. If this is " + + "a real domain, ensure the appropriate DNS provider plugin is deployed and " + + "configured on the gateway; if it came from a non-DNS SAN (e.g. an IP address) or a " + + "non-DNS Subject CN, remove it from the request.", + LogSanitizer.Strip(domain), orderNumber); + skippedDomains.Add((domain, "no DNS provider resolved")); + continue; + } + + _logger.LogInformation( + "Staging DNS TXT record for DCV. OrderNumber={OrderNumber}, Domain={Domain}, Hostname={Hostname}", + orderNumber, LogSanitizer.Strip(domain), LogSanitizer.Strip(hostname)); + + DomainValidationResult stageResult; + try + { + stageResult = await validator.StageValidation(hostname, token, ct); + } + catch (OperationCanceledException) + { + // Same reasoning as the GetDcv cancellation catch above: not a per-domain + // failure, must reach the outer catch rather than the generic clause below. + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, + "DNS provider plugin threw while staging '{Domain}' for order {OrderNumber}; " + + "skipping this domain so the rest of the order can still be validated.", + LogSanitizer.Strip(domain), orderNumber); + skippedDomains.Add((domain, "DNS provider plugin threw")); + continue; + } + + if (!stageResult.Success) + { + _logger.LogError( + "Failed to stage DNS validation for '{Domain}' on order {OrderNumber}: {Error}. " + + "Skipping this domain so the rest of the order can still be validated.", + LogSanitizer.Strip(domain), orderNumber, LogSanitizer.Strip(stageResult.ErrorMessage)); + skippedDomains.Add((domain, $"stage failed: {stageResult.ErrorMessage}")); + continue; + } + + stagedHostnames[hostname + "|" + token] = domain; + stagedValidations.Add((domain, hostname, validator)); + verifyDomains.Add(domain); + } + } + catch (Exception ex) + { + // Nothing in the loop above throws for a per-domain reason any more — this is the + // safety net for a genuinely unexpected failure: cancellation (the shared + // DcvTimeoutMinutes-bound token expiring mid-loop — explicitly re-thrown past the + // per-domain catches above rather than mislabeled as a per-domain failure) or a bug. + // Log before rethrowing: neither caller (EnrollNewAsync's try/finally, or Enroll + // itself) adds a catch, so without a log line here an unanticipated failure on the + // synchronous Enroll-time DCV path would leave no plugin-emitted record at all + // identifying the order or cause — only whatever the gateway host's own unhandled- + // exception logging happens to capture. + _logger.LogError(ex, + "Unexpected failure during DCV staging for order {OrderNumber}; cleaning up any " + + "already-staged TXT records before this propagates.", orderNumber); + await CleanupPartialStagingAsync(); + throw; + } + + if (deferToNextSyncCycle) + { + await CleanupPartialStagingAsync(); + return false; + } + + if (skippedDomains.Count > 0) + { + _logger.LogError( + "{Count} domain(s) on order {OrderNumber} could not be staged for DCV and were skipped: " + + "[{Domains}]. This order cannot be issued by CERTInext until they are resolved.", + skippedDomains.Count, orderNumber, + LogSanitizer.Strip(string.Join(", ", skippedDomains.Select(d => $"{d.domain} ({d.reason})")))); + } + + if (stagedValidations.Count == 0) + return false; + + try + { + // Allow DNS propagation before asking CERTInext to verify. The sync path passes + // a short override (issue 0002) so a bounded set of recent pending orders doesn't + // each burn the full configured delay; Enroll uses the full configured value. + int delaySeconds = propagationDelaySecondsOverride + ?? (_config.DcvPropagationDelaySeconds > 0 ? _config.DcvPropagationDelaySeconds : 30); + _logger.LogInformation( + "Waiting {Delay}s for DNS propagation before verifying DCV. OrderNumber={OrderNumber}", + delaySeconds, orderNumber); + await Task.Delay(TimeSpan.FromSeconds(delaySeconds), ct); + + // Every domain that got through staging — including hostname-sharing siblings — + // still gets its own CA-side verify call; CERTInext tracks DCV per domain entry + // even when two domains share one TXT record. + foreach (var domain in verifyDomains) + { + _logger.LogInformation( + "Triggering CERTInext DCV verification. OrderNumber={OrderNumber}, Domain={Domain}", + orderNumber, LogSanitizer.Strip(domain)); + await _client.VerifyDcvAsync(orderNumber, domain, Constants.Dcv.MethodDnsTxt, ct); + } + + // Poll TrackOrder until CERTInext confirms all staged domains are verified + // before removing TXT records — VerifyDcv triggers an async DNS lookup on + // their side, so cleanup must wait for dcvStatus=1 on every domain. + await WaitForDcvVerificationAsync(orderNumber, verifyDomains, ct); + } + finally + { + // Always clean up staged DNS records — even on failure. Concurrent, not sequential + // — see CleanupPartialStagingAsync's comment above for why: sequential cleanup made + // the aggregate wall-clock time for this block scale with the number of staged SAN + // domains, unbounded relative to DcvTimeoutMinutes, on this ordinary success path too. + await Task.WhenAll(stagedValidations.Select(entry => + CleanupOneStagedValidationAsync(entry, ""))); + } + + return true; + } + + /// + /// True when the exception's message contains the CERTInext V2 EMS-1080 code + /// ("Domain is already verified"), the spec's documented no-op for both + /// GetDcv and VerifyDcv on a domain that is still within its DCV reuse window + /// (issues/0020). Message-based rather than a typed field because the API's + /// RFC 7807 body carries the EMS code as text embedded in `detail`/`title`, + /// not as a separate structured field (see ). + /// + private static bool IsEms1080DomainAlreadyVerified(Exception ex) => + ex?.Message?.IndexOf("EMS-1080", StringComparison.OrdinalIgnoreCase) >= 0; + + /// + /// Performs DNS-01 DCV for a V2 SSL order using the V2 DCV endpoints. + /// + /// Issue 0042: a UCC order's additional SAN domains each carry their own DCV state in + /// Track Order's verifications.domain.domains[] block. This entry point owns the + /// single per-order guard (enrollment + sync overlap + /// protection — one guard entry regardless of how many domains the order has), then + /// dispatches to whichever flow applies: + /// - non-empty → , + /// which loops every domain whose own dcvStatus isn't VERIFIED. + /// - null/empty (single-domain orders, or an older/ + /// simpler response shape that never populated the block) → the original, + /// byte-for-byte-unchanged single-domain flow in + /// . + /// + /// Returns true when DCV steps were executed for at least one domain, false + /// when skipped entirely (not configured, no domain(s) to act on, or already in flight). + /// + private async Task PerformDcvV2IfNeededAsync( + string orderId, + string domain, + string productFamilySlug, + CancellationToken ct, + IReadOnlyList domainEntries = null) + { + if (_domainValidatorFactory == null || !_config.DcvEnabled) + { + _logger.LogDebug( + "V2 DCV skipped: DCV factory not configured or DcvEnabled=false. OrderId={OrderId}", orderId); + return false; + } + + // Issue 0033: domain control validation exists only for the SSL/TLS family — the + // spec's DCV endpoints live under /ssl-certificates only, the Private PKI folder says + // "No DCV - your CA trusts you", and the Document Signer folder has no DCV step. This + // single gate covers every caller (EnrollV2Async, GetSingleRecordV2Async and V2 + // Synchronize), so a non-SSL order that is merely pending approval/documents is never + // sent to a nonexistent /{family}/{orderId}/dcv endpoint. + if (!string.Equals(productFamilySlug, Constants.ApiV2.FamilySsl, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug( + "V2 DCV skipped: product family '{Family}' has no domain control validation step. OrderId={OrderId}", + productFamilySlug, orderId); + return false; + } + + bool multiDomainMode = domainEntries != null && domainEntries.Count > 0; + + if (!multiDomainMode && string.IsNullOrWhiteSpace(domain)) + { + _logger.LogWarning( + "V2 DCV skipped: no domain name available for order {OrderId}.", orderId); + return false; + } + + // Prevent concurrent DCV staging for the same order (enrollment + sync overlap). + // Mirrors the _dcvInFlight guard in TryRunDcvDuringSyncAsync (V1 path). One guard + // entry per ORDER, not per domain — a UCC order with several pending SANs is still + // a single in-flight unit of work. + if (!_dcvInFlight.TryAdd(orderId, 0)) + { + _logger.LogInformation( + "DCV already in flight for V2 order {OrderId}; skipping concurrent attempt.", orderId); + return false; + } + + try + { + return multiDomainMode + ? await PerformDcvV2MultiDomainAsync(orderId, domainEntries, productFamilySlug, ct) + : await PerformDcvV2SingleDomainAsync(orderId, domain, productFamilySlug, ct); + } + finally + { + _dcvInFlight.TryRemove(orderId, out _); + } + } + + /// + /// Original single-domain V2 DCV flow (pre-issue-0042), preserved byte-for-byte except + /// for the _dcvInFlight guard, which its caller + /// now owns for the whole call. Used whenever the order has no per-domain + /// verifications.domain.domains[] block to drive from (single-domain orders, or an + /// older/simpler response shape) — see issue 0042's "keep today's primary-domain + /// behaviour exactly" requirement. + /// + /// Flow: + /// 1. GET /ssl-certificates/{orderId}/dcv → retrieve token (token) + /// 2. Publish TXT record at the configured DcvTxtRecordTemplate hostname + /// (default Constants.Dcv.DefaultTxtRecordTemplate) via + /// 3. POST /ssl-certificates/{orderId}/dcv/verify → trigger CA-side verification + /// 4. Poll until status != "pending-dcv" + /// 5. Clean up TXT record + /// + /// EMS-1080 ("Domain is already verified") from either GetDcv or VerifyDcv is + /// treated as DCV already satisfied (issues/0020): publishing is skipped and + /// the flow proceeds straight to step 4. + /// + /// Returns true when DCV steps were executed, false when skipped. + /// + private async Task PerformDcvV2SingleDomainAsync( + string orderId, + string domain, + string productFamilySlug, + CancellationToken ct) + { + _logger.LogInformation( + "V2 DCV starting for order {OrderId}, domain {Domain}.", orderId, LogSanitizer.Strip(domain)); + + // 1. Fetch challenge + V2DcvChallengeResponse challenge = null; + bool dcvAlreadySatisfied = false; + try + { + challenge = await _client.GetDcvV2Async(orderId, productFamilySlug, ct); + } + catch (Exception ex) when (IsEms1080DomainAlreadyVerified(ex)) + { + // EMS-1080 "Domain is already verified" is a documented no-op (issues/0020), + // not a failure: the domain is account-scoped and reusable, so there is no + // fresh challenge to fetch. Treat DCV as already satisfied and skip straight + // to tracking/issuance instead of deferring to the next sync cycle. + _logger.LogInformation( + "V2 DCV already satisfied (EMS-1080 domain already verified) for order {OrderId}; " + + "skipping TXT publish and proceeding to tracking.", orderId); + dcvAlreadySatisfied = true; + } + catch (Exception ex) + { + _dcvInFlight.TryRemove(orderId, out _); + _logger.LogWarning(ex, + "V2 GetDcv failed for order {OrderId}; deferring DCV to next sync cycle.", orderId); + return false; + } + + string token = null; + string hostname = null; + Keyfactor.AnyGateway.Extensions.IDomainValidator validator = null; + + if (!dcvAlreadySatisfied) + { + token = challenge?.Token; + if (string.IsNullOrWhiteSpace(token)) + { + _dcvInFlight.TryRemove(orderId, out _); + _logger.LogWarning( + "V2 GetDcv returned no token for order {OrderId}; deferring DCV.", orderId); + return false; + } + + // TXT record hostname template — config-driven, mirroring V1's + // PerformDcvIfNeededAsync (issues/0027, item 5a). Falls back to the same + // Constants.Dcv.DefaultTxtRecordTemplate default V1 uses when unconfigured; + // {0} is substituted with the BASE domain name via string.Format, same as V1 — + // a wildcard's "*." label is not a queryable DNS name, so the DNS-side hostname + // and zone resolution use StripWildcardPrefix(domain); the CERTInext calls above + // and below keep using the original `domain` string, since that is what Track + // Order reports back. + string template = string.IsNullOrWhiteSpace(_config.DcvTxtRecordTemplate) + ? Constants.Dcv.DefaultTxtRecordTemplate + : _config.DcvTxtRecordTemplate; + string baseDomain = StripWildcardPrefix(domain); + hostname = string.Format(template, baseDomain); + + validator = DomainValidatorFactory.ResolveDomainValidator(baseDomain, "dns-01"); + if (validator == null) + { + _dcvInFlight.TryRemove(orderId, out _); + _logger.LogError( + "No DNS provider plugin resolved for domain '{Domain}' on V2 order {OrderId}. " + + "Ensure the appropriate DNS provider plugin is deployed and configured.", + LogSanitizer.Strip(domain), orderId); + return false; + } + } + + // staged=true only after a successful StageValidation so the finally only attempts + // cleanup when there is a record to remove (Finding C — cleanup skipped on !Success). + bool staged = false; + try + { + if (!dcvAlreadySatisfied) + { + // 2. Publish TXT record + _logger.LogInformation( + "Staging V2 DNS TXT record. OrderId={OrderId}, Hostname={Hostname}", orderId, LogSanitizer.Strip(hostname)); + + DomainValidationResult stageResult; + try + { + // Non-null here: only reached when !dcvAlreadySatisfied, and + // validator/hostname are always assigned together in that branch above. + stageResult = await validator!.StageValidation(hostname!, token, ct); + } + catch (Exception ex) + { + _logger.LogError(ex, + "V2 DCV: DNS provider threw while staging '{Domain}' for order {OrderId}.", + LogSanitizer.Strip(domain), orderId); + return false; + } + + if (!stageResult.Success) + { + _logger.LogError( + "V2 DCV: Failed to stage DNS TXT for '{Domain}' on order {OrderId}: {Error}.", + LogSanitizer.Strip(domain), orderId, LogSanitizer.Strip(stageResult.ErrorMessage)); + return false; + } + staged = true; + + // Wait for DNS propagation + int delaySeconds = _config.DcvPropagationDelaySeconds > 0 ? _config.DcvPropagationDelaySeconds : 30; + _logger.LogInformation( + "Waiting {Delay}s for DNS propagation before V2 DCV verify. OrderId={OrderId}", delaySeconds, orderId); + await Task.Delay(TimeSpan.FromSeconds(delaySeconds), ct); + + // 3. Trigger CA-side verification + _logger.LogInformation( + "Triggering V2 DCV verification. OrderId={OrderId}, Domain={Domain}", orderId, LogSanitizer.Strip(domain)); + try + { + var verifyResp = await _client.VerifyDcvV2Async(orderId, domain, productFamilySlug, ct); + _logger.LogInformation( + "V2 DCV verify response. OrderId={OrderId}, OverallStatus={Status}", + orderId, verifyResp?.OverallStatus ?? "(null)"); + + if (!string.Equals(verifyResp?.OverallStatus, "VERIFIED", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning( + "V2 DCV verify did not return VERIFIED for order {OrderId}. Status={Status}", + orderId, verifyResp?.OverallStatus); + return false; + } + } + catch (Exception ex) when (IsEms1080DomainAlreadyVerified(ex)) + { + // Same no-op as the GetDcv branch above, but surfaced at Verify time + // instead — the domain became/was already verified between the two + // calls. Treat as verified and continue to tracking rather than + // deferring (issues/0020). + _logger.LogInformation( + "V2 DCV already satisfied (EMS-1080 domain already verified) for order {OrderId} " + + "during VerifyDcv; treating as verified and proceeding to tracking.", orderId); + } + } + + // 4. Poll TrackOrderV2 until status leaves pending-dcv + int timeoutMinutes = _config.GetEffectiveDcvTimeoutMinutes(); + var deadline = DateTime.UtcNow.AddMinutes(timeoutMinutes); + // Fixed short cadence — decoupled from DcvPropagationDelaySeconds (one-shot + // DNS wait), not a poll interval. Reusing it here would yield only ~2 polls + // before the 5-minute timeout. + int pollSeconds = Constants.Dcv.SyncPropagationDelaySeconds; + + while (DateTime.UtcNow < deadline && !ct.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromSeconds(pollSeconds), ct); + try + { + var trackResp = await _client.TrackOrderV2Async(productFamilySlug, orderId, ct); + _logger.LogDebug( + "V2 DCV poll. OrderId={OrderId}, Status={Status}", orderId, trackResp.Status); + if (!string.Equals(trackResp.Status, "pending-dcv", StringComparison.OrdinalIgnoreCase)) + break; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "V2 DCV: TrackOrderV2 poll failed for order {OrderId}.", orderId); + break; + } + } + + return true; + } + finally + { + // Release the in-flight guard regardless of how the staged block exits. + _dcvInFlight.TryRemove(orderId, out _); + + // 5. Clean up TXT record — only when staging succeeded (staged=true). + if (staged) + { + try + { + using var cleanupCts = new CancellationTokenSource( + TimeSpan.FromSeconds(Constants.Dcv.CleanupValidationTimeoutSeconds)); + // Non-null here: staged is only true when !dcvAlreadySatisfied, in + // which case validator/hostname were assigned before staging began. + await validator!.CleanupValidation(hostname!, cleanupCts.Token); + _logger.LogInformation( + "V2 DCV: DNS TXT record cleaned up. OrderId={OrderId}, Hostname={Hostname}", + orderId, LogSanitizer.Strip(hostname)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "V2 DCV: Failed to clean up DNS TXT record. OrderId={OrderId}, Hostname={Hostname}. " + + "May require manual removal.", orderId, LogSanitizer.Strip(hostname)); + } + } + } + } + + /// + /// Generalized V2 DCV for orders whose Track Order response surfaced a per-domain + /// verifications.domain.domains[] block (issue 0042) — chiefly UCC orders with + /// additional SAN domains. Every entry whose own dcvStatus isn't VERIFIED is + /// processed: stage a TXT record for each pending domain, wait once for DNS propagation + /// (not once per domain), verify each domain individually, poll Track Order until every + /// domain just verified is confirmed (or the shared DCV timeout elapses), then always + /// clean up every staged record regardless of outcome. + /// + /// Partial failure: a domain that fails GetDcv/staging/verification is logged and + /// skipped — the others keep going. The order is left pending for any domain not + /// resolved this pass; because the caller always re-derives + /// from its own most recent Track Order response, the next sync/GetSingleRecord call + /// naturally retries only whichever domains are still not VERIFIED. + /// + /// The per-order _dcvInFlight guard is already held by the caller + /// () for the whole call. + /// + /// Returns true when DCV steps were executed for at least one domain (staged, or + /// found already verified via EMS-1080); false only when every domain in + /// was already VERIFIED (nothing to do). + /// + private async Task PerformDcvV2MultiDomainAsync( + string orderId, + IReadOnlyList domainEntries, + string productFamilySlug, + CancellationToken ct) + { + var pendingDomains = domainEntries + .Where(e => !string.IsNullOrWhiteSpace(e?.Domain) + && !string.Equals(e.DcvStatus, Constants.ApiV2.DcvStatusVerified, StringComparison.OrdinalIgnoreCase)) + .Select(e => e.Domain) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (pendingDomains.Count == 0) + { + _logger.LogDebug( + "V2 DCV (multi-domain) skipped: every domain on order {OrderId} is already VERIFIED.", + orderId); + return false; + } + + _logger.LogInformation( + "V2 DCV (multi-domain) starting for order {OrderId}. PendingDomains=[{Domains}]", + orderId, LogSanitizer.Strip(string.Join(", ", pendingDomains))); + + var staged = new List<(string domain, string hostname, Keyfactor.AnyGateway.Extensions.IDomainValidator validator)>(); + var verifiedDomains = new List(); + var failedDomains = new List<(string domain, string reason)>(); + + // Every domain that got through staging this pass — whether it freshly published a + // TXT record or shared an already-staged hostname with a sibling (see + // stagedHostnames below). Drives the per-domain CA Verify calls in Phase 2; kept + // separate from `staged` (which holds only ONE entry per unique hostname, for + // cleanup) so a wildcard/apex pair sharing a base-domain hostname still each get + // their own VerifyDcv call without staging — or cleaning up — the shared TXT record + // twice. + var verifyCandidates = new List(); + + // TXT hostname -> the first domain that staged it this pass. A UCC order can list + // both "example.com" and "*.example.com"; StripWildcardPrefix collapses both to the + // same base-domain hostname, so the second domain to reach it must reuse the + // already-staged record instead of publishing (and later cleaning up) a duplicate. + var stagedHostnames = new Dictionary(StringComparer.OrdinalIgnoreCase); + + async Task CleanupStagedAsync() + { + // Concurrent, not sequential — mirrors the V1 multi-SAN cleanup rationale + // (CleanupPartialStagingAsync above): a UCC order with N staged domains must not + // let the aggregate cleanup time scale with N. + await Task.WhenAll(staged.Select(entry => CleanupOneV2StagedRecordAsync(orderId, entry))); + } + + try + { + // Phase 1: stage a TXT record for every pending domain. Every failure here is + // scoped to the one domain that hit it (logged loudly, then skipped) — never + // thrown — so one bad SAN cannot abort DCV for the co-tenant domains on the same + // order (issue 0042's "partial failure: keep going"). + foreach (var d in pendingDomains) + { + ct.ThrowIfCancellationRequested(); + + V2DcvChallengeResponse challenge = null; + bool alreadySatisfied = false; + try + { + challenge = await _client.GetDcvV2Async(orderId, d, productFamilySlug, ct); + } + catch (Exception ex) when (IsEms1080DomainAlreadyVerified(ex)) + { + _logger.LogInformation( + "V2 DCV already satisfied (EMS-1080) for domain {Domain} on order {OrderId}; " + + "skipping TXT publish for this domain.", LogSanitizer.Strip(d), orderId); + alreadySatisfied = true; + } + catch (OperationCanceledException) + { + // The shared, DcvTimeoutMinutes-bound cancellation — not a per-domain + // failure. Propagate to the outer catch, which logs and cleans up. + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, + "V2 GetDcv failed for domain {Domain} on order {OrderId}; skipping this domain " + + "so the rest of the order can still be validated.", LogSanitizer.Strip(d), orderId); + failedDomains.Add((d, "GetDcv failed")); + continue; + } + + if (alreadySatisfied) + { + verifiedDomains.Add(d); + continue; + } + + string token = challenge?.Token; + if (string.IsNullOrWhiteSpace(token)) + { + _logger.LogError( + "V2 GetDcv returned no token for domain {Domain} on order {OrderId}; skipping " + + "this domain so the rest of the order can still be validated.", + LogSanitizer.Strip(d), orderId); + failedDomains.Add((d, "no DCV token returned")); + continue; + } + + string template = string.IsNullOrWhiteSpace(_config.DcvTxtRecordTemplate) + ? Constants.Dcv.DefaultTxtRecordTemplate + : _config.DcvTxtRecordTemplate; + // DCV publishes/looks up the TXT record under the BASE domain — a wildcard's + // "*." label is not a queryable DNS name. CERTInext's own GetDcv/VerifyDcv + // calls keep using the original `d` string, since that is what Track Order + // reports back per-domain. + string baseDomain = StripWildcardPrefix(d); + string hostname = string.Format(template, baseDomain); + + if (stagedHostnames.TryGetValue(hostname + "|" + token, out string sharedWithDomain)) + { + // Sibling domain (e.g. the wildcard/apex pair of the same base domain) + // already staged this exact TXT hostname this pass — reuse it instead of + // publishing a second record at the same name. This domain still gets its + // own CA-side VerifyDcv in Phase 2 below. + _logger.LogInformation( + "V2 DCV hostname {Hostname} for domain {Domain} on order {OrderId} is already " + + "staged (shared with {SharedWith}); reusing it instead of publishing a second " + + "TXT record.", + LogSanitizer.Strip(hostname), LogSanitizer.Strip(d), orderId, + LogSanitizer.Strip(sharedWithDomain)); + verifyCandidates.Add(d); + continue; + } + + var validator = DomainValidatorFactory.ResolveDomainValidator(baseDomain, "dns-01"); + if (validator == null) + { + _logger.LogError( + "No DNS provider plugin resolved for domain '{Domain}' on V2 order {OrderId}; " + + "skipping this domain so the rest of the order can still be validated.", + LogSanitizer.Strip(d), orderId); + failedDomains.Add((d, "no DNS provider resolved")); + continue; + } + + _logger.LogInformation( + "Staging V2 DNS TXT record for DCV. OrderId={OrderId}, Domain={Domain}, Hostname={Hostname}", + orderId, LogSanitizer.Strip(d), LogSanitizer.Strip(hostname)); + + DomainValidationResult stageResult; + try + { + stageResult = await validator.StageValidation(hostname, token, ct); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, + "V2 DCV: DNS provider threw while staging '{Domain}' for order {OrderId}; " + + "skipping this domain so the rest of the order can still be validated.", + LogSanitizer.Strip(d), orderId); + failedDomains.Add((d, "DNS provider plugin threw")); + continue; + } + + if (!stageResult.Success) + { + _logger.LogError( + "V2 DCV: Failed to stage DNS TXT for '{Domain}' on order {OrderId}: {Error}. " + + "Skipping this domain so the rest of the order can still be validated.", + LogSanitizer.Strip(d), orderId, LogSanitizer.Strip(stageResult.ErrorMessage)); + failedDomains.Add((d, $"stage failed: {stageResult.ErrorMessage}")); + continue; + } + + stagedHostnames[hostname + "|" + token] = d; + staged.Add((d, hostname, validator)); + verifyCandidates.Add(d); + } + } + catch (Exception ex) + { + // Safety net for a genuinely unexpected failure: cancellation (the shared + // DcvTimeoutMinutes-bound token expiring mid-loop, explicitly re-thrown past the + // per-domain catches above) or a bug. Clean up whatever was already staged before + // this propagates — none of the callers add their own cleanup. + _logger.LogError(ex, + "Unexpected failure during V2 DCV staging for order {OrderId}; cleaning up any " + + "already-staged TXT records before this propagates.", orderId); + await CleanupStagedAsync(); + throw; + } + + if (verifyCandidates.Count > 0) + { + try + { + // One propagation wait for the whole batch, not one per domain. + int delaySeconds = _config.DcvPropagationDelaySeconds > 0 ? _config.DcvPropagationDelaySeconds : 30; + _logger.LogInformation( + "Waiting {Delay}s for DNS propagation before V2 DCV verify. OrderId={OrderId}, DomainCount={Count}", + delaySeconds, orderId, verifyCandidates.Count); + await Task.Delay(TimeSpan.FromSeconds(delaySeconds), ct); + + // Phase 2: verify each domain that got through staging individually — the + // spec's Verify DCV body takes a single `domain`, mirroring Get DCV + // Challenges' per-domain shape (per-SAN semantics unconfirmed live + // end-to-end — see v2-api-support-questions.md Finding 9, question 3). This + // includes hostname-sharing siblings (verifyCandidates), not just the domains + // that staged a fresh TXT record (staged) — CERTInext tracks DCV per domain + // entry even when two domains share one TXT record. + foreach (var d in verifyCandidates) + { + try + { + _logger.LogInformation( + "Triggering V2 DCV verification. OrderId={OrderId}, Domain={Domain}", + orderId, LogSanitizer.Strip(d)); + var verifyResp = await _client.VerifyDcvV2Async(orderId, d, productFamilySlug, ct); + _logger.LogInformation( + "V2 DCV verify response. OrderId={OrderId}, Domain={Domain}, OverallStatus={Status}", + orderId, LogSanitizer.Strip(d), verifyResp?.OverallStatus ?? "(null)"); + + if (string.Equals(verifyResp?.OverallStatus, "VERIFIED", StringComparison.OrdinalIgnoreCase)) + { + verifiedDomains.Add(d); + } + else + { + _logger.LogWarning( + "V2 DCV verify did not return VERIFIED for domain {Domain} on order {OrderId}. " + + "Status={Status}", LogSanitizer.Strip(d), orderId, verifyResp?.OverallStatus); + failedDomains.Add((d, $"verify returned {verifyResp?.OverallStatus ?? "(null)"}")); + } + } + catch (Exception ex) when (IsEms1080DomainAlreadyVerified(ex)) + { + // Same no-op as the GetDcv branch above, but surfaced at Verify time + // instead — the domain became/was already verified between the two + // calls. Treat as verified rather than deferring (issues/0020). + _logger.LogInformation( + "V2 DCV already satisfied (EMS-1080) for domain {Domain} on order {OrderId} " + + "during VerifyDcv; treating as verified.", LogSanitizer.Strip(d), orderId); + verifiedDomains.Add(d); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, + "V2 DCV verify failed for domain {Domain} on order {OrderId}; skipping this " + + "domain so the rest of the order can still be validated.", + LogSanitizer.Strip(d), orderId); + failedDomains.Add((d, "verify failed")); + } + } + + // Phase 3: poll Track Order until every domain just verified this pass is + // confirmed there too, before cleanup — mirrors the V1 rationale + // (WaitForDcvVerificationAsync): VerifyDcv's synchronous response may not yet + // be reflected by the CA's own async DNS lookup. + var stagedDomainNames = new HashSet( + verifyCandidates, StringComparer.OrdinalIgnoreCase); + var justVerifiedStaged = verifiedDomains + .Where(d => stagedDomainNames.Contains(d)) + .ToList(); + if (justVerifiedStaged.Count > 0) + { + await WaitForDomainsVerifiedV2Async(orderId, productFamilySlug, justVerifiedStaged, ct); + } + } + finally + { + await CleanupStagedAsync(); + } + } + + if (failedDomains.Count > 0) + { + _logger.LogError( + "{FailedCount} domain(s) on V2 order {OrderId} could not be validated this pass and " + + "were skipped: [{Domains}]. The order remains pending; a later sync/GetSingleRecord " + + "retries only the still-unverified domains.", + failedDomains.Count, orderId, + LogSanitizer.Strip(string.Join(", ", failedDomains.Select(f => $"{f.domain} ({f.reason})")))); + } + + _logger.LogInformation( + "V2 DCV (multi-domain) summary. OrderId={OrderId}, PendingCount={Pending}, VerifiedCount={Verified}, FailedCount={Failed}", + orderId, pendingDomains.Count, verifiedDomains.Count, failedDomains.Count); + + return true; + } + + /// + /// Removes one already-published V2 DCV TXT record. Shared cleanup logic for + /// 's staged-domain list — mirrors the + /// single-domain V2 path's own inline cleanup (and V1's + /// CleanupOneStagedValidationAsync) in both bound and best-effort behavior: a + /// fresh, independently-bounded token (neither the ambient ct nor + /// ) so a stuck DNS provider cannot hang this + /// best-effort compensating action indefinitely, regardless of why cleanup was + /// triggered. + /// + private async Task CleanupOneV2StagedRecordAsync( + string orderId, + (string domain, string hostname, Keyfactor.AnyGateway.Extensions.IDomainValidator validator) entry) + { + var (domain, hostname, validator) = entry; + try + { + using var cleanupCts = new CancellationTokenSource( + TimeSpan.FromSeconds(Constants.Dcv.CleanupValidationTimeoutSeconds)); + await validator.CleanupValidation(hostname, cleanupCts.Token); + _logger.LogInformation( + "V2 DCV: DNS TXT record cleaned up. OrderId={OrderId}, Domain={Domain}, Hostname={Hostname}", + orderId, LogSanitizer.Strip(domain), LogSanitizer.Strip(hostname)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "V2 DCV: Failed to clean up DNS TXT record. OrderId={OrderId}, Domain={Domain}, " + + "Hostname={Hostname}. May require manual removal.", + orderId, LogSanitizer.Strip(domain), LogSanitizer.Strip(hostname)); + } + } + + /// + /// Polls until every domain in + /// reaches a VERIFIED dcvStatus in + /// verifications.domain.domains[], reaches REJECTED (terminal — confirmed live + /// after an order cancellation, issue 0042), or is cancelled / + /// the internal deadline elapses. V2 analogue of . + /// + private async Task WaitForDomainsVerifiedV2Async( + string orderId, string productFamilySlug, IReadOnlyList domains, CancellationToken ct) + { + if (domains.Count == 0) return; + + var pending = new HashSet(domains, StringComparer.OrdinalIgnoreCase); + // Fixed short cadence — decoupled from DcvPropagationDelaySeconds (a one-shot DNS + // wait), not a poll interval. + int pollSeconds = Constants.Dcv.SyncPropagationDelaySeconds; + + // Defense-in-depth deadline, same rationale as WaitForDcvVerificationAsync: bounded + // even if a future refactor breaks the cancellation chain. + var deadline = DateTime.UtcNow.AddMinutes(_config.GetEffectiveDcvTimeoutMinutes()); + + while (pending.Count > 0 && !ct.IsCancellationRequested) + { + if (DateTime.UtcNow >= deadline) + { + _logger.LogWarning( + "V2 DCV verification poll exceeded its internal deadline ({Minutes}min). " + + "OrderId={OrderId}, StillPendingDomains=[{Pending}]. Exiting and leaving TXT " + + "records for the caller's cleanup.", + _config.GetEffectiveDcvTimeoutMinutes(), orderId, + LogSanitizer.Strip(string.Join(",", pending))); + return; + } + + try + { + await Task.Delay(TimeSpan.FromSeconds(pollSeconds), ct); + } + catch (OperationCanceledException) + { + return; + } + + V2OrderStatusResponse poll; + try + { + poll = await _client.TrackOrderV2Async(productFamilySlug, orderId, ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "V2 TrackOrder polling failed during DCV wait. OrderId={OrderId}", orderId); + return; + } + + var entries = poll.Verifications?.Domain?.Domains; + if (entries == null) continue; + + foreach (var entry in entries) + { + if (entry?.Domain == null || !pending.Contains(entry.Domain)) continue; + + if (string.Equals(entry.DcvStatus, Constants.ApiV2.DcvStatusVerified, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation( + "V2 DCV verified by CERTInext. OrderId={OrderId}, Domain={Domain}", + orderId, LogSanitizer.Strip(entry.Domain)); + pending.Remove(entry.Domain); + } + else if (string.Equals(entry.DcvStatus, Constants.ApiV2.DcvStatusRejected, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning( + "V2 DCV rejected by CERTInext. OrderId={OrderId}, Domain={Domain}", + orderId, LogSanitizer.Strip(entry.Domain)); + pending.Remove(entry.Domain); + } + } + } + } +#endif + + /// + /// Polls GetCertificateAsync until either (a) the certificate reaches a terminal + /// state (issued or rejected) or (b) the configured DcvWaitForIssuanceSeconds + /// budget expires. Returns the final response on success, or null if all polls + /// failed (so callers fall back to the pending result they already have). + /// + /// CERTInext's issuance pipeline is asynchronous on their side: after the plugin's + /// VerifyDcv triggers and the per-domain DCV is confirmed, the cert generation step + /// finishes a few seconds later. Without this poll the plugin would catch the cert + /// in pending state and return it that way, forcing the gateway to wait for the next + /// sync cycle. + /// + private async Task WaitForIssuanceAfterDcvAsync( + string orderNumber, CancellationToken ct) + { + int waitBudgetSeconds = _config.GetEffectiveDcvWaitForIssuanceSeconds(); + + // Fixed 3-second poll interval. CERTInext's post-DCV issuance step typically + // completes within 5–15s; polling more aggressively would just add API load, + // and polling more slowly would push the typical-case latency closer to the + // budget ceiling. Decoupled from DcvPropagationDelaySeconds (which is for DNS + // propagation, a different concern) so admins tuning DNS settings don't + // accidentally make post-DCV polling chunky. + int pollIntervalSeconds = 3; + DateTime deadline = DateTime.UtcNow.AddSeconds(Math.Max(0, waitBudgetSeconds)); + LegacyGetCertificateResponse last = null; + + // Admin opt-out: budget <= 0 means "don't wait, let sync pick the cert up". + // Short-circuit before any API call so the gateway doesn't pay a TrackOrder + + // optional DownloadCertificate round trip per Enroll when the admin has + // explicitly disabled the wait. + if (waitBudgetSeconds <= 0) + { + _logger.LogDebug( + "Post-DCV issuance wait disabled (DcvWaitForIssuanceSeconds<=0). " + + "Order {OrderNumber} will be picked up on the next sync cycle.", + orderNumber); + return null; + } + + int attempt = 0; + while (true) + { + attempt++; + ct.ThrowIfCancellationRequested(); + try + { + last = await _client.GetCertificateAsync(orderNumber, ct); + } + catch (Exception ex) + { + // Distinguish first-call failure (no result to return, sync must pick up) + // from later-poll failure (we have a prior pending result that the caller + // can use as a fallback). Without this distinction a repeated first-call + // failure would look identical to a working-but-always-pending enroll. + _logger.LogWarning(ex, + "Post-DCV GetCertificate failed for order {OrderNumber} (attempt {Attempt}). " + + "Returning {Outcome}; sync will pick up the cert later.", + orderNumber, attempt, last == null ? "pending fallback (no prior result)" : "prior pending result"); + return last; + } + + int disposition = StatusMapper.ToRequestDisposition(last.Status); + if (disposition == (int)EndEntityStatus.GENERATED + || disposition == (int)EndEntityStatus.REVOKED + || disposition == (int)EndEntityStatus.FAILED) + { + return last; + } + + if (waitBudgetSeconds <= 0 || DateTime.UtcNow >= deadline) + { + _logger.LogInformation( + "Post-DCV issuance not complete within {Budget}s for order {OrderNumber}. " + + "Returning pending result; sync will pick up the cert later.", + waitBudgetSeconds, orderNumber); + return last; + } + + try + { + await Task.Delay(TimeSpan.FromSeconds(pollIntervalSeconds), ct); + } + catch (OperationCanceledException) + { + return last; + } + } + } + + /// + /// Polls until every domain in + /// reaches dcvStatus=1 (verified) or a terminal + /// failure state (rejected/cancelled), or is cancelled. + /// Called after VerifyDcvAsync to ensure CERTInext has completed its async + /// DNS lookup before TXT records are cleaned up. + /// + private async Task WaitForDcvVerificationAsync(string orderNumber, IReadOnlyList domains, CancellationToken ct) + { + if (domains.Count == 0) return; + + var pending = new HashSet(domains, StringComparer.OrdinalIgnoreCase); + // Fixed short cadence — decoupled from DcvPropagationDelaySeconds, which is a + // one-shot DNS propagation wait, not a polling interval. Reusing it here would + // reduce the number of polls to ~2 before the 5-minute timeout. + int pollSeconds = Constants.Dcv.SyncPropagationDelaySeconds; + + // Defense-in-depth deadline: SOX CC7.3 requires every wait to be bounded. + // The caller passes a `ct` derived from a CancellationTokenSource that already + // cancels after `DcvTimeoutMinutes`, so this method is bounded via that path. + // We add an explicit internal deadline so a future refactor breaking the + // cancellation chain (e.g. accidentally passing CancellationToken.None) can't + // make this loop unbounded — it would still exit on the deadline below. + var verificationDeadline = DateTime.UtcNow.AddMinutes(_config.GetEffectiveDcvTimeoutMinutes()); + + while (pending.Count > 0 && !ct.IsCancellationRequested) + { + if (DateTime.UtcNow >= verificationDeadline) + { + _logger.LogWarning( + "DCV verification poll exceeded its internal deadline ({Minutes}min). " + + "OrderNumber={OrderNumber}, StillPendingDomains=[{Pending}]. " + + "Exiting and leaving TXT records for the caller's finally block to clean up.", + _config.GetEffectiveDcvTimeoutMinutes(), orderNumber, + LogSanitizer.Strip(string.Join(",", pending))); + return; + } + + await Task.Delay(TimeSpan.FromSeconds(pollSeconds), ct); + + TrackOrderResponse poll; + try { poll = await _client.TrackOrderAsync(orderNumber, ct); } + catch (Exception ex) + { + _logger.LogWarning(ex, "TrackOrder polling failed during DCV wait. OrderNumber={OrderNumber}", orderNumber); + return; + } + + var entries = poll.OrderDetails?.DomainVerification?.GetDomainEntries() + ?? new Dictionary(); + + // Check for order-level terminal failure (cancelled/rejected) + if (poll.OrderDetails?.OrderStatusId is "4" or "5") + { + _logger.LogWarning( + "Order {OrderNumber} reached terminal failure state (OrderStatusId={Status}) during DCV wait. TXT records will be cleaned up.", + orderNumber, poll.OrderDetails.OrderStatusId); + return; + } + + foreach (var domain in domains) + { + if (!pending.Contains(domain)) continue; + if (!entries.TryGetValue(domain, out var detail)) continue; + + if (string.Equals(detail.DcvStatus, Constants.Dcv.StatusValidated, StringComparison.Ordinal)) + { + _logger.LogInformation("DCV verified by CERTInext. OrderNumber={OrderNumber}, Domain={Domain}", + orderNumber, LogSanitizer.Strip(domain)); + pending.Remove(domain); + } + else if (string.Equals(detail.DcvStatus, Constants.Dcv.StatusRejected, StringComparison.Ordinal)) + { + _logger.LogWarning("DCV rejected by CERTInext. OrderNumber={OrderNumber}, Domain={Domain}", + orderNumber, LogSanitizer.Strip(domain)); + pending.Remove(domain); + } + } + } + } + + /// + /// Synchronous certificate pickup — parity with the legacy Sectigo connector's + /// PickUpEnrolledCertificate. After an order is submitted, polls + /// GetCertificate up to PickupRetries times, PickupDelay seconds + /// apart (after a fixed initial delay), so an order that issues quickly is returned + /// GENERATED + PEM in the same enrollment call instead of waiting for the next + /// synchronization. If the certificate has not issued within the budget, the original + /// pending result is returned unchanged and the order is imported by a later sync — + /// behaviour identical to before this feature. + /// + /// Applies to ALL products. CERTInext issues OV/EV asynchronously (organization + /// verification, minutes to hours; confirmed by CERTInext support ticket #162763), so + /// those typically exhaust the budget and fall back to pending; only DV / already-approved + /// orders return in-call. Never throws — any polling error degrades to the pending result. + /// + private async Task PickUpEnrolledCertificateAsync( + EnrollmentResult pendingResult, string orderNumber, bool dcvIssuanceWaitRan, + CancellationToken ct = default) + { + // The DCV path already owns the in-call issuance wait for this order — running a second + // stacked poll here would double the wait budget (when DCV ran WaitForIssuanceAfterDcvAsync) + // or waste it polling an order DCV already found terminal / not-yet-validated. Defer to + // the pending result; a later sync completes it. + if (dcvIssuanceWaitRan) + return pendingResult; + + // Only a still-pending (external-validation) result can benefit from a pickup poll. + // An already issued/failed/revoked result is returned as-is. + if (pendingResult == null + || pendingResult.Status != (int)EndEntityStatus.EXTERNALVALIDATION) + return pendingResult; + + // A pending result with no order number cannot be polled — surface the anomaly rather + // than silently returning, so an un-pollable pending state leaves an audit trace. + if (string.IsNullOrWhiteSpace(orderNumber)) + { + _logger.LogWarning( + "Synchronous pickup skipped: a pending enrollment was returned with no order " + + "number to poll. The certificate can only be reconciled by a later synchronization."); + return pendingResult; + } + + int retries = _config.GetEffectivePickupRetries(); + if (retries <= 0) { - _logger.LogWarning(ex, - "Could not resolve CARequestID for serial '{SN}'. Falling back to new enrollment.", priorCertSn); - return await EnrollNewAsync(csr, subject, san, ep); + _logger.LogInformation( + "Synchronous certificate pickup disabled (PickupRetries<=0). Order {OrderNumber} " + + "will be picked up on the next synchronization.", orderNumber); + return pendingResult; } - if (string.IsNullOrWhiteSpace(priorCaRequestId)) + int delaySeconds = _config.GetEffectivePickupDelaySeconds(); + + // Hard ceiling on total in-call occupancy. PickupRetries and PickupDelay are each clamped + // independently, but their product can still reach ~30 min at the extremes — enough to push + // Enroll() past Command's own enrollment timeout. If the configured budget would exceed the + // ceiling, cap the retry count to fit; the remainder is imported by the next synchronization. + int maxPollRetries = Math.Max(1, + (Constants.Pickup.MaxTotalWaitSeconds - Constants.Pickup.InitialDelaySeconds) / delaySeconds); + if (retries > maxPollRetries) { _logger.LogInformation( - "CARequestID for serial '{SN}' is empty — falling back to new enrollment. Subject={Subject}", - priorCertSn, subject); - return await EnrollNewAsync(csr, subject, san, ep); + "Configured pickup budget (PickupRetries={Configured}, PickupDelaySeconds={Delay}) exceeds the " + + "{MaxTotal}s in-call ceiling; capping to {Capped} attempts. The certificate will be imported by " + + "the next synchronization if it has not issued by then.", + retries, delaySeconds, Constants.Pickup.MaxTotalWaitSeconds, maxPollRetries); + retries = maxPollRetries; } - // Determine whether this is within the renewal window. - // - // Semantics (Option A — "window before expiry"): - // useRenewalApi = true when the cert expires within the next RenewalWindowDays. - // useRenewalApi = false when the cert expires further away than that (too early → reissue). - // useRenewalApi = false when the cert is already expired (graceful degradation → new order). - // - // This matches operator expectation: "renew when within N days of expiry". - // Certs expiring far in the future should be reissued, not renewed via the CA's - // renew endpoint (which may assume near-expiry context on its side). - bool useRenewalApi = false; + _logger.LogInformation( + "Starting synchronous certificate pickup. OrderNumber={OrderNumber}, PickupRetries={Retries}, " + + "PickupDelaySeconds={Delay} (max ~{Max}s including a {Initial}s initial delay).", + orderNumber, retries, delaySeconds, + Constants.Pickup.InitialDelaySeconds + retries * delaySeconds, Constants.Pickup.InitialDelaySeconds); + + int pollErrors = 0; try { - DateTime? expiry = _certificateDataReader.GetExpirationDateByRequestId(priorCaRequestId); - if (expiry.HasValue) + // Small static delay before the first poll — mirrors the Sectigo connector's + // attempt to let a fast order finish issuing before we start polling at all. + await Task.Delay(TimeSpan.FromSeconds(Constants.Pickup.InitialDelaySeconds), ct); + + for (int attempt = 1; attempt <= retries; attempt++) { - DateTime now = DateTime.UtcNow; - DateTime renewalWindowEnd = now.AddDays(ep.RenewalWindowDays); - // Renew only if the cert is not yet expired AND expires within the window. - useRenewalApi = expiry.Value > now && expiry.Value <= renewalWindowEnd; + try + { + var cert = await _client.GetCertificateAsync(orderNumber, ct); + int disposition = StatusMapper.ToRequestDisposition(cert.Status); - // SOX CC6.2 / SOC2 CC7.2: the renewal window evaluation is a security-relevant - // policy decision (determines whether an existing CA record is reused). Logged - // at Information so it survives production log filters and is not suppressible - // by log-level configuration. - _logger.LogInformation( - "Renewal window evaluation complete. " + - "PriorCARequestID={PriorId}, CertExpiry={Expiry:O}, " + - "RenewalWindowEnd={WindowEnd:O}, RenewalWindowDays={Window}, UseRenewalApi={Use}", - priorCaRequestId, expiry.Value, renewalWindowEnd, ep.RenewalWindowDays, useRenewalApi); + // SOC2 CC7.3: record each poll's observed disposition so the issuance + // timeline is reconstructable (how many polls ran, what each returned). + _logger.LogDebug( + "Pickup poll observed status. OrderNumber={OrderNumber}, Attempt={Attempt}/{Retries}, " + + "MappedDisposition={Disposition}, Status='{Status}', BodyPresent={HasBody}.", + orderNumber, attempt, retries, disposition, cert.Status, + !string.IsNullOrWhiteSpace(cert.Certificate)); + + // Issued: only surface GENERATED when the PEM is actually present — never + // hand Command a body-less "issued" record. A body-less issued state keeps + // polling until the body appears or the budget runs out. + if (disposition == (int)EndEntityStatus.GENERATED + && !string.IsNullOrWhiteSpace(cert.Certificate)) + { + _logger.LogInformation( + "Synchronous pickup complete. OrderNumber={OrderNumber}, SerialNumber={Serial}, " + + "Attempt={Attempt}/{Retries}.", + orderNumber, + string.IsNullOrWhiteSpace(cert.SerialNumber) ? "(not provided by CA)" : cert.SerialNumber, + attempt, retries); + return new EnrollmentResult + { + CARequestID = string.IsNullOrWhiteSpace(cert.Id) ? orderNumber : cert.Id, + Certificate = cert.Certificate, + Status = (int)EndEntityStatus.GENERATED, + StatusMessage = $"Certificate issued successfully. CERTInext ID: {orderNumber}." + }; + } + + // Terminal non-issued outcomes carry no body and are surfaced immediately. + if (disposition == (int)EndEntityStatus.REVOKED + || disposition == (int)EndEntityStatus.FAILED) + { + // SOX/SOC2 CC7.2: an issuance FAILURE must cross the error threshold that + // SIEM issuance-failure rules key on (parity with BuildEnrollmentResult's + // enroll-time FAILED handling); a REVOKED terminal state is a warning. + if (disposition == (int)EndEntityStatus.FAILED) + _logger.LogError( + "Order {OrderNumber} reached terminal FAILED status '{Status}' during " + + "synchronous pickup (attempt {Attempt}/{Retries}).", + orderNumber, cert.Status, attempt, retries); + else + _logger.LogWarning( + "Order {OrderNumber} was REVOKED ('{Status}') during synchronous pickup " + + "(attempt {Attempt}/{Retries}).", + orderNumber, cert.Status, attempt, retries); + return new EnrollmentResult + { + CARequestID = string.IsNullOrWhiteSpace(cert.Id) ? orderNumber : cert.Id, + Certificate = cert.Certificate, + Status = disposition, + StatusMessage = $"Order {orderNumber} reached status '{cert.Status}' during enrollment pickup." + }; + } + } + catch (Exception ex) + { + if (ex is OperationCanceledException) throw; + // A transient fetch failure consumes an attempt rather than aborting the + // wait; if it never recovers the pending result is returned below. + pollErrors++; + _logger.LogWarning(ex, + "Pickup GetCertificate failed for order {OrderNumber} (attempt {Attempt}/{Retries}).", + orderNumber, attempt, retries); + } + + // Delay after every attempt (including the last), matching the Sectigo + // connector's pickup cadence so the max-occupancy ceiling is identical. + await Task.Delay(TimeSpan.FromSeconds(delaySeconds), ct); } + + // SOC1 accuracy: don't attribute non-completion to "OV/EV async by design" when the + // real cause was every poll erroring (e.g. a CA-side TrackOrder outage). Distinguish + // the two so the log reflects what actually happened. + if (pollErrors == retries) + _logger.LogWarning( + "Synchronous pickup exhausted {Retries} attempts for order {OrderNumber} — ALL polls " + + "errored (see preceding warnings). Returning pending result; the next synchronization " + + "will re-attempt retrieval.", + retries, orderNumber); + else + _logger.LogInformation( + "Synchronous pickup did not complete within {Retries} attempts for order {OrderNumber} " + + "({Errors} poll error(s); remainder still pending). Returning pending result; the " + + "certificate will be imported by the next synchronization. CERTInext issues OV/EV " + + "asynchronously by design (support ticket #162763).", + retries, orderNumber, pollErrors); + pendingResult.StatusMessage = + $"{pendingResult.StatusMessage} The certificate was not issued within the enrollment-pickup " + + "window; it will be imported by a later synchronization."; } catch (Exception ex) { _logger.LogWarning(ex, - "Could not determine expiry for '{Id}'. Defaulting to new enrollment.", priorCaRequestId); + "Synchronous pickup failed for order {OrderNumber}. Returning pending result; " + + "sync will pick up the certificate later.", orderNumber); } - if (useRenewalApi) + return pendingResult; + } + + /// + /// Synchronous certificate pickup for the V2 API — parity with + /// above (issue 0051: V2 enrollment had no + /// analogous poll, so a DV order that CERTInext issues within seconds of CSR submission + /// only ever reached Command via a gateway sync plus a Command full scan). After a V2 + /// order is created and its CSR submitted, polls + /// up to PickupRetries times, PickupDelay seconds apart (after the same + /// fixed initial delay), so a fast-issuing order is returned GENERATED + PEM in this same + /// enrollment call instead of waiting for the next synchronization. Reuses the identical + /// config knobs and wait-budget ceiling as the V1 method (Constants.Pickup, + /// GetEffectivePickupRetries, GetEffectivePickupDelaySeconds) so the two + /// behave identically from an operator's perspective. Never throws — any polling error + /// degrades to the pending result. + /// + /// Also returns the freshest raw CA status string observed while producing the result + /// (falling back to when no fresher poll ran) — issue + /// 0052 needs this so 's terminal REVOKED-to-FAILED + /// normalization can log/report the CA's own status text, not just Command's mapped + /// disposition. Note: a REVOKED result reaching that normalization is never "surfaced + /// immediately" as REVOKED any more — see NormalizeV2RevokedEnrollResult. + /// + private async Task<(EnrollmentResult Result, string RawCaStatus)> PickUpEnrolledCertificateV2Async( + EnrollmentResult pendingResult, string orderId, string productFamilySlug, + bool dcvV2Ran, string lastKnownCaStatus, CancellationToken ct = default) + { + // The inline V2 DCV path already owns the in-call issuance wait for this order — + // running a second stacked poll here would double the wait budget (when + // PerformDcvV2IfNeededAsync ran its own tracking poll) or waste it re-polling an + // order DCV already left mid-flight. Defer to the pending result; a later sync + // completes it. Mirrors dcvIssuanceWaitRan's role in the V1 method above. + if (dcvV2Ran) + return (pendingResult, lastKnownCaStatus); + + // Only a still-pending (external-validation) result can benefit from a pickup poll. + // An already issued/failed/revoked result is returned unpolled — the caller's own + // terminal normalization (NormalizeV2RevokedEnrollResult) decides what a REVOKED + // disposition here ultimately becomes; this method no longer surfaces it as-is. + if (pendingResult == null + || pendingResult.Status != (int)EndEntityStatus.EXTERNALVALIDATION) + return (pendingResult, lastKnownCaStatus); + + // A pending result with no order id cannot be polled — surface the anomaly rather + // than silently returning, so an un-pollable pending state leaves an audit trace. + if (string.IsNullOrWhiteSpace(orderId)) + { + _logger.LogWarning( + "V2 synchronous pickup skipped: a pending enrollment was returned with no order " + + "id to poll. The certificate can only be reconciled by a later synchronization."); + return (pendingResult, lastKnownCaStatus); + } + + int retries = _config.GetEffectivePickupRetries(); + if (retries <= 0) { - // SOX / SOC2 CC7.3: log the renewal attempt at Information so the intent is - // captured before the API call, enabling reconstruction if the call fails. _logger.LogInformation( - "Renewal via CERTInext renew API started. " + - "PriorCARequestID={PriorId}, Subject={Subject}, ProfileId={ProfileId}", - priorCaRequestId, subject, ep.ProfileId); + "V2 synchronous certificate pickup disabled (PickupRetries<=0). Order {OrderId} " + + "will be picked up on the next synchronization.", orderId); + return (pendingResult, lastKnownCaStatus); + } - var renewReq = new RenewCertificateRequest + int delaySeconds = _config.GetEffectivePickupDelaySeconds(); + + // Hard ceiling on total in-call occupancy — identical rationale to the V1 method: + // PickupRetries and PickupDelay are each clamped independently, but their product can + // still reach ~30 min at the extremes. Cap the retry count to fit; the remainder is + // imported by the next synchronization. + int maxPollRetries = Math.Max(1, + (Constants.Pickup.MaxTotalWaitSeconds - Constants.Pickup.InitialDelaySeconds) / delaySeconds); + if (retries > maxPollRetries) + { + _logger.LogInformation( + "Configured pickup budget (PickupRetries={Configured}, PickupDelaySeconds={Delay}) exceeds the " + + "{MaxTotal}s in-call ceiling; capping to {Capped} attempts. The certificate will be imported by " + + "the next synchronization if it has not issued by then.", + retries, delaySeconds, Constants.Pickup.MaxTotalWaitSeconds, maxPollRetries); + retries = maxPollRetries; + } + + _logger.LogInformation( + "Starting V2 synchronous certificate pickup. OrderId={OrderId}, PickupRetries={Retries}, " + + "PickupDelaySeconds={Delay} (max ~{Max}s including a {Initial}s initial delay).", + orderId, retries, delaySeconds, + Constants.Pickup.InitialDelaySeconds + retries * delaySeconds, Constants.Pickup.InitialDelaySeconds); + + int pollErrors = 0; + try + { + // Small static delay before the first poll — mirrors the V1 method's attempt to + // let a fast order finish issuing before we start polling at all. + await Task.Delay(TimeSpan.FromSeconds(Constants.Pickup.InitialDelaySeconds), ct); + + for (int attempt = 1; attempt <= retries; attempt++) { - Csr = csr, - ValidityDays = ep.ValidityDays > 0 ? ep.ValidityDays : (int?)null, - RequesterName = string.IsNullOrWhiteSpace(ep.RequesterName) ? null : ep.RequesterName, - RequesterEmail = string.IsNullOrWhiteSpace(ep.RequesterEmail) ? null : ep.RequesterEmail, - Comment = $"Renewed via Keyfactor Command. Prior ID: {priorCaRequestId}." - }; + try + { + var tracked = await _client.TrackOrderV2Async(productFamilySlug, orderId, ct); + int disposition = StatusMapper.V2StatusToRequestDisposition(tracked.Status); + lastKnownCaStatus = tracked.Status; - var renewResp = await _client.RenewCertificateAsync(priorCaRequestId, renewReq); - var renewResult = BuildEnrollmentResult(renewResp, ep.AutoApprove); + // SOC2 CC7.3: record each poll's observed disposition so the issuance + // timeline is reconstructable (how many polls ran, what each returned). + _logger.LogDebug( + "V2 pickup poll observed status. OrderId={OrderId}, Attempt={Attempt}/{Retries}, " + + "MappedDisposition={Disposition}, Status='{Status}'.", + orderId, attempt, retries, disposition, tracked.Status); - // SOX: log the renewal outcome so the new certificate ID and status are - // independently recorded (the outer Enroll method also logs, but this - // ensures the renew path is auditable if the result is further transformed). - _logger.LogInformation( - "Renewal via CERTInext renew API complete. " + - "PriorCARequestID={PriorId}, NewCARequestID={NewId}, Status={Status}", - priorCaRequestId, renewResult.CARequestID, renewResult.Status); + if (disposition == (int)EndEntityStatus.GENERATED) + { + // Issued: only surface GENERATED when the certificate body actually + // downloads — never hand Command a body-less "issued" record. A failed + // or empty download keeps polling until the body appears or the budget + // runs out, same invariant the V1 method already enforces. + try + { + var certResp = await _client.DownloadCertificateV2Async(productFamilySlug, orderId, ct); + string fullChain = AssembleV2CertChain(certResp); - return renewResult; + if (!string.IsNullOrWhiteSpace(fullChain)) + { + _logger.LogInformation( + "V2 synchronous pickup complete. OrderId={OrderId}, SerialNumber={Serial}, " + + "Attempt={Attempt}/{Retries}.", + orderId, + string.IsNullOrWhiteSpace(certResp.SerialNumber) ? "(not provided by CA)" : certResp.SerialNumber, + attempt, retries); + return (new EnrollmentResult + { + CARequestID = orderId, + Certificate = fullChain, + Status = (int)EndEntityStatus.GENERATED, + StatusMessage = "Certificate issued via V2 API." + }, lastKnownCaStatus); + } + + _logger.LogDebug( + "V2 pickup poll: order {OrderId} reports issued but returned no certificate " + + "body yet (attempt {Attempt}/{Retries}); continuing to poll.", + orderId, attempt, retries); + } + catch (Exception dlEx) + { + // Issued but the download itself failed — consume the attempt and + // keep polling rather than aborting; a later attempt (or the next + // sync) may succeed. + pollErrors++; + _logger.LogWarning(dlEx, + "V2 pickup: order {OrderId} is issued but certificate download failed " + + "(attempt {Attempt}/{Retries}).", orderId, attempt, retries); + } + } + else if (disposition == (int)EndEntityStatus.REVOKED + || disposition == (int)EndEntityStatus.FAILED) + { + // Terminal non-issued outcomes carry no body and stop the poll + // immediately — but neither is "returned as-is" any more: the REVOKED + // case still comes back from this method with Status=REVOKED, but + // EnrollV2Async's terminal NormalizeV2RevokedEnrollResult call maps it + // to FAILED before it ever reaches the gateway (issue 0052), since a + // REVOKED order observed here never has a downloadable certificate body. + if (disposition == (int)EndEntityStatus.FAILED) + _logger.LogError( + "V2 order {OrderId} reached terminal FAILED status '{Status}' during " + + "synchronous pickup (attempt {Attempt}/{Retries}).", + orderId, tracked.Status, attempt, retries); + else + _logger.LogWarning( + "V2 order {OrderId} was REVOKED ('{Status}') during synchronous pickup " + + "(attempt {Attempt}/{Retries}).", + orderId, tracked.Status, attempt, retries); + return (new EnrollmentResult + { + CARequestID = orderId, + Certificate = null, + Status = disposition, + StatusMessage = $"Order {orderId} reached status '{tracked.Status}' during enrollment pickup." + }, lastKnownCaStatus); + } + } + catch (Exception ex) + { + if (ex is OperationCanceledException) throw; + // A transient status-fetch failure consumes an attempt rather than aborting + // the wait; if it never recovers the pending result is returned below. + pollErrors++; + _logger.LogWarning(ex, + "V2 pickup TrackOrderV2Async failed for order {OrderId} (attempt {Attempt}/{Retries}).", + orderId, attempt, retries); + } + + // Delay after every attempt (including the last), matching the V1 method's + // pickup cadence so the max-occupancy ceiling is identical. + await Task.Delay(TimeSpan.FromSeconds(delaySeconds), ct); + } + + // SOC1 accuracy: don't attribute non-completion to "still validating" when the + // real cause was every poll erroring (e.g. a CA-side TrackOrder outage). Distinguish + // the two so the log reflects what actually happened. + if (pollErrors == retries) + _logger.LogWarning( + "V2 synchronous pickup exhausted {Retries} attempts for order {OrderId} — ALL polls " + + "errored (see preceding warnings). Returning pending result; the next synchronization " + + "will re-attempt retrieval.", + retries, orderId); + else + _logger.LogInformation( + "V2 synchronous pickup did not complete within {Retries} attempts for order {OrderId} " + + "({Errors} poll error(s); remainder still pending). Returning pending result; the " + + "certificate will be imported by the next synchronization.", + retries, orderId, pollErrors); + pendingResult.StatusMessage = + $"{pendingResult.StatusMessage} The certificate was not issued within the enrollment-pickup " + + "window; it will be imported by a later synchronization."; } - else + catch (Exception ex) { - _logger.LogInformation( - "Certificate '{Id}' is outside the renewal window ({Window} days) — issuing new certificate. Subject={Subject}", - priorCaRequestId, ep.RenewalWindowDays, subject); - return await EnrollNewAsync(csr, subject, san, ep); + _logger.LogWarning(ex, + "V2 synchronous pickup failed for order {OrderId}. Returning pending result; " + + "sync will pick up the certificate later.", orderId); } + + return (pendingResult, lastKnownCaStatus); } /// @@ -840,6 +5820,16 @@ private EnrollmentResult BuildEnrollmentResult(EnrollCertificateResponse resp, b throw new Exception("CERTInext returned a null enrollment response."); int status = StatusMapper.ToRequestDisposition(resp.Status); + + // CertiNext's "auto-approved"/"downloadable" statuses can arrive before the + // certificate bytes are actually generated — GetCertificate right after order + // placement then fails, leaving resp.Certificate null while resp.Status still + // says issued. Never hand Command a GENERATED result with no PEM (it crashes + // CertificateConverterFactory.FromPEM downstream); demote to pending instead, + // matching the same invariant PickUpEnrolledCertificateAsync already enforces. + if (status == (int)EndEntityStatus.GENERATED && string.IsNullOrWhiteSpace(resp.Certificate)) + status = (int)EndEntityStatus.EXTERNALVALIDATION; + string message; switch (status) @@ -920,46 +5910,484 @@ private static int MapRevocationReasonStringToCode(string reason) } /// - /// Converts the multi-valued SAN dictionary from the AnyCA gateway into the - /// list expected by the CERTInext API. + /// Builds the list submitted to CERTInext: the multi-valued SAN + /// dictionary the AnyCA gateway hands us, falling back to the subjectAltName extension + /// carried inside the CSR itself only when the gateway supplies nothing at all. + /// + /// Fallback, not union, deliberately: Command's SAN dictionary is the channel through + /// which an enrollment pattern's SAN policy is expressed for this request, and a signed + /// CSR — typically generated by the subscriber's own tooling, not by Command — can + /// legitimately carry more names than that policy allows. Unioning them in would + /// re-introduce a name the policy excluded. The CSR is only consulted when the dictionary + /// argument is null — not merely empty or all-empty-arrays. A non-null dictionary, + /// even one that computes to zero names, means Command's enrollment pattern ran and + /// deliberately produced no SANs for this request; only its literal absence means no + /// policy-derived set exists to defer to. + /// + /// The CSR still matters even though CERTInext ignores its subjectAltName extension + /// outright — measured on the US sandbox in SanSubmissionProbeTests: a CSR + /// carrying two DNS names, submitted with additionalDomains omitted, produced an + /// order with only the CN registered. Production behaves the same way: the customer + /// report that prompted this fix was a production UCC order whose CSR carried the SANs + /// and whose issued certificate held only the CN. So on whichever path populates the + /// gateway dictionary — or, in the fallback case, the CSR — this method is the only way + /// those names reach additionalDomains and therefore the certificate. + /// + /// History (UCC SANs silently dropped): the gateway keys this dictionary + /// dnsname, not dns. did not recognize + /// dnsname, so every DNS SAN was typed "dnsname", filtered out by the + /// DNS-only test in BuildAdditionalDomains, and the order went to CERTInext + /// with no additionalDomains at all. The certificate came back holding only + /// the CN, which reads as the CA stripping SANs supplied on the CSR. /// - private static List BuildSanList(Dictionary san) + private List BuildSanList(Dictionary san, string csr, string subject) + => BuildSanList(san, csr, subject, dnsOnly: false, out _); + + /// + /// with an explicit + /// DNS-only mode for callers whose wire field cannot carry non-DNS SANs at all — the V2 + /// SSL UCC additionalDomains path (issue 0046). With set, + /// non-DNS entries are removed before any logging, regardless of + /// (a V1-only switch), and handed back via + /// so the caller can log its own accurate message. The + /// V1-worded "DROPPED because SubmitNonDnsSans is false" / "submitted rather than dropped" + /// warnings are only emitted when is false. + /// + private List BuildSanList( + Dictionary san, string csr, string subject, + bool dnsOnly, out List excludedNonDns) { - if (san == null || san.Count == 0) + excludedNonDns = new List(); + + // "type|value" keys of entries that came from the CSR fallback, not the gateway + // dictionary — used only to word the provenance log accurately once the final, + // possibly-filtered result is known (see below). + var result = CollectRequestedSanEntries(san, csr, out var fromCsrKeys, out var skippedCsrTags); + + // Issue 0040 follow-up: email SAN values masked unless LogSensitiveRequestData is on. + string FormatSans(IEnumerable sans) => + LogSanitizer.FormatSans(sans, _config.LogSensitiveRequestData); + + if (skippedCsrTags.Count > 0) + { + // GeneralName types with no domain-name rendering (otherName — e.g. a UPN from a + // Windows-generated CSR — directoryName, x400Address, ediPartyName, registeredID). + // They cannot be expressed in additionalDomains, so they are not forwarded. Warn + // rather than drop silently: the operator needs to know the CSR asked for something + // the certificate will not carry. + _logger.LogWarning( + "{Count} SAN(s) in the CSR use a type that cannot be represented as a domain name " + + "and were not submitted (ASN.1 GeneralName tag(s): {Tags}). CERTInext's " + + "additionalDomains field carries domain names only, so these cannot appear on the " + + "issued certificate. Remove them from the CSR if they are required. Subject={Subject}", + skippedCsrTags.Count, string.Join(", ", skippedCsrTags), LogSanitizer.Strip(subject)); + } + + if (result.Count == 0) + { + _logger.LogDebug( + "No SANs supplied by the gateway and none found in the CSR — submitting the order " + + "with domainName only. Subject={Subject}", LogSanitizer.Strip(subject)); return null; + } + + // CERTInext's certificateInformation.additionalDomains is a domain-name field, and + // non-DNS SANs are submitted into it deliberately rather than discarded: dropping + // them would issue a certificate silently missing names the subscriber asked for, + // which is the worse failure. + // + // Measured on the US SANDBOX only (SanSubmissionProbeTests, product 844, + // 2026-08-12): CERTInext did NOT reject these at order placement. It accepted the + // order and registered the value verbatim as an order domain — an email address, an + // IP literal and a URI all came back as domainVerification keys. The order then + // cannot pass domain validation, so it parks pending instead of failing fast. + // + // Production is UNVERIFIED for this case and may reject the order outright instead. + // The warning below therefore describes the sandbox outcome as the expected one + // without promising it: either way the operator is told which SANs are the problem, + // which is the part that matters for diagnosis. + // + // This filtering runs BEFORE any of the logging below, and all of that logging is + // computed from `result` as it stands afterward — not from the pre-filter set. A + // prior version of this method logged "resolved" and "added to the order" against the + // pre-filter set and only THEN applied this filter, so with SubmitNonDnsSans=false the + // audit trail could claim a SAN was added when it had in fact just been dropped two + // lines later — a self-contradicting record for the same enrollment. The fix is + // ordering, not new logic: decide what is actually being submitted first, describe + // that. + var nonDns = result.Where(s => !string.Equals(s.Type, "dns", StringComparison.OrdinalIgnoreCase)).ToList(); + + if (dnsOnly) + { + // DNS-only caller (V2 SSL UCC additionalDomains, issue 0046): non-DNS SANs can + // never reach the wire there, whatever SubmitNonDnsSans says, so exclude them + // here — before the logging below — and leave the wording to the caller, which + // knows which field they were excluded from. No V1-worded warning is raised. + excludedNonDns = nonDns; + result = result + .Where(s => string.Equals(s.Type, "dns", StringComparison.OrdinalIgnoreCase)) + .ToList(); + nonDns = new List(); + + if (result.Count == 0) + return null; + } + else if (nonDns.Count > 0 && !_config.SubmitNonDnsSans) + { + _logger.LogWarning( + "{Count} requested SAN(s) are not DNS names and are being DROPPED because " + + "SubmitNonDnsSans is false: {Sans}. The order will issue, but the certificate will " + + "NOT contain these names. Set SubmitNonDnsSans back to true to submit them and have " + + "CERTInext surface the problem instead. Subject={Subject}", + nonDns.Count, FormatSans(nonDns), LogSanitizer.Strip(subject)); + + result = result + .Where(s => string.Equals(s.Type, "dns", StringComparison.OrdinalIgnoreCase)) + .ToList(); + nonDns = new List(); + + if (result.Count == 0) + return null; + } + + // The blind spot that hid the original defect was that nothing logged what we + // resolved. Log the final, post-filter resolved set and its provenance at Information. + int fromCsrKept = result.Count(s => fromCsrKeys.Contains($"{s.Type}|{s.Value}")); + // Post-filter, not the pre-filter `fromGateway` snapshot: gateway- and CSR-sourced + // entries are mutually exclusive by construction (the CSR fallback only ever runs when + // the gateway supplied nothing at all), so whatever's left in `result` and isn't + // fromCsrKept must be gateway-sourced. Using the pre-filter count here reproduced the + // exact self-contradicting-audit-trail bug this method was already restructured once to + // fix — with SubmitNonDnsSans=false this line could read e.g. "Resolved 1 SAN(s) ... + // FromGatewayRequest=3", an arithmetic impossibility for anyone reconciling counts. + int fromGatewayKept = result.Count - fromCsrKept; + _logger.LogInformation( + "Resolved {Total} SAN(s) for submission. FromGatewayRequest={FromGateway}, " + + "AddedFromCsrFallback={FromCsr}, Sans={Sans}, Subject={Subject}", + result.Count, fromGatewayKept, fromCsrKept, FormatSans(result), + LogSanitizer.Strip(subject)); + + if (fromCsrKept > 0) + { + // Worth a Warning, not Debug: it means Command handed us no SAN data at all for + // this enrollment, which is a gateway/template wiring smell even though the CSR + // fallback recovers it here. + _logger.LogWarning( + "Command supplied no SAN data for this enrollment; {Count} SAN(s) present in the CSR " + + "have been added to the order instead. Review the enrollment pattern / template SAN " + + "configuration. Subject={Subject}", + fromCsrKept, LogSanitizer.Strip(subject)); + } + + if (nonDns.Count > 0) + { + // Reaching this line means dnsOnly is false and SubmitNonDnsSans is true (both + // other cases emptied nonDns above), so these are being submitted, not dropped. + _logger.LogWarning( + "{Count} requested SAN(s) are not DNS names: {Sans}. CERTInext's additionalDomains " + + "field takes domain names, so this order will either be rejected outright or be " + + "created and then fail domain validation and sit pending — on the US sandbox it was " + + "accepted verbatim and parked pending. They are submitted rather than dropped on " + + "purpose: a visible failure is preferable to a certificate issued without names the " + + "subscriber requested. Remove them from the CSR or the enrollment pattern if the " + + "order should proceed. Subject={Subject}", + nonDns.Count, FormatSans(nonDns), LogSanitizer.Strip(subject)); + } + + return result; + } + /// + /// The SAN source rule shared by and + /// (issue 0033 extracted it verbatim from + /// so both honour it identically): the gateway-supplied SAN + /// dictionary (types normalized by ), falling back to the CSR's own + /// subjectAltName extension only when the dictionary is itself null — see + /// for why this is a fallback, not a union. Entries are trimmed + /// and de-duplicated by type+value. No filtering and no logging happens here. + /// + /// "type|value" keys of the entries that came from the CSR fallback. + /// GeneralName tags present in the CSR that have no string rendering. + private static List CollectRequestedSanEntries( + Dictionary san, string csr, + out HashSet fromCsrKeys, out List skippedCsrTags) + { var result = new List(); + // Type+value identity, so the same name requested as two different SAN types is + // preserved while an exact repeat across the two sources collapses. + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var csrKeys = new HashSet(StringComparer.OrdinalIgnoreCase); - // AnyCA passes SANs keyed by type name (e.g. "Dns", "Ip", "Email", "Uri") - foreach (var kvp in san) + void Add(string type, string value, bool fromCsr = false) { - string sanType = MapSanType(kvp.Key); - if (kvp.Value == null) continue; + if (string.IsNullOrWhiteSpace(value)) return; + string trimmed = value.Trim(); + string key = $"{type}|{trimmed}"; + if (!seen.Add(key)) return; + result.Add(new SanEntry { Type = type, Value = trimmed }); + if (fromCsr) csrKeys.Add(key); + } - foreach (string value in kvp.Value) + // AnyCA passes SANs keyed by type name — the real gateway uses "dnsname", + // "rfc822name", "ipaddress"; MapSanType normalizes the spelling variants. + if (san != null) + { + foreach (var kvp in san) { - if (!string.IsNullOrWhiteSpace(value)) - result.Add(new SanEntry { Type = sanType, Value = value.Trim() }); + string sanType = MapSanType(kvp.Key); + if (kvp.Value == null) continue; + + foreach (string value in kvp.Value) + Add(sanType, value); } } - return result.Count > 0 ? result : null; + // CSR fallback — only when the gateway dictionary is itself absent (san == null), NOT + // merely "computed to zero SAN entries" (i.e. result.Count == 0 at this point). Those + // are different things: + // a non-null dictionary — even an empty one, or one whose keys all map to empty arrays + // — means Command's enrollment pattern ran and deliberately produced no SANs for this + // request, which the CSR fallback must respect rather than override. san == null means + // Command never populated SAN data for this enrollment path at all, which is the one + // case this fallback exists for. Checking "computed to zero" instead of "san is null" + // would let an enrollment pattern that explicitly computes zero SANs still have + // CSR-derived names spliced back in — reopening the policy-reintroduction risk the + // fallback-over-union redesign exists to close. + skippedCsrTags = new List(); + if (san == null) + { + var csrSans = ExtractSanEntriesFromCsr(csr, out skippedCsrTags); + foreach (var csrSan in csrSans) + Add(csrSan.Type, csrSan.Value, fromCsr: true); + } + + fromCsrKeys = csrKeys; + return result; + } + + /// + /// Issue 0033: resolves the additionalHosts list for a V2 private-pki order. Spec + /// ("Private PKI Certificates" -> Create - Intranet SSL): "additionalHosts[] - SAN + /// list (DNS names or IPv4 / IPv6)"; the example body sends + /// ["portal.acme.local", "reports.acme.local", "10.0.0.50"] with the primary host in + /// hostname, not repeated here. + /// + /// - Source: — the same gateway-dictionary-with- + /// CSR-fallback rule the SSL UCC path uses via . + /// - DNS and IP SANs are both submitted. Unlike SSL's FQDN-only additionalDomains, + /// IP literals are native to this field, so the V1-era SubmitNonDnsSans switch + /// (whose purpose is keeping SANs CERTInext cannot validate out of a domain-name field) + /// is deliberately not consulted — dropping a requested IP SAN here would silently issue + /// a certificate without it. + /// - Email/URI SANs (and CSR GeneralName types with no string form) have no place in a + /// host list: excluded with a warning that names only their types (an email SAN value is + /// personal data). + /// - The primary is excluded; duplicates collapse + /// case-insensitively. Returns null (field omitted) when nothing remains. + /// + private List BuildPrivatePkiAdditionalHosts( + Dictionary san, string csr, string subject, string hostname) + { + var requested = CollectRequestedSanEntries(san, csr, out var fromCsrKeys, out var skippedCsrTags); + + if (skippedCsrTags.Count > 0) + { + _logger.LogWarning( + "{Count} SAN(s) in the CSR use a type that cannot be represented as a host name or IP " + + "address and were not submitted (ASN.1 GeneralName tag(s): {Tags}). V2 private-pki " + + "additionalHosts carries DNS names and IPv4/IPv6 addresses only, so these cannot appear " + + "on the issued certificate. Subject={Subject}", + skippedCsrTags.Count, string.Join(", ", skippedCsrTags), LogSanitizer.Strip(subject)); + } + + bool IsHostType(SanEntry s) => + string.Equals(s.Type, "dns", StringComparison.OrdinalIgnoreCase) + || string.Equals(s.Type, "ip", StringComparison.OrdinalIgnoreCase); + + var unsupported = requested.Where(s => !IsHostType(s)).ToList(); + if (unsupported.Count > 0) + { + _logger.LogWarning( + "EnrollV2Async: {Count} requested SAN(s) are neither DNS names nor IP addresses and cannot " + + "be submitted via V2 private-pki additionalHosts (DNS names or IPv4/IPv6 only) — they will " + + "NOT appear on the issued certificate. Types=[{Types}]", + unsupported.Count, string.Join(", ", unsupported.Select(s => s.Type))); + } + + var hosts = requested + .Where(IsHostType) + .Select(s => s.Value?.Trim()) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Where(v => !string.Equals(v, hostname, StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + // Gateway- and CSR-sourced entries are mutually exclusive by construction (the CSR is + // only read when the gateway dictionary is null), so when any entry came from the CSR, + // every surviving host did. Same wiring-smell warning BuildSanList raises for SSL. + if (fromCsrKeys.Count > 0 && hosts.Count > 0) + { + _logger.LogWarning( + "Command supplied no SAN data for this enrollment; {Count} SAN(s) present in the CSR " + + "have been added to the private-pki order's additionalHosts instead. Review the " + + "enrollment pattern / template SAN configuration. Subject={Subject}", + hosts.Count, LogSanitizer.Strip(subject)); + } + + return hosts.Count == 0 ? null : hosts; } private static string MapSanType(string anyCAType) { switch (anyCAType?.ToLowerInvariant()) { - case "dns": return "dns"; + // "dnsname" is what the AnyCA REST Gateway actually sends; "dns"/"dnsnames" + // are kept for callers and older hosts that use the shorter spelling. + case "dns": + case "dnsname": + case "dnsnames": return "dns"; case "ip": - case "ipaddress": return "ip"; + case "ipaddress": + case "ipaddresses": return "ip"; case "email": - case "rfc822": return "email"; - case "uri": return "uri"; + case "rfc822": + case "rfc822name": return "email"; + case "uri": + case "uniformresourceidentifier": return "uri"; default: return anyCAType?.ToLowerInvariant() ?? "dns"; } } + /// + /// Extracts the subjectAltName entries from a PEM-encoded PKCS#10 CSR. + /// + /// Implemented with BouncyCastle (per the project's crypto policy: all certificate + /// and key handling goes through BouncyCastle, never BCL System.Security.Cryptography). + /// Never throws — an absent, truncated, or otherwise unparseable CSR returns an empty + /// list so enrollment continues on the gateway-supplied SAN data alone. + /// + /// PEM-encoded PKCS#10 request, or null/garbage. + /// + /// ASN.1 GeneralName tag numbers present in the CSR that have no domain-name rendering and + /// were therefore not returned (otherName, directoryName, x400Address, ediPartyName, + /// registeredID, and any malformed IPAddress). Reported so the caller can warn instead of + /// dropping them silently. + /// + private static List ExtractSanEntriesFromCsr(string csrPem, out List skippedTagNumbers) + { + var result = new List(); + skippedTagNumbers = new List(); + if (string.IsNullOrWhiteSpace(csrPem)) + return result; + + try + { + string b64 = csrPem + .Replace("-----BEGIN CERTIFICATE REQUEST-----", string.Empty) + .Replace("-----END CERTIFICATE REQUEST-----", string.Empty) + .Replace("-----BEGIN NEW CERTIFICATE REQUEST-----", string.Empty) + .Replace("-----END NEW CERTIFICATE REQUEST-----", string.Empty) + .Replace("\r", string.Empty) + .Replace("\n", string.Empty) + .Trim(); + + if (string.IsNullOrWhiteSpace(b64)) + return result; + + var csr = new Org.BouncyCastle.Pkcs.Pkcs10CertificationRequest(Convert.FromBase64String(b64)); + + // SANs live in the PKCS#9 extensionRequest attribute, not the CSR body. + var extensions = csr.GetRequestedExtensions(); + var sanExtension = extensions?.GetExtension( + Org.BouncyCastle.Asn1.X509.X509Extensions.SubjectAlternativeName); + if (sanExtension == null) + return result; + + var names = Org.BouncyCastle.Asn1.X509.GeneralNames.GetInstance(sanExtension.GetParsedValue()); + foreach (var generalName in names.GetNames()) + { + var entry = GeneralNameToSanEntry(generalName); + if (entry != null) + result.Add(entry); + else + skippedTagNumbers.Add(generalName.TagNo); + } + } + catch (Exception ex) + { + // Enrollment must not fail because we could not read the CSR's SANs — the + // gateway-supplied set still applies, and CERTInext validates the CSR itself. + // Debug so an operator diagnosing a missing SAN can see the parse was skipped. + LogHandler.GetClassLogger(typeof(CERTInextCAPlugin)) + .LogDebug(ex, "ExtractSanEntriesFromCsr suppressed CSR parse failure"); + } + + return result; + } + + /// + /// Maps a GeneralName to the this plugin would submit for it, or null + /// for a name whose value cannot be rendered meaningfully — skipped rather than submitted as + /// ASN.1 debris. One switch, not two: a separate tag→type mapping alongside this one used to + /// assign a type string ("directoryname", "registeredid", ...) to tags that always return a + /// null value here anyway, so those branches were dead — the type never reached a caller + /// with no value to pair it with. + /// + private static SanEntry GeneralNameToSanEntry(Org.BouncyCastle.Asn1.X509.GeneralName generalName) + { + string type; + string value; + + switch (generalName.TagNo) + { + case Org.BouncyCastle.Asn1.X509.GeneralName.DnsName: + type = "dns"; + value = Org.BouncyCastle.Asn1.DerIA5String.GetInstance(generalName.Name).GetString(); + break; + + case Org.BouncyCastle.Asn1.X509.GeneralName.Rfc822Name: + type = "email"; + value = Org.BouncyCastle.Asn1.DerIA5String.GetInstance(generalName.Name).GetString(); + break; + + case Org.BouncyCastle.Asn1.X509.GeneralName.UniformResourceIdentifier: + type = "uri"; + value = Org.BouncyCastle.Asn1.DerIA5String.GetInstance(generalName.Name).GetString(); + break; + + case Org.BouncyCastle.Asn1.X509.GeneralName.IPAddress: + type = "ip"; + // Octet string → dotted-quad / RFC 5952 text, so what we submit and log is + // the address the subscriber asked for rather than its hex encoding. + byte[] octets = Org.BouncyCastle.Asn1.Asn1OctetString.GetInstance(generalName.Name).GetOctets(); + value = octets.Length == 4 || octets.Length == 16 + ? new System.Net.IPAddress(octets).ToString() + : null; + break; + + default: + // otherName, directoryName, x400Address, ediPartyName, registeredID. + // + // Deliberately null, not Name.ToString(). BouncyCastle renders these as an + // ASN.1 dump — a UPN otherName from a Windows-generated CSR stringifies to + // "[1.3.6.1.4.1.311.20.2.3, [CONTEXT 0]svc@corp.example.com]" and a + // directoryName to "CN=host.example.com,O=Acme". Submitting that as an entry in + // additionalDomains is not "forwarding the name the subscriber asked for" — it + // is putting ASN.1 debris in a domain-name field, which cannot become a + // certificate SAN under any circumstances and only breaks the order. That is + // different from a well-formed non-DNS SAN (IP/email/URI), which we do submit + // on purpose so nothing the subscriber requested is dropped silently. + // + // Skipped is not silent: BuildSanList warns with the tag numbers so the + // operator can see a SAN was present and not forwarded. + type = null; + value = null; + break; + } + + return string.IsNullOrWhiteSpace(value) ? null : new SanEntry { Type = type, Value = value }; + } + private static string GetStringValue( Dictionary dict, string key, string defaultValue = "") { @@ -969,31 +6397,85 @@ private static string GetStringValue( } /// - /// Extracts the X.509 serial number from a PEM-encoded certificate for inclusion - /// in audit log entries. Returns "(parse-error)" rather than throwing, so that a - /// logging failure never suppresses an audit record. + /// Validates that is an absolute URI using https, or http only + /// when the host is loopback (localhost/127.0.0.1/::1). Shared by every config field + /// that receives a credential on every outbound request — currently ApiUrl + /// (, ) and, in V1 OAuth + /// mode, OAuthTokenUrl (same two call sites) — so the http-cleartext rule can + /// never drift between connection-test time and actual startup. Does NOT check for + /// null/empty; callers that need a distinct "is required" message should check that + /// first and only call this helper once the value is known to be non-blank. + /// + /// null when passes; otherwise an actionable + /// error message naming . + private static string ValidateHttpsOrLoopbackUrl(string fieldName, string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out Uri parsed)) + return $"'{fieldName}' is not a valid absolute URI."; + + if (!string.Equals(parsed.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + && !parsed.IsLoopback) + { + return $"'{fieldName}' must use https — credentials (the OAuth client secret or API " + + "key) are sent to this URL on every request, and http would transmit them in " + + "cleartext. http is only allowed for a loopback host (localhost/127.0.0.1/::1)."; + } + + return null; + } + + /// + /// Extracts the X.509 serial number of the *leaf* (first) certificate from a + /// PEM-encoded certificate — or from a PEM chain — for inclusion in audit log + /// entries. Returns "(parse-error)" rather than throwing, so that a logging + /// failure never suppresses an audit record. + /// + /// V2 enrollment/sync pass this a full chain PEM (: + /// leaf followed by intermediate blocks). Decoding that as a single base64 blob is + /// wrong on two counts: each PEM block carries its own base64 padding, so a '=' + /// from the leaf block lands mid-string once concatenated (Convert.FromBase64String + /// throws FormatException), and even if it didn't, the leaf and intermediate DER + /// would be mashed into one invalid ASN.1 structure. Reading block-by-block via + /// BouncyCastle's PemReader and stopping after the first block sidesteps both: + /// it returns exactly the leaf block's own decoded DER bytes, ignoring the rest. + /// + /// Implemented with BouncyCastle (per the project's crypto policy: all certificate + /// and key handling goes through BouncyCastle, never BCL System.Security.Cryptography). /// private static string ExtractSerialFromPem(string pem) { try { - // Strip PEM headers and decode the DER bytes - string b64 = pem - .Replace("-----BEGIN CERTIFICATE-----", string.Empty) - .Replace("-----END CERTIFICATE-----", string.Empty) - .Replace("\r", string.Empty) - .Replace("\n", string.Empty) - .Trim(); - - if (string.IsNullOrWhiteSpace(b64)) + if (string.IsNullOrWhiteSpace(pem)) return "(empty-pem)"; - byte[] der = Convert.FromBase64String(b64); - using var cert = new System.Security.Cryptography.X509Certificates.X509Certificate2(der); - return cert.SerialNumber; + using var stringReader = new System.IO.StringReader(pem); + var pemReader = new Org.BouncyCastle.Utilities.IO.Pem.PemReader(stringReader); + var pemObject = pemReader.ReadPemObject(); + if (pemObject == null) + return "(parse-error)"; // no "-----BEGIN"/"-----END" block found at all + if (pemObject.Content == null || pemObject.Content.Length == 0) + return "(empty-pem)"; // block markers present but body is empty + + var parser = new Org.BouncyCastle.X509.X509CertificateParser(); + var cert = parser.ReadCertificate(pemObject.Content); + if (cert == null) + return "(parse-error)"; + // Match X509Certificate2.SerialNumber's format precisely: uppercase hex, + // byte-per-byte, *preserving* leading-zero bytes (e.g. serial bytes + // 0A 12 34 56 → "0A123456", not "A123456"). BouncyCastle's + // BigInteger.ToString(16) drops the leading-zero nibble, which would + // break audit-log correlation against Command's stored serial. Convert + // the unsigned-magnitude byte array to hex directly instead. + byte[] serialBytes = cert.SerialNumber.ToByteArrayUnsigned(); + return Convert.ToHexString(serialBytes).ToUpperInvariant(); } - catch + catch (Exception ex) { + // SOC2 CC7.2: never let audit-log generation throw, but log the suppression + // at Debug so an auditor diagnosing missing serial numbers can see the cause. + LogHandler.GetClassLogger(typeof(CERTInextCAPlugin)) + .LogDebug(ex, "ExtractSerialFromPem suppressed parse failure"); return "(parse-error)"; } } diff --git a/CERTInext/CERTInextCAPluginConfig.cs b/CERTInext/CERTInextCAPluginConfig.cs index acd3f81..e12ecd7 100644 --- a/CERTInext/CERTInextCAPluginConfig.cs +++ b/CERTInext/CERTInextCAPluginConfig.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 Keyfactor // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. // You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, @@ -46,10 +46,61 @@ public static Dictionary GetCAConnectorAnnotations() [Constants.Config.GroupNumber] = new PropertyConfigInfo { Comments = "OPTIONAL: CERTInext group (delegation) number. " + - "When set, it is included in GetProductDetails requests so the full " + - "product list is returned. Some sandbox accounts require this to avoid " + - "receiving an empty product list. Available in the CERTInext portal under " + - "Delegation → Groups.", + "When set, it is included in GetProductDetails requests AND in the " + + "`delegationInformation.groupNumber` field of every SSL order so the order " + + "is routed to the correct account group. Some accounts will queue orders for " + + "additional review when this field is omitted. " + + "Available in the CERTInext portal under Delegation → Groups.", + Hidden = false, + DefaultValue = string.Empty, + Type = "String" + }, + [Constants.Config.OrganizationNumber] = new PropertyConfigInfo + { + Comments = "STRONGLY RECOMMENDED for OV/EV and faster DV issuance: numeric " + + "CERTInext organization number for a pre-vetted organization (e.g. " + + "your company's pre-vetted entry). When set, every SSL order is submitted " + + "with `organizationDetails.preVetting=\"1\"` and the configured " + + "`organizationNumber`, telling CERTInext to skip the manual " + + "organization-vetting queue. Without this value, orders are placed without " + + "any organizationDetails block and CERTInext may park them in " + + "`Pending System RA` for extended manual review (observed: tens of hours). " + + "Available in the CERTInext portal under Organizations → " + + "Pre-vetted Organizations.", + Hidden = false, + DefaultValue = string.Empty, + Type = "String" + }, + [Constants.Config.TechnicalContactName] = new PropertyConfigInfo + { + Comments = "OPTIONAL: Name sent in the `technicalPointOfContact.tpcName` field of every " + + "SSL order. Defaults to the configured RequestorName when blank. " + + "Some product configurations require a TPoC to be present; omitting it can " + + "cause CERTInext to park orders awaiting manual completion of the field.", + Hidden = false, + DefaultValue = string.Empty, + Type = "String" + }, + [Constants.Config.TechnicalContactEmail] = new PropertyConfigInfo + { + Comments = "OPTIONAL: Email sent in the `technicalPointOfContact.tpcEmail` field of every " + + "SSL order. Defaults to the configured RequestorEmail when blank.", + Hidden = false, + DefaultValue = string.Empty, + Type = "String" + }, + [Constants.Config.TechnicalContactIsdCode] = new PropertyConfigInfo + { + Comments = "OPTIONAL: International dialing code for the TPoC phone number. " + + "Defaults to the configured RequestorIsdCode when blank.", + Hidden = false, + DefaultValue = string.Empty, + Type = "String" + }, + [Constants.Config.TechnicalContactMobileNumber] = new PropertyConfigInfo + { + Comments = "OPTIONAL: Mobile number for the TPoC (digits only). " + + "Defaults to the configured RequestorMobileNumber when blank.", Hidden = false, DefaultValue = string.Empty, Type = "String" @@ -124,9 +175,22 @@ public static Dictionary GetCAConnectorAnnotations() DefaultValue = string.Empty, Type = "String" }, + [Constants.Config.RequestorDesignation] = new PropertyConfigInfo + { + Comments = "OPTIONAL: Job title / role of the requestor (e.g. 'IT Administrator'). " + + "Sent in V2 orders' `requestor.designation` field. Free text with no CA-side " + + "enum. Left blank by default, in which case the field is omitted entirely " + + "from the order rather than sent with a default value.", + Hidden = false, + DefaultValue = string.Empty, + Type = "String" + }, [Constants.Config.SignerPlace] = new PropertyConfigInfo { - Comments = "City or location of the subscriber agreement signer. Required by CERTInext for all orders.", + Comments = "City or location of the subscriber agreement signer (e.g. 'San Francisco, CA'). " + + "REQUIRED when UseV2Api is on: the V2 Subscriber Agreement sent with every SSL order " + + "requires it, so the connector cannot be saved with it blank. A per-template " + + "SignerPlace enrollment parameter overrides it.", Hidden = false, DefaultValue = string.Empty, Type = "String" @@ -147,6 +211,61 @@ public static Dictionary GetCAConnectorAnnotations() DefaultValue = string.Empty, Type = "String" }, + [Constants.Config.AccountingModel] = new PropertyConfigInfo + { + Comments = "OPTIONAL: CERTInext billing model sent in `orderDetails.accountingModel`. " + + "\"2\" = credit-based (most accounts, default). \"1\" = cash model.", + Hidden = false, + DefaultValue = "2", + Type = "String" + }, + [Constants.Config.EmailNotifications] = new PropertyConfigInfo + { + Comments = "OPTIONAL: Whether CERTInext sends lifecycle-event emails to the requestor. " + + "\"1\" = full notification set (V1 sends it as-is; V2 maps it to \"all\"). " + + "\"0\" = silent on both V1 and V2 (V2 confirmed live 2026-09-28). Blank/unset " + + "stays silent on V1 (sent as \"0\") but is omitted on V2, so the CA's own " + + "default (\"all\", not silent) applies instead. Any other value fails V2 " + + "enrollment before any CA call. Default: \"0\" — V2 orders are now silent by " + + "default, matching V1 (previously V2 always sent \"all\").", + Hidden = false, + DefaultValue = "0", + Type = "String" + }, + [Constants.Config.SubscriptionValidityYears] = new PropertyConfigInfo + { + Comments = "OPTIONAL: Default validity in years for SSL orders. \"1\", \"2\", or \"3\". " + + "Override per template via the ValidityYears product parameter. Default: \"1\".", + Hidden = false, + DefaultValue = "1", + Type = "String" + }, + [Constants.Config.SubscriptionAutoRenew] = new PropertyConfigInfo + { + Comments = "OPTIONAL: Whether CERTInext should auto-renew certificates issued through " + + "this connector. \"0\" = disabled (recommended — renewal is driven by Keyfactor " + + "Command), \"1\" = enabled. Default: \"0\".", + Hidden = false, + DefaultValue = "0", + Type = "String" + }, + [Constants.Config.SubscriptionRenewCriteriaDays] = new PropertyConfigInfo + { + Comments = "OPTIONAL: Days before expiry at which CERTInext auto-renews (only honored when " + + "SubscriptionAutoRenew = \"1\"). Typical values: \"30\" or \"60\". Default: \"30\".", + Hidden = false, + DefaultValue = "30", + Type = "String" + }, + [Constants.Config.AutoSecureWww] = new PropertyConfigInfo + { + Comments = "OPTIONAL: If \"1\", CERTInext automatically adds the `www.` variant of the " + + "primary domain as an additional SAN. \"0\" = use only the CN/SANs supplied " + + "with the CSR. Default: \"0\".", + Hidden = false, + DefaultValue = "0", + Type = "String" + }, [Constants.Config.IgnoreExpired] = new PropertyConfigInfo { Comments = "If true, expired certificates will be skipped during synchronization. Default: false.", @@ -154,6 +273,19 @@ public static Dictionary GetCAConnectorAnnotations() DefaultValue = false, Type = "Boolean" }, + [Constants.Config.SubmitNonDnsSans] = new PropertyConfigInfo + { + Comments = "If true (default), SANs that are not DNS names (IP address, email, URI) are " + + "submitted to CERTInext in additionalDomains along with the DNS names. CERTInext " + + "registers them verbatim as order domains and they cannot pass domain validation, " + + "so such an order will not issue until they are removed — but nothing the " + + "subscriber requested is dropped silently. Set to false to submit DNS names only, " + + "which restores the pre-1.0.1 behaviour: the order issues, but the certificate " + + "will not contain the non-DNS names. Default: true.", + Hidden = false, + DefaultValue = true, + Type = "Boolean" + }, [Constants.Config.PageSize] = new PropertyConfigInfo { Comments = "Number of orders to fetch per page during synchronization. " + @@ -169,6 +301,165 @@ public static Dictionary GetCAConnectorAnnotations() Hidden = false, DefaultValue = true, Type = "Boolean" + }, + [Constants.Config.LogSensitiveRequestData] = new PropertyConfigInfo + { + Comments = "OPTIONAL diagnostic escape hatch. When true, enabling it writes requestor " + + "personal data (name, email, phone, and other organization contact details) " + + "AND full CA request/response payloads to the gateway logs: the Trace-level " + + "request/response bodies logged for every CA call are left unredacted (beyond " + + "the credential scrubbing that always applies), and the Information-level " + + "enrollment-attempt log line includes the requestor's name and email in full. " + + "This is meant for temporary use while verifying a new deployment — confirming " + + "exactly what was sent to the CA and that the order succeeded — and should be " + + "turned back off once verification is complete. When false (default), personal " + + "data fields are redacted to '***REDACTED***' (email is masked but keeps its " + + "domain, e.g. 'j***@example.com') and the enrollment log line omits the " + + "requester name entirely. Credentials (API keys, OAuth secrets, tokens) are " + + "always redacted regardless of this setting. Default: false.", + Hidden = false, + DefaultValue = false, + Type = "Boolean" + }, + [Constants.Config.PickupRetries] = new PropertyConfigInfo + { + Comments = "OPTIONAL: Number of times Enroll() will poll CERTInext to download the certificate after a " + + "successful order submission. If the certificate has not issued within this window it is " + + "picked up during the next synchronization instead. Set to 0 to disable the wait. " + + $"Default: {Constants.Pickup.DefaultRetries}. NOTE: CERTInext issues OV/EV certificates " + + "asynchronously (organization verification, minutes to hours), so those typically exhaust " + + "the wait and are returned pending regardless of this value.", + Hidden = false, + DefaultValue = Constants.Pickup.DefaultRetries, + Type = "Number" + }, + [Constants.Config.PickupDelay] = new PropertyConfigInfo + { + Comments = "OPTIONAL: Number of seconds between certificate-pickup retries. PickupRetries times this " + + "delay (plus a short initial delay) is the maximum time an enrollment call occupies a Command " + + "worker thread. If the duration is too long the request may time out, so target a total well " + + $"under ~90s. As a safety backstop the plugin additionally caps the effective total at " + + $"{Constants.Pickup.MaxTotalWaitSeconds}s regardless of how PickupRetries/PickupDelay are set, " + + $"reducing the retry count to fit. Default: {Constants.Pickup.DefaultDelaySeconds} " + + $"(with default retries this yields a ~{Constants.Pickup.InitialDelaySeconds + Constants.Pickup.DefaultRetries * Constants.Pickup.DefaultDelaySeconds}s ceiling).", + Hidden = false, + DefaultValue = Constants.Pickup.DefaultDelaySeconds, + Type = "Number" + }, + [Constants.Config.DcvEnabled] = new PropertyConfigInfo + { + Comments = "OPTIONAL: When true, the gateway will perform DNS-based Domain Control Validation (DCV) " + + "during enrollment for orders that require it, using the configured DNS provider plugin. " + + "Requires a DNS provider plugin (e.g. azure-azuredns-dnsplugin) to be deployed on the gateway. " + + "Default: false.", + Hidden = false, + DefaultValue = false, + Type = "Boolean" + }, + [Constants.Config.DcvTxtRecordTemplate] = new PropertyConfigInfo + { + Comments = "OPTIONAL: Format string for the DNS TXT record hostname used during DCV. " + + "{0} is replaced with the domain name being validated. " + + $"Default: {Constants.Dcv.DefaultTxtRecordTemplate}", + Hidden = false, + DefaultValue = Constants.Dcv.DefaultTxtRecordTemplate, + Type = "String" + }, + [Constants.Config.DcvPropagationDelaySeconds] = new PropertyConfigInfo + { + Comments = "OPTIONAL: Seconds to wait after publishing the DNS TXT record before asking CERTInext " + + "to verify it. Increase for zones with slow propagation. Default: 30.", + Hidden = false, + DefaultValue = 30, + Type = "Number" + }, + [Constants.Config.DcvTimeoutMinutes] = new PropertyConfigInfo + { + Comments = $"OPTIONAL: Maximum minutes to wait for the entire DCV flow (DNS publish + propagation + verify) " + + $"before timing out the enrollment. Can also be set via the {Constants.Config.DcvTimeoutMinutesEnvVar} " + + $"environment variable; the env var takes precedence when both are set. Default: 10.", + Hidden = false, + DefaultValue = 10, + Type = "Number" + }, + [Constants.Config.DcvWaitForChallengeSeconds] = new PropertyConfigInfo + { + Comments = "OPTIONAL: How long (seconds) the plugin will wait inside Enroll() for CERTInext to " + + "expose the DCV challenge (i.e. populate `domainVerification` in TrackOrder). Under " + + "concurrent load CERTInext sometimes takes a few seconds after GenerateOrderSSL " + + "before the slot appears. Without this wait, the plugin's initial TrackOrder check " + + "sees null and skips DCV — the order then has to wait for the next gateway sync " + + "cycle to be picked up. Setting to 0 disables the wait (single-check behaviour). " + + $"Can also be set via the {Constants.Config.DcvWaitForChallengeSecondsEnvVar} " + + "environment variable; the env var takes precedence when both are set. Default: 60.", + Hidden = false, + DefaultValue = 60, + Type = "Number" + }, + [Constants.Config.DcvWaitForIssuanceSeconds] = new PropertyConfigInfo + { + Comments = "OPTIONAL: How long (seconds) the plugin will wait inside Enroll() after DCV " + + "verifies for CERTInext to finish generating the certificate. CERTInext issuance " + + "is async — DCV may be verified but the cert PEM isn't yet available for download. " + + "Without this wait, Enroll() returns a pending result and the issued cert is " + + "picked up by the next sync cycle. Setting to 0 disables the wait (single-fetch " + + "behaviour). " + + $"Can also be set via the {Constants.Config.DcvWaitForIssuanceSecondsEnvVar} " + + "environment variable; the env var takes precedence when both are set. Default: 60.", + Hidden = false, + DefaultValue = 60, + Type = "Number" + }, + [Constants.Config.DcvSyncMaxOrderAgeHours] = new PropertyConfigInfo + { + Comments = "OPTIONAL: During synchronization, only pending DV orders younger than this many hours " + + "are eligible to be driven through DCV. This keeps a sync pass fast when there is a " + + "large backlog of old, never-completing pending orders (e.g. abandoned orders or domains " + + "outside the configured DNS provider's zone): they age out and are simply reported as " + + "pending rather than retried every pass. Recently-placed orders (the ones that legitimately " + + "deferred DCV) are always within the window and complete via the normal scan cadence. " + + $"Set to 0 to disable the age filter (attempt DCV for all pending). Default: {Constants.Dcv.DefaultSyncMaxOrderAgeHours}.", + Hidden = false, + DefaultValue = Constants.Dcv.DefaultSyncMaxOrderAgeHours, + Type = "Number" + }, + [Constants.Config.DcvSyncMaxPerPass] = new PropertyConfigInfo + { + Comments = "OPTIONAL: Maximum number of pending DV orders the plugin will attempt to drive through DCV " + + "in a single synchronization pass. Bounds the per-pass cost regardless of backlog size; " + + "remaining pending orders are reported as-is and picked up on a later pass (the per-minute " + + $"incremental scan keeps recent orders moving). Set to 0 to disable the cap. Default: {Constants.Dcv.DefaultSyncMaxPerPass}.", + Hidden = false, + DefaultValue = Constants.Dcv.DefaultSyncMaxPerPass, + Type = "Number" + }, + + // ----------------------------------------------------------------------- + // V2 API settings — only required when UseV2Api = true + // ----------------------------------------------------------------------- + + [Constants.ConfigV2.UseV2Api] = new PropertyConfigInfo + { + Comments = "OPTIONAL: When true, the plugin routes Enroll / GetSingleRecord / Revoke / Synchronize " + + "through the CERTInext V2 REST API (/api/certinext/v2/), including V2 " + + "/reports/orders for Synchronize. Requires ApiUrl (the V2 base URL in this mode) " + + "plus OAuthClientId and OAuthClientSecret. V1 credentials (ApiKey/AccountNumber/AuthMode) " + + "are not required when this is true. Default: false (V1 API).", + Hidden = false, + DefaultValue = false, + Type = "Boolean" + }, + [Constants.Config.V2SyncLookbackHours] = new PropertyConfigInfo + { + Comments = "OPTIONAL (V2 mode only): during an incremental Synchronize, the plugin queries V2 " + + "/reports/orders with a 'from' date of (lastSync minus this many hours) rather than " + + "exactly lastSync. Live probing could not confirm whether the API's from/to filter " + + "brackets order-placement date or issuance date (issues/0022); a lookback window " + + "ensures an order created before lastSync but issued afterward (e.g. a slow DCV order) " + + $"still surfaces on the next incremental pass. Ignored when UseV2Api is false. Default: {Constants.ApiV2.DefaultSyncLookbackHours}.", + Hidden = false, + DefaultValue = Constants.ApiV2.DefaultSyncLookbackHours, + Type = "Number" } }; } @@ -184,8 +475,11 @@ public static Dictionary GetTemplateParameterAnnotat [Constants.EnrollmentParam.ProductCode] = new PropertyConfigInfo { Comments = "OPTIONAL: Override the numeric CERTInext product code for this template. " + - "When omitted, the default production code for the selected product is used automatically " + - "(e.g. DV SSL → 838). Set this explicitly when targeting sandbox or a non-standard code.", + "When omitted: on the V1 API, the default production code for the selected product " + + "is used automatically; on the V2 API, the code is instead resolved live from the " + + "CERTInext product catalog by matching the selected product, so it stays correct " + + "even though V2 catalog numbering varies by account. Set this explicitly when " + + "targeting sandbox or a non-standard code.", Hidden = false, DefaultValue = string.Empty, Type = "String" @@ -217,8 +511,8 @@ public static Dictionary GetTemplateParameterAnnotat }, [Constants.EnrollmentParam.AutoApprove] = new PropertyConfigInfo { - Comments = "OPTIONAL: If true, the gateway will attempt automatic approval of certificates " + - "that are returned in a pending-approval state. Default: false.", + Comments = "Currently has no effect — reserved for future use. The plugin does not call " + + "any approval endpoint against CERTInext regardless of this setting.", Hidden = false, DefaultValue = false, Type = "Boolean" @@ -258,7 +552,7 @@ public static Dictionary GetTemplateParameterAnnotat }, [Constants.EnrollmentParam.DomainName] = new PropertyConfigInfo { - Comments = "OPTIONAL: Primary domain for SSL/TLS orders. " + + Comments = "OPTIONAL: Primary domain for SSL/TLS orders (for V2 private-pki orders, the primary hostname). " + "Derived from the CSR CN if omitted.", Hidden = false, DefaultValue = string.Empty, @@ -287,6 +581,32 @@ public static Dictionary GetTemplateParameterAnnotat Hidden = false, DefaultValue = string.Empty, Type = "String" + }, + + // ----------------------------------------------------------------------- + // V2 API enrollment parameters (only used when UseV2Api = true) + // ----------------------------------------------------------------------- + + [Constants.EnrollmentParam.ProductFamily] = new PropertyConfigInfo + { + Comments = "V2 API ONLY: Product family for this template. " + + "Accepted values: 'ssl' (default), 'private-pki', 'signature'. " + + "Maps to the corresponding V2 resource path (/api/certinext/v2/{family}-certificates/). " + + "'private-pki' requires an explicit ProductCode and a Private PKI ProductVariant. " + + "'signature' (Document Signer) enrollment is not yet supported.", + Hidden = false, + DefaultValue = "ssl", + Type = "String" + }, + [Constants.EnrollmentParam.ProductVariant] = new PropertyConfigInfo + { + Comments = "V2 API ONLY: Product variant sent in the V2 order body. " + + "ProductFamily 'ssl': 'dv' (default), 'ov', 'ev'. " + + "ProductFamily 'private-pki': 'intranet-ssl' or 'igtf-host' (required; no default). " + + "Must match the variant associated with the configured product code.", + Hidden = false, + DefaultValue = "dv", + Type = "String" } }; } @@ -318,12 +638,27 @@ public class CERTInextConfig /// /// Optional CERTInext group (delegation) number. When set, it is passed in /// the productDetails.groupNumber field of GetProductDetails - /// requests so that the account's full product list is returned. Some sandbox - /// accounts return an empty product list if this field is omitted. + /// requests AND in the delegationInformation.groupNumber field of every + /// SSL order body so the order is routed to the correct account group. Some + /// accounts queue orders for extra review when this field is omitted. /// [JsonPropertyName("GroupNumber")] public string GroupNumber { get; set; } = string.Empty; + /// + /// CERTInext organization number for a pre-vetted organization (e.g. the customer's + /// company). When set, every SSL order is submitted with + /// organizationDetails.preVetting="1" and the configured + /// organizationNumber, telling CERTInext to skip the manual organization + /// vetting queue. Strongly recommended for OV/EV products; significantly speeds + /// up DV issuance because CERTInext otherwise parks orders in Pending System RA + /// for extended manual review (observed tens of hours on the sandbox). + /// Empty by default — the plugin omits the organizationDetails block when + /// this is unset, preserving prior behavior. + /// + [JsonPropertyName("OrganizationNumber")] + public string OrganizationNumber { get; set; } = string.Empty; + // ----------------------------------------------------------------------- // Authentication // ----------------------------------------------------------------------- @@ -390,6 +725,14 @@ public class CERTInextConfig [JsonPropertyName("RequestorMobileNumber")] public string RequestorMobileNumber { get; set; } = string.Empty; + /// + /// Default requestor job title / role. Blank by default; when blank, the V2 order's + /// requestor.designation field is omitted rather than sent with any default value + /// (see issues/0027-v2-request-builder-drops-config-fields.md item 5e). + /// + [JsonPropertyName("RequestorDesignation")] + public string RequestorDesignation { get; set; } = string.Empty; + /// Subscriber agreement signer place (city/location). Required by CERTInext. [JsonPropertyName("SignerPlace")] public string SignerPlace { get; set; } = string.Empty; @@ -405,6 +748,61 @@ public class CERTInextConfig [JsonPropertyName("DefaultProductCode")] public string DefaultProductCode { get; set; } = string.Empty; + // ----------------------------------------------------------------------- + // Technical point-of-contact — populated into technicalPointOfContact on SSL orders. + // When any field is blank, the corresponding Requestor* default is used. + // ----------------------------------------------------------------------- + + /// Technical contact name. Defaults to when blank. + [JsonPropertyName("TechnicalContactName")] + public string TechnicalContactName { get; set; } = string.Empty; + + /// Technical contact email. Defaults to when blank. + [JsonPropertyName("TechnicalContactEmail")] + public string TechnicalContactEmail { get; set; } = string.Empty; + + /// Technical contact ISD code. Defaults to when blank. + [JsonPropertyName("TechnicalContactIsdCode")] + public string TechnicalContactIsdCode { get; set; } = string.Empty; + + /// Technical contact mobile number. Defaults to when blank. + [JsonPropertyName("TechnicalContactMobileNumber")] + public string TechnicalContactMobileNumber { get; set; } = string.Empty; + + // ----------------------------------------------------------------------- + // SSL order body defaults — every value matches a CERTInext-documented field + // and is overridable per-connector via the gateway admin UI. + // ----------------------------------------------------------------------- + + /// CERTInext billing model ("2" credit, "1" cash). Default "2". + [JsonPropertyName("AccountingModel")] + public string AccountingModel { get; set; } = "2"; + + /// + /// "1" = full notification set (V1 sends it as-is; V2 maps to "all"). "0" = silent on + /// both V1 and V2 (default; V2 confirmed live 2026-09-28). Blank stays silent on V1 (sent + /// as "0") but is omitted on V2, letting the CA's own default ("all") apply instead. Any + /// other value fails V2 enrollment before any CA call. See issue 0027 item 1a. + /// + [JsonPropertyName("EmailNotifications")] + public string EmailNotifications { get; set; } = "0"; + + /// Default validity in years sent in subscriptionDetails. "1", "2", or "3". Default "1". + [JsonPropertyName("SubscriptionValidityYears")] + public string SubscriptionValidityYears { get; set; } = "1"; + + /// "0" = disable CERTInext-side auto-renew (recommended — renewal is driven by Command). "1" = enable. + [JsonPropertyName("SubscriptionAutoRenew")] + public string SubscriptionAutoRenew { get; set; } = "0"; + + /// Days before expiry at which CERTInext auto-renews (only honored when SubscriptionAutoRenew="1"). + [JsonPropertyName("SubscriptionRenewCriteriaDays")] + public string SubscriptionRenewCriteriaDays { get; set; } = "30"; + + /// "1" = let CERTInext auto-add the www. variant, "0" = use only the supplied CN/SANs (default). + [JsonPropertyName("AutoSecureWww")] + public string AutoSecureWww { get; set; } = "0"; + // ----------------------------------------------------------------------- // Sync / behaviour // ----------------------------------------------------------------------- @@ -412,10 +810,214 @@ public class CERTInextConfig [JsonPropertyName("IgnoreExpired")] public bool IgnoreExpired { get; set; } = false; + /// + /// Whether non-DNS SANs (IP address, email, URI) are submitted to CERTInext. + /// + /// Defaults to true: nothing the subscriber requested is dropped silently. CERTInext + /// registers such values verbatim as order domains, and they cannot pass domain validation, + /// so the order will not issue until they are removed — a visible failure, deliberately + /// preferred over a certificate quietly missing requested names. + /// + /// Set to false to submit DNS names only, restoring the pre-1.0.1 behaviour where the + /// order issues but the non-DNS names are absent from the certificate. This exists as an + /// upgrade escape hatch: on a host that was issuing certificates for requests carrying an IP + /// or email SAN, the default flips those enrollments from "issues (incomplete)" to "parks + /// pending", and an operator needs a way back that does not involve downgrading the plugin. + /// + [JsonPropertyName("SubmitNonDnsSans")] + public bool SubmitNonDnsSans { get; set; } = true; + [JsonPropertyName("PageSize")] public int PageSize { get; set; } = Constants.Api.DefaultPageSize; [JsonPropertyName("Enabled")] public bool Enabled { get; set; } = true; + + /// + /// OPTIONAL diagnostic escape hatch. When true, full CA request/response payloads are + /// logged at Trace (beyond the credential scrubbing that always applies), and the + /// enrollment-attempt Information log line includes the requestor's name and email in + /// full. This writes personal data belonging to whoever placed the order — name, email, + /// phone, and other organization contact fields — plus complete CA request/response + /// bodies into the gateway's log files. Intended only for temporary use while verifying + /// a new deployment (confirming exactly what was sent to the CA and that the order + /// succeeded); turn it back off once verification is complete. When false (default), + /// personal-data fields are replaced with "***REDACTED***" (email values are masked but + /// keep their domain, e.g. "j***@example.com") and the enrollment log line omits the + /// requester name entirely. Credentials (API keys, OAuth secrets, tokens) are always + /// redacted regardless of this setting. Default: false. + /// + [JsonPropertyName("LogSensitiveRequestData")] + public bool LogSensitiveRequestData { get; set; } = false; + + // ----------------------------------------------------------------------- + // DCV — domain control validation via DNS provider plugins + // ----------------------------------------------------------------------- + + /// + /// When true, the plugin will run DNS DCV for orders that require it during enrollment. + /// Requires IDomainValidatorFactory to be injected by the gateway (available from + /// IAnyCAPlugin 3.3.0-prerelease). Default: false. + /// + [JsonPropertyName("DcvEnabled")] + public bool DcvEnabled { get; set; } = false; + + /// + /// Format string for the TXT record hostname. {0} is replaced with the domain. + /// Default: _emsign-validation.{0}. + /// + [JsonPropertyName("DcvTxtRecordTemplate")] + public string DcvTxtRecordTemplate { get; set; } = Constants.Dcv.DefaultTxtRecordTemplate; + + /// + /// Seconds to wait after publishing the DNS TXT record before calling VerifyDcv. + /// Default: 30. + /// + /// + /// Number of GetCertificate poll attempts inside Enroll() after an order is + /// submitted, before falling back to a pending result (picked up by the next sync). + /// Mirrors the legacy Sectigo connector's PickupRetries. Set to 0 to disable. + /// Default: 5. + /// + [JsonPropertyName("PickupRetries")] + public int PickupRetries { get; set; } = Constants.Pickup.DefaultRetries; + + /// + /// Seconds between certificate-pickup retries. PickupRetries * PickupDelay (plus a + /// short initial delay) bounds the time an enrollment call occupies a Command worker + /// thread. Mirrors the legacy Sectigo connector's PickupDelay. Default: 10. + /// + [JsonPropertyName("PickupDelay")] + public int PickupDelayInSeconds { get; set; } = Constants.Pickup.DefaultDelaySeconds; + + [JsonPropertyName("DcvPropagationDelaySeconds")] + public int DcvPropagationDelaySeconds { get; set; } = 30; + + /// + /// Maximum minutes for the entire DCV flow before the enrollment is cancelled. + /// Overridden by the CERTINEXT_DCV_TIMEOUT_MINUTES environment variable when set. + /// Default: 10. + /// + [JsonPropertyName("DcvTimeoutMinutes")] + public int DcvTimeoutMinutes { get; set; } = 10; + + /// + /// Seconds the plugin will poll inside Enroll() waiting for CERTInext to populate + /// domainVerification in TrackOrder. Under concurrent load the slot can + /// take a few seconds to appear after GenerateOrderSSL returns; without this + /// wait the plugin's initial single-shot check sees null and skips DCV. + /// Set to 0 to disable the wait (preserving the single-check behaviour). + /// Overridden by CERTINEXT_DCV_WAIT_FOR_CHALLENGE_SECONDS when set. Default: 60. + /// + [JsonPropertyName("DcvWaitForChallengeSeconds")] + public int DcvWaitForChallengeSeconds { get; set; } = 60; + + /// + /// Seconds the plugin will poll GetCertificate inside Enroll() after DCV + /// verifies, waiting for CERTInext to finish generating the certificate. CERTInext + /// issuance is async — DCV may be verified but the cert PEM isn't yet available. + /// Set to 0 to disable the wait (preserving the single-fetch behaviour, where + /// the cert is picked up on the next sync cycle). Overridden by + /// CERTINEXT_DCV_WAIT_FOR_ISSUANCE_SECONDS when set. Default: 60. + /// + [JsonPropertyName("DcvWaitForIssuanceSeconds")] + public int DcvWaitForIssuanceSeconds { get; set; } = 60; + + /// + /// During synchronization, only pending DV orders younger than this many hours are + /// eligible for DCV completion. Bounds a sync pass against a large backlog of old, + /// never-completing pending orders (issue 0002). 0 disables the age filter. + /// Default: 24. + /// + [JsonPropertyName("DcvSyncMaxOrderAgeHours")] + public int DcvSyncMaxOrderAgeHours { get; set; } = Constants.Dcv.DefaultSyncMaxOrderAgeHours; + + /// + /// Maximum number of pending DV orders the plugin attempts to drive through DCV in a + /// single sync pass (issue 0002). Bounds per-pass cost regardless of backlog size; the + /// remainder are reported pending and revisited on a later pass. 0 disables the cap. + /// Default: 50. + /// + [JsonPropertyName("DcvSyncMaxPerPass")] + public int DcvSyncMaxPerPass { get; set; } = Constants.Dcv.DefaultSyncMaxPerPass; + + // ----------------------------------------------------------------------- + // V2 API settings + // ----------------------------------------------------------------------- + + /// + /// When true, Enroll / GetSingleRecord / Revoke / Synchronize use the CERTInext V2 REST + /// API. In this mode is the V2 base URL (e.g. + /// https://sandbox-us-api.certinext.io, no trailing path suffix) and V2 OAuth2 auth reuses + /// / . V1-only credentials + /// (, , ) are not + /// required when this is true. Default: false. + /// + [JsonPropertyName("UseV2Api")] + public bool UseV2Api { get; set; } = false; + + /// + /// V2 mode only: during an incremental Synchronize, query V2 /reports/orders with a + /// 'from' date of (lastSync minus this many hours) rather than exactly lastSync — see + /// and issues/0022 for why (the + /// from/to filter's order-date-vs-issue-date semantics could not be confirmed live). + /// Ignored when is false. Default: 72. + /// + [JsonPropertyName("V2SyncLookbackHours")] + public int V2SyncLookbackHours { get; set; } = Constants.ApiV2.DefaultSyncLookbackHours; + + /// + /// Returns the effective DCV timeout, preferring the environment variable over the + /// config field so operators can adjust the ceiling without a connector reconfiguration. + /// + public int GetEffectiveDcvTimeoutMinutes() + { + var env = System.Environment.GetEnvironmentVariable(Constants.Config.DcvTimeoutMinutesEnvVar); + if (!string.IsNullOrEmpty(env) && int.TryParse(env, out int envVal) && envVal > 0) + return envVal; + return DcvTimeoutMinutes > 0 ? DcvTimeoutMinutes : 10; + } + + /// + /// Returns the effective wait for the DCV challenge to appear in TrackOrder, preferring + /// the env var so operators can tune without re-saving the connector. A value of 0 + /// (either field or env var) disables the wait entirely. + /// + public int GetEffectiveDcvWaitForChallengeSeconds() + { + var env = System.Environment.GetEnvironmentVariable(Constants.Config.DcvWaitForChallengeSecondsEnvVar); + if (!string.IsNullOrEmpty(env) && int.TryParse(env, out int envVal) && envVal >= 0) + return envVal; + return DcvWaitForChallengeSeconds >= 0 ? DcvWaitForChallengeSeconds : 60; + } + + /// + /// Returns the effective post-DCV wait for cert issuance, preferring the env var. + /// A value of 0 disables the wait. + /// + public int GetEffectiveDcvWaitForIssuanceSeconds() + { + var env = System.Environment.GetEnvironmentVariable(Constants.Config.DcvWaitForIssuanceSecondsEnvVar); + if (!string.IsNullOrEmpty(env) && int.TryParse(env, out int envVal) && envVal >= 0) + return envVal; + return DcvWaitForIssuanceSeconds >= 0 ? DcvWaitForIssuanceSeconds : 60; + } + + /// + /// Effective number of certificate-pickup retries, clamped to + /// [0, ]. 0 disables the synchronous pickup. + /// + public int GetEffectivePickupRetries() + => System.Math.Max(0, System.Math.Min(PickupRetries, Constants.Pickup.MaxRetries)); + + /// + /// Effective seconds between pickup retries, clamped to + /// [1, ]. A non-positive configured value + /// falls back to the default rather than producing a tight busy-loop. + /// + public int GetEffectivePickupDelaySeconds() + => System.Math.Max(1, System.Math.Min( + PickupDelayInSeconds > 0 ? PickupDelayInSeconds : Constants.Pickup.DefaultDelaySeconds, + Constants.Pickup.MaxDelaySeconds)); } } diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs index 70cfa5d..aed04a9 100644 --- a/CERTInext/Client/CERTInextClient.cs +++ b/CERTInext/Client/CERTInextClient.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 Keyfactor // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. // You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, @@ -7,14 +7,15 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net; using System.Runtime.CompilerServices; -using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; using Keyfactor.Extensions.CAPlugin.CERTInext.Models; using Keyfactor.Logging; using Microsoft.Extensions.Logging; @@ -42,11 +43,17 @@ public class CERTInextClient : ICERTInextClient, IDisposable private readonly CERTInextConfig _config; private readonly RestClient _http; - // OAuth2 token cache — refreshed when expired + // OAuth2 token cache — refreshed when expired (V1) private string _cachedToken; private DateTime _tokenExpiry = DateTime.MinValue; private readonly SemaphoreSlim _tokenLock = new SemaphoreSlim(1, 1); + // V2 API HTTP client and token cache + private readonly RestClient _httpV2; + private string _v2Token; + private DateTime _v2TokenExpiry = DateTime.MinValue; + private readonly SemaphoreSlim _v2TokenLock = new SemaphoreSlim(1, 1); + // --------------------------------------------------------------------------- // Construction // --------------------------------------------------------------------------- @@ -68,6 +75,20 @@ public CERTInextClient(CERTInextConfig config) }; _http = new RestClient(options); + + // V2 client — only constructed when V2 is enabled and ApiUrl is set. A single ApiUrl + // serves both modes (its meaning follows UseV2Api — issues/0022 config consolidation); + // in V2 mode it is the V2 base URL (no trailing path suffix). + // No authenticator: tokens are injected per-request via BuildV2RequestAsync. + if (config.UseV2Api && !string.IsNullOrWhiteSpace(config.ApiUrl)) + { + var v2Options = new RestClientOptions(config.ApiUrl.TrimEnd('/')) + { + ThrowOnAnyError = false, + Timeout = TimeSpan.FromSeconds(120) + }; + _httpV2 = new RestClient(v2Options); + } } // --------------------------------------------------------------------------- @@ -78,6 +99,8 @@ public void Dispose() { _http?.Dispose(); _tokenLock?.Dispose(); + _httpV2?.Dispose(); + _v2TokenLock?.Dispose(); } // --------------------------------------------------------------------------- @@ -161,9 +184,12 @@ public async Task PingAsync(CancellationToken ct = default) var result = DeserializeOrThrow(resp, "validate credentials"); if (result.Meta != null && !result.Meta.IsSuccess) { - Logger.LogError( - "CERTInext ValidateCredentials returned failure. ErrorCode={ErrorCode}, ErrorMessage={ErrorMsg}", - result.Meta.ErrorCode, result.Meta.ErrorMessage); + // Authentication-failure-shaped event: log at Error so SOX-required + // SIEM rules on authentication failures fire. Every other meta-failure + // call site logs at the LogApiFailure default (Warning). + LogApiFailure("ValidateCredentials", resp, + result.Meta.ErrorCode, result.Meta.ErrorMessage, + level: LogLevel.Error); throw new Exception( $"CERTInext credential validation failed: {result.Meta.ErrorMessage ?? result.Meta.ErrorCode}. " + "See gateway logs for details."); @@ -184,36 +210,159 @@ public async Task PlaceOrderAsync( if (request.Meta == null) request.Meta = await BuildMetaAsync(ct); + // The domain set is logged here, at the wire, not just where Command hands it to us. + // A UCC order that silently lost its SANs upstream of this point is otherwise + // indistinguishable in the gateway log from one the CA stripped — reconciling the + // enrollment-start "SANs=" line against this one localizes the loss immediately. + var certInfo = request.OrderDetails?.CertificateInformation; Logger.LogInformation( - "Submitting order to CERTInext. ProductCode={ProductCode}", - request.OrderDetails?.ProductCode); + "Submitting order to CERTInext. ProductCode={ProductCode}, DomainName={DomainName}, " + + "AdditionalDomainCount={AdditionalDomainCount}, AdditionalDomains={AdditionalDomains}", + request.OrderDetails?.ProductCode, + LogSanitizer.Strip(certInfo?.DomainName), + certInfo?.AdditionalDomains?.Count ?? 0, + // Untyped by now: an email SAN submitted here is masked unless + // LogSensitiveRequestData is on (issue 0040 follow-up). + LogSanitizer.FormatUntypedSans(certInfo?.AdditionalDomains, _config.LogSensitiveRequestData)); + + GenerateOrderResponse result = null; + RestResponse resp = null; + // Cumulative backoff time across all rate-limit retries this call. Emitted + // on the success branch so an operator scraping gateway logs for rate-limit + // pressure (SOC2 CC7.2 anomaly-detection) can correlate by single log line + // rather than threading per-attempt warnings by OrderNumber. + double totalRateLimitBackoffSeconds = 0.0; + + // Issue #8 rate-limit retry: the sandbox returns "Inactive Account User." + // as a generic error string for several conditions, including burst-rate-limit + // rejection. Empirically this resolves within seconds; auto-retrying lets a + // transient burst limit hit transparently. After RateLimitMaxAttempts the + // original exception is propagated unchanged so a genuinely-inactive account + // surfaces as the same operator-facing failure today. + for (int attempt = 1; ; attempt++) + { + // Refresh the request body's meta block on every retry — txn must be + // unique per call (CERTInext rejects duplicate txns), and a fresh ts/txn + // gives the CA a clean canary for whether the limiter has cleared. + if (attempt > 1) + request.Meta = await BuildMetaAsync(ct); + + var req = new RestRequest(Constants.Api.GenerateOrderSslPath, Method.Post); + string jsonBody = JsonSerializer.Serialize(request, GetJsonOptions()); + Logger.LogTrace("PlaceOrderAsync request payload: {Payload}", + ApplyLoggingRedaction(jsonBody, _config.LogSensitiveRequestData)); + req.AddJsonBody(jsonBody); - var req = new RestRequest(Constants.Api.GenerateOrderSslPath, Method.Post); - req.AddJsonBody(JsonSerializer.Serialize(request, GetJsonOptions())); + var sw = System.Diagnostics.Stopwatch.StartNew(); + // idempotent:false — order submission is non-idempotent. A network-level + // timeout may occur after CERTInext already created the order, so re-sending the + // same requestTxn would be rejected as EMS-947 and orphan the created order + // Rate-limit retries are still handled below (with a fresh txn). + resp = await ExecuteWithRetryAsync(req, ct, idempotent: false); + sw.Stop(); - var sw = System.Diagnostics.Stopwatch.StartNew(); - var resp = await ExecuteWithRetryAsync(req, ct); - sw.Stop(); + Logger.LogInformation( + "CERTInext API call: Method=POST, Path={Path}, HttpStatus={Status}, LatencyMs={Latency}, AuthMode={AuthMode}, RateLimitRetryAttempt={Attempt}", + Constants.Api.GenerateOrderSslPath, (int)resp.StatusCode, sw.ElapsedMilliseconds, _config.AuthMode, attempt); - Logger.LogInformation( - "CERTInext API call: Method=POST, Path={Path}, HttpStatus={Status}, LatencyMs={Latency}, AuthMode={AuthMode}", - Constants.Api.GenerateOrderSslPath, (int)resp.StatusCode, sw.ElapsedMilliseconds, _config.AuthMode); + if (resp.StatusCode == HttpStatusCode.Unauthorized || resp.StatusCode == HttpStatusCode.Forbidden) + { + Logger.LogError( + "PlaceOrder API authentication failure. HttpStatus={Status}, AuthMode={AuthMode}", + (int)resp.StatusCode, _config.AuthMode); + throw new Exception( + $"Authentication failure during certificate order. HTTP {(int)resp.StatusCode}. See gateway logs for details."); + } - if (resp.StatusCode == HttpStatusCode.Unauthorized || resp.StatusCode == HttpStatusCode.Forbidden) - { - Logger.LogError( - "PlaceOrder API authentication failure. HttpStatus={Status}, AuthMode={AuthMode}", - (int)resp.StatusCode, _config.AuthMode); - throw new Exception( - $"Authentication failure during certificate order. HTTP {(int)resp.StatusCode}. See gateway logs for details."); - } + // Transient/network failure (5xx or no HTTP status) on a non-idempotent submit: + // CERTInext may have already created the order (the response just didn't reach us). + // We deliberately did not retry (see idempotent:false above). Fail clearly instead + // of deserializing an empty body; if the order was created, the next sync imports it. + bool transientFailure = !resp.IsSuccessful + && !((int)resp.StatusCode >= 400 && (int)resp.StatusCode < 500); + if (transientFailure) + { + Logger.LogWarning( + "PlaceOrder received no usable response (DomainName={Domain}, HttpStatus={Status}, LatencyMs={Latency}). " + + "Not retrying to avoid a duplicate order (EMS-947). If CERTInext created the order it " + + "will be imported by the next synchronization.", + LogSanitizer.Strip(request.OrderDetails?.CertificateInformation?.DomainName), + (int)resp.StatusCode, sw.ElapsedMilliseconds); + throw new Exception( + "CERTInext did not return a usable response to the order submission. If the order was " + + "created it will be imported by the next synchronization — do not resubmit immediately. " + + "See gateway logs for details."); + } - var result = DeserializeOrThrow(resp, "place order"); + result = DeserializeOrThrow(resp, "place order"); - if (result.Meta != null && !result.Meta.IsSuccess) - throw new Exception( - $"CERTInext order failed: {result.Meta.ErrorMessage ?? result.Meta.ErrorCode}. " + - "See gateway logs for details."); + if (result.Meta != null && !result.Meta.IsSuccess) + { + LogApiFailure(Constants.Api.GenerateOrderSslPath, resp, + result.Meta.ErrorCode, result.Meta.ErrorMessage); + + // Auto-retry the documented rate-limit surface up to RateLimitMaxAttempts. + if (IsRateLimitSurface(result.Meta.ErrorMessage) && attempt < RateLimitMaxAttempts) + { + double waitSeconds = ComputeRateLimitBackoffSeconds(attempt); + totalRateLimitBackoffSeconds += waitSeconds; + Logger.LogWarning( + "PlaceOrder hit rate-limit-shaped error \"{ErrorMessage}\" (attempt {Attempt}/{Max}). " + + "Backing off {WaitSeconds:F1}s before retrying. See Troubleshooting in README for context.", + result.Meta.ErrorMessage, attempt, RateLimitMaxAttempts, waitSeconds); + try + { + await Task.Delay(TimeSpan.FromSeconds(waitSeconds), ct); + } + catch (OperationCanceledException) + { + throw; + } + continue; // retry + } + + // EMS-947 "Duplicate requestTxn": CERTInext already received an order for this + // transaction. With the non-idempotent-retry fix above this should no longer be + // caused by our own retry, but if it still surfaces the order exists on the CA + // side and will be imported by the next sync — say so, not a generic failure. + bool isDuplicateTxn = + string.Equals(result.Meta.ErrorCode, "EMS-947", StringComparison.OrdinalIgnoreCase) + || (result.Meta.ErrorMessage?.IndexOf("Duplicate requestTxn", StringComparison.OrdinalIgnoreCase) >= 0); + if (isDuplicateTxn) + { + // Log the classification decision itself (parity with the transient-failure + // branch above) so an auditor sees the plugin deliberately treated this as a + // benign duplicate rather than a hard failure. + Logger.LogWarning( + "PlaceOrder classified {ErrorCode} as a duplicate transaction (not a hard failure). " + + "DomainName={Domain}, Path={Path}, HttpStatus={Status}, LatencyMs={Latency}. If an order exists " + + "for this transaction it will be imported by the next synchronization.", + result.Meta.ErrorCode, + LogSanitizer.Strip(request.OrderDetails?.CertificateInformation?.DomainName), + Constants.Api.GenerateOrderSslPath, (int)resp.StatusCode, sw.ElapsedMilliseconds); + throw new Exception( + "CERTInext reported a duplicate order transaction (EMS-947). If an order was created " + + "for this transaction it will be imported by the next synchronization — do not resubmit " + + "immediately. See gateway logs for details."); + } + + throw new Exception( + $"CERTInext order failed: {result.Meta.ErrorMessage ?? result.Meta.ErrorCode}. " + + "See gateway logs for details."); + } + + // Success — if we retried, emit a single summary line so the rate-limit + // pressure is correlatable per-call without joining the per-attempt + // warnings by OrderNumber. (SOC2 CC7.2 anomaly-detection enablement.) + if (attempt > 1) + { + Logger.LogInformation( + "PlaceOrder succeeded after rate-limit retries. OrderNumber={OrderNumber}, " + + "RateLimitRetryCount={RetryCount}, TotalBackoffSeconds={BackoffSeconds:F1}", + result.OrderDetails?.OrderNumber, attempt - 1, totalRateLimitBackoffSeconds); + } + break; // success + } Logger.LogInformation( "CERTInext order placed. OrderNumber={OrderNumber}, RequestNumber={RequestNumber}", @@ -238,7 +387,9 @@ public async Task SubmitCsrAsync(SubmitCsrRequest request, CancellationToken ct req.AddJsonBody(JsonSerializer.Serialize(request, GetJsonOptions())); var sw = System.Diagnostics.Stopwatch.StartNew(); - var resp = await ExecuteWithRetryAsync(req, ct); + // idempotent:false — submitting a CSR is non-idempotent; do not resend on a network + // timeout (the first attempt may have been received). See PlaceOrderAsync. + var resp = await ExecuteWithRetryAsync(req, ct, idempotent: false); sw.Stop(); Logger.LogInformation( @@ -246,7 +397,27 @@ public async Task SubmitCsrAsync(SubmitCsrRequest request, CancellationToken ct Constants.Api.SubmitCsrPath, (int)resp.StatusCode, sw.ElapsedMilliseconds, _config.AuthMode); if (!resp.IsSuccessful) + { + LogApiFailure(Constants.Api.SubmitCsrPath, resp); + // Parity with PlaceOrderAsync: a transient/network failure on this non-idempotent + // submit was NOT retried, so record that decision (the CSR may already have been + // received). 4xx client errors fall through to the generic failure below. + bool transientFailure = !((int)resp.StatusCode >= 400 && (int)resp.StatusCode < 500); + if (transientFailure) + { + Logger.LogWarning( + "SubmitCSR received no usable response (OrderNumber={OrderNumber}, HttpStatus={Status}, " + + "LatencyMs={Latency}); not retrying (non-idempotent). If CERTInext already received the CSR, " + + "do not resubmit immediately.", + request.OrderDetails?.OrderNumber, (int)resp.StatusCode, sw.ElapsedMilliseconds); + // Parity with PlaceOrderAsync: carry the actionable guidance into the surfaced + // exception, not only the log line. + throw new Exception( + "CERTInext did not return a usable response to the CSR submission. If the CSR was received " + + "it will take effect — do not resubmit immediately. See gateway logs for details."); + } throw new Exception($"CERTInext SubmitCSR failed. HTTP {(int)resp.StatusCode}. See gateway logs for details."); + } Logger.MethodExit(LogLevel.Trace); } @@ -289,10 +460,14 @@ public async Task TrackOrderAsync(string orderNumber, Cancel } var result = DeserializeOrThrow(resp, $"track order {orderNumber}"); + Logger.LogTrace("TrackOrderAsync response payload (Order={OrderNumber}): {Payload}", + orderNumber, ApplyLoggingRedaction(resp.Content, _config.LogSensitiveRequestData)); // A meta status of "0" with errorCode EMS-913 or similar means the order was not found if (result.Meta != null && !result.Meta.IsSuccess) { + LogApiFailure($"{Constants.Api.TrackOrderPath} {orderNumber}", resp, + result.Meta.ErrorCode, result.Meta.ErrorMessage); if (result.Meta.ErrorCode != null && (result.Meta.ErrorCode.StartsWith("EMS-9") || result.Meta.ErrorMessage?.Contains("not found", StringComparison.OrdinalIgnoreCase) == true)) { @@ -344,8 +519,12 @@ public async Task DownloadCertificateAsync(string orderN var result = DeserializeOrThrow(resp, $"download certificate {orderNumber}"); if (result.Meta != null && !result.Meta.IsSuccess) + { + LogApiFailure($"{Constants.Api.GetCertificatePath} {orderNumber}", resp, + result.Meta.ErrorCode, result.Meta.ErrorMessage); throw new Exception( $"CERTInext GetCertificate failed for order '{orderNumber}': {result.Meta.ErrorMessage ?? result.Meta.ErrorCode}."); + } Logger.MethodExit(LogLevel.Trace); return result; @@ -402,6 +581,9 @@ public async Task RevokeOrderAsync(RevokeOrderRequest request, CancellationToken var revResp = JsonSerializer.Deserialize(resp.Content, GetJsonOptions()); if (revResp?.Meta != null && !revResp.Meta.IsSuccess) { + LogApiFailure( + $"{Constants.Api.RevokeOrderPath} {request.RevocationDetails?.OrderNumber}", + resp, revResp.Meta.ErrorCode, revResp.Meta.ErrorMessage); throw new Exception( $"CERTInext RevokeOrder returned failure for order " + $"'{request.RevocationDetails?.OrderNumber}': {revResp.Meta.ErrorMessage ?? revResp.Meta.ErrorCode}."); @@ -636,26 +818,75 @@ public async Task RenewCertificateAsync( throw new KeyNotFoundException($"Cannot renew: prior order '{certificateId}' was not found in CERTInext."); } - // We don't have the product code from TrackOrder — build an order using - // the config defaults and the CSR from the renewal request. + // Primary domain for the renewal order. Prefer the CN of the subject Command gave + // us; the prior order's requestorName is only a last resort and is not a domain — + // it is retained solely so an old caller that sets no Subject behaves as before. + // Hoisted: the same parse drives both the domain and the "did we get a CN?" warning, + // mirroring BuildOrderRequestFromLegacyEnrollRequest. + string subjectCn = ExtractCnFromSubject(request.Subject); + + string renewalDomainName = + subjectCn + ?? priorTrack.OrderDetails?.RequestorInformation?.RequestorName + ?? "unknown"; + + if (subjectCn == null) + { + Logger.LogWarning( + "Renewal of order {PriorId} has no usable CN in its subject; falling back to " + + "DomainName='{DomainName}' from the prior order. Verify the renewed certificate's " + + "primary domain.", + certificateId, LogSanitizer.Strip(renewalDomainName)); + } + + // Prefer the template's own product code (threaded through via request.ProfileId); + // only fall back to the connector-level default when the caller didn't supply one. + // EnrollmentParams.ProductCode never returns null (it returns string.Empty when it + // can't resolve a code), so this must be a blank check, not a null-coalesce — a + // null-coalesce here would make the DefaultProductCode fallback unreachable, the + // same dead-fallback bug that made DefaultProductCode a no-op for new enrollments. var orderReq = new GenerateOrderSslRequest { Meta = await BuildMetaAsync(ct), OrderDetails = new SslOrderDetails { - ProductCode = _config.DefaultProductCode ?? string.Empty, + ProductCode = string.IsNullOrWhiteSpace(request.ProfileId) + ? (_config.DefaultProductCode ?? string.Empty) + : request.ProfileId, SaveAndHold = "0", + // Mirrors BuildOrderRequestFromLegacyEnrollRequest — omit when blank so the + // order falls back to the unvetted/ungroup path, same as new enrollments. + DelegationInformation = !string.IsNullOrWhiteSpace(_config.GroupNumber) + ? new DelegationInformation { GroupNumber = _config.GroupNumber } + : null, RequestorInformation = new RequestorInformation { RequestorName = request.RequesterName ?? _config.RequestorName, RequestorEmail = request.RequesterEmail ?? _config.RequestorEmail, RequestorIsdCode = _config.RequestorIsdCode ?? "1", - RequestorMobileNumber = _config.RequestorMobileNumber ?? string.Empty + RequestorMobileNumber = _config.RequestorMobileNumber ?? string.Empty, + RequestorDesignation = string.IsNullOrWhiteSpace(_config.RequestorDesignation) ? null : _config.RequestorDesignation.Trim() + }, + TechnicalPointOfContact = new TechnicalPointOfContact + { + TpcName = string.IsNullOrWhiteSpace(_config.TechnicalContactName) + ? (request.RequesterName ?? _config.RequestorName) + : _config.TechnicalContactName, + TpcEmail = string.IsNullOrWhiteSpace(_config.TechnicalContactEmail) + ? (request.RequesterEmail ?? _config.RequestorEmail) + : _config.TechnicalContactEmail, + TpcIsdCode = string.IsNullOrWhiteSpace(_config.TechnicalContactIsdCode) + ? (string.IsNullOrWhiteSpace(_config.RequestorIsdCode) ? "1" : _config.RequestorIsdCode) + : _config.TechnicalContactIsdCode, + TpcMobileNumber = string.IsNullOrWhiteSpace(_config.TechnicalContactMobileNumber) + ? (_config.RequestorMobileNumber ?? string.Empty) + : _config.TechnicalContactMobileNumber }, SubscriptionDetails = new SubscriptionDetails { Validity = "1" }, CertificateInformation = new CertificateInformation { - DomainName = priorTrack.OrderDetails?.RequestorInformation?.RequestorName ?? "unknown" + DomainName = renewalDomainName, + AdditionalDomains = BuildAdditionalDomains(request.Sans, renewalDomainName) }, Csr = request.Csr, AgreementDetails = BuildDefaultAgreementDetails() @@ -840,6 +1071,145 @@ public async Task> GetProfilesAsync(CancellationToken ct = def return profiles; } + // --------------------------------------------------------------------------- + // ICERTInextClient — DCV methods + // --------------------------------------------------------------------------- + + /// + public async Task GetDcvAsync( + string orderNumber, + string domainName, + string dcvMethod, + CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + + var body = new GetDcvRequest + { + Meta = await BuildMetaAsync(ct), + DcvDetails = new DcvRequestDetails + { + RequestorEmail = _config.RequestorEmail, + OrderNumber = orderNumber, + DomainName = domainName, + DcvMethod = dcvMethod + } + }; + + var req = new RestRequest(Constants.Api.GetDcvPath, Method.Post); + req.AddJsonBody(JsonSerializer.Serialize(body, GetJsonOptions())); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + var resp = await ExecuteWithRetryAsync(req, ct); + sw.Stop(); + + Logger.LogInformation( + "CERTInext API call: Method=POST, Path={Path}, OrderNumber={OrderNumber}, Domain={Domain}, HttpStatus={Status}, LatencyMs={Latency}", + Constants.Api.GetDcvPath, orderNumber, domainName, (int)resp.StatusCode, sw.ElapsedMilliseconds); + + if (resp.StatusCode == HttpStatusCode.Unauthorized || resp.StatusCode == HttpStatusCode.Forbidden) + { + Logger.LogError( + "GetDcv API authentication failure. OrderNumber={OrderNumber}, Domain={Domain}, HttpStatus={Status}", + orderNumber, domainName, (int)resp.StatusCode); + throw new Exception( + $"Authentication failure calling GetDcv for order '{orderNumber}' domain '{domainName}'. HTTP {(int)resp.StatusCode}. See gateway logs for details."); + } + + var result = DeserializeOrThrow(resp, $"get DCV token {orderNumber}/{domainName}"); + + if (result.Meta != null && !result.Meta.IsSuccess) + { + LogApiFailure( + $"{Constants.Api.GetDcvPath} {orderNumber}/{domainName}", + resp, result.Meta.ErrorCode, result.Meta.ErrorMessage); + throw new Exception( + $"CERTInext GetDcv failed for order '{orderNumber}' domain '{domainName}': {result.Meta.ErrorMessage ?? result.Meta.ErrorCode}."); + } + + // SOX CC7.3: log token presence (never value) so each DCV step is independently + // auditable — an auditor must be able to confirm the token was obtained before + // StageValidation was called. + Logger.LogInformation( + "GetDcv response received. OrderNumber={OrderNumber}, Domain={Domain}, TokenPresent={TokenPresent}", + orderNumber, domainName, !string.IsNullOrWhiteSpace(result.DcvDetails?.Token)); + + Logger.MethodExit(LogLevel.Trace); + return result; + } + + /// + public async Task VerifyDcvAsync( + string orderNumber, + string domainName, + string dcvMethod, + CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + + var body = new VerifyDcvRequest + { + Meta = await BuildMetaAsync(ct), + DcvDetails = new DcvRequestDetails + { + RequestorEmail = _config.RequestorEmail, + OrderNumber = orderNumber, + DomainName = domainName, + DcvMethod = dcvMethod + } + }; + + var req = new RestRequest(Constants.Api.VerifyDcvPath, Method.Post); + req.AddJsonBody(JsonSerializer.Serialize(body, GetJsonOptions())); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + var resp = await ExecuteWithRetryAsync(req, ct); + sw.Stop(); + + Logger.LogInformation( + "CERTInext API call: Method=POST, Path={Path}, OrderNumber={OrderNumber}, Domain={Domain}, HttpStatus={Status}, LatencyMs={Latency}", + Constants.Api.VerifyDcvPath, orderNumber, domainName, (int)resp.StatusCode, sw.ElapsedMilliseconds); + + if (resp.StatusCode == HttpStatusCode.Unauthorized || resp.StatusCode == HttpStatusCode.Forbidden) + { + Logger.LogError( + "VerifyDcv API authentication failure. OrderNumber={OrderNumber}, Domain={Domain}, HttpStatus={Status}", + orderNumber, domainName, (int)resp.StatusCode); + throw new Exception( + $"Authentication failure calling VerifyDcv for order '{orderNumber}' domain '{domainName}'. HTTP {(int)resp.StatusCode}. See gateway logs for details."); + } + + if (!resp.IsSuccessful) + throw new Exception( + $"CERTInext VerifyDcv failed for order '{orderNumber}' domain '{domainName}'. HTTP {(int)resp.StatusCode}. See gateway logs for details."); + + // Attempt to read meta.status from the response body + if (!string.IsNullOrWhiteSpace(resp.Content)) + { + try + { + var verifyResp = JsonSerializer.Deserialize(resp.Content, GetJsonOptions()); + if (verifyResp?.Meta != null && !verifyResp.Meta.IsSuccess) + { + // SOX CC7.3 + issue #8: log the failure with the raw body so an + // auditor / operator can see exactly what CERTInext returned. + LogApiFailure( + $"{Constants.Api.VerifyDcvPath} {orderNumber}/{domainName}", + resp, verifyResp.Meta.ErrorCode, verifyResp.Meta.ErrorMessage); + throw new Exception( + $"CERTInext VerifyDcv returned failure for order '{orderNumber}' domain '{domainName}': {verifyResp.Meta.ErrorMessage ?? verifyResp.Meta.ErrorCode}."); + } + } + catch (JsonException) { /* non-JSON 200 body is acceptable */ } + } + + // SOX CC7.3 / SOC2 CC7.3: log success only after the meta check so the log entry + // unambiguously reflects that CERTInext acknowledged the verification request. + Logger.LogInformation( + "DCV verification succeeded. OrderNumber={OrderNumber}, Domain={Domain}", orderNumber, domainName); + Logger.MethodExit(LogLevel.Trace); + } + // --------------------------------------------------------------------------- // Auth helpers // --------------------------------------------------------------------------- @@ -872,8 +1242,10 @@ private Task BuildMetaAsync(CancellationToken ct) authKey = ComputeAuthKey(_config.ApiKey, ts, txn); } - // SOX CC6.1: log credential use (presence only, never the value) at Information. - Logger.LogInformation( + // SOC2 CC7.2: log credential use at Debug only — this is called on every outbound + // request, so Information would flood the log and degrade anomaly detection signal. + // Per-operation audit entries (LogInformation) are emitted at the call sites above. + Logger.LogDebug( "Outbound API request authenticated. AuthMode={AuthMode}, AccountNumber={AccountNumber}, " + "ApiKeyPresent={Present}", _config.AuthMode, _config.AccountNumber, !string.IsNullOrEmpty(_config.ApiKey)); @@ -890,113 +1262,1050 @@ private Task BuildMetaAsync(CancellationToken ct) /// /// Computes the CERTInext authKey: SHA256(accessKey + ts + txn) as lowercase hex. + /// + /// Implemented with BouncyCastle (per the project's crypto policy: all hashing and + /// key handling goes through BouncyCastle, never BCL System.Security.Cryptography). /// private static string ComputeAuthKey(string accessKey, string ts, string txn) { string input = accessKey + ts + txn; - byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); + byte[] inputBytes = Encoding.UTF8.GetBytes(input); + var digest = new Org.BouncyCastle.Crypto.Digests.Sha256Digest(); + digest.BlockUpdate(inputBytes, 0, inputBytes.Length); + byte[] hash = new byte[digest.GetDigestSize()]; + digest.DoFinal(hash, 0); return Convert.ToHexString(hash).ToLowerInvariant(); } /// - /// Generates a unique transaction ID (alphanumeric, 16–18 digits). + /// Generates a unique transaction ID (decimal, up to 18 digits). + /// + /// `txn` is part of the SHA-256 input for the CERTInext authKey + /// (SHA256(accessKey + ts + txn)). A predictable txn shrinks the search + /// space against a leaked accessKey, so we use a cryptographically-strong source + /// rather than — per the project's BouncyCastle-only + /// crypto policy, that source is Org.BouncyCastle.Security.SecureRandom. /// + private static readonly Org.BouncyCastle.Security.SecureRandom _txnRandom = + new Org.BouncyCastle.Security.SecureRandom(); + private static string GenerateTxnId() { - // Match the Postman pre-request script: Math.floor(Math.random() * 1e18 + 1) - long val = (long)(Random.Shared.NextDouble() * 1_000_000_000_000_000_000L) + 1L; + // Produce a positive long in [1, 1e18). NextLong() returns the full Int64 + // range including negatives — mask off the sign bit and reduce. + long val = (_txnRandom.NextLong() & long.MaxValue) % 1_000_000_000_000_000_000L + 1L; return val.ToString(); } /// /// Returns a valid OAuth2 access token, refreshing it if expired. Thread-safe. /// - private async Task GetOrRefreshTokenAsync(CancellationToken ct) - { - if (!string.IsNullOrEmpty(_cachedToken) && DateTime.UtcNow < _tokenExpiry) - return _cachedToken; + // --------------------------------------------------------------------------- + // ICERTInextClient — V2 REST API methods + // --------------------------------------------------------------------------- - await _tokenLock.WaitAsync(ct); - try - { - if (!string.IsNullOrEmpty(_cachedToken) && DateTime.UtcNow < _tokenExpiry) - return _cachedToken; + /// + public async Task GetAuthMeV2Async(CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + EnsureV2Client(); + var req = await BuildV2RequestAsync(Constants.ApiV2.AuthMePath, Method.Get, ct); + var resp = await _httpV2.ExecuteAsync(req, ct); + ThrowOnV2Failure(resp, "auth/me"); + var result = DeserializeV2OrThrow(resp, "auth/me"); + Logger.MethodExit(LogLevel.Trace); + return result; + } - Logger.LogInformation( - "OAuth2 token acquisition attempt started. TokenUrl={TokenUrl}, ClientId={ClientId}", - _config.OAuthTokenUrl, _config.OAuthClientId); + /// + public async Task PingV2Async(CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + var me = await GetAuthMeV2Async(ct); + Logger.LogInformation( + "CERTInext V2 ping successful. AccountNumber={AccountNumber}, AuthType={AuthType}", + me.AccountNumber, me.AuthType); + Logger.MethodExit(LogLevel.Trace); + } - using var tokenClient = new RestClient(_config.OAuthTokenUrl); - var tokenReq = new RestRequest(string.Empty, Method.Post); - tokenReq.AddHeader("Content-Type", "application/x-www-form-urlencoded"); - tokenReq.AddParameter("grant_type", "client_credentials"); - tokenReq.AddParameter("client_id", _config.OAuthClientId); - tokenReq.AddParameter("client_secret", _config.OAuthClientSecret); + /// + public async Task PlaceOrderV2Async( + string productFamilySlug, + string productCode, + V2CreateSslOrderRequest request, + CancellationToken ct = default) + { + // Issue 0033: the SSL-shaped body must only ever go to the SSL endpoint. Previously + // the slug was substituted into the URL unchecked, so a private-pki/signature + // template silently sent this body to the wrong family's create endpoint. + if (!string.Equals(productFamilySlug, Constants.ApiV2.FamilySsl, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException( + $"A V2 SSL/TLS order body can only be sent to the '{Constants.ApiV2.FamilySsl}' family, " + + $"not '{productFamilySlug}'. Use the Private PKI or Document Signer PlaceOrderV2Async overload.", + nameof(productFamilySlug)); + + return await PlaceOrderV2CoreAsync(Constants.ApiV2.SslCertificatesPath, productCode, request, ct); + } - var tokenResp = await tokenClient.ExecuteAsync(tokenReq, ct); - if (!tokenResp.IsSuccessful || string.IsNullOrWhiteSpace(tokenResp.Content)) - { - Logger.LogError( - "OAuth2 token acquisition failed. TokenUrl={TokenUrl}, ClientId={ClientId}, HttpStatus={Status}", - _config.OAuthTokenUrl, _config.OAuthClientId, (int)tokenResp.StatusCode); - throw new Exception( - $"Failed to obtain OAuth2 token from CERTInext. HTTP {(int)tokenResp.StatusCode}. See gateway logs for details."); - } + /// + public Task PlaceOrderV2Async( + string productCode, + V2CreatePrivatePkiOrderRequest request, + CancellationToken ct = default) + => PlaceOrderV2CoreAsync(Constants.ApiV2.PrivatePkiCertificatesPath, productCode, request, ct); - var tokenPayload = JsonSerializer.Deserialize(tokenResp.Content, GetJsonOptions()); + /// + public Task PlaceOrderV2Async( + string productCode, + V2CreateSignatureOrderRequest request, + CancellationToken ct = default) + => PlaceOrderV2CoreAsync(Constants.ApiV2.SignatureCertificatesPath, productCode, request, ct); - if (tokenPayload == null || string.IsNullOrEmpty(tokenPayload.AccessToken)) - { - Logger.LogError( - "OAuth2 token acquisition failed — response did not contain access_token. TokenUrl={TokenUrl}", - _config.OAuthTokenUrl); - throw new Exception("OAuth2 token response from CERTInext did not contain an access_token."); - } + /// + /// Shared V2 create-order transport for every product family (issue 0033): POSTs the + /// family-specific body to with the X-Product-Code and + /// Idempotency-Key headers. Request and response bodies are only ever logged (Trace) + /// through , so credentials are always scrubbed and + /// requestor/subject PII is scrubbed unless LogSensitiveRequestData is on. + /// X-Product-Code is the spec's "Optional override" on SSL create (and is sent the same + /// way for Private PKI / Document Signer, issue 0054 item #4): a null/blank + /// omits the header entirely rather than sending it empty, + /// which is not itself a valid override value. + /// + private async Task PlaceOrderV2CoreAsync( + string path, + string productCode, + TRequest request, + CancellationToken ct) + { + Logger.MethodEntry(LogLevel.Trace); + EnsureV2Client(); + string idempotencyKey = Guid.NewGuid().ToString(); + var req = await BuildV2RequestAsync(path, Method.Post, ct, idempotencyKey); + if (!string.IsNullOrWhiteSpace(productCode)) + req.AddHeader("X-Product-Code", productCode); + string json = JsonSerializer.Serialize(request, GetJsonOptions()); + Logger.LogTrace("PlaceOrderV2Async request payload: {Payload}", + ApplyLoggingRedaction(json, _config.LogSensitiveRequestData)); + req.AddJsonBody(json); + var sw = System.Diagnostics.Stopwatch.StartNew(); + var resp = await _httpV2.ExecuteAsync(req, ct); + sw.Stop(); + Logger.LogInformation( + "CERTInext V2 API call: Method=POST, Path={Path}, HttpStatus={Status}, LatencyMs={Latency}", + path, (int)resp.StatusCode, sw.ElapsedMilliseconds); + Logger.LogTrace("PlaceOrderV2Async response: {Body}", + ApplyLoggingRedaction(resp.Content, _config.LogSensitiveRequestData)); + ThrowOnV2Failure(resp, "V2 place order"); + var result = DeserializeV2OrThrow(resp, "V2 place order"); + Logger.MethodExit(LogLevel.Trace); + return result; + } - _cachedToken = tokenPayload.AccessToken; - _tokenExpiry = DateTime.UtcNow.AddSeconds(Math.Max(tokenPayload.ExpiresIn - 60, 30)); + /// + public async Task SubmitCsrV2Async( + string productFamilySlug, + string orderId, + string csrPem, + CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + EnsureV2Client(); + string path = BuildV2OrderPath(productFamilySlug, orderId) + "/csr"; + var req = await BuildV2RequestAsync(path, Method.Put, ct); + var body = new V2SubmitCsrRequest { Csr = csrPem }; + req.AddJsonBody(JsonSerializer.Serialize(body, GetJsonOptions())); + var resp = await _httpV2.ExecuteAsync(req, ct); + ThrowOnV2Failure(resp, "V2 submit CSR"); + Logger.MethodExit(LogLevel.Trace); + } - Logger.LogInformation( - "OAuth2 token acquired. TokenUrl={TokenUrl}, ClientId={ClientId}, ExpiresAt={Expiry:u}", - _config.OAuthTokenUrl, _config.OAuthClientId, _tokenExpiry); - return _cachedToken; - } - finally + /// + public async Task TrackOrderV2Async( + string productFamilySlug, + string orderId, + CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + EnsureV2Client(); + string path = BuildV2OrderPath(productFamilySlug, orderId); + var req = await BuildV2RequestAsync(path, Method.Get, ct); + var resp = await _httpV2.ExecuteAsync(req, ct); + if (resp.StatusCode == HttpStatusCode.NotFound) { - _tokenLock.Release(); + Logger.MethodExit(LogLevel.Trace); + throw new KeyNotFoundException($"V2 order '{orderId}' not found in family '{productFamilySlug}'."); } + ThrowOnV2Failure(resp, "V2 track order"); + var result = DeserializeV2OrThrow(resp, "V2 track order"); + Logger.MethodExit(LogLevel.Trace); + return result; } - // --------------------------------------------------------------------------- - // Retry helper - // --------------------------------------------------------------------------- - - /// - /// Executes a with up to + /// + public async Task DownloadCertificateV2Async( + string productFamilySlug, + string orderId, + CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + EnsureV2Client(); + string path = BuildV2OrderPath(productFamilySlug, orderId) + "/certificate"; + var req = await BuildV2RequestAsync(path, Method.Get, ct); + var resp = await _httpV2.ExecuteAsync(req, ct); + if (resp.StatusCode == HttpStatusCode.NotFound) + { + Logger.MethodExit(LogLevel.Trace); + throw new KeyNotFoundException($"V2 certificate for order '{orderId}' not found in family '{productFamilySlug}'."); + } + ThrowOnV2Failure(resp, "V2 download certificate"); + var result = DeserializeV2OrThrow(resp, "V2 download certificate"); + Logger.MethodExit(LogLevel.Trace); + return result; + } + + /// + public async Task RevokeOrderV2Async( + string productFamilySlug, + string orderId, + V2RevokeRequest request, + CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + EnsureV2Client(); + string idempotencyKey = Guid.NewGuid().ToString(); + string path = BuildV2OrderPath(productFamilySlug, orderId) + "/revoke"; + var req = await BuildV2RequestAsync(path, Method.Post, ct, idempotencyKey); + req.AddJsonBody(JsonSerializer.Serialize(request, GetJsonOptions())); + var resp = await _httpV2.ExecuteAsync(req, ct); + if (resp.StatusCode == HttpStatusCode.NotFound) + { + // Compliance finding: a revoke denial must leave an audit trail (SOX/SOC2 + // who/what/when/outcome) even though this branch returns before + // ThrowOnV2Failure/LogV2ApiFailure would otherwise run it. Log explicitly with + // the order/family identity plus the usual HTTP-status+redacted-body line. + Logger.LogWarning( + "V2 revoke denied — order not found or not in a revokable state. " + + "OrderId={OrderId}, ProductFamily={Family}, HttpStatus={HttpStatus}", + orderId, productFamilySlug, (int)resp.StatusCode); + LogV2ApiFailure("V2 revoke order", resp, LogLevel.Warning); + Logger.MethodExit(LogLevel.Trace); + // Per the V2 spec ("Revoke Certificate", 404 response): "Order not found + // or not in a revokable state." This is deliberately ambiguous on the + // wire — callers that have already confirmed the order lives in + // `productFamilySlug` (e.g. via TrackOrderV2Async) should treat a 404 + // here as "not revokable", not as a family miss (issues/0019). + throw new KeyNotFoundException( + $"V2 order '{orderId}' in family '{productFamilySlug}' not found or not in a revokable state."); + } + if (resp.StatusCode == (HttpStatusCode)422) + { + // Label by whatever EMS code/detail CERTInext actually returned rather + // than presuming "not in issued state" — 422s here cover multiple + // distinct conditions (EMS-969 revoke reason ID missing, sandbox-timing + // "Certificate Request still being processed", etc. — see issues/0019). + string detail = ExtractV2ErrorMessage(resp.Content, "V2 revoke"); + // Compliance finding: same audit-trail requirement as the 404 branch above — + // this also returns before ThrowOnV2Failure would otherwise log it. + Logger.LogWarning( + "V2 revoke rejected. OrderId={OrderId}, ProductFamily={Family}, HttpStatus={HttpStatus}, " + + "Detail={Detail}", + orderId, productFamilySlug, (int)resp.StatusCode, detail); + LogV2ApiFailure("V2 revoke order", resp, LogLevel.Warning); + throw new InvalidOperationException($"V2 revoke rejected. {detail}"); + } + ThrowOnV2Failure(resp, "V2 revoke order"); + Logger.MethodExit(LogLevel.Trace); + } + + /// + public async Task CancelOrderV2Async( + string productFamilySlug, + string orderId, + string reason, + CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + if (string.IsNullOrWhiteSpace(reason)) + throw new ArgumentException( + "A cancel reason is required (CERTInext rejects an empty one with EMS-984).", nameof(reason)); + EnsureV2Client(); + string idempotencyKey = Guid.NewGuid().ToString(); + string path = BuildV2OrderPath(productFamilySlug, orderId) + "/cancel"; + var req = await BuildV2RequestAsync(path, Method.Post, ct, idempotencyKey); + req.AddJsonBody(JsonSerializer.Serialize(new V2CancelOrderRequest { Reason = reason }, GetJsonOptions())); + var sw = System.Diagnostics.Stopwatch.StartNew(); + var resp = await _httpV2.ExecuteAsync(req, ct); + sw.Stop(); + Logger.LogInformation( + "CERTInext V2 API call: Method=POST, Path={Path}, HttpStatus={Status}, LatencyMs={Latency}", + path, (int)resp.StatusCode, sw.ElapsedMilliseconds); + if (resp.StatusCode == (HttpStatusCode)422) + { + // Spec "Cancel Order" 422: "order already in a terminal state" (e.g. already + // issued — use /revoke). Not an exception: the caller reports it as "not cancelled". + LogV2ApiFailure("V2 cancel order", resp, LogLevel.Warning); + Logger.MethodExit(LogLevel.Trace); + return V2CancelOrderOutcome.AlreadyTerminal; + } + ThrowOnV2Failure(resp, "V2 cancel order"); + Logger.MethodExit(LogLevel.Trace); + return V2CancelOrderOutcome.Cancelled; + } + + /// + public async Task ResolveAndTrackOrderV2Async( + string orderId, + CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + var (_, status) = await ResolveV2OrderFamilyAsync(orderId, ct); + Logger.MethodExit(LogLevel.Trace); + return status; + } + + /// + public async Task<(string family, V2OrderStatusResponse status)> ResolveAndTrackOrderV2WithFamilyAsync( + string orderId, + CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + var result = await ResolveV2OrderFamilyAsync(orderId, ct); + Logger.MethodExit(LogLevel.Trace); + return result; + } + + /// + public async Task ResolveAndDownloadCertificateV2Async( + string orderId, + CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + var (family, _) = await ResolveV2OrderFamilyAsync(orderId, ct); + var cert = await DownloadCertificateV2Async(family, orderId, ct); + Logger.MethodExit(LogLevel.Trace); + return cert; + } + + /// + public async Task GetDcvV2Async(string orderId, string familySlug, CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + EnsureV2Client(); + string path = $"/api/certinext/v2/{familySlug}/{orderId}/dcv"; + var req = await BuildV2RequestAsync(path, Method.Get, ct); + var resp = await _httpV2.ExecuteAsync(req, ct); + Logger.LogInformation( + "CERTInext V2 API call: Method=GET, Path={Path}, HttpStatus={Status}", + path, (int)resp.StatusCode); + ThrowOnV2Failure(resp, "V2 get DCV challenge"); + var result = DeserializeV2OrThrow(resp, "V2 get DCV challenge"); + Logger.MethodExit(LogLevel.Trace); + return result; + } + + /// + public async Task GetDcvV2Async( + string orderId, string domain, string familySlug, CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + EnsureV2Client(); + // Per-domain scope (issue 0042) — confirmed live to return a distinct token per SAN + // on a UCC order (v2-api-support-questions.md Finding 9). + string path = $"/api/certinext/v2/{familySlug}/{orderId}/dcv?domain=" + Uri.EscapeDataString(domain); + var req = await BuildV2RequestAsync(path, Method.Get, ct); + var resp = await _httpV2.ExecuteAsync(req, ct); + Logger.LogInformation( + "CERTInext V2 API call: Method=GET, Path={Path}, HttpStatus={Status}", + path, (int)resp.StatusCode); + ThrowOnV2Failure(resp, "V2 get DCV challenge (per-domain)"); + var result = DeserializeV2OrThrow(resp, "V2 get DCV challenge (per-domain)"); + Logger.MethodExit(LogLevel.Trace); + return result; + } + + /// + public async Task VerifyDcvV2Async(string orderId, string domain, string familySlug, CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + EnsureV2Client(); + string path = $"/api/certinext/v2/{familySlug}/{orderId}/dcv/verify"; + var req = await BuildV2RequestAsync(path, Method.Post, ct); + var body = new V2DcvVerifyRequest { Domain = domain, Method = "dns-txt" }; + req.AddJsonBody(JsonSerializer.Serialize(body, GetJsonOptions())); + var resp = await _httpV2.ExecuteAsync(req, ct); + Logger.LogInformation( + "CERTInext V2 API call: Method=POST, Path={Path}, HttpStatus={Status}", + path, (int)resp.StatusCode); + + if (resp.StatusCode == (HttpStatusCode)422) + { + string detail = ExtractV2ErrorMessage(resp.Content, "V2 verify DCV"); + throw new InvalidOperationException( + $"V2 DCV verification failed for order '{orderId}', domain '{domain}'. {detail}"); + } + + // 204 No Content is a valid success — return an empty verified response + if (resp.StatusCode == System.Net.HttpStatusCode.NoContent || string.IsNullOrWhiteSpace(resp.Content)) + { + Logger.MethodExit(LogLevel.Trace); + return new V2DcvVerifyResponse { OverallStatus = "VERIFIED" }; + } + + ThrowOnV2Failure(resp, "V2 verify DCV"); + var result = DeserializeV2OrThrow(resp, "V2 verify DCV"); + Logger.MethodExit(LogLevel.Trace); + return result; + } + + /// + public async Task> GetProductDetailsV2Async(CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + EnsureV2Client(); + + // Scope the catalog to the connector's configured billing group, mirroring V1's + // GetProductDetailsAsync (ProductDetailsFilter.GroupNumber). Omitted entirely when + // unconfigured so the account's default group is used, same as V1 (issue 0029). + string path = Constants.ApiV2.CatalogProductsPath; + if (!string.IsNullOrWhiteSpace(_config.GroupNumber)) + path += "?groupNumber=" + Uri.EscapeDataString(_config.GroupNumber); + + var req = await BuildV2RequestAsync(path, Method.Get, ct); + var resp = await _httpV2.ExecuteAsync(req, ct); + Logger.LogInformation( + "CERTInext V2 API call: Method=GET, Path={Path}, HttpStatus={Status}", + Constants.ApiV2.CatalogProductsPath, (int)resp.StatusCode); + ThrowOnV2Failure(resp, "V2 get product details"); + var result = ParseProductDetailsV2Response(resp.Content); + Logger.MethodExit(LogLevel.Trace); + return result; + } + + /// + public async IAsyncEnumerable ListOrdersV2Async( + string from = null, + string to = null, + int pageSize = Constants.Api.DefaultPageSize, + [EnumeratorCancellation] CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + EnsureV2Client(); + + // Server clamps size to 100 and treats page=0 as page 1 (issues/0022 Phase 0 probe); + // the client always sends 1-based pages itself so that quirk never surfaces here. + int size = pageSize <= 0 + ? Constants.Api.DefaultPageSize + : Math.Min(pageSize, Constants.ApiV2.OrdersReportMaxPageSize); + + int page = 1; + int totalPages = int.MaxValue; // sentinel until the first response tells us + + while (true) + { + ct.ThrowIfCancellationRequested(); + + var query = new StringBuilder(); + query.Append(Constants.ApiV2.OrdersReportPath) + .Append("?page=").Append(page) + .Append("&size=").Append(size); + if (!string.IsNullOrWhiteSpace(from)) + query.Append("&from=").Append(Uri.EscapeDataString(from)); + if (!string.IsNullOrWhiteSpace(to)) + query.Append("&to=").Append(Uri.EscapeDataString(to)); + // Scope the orders report to the connector's configured billing group, mirroring + // V1's DelegationInformation.GroupNumber. Omitted entirely when unconfigured so + // the account's default group is used, same as V1 (issue 0029). + if (!string.IsNullOrWhiteSpace(_config.GroupNumber)) + query.Append("&groupNumber=").Append(Uri.EscapeDataString(_config.GroupNumber)); + + var req = await BuildV2RequestAsync(query.ToString(), Method.Get, ct); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + var resp = await _httpV2.ExecuteAsync(req, ct); + sw.Stop(); + + Logger.LogInformation( + "CERTInext V2 API call: Method=GET, Path={Path}, Page={Page}, HttpStatus={Status}, LatencyMs={Latency}", + Constants.ApiV2.OrdersReportPath, page, (int)resp.StatusCode, sw.ElapsedMilliseconds); + + ThrowOnV2Failure(resp, "V2 list orders"); + var listResp = DeserializeV2OrThrow(resp, $"V2 list orders page {page}"); + + var rows = listResp.Content; + if (rows == null || rows.Count == 0) + break; + + if (page == 1) + totalPages = listResp.TotalPages > 0 ? listResp.TotalPages : 1; + + Logger.LogDebug( + "V2 orders report: fetched page {Page}/{TotalPages} with {Count} rows (totalElements={Total}).", + page, totalPages, rows.Count, listResp.TotalElements); + + foreach (var row in rows) + yield return row; + + if (page >= totalPages) + break; + + page++; + } + + Logger.MethodExit(LogLevel.Trace); + } + + /// + /// Minimal, read-only escape hatch for probing V2 endpoints that don't yet have a + /// typed client method (e.g. /reports/orders, /domains during discovery). + /// Issues a GET against the V2 base URL using the same token/header machinery as the + /// typed V2 methods, and returns the raw status/content instead of throwing on + /// non-success so callers can inspect 4xx/5xx bodies directly. Intended for + /// integration-test spikes — prefer a typed method once the response shape is known. + /// + public async Task<(int StatusCode, string ContentType, string Content)> ProbeV2GetAsync( + string pathAndQuery, CancellationToken ct = default) + { + Logger.MethodEntry(LogLevel.Trace); + EnsureV2Client(); + var req = await BuildV2RequestAsync(pathAndQuery, Method.Get, ct); + var resp = await _httpV2.ExecuteAsync(req, ct); + Logger.LogInformation( + "CERTInext V2 probe call: Method=GET, Path={Path}, HttpStatus={Status}", + pathAndQuery, (int)resp.StatusCode); + Logger.MethodExit(LogLevel.Trace); + return ((int)resp.StatusCode, resp.ContentType, resp.Content); + } + + // --------------------------------------------------------------------------- + // V2 private helpers + // --------------------------------------------------------------------------- + + /// + /// Parses the GET /api/certinext/v2/catalog/products response into a flat + /// list. The endpoint may return a bare JSON array + /// or a JSON object that wraps the list under a known property name + /// ("products", "data", "items", or "catalog"). Confirmed live 2026-09-24 + /// (issues/0025 step 0, issues/0016): the sandbox account returns the SAME nested + /// category-envelope shape as V1's GetProductDetails — each top-level array element + /// is a category ("categoryName"/"categoryID"/"currencyType") containing its own + /// nested "products" array of {productCode, productName, productTypeID, ...}. The + /// Postman spec's flat "productId" example is also handled as a fallback in case a + /// different account/API version returns it. Per-element, not per-response, so a + /// mixed response (unlikely but not contractually excluded) is still flattened. + /// + private List ParseProductDetailsV2Response(string content) + { + if (string.IsNullOrWhiteSpace(content)) + return new List(); + + using var doc = JsonDocument.Parse(content); + var root = doc.RootElement; + + JsonElement arr; + if (root.ValueKind == JsonValueKind.Array) + { + arr = root; + } + else if (root.ValueKind == JsonValueKind.Object) + { + // Log the top-level property names so the actual schema is visible in test output. + var keys = string.Join(", ", root.EnumerateObject().Select(p => p.Name)); + Logger.LogInformation( + "GetProductDetailsV2Async: response is a JSON object with top-level keys: [{Keys}]", keys); + + JsonElement? found = null; + foreach (string candidate in new[] { "products", "data", "items", "catalog" }) + { + if (root.TryGetProperty(candidate, out JsonElement candidateArr) && candidateArr.ValueKind == JsonValueKind.Array) + { + found = candidateArr; + break; + } + } + + if (found == null) + { + // No recognised array property found — surface the object keys in the exception + // so the caller/test can see the actual schema and create a proper DTO. + throw new InvalidOperationException( + $"V2 catalog/products returned an unexpected JSON object. Top-level keys: [{keys}]. " + + "Update ParseProductDetailsV2Response with the correct property name."); + } + + arr = found.Value; + } + else + { + throw new InvalidOperationException( + $"V2 catalog/products returned unexpected JSON kind: {root.ValueKind}."); + } + + var result = new List(); + foreach (var element in arr.EnumerateArray()) + { + if (element.ValueKind != JsonValueKind.Object) + continue; + + if (element.TryGetProperty("products", out JsonElement nestedProducts) + && nestedProducts.ValueKind == JsonValueKind.Array) + { + // Nested category envelope — mirror V1's GetProductDetailsResponse.FlattenProducts(). + string categoryName = element.TryGetProperty("categoryName", out var cn) && cn.ValueKind == JsonValueKind.String + ? cn.GetString() + : null; + + foreach (var product in nestedProducts.EnumerateArray()) + { + if (product.ValueKind != JsonValueKind.Object) + continue; + + result.Add(new ProductDetail + { + ProductCode = product.TryGetProperty("productCode", out var pc) && pc.ValueKind == JsonValueKind.String ? pc.GetString() : null, + ProductName = product.TryGetProperty("productName", out var pn) && pn.ValueKind == JsonValueKind.String ? pn.GetString() : null, + ProductType = categoryName, + ProductTypeId = product.TryGetProperty("productTypeID", out var pt) + ? (pt.ValueKind == JsonValueKind.String ? pt.GetString() : pt.ToString()) + : null, + Active = true // the API only returns products available on the account + }); + } + } + else if (element.TryGetProperty("productCode", out _)) + { + // Flat row already shaped like ProductDetail. + result.Add(JsonSerializer.Deserialize(element.GetRawText(), GetJsonOptions())); + } + else if (element.TryGetProperty("productId", out var pid)) + { + // Flat row using the Postman example's "productId" key instead of "productCode". + result.Add(new ProductDetail + { + ProductCode = pid.ValueKind == JsonValueKind.String ? pid.GetString() : pid.ToString(), + ProductName = element.TryGetProperty("productName", out var pn2) && pn2.ValueKind == JsonValueKind.String ? pn2.GetString() : null, + ProductType = element.TryGetProperty("masterProductName", out var mpn) && mpn.ValueKind == JsonValueKind.String ? mpn.GetString() : null, + ProductTypeId = element.TryGetProperty("productTypeID", out var pt2) + ? (pt2.ValueKind == JsonValueKind.String ? pt2.GetString() : pt2.ToString()) + : null, + Active = true + }); + } + else + { + Logger.LogWarning( + "GetProductDetailsV2Async: skipping catalog element with unrecognised shape. Keys: [{Keys}]", + string.Join(", ", element.EnumerateObject().Select(p => p.Name))); + } + } + + return result; + } + + private void EnsureV2Client() + { + if (_httpV2 == null) + throw new InvalidOperationException( + "V2 API client is not initialised. Ensure UseV2Api=true and ApiUrl is set in the connector configuration."); + } + + private static string BuildV2OrderPath(string productFamilySlug, string orderId) + => $"/api/certinext/v2/{productFamilySlug}/{orderId}"; + + /// + /// Probes all three V2 product families (SSL → Private PKI → Signature) to find + /// which one owns the given order ID. Returns the matching family slug and the + /// TrackOrder response. Throws if not found. + /// + private async Task<(string family, V2OrderStatusResponse status)> ResolveV2OrderFamilyAsync( + string orderId, + CancellationToken ct) + { + foreach (var family in new[] + { + Constants.ApiV2.FamilySsl, + Constants.ApiV2.FamilyPrivatePki, + Constants.ApiV2.FamilySignature + }) + { + try + { + var status = await TrackOrderV2Async(family, orderId, ct); + return (family, status); + } + catch (KeyNotFoundException) + { + // Not in this family — try the next one + } + } + throw new KeyNotFoundException($"V2 order '{orderId}' not found in any product family."); + } + + /// + /// Fetches or returns the cached V2 OAuth2 bearer token. + /// Uses standard client_credentials grant with form-encoded body. + /// Token is cached until 60 seconds before its expiry. + /// + private async Task GetOrRefreshV2TokenAsync(CancellationToken ct) + { + if (!string.IsNullOrEmpty(_v2Token) && DateTime.UtcNow < _v2TokenExpiry) + return _v2Token; + + await _v2TokenLock.WaitAsync(ct); + try + { + if (!string.IsNullOrEmpty(_v2Token) && DateTime.UtcNow < _v2TokenExpiry) + return _v2Token; + + Logger.LogInformation( + "V2 OAuth2 token acquisition started. ApiUrl={ApiUrl}, ClientId={ClientId}", + _config.ApiUrl, _config.OAuthClientId); + + string tokenUrl = _config.ApiUrl.TrimEnd('/') + Constants.ApiV2.TokenPath; + using var tokenClient = new RestClient(tokenUrl); + var tokenReq = new RestRequest(string.Empty, Method.Post); + tokenReq.AddHeader("Content-Type", "application/x-www-form-urlencoded"); + tokenReq.AddParameter("grant_type", "client_credentials"); + tokenReq.AddParameter("client_id", _config.OAuthClientId); + tokenReq.AddParameter("client_secret", _config.OAuthClientSecret); + + var tokenResp = await tokenClient.ExecuteAsync(tokenReq, ct); + if (!tokenResp.IsSuccessful || string.IsNullOrWhiteSpace(tokenResp.Content)) + { + // SOX CC6.1: never log tokenResp.Content — may contain client_secret. + // Per the V2 spec's OAuth2 error table: 401 invalid_client = wrong client_id / + // client_secret (or a revoked key); 403 unauthorized_client = the key exists but + // was never generated in OAuth mode in the portal. These are distinct failure + // modes with distinct fixes, so each gets its own hint rather than sharing one. + if ((int)tokenResp.StatusCode == 401) + { + Logger.LogError( + "V2 OAuth2 token acquisition failed with 401 Unauthorized (invalid_client). " + + "ApiUrl={ApiUrl}, ClientId={ClientId}. " + + "Hint: the OAuthClientId or OAuthClientSecret is wrong, or the key was revoked in the portal.", + _config.ApiUrl, _config.OAuthClientId); + throw new Exception( + "V2 OAuth2 token request denied (401 Unauthorized, invalid_client). " + + "The OAuthClientId or OAuthClientSecret is incorrect, or the key was revoked. " + + "Regenerate the client secret in the CERTInext portal (Integration → REST APIs → OAuth2) " + + "and update the connector config. See gateway logs for details."); + } + if ((int)tokenResp.StatusCode == 403) + { + Logger.LogError( + "V2 OAuth2 token acquisition failed with 403 Forbidden (unauthorized_client). " + + "ApiUrl={ApiUrl}, ClientId={ClientId}. " + + "Hint: the access key exists but was not generated in OAuth mode in the portal.", + _config.ApiUrl, _config.OAuthClientId); + throw new Exception( + "V2 OAuth2 token request denied (403 Forbidden, unauthorized_client). " + + "The access key was not generated in OAuth mode. Recreate the key in the CERTInext " + + "portal (Integration → REST APIs → OAuth2) with the OAuth radio button selected. " + + "See gateway logs for details."); + } + Logger.LogError( + "V2 OAuth2 token acquisition failed. ApiUrl={ApiUrl}, ClientId={ClientId}, HttpStatus={Status}", + _config.ApiUrl, _config.OAuthClientId, (int)tokenResp.StatusCode); + throw new Exception( + $"Failed to obtain V2 OAuth2 token. HTTP {(int)tokenResp.StatusCode}. See gateway logs for details."); + } + + var tokenPayload = JsonSerializer.Deserialize(tokenResp.Content, GetJsonOptions()); + if (tokenPayload == null || string.IsNullOrEmpty(tokenPayload.AccessToken)) + { + Logger.LogError( + "V2 OAuth2 token response did not contain access_token. ApiUrl={ApiUrl}", + _config.ApiUrl); + throw new Exception("V2 OAuth2 token response did not contain an access_token."); + } + + _v2Token = tokenPayload.AccessToken; + _v2TokenExpiry = DateTime.UtcNow.AddSeconds(Math.Max(tokenPayload.ExpiresIn - 60, 30)); + + Logger.LogInformation( + "V2 OAuth2 token acquired. ApiUrl={ApiUrl}, ClientId={ClientId}, ExpiresAt={Expiry:u}", + _config.ApiUrl, _config.OAuthClientId, _v2TokenExpiry); + return _v2Token; + } + finally + { + _v2TokenLock.Release(); + } + } + + /// + /// Builds a V2 REST request with the Authorization: Bearer header populated from + /// the cached/refreshed V2 token. Optionally adds an Idempotency-Key header. + /// + private async Task BuildV2RequestAsync( + string path, + Method method, + CancellationToken ct, + string idempotencyKey = null) + { + string token = await GetOrRefreshV2TokenAsync(ct); + var req = new RestRequest(path, method); + req.AddHeader("Authorization", $"Bearer {token}"); + req.AddHeader("Accept", "application/json"); + if (!string.IsNullOrEmpty(idempotencyKey)) + req.AddHeader("Idempotency-Key", idempotencyKey); + return req; + } + + /// + /// Throws an appropriate exception for V2 API non-success responses. + /// Handles RFC 7807 problem+json and plain HTTP errors. + /// + // Instance (not static) — calls LogV2ApiFailure, which needs _config.LogSensitiveRequestData. + private void ThrowOnV2Failure(RestResponse resp, string operation) + { + if (resp.IsSuccessful) return; + + if (resp.StatusCode == HttpStatusCode.Unauthorized) + { + LogV2ApiFailure(operation, resp, LogLevel.Error); + throw new Exception($"V2 authentication failure during '{operation}'. HTTP 401. See gateway logs for details."); + } + + if (resp.StatusCode == HttpStatusCode.Forbidden) + { + LogV2ApiFailure(operation, resp, LogLevel.Error); + string hint = ExtractV2ErrorMessage(resp.Content, operation); + throw new Exception( + $"V2 access denied during '{operation}'. HTTP 403. {hint} " + + "If error code is EMS-2022, ensure OAuth2 is enabled in the CERTInext portal."); + } + + LogV2ApiFailure(operation, resp, LogLevel.Warning); + string msg = ExtractV2ErrorMessage(resp.Content, operation); + throw new Exception($"CERTInext V2 API error during '{operation}'. HTTP {(int)resp.StatusCode}. {msg}"); + } + + /// + /// Writes a structured log for a V2 API non-success response — matching the V1 + /// pattern but adapted for V2's RFC 7807 error shape. + /// Call immediately before throwing so the exception's "See gateway logs for details" + /// message has a corresponding structured entry in the gateway log. + /// + // Instance (not static) so it can read _config.LogSensitiveRequestData — see issue 0040. + private void LogV2ApiFailure(string operation, RestResponse resp, LogLevel level = LogLevel.Warning) + { + string sanitizedBody = Truncate( + ApplyLoggingRedaction(resp?.Content, _config.LogSensitiveRequestData) ?? "(empty)", + LoggedResponseBodyCapBytes); + Logger.Log( + level, + "CERTInext V2 API non-success. Operation={Operation}, Method={Method}, Path={Path}, " + + "HttpStatus={HttpStatus}, ResponseBody={ResponseBody}", + operation, + resp?.Request?.Method.ToString() ?? "(unknown)", + resp?.Request?.Resource ?? "(unknown)", + (int?)resp?.StatusCode ?? 0, + sanitizedBody); + } + + /// + /// Parses an RFC 7807 problem+json body and returns a human-readable message. + /// Falls back to a generic message on parse failure. + /// + private static string ExtractV2ErrorMessage(string content, string operation) + { + if (string.IsNullOrWhiteSpace(content)) + return $"CERTInext V2 returned no body for '{operation}'."; + + string capped = content.Length > MaxErrorBodyBytes + ? content.Substring(0, MaxErrorBodyBytes) + : content; + + try + { + var problem = JsonSerializer.Deserialize(capped, GetJsonOptions()); + if (problem != null && (!string.IsNullOrWhiteSpace(problem.Detail) || !string.IsNullOrWhiteSpace(problem.Title) + || (problem.Errors != null && problem.Errors.Count > 0))) + { + string msg = $"{problem.Title}: {problem.Detail}".Trim(':').Trim(); + + // RFC 7807 per-field validation errors (spec's "errors[]", e.g. a 400 on + // order create naming exactly which field failed) — fold them into the + // message so the operator doesn't have to go dig the raw response out of + // the gateway log to find out which field CERTInext rejected. + if (problem.Errors != null && problem.Errors.Count > 0) + { + string fieldErrors = string.Join("; ", problem.Errors + .Where(e => !string.IsNullOrWhiteSpace(e?.Field) || !string.IsNullOrWhiteSpace(e?.Message)) + .Select(e => $"{e.Field}: {e.Message}".Trim(':').Trim())); + if (!string.IsNullOrWhiteSpace(fieldErrors)) + msg = string.IsNullOrWhiteSpace(msg) ? fieldErrors : $"{msg} [{fieldErrors}]"; + } + + if (!string.IsNullOrWhiteSpace(msg)) + return msg; + } + } + catch + { + // Not a problem+json body — fall through + } + + return $"See gateway logs for raw response. Operation='{operation}'."; + } + + private static T DeserializeV2OrThrow(RestResponse resp, string operation) where T : class + { + if (string.IsNullOrWhiteSpace(resp.Content)) + throw new Exception($"CERTInext V2 returned an empty body for '{operation}'."); + var result = JsonSerializer.Deserialize(resp.Content, GetJsonOptions()); + if (result == null) + throw new Exception($"CERTInext V2 returned a null/unrecognised body for '{operation}'."); + return result; + } + + // --------------------------------------------------------------------------- + // V1 token helper (unchanged) + // --------------------------------------------------------------------------- + + private async Task GetOrRefreshTokenAsync(CancellationToken ct) + { + if (!string.IsNullOrEmpty(_cachedToken) && DateTime.UtcNow < _tokenExpiry) + return _cachedToken; + + await _tokenLock.WaitAsync(ct); + try + { + if (!string.IsNullOrEmpty(_cachedToken) && DateTime.UtcNow < _tokenExpiry) + return _cachedToken; + + Logger.LogInformation( + "OAuth2 token acquisition attempt started. TokenUrl={TokenUrl}, ClientId={ClientId}", + _config.OAuthTokenUrl, _config.OAuthClientId); + + using var tokenClient = new RestClient(_config.OAuthTokenUrl); + var tokenReq = new RestRequest(string.Empty, Method.Post); + tokenReq.AddHeader("Content-Type", "application/x-www-form-urlencoded"); + tokenReq.AddParameter("grant_type", "client_credentials"); + tokenReq.AddParameter("client_id", _config.OAuthClientId); + tokenReq.AddParameter("client_secret", _config.OAuthClientSecret); + + var tokenResp = await tokenClient.ExecuteAsync(tokenReq, ct); + if (!tokenResp.IsSuccessful || string.IsNullOrWhiteSpace(tokenResp.Content)) + { + // SOX CC6.1 (credential confidentiality): NEVER log tokenResp.Content, + // tokenResp.ErrorMessage, or tokenResp.ErrorException — RestSharp's + // failure paths can echo the original request including the + // `client_secret` form value. Only StatusCode + non-secret config + // identifiers are safe to log here. + Logger.LogError( + "OAuth2 token acquisition failed. TokenUrl={TokenUrl}, ClientId={ClientId}, HttpStatus={Status}", + _config.OAuthTokenUrl, _config.OAuthClientId, (int)tokenResp.StatusCode); + throw new Exception( + $"Failed to obtain OAuth2 token from CERTInext. HTTP {(int)tokenResp.StatusCode}. See gateway logs for details."); + } + + var tokenPayload = JsonSerializer.Deserialize(tokenResp.Content, GetJsonOptions()); + + if (tokenPayload == null || string.IsNullOrEmpty(tokenPayload.AccessToken)) + { + Logger.LogError( + "OAuth2 token acquisition failed — response did not contain access_token. TokenUrl={TokenUrl}", + _config.OAuthTokenUrl); + throw new Exception("OAuth2 token response from CERTInext did not contain an access_token."); + } + + _cachedToken = tokenPayload.AccessToken; + _tokenExpiry = DateTime.UtcNow.AddSeconds(Math.Max(tokenPayload.ExpiresIn - 60, 30)); + + Logger.LogInformation( + "OAuth2 token acquired. TokenUrl={TokenUrl}, ClientId={ClientId}, ExpiresAt={Expiry:u}", + _config.OAuthTokenUrl, _config.OAuthClientId, _tokenExpiry); + return _cachedToken; + } + finally + { + _tokenLock.Release(); + } + } + + // --------------------------------------------------------------------------- + // Retry helper + // --------------------------------------------------------------------------- + + /// + /// Executes a with up to /// attempts, retrying on HTTP 5xx and network-level failures (no status code). /// 4xx responses are returned immediately — client errors will not be resolved /// by retrying. + /// + /// When is false the request is sent exactly + /// once and transient failures are NOT retried. This is required for non-idempotent + /// order-submission calls: a network-level timeout can occur *after* CERTInext has + /// already received and created the order, so re-sending the same body (same + /// requestTxn) is rejected as "Duplicate requestTxn" (EMS-947) and orphans the + /// order the first attempt actually created. /// private async Task ExecuteWithRetryAsync( RestRequest req, CancellationToken ct, - int maxAttempts = 3) + int maxAttempts = 3, + bool idempotent = true) { + int attempts = idempotent ? maxAttempts : 1; RestResponse resp = null; - for (int attempt = 1; attempt <= maxAttempts; attempt++) + var sw = System.Diagnostics.Stopwatch.StartNew(); + for (int attempt = 1; attempt <= attempts; attempt++) { resp = await _http.ExecuteAsync(req, ct); - // Success or 4xx client error — return immediately + // Success or 4xx client error — return immediately, checked BEFORE the + // cancellation check below. `_http.ExecuteAsync` already ran to completion by the + // time control reaches this line; whether `ct` has *since* flipped to cancelled is + // a separate, unsynchronized fact (a check-after-await race, not a fabricated one — + // a CancellationTokenSource(TimeSpan) callback and this awaited Task's completion + // are not mutually exclusive events). A deadline (the shared DcvTimeoutMinutes + // budget) firing at essentially the same instant a call genuinely succeeded must not + // discard that success: for VerifyDcv specifically, discarding it here would abort + // PerformDcvIfNeededAsync's loop before WaitForDcvVerificationAsync ever ran, and + // its finally block would delete the just-staged TXT record even though CERTInext + // had genuinely received the verify trigger — turning a real CA-side success into a + // self-inflicted DCV failure. bool isClientError = (int)resp.StatusCode >= 400 && (int)resp.StatusCode < 500; if (resp.IsSuccessful || isClientError) return resp; - if (attempt < maxAttempts) + // Only for a call that did NOT succeed: this client is built with + // ThrowOnAnyError=false (see the constructor), so a cancelled ct does not surface as + // OperationCanceledException from ExecuteAsync — RestSharp catches + // HttpClient.SendAsync's cancellation internally and returns a non-throwing, + // unsuccessful RestResponse instead. Left unchecked, that response reaches + // DeserializeOrThrow and becomes a plain Exception indistinguishable from a genuine + // API failure — which is exactly how a caller such as PerformDcvIfNeededAsync's + // shared DCV-timeout cancellation was still landing in a generic "GetDcv failed" + // per-domain catch instead of the cancellation-specific one, even after that method + // was hardened to re-throw a real OperationCanceledException past its per-domain + // catches. Surface the true cancellation here, at the one place in the client that + // actually holds `ct`, before any retry or error-wrapping logic sees the response. + // + // Throwing here means every caller's own per-call audit line (Method/Path/HttpStatus/ + // LatencyMs, logged after ExecuteWithRetryAsync returns) never executes for the + // cancelled call — that specific attempt would otherwise vanish from the audit trail + // entirely, leaving only a coarser, order-level "unexpected failure" log with no + // domain/endpoint/status/latency. Log that record here instead, at the one place that + // reliably sees every cancellation regardless of which of ExecuteWithRetryAsync's ~10 + // callers is in flight. + if (ct.IsCancellationRequested) + { + Logger.LogWarning( + "CERTInext API call cancelled: Method={Method}, Path={Path}, HttpStatus={Status}, " + + "ResponseStatus={ResponseStatus}, LatencyMs={Latency}, Attempt={Attempt}/{Max}.", + req.Method, req.Resource, (int)resp.StatusCode, resp.ResponseStatus, + sw.ElapsedMilliseconds, attempt, attempts); + } + ct.ThrowIfCancellationRequested(); + + if (attempt < attempts) { Logger.LogWarning( "CERTInext API returned {Status} on attempt {Attempt}/{Max} — retrying...", - (int)resp.StatusCode, attempt, maxAttempts); + (int)resp.StatusCode, attempt, attempts); } } @@ -1053,42 +2362,107 @@ private static LegacyGetCertificateResponse MapOrderReportEntryToLegacy(OrderRep { // Note: GetOrderReport does not return requestor name/email in the ordersArray. // Those fields are only available via TrackOrder on individual orders. + System.DateTime? orderDate = null; + if (!string.IsNullOrWhiteSpace(entry.OrderDate) + && System.DateTime.TryParse(entry.OrderDate, + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, + out var parsed)) + { + orderDate = parsed; + } + return new LegacyGetCertificateResponse { Id = string.IsNullOrWhiteSpace(entry.OrderNumber) ? entry.RequestNumber : entry.OrderNumber, Status = MapCertStatusIdToLegacyString(entry.CertificateStatusId), Subject = entry.DomainName, - ProfileId = entry.ProductCode + ProfileId = entry.ProductCode, + OrderDate = orderDate }; } private GenerateOrderSslRequest BuildOrderRequestFromLegacyEnrollRequest(EnrollCertificateRequest request) { + // ValidityYears takes precedence; ValidityDays is converted to years as a fallback. + string validityYears = request.ValidityYears.HasValue + ? request.ValidityYears.Value.ToString() + : request.ValidityDays.HasValue + ? Math.Ceiling(request.ValidityDays.Value / 365.0).ToString("0") + : (string.IsNullOrWhiteSpace(_config.SubscriptionValidityYears) + ? "1" + : _config.SubscriptionValidityYears); + + string requestorName = request.RequesterName ?? _config.RequestorName ?? "Keyfactor Gateway"; + string requestorEmail = request.RequesterEmail ?? _config.RequestorEmail ?? string.Empty; + string requestorIsd = string.IsNullOrWhiteSpace(_config.RequestorIsdCode) ? "1" : _config.RequestorIsdCode; + string requestorMobile = _config.RequestorMobileNumber ?? string.Empty; + + // Hoisted: additionalDomains is de-duplicated against the primary domain, so both + // fields have to be built from the same value. + string domainName = ExtractCnFromSubject(request.Subject) ?? "unknown"; + return new GenerateOrderSslRequest { // Meta will be set by PlaceOrderAsync OrderDetails = new SslOrderDetails { ProductCode = request.ProfileId ?? _config.DefaultProductCode ?? string.Empty, + AccountingModel = string.IsNullOrWhiteSpace(_config.AccountingModel) ? "2" : _config.AccountingModel, SaveAndHold = "0", + EmailNotifications = string.IsNullOrWhiteSpace(_config.EmailNotifications) ? "0" : _config.EmailNotifications, + + // delegationInformation — routes the order to the configured account group. + // Omitted entirely when GroupNumber is blank (the model JsonIgnore-WhenNull + // handles property absence further down). + DelegationInformation = !string.IsNullOrWhiteSpace(_config.GroupNumber) + ? new DelegationInformation { GroupNumber = _config.GroupNumber } + : null, + + // organizationDetails — declares pre-vetted org when configured. This is the + // single biggest factor in how quickly CERTInext releases an order from + // Pending System RA. When OrganizationNumber is blank we omit the whole + // block (the model is JsonIgnore-WhenNull) so the order falls back to the + // unvetted path — same behavior as the prior plugin builds. + OrganizationDetails = !string.IsNullOrWhiteSpace(_config.OrganizationNumber) + ? new OrganizationDetails + { + PreVetting = "1", + OrganizationNumber = _config.OrganizationNumber + } + : null, + RequestorInformation = new RequestorInformation { - RequestorName = request.RequesterName ?? _config.RequestorName ?? "Keyfactor Gateway", - RequestorEmail = request.RequesterEmail ?? _config.RequestorEmail ?? string.Empty, - RequestorIsdCode = _config.RequestorIsdCode ?? "1", - RequestorMobileNumber = _config.RequestorMobileNumber ?? string.Empty + RequestorName = requestorName, + RequestorEmail = requestorEmail, + RequestorIsdCode = requestorIsd, + RequestorMobileNumber = requestorMobile, + RequestorDesignation = string.IsNullOrWhiteSpace(_config.RequestorDesignation) ? null : _config.RequestorDesignation.Trim() }, SubscriptionDetails = new SubscriptionDetails { - Validity = request.ValidityDays.HasValue - ? Math.Ceiling(request.ValidityDays.Value / 365.0).ToString("0") - : "1" + Validity = validityYears, + AutoRenew = string.IsNullOrWhiteSpace(_config.SubscriptionAutoRenew) ? "0" : _config.SubscriptionAutoRenew, + RenewCriteria = string.IsNullOrWhiteSpace(_config.SubscriptionRenewCriteriaDays) ? "30" : _config.SubscriptionRenewCriteriaDays }, CertificateInformation = new CertificateInformation { - DomainName = ExtractCnFromSubject(request.Subject) ?? "unknown", - AdditionalDomains = BuildAdditionalDomains(request.Sans) + DomainName = domainName, + AdditionalDomains = BuildAdditionalDomains(request.Sans, domainName), + AutoSecureWww = string.IsNullOrWhiteSpace(_config.AutoSecureWww) ? "0" : _config.AutoSecureWww }, + + // technicalPointOfContact — each field falls back to the requestor default + // when its TechnicalContact* counterpart is blank. + TechnicalPointOfContact = new TechnicalPointOfContact + { + TpcName = string.IsNullOrWhiteSpace(_config.TechnicalContactName) ? requestorName : _config.TechnicalContactName, + TpcEmail = string.IsNullOrWhiteSpace(_config.TechnicalContactEmail) ? requestorEmail : _config.TechnicalContactEmail, + TpcIsdCode = string.IsNullOrWhiteSpace(_config.TechnicalContactIsdCode) ? requestorIsd : _config.TechnicalContactIsdCode, + TpcMobileNumber = string.IsNullOrWhiteSpace(_config.TechnicalContactMobileNumber) ? requestorMobile : _config.TechnicalContactMobileNumber + }, + Csr = request.Csr, AgreementDetails = BuildDefaultAgreementDetails(), AdditionalInformation = new AdditionalInformation @@ -1101,12 +2475,28 @@ private GenerateOrderSslRequest BuildOrderRequestFromLegacyEnrollRequest(EnrollC private AgreementDetails BuildDefaultAgreementDetails() { + // SOC1 accuracy-of-processing: the subscriber agreement is a legal artefact + // and the SignerIp it carries is part of the audit record CERTInext stores. + // Submitting 127.0.0.1 is a misrepresentation. We retain the fallback so we + // don't break existing deployments (and our enrollment never fails just + // because SignerIp is blank), but a missing value emits a Warning so an + // auditor sees the misrepresentation as an actionable signal in the gateway log. + string signerIp = _config.SignerIp; + if (string.IsNullOrWhiteSpace(signerIp)) + { + Logger.LogWarning( + "Connector config SignerIp is empty — falling back to 127.0.0.1 for the " + + "subscriber agreement. Set the SignerIp config field to the gateway host's " + + "actual public-routable IP so the audit record is accurate."); + signerIp = "127.0.0.1"; + } return new AgreementDetails { AcceptAgreement = "1", SignerName = _config.RequestorName ?? "Keyfactor Gateway", + // Effectively dead fallback (issue 0039): SignerPlace defaults to "", not null, so a blank setting sends "" (only an explicit JSON null reaches "Gateway"). V1 wire behaviour intentionally unchanged. SignerPlace = _config.SignerPlace ?? "Gateway", - SignerIp = _config.SignerIp ?? "127.0.0.1" + SignerIp = signerIp }; } @@ -1122,16 +2512,57 @@ private static string ExtractCnFromSubject(string subject) return null; } - private static List BuildAdditionalDomains(System.Collections.Generic.List sans) + /// + /// Projects the resolved SAN list onto certificateInformation.additionalDomains. + /// + /// Every requested SAN is submitted regardless of type. Filtering to DNS-only (the + /// original behaviour) issued certificates quietly missing names the subscriber had + /// requested, which is the worse failure; the caller warns about the non-DNS entries + /// before we get here. + /// + /// is the value already going out as the order's primary + /// domain, and Command normally includes the CN in the SAN set as well. On the US + /// sandbox CERTInext was measured to collapse that repetition itself + /// (SanSubmissionProbeTests: CN submitted twice came back registered once), but that is + /// undocumented and unverified against production — which is exactly why we exclude it + /// here rather than relying on CA-side de-duplication. It also keeps the submitted body + /// matching what we log. + /// + private List BuildAdditionalDomains( + System.Collections.Generic.List sans, + string domainName) { if (sans == null || sans.Count == 0) return null; + var domains = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + bool haveDomainName = !string.IsNullOrWhiteSpace(domainName); + if (haveDomainName) + seen.Add(domainName.Trim()); + + int duplicates = 0; foreach (var san in sans) { - if (string.Equals(san.Type, "dns", StringComparison.OrdinalIgnoreCase) && - !string.IsNullOrWhiteSpace(san.Value)) - domains.Add(san.Value); + if (san == null || string.IsNullOrWhiteSpace(san.Value)) continue; + + string value = san.Value.Trim(); + if (!seen.Add(value)) + { + duplicates++; + continue; + } + domains.Add(value); } + + if (duplicates > 0) + { + Logger.LogDebug( + "Collapsed {Count} duplicate SAN value(s) out of additionalDomains " + + "(already submitted as domainName '{DomainName}', or repeated in the SAN set).", + duplicates, LogSanitizer.Strip(domainName)); + } + return domains.Count > 0 ? domains : null; } @@ -1139,14 +2570,17 @@ private static List BuildAdditionalDomains(System.Collections.Generic.Li // Deserialization helpers // --------------------------------------------------------------------------- - private static T DeserializeOrThrow(RestResponse resp, string operation) where T : class + // Instance (not static) — calls LogApiFailure, which needs _config.LogSensitiveRequestData. + private T DeserializeOrThrow(RestResponse resp, string operation) where T : class { if (!resp.IsSuccessful) { - string errMsg = ExtractErrorMessage(resp.Content, operation); - Logger.LogError( - "CERTInext API error during '{Operation}': HttpStatus={Status}, Error={Error}", - operation, (int)resp.StatusCode, errMsg); + // Issue 0044: V1 documents errors only as HTTP-200 meta envelopes, so a non-2xx + // body here is usually not from the V1 application at all (e.g. ApiUrl missing the + // /emSignHub-API/ segment). Log the redacted body and put the HTTP status in the + // message so "See gateway logs for details" has something to point at. + string errMsg = ExtractErrorMessage(resp.Content, operation, (int)resp.StatusCode); + LogApiFailure(operation, resp, errorMessage: errMsg, level: LogLevel.Error); throw new Exception(errMsg); } @@ -1171,10 +2605,444 @@ private static T DeserializeOrThrow(RestResponse resp, string operation) wher return result; } - private static string ExtractErrorMessage(string content, string operation) + // SOC2 CC7.2 DoS guard: cap the size of any response body we parse here. CERTInext + // error envelopes are always under a few KB; a multi-MB body is either a misrouted + // response or a hostile payload aimed at exhausting our JsonDocument buffer. + private const int MaxErrorBodyBytes = 64 * 1024; + + // --------------------------------------------------------------------------- + // Rate-limit retry — see GitHub issue #8. + // + // The CERTInext sandbox returns the generic string "Inactive Account User." for + // several distinct conditions including burst-rate-limit rejection. Empirically + // this resolves within seconds — auto-retrying lets a transient burst limit hit + // transparently while still surfacing the original exception text for genuinely + // inactive accounts (after RateLimitMaxAttempts the throw is unchanged). + // --------------------------------------------------------------------------- + + private const int RateLimitMaxAttempts = 5; + private const double RateLimitBaseBackoffSeconds = 1.0; + + /// + /// True when matches the documented rate-limit + /// surface CERTInext uses on its sandbox. Substring + case-insensitive match; + /// the trailing punctuation/whitespace varies across observed payloads. + /// + /// + /// Contract: callers MUST only invoke this inside the + /// !result.Meta.IsSuccess branch of an API response. CERTInext's + /// successful responses are not currently observed to include this phrase, + /// but the predicate is intentionally permissive to handle CA-side wording + /// drift, and we want the safety net of the surrounding failure context. + /// + /// + /// + /// Known cost: a genuinely-inactive account (admin disabled, billing + /// hold) returns the same error string as a rate-limit hit. Today there is + /// no distinguishing errorCode field in the observed payloads, so + /// callers gated by this predicate will exhaust their full retry budget + /// (5 attempts × ~31 s total wait) before propagating the original failure + /// to the gateway. Quota cost: up to 5 enrollment attempts per affected + /// call. See GitHub issue #8 for the discussion. + /// + /// + internal static bool IsRateLimitSurface(string errorMessage) + { + if (string.IsNullOrEmpty(errorMessage)) return false; + return errorMessage.IndexOf("Inactive Account User", StringComparison.OrdinalIgnoreCase) >= 0; + } + + /// + /// Exponential backoff with ±25% jitter for the rate-limit retry inside + /// . Attempts 1..5 produce roughly + /// 1s / 2s / 4s / 8s / 16s of nominal delay. + /// + /// + /// Thundering-herd assumption: jitter is sampled from a process-wide + /// (_txnRandom), + /// so concurrent callers in the same process get independent samples. + /// Multiple gateway pods hitting the same CERTInext tenant each have their + /// own seeded instance, so jitter is also independent across pods. The + /// ±25% spread on the 16s nominal at attempt 5 produces a 4s window — wide + /// enough to de-correlate from the documented "~16 orders / 10 s" sandbox + /// limit if a multi-pod fleet hits the limit simultaneously. + /// + /// + /// Exposed internal so unit tests can verify the schedule. + /// + internal static double ComputeRateLimitBackoffSeconds(int attempt) + { + if (attempt < 1) attempt = 1; + double nominal = RateLimitBaseBackoffSeconds * Math.Pow(2, attempt - 1); + // ±25% jitter via SecureRandom — non-cryptographic randomness is fine for + // jitter, but we already have a SecureRandom instance for txn IDs and + // reusing it is one fewer source of randomness to think about. + double jitterFactor = 0.75 + _txnRandom.NextDouble() * 0.5; + return nominal * jitterFactor; + } + + // Cap on the response body length we include in operator-facing warning logs. + // 4 KB is comfortably more than every observed CERTInext error envelope (typically + // <500 B) while still bounding the log line if a misrouted response ever shows up. + // See GitHub issue #8 — operators need the raw body to disambiguate misleading + // CA error strings (e.g. the sandbox's "Inactive Account User." rate-limit surface). + private const int LoggedResponseBodyCapBytes = 4 * 1024; + + /// + /// Truncates to at most characters, + /// appending a "(truncated, N more chars)" marker so log readers can tell at a + /// glance that the value was cut. Returns the input unchanged when short enough. + /// + private static string Truncate(string s, int max) + { + if (string.IsNullOrEmpty(s) || s.Length <= max) return s; + return s.Substring(0, max) + $"…(truncated, {s.Length - max} more chars)"; + } + + /// + /// Scrubs known credential-bearing keys out of a JSON-ish body before it goes + /// into a log line. CERTInext error envelopes are not currently observed to + /// echo request fields, but the response shape isn't contractually fixed and + /// the authKey digest in the request meta block IS a replayable + /// privileged credential under SOX (anyone with one valid + /// (ts, txn, authKey) triple can replay until the timestamp window expires). + /// Defense-in-depth: redact before logging, not after a leak. + /// + /// Conservative substring/regex pass — handles JSON, form-urlencoded, and + /// header-line shapes. Exposed internal for unit-testing. + /// + internal static string RedactCredentials(string body) + { + if (string.IsNullOrEmpty(body)) return body; + + // JSON: "authKey": "..." → "authKey":"***REDACTED***" + // JSON: "client_secret":"..." → same + // JSON: "ApiKey":"..." → same (defensive — not currently sent on the wire, + // but the field name is a common one and the cost of redacting it is zero). + body = System.Text.RegularExpressions.Regex.Replace( + body, + @"(?i)""(authKey|client_secret|apiKey|accessKey|password)""\s*:\s*""[^""]*""", + @"""$1"":""***REDACTED***"""); + + // Form-urlencoded: client_secret=... or authKey=... (before any & or end) + body = System.Text.RegularExpressions.Regex.Replace( + body, + @"(?i)\b(authKey|client_secret|apiKey|accessKey|password)=([^&\s""]+)", + "$1=***REDACTED***"); + + // Authorization header lines if a header dump ever ends up in body shape. + // Match through end-of-line so multi-token values (e.g. "Bearer ") + // are fully scrubbed, not just the scheme word. + body = System.Text.RegularExpressions.Regex.Replace( + body, + @"(?im)^Authorization:[^\r\n]*", + "Authorization: ***REDACTED***"); + + return body; + } + + // Exact JSON key names that carry a person's email address across the V1 and V2 wire + // shapes (see CERTInext/API/CertificateRequest.cs and CERTInext/API/V2/CertificateRequestV2.cs). + // Every one of these is a full, exact key — never a substring of an unrelated key (e.g. + // "domainName"/"organizationName" do not end in a bare "email" key) — so matching the key + // by exact name cannot cross-contaminate unrelated fields. + private static readonly string[] PersonalEmailFieldNames = + { + "requestorEmail", "requesterEmail", "tpcEmail", "requestorEmailId", "dcvEmail", "email" + }; + + // Exact JSON key names carrying other person/contact data (name, phone/ISD/mobile, + // designation, signer place/IP). "name" is bare only inside the V2 requestor / + // technicalPointOfContact blocks in every currently-logged body — it is never used as an + // exact top-level key anywhere else on the CERTInext wire shapes this plugin logs raw. + // + // Issue 0033: the V2 Document Signer (signature) body's `subject` block and its create + // response add a natural person's name, identity-document and street-address fields + // (firstName, lastName, identityDocumentType, identificationNumber, streetAddress1/2, + // locality, postalCode) and a `subjectDisplayName` (full name for natural/legal person). + // subject.email / subject.phone / subject.designation are already covered by the bare + // "email"/"phone"/"designation" keys. Deliberately NOT added: organizationName, + // organizationUnit, organizationIdentificationNumber, businessCategory, state, + // countryCode — organization or coarse-location data, and "organizationName" is also a + // V1 order/report key whose value is an OV organization, not a person. The V2 + // private-pki body (issue 0033) adds no new personal keys: its requestor / + // technicalPointOfContact blocks reuse the bare keys above, and hostname / + // additionalHosts are host names / IP literals, not personal data. + private static readonly string[] PersonalOtherFieldNames = + { + "requestorName", "requesterName", "tpcName", "signerName", "name", + "requestorIsdCode", "requestorMobileNumber", "requestorDesignation", + "tpcIsdCode", "tpcMobileNumber", "signerPlace", "signedPlace", "signerip", "phone", "designation", + "firstName", "lastName", "subjectDisplayName", "identityDocumentType", "identificationNumber", + "streetAddress1", "streetAddress2", "locality", "postalCode" + }; + + /// + /// Scrubs known person/contact-bearing keys out of a JSON-ish body before it goes into a + /// log line, when LogSensitiveRequestData is off (issue 0040). Covers the V1 + /// requestorInformation / technicalPointOfContact / agreementDetails + /// shapes (requestorName, requestorEmail, requestorIsdCode, + /// requestorMobileNumber, requestorDesignation, tpcName, + /// tpcEmail, tpcIsdCode, tpcMobileNumber, signerName, + /// signerPlace, signerIP/signerIp, the legacy requesterName/ + /// requesterEmail aliases, and the requestorEmailId search filter) and the + /// V2 nested requestor / technicalPointOfContact shapes (bare name/ + /// email/phone/designation), plus the V2 Document Signer + /// subject block's person fields and subjectDisplayName (issue 0033 — see + /// PersonalOtherFieldNames for the exact list and what is deliberately excluded). + /// + /// Email values are masked via so the domain stays + /// visible (e.g. "j***@example.com") while the local part is hidden. Every other + /// matched field is replaced outright with "***REDACTED***". Fields that are + /// already blank/empty on the wire are left untouched — there is nothing to redact. + /// + /// Conservative substring/regex pass, same style as — + /// tolerant of whitespace around the JSON key : value separator (including + /// pretty-printed bodies), and anchored on the opening/closing quote of the key so it + /// cannot match a key name as a substring of a longer one. Email SANs inside SAN arrays + /// (additionalDomains/additionalHosts) and domainVerification keys are + /// masked afterwards by . Exposed internal + /// for unit testing. + /// + internal static string RedactPersonalData(string body) + { + if (string.IsNullOrEmpty(body)) return body; + + foreach (var key in PersonalEmailFieldNames) + body = RedactJsonField(body, key, LogSanitizer.MaskEmail); + + foreach (var key in PersonalOtherFieldNames) + body = RedactJsonField(body, key, _ => "***REDACTED***"); + + return MaskEmailsInSanContainers(body); + } + + // Exact JSON keys whose value is an array of SAN strings. V1 additionalDomains carries every + // requested SAN regardless of type, emails included (SanSubmissionProbeTests finding B). + // V2 SSL additionalDomains and private-pki additionalHosts are filtered to DNS / IP before + // submission, so they are covered only as defence in depth: a DNS name or IP literal never + // contains '@', so masking there can only ever touch a mis-typed email. + private static readonly string[] SanArrayFieldNames = { "additionalDomains", "additionalHosts" }; + + // Exact JSON keys whose value is an object keyed by SAN value. The V1 TrackOrder + // domainVerification block is { "": { ... }, "status": "..." }, and an email + // submitted in additionalDomains comes back as one of those keys (SanSubmissionProbeTests + // finding B: "san-probe@example.com" was returned as a domainVerification key). + private static readonly string[] SanKeyedObjectFieldNames = { "domainVerification" }; + + /// + /// Masks email addresses (issue 0040 follow-up) in the two SAN-bearing container shapes the + /// key/value regex in cannot reach: string elements of a + /// array, and property names directly inside a + /// object. Only values containing @ are masked, + /// with . DNS / IP values, every other key, and anything + /// nested deeper inside those containers are left alone. Keys match exactly and + /// case-insensitively, the same as . + /// + /// Uses to find the exact token spans, then splices masked + /// tokens into the original bytes. The rest of the body stays byte-for-byte as it was, with + /// its whitespace and escaping unchanged. A regex cannot follow nesting depth or escaped + /// quotes reliably, and a DOM re-serialize would reformat the whole logged body. Never + /// throws: a body that does not start with {/[ is returned unchanged, and on + /// malformed or truncated JSON the masks found before the fault are still applied. + /// + internal static string MaskEmailsInSanContainers(string body) { + if (string.IsNullOrEmpty(body)) return body; + if (body.IndexOf('@') < 0 && body.IndexOf("\\u0040", StringComparison.OrdinalIgnoreCase) < 0) + return body; + + int first = 0; + while (first < body.Length && char.IsWhiteSpace(body[first])) first++; + if (first == body.Length || (body[first] != '{' && body[first] != '[')) return body; + + byte[] utf8 = Encoding.UTF8.GetBytes(body); + var edits = new List<(int Start, int Length, string Replacement)>(); + + try + { + var reader = new Utf8JsonReader(utf8, new JsonReaderOptions + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }); + + string pendingProperty = null; + int containerDepth = -1; // CurrentDepth of tokens directly inside the targeted container + bool containerIsArray = false; + + while (reader.Read()) + { + if (containerDepth >= 0) + { + if (reader.CurrentDepth < containerDepth) + { + containerDepth = -1; // the container's own End token + continue; + } + + bool candidate = reader.CurrentDepth == containerDepth && + (containerIsArray + ? reader.TokenType == JsonTokenType.String + : reader.TokenType == JsonTokenType.PropertyName); + if (candidate) + { + string value = reader.GetString(); + if (value != null && value.IndexOf('@') >= 0) + { + // TokenStartIndex is the opening quote; ValueSpan is the raw content. + string masked = reader.ValueIsEscaped + ? JsonSerializer.Serialize(LogSanitizer.MaskEmail(value)) + : "\"" + LogSanitizer.MaskEmail(Encoding.UTF8.GetString(reader.ValueSpan)) + "\""; + edits.Add(((int)reader.TokenStartIndex, reader.ValueSpan.Length + 2, masked)); + } + } + continue; + } + + if (reader.TokenType == JsonTokenType.PropertyName) + { + pendingProperty = reader.GetString(); + continue; + } + + if (pendingProperty != null) + { + if (reader.TokenType == JsonTokenType.StartArray && MatchesAny(SanArrayFieldNames, pendingProperty)) + { + containerDepth = reader.CurrentDepth + 1; + containerIsArray = true; + } + else if (reader.TokenType == JsonTokenType.StartObject && MatchesAny(SanKeyedObjectFieldNames, pendingProperty)) + { + containerDepth = reader.CurrentDepth + 1; + containerIsArray = false; + } + } + pendingProperty = null; + } + } + catch (JsonException) + { + // Malformed or truncated body: keep the masks collected before the fault. Everything + // up to that point was well-formed, so those spans are correct. + } + + if (edits.Count == 0) return body; + + var output = new System.IO.MemoryStream(utf8.Length); + int cursor = 0; + foreach (var (start, length, replacement) in edits) + { + output.Write(utf8, cursor, start - cursor); + byte[] replacementBytes = Encoding.UTF8.GetBytes(replacement); + output.Write(replacementBytes, 0, replacementBytes.Length); + cursor = start + length; + } + output.Write(utf8, cursor, utf8.Length - cursor); + return Encoding.UTF8.GetString(output.GetBuffer(), 0, (int)output.Length); + + static bool MatchesAny(string[] keys, string name) + { + foreach (var key in keys) + if (string.Equals(key, name, StringComparison.OrdinalIgnoreCase)) return true; + return false; + } + } + + /// + /// Replaces the value of every occurrence of a JSON string field named + /// (case-insensitive, exact key match) with applied to the + /// original value. Leaves already-empty values untouched. Whitespace around the colon and + /// around the key's own quotes is tolerated. + /// + private static string RedactJsonField(string body, string keyName, Func transform) + { + return System.Text.RegularExpressions.Regex.Replace( + body, + $@"(?i)(""{System.Text.RegularExpressions.Regex.Escape(keyName)}""\s*:\s*"")([^""]*)("")", + m => string.IsNullOrEmpty(m.Groups[2].Value) + ? m.Value + : m.Groups[1].Value + transform(m.Groups[2].Value) + m.Groups[3].Value); + } + + /// + /// Applies the standard logging redaction pipeline to a request/response body before it + /// is written to a log line: credentials are always scrubbed via + /// , and personal-data fields are additionally scrubbed via + /// unless is + /// true (issue 0040). Centralizing this here keeps all six raw-body log sites in this + /// class (and LogApiFailure/LogV2ApiFailure) consistent and gives the on/off + /// behavior one place to unit-test. + /// + internal static string ApplyLoggingRedaction(string body, bool logSensitiveRequestData) + { + string redacted = RedactCredentials(body); + return logSensitiveRequestData ? redacted : RedactPersonalData(redacted); + } + + /// + /// Writes a structured log capturing every diagnostic field available for a + /// non-success CERTInext API response — HTTP status, the CERTInext-side error + /// code and message, and the (truncated, credential-scrubbed) raw response body. + /// Call this immediately before throwing so the exception's "See gateway logs + /// for details" instruction actually points somewhere useful. + /// + /// Background: issue #8 surfaced that the sandbox returns the generic string + /// "Inactive Account User." for several conditions including burst + /// rate-limit rejection. Without the raw body in the log, an operator has no + /// way to disambiguate "the account is genuinely inactive" from "you submitted + /// 16 orders in 10 seconds and the CA's burst quota kicked in." + /// + /// + /// Do NOT call this helper from the OAuth token-exchange path — that + /// request body contains the plaintext client_secret, and while + /// scrubs known credential keys defensively, + /// the token-exchange path has its own explicit log-suppression comment at + /// the existing throw site and we want to keep that path's blast radius tight. + /// + /// + /// Default is — meta-failure-on-HTTP-200 + /// is the CA saying "no" to a request, a business outcome rather than a plugin + /// fault. Callers handling authentication failures should pass + /// so SOX-loggable authentication events match + /// the SIEM-alert level convention. + /// + // Instance (not static) so it can read _config.LogSensitiveRequestData — see issue 0040. + private void LogApiFailure( + string operationContext, + RestResponse resp, + string errorCode = null, + string errorMessage = null, + LogLevel level = LogLevel.Warning) + { + string sanitizedBody = ApplyLoggingRedaction(resp?.Content, _config.LogSensitiveRequestData) ?? "(empty)"; + Logger.Log( + level, + "CERTInext API non-success. Operation={Operation}, HttpStatus={HttpStatus}, " + + "ErrorCode={ErrorCode}, ErrorMessage={ErrorMessage}, ResponseBody={ResponseBody}", + operationContext, + (int?)resp?.StatusCode ?? 0, + errorCode ?? "(none)", + errorMessage ?? "(none)", + Truncate(sanitizedBody, LoggedResponseBodyCapBytes)); + } + + internal static string ExtractErrorMessage(string content, string operation, int? httpStatus = null) + { + string status = httpStatus.HasValue ? $" (HTTP {httpStatus.Value})" : string.Empty; + if (string.IsNullOrWhiteSpace(content)) - return $"CERTInext returned no body for operation '{operation}'."; + return $"CERTInext returned no body{status} for operation '{operation}'."; + + if (content.Length > MaxErrorBodyBytes) + { + Logger.LogWarning( + "CERTInext response body for '{Operation}' exceeded the parser size cap " + + "({Length} bytes, cap {Cap}). Truncating before JSON parse to avoid memory exhaustion.", + operation, content.Length, MaxErrorBodyBytes); + content = content.Substring(0, MaxErrorBodyBytes); + } try { @@ -1187,19 +3055,19 @@ private static string ExtractErrorMessage(string content, string operation) if (meta.TryGetProperty("errorMessage", out var em)) errMsg = em.GetString(); if (meta.TryGetProperty("errorCode", out var ec)) errCode = ec.GetString(); if (!string.IsNullOrWhiteSpace(errMsg) || !string.IsNullOrWhiteSpace(errCode)) - return $"CERTInext error during '{operation}': {errMsg ?? errCode} [{errCode}]"; + return $"CERTInext error during '{operation}'{status}: {errMsg ?? errCode} [{errCode}]"; } // Fall back to legacy ApiErrorResponse shape if (doc.RootElement.TryGetProperty("message", out var legacyMsg)) - return $"CERTInext error during '{operation}': {legacyMsg.GetString()}"; + return $"CERTInext error during '{operation}'{status}: {legacyMsg.GetString()}"; } catch { // Fall through to safe generic message } - return $"CERTInext returned an unrecognised error body for operation '{operation}'. " + + return $"CERTInext returned an unrecognised error body{status} for operation '{operation}'. " + "See gateway logs for details."; } diff --git a/CERTInext/Client/ICERTInextClient.cs b/CERTInext/Client/ICERTInextClient.cs index b256ffa..38aa7b4 100644 --- a/CERTInext/Client/ICERTInextClient.cs +++ b/CERTInext/Client/ICERTInextClient.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 Keyfactor // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. // You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, @@ -10,6 +10,7 @@ using System.Threading; using System.Threading.Tasks; using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2; namespace Keyfactor.Extensions.CAPlugin.CERTInext.Client { @@ -148,5 +149,214 @@ IAsyncEnumerable ListCertificatesAsync( /// Use for new code. /// Task> GetProfilesAsync(CancellationToken ct = default); + + // ----------------------------------------------------------------------- + // DCV — domain control validation endpoints (used for DV/OV SSL orders) + // ----------------------------------------------------------------------- + + /// + /// Fetches the DCV token for a single domain on an existing order via POST {baseURL}GetDcv. + /// The token is the TXT record value to publish (for dcvMethod=1 / DNS TXT). + /// + Task GetDcvAsync( + string orderNumber, + string domainName, + string dcvMethod, + CancellationToken ct = default); + + /// + /// Instructs CERTInext to verify the DCV token for a domain via POST {baseURL}VerifyDcv. + /// Call after the DNS TXT record has been published and propagated. + /// + Task VerifyDcvAsync( + string orderNumber, + string domainName, + string dcvMethod, + CancellationToken ct = default); + + // ----------------------------------------------------------------------- + // V2 REST API methods — only active when UseV2Api = true + // ----------------------------------------------------------------------- + + /// + /// V2 connectivity check via GET /api/certinext/v2/auth/me. + /// Throws if the V2 endpoint is unreachable or credentials are invalid. + /// + Task PingV2Async(CancellationToken ct = default); + + /// + /// Places a new SSL/TLS order via POST /api/certinext/v2/{productFamilySlug}. + /// The product code is sent as the X-Product-Code header. + /// An Idempotency-Key is generated automatically. + /// must be ssl-certificates: this body is the + /// SSL/TLS shape, and sending it to another family's endpoint is exactly issue 0033 — + /// any other slug throws before a request is made. Use + /// the / + /// overloads for the other families. + /// + Task PlaceOrderV2Async( + string productFamilySlug, + string productCode, + V2CreateSslOrderRequest request, + CancellationToken ct = default); + + /// + /// Places a new Private PKI order via POST /api/certinext/v2/private-pki-certificates + /// (issue 0033). Same X-Product-Code / Idempotency-Key handling as the SSL overload. + /// + /// A distinct overload rather than a shared base type on the SSL overload's request + /// parameter, deliberately: every existing Moq Setup/Callback is typed to + /// , and widening that parameter would break the + /// typed callbacks at runtime. The family is implied by the request type, so there is no + /// slug parameter that could be mismatched with the body. + /// + Task PlaceOrderV2Async( + string productCode, + V2CreatePrivatePkiOrderRequest request, + CancellationToken ct = default); + + /// + /// Places a new Document Signer order via POST /api/certinext/v2/signature-certificates + /// (issue 0033). Same X-Product-Code / Idempotency-Key handling as the SSL overload. Not + /// yet called by EnrollV2Async — see . + /// + Task PlaceOrderV2Async( + string productCode, + V2CreateSignatureOrderRequest request, + CancellationToken ct = default); + + /// + /// Submits a CSR to an existing V2 order via PUT /api/certinext/v2/{family}/{orderId}/csr. + /// + Task SubmitCsrV2Async( + string productFamilySlug, + string orderId, + string csrPem, + CancellationToken ct = default); + + /// + /// Returns the current status of a V2 order via GET /api/certinext/v2/{family}/{orderId}. + /// Throws when the order does not exist in that family. + /// + Task TrackOrderV2Async( + string productFamilySlug, + string orderId, + CancellationToken ct = default); + + /// + /// Downloads the issued certificate for a V2 order. + /// GET /api/certinext/v2/{family}/{orderId}/certificate + /// + Task DownloadCertificateV2Async( + string productFamilySlug, + string orderId, + CancellationToken ct = default); + + /// + /// Revokes a V2 certificate via POST /api/certinext/v2/{family}/{orderId}/revoke. + /// An Idempotency-Key is generated automatically. + /// + Task RevokeOrderV2Async( + string productFamilySlug, + string orderId, + V2RevokeRequest request, + CancellationToken ct = default); + + /// + /// Cancels a not-yet-issued V2 order via POST /api/certinext/v2/{family}/{orderId}/cancel + /// with body { "reason": ... } (issue 0039). An Idempotency-Key is generated + /// automatically. Returns on 2xx (spec: 204) + /// and on 422 (spec: "order already in + /// a terminal state"); throws on any other failure. Never retries. + /// + /// One of the Constants.ApiV2.Family* slugs. + /// The V2 order ID. + /// Required free-text reason (the CA rejects an empty one with EMS-984). + Task CancelOrderV2Async( + string productFamilySlug, + string orderId, + string reason, + CancellationToken ct = default); + + /// + /// Returns V2 auth/me response (accountNumber, authType). + /// + Task GetAuthMeV2Async(CancellationToken ct = default); + + /// + /// Resolves the product-family slug for the given V2 order ID by probing all three + /// families (ssl → private-pki → signature), then returns the track response. + /// Throws if the order is not found in any family. + /// + Task ResolveAndTrackOrderV2Async( + string orderId, + CancellationToken ct = default); + + /// + /// Resolves the product-family slug for the given V2 order ID and downloads the certificate. + /// Throws if the order is not found in any family. + /// + Task ResolveAndDownloadCertificateV2Async( + string orderId, + CancellationToken ct = default); + + /// + /// Returns the DCV challenge details for a V2 order. + /// GET /api/certinext/v2/{familySlug}/{orderId}/dcv + /// + Task GetDcvV2Async(string orderId, string familySlug, CancellationToken ct = default); + + /// + /// Returns the DCV challenge details for one specific domain on a V2 order (issue 0042). + /// GET /api/certinext/v2/{familySlug}/{orderId}/dcv?domain={domain} + /// + /// A distinct overload rather than an optional parameter on + /// deliberately: Moq (and any other expression-tree-based mocking) cannot omit an + /// argument on a mocked call — every existing 3-argument Setup/Verify for the no-domain + /// overload would otherwise fail to compile. Parameter order mirrors + /// 's established (orderId, domain, familySlug, ct) + /// convention. Confirmed live to return a distinct token per SAN on a UCC order + /// (v2-api-support-questions.md Finding 9). + /// + Task GetDcvV2Async(string orderId, string domain, string familySlug, CancellationToken ct = default); + + /// + /// Asks CERTInext to verify the DNS TXT record for the given domain on a V2 order. + /// POST /api/certinext/v2/{familySlug}/{orderId}/dcv/verify + /// Both 200 OK and 204 No Content are treated as success. + /// Throws on 422 (verification failed). + /// + Task VerifyDcvV2Async(string orderId, string domain, string familySlug, CancellationToken ct = default); + + /// + /// Resolves the product-family slug for the given V2 order ID by probing all three + /// families (ssl → private-pki → signature), then returns both the resolved slug and the + /// track response. Use this when the caller needs to pass the family slug to downstream + /// operations such as DCV. + /// Throws if the order is not found in any family. + /// + Task<(string family, V2OrderStatusResponse status)> ResolveAndTrackOrderV2WithFamilyAsync( + string orderId, + CancellationToken ct = default); + + /// + /// Returns the list of products available in the V2 catalog. + /// GET /api/certinext/v2/catalog/products + /// + Task> GetProductDetailsV2Async(CancellationToken ct = default); + + /// + /// Pages through all orders via GET /api/certinext/v2/reports/orders. Used for V2-mode + /// Synchronize (issues/0022). Paging is 1-based; is clamped + /// to (100) server-side. + /// + /// Optional inclusive start date filter (YYYY-MM-DD). + /// Optional inclusive end date filter (YYYY-MM-DD). + /// Page size requested; server clamps to 100. + IAsyncEnumerable ListOrdersV2Async( + string from = null, + string to = null, + int pageSize = Constants.Api.DefaultPageSize, + CancellationToken ct = default); } } diff --git a/CERTInext/Constants.cs b/CERTInext/Constants.cs index 65005a5..bb1e53d 100644 --- a/CERTInext/Constants.cs +++ b/CERTInext/Constants.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 Keyfactor // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. // You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, @@ -16,17 +16,84 @@ public static class Config public const string ApiKey = "ApiKey"; // the raw Access Key (used to compute authKey) public const string AccountNumber = "AccountNumber"; // CERTInext account number public const string GroupNumber = "GroupNumber"; // optional delegation group number + public const string OrganizationNumber = "OrganizationNumber"; // pre-vetted organization (declares preVetting=1) public const string AuthMode = "AuthMode"; public const string Enabled = "Enabled"; public const string IgnoreExpired = "IgnoreExpired"; + public const string SubmitNonDnsSans = "SubmitNonDnsSans"; public const string PageSize = "PageSize"; + + // Diagnostic escape hatch — see CERTInextConfig.LogSensitiveRequestData. Off by + // default; only meant for temporary use while verifying a new deployment. + public const string LogSensitiveRequestData = "LogSensitiveRequestData"; + + // Synchronous certificate pickup (parity with the legacy Sectigo connector). + // After submitting an order, Enroll() polls GetCertificate up to PickupRetries + // times, PickupDelay seconds apart (after a fixed initial delay), so a fast-issuing + // order returns the issued certificate in the same enrollment call instead of + // waiting for the next synchronization. On timeout the order is returned pending and + // imported by a later sync — behaviour identical to before this feature. + public const string PickupRetries = "PickupRetries"; + public const string PickupDelay = "PickupDelay"; + public const string RequestorName = "RequestorName"; public const string RequestorEmail = "RequestorEmail"; public const string RequestorIsdCode = "RequestorIsdCode"; public const string RequestorMobileNumber = "RequestorMobileNumber"; + public const string RequestorDesignation = "RequestorDesignation"; public const string SignerPlace = "SignerPlace"; public const string SignerIp = "SignerIp"; + // Technical point-of-contact defaults (TpcName/Email default to Requestor* when blank) + public const string TechnicalContactName = "TechnicalContactName"; + public const string TechnicalContactEmail = "TechnicalContactEmail"; + public const string TechnicalContactIsdCode = "TechnicalContactIsdCode"; + public const string TechnicalContactMobileNumber = "TechnicalContactMobileNumber"; + + // SSL order body defaults — every value matches a CERTInext-documented field and + // is overridable by the connector admin via the gateway's connector-config UI. + public const string AccountingModel = "AccountingModel"; + public const string EmailNotifications = "EmailNotifications"; + public const string SubscriptionValidityYears = "SubscriptionValidityYears"; + public const string SubscriptionAutoRenew = "SubscriptionAutoRenew"; + public const string SubscriptionRenewCriteriaDays = "SubscriptionRenewCriteriaDays"; + public const string AutoSecureWww = "AutoSecureWww"; + + // DCV — domain control validation via DNS provider plugins + public const string DcvEnabled = "DcvEnabled"; + public const string DcvTxtRecordTemplate = "DcvTxtRecordTemplate"; + public const string DcvPropagationDelaySeconds = "DcvPropagationDelaySeconds"; + public const string DcvTimeoutMinutes = "DcvTimeoutMinutes"; + + // How long to wait inside Enroll() for CERTInext to expose the DCV challenge + // (domainVerification metadata in TrackOrder). Under concurrent load CERTInext + // sometimes takes a few seconds after GenerateOrderSSL before the slot appears. + // Without this wait, the plugin's single TrackOrder check sees null and skips + // DCV; the order then has to wait for the next gateway sync cycle to be picked up. + public const string DcvWaitForChallengeSeconds = "DcvWaitForChallengeSeconds"; + + // How long to wait inside Enroll() for CERTInext to finish generating the cert + // after DCV verification succeeds. CERTInext's issuance is async — DCV may be + // verified but the cert PEM isn't yet available for download. Without this + // wait, Enroll() returns pending and the cert is picked up on the next sync. + public const string DcvWaitForIssuanceSeconds = "DcvWaitForIssuanceSeconds"; + + // Bounds on DCV-during-sync so a large pending backlog can't make a sync pass + // slow (issue 0002). Only pending orders younger than DcvSyncMaxOrderAgeHours + // are eligible for DCV completion during sync, and at most DcvSyncMaxPerPass + // orders are attempted per pass; the rest are emitted as pending and revisited + // on a later pass (the per-minute incremental cadence keeps recent orders moving). + public const string DcvSyncMaxOrderAgeHours = "DcvSyncMaxOrderAgeHours"; + public const string DcvSyncMaxPerPass = "DcvSyncMaxPerPass"; + + // V2 mode only: incremental-sync lookback window for /reports/orders (issues/0022). + public const string V2SyncLookbackHours = "V2SyncLookbackHours"; + + // Environment variable that overrides DcvTimeoutMinutes when set. + public const string DcvTimeoutMinutesEnvVar = "CERTINEXT_DCV_TIMEOUT_MINUTES"; + public const string DcvWaitForChallengeSecondsEnvVar = "CERTINEXT_DCV_WAIT_FOR_CHALLENGE_SECONDS"; + public const string DcvWaitForIssuanceSecondsEnvVar = "CERTINEXT_DCV_WAIT_FOR_ISSUANCE_SECONDS"; + // Auth mode values public const string AuthModeAccessKey = "AccessKey"; // default; authKey = SHA256(accessKey+ts+txn) public const string AuthModeOAuth = "OAuth"; // bearer token via OAuth @@ -63,6 +130,10 @@ public static class EnrollmentParam public const string SignerIp = "SignerIp"; public const string DomainName = "DomainName"; // primary domain for SSL/TLS orders public const string KeyType = "KeyType"; + + // V2 API enrollment parameters + public const string ProductFamily = "ProductFamily"; // V2: "ssl", "private-pki", or "signature" + public const string ProductVariant = "ProductVariant"; // V2: ssl "dv"/"ov"/"ev"; private-pki "intranet-ssl"/"igtf-host" } public static class Products @@ -78,9 +149,16 @@ public static class Products public const string EvSsl = "EV SSL"; public const string EvSslUcc = "EV SSL Multi-Domain (UCC)"; - // Default production numeric codes. These are the standard codes for the - // CERTInext production environment. Sandbox codes differ — set ProductCode - // explicitly on the template to override when targeting sandbox. + // V1-ONLY. Default production numeric codes for the CERTInext V1 (legacy) API. + // These are the standard codes for the CERTInext production environment under V1. + // Sandbox codes differ — set ProductCode explicitly on the template to override + // when targeting sandbox. + // + // Do NOT reuse this table for V2 dispatch: its numbering does not match the live + // V2 catalog (issue 0036 — e.g. this table's "842" is OV SSL, but the live V2 + // catalog's "842" is DV SSL, a flat +4 offset across all 10 codes). V2 resolves the + // product code live from the Catalog response instead — see ProductTypeIdsV2 below + // and EnrollV2Async/ValidateProductInfo in CERTInextCAPlugin.cs. public static readonly System.Collections.Generic.Dictionary DefaultProductCodes = new System.Collections.Generic.Dictionary(System.StringComparer.OrdinalIgnoreCase) { @@ -95,6 +173,71 @@ public static class Products [EvSsl] = "846", [EvSslUcc] = "847", }; + + // V2-ONLY. Maps each product name (ProductId, as advertised by GetProductIds()) to + // the CERTInext V2 catalog's stable numeric productTypeID value (spec: + // docs/reference/specs/CERTInext API v2.postman_collection (1).json, "Get Product + // Details" field vocabulary). productTypeID is the CA's own documented mechanism + // for "programmatic routing" (its docs explicitly say productName is "for display" + // only) — unlike productCode (V1-era table above, wrong numbering for V2) or + // productName (varies by account/catalog version: the live catalog, the V1 Postman + // table, and the V2 Postman table each use different spellings/suffixes for the same + // product — see issue 0036), productTypeID is a small, stable, CERTInext-documented + // enum. F3 independently reached the same conclusion for UCC detection and + // live-verified 15/18/20/21/22 against the real V2 sandbox catalog + // (issues/f3-v2-multi-san-limitation.md); the other five (13/14/16/17/19) are + // spec-documented but not yet independently live-probed. + // + // Used by EnrollV2Async/ValidateProductInfo to resolve/validate the real V2 product + // code from the live catalog when no explicit ProductCode override is configured. + // Never used for V1. + public static readonly System.Collections.Generic.Dictionary ProductTypeIdsV2 = + new System.Collections.Generic.Dictionary(System.StringComparer.OrdinalIgnoreCase) + { + [DvSsl] = "13", + [DvSslWildcard] = "14", + [DvSslUcc] = "15", + [DvSslWildcardUcc] = "21", + [OvSsl] = "16", + [OvSslWildcard] = "17", + [OvSslUcc] = "18", + [OvSslWildcardUcc] = "22", + [EvSsl] = "19", + [EvSslUcc] = "20", + }; + + // V2-ONLY. Maps each SSL product name (ProductId) to the V2 create body's + // productVariant value ("dv"/"ov"/"ev") — issue 0059. Grouped by the same + // productTypeID assurance level ProductTypeIdsV2 above already documents (13-15 and + // 21 -> dv, 16-18 and 22 -> ov, 19-20 -> ev); kept as its own ProductId-keyed table + // (rather than a second indirection through ProductTypeIdsV2) so it reads the same way + // as DefaultProductCodes/ProductTypeIdsV2 above. + // + // Used by CERTInextCAPlugin.ResolveSslProductVariant (EnrollV2Async/ValidateProductInfo) + // to derive productVariant when the template's ProductVariant enrollment parameter is + // not set explicitly, and to reject an explicit ProductVariant that contradicts the + // selected product (e.g. "dv" configured for "OV SSL" — the bug this table fixes: the + // plugin was sending productVariant:"dv" for every product regardless of ProductId, + // which skips the OV/EV organization block CERTInext requires). + // + // All 10 SSL ProductIds are covered; there is no "unmapped" case today. private-pki + // and signature families are unrelated (private-pki's variant enum is intranet-ssl / + // igtf-host — Constants.ApiV2.PrivatePkiVariants — and signature enrollment is not yet + // supported) and must not consult this table. + public static readonly System.Collections.Generic.Dictionary ProductVariantsV2 = + new System.Collections.Generic.Dictionary(System.StringComparer.OrdinalIgnoreCase) + { + [DvSsl] = ApiV2.ProductVariantDv, + [DvSslWildcard] = ApiV2.ProductVariantDv, + [DvSslUcc] = ApiV2.ProductVariantDv, + [DvSslWildcardUcc] = ApiV2.ProductVariantDv, + [OvSsl] = ApiV2.ProductVariantOv, + [OvSslWildcard] = ApiV2.ProductVariantOv, + [OvSslUcc] = ApiV2.ProductVariantOv, + [OvSslWildcardUcc] = ApiV2.ProductVariantOv, + [EvSsl] = ApiV2.ProductVariantEv, + [EvSslUcc] = ApiV2.ProductVariantEv, + }; } public static class CertificateStatusId @@ -220,7 +363,219 @@ public static class RevocationReasonId public const int Default = KeyCompromise; } + public static class Pickup + { + // Defaults mirror the legacy Sectigo connector's PickUpEnrolledCertificate: + // a 5-second initial delay, then up to 5 poll attempts 10 seconds apart, so the + // maximum time an enrollment call occupies a Command worker thread is + // InitialDelaySeconds + DefaultRetries * DefaultDelaySeconds = 5 + 5*10 = 55 seconds. + // Set PickupRetries to 0 to disable the wait entirely (immediate pending return). + public const int DefaultRetries = 5; + public const int DefaultDelaySeconds = 10; + + // Small static delay before the first poll — gives a fast order a chance to finish + // issuing before we poll at all, avoiding a guaranteed-miss first attempt. + public const int InitialDelaySeconds = 5; + + // Per-factor safety clamps so a single mis-typed value cannot produce a tight busy-loop + // or an absurd per-attempt delay. These bound each knob independently; the *product* + // (retries * delay) is bounded separately by MaxTotalWaitSeconds below. + public const int MaxRetries = 30; + public const int MaxDelaySeconds = 60; + + // Hard ceiling on total in-call pickup occupancy (initial delay + retries * delay). + // The per-factor clamps above still permit a ~1805s product at the extremes, which could + // push Enroll() past Command's enrollment timeout; PickUpEnrolledCertificateAsync caps the + // effective retry count so the total never exceeds this. Kept comfortably under a typical + // enrollment timeout while leaving room for the documented ~90s default guidance. + public const int MaxTotalWaitSeconds = 180; + } + + public static class Dcv + { + // CERTInext dcvMethod values (dcvDetails.dcvMethod in GetDcv / VerifyDcv) + public const string MethodDnsTxt = "1"; // DNS TXT record (numeric, used in API requests) + public const string MethodDnsTxtLabel = "DNS TXT Record"; // DNS TXT record (string label returned by TrackOrder) + public const string MethodHttpFile = "2"; // HTTP file validation + public const string MethodEmail = "3"; // Email validation + + // CERTInext dcvStatus values (per-domain entries in TrackOrder domainVerification) + public const string StatusPending = "0"; + public const string StatusValidated = "1"; + public const string StatusRejected = "2"; + + // Default TXT record hostname template; {0} is replaced with the bare domain name. + // Override via the DcvTxtRecordTemplate connector config field. + public const string DefaultTxtRecordTemplate = "_emsign-validation.{0}"; + + // Independent bound for a single CleanupValidation (TXT-record removal) call. This is + // deliberately its own fixed ceiling, not a fraction of DcvTimeoutMinutes and not the + // ambient DCV-flow cancellation token: cleanup is a best-effort compensating action that + // must get a real chance to run even when the operation it's cleaning up after was + // itself cancelled (the ambient token would already be cancelled at that point), but it + // still must not be allowed to hang the calling gateway request forever if a DNS + // provider plugin's underlying network call stalls. 60s comfortably covers a single + // DELETE-shaped call under normal conditions (the reference CloudflareDomainValidator's + // HttpClient default alone is 100s) without risking an indefinite hang. + public const int CleanupValidationTimeoutSeconds = 60; + + // Defaults for the DCV-during-sync bounds (issue 0002). + public const int DefaultSyncMaxOrderAgeHours = 24; + public const int DefaultSyncMaxPerPass = 50; + + // Propagation delay used on the *sync* DCV path (issue 0002). Sync runs frequently + // and bounds work per pass, so it uses a short delay rather than the full + // DcvPropagationDelaySeconds (which the Enroll path uses for a one-shot finish). + // A few seconds is enough for the staged TXT to be visible to CERTInext's resolver; + // if a verify lands too early, the order simply stays pending and is retried next pass. + public const int SyncPropagationDelaySeconds = 3; + } + + /// + /// V2 REST API constants — all paths, status strings, and family slugs for the + /// /api/certinext/v2/ surface. Auth is OAuth2 client_credentials. The plugin sends + /// an Idempotency-Key header on order-create/revoke, but the spec only documents + /// this header (as "parsed today, enforced in a future release") on Verify DCV and Domains + /// endpoints, not order-create/revoke — see issue 0032. + /// + public static class ApiV2 + { + // Auth / connectivity + public const string TokenPath = "/oauth/token"; + public const string AuthMePath = "/api/certinext/v2/auth/me"; + + // Product-family resource paths (appended to base URL) + public const string SslCertificatesPath = "/api/certinext/v2/ssl-certificates"; + public const string PrivatePkiCertificatesPath = "/api/certinext/v2/private-pki-certificates"; + public const string SignatureCertificatesPath = "/api/certinext/v2/signature-certificates"; + public const string CatalogProductsPath = "/api/certinext/v2/catalog/products"; + + // Order status strings (V2 REST — NOT numeric IDs) + public const string StatusPendingDcv = "pending-dcv"; + public const string StatusPendingCsr = "pending-csr"; + public const string StatusPendingAgreement = "pending-agreement"; + public const string StatusPendingOrganizationVerification = "pending-organization-verification"; + public const string StatusPendingDocuments = "pending-documents"; + public const string StatusPendingApproval = "pending-approval"; + public const string StatusIssued = "issued"; + public const string StatusCancelled = "cancelled"; + public const string StatusRevoked = "revoked"; + public const string StatusRejected = "rejected"; + public const string StatusExpired = "expired"; + // Spec-documented V2 status (issue 0039) — CERTInext can't say where the order is; not terminal. + public const string StatusUnknown = "unknown"; + + // Per-domain dcvStatus values on Track Order's verifications.domain.domains[] + // block (issue 0042). Confirmed live (2026-09-28, order 7465857196): PENDING while + // a SAN's DCV is outstanding, VERIFIED once confirmed, REJECTED after the parent + // order is cancelled. Uppercase — distinct from the V1 Dcv class's numeric "0"/"1" + // dcvStatus values, which belong to a different API generation entirely. + public const string DcvStatusPending = "PENDING"; + public const string DcvStatusVerified = "VERIFIED"; + public const string DcvStatusRejected = "REJECTED"; + + // Product-family slugs (used as URL path segments) + public const string FamilySsl = "ssl-certificates"; + public const string FamilyPrivatePki = "private-pki-certificates"; + public const string FamilySignature = "signature-certificates"; + + // productVariant values that require an organization block (issue 0028) — every + // other value (dv and its wildcard/UCC combinations) omits it entirely. + public const string ProductVariantOv = "ov"; + public const string ProductVariantEv = "ev"; + + // The SSL family's own "no assurance vetting" variant. Given its own named constant + // (issue 0059) so Constants.Products.ProductVariantsV2 below doesn't repeat the "dv" + // literal that EnrollmentParams.ProductVariant/V2CreateSslOrderRequest.ProductVariant + // also default to. + public const string ProductVariantDv = "dv"; + + // Private PKI create-body `variant` enum (issue 0033). Spec, "Private PKI + // Certificates" field table: "`variant` | **Mandatory** (`intranet-ssl` / + // `igtf-host`)". Sourced from the ProductVariant template parameter (the same + // "variant within the family" parameter the SSL body's productVariant uses). + // Note: the spec's create-*response* table echoes a wider enum (`intranet-ssl` / + // `igtf-host` / `igtf-personal` / `device` / `vpn`) — only the two documented + // create values are accepted here; see issue 0033. + public const string PrivatePkiVariantIntranetSsl = "intranet-ssl"; + public const string PrivatePkiVariantIgtfHost = "igtf-host"; + public static readonly System.Collections.Generic.HashSet PrivatePkiVariants = + new System.Collections.Generic.HashSet(System.StringComparer.OrdinalIgnoreCase) + { + PrivatePkiVariantIntranetSsl, + PrivatePkiVariantIgtfHost + }; + + // Catalog productTypeID for Private PKI products. Spec, Catalog -> List Products + // "productTypeID values": `"39"` | Private PKI | Private PKI (`8`). Also observed live + // on the sandbox catalog (product 149, "Sandbox emSign Intranet SSL 1 Year"). + public const string PrivatePkiProductTypeId = "39"; + + // Document Signer create-body `subjectType` enum (issue 0033). Spec, "Document + // Signer Certificates" field table: "`subjectType` | **Mandatory** + // (`natural-person` / `legal-person` / `legal-entity`)". Not yet sourced by + // EnrollV2Async — signature enrollment is still an open design decision. + public static readonly System.Collections.Generic.HashSet SignatureSubjectTypes = + new System.Collections.Generic.HashSet(System.StringComparer.OrdinalIgnoreCase) + { + "natural-person", + "legal-person", + "legal-entity" + }; + + // Fixed designation sent on technicalPointOfContact.designation (issue 0030). The + // spec documents this as free text with no enum (examples: "Technical Contact", + // "IT Administrator", "PKI Manager", "Authorized Signer") and there is no connector + // config field for it — deliberately out of scope for issue 0027 item 5e's + // RequestorDesignation fix (Config.RequestorDesignation), which only covers + // requestor.designation. No existing generic designation/title config field was + // found to reuse for this one, so this remains a fixed default. + public const string DefaultTechnicalContactDesignation = "Technical Contact"; + + // UCC (multi-SAN) product family detection — from the live Catalog response's + // productTypeID field: 15=DV SSL UCC, 18=OV SSL UCC, 20=EV SSL UCC, + // 21=DV SSL Wildcard UCC, 22=OV SSL Wildcard UCC (issues/f3-v2-multi-san-limitation.md). + // Deliberately NOT derived from Constants.Products.DefaultProductCodes — that table's + // numbering disagrees with the live/spec numbering (issue 0036). + public static readonly System.Collections.Generic.HashSet UccProductTypeIds = + new System.Collections.Generic.HashSet { "15", "18", "20", "21", "22" }; + + // Non-UCC wildcard product family detection — from the live Catalog response's + // productTypeID field: 14=DV SSL Wildcard, 17=OV SSL Wildcard + // (Constants.Products.ProductTypeIdsV2). Deliberately excludes the UCC wildcard + // type IDs (21/22, in UccProductTypeIds above) — those are already exempt from + // the single-domain SAN guard by virtue of being UCC, and their additionalDomains + // handling is unrelated to the apex-SAN exemption this set is used for (the V2 + // single-domain enrollment guard in EnrollV2Async). + public static readonly System.Collections.Generic.HashSet WildcardProductTypeIds = + new System.Collections.Generic.HashSet { "14", "17" }; + + // Orders report (Synchronize, V2 mode) — GET /api/certinext/v2/reports/orders. + // Spring-style page envelope: content/page/size/totalPages/totalElements. + // Paging is 1-based; size is clamped to 100 server-side; page=0 is treated as + // page 1 (issues/0022 Phase 0 live probe findings). + public const string OrdersReportPath = "/api/certinext/v2/reports/orders"; + public const int OrdersReportMaxPageSize = 100; + + // Default lookback window (issues/0022): live probing could not determine + // whether /reports/orders' from/to filter brackets order date or issue date. + // An incremental sync re-requests from (lastSync - this window) rather than + // exactly lastSync, so an order created before lastSync but issued after it + // (e.g. a slow-DCV order) still surfaces. See CERTInextConfig.V2SyncLookbackHours. + public const int DefaultSyncLookbackHours = 72; + } + + // V2 config key constants (added here alongside existing Config constants) + public static class ConfigV2 + { + public const string UseV2Api = "UseV2Api"; + } + // Legacy string revocation reasons — retained so StatusMapper still compiles. + // V1 never puts these on the wire (RevokeOrderRequest sends a numeric + // revokeReasonId — see CERTInextClient.RevokeCertificateAsync / + // MapLegacyReasonStringToCrlCode), so this class is intentionally left + // untouched by the 0019 V2 kebab-case fix; see RevocationReasonV2 below. public static class RevocationReason { public const string Unspecified = "unspecified"; @@ -234,5 +589,27 @@ public static class RevocationReason public const string PrivilegeWithdrawn = "privilegeWithdrawn"; public const string AACompromise = "aACompromise"; } + + // V2 API revocation reason strings. These must match the CERTInext V2 spec's + // kebab-case `reason` enum exactly (docs/reference/specs/CERTInext API + // v2.postman_collection.json, "Revoke Certificate"). Sending camelCase (the + // pre-fix values, shared with the legacy RevocationReason class above) gets + // HTTP 400 — see issues/0019. `AACompromise` is accepted on the + // signature-certificates / private-pki-certificates revoke endpoints per spec, + // but is not documented on ssl-certificates; kept here as the RFC 5280 code-10 + // mapping for those other families. There is no V2 equivalent of the RFC 5280 + // CRL-only "removeFromCRL" (code 8) reason, so it is intentionally absent here. + public static class RevocationReasonV2 + { + public const string Unspecified = "unspecified"; + public const string KeyCompromise = "key-compromise"; + public const string CACompromise = "ca-compromise"; + public const string AffiliationChanged = "affiliation-changed"; + public const string Superseded = "superseded"; + public const string CessationOfOperation = "cessation-of-operation"; + public const string CertificateHold = "certificate-hold"; + public const string PrivilegeWithdrawn = "privilege-withdrawn"; + public const string AACompromise = "aa-compromise"; + } } } diff --git a/CERTInext/Models/EnrollmentParams.cs b/CERTInext/Models/EnrollmentParams.cs index 69b662f..ac93da5 100644 --- a/CERTInext/Models/EnrollmentParams.cs +++ b/CERTInext/Models/EnrollmentParams.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 Keyfactor // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. // You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, @@ -33,14 +33,18 @@ public EnrollmentParams(EnrollmentProductInfo productInfo) /// Resolution order: /// 1. ProductCode template parameter (explicit override — use for sandbox or non-standard codes) /// 2. ProfileId template parameter (deprecated alias for ProductCode) - /// 3. Default production code looked up from the selected product name (ProductId) + /// 3. V1-ONLY fallback: default production code looked up from the selected product + /// name (ProductId) via Constants.Products.DefaultProductCodes. + /// V2 callers must check before using this value: + /// when false, this getter's fallback (step 3) is the V1-era table, whose numbering does + /// not match the live V2 catalog (issue 0036) — resolve the V2 code from the live catalog + /// by ProductTypeId instead (see EnrollV2Async / ValidateProductInfo). /// public string ProductCode { get { - var explicit_ = GetString(Constants.EnrollmentParam.ProductCode, - GetString(Constants.EnrollmentParam.ProfileId, string.Empty)); + var explicit_ = GetExplicitProductCode(); if (!string.IsNullOrEmpty(explicit_)) return explicit_; @@ -52,6 +56,23 @@ public string ProductCode /// Alias for ProductCode — kept for backward compat. public string ProfileId => ProductCode; + /// + /// True when an explicit ProductCode or ProfileId override was configured on the + /// template. False means 's getter falls back to the V1-only + /// Constants.Products.DefaultProductCodes table — V2 callers must not use that fallback + /// value (issue 0036); resolve the code from the live catalog by ProductTypeId instead. + /// + public bool HasExplicitProductCode => !string.IsNullOrEmpty(GetExplicitProductCode()); + + private string GetExplicitProductCode() + { + return GetString(Constants.EnrollmentParam.ProductCode, + GetString(Constants.EnrollmentParam.ProfileId, string.Empty)); + } + + /// Requested subscription validity in years (1, 2, or 3). Takes precedence over ValidityDays. + public int ValidityYears => GetInt(Constants.EnrollmentParam.ValidityYears, 0); + /// Requested validity in days; 0 means "use profile default". public int ValidityDays => GetInt(Constants.EnrollmentParam.ValidityDays, 0); @@ -73,8 +94,8 @@ public string ProductCode public string KeyType => GetString(Constants.EnrollmentParam.KeyType, string.Empty); /// - /// Primary domain name for SSL/TLS orders. - /// Derived from the CSR CN by the client if omitted here. + /// Primary domain name for SSL/TLS orders (and the hostname of a V2 private-pki + /// order — issue 0033). Derived from the CSR CN by the client if omitted here. /// public string DomainName => GetString(Constants.EnrollmentParam.DomainName, string.Empty); @@ -96,6 +117,46 @@ public string ProductCode /// public string SignerIp => GetString(Constants.EnrollmentParam.SignerIp, string.Empty); + // ------------------------------------------------------------------ + // V2 API parameters + // ------------------------------------------------------------------ + + /// + /// V2 product family. Accepted values: "ssl" (default), "private-pki", "signature". + /// Used to select the correct V2 resource path. + /// + public string ProductFamily => GetString(Constants.EnrollmentParam.ProductFamily, "ssl"); + + /// + /// V2 product family as the REST path slug used in V2 URL construction. + /// Maps "ssl" → "ssl-certificates", "private-pki" → "private-pki-certificates", + /// "signature" → "signature-certificates". + /// + public string ProductFamilySlug => ProductFamily.ToLowerInvariant() switch + { + "ssl" => Constants.ApiV2.FamilySsl, + "private-pki" => Constants.ApiV2.FamilyPrivatePki, + "signature" => Constants.ApiV2.FamilySignature, + _ => Constants.ApiV2.FamilySsl + }; + + /// + /// V2 product variant within the family, sent in the order body. For the SSL family this + /// is the productVariant field ("dv", "ov", "ev"); for the private-pki family it + /// is the variant field ("intranet-ssl", "igtf-host" — issue 0033). + /// Default: "dv" (SSL-only; private-pki enrollment rejects it — see + /// ). + /// + public string ProductVariant => GetString(Constants.EnrollmentParam.ProductVariant, "dv"); + + /// + /// True when the ProductVariant template parameter is actually set (non-blank), as + /// opposed to falling back to its SSL-only "dv" default. + /// Used only to word the private-pki validation error accurately (issue 0033). + /// + public bool HasExplicitProductVariant => + !string.IsNullOrEmpty(GetString(Constants.EnrollmentParam.ProductVariant, string.Empty)); + // ------------------------------------------------------------------ // Helpers // ------------------------------------------------------------------ diff --git a/CERTInext/Models/LogSanitizer.cs b/CERTInext/Models/LogSanitizer.cs new file mode 100644 index 0000000..9ebaf07 --- /dev/null +++ b/CERTInext/Models/LogSanitizer.cs @@ -0,0 +1,135 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Models +{ + /// + /// Neutralizes control characters before a requester-controlled value is interpolated into a + /// log message. + /// + /// SAN values reach the log from the CSR and from Command's SAN dictionary, i.e. from the + /// requester. Structured message templates stop format-string abuse but not embedded newlines, + /// and NLog's text layout does not escape them — so an unsanitized value can forge additional, + /// well-formed-looking records in the gateway log (CWE-117). That matters here specifically + /// because these log lines exist to make the submitted SAN set auditable; a forged line could + /// assert a different SAN set than the one actually sent. + /// + /// Shared between CERTInextCAPlugin and Client.CERTInextClient — both sanitize the + /// same kind of value at their respective log sinks, so this used to be defined twice, byte- + /// identical, one per class. + /// + internal static class LogSanitizer + { + internal static string Strip(string value) + { + if (string.IsNullOrEmpty(value)) return value; + return value + .Replace("\r", "\\r") + .Replace("\n", "\\n") + .Replace("\t", "\\t"); + } + + /// + /// Masks the local part of an email address for logging while preserving the domain + /// (e.g. "j***@example.com"), so an operator can still tell which organization + /// an order came from without seeing exactly who submitted it. Used by both + /// CERTInextCAPlugin and Client.CERTInextClient when + /// LogSensitiveRequestData is off (issue 0040). Values with no @ (blank, + /// malformed, or not actually an email) fall back to a full "***REDACTED***". + /// + internal static string MaskEmail(string value) + { + if (string.IsNullOrEmpty(value)) return value; + int at = value.IndexOf('@'); + if (at <= 0) return "***REDACTED***"; + string domain = value.Substring(at + 1); + return value.Substring(0, 1) + "***@" + domain; + } + + // SAN type spellings (case-insensitive) whose values are email addresses: the gateway's + // "rfc822name" plus the variants CERTInextCAPlugin.MapSanType normalizes to "email". + private static readonly HashSet EmailSanTypes = + new HashSet(StringComparer.OrdinalIgnoreCase) { "email", "rfc822", "rfc822name" }; + + // SAN types logged verbatim even with LogSensitiveRequestData off: host names, IP + // literals and URIs are audit fields, not personal data (issue 0040 follow-up). + private static readonly HashSet VerbatimSanTypes = + new HashSet(StringComparer.OrdinalIgnoreCase) + { + "dns", "dnsname", "dnsnames", + "ip", "ipaddress", "ipaddresses", + "uri", "uniformresourceidentifier" + }; + + /// + /// Returns a single SAN value as it should appear in a log line (issue 0040 follow-up). + /// With on, the value is returned as-is. Off, + /// an email-type SAN (rfc822name and its spelling variants) is masked with + /// , and so is any value containing @ whose type is unknown + /// or null (untyped host lists). DNS, IP and URI values are always returned as-is. + /// Does not ; callers strip the formatted line. + /// + internal static string FormatSanValue(string sanType, string value, bool logSensitiveRequestData) + { + if (logSensitiveRequestData || string.IsNullOrEmpty(value)) return value; + if (sanType != null && EmailSanTypes.Contains(sanType)) return MaskEmail(value); + if (sanType != null && VerbatimSanTypes.Contains(sanType)) return value; + return value.IndexOf('@') >= 0 ? MaskEmail(value) : value; + } + + /// + /// Formats typed SAN entries as "type:value; type:value" for a log line, applying + /// to each value and to the result. + /// Returns "(none)" for a null or empty collection. + /// + internal static string FormatSans( + IEnumerable> sans, bool logSensitiveRequestData) + { + var parts = sans? + .Select(s => $"{s.Key}:{FormatSanValue(s.Key, s.Value, logSensitiveRequestData)}") + .ToList(); + return parts == null || parts.Count == 0 ? "(none)" : Strip(string.Join("; ", parts)); + } + + /// Gateway SAN dictionary overload of . + internal static string FormatSans(Dictionary san, bool logSensitiveRequestData) + => FormatSans( + san?.SelectMany(kvp => (kvp.Value ?? Array.Empty()) + .Select(v => new KeyValuePair(kvp.Key, v))), + logSensitiveRequestData); + + /// Resolved overload of . + internal static string FormatSans(IEnumerable sans, bool logSensitiveRequestData) + => FormatSans( + sans?.Where(s => s != null).Select(s => new KeyValuePair(s.Type, s.Value)), + logSensitiveRequestData); + + /// + /// Formats an untyped list of SAN-derived names (e.g. V1 additionalDomains, or order + /// domains echoed back by the CA) joined by . With no type to go + /// on, any value containing @ is masked when + /// is off. The result is ped; "(none)" for a null or empty list. + /// + internal static string FormatUntypedSans( + IEnumerable values, bool logSensitiveRequestData, string separator = "; ") + { + var parts = values?.Select(v => FormatSanValue(null, v, logSensitiveRequestData)).ToList(); + return parts == null || parts.Count == 0 ? "(none)" : Strip(string.Join(separator, parts)); + } + } +} diff --git a/CERTInext/Models/StatusMapper.cs b/CERTInext/Models/StatusMapper.cs index 59795f8..137af17 100644 --- a/CERTInext/Models/StatusMapper.cs +++ b/CERTInext/Models/StatusMapper.cs @@ -1,11 +1,13 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 Keyfactor // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. // You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions // and limitations under the License. +using Keyfactor.Logging; using Keyfactor.PKI.Enums.EJBCA; +using Microsoft.Extensions.Logging; namespace Keyfactor.Extensions.CAPlugin.CERTInext.Models { @@ -19,6 +21,8 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.Models /// internal static class StatusMapper { + private static readonly ILogger Logger = LogHandler.GetClassLogger(typeof(StatusMapper)); + // ----------------------------------------------------------------------- // Certificate status ID mapping (TrackOrder.orderDetails.certificateStatusId) // ----------------------------------------------------------------------- @@ -191,6 +195,136 @@ public static string ToRevocationReason(uint crlReason) } } + // ----------------------------------------------------------------------- + // V2 API status mapping + // ----------------------------------------------------------------------- + + /// + /// Maps a V2 REST API order status string to the Keyfactor + /// integer code expected by the gateway. + /// + /// Covers the status values documented by the V2 spec's /reports/orders + /// status filter (issues/0031). pending-organization-verification, + /// pending-documents, and pending-approval join the existing + /// pending-* values as EXTERNALVALIDATION — they are OV/EV/DV orders still + /// actively progressing toward issuance, not failures. rejected is a + /// terminal negative outcome mapped to FAILED deliberately, same as the + /// pre-existing cancelled. expired maps to GENERATED instead — + /// an expired-but-not-revoked certificate remains issued inventory, mirroring + /// 's V1 convention and the sync/report path's + /// own "expired" case (CERTInextCAPlugin.TryMapV2ReportDisplayStatus). The + /// spec-documented unknown maps to + /// EXTERNALVALIDATION (issue 0039): the order may still be live. Any value not in this list falls + /// through to the default arm, which also returns FAILED but logs a warning — + /// see issues/0031 for why "deliberately FAILED" and "unmapped, degrading to + /// FAILED" are kept distinguishable in the logs even though the return value + /// is the same today. + /// + /// Status string from the V2 order response. + public static int V2StatusToRequestDisposition(string v2Status) + { + switch (v2Status?.ToLowerInvariant()) + { + case Constants.ApiV2.StatusIssued: + // Expired-but-not-revoked certs remain in inventory as GENERATED — + // mirrors StatusMapper.ToRequestDisposition's V1 convention and the + // sync/report path's own "expired" case. + case Constants.ApiV2.StatusExpired: + return (int)EndEntityStatus.GENERATED; + + case Constants.ApiV2.StatusPendingDcv: + case Constants.ApiV2.StatusPendingCsr: + case Constants.ApiV2.StatusPendingAgreement: + case Constants.ApiV2.StatusPendingOrganizationVerification: + case Constants.ApiV2.StatusPendingDocuments: + case Constants.ApiV2.StatusPendingApproval: + return (int)EndEntityStatus.EXTERNALVALIDATION; + + case Constants.ApiV2.StatusRevoked: + return (int)EndEntityStatus.REVOKED; + + case Constants.ApiV2.StatusCancelled: + case Constants.ApiV2.StatusRejected: + return (int)EndEntityStatus.FAILED; + + case Constants.ApiV2.StatusUnknown: + // Issue 0039: `unknown` is in the spec's documented status list, so it is NOT + // the "status we've never heard of" case below — CERTInext is saying it can't + // currently report where the order is, not that the order is dead. Treat it as + // pending so Command keeps the order and sync/pickup keep re-checking it, rather + // than dropping a possibly-live order as FAILED. Warn so an order stuck here is + // visible to operators. + Logger.LogWarning( + "V2StatusToRequestDisposition: CERTInext reported V2 order status 'unknown' — " + + "treating the order as pending (EXTERNALVALIDATION) rather than FAILED; it will be " + + "re-checked on the next status poll or sync. If an order stays 'unknown', raise it with CERTInext."); + return (int)EndEntityStatus.EXTERNALVALIDATION; + + default: + // Distinct from the deliberate cancelled/rejected/expired -> FAILED + // mappings above: this status string isn't recognized at all. Log so + // an operator (or issues/0031-style audit) can tell "legitimately + // failed" apart from "gateway doesn't know this status yet" — degrade + // to FAILED rather than guessing EXTERNALVALIDATION, since an + // unrecognized value could just as easily be a new terminal state. + Logger.LogWarning( + "V2StatusToRequestDisposition: unmapped V2 order status '{V2Status}' — " + + "defaulting to FAILED. This is not one of the V2 spec's documented status " + + "values; if CERTInext has added a new status, StatusMapper needs updating.", + v2Status); + return (int)EndEntityStatus.FAILED; + } + } + + /// + /// Converts an RFC 5280 CRL reason code to the V2 API revocation reason string. + /// Values are the CERTInext V2 spec's kebab-case `reason` enum (see + /// and issues/0019 — sending the + /// legacy camelCase strings gets HTTP 400). Codes without a direct V2 + /// equivalent (e.g. RFC 5280 code 8, "removeFromCRL", which is CRL-only and + /// not a valid revocation request reason) are mapped to "unspecified". + /// + /// RFC 5280 CRL reason code from the gateway. + public static string ToV2RevocationReason(uint crlReason) => + crlReason switch + { + 1 => Constants.RevocationReasonV2.KeyCompromise, // RFC: keyCompromise + 2 => Constants.RevocationReasonV2.CACompromise, // RFC: cACompromise + 3 => Constants.RevocationReasonV2.AffiliationChanged, // RFC: affiliationChanged + 4 => Constants.RevocationReasonV2.Superseded, // RFC: superseded + 5 => Constants.RevocationReasonV2.CessationOfOperation,// RFC: cessationOfOperation + 6 => Constants.RevocationReasonV2.CertificateHold, // RFC: certificateHold + 9 => Constants.RevocationReasonV2.PrivilegeWithdrawn, // RFC: privilegeWithdrawn + 10 => Constants.RevocationReasonV2.AACompromise, // RFC: aACompromise + _ => Constants.RevocationReasonV2.Unspecified + }; + + /// + /// Converts a V2 API revocation reason string (the CERTInext V2 spec's kebab-case + /// reason enum on the Track Order response's nested revocation object, + /// e.g. "cessation-of-operation") back to the RFC 5280 CRL reason code for storage in + /// the Keyfactor Command database (issues/0034). Inverse of + /// . Unrecognized or null input (including the + /// not-revoked case, where the caller should not invoke this at all) falls back to 0 + /// (unspecified), mirroring 's V1 default. + /// + /// Raw revocation.reason string from the V2 Track Order response. + public static int V2RevocationReasonToCrlCode(string v2Reason) + { + switch (v2Reason?.ToLowerInvariant()) + { + case Constants.RevocationReasonV2.KeyCompromise: return 1; + case Constants.RevocationReasonV2.CACompromise: return 2; + case Constants.RevocationReasonV2.AffiliationChanged: return 3; + case Constants.RevocationReasonV2.Superseded: return 4; + case Constants.RevocationReasonV2.CessationOfOperation: return 5; + case Constants.RevocationReasonV2.CertificateHold: return 6; + case Constants.RevocationReasonV2.PrivilegeWithdrawn: return 9; + case Constants.RevocationReasonV2.AACompromise: return 10; + default: return 0; + } + } + /// /// Converts a CERTInext revokeReasonId integer back to the RFC 5280 CRL /// reason code for storage in the Keyfactor Command database. diff --git a/CHANGELOG.md b/CHANGELOG.md index c49165c..936b3ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,149 @@ -# Changelog +# 1.0.1 -## [1.0.0] - Unreleased +## Features +- feat(v2): Add opt-in CERTInext V2 REST API code path — OAuth2 `client_credentials` auth and V2 status mapping — controlled by `UseV2Api` config flag (defaults `false`; V1 unchanged). +- feat(v2): V2 enrollment handles all three `EnrollmentType` values (New/Reissue/RenewOrReissue) via a single V2 order placement; issued orders download the certificate immediately. +- feat(v2): V2 revocation resolves the order's product family (SSL → Private PKI → Signature) and revokes it there. +- feat(v2): V2 `GetSingleRecord` resolves order status across all three V2 product families without touching the V1 path. +- feat(v2): Synchronize now uses V2 `/reports/orders` when `UseV2Api` is true, with an incremental lookback window (`V2SyncLookbackHours`, default 72h) — V1 credentials are no longer required in V2 mode. +- feat(v2): Consolidated V2 config onto the existing `ApiUrl`/`OAuthClientId`/`OAuthClientSecret` fields; the never-shipped `ApiUrlV2`/`ClientId`/`ClientSecret` fields are removed. +- **Faster enrollment for quickly-issued certificates.** Enrollment now waits briefly and returns the certificate in the same request when it issues fast, instead of always waiting for the next sync. Configurable via `PickupRetries` (default 5, `0` disables) and `PickupDelay` (default 10s). Orders that don't issue in time (e.g. OV/EV) return pending and are picked up by the next sync, as before. +- feat(v2): V2 enrollment now supports multi-SAN (UCC) certificates. UCC products are detected from the live Catalog's `productTypeID`, and the SAN set is sent via `additionalDomains` (F3). -### Added +## Bug Fixes +- fix(revoke): V2 revoke denials (404/422) and the not-GENERATED/retry-failure paths now log an audit record (CARequestID, product family, HTTP status, EMS code). +- fix(enroll): a transport error or timeout on V2 CSR submission no longer cancels an order the CA may have accepted; the plugin tracks the order first and cancels only if it is still pending-csr. +- **UCC certificates no longer come back with only the common name.** The gateway sends SANs under the key `dnsname`, which the plugin didn't recognize, so orders went out with an empty domain list. SANs are now read from every key the gateway sends, plus from the CSR itself. +- **Renewals no longer lose their SANs.** Renewals were submitted with no additional domains and the wrong primary domain; both now come from the certificate being renewed. +- **Enrollment no longer fails on an order CERTInext auto-approves before it finishes issuing.** The plugin used to report these as issued with no certificate attached, which the gateway rejected. It now returns pending and picks up the certificate once CERTInext finishes issuing it. +- **Renewals now use the certificate template's product code.** Renewals previously always used the connector's `DefaultProductCode`, which could send an empty product code if that setting was never configured. Renewals now use the template's code, falling back to `DefaultProductCode` only when the template doesn't have one. +- **V2 OAuth errors now name the right cause.** 401 means a bad ClientId/ClientSecret; 403 means the key wasn't created in OAuth mode. +- **V2 error messages now include CERTInext's per-field validation errors.** +- **V2 revocation no longer fails with HTTP 400 for most reasons.** Reasons are now sent in the kebab-case form the API requires. +- **V2 revocation no longer fails when Command supplies no specific reason.** CERTInext rejects the "unspecified" reason value; the plugin now retries once with "cessation-of-operation" (0026). +- **V2 revocation now reports "not found or not revokable" instead of a misleading product-family error.** +- **V2 DCV now treats an already-verified domain (EMS-1080) as satisfied instead of deferring.** +- **V2 DCV now reads the live `token` field instead of the never-populated `fileNameContent` field**, so fresh-domain DV orders no longer get stuck at EXTERNALVALIDATION forever (0037). +- fix(config): `ValidateProductInfo` now validates template `ProductCode` against the V2 catalog when `UseV2Api=true`, instead of always calling the V1-only `GetProductDetails` (0025). +- fix(client): `ParseProductDetailsV2Response` now flattens the nested category envelope the live V2 catalog actually returns, instead of misreading it as flat rows (0016). +- fix(sync): `V2StatusToRequestDisposition` now maps all 11 V2 order statuses; OV/EV/DV orders in `pending-organization-verification`, `pending-documents`, or `pending-approval` no longer get misreported to Command as FAILED (0031). +- fix(enroll): V2 OV/EV orders now send an `organization` block from `OrganizationNumber`; CERTInext previously hard-rejected every V2 OV/EV enrollment with HTTP 422 `EMS-1180` (0028). +- fix(v2): V2 enroll and `ValidateProductInfo` now resolve/validate the product code from the live catalog by `productTypeID` instead of the V1-only `DefaultProductCodes` table, which could silently order the wrong assurance-level product (0036). +- fix(v2): V2 order create, catalog, and orders-report calls now send `GroupNumber` when configured, instead of always billing/scoping to the account's default group (0029). +- fix(v2): V2 SSL order create now sends a `technicalPointOfContact` block from the connector's `TechnicalContact*` config (falling back to `Requestor*` when blank), instead of never sending one (0030). +- fix(v2): V2 `GetSingleRecord`/`Synchronize` now populate `RevocationDate`/`RevocationReason` from the Track Order response's nested `revocation` object, instead of a flat DTO shape that never matched the live API and was never read anyway (0034). +- fix(sync): V2 `Synchronize` now falls back to an already-fetched Track Order `productVariant` when the orders-report row's `ProductCode` is empty, instead of always leaving `ProductID` blank in that case (0035). +- fix(sync): V2 `Synchronize` now honors `IgnoreExpired`, instead of always including expired certificates (0027). +- fix(v2): V2 DCV TXT record hostname now uses the configured `DcvTxtRecordTemplate` (falling back to the same default V1 uses), instead of a hardcoded `_emudhra-challenge` label (0027). +- fix(v2): V2 SSL order create now combines `RequestorIsdCode` with the mobile number for `Requestor.Phone`, instead of sending the bare mobile number (0027). +- fix(v2): V2 SSL order create now sends `Subscription.AutoRenew`/`RenewBeforeDays` from `SubscriptionAutoRenew`/`SubscriptionRenewCriteriaDays` config, instead of hardcoding `false`/`30` (0027). +- fix(config): `requestor.designation` (V2) and `requestorInformation.requestorDesignation` (V1) are now sourced from a new `RequestorDesignation` config field, instead of a hardcoded `"IT Administrator"` (V2) or never being sent at all (V1) (0027). +- fix(v2): V2 SSL order create now honors the connector's `EmailNotifications` setting (`"1"`→`"all"`, `"0"`→`"0"`, blank→omitted) instead of always sending `"all"`; V2 orders now default to `"0"` (reduced notifications), matching V1 (0027). +- fix(enroll): the V2 single-domain CSR-SAN-count guard now exempts UCC products, instead of rejecting every UCC CSR enrollment before it could reach the UCC path (0047). +- fix(audit): the "Enrollment complete" audit log now records the leaf serial for V2 chain PEMs instead of `(parse-error)` (0050). +- fix(v2): V2 enrollment now runs the same short certificate-pickup poll as V1 instead of returning pending when the order hasn't issued yet at the post-CSR check (0051). +- fix(v2): V2 DCV now runs for every SAN on a UCC order, not just the primary domain (0042). +- fix(sync): V2 `Synchronize`/`GetSingleRecord` no longer emit a body-less REVOKED record unless the gateway already holds a certificate body for that order, preventing a poisoned gateway row that broke every future Command scan of the CA (0049). +- fix(enroll): V2 `Enroll` no longer returns a body-less REVOKED result; a REVOKED disposition observed post-CSR-submit, post-DCV, or during the pickup poll is now reported as FAILED (0052). +- fix(v2): V2 `private-pki` enrollment now sends the Private PKI order body (`variant`, `hostname`, `additionalHosts` incl. IP SANs; no DCV) instead of the SSL body; `signature` enrollment fails fast until its subject mapping is designed (0033). +- fix(client): V1 calls that get a non-2xx response now include the HTTP status in the error and log the redacted response body (0044). +- fix(v2): V2 UCC enrollment now logs non-DNS SANs as excluded from `additionalDomains` instead of the V1 "submitted rather than dropped" warning (0046). +- fix(logging): requestor personal data (name, email, phone, org contact fields) and full CA request/response payloads are now redacted from gateway logs by default, gated behind a new opt-in `LogSensitiveRequestData` connector setting (0040). +- fix(logging): email SAN values are now masked in enrollment and SAN-resolution log lines unless `LogSensitiveRequestData` is on (0040). +- fix(logging): email SANs in Trace-logged `additionalDomains`/`additionalHosts` arrays and V1 `domainVerification` keys are now masked unless `LogSensitiveRequestData` is on (0040). +- fix(v2): if V2 CSR submission fails after the order is placed, the plugin now cancels the orphaned order once (best effort) and returns FAILED with its order ID (0039). +- fix(config): V2 connectors now require `SignerPlace` at save time, and V2 SSL enrollment fails fast if it resolves blank, since the Subscriber Agreement requires it (0039). +- fix(sync): the spec-documented V2 order status `unknown` now maps to pending instead of FAILED, so a possibly-live order isn't dropped (0039). +- fix(v2): V2 order create now omits `X-Product-Code` entirely for a null/blank product code instead of sending it empty (0054). +- fix(v2): non-UCC V2 enrollment now rejects extra SANs from Command's SAN dictionary instead of silently dropping them (0061). +- fix(v2): V2 SSL enrollment now derives `productVariant` from the product and rejects a mismatched override, instead of always sending `dv` (0059). +- fix(sync): V2 order status `expired` now maps to GENERATED instead of FAILED, matching V1 and the sync/report path. +- fix(config): `ApiUrl` now requires `https` (credentials would otherwise go out in cleartext); `http` remains allowed for loopback hosts only, for local test servers. +- fix(revoke): V2 revoke's reason-rejection retry now covers all 4 live-rejected CRL reasons (0, 2, 6, 10), not just "unspecified"; each retries once with an accepted fallback (0026). +- fix(enroll): V2 product resolution (no explicit ProductCode) now rejects an ambiguous catalog match instead of silently picking the first-listed entry, which on sandbox ordered an unorderable DV SSL variant; `DefaultProductCode` can disambiguate. Mirrored in `ValidateProductInfo`. +- fix(enroll): the V2 single-domain SAN guard now exempts a wildcard product's bare apex (e.g. `example.com` alongside `*.example.com`) instead of rejecting it as an extra SAN; the rejection message for wildcard products no longer suggests a UCC product. +- fix(crypto): DCV now derives the TXT record hostname (and DNS validator zone) from a wildcard domain's base domain instead of staging a literal `*.` DNS label, across the V1 and V2 single/multi-domain DCV paths; a UCC order listing both the apex and its wildcard now stages and cleans up one shared TXT record instead of two. CA-side acceptance of a base-domain TXT record for a wildcard domain entry is unverified against the live API. +- fix(config): `Initialize` now enforces the same https-or-loopback rule on `ApiUrl`/`OAuthTokenUrl` as `ValidateCAConnectionInfo`, closing a gap where a connector saved before that check existed kept sending credentials in cleartext on every gateway restart. +- fix(config): `OAuthTokenUrl` (V1 OAuth mode) now requires https-or-loopback, matching `ApiUrl`; it previously only checked for non-empty. + +## Chores +- chore(tests): regression tests for the wildcard DCV hostname fix — hostname derivation, apex/wildcard hostname dedupe and single cleanup, and non-wildcard-unchanged, across V1 and V2 single/multi-domain paths. +- chore(tests): the V2 fresh-DCV integration tests now target a sibling of a genuinely unverified parent (`CERTINEXT_V2_FRESH_DCV_PARENT`) instead of a subdomain of `CERTINEXT_DCV_DOMAIN`, which that domain's own prior DCV already covers and could never actually exercise the publish path. +- chore(tests): the V2 wildcard+apex SAN integration test now records SAN coverage of the issued certificate (observation only) and describes the guard's wildcard-apex exemption instead of a stale "suspected guard bug NOT reproduced" comment. +- docs(v2): V2 renewal/reissue places a new order by design; the CA's `/reissue` endpoint is intentionally unused (0021, 0038). +- docs(v2): correct stale V2 claims about UCC, credentials, order IDs, idempotency, sync, revoke reasons, and product codes (0038). +- chore(scripts): `scripts/v2/*.sh` dev helpers now use `CERTINEXT_API_URL` + OAuth2 `client_credentials` from `~/.env_certinext_v2`; mutating scripts require `--yes-mutate` (0048). +- chore(tests): WireMock-based unit tests for all V2 client methods (token fetch, caching, PlaceOrder, TrackOrder, Download, Revoke, family resolution). +- chore(tests): Moq-based unit tests verifying V2 dispatch in `CERTInextCAPlugin` (Ping, Enroll, GetSingleRecord, Revoke, Synchronize) with `Times.Never` assertions on V1 paths. +- chore(tests): `StatusMapperV2Tests` covering all V2 status strings and CRL-to-V2-reason mappings. +- chore(tests): Integration test stubs in `V2ApiTests.cs` (gated behind `CERTINEXT_USE_V2_API=1`); skip gracefully when V2 credentials are absent. +- chore(tests): Unit tests for V2 `ValidateCAConnectionInfo`. +- chore(tests): Unit tests for V2 token caching and expiry (`refresh_token` grant never sent). +- chore(tests): DCV cleanup-concurrency test now checks peak concurrency instead of wall-clock time. +- chore(tests): `ValidateProductInfo` coverage in V1 and V2 modes, plus V2 catalog-parser unit and live-integration tests (0025). +- chore(tests): regression tests for the V2 `organization` block (populated for OV/EV, omitted for DV, fail-fast without `OrganizationNumber`); live acceptance against the sandbox confirmed CERTInext accepts the fixed request (0028). +- chore(tests): regression coverage pinning the live V2 DCV response shape (`token`/`tokenExpiryDate` only) against both the client deserializer and the plugin's DCV staging path (0037). +- chore(tests): regression tests for `productVariant` derivation/validation at enroll and template save, including the OV/EV organization-block interaction (0059). +- chore(tests): regression coverage for V2 `productTypeID`-based product code resolution/validation and the V1-fallback-unaffected guarantee (0036). +- chore(tests): regression coverage for `GroupNumber` on V2 order create, catalog, and orders-report calls (0029). +- chore(tests): regression coverage for `technicalPointOfContact` (configured, blank-fallback, and per-field-fallback) on V2 SSL orders (0030). +- chore(tests): regression coverage for the nested V2 `revocation` DTO shape and `RevocationDate`/`RevocationReason` population in `GetSingleRecord`/`Synchronize` (0034). +- chore(tests): regression coverage for `Synchronize`'s ProductID preference order — report row's `ProductCode` first, then a lazily-fetched `productVariant`, then empty (0035). +- chore(tests): regression coverage for `IgnoreExpired` in V2 `Synchronize`, the configurable V2 DCV TXT record template, and ISD-code composition for `Requestor.Phone` (0027). +- chore(tests): regression coverage for `EmailNotifications` mapping (`"1"`/`"0"`/blank/invalid) on V2 order create (0027). +- chore(tests): regression coverage for the V2 CSR-SAN-count guard through `Enroll` for both UCC (order placed, SANs as `additionalDomains`) and non-UCC (FAILED, no order placed) products (0047). +- chore(tests): regression coverage for the V2 bodyless-REVOKED guard — gateway-holds-body, no-body, no-row, reader-failure, and GENERATED-unaffected cases, in both `Synchronize` and `GetSingleRecord` (0049). +- chore(tests): regression coverage for the V2 `Enroll` REVOKED→FAILED normalization — post-CSR-submit, mid-pickup-poll, post-DCV-recheck, and FAILED/GENERATED-unaffected cases (0052). +- chore(tests): regression coverage for `RedactPersonalData`/`ApplyLoggingRedaction` against realistic V1/V2 order payloads, `LogSanitizer.MaskEmail`, the `LogSensitiveRequestData` config default/annotation, and the flag's on/off behavior in `Enroll`'s audit log line (0040). +- chore(tests): opt-in live V2 lifecycle coverage for DV UCC, OV, OV UCC, EV, wildcard DV (both CSR shapes), renew/reissue, and DCV against a fresh unverified domain — product shapes the V2 suite had no assertion-bearing live test for. +- chore(tests): the renew/reissue lifecycle test now fails on a FAILED order instead of just recording it; OV/OV UCC skip cleanly (and sweep for an orphaned order) on the known 120s client-timeout condition (0064); EV now requires its own `CERTINEXT_EV_ORG_NUMBER`; a new wildcard fresh-subdomain DCV test records the staged TXT hostname and Track Order's per-domain verification detail. +- chore(tests): add an opt-in `CERTINEXT_V2_SWEEP_FROM`/`_TO` orders-report sweep that lists, and via `CERTINEXT_V2_SWEEP_CANCEL_IDS` optionally cancels, orders in an arbitrary UTC window. +- **`OrganizationNumber`, `DefaultProductCode`, and `GroupNumber` are now visible in the startup log.** Whether each is set is now logged alongside the other connector settings, making a misconfigured connector easier to diagnose from logs alone. +- **Corrected the `AutoApprove` template setting's description.** It previously implied the plugin would attempt automatic approval of pending certificates; it does not currently do this. + +## Upgrade Notes +- **V2 templates with only `ProductId` need `ProductCode` or the connector's `DefaultProductCode` when the catalog has several products of that type**; otherwise enrollment fails and lists the candidates. +- V2 OV/EV templates without `ProductVariant` now send `ov`/`ev` and require the connector's `OrganizationNumber`. +- **`ApiUrl` and `OAuthTokenUrl` must use https** (http only for loopback); existing http connectors now fail at startup. +- V2 revoke reasons CERTInext rejects are recorded as substitutes: CA/AA compromise as key-compromise, unspecified/certificate-hold as cessation-of-operation. +- V2 `expired` orders are reported as issued, and revoking one is now sent to the CA instead of being refused locally. +- V2 Renew/Reissue place a new order; the original is not revoked. +- **Non-DNS SANs (IP, email, URI) are now submitted instead of silently dropped.** CERTInext can't validate them, so such an order won't issue until the SAN is removed. Set `SubmitNonDnsSans` to `false` to restore the old drop-silently behavior. +- **No more duplicate or orphaned orders after a network timeout.** Order/CSR submissions no longer auto-retry after a timeout, since the CA may have already created the order. If it was created, the next sync imports it. + +## Known Limitations +- OV and OV UCC are not verified end to end under V2 — a slow create can exceed the plugin's 120s client timeout, leaving a CA-side order the gateway doesn't track (0064). +- EV is not verified live under V2. +- DCV TXT publish/verify on a never-before-validated domain, and wildcard DCV, are not verified live against the CA (sandbox limitation). +- V2's multi-domain order model differs from the spec's documented product auto-resolve behavior; a non-UCC product with extra CSR SANs is rejected client-side before any order is placed (0060). +- The CSR is submitted in a separate call after order creation, not inline on create (0062). +- A UCC order's CSR carries every SAN, not just the primary domain in `CN` as the spec describes (0063). + +# 1.0.0 + +Initial release of the CERTInext (emSign Hub) AnyCA REST Gateway plugin. + +## Features +- feat(enroll): Certificate enrollment for CERTInext SSL products — DV, OV, and EV SSL, including Wildcard and Multi-Domain (UCC) variants — with connector- and template-level overrides for product code, requestor identity, organization/group, and validity. +- feat(dcv): End-to-end DNS-01 domain validation for DV SSL through a pluggable `IDomainValidatorFactory` (Cloudflare provider included). Publishes the TXT challenge, asks CERTInext to verify, waits for issuance, and returns the issued certificate directly from `Enroll`. (DCV build — AnyCA Gateway 26.x.) +- feat(sync): Full and incremental CA synchronization via paginated `GetOrderReport`. Issued certificates carry their full PEM body; revoked certificates carry revocation metadata. +- feat(sync): Sync-driven DCV retry drives orders left pending validation to completion on later sync passes, bounded by configurable `DcvSyncMaxOrderAgeHours` and `DcvSyncMaxPerPass` caps so large accounts stay fast. +- feat(revoke): Certificate revocation via `RevokeOrder` with RFC 5280 reason-code mapping. +- feat(auth): AccessKey (HMAC-SHA256) and OAuth client-credentials authentication modes. +- feat(build): Single `DcvSupport` MSBuild flag selects the host-matched build from one codebase — default no-DCV (IAnyCAPlugin `3.2.0`, AnyCA Gateway 25.5.x) or `-p:DcvSupport=true` for the DCV build (IAnyCAPlugin `3.3.0-PRERELEASE`, 26.x). Records persist only when the build matches the host's IAnyCAPlugin version. +- feat(config): Connector-level configuration for pre-vetted organization/group/technical-contact injection, DCV timing knobs (challenge/issuance waits), and SSL order defaults. +- feat(sync): `IgnoreExpired` flag to exclude expired certificates from synchronization. + +## Bug Fixes +- fix(sync): Issued certificates now synchronize with their full PEM body — the `GetOrderReport` listing carries no body, so the plugin refetches the full certificate for issued/revoked records. Previously issued certs synced empty and never appeared in Command. +- fix(sync): Preserve listing metadata (`Subject`, `ProductID`, order date) when refetching the certificate body during synchronization, so issued records are not emitted with null fields. +- fix(diagnostics): Every CERTInext API failure logs the HTTP status plus the CA's error code and message; transient rate-limit responses are retried with exponential backoff and jitter. + +## Chores +- chore(crypto): All cryptographic operations (CSR/key generation, hashing, the auth nonce) use BouncyCastle exclusively — no `System.Security.Cryptography`. +- chore(deps): `BouncyCastle.Cryptography` 2.6.2 (closes 3 moderate-severity CVEs). +- chore(compat): Ship builds for both `net8.0` and `net10.0`. +- chore(logging): Verbose Debug/Trace logging across the sync flow with method entry/exit tracing. +- chore(tests): Live integration tests covering all supported SSL/TLS product types, the DCV enroll → issue → sync flow, and a key-algorithm matrix — confirms CERTInext issues RSA 2048/3072/4096 and ECC P-256/P-384, and rejects larger RSA, ECC P-521, and Ed25519/Ed448. +- chore(scripts): API smoke-test scripts for every endpoint, including `reject-order` / `reject-all-pending` for cancelling pending orders. -- Initial release of the CERTInext AnyCA REST Gateway plugin -- Certificate enrollment for DV SSL (838), DV Wildcard (839), DV UCC (840), OV SSL (842), and EV SSL (846) product types -- Certificate revocation via `RevokeOrder` with RFC 5280 reason code mapping -- Full and incremental CA synchronization via paginated `GetOrderReport` -- AccessKey (HMAC-SHA256) and OAuth client credentials authentication modes -- `IgnoreExpired` flag to exclude expired certificates from synchronization -- Live integration tests covering all supported SSL/TLS product types (draft order mode) diff --git a/DCV_BUILD_SUPPORT.md b/DCV_BUILD_SUPPORT.md new file mode 100644 index 0000000..b78a3d8 --- /dev/null +++ b/DCV_BUILD_SUPPORT.md @@ -0,0 +1,41 @@ +# DNS-01 DCV: build flag and code fencing + +## Build flag + +DNS-01 DCV support is gated behind a single MSBuild property, **`DcvSupport`**, default `true`. It's defined in `CERTInext/CERTInext.csproj`: + +``` +dotnet build -p:DcvSupport=false +``` + +- `DcvSupport=true` (default): compiles against `Keyfactor.AnyGateway.IAnyCAPlugin` **3.3.0** (stable release), defines `SUPPORTS_DCV` — this is what CI ships, targeting 26.x/DCV-capable gateway hosts. +- `DcvSupport=false`: compiles against **3.2.0**, no `SUPPORTS_DCV` constant, targeting GA gateway hosts (AnyCA Gateway 25.5.x, see issue 0003). + +The project also only targets **`net10.0`** (single TFM, no more `net8.0`/`net10.0` multi-targeting). + +Relevant lines: [`CERTInext.csproj#L18-L19`](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInext.csproj#L18-L19) (the flag → `SUPPORTS_DCV` define) and [`#L29-L30`](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInext.csproj#L29-L30) (the package-version swap). + +The two test projects mirror this flag so DCV test files only compile in when asked: +- [`CERTInext.Tests.csproj#L11-L12`](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext.Tests/CERTInext.Tests.csproj#L11-L12) +- [`CERTInext.IntegrationTests.csproj#L11-L12`](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj#L11-L12) + +## Where `#if SUPPORTS_DCV` fences the feature + +All in `CERTInext/CERTInextCAPlugin.cs`: + +| Lines | What's fenced | +|---|---| +| [22–24](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L22-L24) | `using` alias for `IDomainValidatorFactory` | +| [72–75](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L72-L75) | typed `DomainValidatorFactory` property (casts the untyped `_domainValidatorFactory` field) | +| [155–163](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L155-L163) | internal test constructor that injects an `IDomainValidatorFactory` | +| [178–197](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L178-L197) | `SetDomainValidatorFactory` — real assignment vs. `#else` no-op log | +| [789–798](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L789-L798) | `Synchronize`: DCV-during-sync bookkeeping vars (age window, per-pass cap, counters) | +| [849–897](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L849-L897) | `Synchronize`: the actual per-order DCV-during-sync gate/attempt/refetch logic | +| [1014–1021](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L1014-L1021) | `Synchronize`: builds the DCV summary log clause — real stats vs. `#else` "not supported on this build" | +| [1108–1171](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L1108-L1171) | `EnrollNewAsync`: runs DCV right after a fresh order is placed, then polls for issuance | +| [1373–1424](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L1373-L1424) | `TryRunDcvDuringSyncAsync` — full retry-path body vs. `#else` `return false` no-op | +| [1442–1704](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L1442-L1704) | `PerformDcvIfNeededAsync` itself — the whole method only exists in the DCV build | + +Design note baked into the comments: `_domainValidatorFactory` is deliberately typed as `object` (not `IDomainValidatorFactory`) so the JIT never needs to resolve the 3.3-only type when `SUPPORTS_DCV` is off — casts only happen inside method bodies fenced by `#if`, which is what lets the no-DCV build load cleanly on a 3.2.0 gateway host (see the field comment at [L40-L60](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L40-L60), issue #7). + +Reminder: DCV is now the default build — `-p:DcvSupport=false` is the opt-out for GA/no-DCV hosts. Without it, the build targets 26.x hosts and depends on the stable `3.3.0` package. diff --git a/Makefile b/Makefile index 1a3e8f0..1b445d4 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,15 @@ SLN := certinext-caplugin.sln COVERAGE_DIR := /tmp/certinext-coverage REPORT_DIR := /tmp/certinext-coverage-report +# --------------------------------------------------------------------------- +# V2 API credentials — CERTINEXT_API_URL / CERTINEXT_CLIENT_ID / +# CERTINEXT_CLIENT_SECRET in ~/.env_certinext_v2 (override the path with +# CERTINEXT_V2_ENV_FILE). CERTINEXT_API_URL is the V2 base URL, without the +# /emSignHub-API/ suffix, e.g.: +# CERTINEXT_API_URL=https://sandbox-us.certinext.io +# See scripts/v2/README.md. +# --------------------------------------------------------------------------- + .PHONY: build test integration-test coverage coverage-report open-coverage clean \ ping \ get-product-details products \ @@ -11,10 +20,14 @@ REPORT_DIR := /tmp/certinext-coverage-report get-order-report orders \ track-order get-order \ get-certificate get-cert \ + get-dcv \ + verify-dcv \ generate-order \ revoke-order \ submit-csr \ list-cas \ + register register-profiles register-ca-config register-claims \ + register-command-ca register-import register-enrollment \ create-product \ generate-order-igtf \ generate-order-149-fresh \ @@ -24,7 +37,28 @@ REPORT_DIR := /tmp/certinext-coverage-report show-postman-bodies \ show-postman-variables \ probe-private-pki-payloads \ - api-help + api-help \ + v2-ping \ + v2-list-products \ + v2-get-custom-fields \ + v2-list-groups \ + v2-list-organizations \ + v2-list-domains \ + v2-create-ssl-order \ + v2-track-order \ + v2-get-dcv \ + v2-verify-dcv \ + v2-submit-csr \ + v2-accept-agreement \ + v2-download-certificate \ + v2-revoke-ssl \ + v2-cancel-ssl-order \ + v2-create-private-pki-order \ + v2-track-private-pki \ + v2-submit-csr-private-pki \ + v2-download-certificate-private-pki \ + v2-revoke-private-pki \ + v2-orders-report build: dotnet build $(SLN) @@ -175,6 +209,32 @@ track-order get-order: get-certificate get-cert: @ORDER_NUMBER=$(ORDER_NUMBER) scripts/get-certificate.sh + +# --------------------------------------------------------------------------- +# GetDcv — POST {baseURL}GetDcv +# Fetches the DCV token for a domain on an existing order +# Mirrors ICERTInextClient.GetDcvAsync +# Required: ORDER_NUMBER= DOMAIN_NAME= +# Optional: DCV_METHOD=1 (1=DNS TXT, 2=HTTP file, 3=Email; default 1) +# --------------------------------------------------------------------------- + +DCV_METHOD ?= 1 + +get-dcv: + @ORDER_NUMBER=$(ORDER_NUMBER) DOMAIN_NAME=$(DOMAIN_NAME) DCV_METHOD=$(DCV_METHOD) scripts/get-dcv.sh + +# --------------------------------------------------------------------------- +# VerifyDcv — POST {baseURL}VerifyDcv +# Instructs CERTInext to check the published DCV token for a domain +# Mirrors ICERTInextClient.VerifyDcvAsync +# Call after publishing the TXT record and allowing time for DNS propagation. +# Required: ORDER_NUMBER= DOMAIN_NAME= +# Optional: DCV_METHOD=1 (default 1 = DNS TXT) +# --------------------------------------------------------------------------- + +verify-dcv: + @ORDER_NUMBER=$(ORDER_NUMBER) DOMAIN_NAME=$(DOMAIN_NAME) DCV_METHOD=$(DCV_METHOD) scripts/verify-dcv.sh + # --------------------------------------------------------------------------- # GenerateOrderSSL — POST {baseURL}GenerateOrderSSL # Places a new SSL/TLS certificate order — mirrors ICERTInextClient.PlaceOrderAsync @@ -243,6 +303,51 @@ submit-csr: list-cas: @scripts/list-cas.sh +# --------------------------------------------------------------------------- +# register-* — provision profiles/templates into the AnyCA REST Gateway and +# Keyfactor Command. These talk to Command/gateway (OAuth2 client_credentials), +# NOT the CERTInext API — see scripts/lib/command-auth.sh for the env contract +# (TOKEN_URL, OIDC_CLIENT_ID/SECRET, GATEWAY_HOST, COMMAND_HOST, ...). +# +# make register # full provisioning (stages 01..06) +# make register DRY_RUN=1 # DRY_RUN forwards to every stage +# make register SKIP_03=1 # skip a stage by number +# +# Per-stage (each idempotent; add DRY_RUN=1 for an offline preview): +# make register-profiles # 01 gateway certificate profiles [CHECK=1] +# make register-ca-config # 02 gateway CAConnection + Templates +# make register-claims # 03 gateway access claims (IAM) +# make register-command-ca # 04 register CA in Command +# make register-import # 05 import templates into Command [CHECK=1] +# make register-enrollment # 06 enrollment patterns + template KeyRetention +# +# Stages 01 and 06 are VERIFIED live; 02-05 are built from docs/reference +# captures — validate against a live gateway/Command before relying on them. +# Auth (cookie/token/OAuth), env vars, and gotchas: scripts/register/README.md. +# NOTE: stage 04 (and stage 02's CA-connection PUT) touch the CA config, which +# is fragile — leave it alone unless explicitly required. +# --------------------------------------------------------------------------- +register: + @scripts/register/00-register-all.sh + +register-profiles: + @scripts/register/01-gateway-profiles.sh + +register-ca-config: + @scripts/register/02-gateway-ca-config.sh + +register-claims: + @scripts/register/03-gateway-claims.sh + +register-command-ca: + @scripts/register/04-command-register-ca.sh + +register-import: + @scripts/register/05-command-import-templates.sh + +register-enrollment: + @scripts/register/06-command-enrollment-patterns.sh + # --------------------------------------------------------------------------- # create-product — Create a custom product via API # @@ -382,6 +487,277 @@ probe-private-pki-payloads: generate-test-csr --product "$(PRIVATE_PKI_CODE)" \ --save-and-hold "$(SAVE_AND_HOLD)" +# --------------------------------------------------------------------------- +# V2 API targets (credentials from ~/.env_certinext_v2) +# +# Auth: scripts/lib/certinext-v2-auth.sh fetches an OAuth2 client_credentials +# token at POST {CERTINEXT_API_URL}/oauth/token (same as the plugin's V2 mode). +# The env file is parsed, not sourced; the secret/token never hit argv, disk, +# or output. +# +# Mutating targets (create/verify-dcv/submit-csr/accept/cancel/revoke) only +# PREVIEW the request unless you pass V2_ARGS=--yes-mutate, e.g.: +# make v2-revoke-ssl ORDER_ID=123 V2_ARGS=--yes-mutate +# Full details: scripts/v2/README.md. +# --------------------------------------------------------------------------- + +V2_ARGS ?= + +# --------------------------------------------------------------------------- +# v2-ping — GET /api/certinext/v2/auth/me +# Connectivity + auth check; returns the account context the token resolves to. +# Mirrors ICERTInextClient.PingAsync via the V2 API. +# --------------------------------------------------------------------------- + +v2-ping: + @echo "V2 ping — GET /api/certinext/v2/auth/me" + @scripts/v2/ping.sh + +# --------------------------------------------------------------------------- +# v2-list-products — GET /api/certinext/v2/catalog/products +# Lists every SSL / Document Signer / Private PKI product the account can order. +# Each entry carries a stable productCode used as the X-Product-Code header. +# --------------------------------------------------------------------------- + +v2-list-products: + @echo "V2 list products — GET /api/certinext/v2/catalog/products" + @scripts/v2/list-products.sh + +# --------------------------------------------------------------------------- +# v2-get-custom-fields — GET /api/certinext/v2/catalog/products/{code}/custom-fields +# Returns mandatory + optional custom fields for a product code. +# Required: PRODUCT_CODE= +# --------------------------------------------------------------------------- + +V2_PRODUCT_CODE ?= 842 + +v2-get-custom-fields: + @echo "V2 get custom fields — GET /api/certinext/v2/catalog/products/$(V2_PRODUCT_CODE)/custom-fields" + @PRODUCT_CODE=$(V2_PRODUCT_CODE) scripts/v2/get-custom-fields.sh + +# --------------------------------------------------------------------------- +# v2-list-groups — GET /api/certinext/v2/groups +# Lists billing groups accessible to this account. +# Use a groupNumber in order bodies to charge a specific cost centre. +# --------------------------------------------------------------------------- + +v2-list-groups: + @echo "V2 list groups — GET /api/certinext/v2/groups" + @scripts/v2/list-groups.sh + +# --------------------------------------------------------------------------- +# v2-list-organizations — GET /api/certinext/v2/organizations +# Lists pre-vetted organizations available for OV/EV SSL orders. +# Reference an organizationNumber in order bodies to skip re-vetting. +# --------------------------------------------------------------------------- + +v2-list-organizations: + @echo "V2 list organizations — GET /api/certinext/v2/organizations" + @scripts/v2/list-organizations.sh + +# --------------------------------------------------------------------------- +# v2-list-domains — GET /api/certinext/v2/domains +# Lists domains already pre-validated under this account. +# DCV does not need to be repeated for domains in this list. +# --------------------------------------------------------------------------- + +v2-list-domains: + @echo "V2 list domains — GET /api/certinext/v2/domains" + @scripts/v2/list-domains.sh + +# --------------------------------------------------------------------------- +# v2-create-ssl-order — POST /api/certinext/v2/ssl-certificates +# Places a new SSL/TLS certificate order. +# Required: PRODUCT_CODE= DOMAIN= +# Optional: VARIANT=dv (also: ov, ev) +# +# Prints orderId on success. Use orderId with v2-track-order, v2-get-dcv, +# v2-verify-dcv, v2-submit-csr, v2-accept-agreement, v2-download-certificate, +# v2-revoke-ssl, and v2-cancel-ssl-order. +# --------------------------------------------------------------------------- + +V2_DOMAIN ?= +V2_VARIANT ?= dv + +v2-create-ssl-order: + @echo "V2 create SSL order — POST /api/certinext/v2/ssl-certificates" + @PRODUCT_CODE=$(V2_PRODUCT_CODE) DOMAIN=$(V2_DOMAIN) VARIANT=$(V2_VARIANT) scripts/v2/create-ssl-order.sh $(V2_ARGS) + +# --------------------------------------------------------------------------- +# v2-track-order — GET /api/certinext/v2/ssl-certificates/{orderId} +# Fetches current state of an SSL order. +# Required: ORDER_ID= +# +# Status sequence: pending-dcv -> pending-csr -> pending-agreement -> issued +# (or cancelled / revoked) +# --------------------------------------------------------------------------- + +ORDER_ID ?= + +v2-track-order: + @echo "V2 track SSL order — GET /api/certinext/v2/ssl-certificates/$(ORDER_ID)" + @ORDER_ID=$(ORDER_ID) scripts/v2/track-order.sh + +# --------------------------------------------------------------------------- +# v2-get-dcv — GET /api/certinext/v2/ssl-certificates/{orderId}/dcv?domain={domain} +# Returns DCV challenge artifacts (http-url, dns-txt, email) for a domain. +# Required: ORDER_ID= DOMAIN= +# --------------------------------------------------------------------------- + +v2-get-dcv: + @echo "V2 get DCV challenges — GET /api/certinext/v2/ssl-certificates/$(ORDER_ID)/dcv" + @ORDER_ID=$(ORDER_ID) DOMAIN=$(V2_DOMAIN) scripts/v2/get-dcv.sh + +# --------------------------------------------------------------------------- +# v2-verify-dcv — POST /api/certinext/v2/ssl-certificates/{orderId}/dcv/verify +# Asks the CA to re-check the DCV artifact you published. +# Required: ORDER_ID= DOMAIN= +# Optional: METHOD=http-url (also: dns-txt, email) +# --------------------------------------------------------------------------- + +V2_DCV_METHOD ?= http-url + +v2-verify-dcv: + @echo "V2 verify DCV — POST /api/certinext/v2/ssl-certificates/$(ORDER_ID)/dcv/verify" + @ORDER_ID=$(ORDER_ID) DOMAIN=$(V2_DOMAIN) METHOD=$(V2_DCV_METHOD) scripts/v2/verify-dcv.sh $(V2_ARGS) + +# --------------------------------------------------------------------------- +# v2-submit-csr — PUT /api/certinext/v2/ssl-certificates/{orderId}/csr +# Attaches a PEM CSR to an SSL order. +# Required: ORDER_ID= CSR_FILE= +# --------------------------------------------------------------------------- + +V2_CSR_FILE ?= + +v2-submit-csr: + @echo "V2 submit CSR (SSL) — PUT /api/certinext/v2/ssl-certificates/$(ORDER_ID)/csr" + @ORDER_ID=$(ORDER_ID) CSR_FILE=$(V2_CSR_FILE) scripts/v2/submit-csr.sh $(V2_ARGS) + +# --------------------------------------------------------------------------- +# v2-accept-agreement — POST /api/certinext/v2/ssl-certificates/{orderId}/agreement +# Records Subscriber Agreement acceptance. The CA proceeds to issue after this. +# Required: ORDER_ID= +# --------------------------------------------------------------------------- + +v2-accept-agreement: + @echo "V2 accept agreement — POST /api/certinext/v2/ssl-certificates/$(ORDER_ID)/agreement" + @ORDER_ID=$(ORDER_ID) scripts/v2/accept-agreement.sh $(V2_ARGS) + +# --------------------------------------------------------------------------- +# v2-download-certificate — GET /api/certinext/v2/ssl-certificates/{orderId}/certificate +# Downloads the issued SSL certificate (JSON with PEM, serial, subject, validity). +# Required: ORDER_ID= +# --------------------------------------------------------------------------- + +v2-download-certificate: + @echo "V2 download certificate (SSL) — GET /api/certinext/v2/ssl-certificates/$(ORDER_ID)/certificate" + @ORDER_ID=$(ORDER_ID) scripts/v2/download-certificate.sh + +# --------------------------------------------------------------------------- +# v2-revoke-ssl — POST /api/certinext/v2/ssl-certificates/{orderId}/revoke +# Permanently revokes an issued SSL certificate. +# Required: ORDER_ID= +# Optional: REASON=superseded +# +# RFC 5280 reason values: unspecified, keyCompromise, caCompromise, +# affiliationChanged, superseded, cessationOfOperation, privilegeWithdrawn +# --------------------------------------------------------------------------- + +V2_REASON ?= superseded + +v2-revoke-ssl: + @echo "V2 revoke SSL — POST /api/certinext/v2/ssl-certificates/$(ORDER_ID)/revoke" + @ORDER_ID=$(ORDER_ID) REASON=$(V2_REASON) scripts/v2/revoke-ssl.sh $(V2_ARGS) + +# --------------------------------------------------------------------------- +# v2-cancel-ssl-order — POST /api/certinext/v2/ssl-certificates/{orderId}/cancel +# Withdraws an SSL order before issuance. Use v2-revoke-ssl after issuance. +# Required: ORDER_ID= +# --------------------------------------------------------------------------- + +v2-cancel-ssl-order: + @echo "V2 cancel SSL order — POST /api/certinext/v2/ssl-certificates/$(ORDER_ID)/cancel" + @ORDER_ID=$(ORDER_ID) scripts/v2/cancel-ssl-order.sh $(V2_ARGS) + +# --------------------------------------------------------------------------- +# v2-create-private-pki-order — POST /api/certinext/v2/private-pki-certificates +# Creates a Private PKI certificate order against a customer-owned CA. +# Required: V2_HOSTNAME= V2_CA_PROFILE_ID= V2_MASTER_PRODUCT_ID= +# Optional: V2_PRODUCT_CODE= +# (the script input is CERT_HOSTNAME; bash always sets HOSTNAME to the local machine name) +# +# Prints orderId on success. Use orderId with v2-track-private-pki, +# v2-submit-csr-private-pki, v2-download-certificate-private-pki, and +# v2-revoke-private-pki. +# --------------------------------------------------------------------------- + +V2_HOSTNAME ?= +V2_CA_PROFILE_ID ?= +V2_MASTER_PRODUCT_ID ?= + +v2-create-private-pki-order: + @echo "V2 create Private PKI order — POST /api/certinext/v2/private-pki-certificates" + @PRODUCT_CODE=$(V2_PRODUCT_CODE) CERT_HOSTNAME=$(V2_HOSTNAME) CA_PROFILE_ID=$(V2_CA_PROFILE_ID) MASTER_PRODUCT_ID=$(V2_MASTER_PRODUCT_ID) scripts/v2/create-private-pki-order.sh $(V2_ARGS) + +# --------------------------------------------------------------------------- +# v2-track-private-pki — GET /api/certinext/v2/private-pki-certificates/{orderId} +# Fetches current state of a Private PKI order. +# Required: ORDER_ID= +# +# Status sequence: pending-csr -> issued (or cancelled / revoked) +# --------------------------------------------------------------------------- + +v2-track-private-pki: + @echo "V2 track Private PKI order — GET /api/certinext/v2/private-pki-certificates/$(ORDER_ID)" + @ORDER_ID=$(ORDER_ID) scripts/v2/track-private-pki.sh + +# --------------------------------------------------------------------------- +# v2-submit-csr-private-pki — PUT /api/certinext/v2/private-pki-certificates/{orderId}/csr +# Attaches a PEM CSR to a Private PKI order. The customer CA signs immediately. +# Required: ORDER_ID= CSR_FILE= +# --------------------------------------------------------------------------- + +v2-submit-csr-private-pki: + @echo "V2 submit CSR (Private PKI) — PUT /api/certinext/v2/private-pki-certificates/$(ORDER_ID)/csr" + @ORDER_ID=$(ORDER_ID) CSR_FILE=$(V2_CSR_FILE) scripts/v2/submit-csr-private-pki.sh $(V2_ARGS) + +# --------------------------------------------------------------------------- +# v2-download-certificate-private-pki — GET /api/certinext/v2/private-pki-certificates/{orderId}/certificate +# Downloads the issued Private PKI certificate. +# Required: ORDER_ID= +# --------------------------------------------------------------------------- + +v2-download-certificate-private-pki: + @echo "V2 download certificate (Private PKI) — GET /api/certinext/v2/private-pki-certificates/$(ORDER_ID)/certificate" + @ORDER_ID=$(ORDER_ID) scripts/v2/download-certificate-private-pki.sh + +# --------------------------------------------------------------------------- +# v2-revoke-private-pki — POST /api/certinext/v2/private-pki-certificates/{orderId}/revoke +# Permanently revokes an issued Private PKI certificate. +# Required: ORDER_ID= +# Optional: REASON=superseded +# +# RFC 5280 reason values: unspecified, keyCompromise, affiliationChanged, +# superseded, cessationOfOperation, privilegeWithdrawn +# --------------------------------------------------------------------------- + +v2-revoke-private-pki: + @echo "V2 revoke Private PKI — POST /api/certinext/v2/private-pki-certificates/$(ORDER_ID)/revoke" + @ORDER_ID=$(ORDER_ID) REASON=$(V2_REASON) scripts/v2/revoke-private-pki.sh $(V2_ARGS) + +# --------------------------------------------------------------------------- +# v2-orders-report — GET /api/certinext/v2/reports/orders?page=1&size=100 +# One page of order history (the endpoint V2 Synchronize pages through). +# Optional: V2_PAGE=1 (1-based) V2_SIZE=100 (server clamps to 100) +# --------------------------------------------------------------------------- + +V2_PAGE ?= 1 +V2_SIZE ?= 100 + +v2-orders-report: + @echo "V2 orders report — GET /api/certinext/v2/reports/orders?page=$(V2_PAGE)&size=$(V2_SIZE)" + @PAGE=$(V2_PAGE) SIZE=$(V2_SIZE) scripts/v2/orders-report.sh + # --------------------------------------------------------------------------- # Help # --------------------------------------------------------------------------- diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..40b8292 --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,806 @@ +# CERTInext CA Plugin — Quickstart + +End-to-end setup for the **CERTInext (eMudhra) CA plugin** running behind +the Keyfactor AnyCA REST Gateway. Walks an operator from "plugin DLL is +on the gateway pod" to "Keyfactor Command can enroll an end-entity +certificate through the plugin" with copy-pasteable scripts. + +Each step is shown twice: a Bash + curl block and a PowerShell block. +Use whichever fits your shell. Variables flow forward through the doc, +so set them once and reuse them. + +--- + +## What this guide covers + +1. Authenticate to the gateway and to Command (client-credentials OAuth) +2. Create a **gateway certificate profile** for each CERTInext product + (a top-level key-algorithm policy, not tied to any CA yet) +3. Create the **gateway CA** (the plugin connection + a `Templates[]` + array that references the profiles from step 2 by name) +4. **Register the gateway CA in Command** so Command can talk to it +5. **Import templates from the gateway into Command** as + `AnyCA_` templates Command can enroll against +6. **Enroll a test certificate** end-to-end + +The CERTInext sandbox returns orders in `EXTERNAL_VALIDATION` status +(pending DCV or manual review), so the final enrollment test reports a +pending result by design — that's success. + +### Data model & dependency order + +It's easy to swap steps 2 and 3 by accident — both have things called +"templates" in them. The actual gateway data model is: + +``` +gateway certificateprofile (top-level, independent of any CA) + | + | referenced by name + v +gateway CA configuration (one record with a Templates[] array; + each entry maps ProductID -> profile) + | + | Command queries this + v +Command CA registration (/KeyfactorAPI/CertificateAuthorities) + | + | ConfigurationTenant ties to this + v +Command templates (/KeyfactorAPI/Templates/Import) +``` + +So gateway profiles **must** exist before the gateway CA config that +references them, and the gateway CA config **must** exist before +Command can register it or import templates from it. Hence steps 2 → 3 +→ 4 → 5 in that order. + +### Reference JSON for each step + +Each step that creates GET-able state has a sanitised JSON snapshot in +[`docs/reference/`](docs/reference/) from a known-working lab. Linked +again inline in each step's intro: + +| Step | Reference file | +|---|---| +| 2 — gateway profiles | [`docs/reference/gateway/certificate-profiles.json`](docs/reference/gateway/certificate-profiles.json) | +| 3 — gateway CA config | not GET-able (HTTP 405); see [`docs/reference/gateway/claims.json`](docs/reference/gateway/claims.json) for the authz table this step seeds | +| 4 — Command CA | [`docs/reference/command/certificate-authority.json`](docs/reference/command/certificate-authority.json) | +| 5 — Command templates | [`docs/reference/command/templates-certinext.json`](docs/reference/command/templates-certinext.json) | + +--- + +## Prerequisites + +| Component | Required state | +|---|---| +| Keyfactor Command | Deployed and reachable at `${COMMAND_URL}` | +| AnyCA REST Gateway | Deployed and reachable at `${GATEWAY_URL}` | +| CERTInext plugin DLL | Already staged at `/app/Extensions/certinext-caplugin/` on the gateway pod; gateway has been restarted since | +| Identity Provider | OIDC client credentials issued for both the gateway and Command (Authentik, Keycloak, Entra, etc.) | +| CERTInext sandbox account | AccessKey, AccountNumber, GroupNumber, OrganizationNumber, registered requestor email | +| CERTInext sandbox PEM | The combined intermediate + root certificate for the CERTInext sandbox issuer (required for `GatewayCertificate.ImportedCertificate`) | + +If any of those aren't true, finish the prerequisite work before +returning here. See the README's **Installation** and **Configuration** +sections for the underlying setup. + +--- + +## Step 0 — Variables + +Set these once at the top of your shell; the rest of the doc reuses them. + +### Bash + +```bash +# URLs +export COMMAND_URL="https://command.example.com" +export GATEWAY_URL="https://gateway.example.com" +export TOKEN_URL="https://auth.example.com/application/o/token/" + +# OIDC client credentials +export CMD_CLIENT_ID="" +export CMD_CLIENT_SECRET="" +export GW_CLIENT_ID="" +export GW_CLIENT_SECRET="" + +# CERTInext sandbox creds +export CERTINEXT_API_URL="https://sandbox-us-api.certinext.io/emSignHub-API" +export CERTINEXT_ACCESS_KEY="" +export CERTINEXT_ACCOUNT_NUMBER="" +export CERTINEXT_GROUP_NUMBER="" +export CERTINEXT_ORG_NUMBER="" +export CERTINEXT_REQUESTOR_NAME="Your Name" +export CERTINEXT_REQUESTOR_EMAIL="you@example.com" +export CERTINEXT_SIGNER_IP="$(curl -s https://api.ipify.org)" + +# Names you'll reference in Command after setup +export CA_LOGICAL_NAME="certinext-caplugin" # also used as ConfigurationTenant +export PRODUCT_ID="DV SSL" # the first product to register +export PRODUCT_CODE="842" # sandbox DV SSL product code + +# Sandbox issuer chain file (PEM, intermediate + root concatenated) +export SANDBOX_CHAIN_PEM="${HOME}/certinext-sandbox-chain.pem" +``` + +### PowerShell + +```powershell +# URLs +$CommandUrl = "https://command.example.com" +$GatewayUrl = "https://gateway.example.com" +$TokenUrl = "https://auth.example.com/application/o/token/" + +# OIDC client credentials +$CmdClientId = "" +$CmdClientSecret = "" +$GwClientId = "" +$GwClientSecret = "" + +# CERTInext sandbox creds +$CertInextApiUrl = "https://sandbox-us-api.certinext.io/emSignHub-API" +$CertInextAccessKey = "" +$CertInextAccountNumber = "" +$CertInextGroupNumber = "" +$CertInextOrgNumber = "" +$CertInextRequestorName = "Your Name" +$CertInextRequestorEmail = "you@example.com" +$CertInextSignerIp = (Invoke-RestMethod -Uri "https://api.ipify.org").ToString() + +# Names you'll reference in Command after setup +$CaLogicalName = "certinext-caplugin" # also used as ConfigurationTenant +$ProductId = "DV SSL" # the first product to register +$ProductCode = "842" # sandbox DV SSL product code + +# Sandbox issuer chain file (PEM, intermediate + root concatenated) +$SandboxChainPem = Join-Path $HOME "certinext-sandbox-chain.pem" +``` + +> **TLS note.** Examples use `-k` (curl) / `-SkipCertificateCheck` +> (PowerShell 7+). Remove these when you're targeting a properly-trusted +> Command / Gateway in production. + +--- + +## Step 1 — Get OAuth tokens + +Both the gateway's `/AnyGatewayREST/config/*` API and Command's +`/KeyfactorAPI/*` API use OAuth2 client credentials. Mint one token for +each; they're independent. + +### Bash + +```bash +GW_TOKEN=$(curl -sk -X POST "${TOKEN_URL}" \ + -d "grant_type=client_credentials" \ + -d "client_id=${GW_CLIENT_ID}" \ + -d "client_secret=${GW_CLIENT_SECRET}" \ + -d "scope=keyfactor-anyca-gateway" \ + | jq -r '.access_token') + +CMD_TOKEN=$(curl -sk -X POST "${TOKEN_URL}" \ + -d "grant_type=client_credentials" \ + -d "client_id=${CMD_CLIENT_ID}" \ + -d "client_secret=${CMD_CLIENT_SECRET}" \ + | jq -r '.access_token') + +[ -n "${GW_TOKEN}" ] || { echo "gateway token mint failed"; exit 1; } +[ -n "${CMD_TOKEN}" ] || { echo "command token mint failed"; exit 1; } +``` + +### PowerShell + +```powershell +$GwToken = (Invoke-RestMethod -Method Post -Uri $TokenUrl -SkipCertificateCheck ` + -Body @{ + grant_type = "client_credentials" + client_id = $GwClientId + client_secret = $GwClientSecret + scope = "keyfactor-anyca-gateway" + }).access_token + +$CmdToken = (Invoke-RestMethod -Method Post -Uri $TokenUrl -SkipCertificateCheck ` + -Body @{ + grant_type = "client_credentials" + client_id = $CmdClientId + client_secret = $CmdClientSecret + }).access_token + +if (-not $GwToken) { throw "gateway token mint failed" } +if (-not $CmdToken) { throw "command token mint failed" } +``` + +--- + +## Step 2 — Create the gateway certificate profile + +> **Reference state after this step:** see +> [`docs/reference/gateway/certificate-profiles.json`](docs/reference/gateway/certificate-profiles.json) +> for the final 8-profile shape (one per sandbox product) the gateway +> returns from `GET /AnyGatewayREST/config/certificateprofile` after +> all profiles are in place. + +A **certificate profile** on the gateway is a top-level resource: a +named key-algorithm policy that's independent of any CA. CA +configurations (created in step 3) reference these profiles by name +through their `Templates[]` array, so the profile must exist first. + +The profile sets the key constraints (allowed algorithms, sizes, +curves) the gateway enforces on incoming CSRs / key generations for any +ProductID bound to it. One profile can be shared by many CA configs; +in this guide we use a 1-to-1 profile-per-ProductID convention because +the `WirePlugin` code path in `kfclab` does the same. + +Without an explicit `key_algs` block the gateway uses an empty default +that Command interprets as "no key types allowed" — PFX enrollment then +fails with `0xA0110004` ("Key type 'RSA' is unsupported or disallowed by +policy"). The body below is the canonical "permit everything we care +about" payload. + +### Bash + +```bash +KEY_ALGS='{ + "rsa": {"bit_lengths":[2048,3072,4096]}, + "ecdsa": {"curves":["1.2.840.10045.3.1.7","1.3.132.0.34","1.3.132.0.35"]}, + "ed25519": {"bit_lengths":[255]} +}' + +PROFILE_BODY=$(jq -n \ + --arg name "${PRODUCT_ID}" \ + --argjson key_algs "${KEY_ALGS}" \ + '{name: $name, key_algs: $key_algs}') + +curl -sk -X POST "${GATEWAY_URL}/AnyGatewayREST/config/certificateprofile" \ + -H "Authorization: Bearer ${GW_TOKEN}" \ + -H "x-keyfactor-requested-with: APIClient" \ + -H "Content-Type: application/json" \ + -d "${PROFILE_BODY}" \ + -w "\nHTTP %{http_code}\n" +``` + +If the profile already exists this POST returns a 4xx; that's fine. +For idempotent updates, GET the profile, extract its `id`, then PUT: + +```bash +PROFILE_ID=$(curl -sk "${GATEWAY_URL}/AnyGatewayREST/config/certificateprofile" \ + -H "Authorization: Bearer ${GW_TOKEN}" \ + -H "x-keyfactor-requested-with: APIClient" \ + | jq -r --arg n "${PRODUCT_ID}" '.[] | select(.name == $n) | .id') + +curl -sk -X PUT "${GATEWAY_URL}/AnyGatewayREST/config/certificateprofile" \ + -H "Authorization: Bearer ${GW_TOKEN}" \ + -H "x-keyfactor-requested-with: APIClient" \ + -H "Content-Type: application/json" \ + -d "$(echo "${PROFILE_BODY}" | jq --argjson id "${PROFILE_ID}" '. + {id: $id}')" +``` + +### PowerShell + +```powershell +$KeyAlgs = @{ + rsa = @{ bit_lengths = @(2048, 3072, 4096) } + ecdsa = @{ curves = @( + "1.2.840.10045.3.1.7", # secp256r1 (P-256) + "1.3.132.0.34", # secp384r1 (P-384) + "1.3.132.0.35" # secp521r1 (P-521) + ) } + ed25519 = @{ bit_lengths = @(255) } +} + +$ProfileBody = @{ + name = $ProductId + key_algs = $KeyAlgs +} | ConvertTo-Json -Depth 10 + +$Headers = @{ + "Authorization" = "Bearer $GwToken" + "x-keyfactor-requested-with" = "APIClient" + "Content-Type" = "application/json" +} + +try { + Invoke-RestMethod -Method Post ` + -Uri "$GatewayUrl/AnyGatewayREST/config/certificateprofile" ` + -Headers $Headers -Body $ProfileBody -SkipCertificateCheck +} catch { + # Already exists — fetch its id and PUT instead. + $existing = Invoke-RestMethod -Method Get ` + -Uri "$GatewayUrl/AnyGatewayREST/config/certificateprofile" ` + -Headers $Headers -SkipCertificateCheck + $profile = $existing | Where-Object { $_.name -eq $ProductId } | Select-Object -First 1 + if ($profile) { + $UpdateBody = @{ + id = $profile.id + name = $ProductId + key_algs = $KeyAlgs + } | ConvertTo-Json -Depth 10 + Invoke-RestMethod -Method Put ` + -Uri "$GatewayUrl/AnyGatewayREST/config/certificateprofile" ` + -Headers $Headers -Body $UpdateBody -SkipCertificateCheck + } +} +``` + +> **Note on CERTInext key algorithm restrictions:** The gateway profile's `key_algs` block defines what Command *allows* — it does not reflect what CERTInext will accept. CERTInext additionally restricts enrollments to RSA 2048/3072/4096 and ECC P-256/P-384. Orders submitted with P-521, Ed25519, Ed448, or RSA larger than 4096 bits are accepted by Command and the gateway but rejected by CERTInext with `Invalid key size`. Configure your profiles and templates to only permit the key types CERTInext supports. + +> **Doing this for all 8 non-EV sandbox products?** Wrap Steps 2 and 3 in a +> loop over the (ProductID, ProductCode) pairs. The sandbox non-EV product +> codes are 842 (DV SSL), 843 (DV Wildcard), 844 (DV UCC), 845 (DV +> Wildcard UCC), 846 (OV SSL), 847 (OV Wildcard), 848 (OV UCC), 849 +> (OV Wildcard UCC). EV SSL (850) and EV UCC (851) require additional +> `contractSignerInfo`, `certificateApproverInfo`, and org/contract fields +> beyond the base product set. + +--- + +## Step 3 — Create the gateway CA configuration + +> **Reference state after this step:** +> [`docs/reference/gateway/claims.json`](docs/reference/gateway/claims.json) +> shows the gateway authz table — the `akadmin` admin claim is added +> as part of this step on the kfclab path, so authenticated human users +> can hit the gateway UI without being denied. +> +> The CA configuration itself is **not GET-able** (the gateway returns +> HTTP 405 on `GET /config/configuration` — POST/PUT only), so there's +> no live JSON snapshot to compare against. The exact body shape this +> step submits is documented in the script blocks below. + +This is the **single biggest configuration step**. It creates the +gateway-side CA record, which has four jobs: + +- Tell the gateway how to authenticate to the CERTInext API + (`CAConnection` block) +- Give the CA a logical name and an issuer chain to present to Command + (`GatewayRegistration` block) +- Schedule sync intervals (`ServiceSettings` block) +- **Map each ProductID to the gateway certificate profile from step 2** + (`Templates[]` array — `Templates[*].CertificateProfile` must match + a profile name created in step 2) + +The CA configuration is what Command later queries (in step 4 and +step 5) to learn about this CA. Until this POST/PUT lands, the gateway +has no CA configured and Command has nothing to register or import. + +The shape uses four top-level keys: + +| Key | Purpose | +|---|---| +| `CAConnection` | The CERTInext plugin's connection config (auth + identifying numbers). All `RequestorIsdCode`, `RequestorMobileNumber`, `SignerPlace`, `Enabled` etc. live here. | +| `GatewayRegistration` | `LogicalName` (what Command will see) + `GatewayCertificate.ImportedCertificate` (PEM blob, base64-of-PEM is also accepted). | +| `ServiceSettings` | Scan intervals; tune for your environment. | +| `Templates[]` | The (ProductID → CertificateProfile) mapping. Parameters carry per-product config like `ProductCode` and `ValidityYears`. | + +`POST` creates; `PUT` updates an existing config. Most operators end up +using `PUT` after the first run. + +### Bash + +```bash +GATEWAY_CERT_PEM=$(cat "${SANDBOX_CHAIN_PEM}") + +CONFIG_BODY=$(jq -n \ + --arg api_url "${CERTINEXT_API_URL}" \ + --arg account "${CERTINEXT_ACCOUNT_NUMBER}" \ + --arg group "${CERTINEXT_GROUP_NUMBER}" \ + --arg org "${CERTINEXT_ORG_NUMBER}" \ + --arg access_key "${CERTINEXT_ACCESS_KEY}" \ + --arg req_name "${CERTINEXT_REQUESTOR_NAME}" \ + --arg req_email "${CERTINEXT_REQUESTOR_EMAIL}" \ + --arg signer_ip "${CERTINEXT_SIGNER_IP}" \ + --arg logical "${CA_LOGICAL_NAME}" \ + --arg cert "${GATEWAY_CERT_PEM}" \ + --arg product_id "${PRODUCT_ID}" \ + --arg product_code "${PRODUCT_CODE}" \ +'{ + "CAConnection": { + "ApiUrl": $api_url, + "AccountNumber": $account, + "GroupNumber": $group, + "OrganizationNumber": $org, + "AuthMode": "AccessKey", + "ApiKey": $access_key, + "RequestorName": $req_name, + "RequestorEmail": $req_email, + "RequestorIsdCode": "1", + "RequestorMobileNumber": "0000000000", + "SignerPlace": "Gateway", + "SignerIp": $signer_ip, + "Enabled": true + }, + "GatewayRegistration": { + "LogicalName": $logical, + "GatewayCertificate": { + "Source": "FileUpload", + "ImportedCertificate": $cert + } + }, + "ServiceSettings": { + "FullScan": {"Daily": {"Time": "2:00"}}, + "IncrementalScan": {"Interval": {"Minutes": 60}} + }, + "Templates": [ + { + "ProductID": $product_id, + "Parameters": {"ProductCode": $product_code, "ValidityYears": "1"}, + "CertificateProfile": $product_id + } + ] +}') + +# POST first; if "already exists", fall through to PUT. +RESP=$(curl -sk -X POST "${GATEWAY_URL}/AnyGatewayREST/config/configuration" \ + -H "Authorization: Bearer ${GW_TOKEN}" \ + -H "x-keyfactor-requested-with: APIClient" \ + -H "Content-Type: application/json" \ + -d "${CONFIG_BODY}" -w "\nHTTP %{http_code}") +echo "${RESP}" + +if echo "${RESP}" | grep -qiE "already exists|duplicate"; then + curl -sk -X PUT "${GATEWAY_URL}/AnyGatewayREST/config/configuration" \ + -H "Authorization: Bearer ${GW_TOKEN}" \ + -H "x-keyfactor-requested-with: APIClient" \ + -H "Content-Type: application/json" \ + -d "${CONFIG_BODY}" -w "\nHTTP %{http_code}" +fi +``` + +### PowerShell + +```powershell +$GatewayCertPem = Get-Content -Path $SandboxChainPem -Raw + +$ConfigBody = @{ + CAConnection = @{ + ApiUrl = $CertInextApiUrl + AccountNumber = $CertInextAccountNumber + GroupNumber = $CertInextGroupNumber + OrganizationNumber = $CertInextOrgNumber + AuthMode = "AccessKey" + ApiKey = $CertInextAccessKey + RequestorName = $CertInextRequestorName + RequestorEmail = $CertInextRequestorEmail + RequestorIsdCode = "1" + RequestorMobileNumber = "0000000000" + SignerPlace = "Gateway" + SignerIp = $CertInextSignerIp + Enabled = $true + } + GatewayRegistration = @{ + LogicalName = $CaLogicalName + GatewayCertificate = @{ + Source = "FileUpload" + ImportedCertificate = $GatewayCertPem + } + } + ServiceSettings = @{ + FullScan = @{ Daily = @{ Time = "2:00" } } + IncrementalScan = @{ Interval = @{ Minutes = 60 } } + } + Templates = @( + @{ + ProductID = $ProductId + Parameters = @{ ProductCode = $ProductCode; ValidityYears = "1" } + CertificateProfile = $ProductId + } + ) +} | ConvertTo-Json -Depth 10 + +$ConfigUri = "$GatewayUrl/AnyGatewayREST/config/configuration" + +try { + Invoke-RestMethod -Method Post -Uri $ConfigUri ` + -Headers $Headers -Body $ConfigBody -SkipCertificateCheck +} catch { + # Already exists — PUT update instead. + if ($_.Exception.Message -match "already exists|duplicate") { + Invoke-RestMethod -Method Put -Uri $ConfigUri ` + -Headers $Headers -Body $ConfigBody -SkipCertificateCheck + } else { + throw + } +} +``` + +After this completes, the gateway is fully wired to CERTInext. Confirm +by GETting the configuration back: + +```bash +curl -sk "${GATEWAY_URL}/AnyGatewayREST/config/configuration" \ + -H "Authorization: Bearer ${GW_TOKEN}" \ + -H "x-keyfactor-requested-with: APIClient" | jq '.Templates' +``` + +You should see your `Templates[]` array with the (ProductID, +CertificateProfile) entries from above. + +--- + +## Step 4 — Register the CA in Command + +> **Reference state after this step:** see +> [`docs/reference/command/certificate-authority.json`](docs/reference/command/certificate-authority.json) +> for the full CA record Command returns from +> `GET /KeyfactorAPI/CertificateAuthorities` (filtered to the +> `LogicalName=certinext-caplugin` entry). Useful to compare against +> when debugging — every field the API populates is present, and +> `ClientSecret.SecretValue` is masked by Command on read. + +Command needs to know the gateway exists and what auth to use when +talking to it. The CA registration carries the OAuth client used for +Command-to-gateway calls (the same gateway OAuth client from Step 1) and +the `ConfigurationTenant` that ties this registration to the gateway's +plugin (the plugin name — by convention `certinext-caplugin`). + +Important fields: + +| Field | Value | Why | +|---|---|---| +| `HostName` | `${GATEWAY_URL}/AnyGatewayREST/ejbca/ejbca-rest-api` | All AnyCA REST Gateway plugins are served behind the EJBCA-compatible prefix; Command speaks EJBCA REST to the gateway. | +| `CAType` | `1` | HTTPS (AnyCA REST). `0` is DCOM (legacy Windows). | +| `ConfigurationTenant` | `certinext-caplugin` | Must match the LogicalName the plugin uses; also the value you'll pass to `/Templates/Import` in Step 5. | +| `Scope` | `keyfactor-anyca-gateway` | The OAuth scope the gateway's token introspection allows. | +| `ClientSecret` | `{"SecretValue": "..."}` | Command's `KeyfactorSecret` shape; raw strings are rejected with `"Invalid JSON schema. Expected: 'StartObject' Received: 'String'"`. | + +### Bash + +```bash +CA_BODY=$(jq -n \ + --arg logical "${CA_LOGICAL_NAME}" \ + --arg host "${GATEWAY_URL}/AnyGatewayREST/ejbca/ejbca-rest-api" \ + --arg tenant "${CA_LOGICAL_NAME}" \ + --arg token_url "${TOKEN_URL}" \ + --arg client_id "${GW_CLIENT_ID}" \ + --arg secret "${GW_CLIENT_SECRET}" \ +'{ + "LogicalName": $logical, + "HostName": $host, + "CAType": 1, + "ConfigurationTenant": $tenant, + "NewEndEntityOnRenewAndReissue": true, + "AllowOneClickRenewals": true, + "UseForEnrollment": true, + "KeyRetention": "Indefinite", + "AllowedEnrollmentTypes": 3, + "FullScan": {"Interval": {"Minutes": 720}}, + "IncrementalScan": {"Interval": {"Minutes": 5}}, + "TokenURL": $token_url, + "ClientId": $client_id, + "ClientSecret": {"SecretValue": $secret}, + "Scope": "keyfactor-anyca-gateway" +}') + +curl -sk -X POST "${COMMAND_URL}/KeyfactorAPI/CertificateAuthorities" \ + -H "Authorization: Bearer ${CMD_TOKEN}" \ + -H "x-keyfactor-requested-with: APIClient" \ + -H "x-keyfactor-api-version: 1" \ + -H "Content-Type: application/json" \ + -d "${CA_BODY}" -w "\nHTTP %{http_code}\n" +``` + +### PowerShell + +```powershell +$CaBody = @{ + LogicalName = $CaLogicalName + HostName = "$GatewayUrl/AnyGatewayREST/ejbca/ejbca-rest-api" + CAType = 1 + ConfigurationTenant = $CaLogicalName + NewEndEntityOnRenewAndReissue = $true + AllowOneClickRenewals = $true + UseForEnrollment = $true + KeyRetention = "Indefinite" + AllowedEnrollmentTypes = 3 + FullScan = @{ Interval = @{ Minutes = 720 } } + IncrementalScan = @{ Interval = @{ Minutes = 5 } } + TokenURL = $TokenUrl + ClientId = $GwClientId + ClientSecret = @{ SecretValue = $GwClientSecret } + Scope = "keyfactor-anyca-gateway" +} | ConvertTo-Json -Depth 10 + +$CmdHeaders = @{ + "Authorization" = "Bearer $CmdToken" + "x-keyfactor-requested-with" = "APIClient" + "x-keyfactor-api-version" = "1" + "Content-Type" = "application/json" +} + +Invoke-RestMethod -Method Post ` + -Uri "$CommandUrl/KeyfactorAPI/CertificateAuthorities" ` + -Headers $CmdHeaders -Body $CaBody -SkipCertificateCheck +``` + +Verify the CA appears in Command: + +```bash +curl -sk "${COMMAND_URL}/KeyfactorAPI/CertificateAuthorities" \ + -H "Authorization: Bearer ${CMD_TOKEN}" \ + -H "x-keyfactor-requested-with: APIClient" \ + | jq --arg n "${CA_LOGICAL_NAME}" '.[] | select(.LogicalName == $n)' +``` + +--- + +## Step 5 — Import templates into Command + +> **Reference state after this step:** see +> [`docs/reference/command/templates-certinext.json`](docs/reference/command/templates-certinext.json) +> for the 8 templates Command creates from the 8 ProductIDs registered +> in Step 3 (filtered from `GET /KeyfactorAPI/Templates` by +> `ConfigurationTenant=certinext-caplugin`). Confirms the +> `AnyCA_` naming convention, the `ExtendedKeyUsages` set, +> the `KeyTypes` list synced from the gateway profile's `key_algs`, +> and the per-template `Id` / `Oid` shape. + +Command's `/Templates/Import` endpoint asks the registered gateway CA +for its template list and creates corresponding Command-side templates +named `AnyCA_` (e.g. `AnyCA_DV SSL`). One call covers every +template you defined under `Templates[]` in Step 3. + +### Bash + +```bash +curl -sk -X POST "${COMMAND_URL}/KeyfactorAPI/Templates/Import" \ + -H "Authorization: Bearer ${CMD_TOKEN}" \ + -H "x-keyfactor-requested-with: APIClient" \ + -H "x-keyfactor-api-version: 1" \ + -H "Content-Type: application/json" \ + -d "{\"ConfigurationTenant\":\"${CA_LOGICAL_NAME}\"}" \ + -w "\nHTTP %{http_code}\n" + +# Confirm the templates landed: +curl -sk "${COMMAND_URL}/KeyfactorAPI/Templates" \ + -H "Authorization: Bearer ${CMD_TOKEN}" \ + -H "x-keyfactor-requested-with: APIClient" \ + | jq '[.[] | select(.ShortName | startswith("AnyCA_"))] | map({Id, ShortName, DisplayName})' +``` + +### PowerShell + +```powershell +$ImportBody = @{ ConfigurationTenant = $CaLogicalName } | ConvertTo-Json + +Invoke-RestMethod -Method Post ` + -Uri "$CommandUrl/KeyfactorAPI/Templates/Import" ` + -Headers $CmdHeaders -Body $ImportBody -SkipCertificateCheck + +# Confirm: +$AllTemplates = Invoke-RestMethod -Method Get ` + -Uri "$CommandUrl/KeyfactorAPI/Templates" ` + -Headers $CmdHeaders -SkipCertificateCheck + +$AllTemplates ` + | Where-Object { $_.ShortName -like "AnyCA_*" } ` + | Select-Object Id, ShortName, DisplayName +``` + +> **Re-run after gateway profile changes.** Any time you update the +> gateway's `certificateprofile` `key_algs`, re-run this `/Templates/Import` +> call — Command caches the allowed key types per-template in +> `dbo.KeyAlgorithms` and only refreshes them through this endpoint. If +> you skip the re-import, PFX enrollment continues to fail with +> `0xA0110004` despite the gateway being correct. + +--- + +## Step 6 — Verify with a test enrollment + +End-to-end check. The CERTInext sandbox returns orders in +`EXTERNAL_VALIDATION` status (DCV or manual review pending), so a +**successful** verification returns **HTTP 200 with a null +`Pkcs12Blob`** and a `RequestDisposition` of `EXTERNAL_VALIDATION` — +that's the expected outcome, not a failure. + +### Bash (PFX) + +```bash +CN="qs-test-$(date +%s).example.com" + +PFX_BODY=$(jq -n \ + --arg template "AnyCA_${PRODUCT_ID}" \ + --arg ca "${CA_LOGICAL_NAME}" \ + --arg subject "CN=${CN},O=Quickstart,C=US" \ + --arg ts "$(date -u +%FT%TZ)" \ +'{ + Template: $template, + CertificateAuthority: $ca, + Subject: $subject, + Password: "Tr@nsientP@ss1", + IncludeChain: true, + SANs: {}, + Timestamp: $ts +}') + +curl -sk -X POST "${COMMAND_URL}/KeyfactorAPI/Enrollment/PFX" \ + -H "Authorization: Bearer ${CMD_TOKEN}" \ + -H "x-keyfactor-requested-with: APIClient" \ + -H "x-keyfactor-api-version: 1" \ + -H "Content-Type: application/json" \ + -d "${PFX_BODY}" | jq '{ + RequestDisposition: .CertificateInformation.RequestDisposition, + DispositionMessage: .CertificateInformation.DispositionMessage, + KeyfactorRequestId: .CertificateInformation.KeyfactorRequestId, + WorkflowReferenceId: .CertificateInformation.WorkflowReferenceId + }' +``` + +Expected output: + +```json +{ + "RequestDisposition": "EXTERNAL_VALIDATION", + "DispositionMessage": "The certificate request is being processed by the CA, and will be available at a later time.", + "KeyfactorRequestId": 1, + "WorkflowReferenceId": 1 +} +``` + +### PowerShell (PFX) + +```powershell +$Cn = "qs-test-$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds()).example.com" + +$PfxBody = @{ + Template = "AnyCA_$ProductId" + CertificateAuthority = $CaLogicalName + Subject = "CN=$Cn,O=Quickstart,C=US" + Password = "Tr@nsientP@ss1" + IncludeChain = $true + SANs = @{} + Timestamp = (Get-Date).ToUniversalTime().ToString("o") +} | ConvertTo-Json -Depth 10 + +$Response = Invoke-RestMethod -Method Post ` + -Uri "$CommandUrl/KeyfactorAPI/Enrollment/PFX" ` + -Headers $CmdHeaders -Body $PfxBody -SkipCertificateCheck + +[PSCustomObject]@{ + RequestDisposition = $Response.CertificateInformation.RequestDisposition + DispositionMessage = $Response.CertificateInformation.DispositionMessage + KeyfactorRequestId = $Response.CertificateInformation.KeyfactorRequestId + WorkflowReferenceId = $Response.CertificateInformation.WorkflowReferenceId +} | Format-List +``` + +You should see `RequestDisposition = EXTERNAL_VALIDATION`. The +gateway's `Certificates` table will have a new row at status `90` +(pending external validation); once CERTInext completes DCV / manual +review, the status flips to `40` (issued) and Command's next inventory +sync pulls down the actual certificate. + +--- + +## Next steps + +- **More products.** Re-run Steps 2 (one POST per product) and update + the `Templates[]` array in Step 3's PUT to include all the + (ProductID, ProductCode, CertificateProfile) tuples you want to use. + Then re-run Step 5 (`/Templates/Import`) so Command picks up the new + templates. +- **Production hardening.** Drop `-k` / `-SkipCertificateCheck`, swap + the sandbox API URL for production + (`https://api.certinext.io/emSignHub-API`), update the + `GatewayCertificate.ImportedCertificate` to the production issuer + chain, and rotate the access key. +- **CSR enrollment.** `/KeyfactorAPI/Enrollment/CSR` accepts the same + body shape but with a `CSR` field instead of `Password`/`IncludeChain`. + Useful when the requesting system already has a keypair it doesn't + want to surface to Command. +- **Sandbox quota.** The CERTInext sandbox enforces a burst rate limit + that surfaces as the misleading error string `"Inactive Account + User."`. If you're submitting many test orders in tight succession + and start seeing that error, throttle to one order every 1-2 seconds + and wait ~5-25 minutes for the cooldown. Tracking issue: + [Keyfactor/certinext-caplugin#8](https://github.com/Keyfactor/certinext-caplugin/issues/8). + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| Step 5 returns 0 templates imported | `ConfigurationTenant` doesn't match between Steps 3 and 4 | Re-check both call to make sure the LogicalName / ConfigurationTenant agree. | +| Step 6 returns `0xA0110004` "Key type 'RSA' disallowed by policy" | Gateway `key_algs` are empty or wrong, or Command hasn't re-imported templates after a profile change | Update `key_algs` (Step 2), re-run `/Templates/Import` (Step 5). | +| Step 6 returns `0xA0010023` "external validation" with HTTP 400 | The gateway returned a pending response and Command's exception filter translated it — Command 25.x bug | The plugin DID accept the order. Confirm via `GET ${GATEWAY_URL}/AnyGatewayREST/.../v1/certificate/`. Fixed in newer Command builds; rewrite as 200 with disposition `EXTERNAL_VALIDATION`. | +| Step 6 returns `"Inactive Account User."` from the gateway log | CERTInext sandbox rate limit | Wait 5-25 minutes; retry a single order to confirm the account is alive. See [#8](https://github.com/Keyfactor/certinext-caplugin/issues/8). | +| Step 6 returns `TypeLoadException IDomainValidatorFactory` in the gateway pod log | DCV build deployed on a gateway running IAnyCAPlugin 3.2.x (25.5.x) | Deploy the no-DCV build (the default release artifact); do not deploy the DCV build (`-p:DcvSupport=true`) on a gateway running IAnyCAPlugin 3.2.x (25.5.x). Use the DCV build only on 26.x. | diff --git a/README.md b/README.md index b764df3..708874f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

-Integration Status: prototype +Integration Status: production Release Issues GitHub Downloads (all assets, all releases) @@ -14,7 +14,7 @@ Support - + · Requirements @@ -33,7 +33,6 @@

- The CERTInext AnyCA Gateway REST plugin extends the certificate lifecycle capabilities of the CERTInext platform (by eMudhra) to Keyfactor Command via the Keyfactor AnyCA Gateway REST. The plugin represents a fully featured AnyCA REST plugin with the following capabilities: * CA Synchronization: @@ -41,8 +40,9 @@ The CERTInext AnyCA Gateway REST plugin extends the certificate lifecycle capabi * Expired certificates can optionally be excluded from synchronization using the `IgnoreExpired` configuration flag. * Certificate Enrollment for profiles configured in CERTInext: * New certificate enrollment (new keys and certificate). - * Certificate renewal via the CERTInext renew API when the prior certificate is within the configured renewal window. + * Certificate renewal — submits a new `GenerateOrderSSL` order when the prior certificate is within the configured renewal window (CERTInext has no dedicated renewal endpoint; the renewal-window check governs how Command tracks old→new, not which API is called). * Certificate reissuance (new keys with the same or updated subject/SANs) when outside the renewal window or no prior certificate is found. + * Synchronous certificate pickup — a fast-issuing order (DV, or already-approved) can return the certificate in the same enrollment call instead of always waiting for the next sync, via `PickupRetries`/`PickupDelay`. * Certificate Revocation: * Request revocation of a previously issued certificate using any RFC 5280 CRL reason code. * Supported authentication modes for calls to the CERTInext API: @@ -51,17 +51,17 @@ The CERTInext AnyCA Gateway REST plugin extends the certificate lifecycle capabi ## Compatibility -The CERTInext AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 24.2.0 and later. +The CERTInext AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 25.5.0 and later. ## Support -The CERTInext AnyCA Gateway REST plugin is open source and there is **no SLA**. Keyfactor will address issues as resources become available. Keyfactor customers may request escalation by opening up a support ticket through their Keyfactor representative. +The CERTInext AnyCA Gateway REST plugin is supported by Keyfactor for Keyfactor customers. If you have a support issue, please open a support ticket via the Keyfactor Support Portal at https://support.keyfactor.com. > To report a problem or suggest a new feature, use the **[Issues](../../issues)** tab. If you want to contribute actual bug fixes or proposed enhancements, use the **[Pull requests](../../pulls)** tab. ## Requirements -* Keyfactor Command 10.x or later -* AnyCA Gateway REST framework version 24.2.0 or later +* Keyfactor Command 25.5.x or later +* AnyCA Gateway REST framework version 25.5.0 or later * A CERTInext account with API access enabled and at least one certificate product configured * Network connectivity from the AnyCA Gateway host to the CERTInext API endpoint for your region (see table below) * The AnyCA Gateway host must trust the TLS certificate presented by the CERTInext API endpoint @@ -84,16 +84,16 @@ CERTInext operates three separate environments. Use the sandbox environment for 2. On the server hosting the AnyCA Gateway REST, download and unzip the latest [CERTInext AnyCA Gateway REST plugin](https://github.com/Keyfactor/certinext-caplugin/releases/latest) from GitHub. -3. Copy the unzipped directory (usually called `net6.0` or `net8.0`) to the Extensions directory: +3. Copy the unzipped directory (usually called `net8.0` or `net10.0`) to the Extensions directory: ```shell Depending on your AnyCA Gateway REST version, copy the unzipped directory to one of the following locations: - Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net6.0\Extensions Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net8.0\Extensions + Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net10.0\Extensions ``` - > The directory containing the CERTInext AnyCA Gateway REST plugin DLLs (`net6.0` or `net8.0`) can be named anything, as long as it is unique within the `Extensions` directory. + > The directory containing the CERTInext AnyCA Gateway REST plugin DLLs (`net8.0` or `net10.0`) can be named anything, as long as it is unique within the `Extensions` directory. 4. Restart the AnyCA Gateway REST service. @@ -106,7 +106,7 @@ CERTInext operates three separate environments. Use the sandbox environment for * **Gateway Registration** Before enrolling certificates, the Keyfactor Command server must trust the CERTInext issuing CA chain. - + 1. Log in to the CERTInext portal and download the root CA certificate and any intermediate CA certificates in the chain as PEM or DER files. 2. On the Keyfactor Command server, import those certificates into the appropriate Windows certificate store — **Trusted Root Certification Authorities** for the root CA and **Intermediate Certification Authorities** for any subordinate CAs. 3. In the Keyfactor Command Management Portal, navigate to **CA Connectors** and add a new CA using the **CERTInext AnyCA REST Gateway Plugin**. @@ -116,63 +116,85 @@ CERTInext operates three separate environments. Use the sandbox environment for Populate using the configuration fields collected in the [requirements](#requirements) section. - * **ApiUrl** - REQUIRED: CERTInext API base URL. Sandbox (US): https://sandbox-us-api.certinext.io/emSignHub-API/ — Production (US): https://us-api.certinext.io/ — Production (Global/India): https://api.certinext.io/ - * **AccountNumber** - REQUIRED: Your CERTInext account number (numeric string). Available in the CERTInext portal. - * **GroupNumber** - OPTIONAL: CERTInext group (delegation) number. When set, it is included in GetProductDetails requests so the full product list is returned. Some sandbox accounts require this to avoid receiving an empty product list. Available in the CERTInext portal under Delegation → Groups. - * **AuthMode** - REQUIRED: Authentication mode. 'AccessKey' (default) — uses authKey = SHA256(accessKey + ts + txn) in every request body. 'OAuth' — uses an OAuth2 bearer token (requires OAuthTokenUrl, OAuthClientId, OAuthClientSecret). - * **ApiKey** - REQUIRED when AuthMode is 'AccessKey': the REST API Access Key generated in the CERTInext portal under Integrations → APIs. This value is used to compute authKey = SHA256(accessKey + ts + txn); it is never transmitted directly. - * **OAuthTokenUrl** - OAuth token endpoint URL. Required when AuthMode is 'OAuth'. - * **OAuthClientId** - OAuth client ID. Required when AuthMode is 'OAuth'. - * **OAuthClientSecret** - OAuth client secret. Required when AuthMode is 'OAuth'. - * **RequestorName** - REQUIRED: Default requestor name submitted with all certificate orders. This is the name of the person/service responsible for the certificates. - * **RequestorEmail** - REQUIRED: Default requestor email submitted with all certificate orders. Must be a valid email address registered in your CERTInext account. - * **RequestorIsdCode** - International dialing code for the requestor phone number (e.g. '1' for US). Default: '1'. - * **RequestorMobileNumber** - Requestor mobile number (digits only, no country code). - * **SignerPlace** - City or location of the subscriber agreement signer. Required by CERTInext for all orders. - * **SignerIp** - IP address of the subscriber agreement signer. Required by CERTInext for all orders. - * **DefaultProductCode** - OPTIONAL: Default numeric product code used when not specified at template level. Product codes are provided by eMudhra (e.g. the SSL DV 1-year code for your account). Retrieve available codes from Integrations → APIs → GetProductDetails. - * **IgnoreExpired** - If true, expired certificates will be skipped during synchronization. Default: false. - * **PageSize** - Number of orders to fetch per page during synchronization. Default: 100, max: 500. - * **Enabled** - Flag to Enable or Disable gateway functionality. Disabling is primarily used to allow creation of the CA connector prior to configuration information being available. + * **ApiUrl** - REQUIRED: CERTInext API base URL. Its meaning follows UseV2Api. V1 (default): Sandbox (US): https://sandbox-us-api.certinext.io/emSignHub-API/ — Production (US): https://us-api.certinext.io/emSignHub-API/ — Production (Global/India): https://api.certinext.io/emSignHub-API/. V2 (UseV2Api=true): the bare V2 host, e.g. https://sandbox-us-api.certinext.io, no trailing slash or path suffix — V1 and V2 are hosted differently, so this value changes when UseV2Api is toggled. + * **AccountNumber** - REQUIRED: Your CERTInext account number (numeric string). Available in the CERTInext portal. + * **GroupNumber** - OPTIONAL: CERTInext group (delegation) number. When set, it is included in GetProductDetails requests AND in the `delegationInformation.groupNumber` field of every SSL order so the order is routed to the correct account group. Some accounts will queue orders for additional review when this field is omitted. Available in the CERTInext portal under Delegation → Groups. + * **OrganizationNumber** - STRONGLY RECOMMENDED for OV/EV and faster DV issuance: numeric CERTInext organization number for a pre-vetted organization (e.g. your company's pre-vetted entry). When set, every SSL order is submitted with `organizationDetails.preVetting="1"` and the configured `organizationNumber`, telling CERTInext to skip the manual organization-vetting queue. Without this value, orders are placed without any organizationDetails block and CERTInext may park them in `Pending System RA` for extended manual review (observed: tens of hours). Available in the CERTInext portal under Organizations → Pre-vetted Organizations. + * **TechnicalContactName** - OPTIONAL: Name sent in the `technicalPointOfContact.tpcName` field of every SSL order. Defaults to the configured RequestorName when blank. Some product configurations require a TPoC to be present; omitting it can cause CERTInext to park orders awaiting manual completion of the field. + * **TechnicalContactEmail** - OPTIONAL: Email sent in the `technicalPointOfContact.tpcEmail` field of every SSL order. Defaults to the configured RequestorEmail when blank. + * **TechnicalContactIsdCode** - OPTIONAL: International dialing code for the TPoC phone number. Defaults to the configured RequestorIsdCode when blank. + * **TechnicalContactMobileNumber** - OPTIONAL: Mobile number for the TPoC (digits only). Defaults to the configured RequestorMobileNumber when blank. + * **AuthMode** - REQUIRED: Authentication mode. 'AccessKey' (default) — uses authKey = SHA256(accessKey + ts + txn) in every request body. 'OAuth' — uses an OAuth2 bearer token (requires OAuthTokenUrl, OAuthClientId, OAuthClientSecret). + * **ApiKey** - REQUIRED when AuthMode is 'AccessKey': the REST API Access Key generated in the CERTInext portal under Integrations → APIs. This value is used to compute authKey = SHA256(accessKey + ts + txn); it is never transmitted directly. + * **OAuthTokenUrl** - OAuth token endpoint URL. Required when AuthMode is 'OAuth'. + * **OAuthClientId** - OAuth client ID. Required when AuthMode is 'OAuth' (V1). Also required, and reused, when UseV2Api is true — V2 authenticates with these same OAuthClientId/OAuthClientSecret fields via client_credentials against {ApiUrl}/oauth/token, rather than separate V2-only credentials. + * **OAuthClientSecret** - OAuth client secret. Required when AuthMode is 'OAuth' (V1). Also required, and reused, when UseV2Api is true (see OAuthClientId). + * **RequestorName** - REQUIRED: Default requestor name submitted with all certificate orders. This is the name of the person/service responsible for the certificates. + * **RequestorEmail** - REQUIRED: Default requestor email submitted with all certificate orders. Must be a valid email address registered in your CERTInext account. + * **RequestorIsdCode** - International dialing code for the requestor phone number (e.g. '1' for US). Default: '1'. + * **RequestorMobileNumber** - Requestor mobile number (digits only, no country code). + * **RequestorDesignation** - OPTIONAL: Job title / role of the requestor (e.g. 'IT Administrator'). Sent in V2 orders' `requestor.designation` field. Free text with no CA-side enum. Left blank by default, in which case the field is omitted entirely from the order rather than sent with a default value. + * **SignerPlace** - City or location of the subscriber agreement signer (e.g. 'San Francisco, CA'). REQUIRED when UseV2Api is on: the V2 Subscriber Agreement sent with every SSL order requires it, so the connector cannot be saved with it blank. A per-template SignerPlace enrollment parameter overrides it. + * **SignerIp** - IP address of the subscriber agreement signer. Required by CERTInext for all orders. + * **DefaultProductCode** - OPTIONAL: Default numeric product code used when not specified at template level. Product codes are provided by eMudhra (e.g. the SSL DV 1-year code for your account). Retrieve available codes from Integrations → APIs → GetProductDetails. + * **AccountingModel** - OPTIONAL: CERTInext billing model sent in `orderDetails.accountingModel`. "2" = credit-based (most accounts, default). "1" = cash model. + * **EmailNotifications** - OPTIONAL: Whether CERTInext sends lifecycle-event emails to the requestor. "1" = full notification set (V1 sends it as-is; V2 maps it to "all"). "0" = silent on both V1 and V2 (V2 confirmed live 2026-09-28). Blank/unset stays silent on V1 (sent as "0") but is omitted on V2, so the CA's own default ("all", not silent) applies instead. Any other value fails V2 enrollment before any CA call. Default: "0" — V2 orders are now silent by default, matching V1 (previously V2 always sent "all"). + * **SubscriptionValidityYears** - OPTIONAL: Default validity in years for SSL orders. "1", "2", or "3". Override per template via the ValidityYears product parameter. Default: "1". + * **SubscriptionAutoRenew** - OPTIONAL: Whether CERTInext should auto-renew certificates issued through this connector. "0" = disabled (recommended — renewal is driven by Keyfactor Command), "1" = enabled. Default: "0". + * **SubscriptionRenewCriteriaDays** - OPTIONAL: Days before expiry at which CERTInext auto-renews (only honored when SubscriptionAutoRenew = "1"). Typical values: "30" or "60". Default: "30". + * **AutoSecureWww** - OPTIONAL: If "1", CERTInext automatically adds the `www.` variant of the primary domain as an additional SAN. "0" = use only the CN/SANs supplied with the CSR. Default: "0". + * **IgnoreExpired** - If true, expired certificates will be skipped during synchronization. Default: false. + * **PageSize** - Number of orders to fetch per page during synchronization. Default: 100, max: 500. + * **Enabled** - Enables or disables the CA connector. Set to false to create the connector record before credentials are available. Default: true. + * **LogSensitiveRequestData** - OPTIONAL diagnostic escape hatch. When true, enabling it writes requestor personal data (name, email, phone, and other organization contact details) and full CA request/response payloads to the gateway logs. Meant for temporary use while verifying a new deployment — confirming exactly what was sent to the CA and that the order succeeded — and should be turned back off once verification is complete. When false (default), personal-data fields are redacted (email is masked but keeps its domain, e.g. 'j***@example.com') and the enrollment log line omits the requester name entirely. Email SAN values (rfc822Name) in log lines are masked the same way; DNS, IP and URI SANs are always logged in full. Credentials (API keys, OAuth secrets, tokens) are always redacted regardless of this setting. Default: false. + * **DcvEnabled** - OPTIONAL: When true, the gateway will perform DNS-based Domain Control Validation (DCV) during enrollment for orders that require it, using the configured DNS provider plugin. Requires a DNS provider plugin (e.g. azure-azuredns-dnsplugin) to be deployed on the gateway. Default: false. + * **DcvTxtRecordTemplate** - OPTIONAL: Format string for the DNS TXT record hostname used during DCV. {0} is replaced with the domain name being validated. Default: _emsign-validation.{0} + * **DcvPropagationDelaySeconds** - OPTIONAL: Seconds to wait after publishing the DNS TXT record before asking CERTInext to verify it. Increase for zones with slow propagation. Default: 30. + * **DcvTimeoutMinutes** - OPTIONAL: Maximum minutes to wait for the entire DCV flow (DNS publish + propagation + verify) before timing out the enrollment. Can also be set via the CERTINEXT_DCV_TIMEOUT_MINUTES environment variable; the env var takes precedence when both are set. Default: 10. + * **DcvWaitForChallengeSeconds** - OPTIONAL: How long (seconds) the plugin will wait inside Enroll() for CERTInext to expose the DCV challenge (i.e. populate `domainVerification` in TrackOrder). Under concurrent load CERTInext sometimes takes a few seconds after GenerateOrderSSL before the slot appears. Without this wait, the plugin's initial TrackOrder check sees null and skips DCV — the order then has to wait for the next gateway sync cycle to be picked up. Setting to 0 disables the wait (single-check behaviour). Can also be set via the CERTINEXT_DCV_WAIT_FOR_CHALLENGE_SECONDS environment variable; the env var takes precedence when both are set. Default: 60. + * **DcvWaitForIssuanceSeconds** - OPTIONAL: How long (seconds) the plugin will wait inside Enroll() after DCV verifies for CERTInext to finish generating the certificate. CERTInext issuance is async — DCV may be verified but the cert PEM isn't yet available for download. Without this wait, Enroll() returns a pending result and the issued cert is picked up by the next sync cycle. Setting to 0 disables the wait (single-fetch behaviour). Can also be set via the CERTINEXT_DCV_WAIT_FOR_ISSUANCE_SECONDS environment variable; the env var takes precedence when both are set. Default: 60. + * **DcvSyncMaxOrderAgeHours** - OPTIONAL: During synchronization, only pending DV orders younger than this many hours are eligible to be driven through DCV. This keeps a sync pass fast when there is a large backlog of old, never-completing pending orders (e.g. abandoned orders or domains outside the configured DNS provider's zone): they age out and are simply reported as pending rather than retried every pass. Recently-placed orders (the ones that legitimately deferred DCV) are always within the window and complete via the normal scan cadence. Set to 0 to disable the age filter (attempt DCV for all pending). Default: 24. + * **DcvSyncMaxPerPass** - OPTIONAL: Maximum number of pending DV orders the plugin will attempt to drive through DCV in a single synchronization pass. Bounds the per-pass cost regardless of backlog size; remaining pending orders are reported as-is and picked up on a later pass (the per-minute incremental scan keeps recent orders moving). Set to 0 to disable the cap. Default: 50. + * **UseV2Api** - OPTIONAL: When true, the plugin routes Enroll / GetSingleRecord / Revoke / Synchronize through the CERTInext V2 REST API (/api/certinext/v2/), including V2 /reports/orders for Synchronize. Requires ApiUrl (the V2 base URL in this mode) plus OAuthClientId and OAuthClientSecret. V1 credentials (ApiKey/AccountNumber/AuthMode) are not required when this is true. Default: false (V1 API). + * **V2SyncLookbackHours** - OPTIONAL (V2 mode only): during an incremental Synchronize, the plugin queries V2 /reports/orders with a 'from' date of (lastSync minus this many hours) rather than exactly lastSync, since live probing could not confirm whether the API's from/to filter brackets order-placement date or issuance date. A lookback window ensures an order created before lastSync but issued afterward (e.g. a slow DCV order) still surfaces on the next incremental pass. Ignored when UseV2Api is false. Default: 72. 2. A Keyfactor Command certificate template maps an enrollment request to a specific CERTInext product. Create one template per CERTInext product that you want to make available to requesters. - In the Keyfactor Command Management Portal, navigate to **Certificate Templates** and create a new template associated with the CERTInext CA connector. The following enrollment parameters are available: - - | Parameter | Required / Optional | Type | Description | Example / Default | - |---|---|---|---|---| - | `ProductCode` | Optional | String | Override the numeric CERTInext product code for this template. Product codes are provisioned per account by eMudhra — obtain the correct code from `GetProductDetails` for your account. Set this explicitly when targeting the sandbox environment or when the connector `DefaultProductCode` should not apply to this template. | `842` (sandbox DV SSL, account-specific) | - | `ProfileId` | Deprecated | String | Legacy alias for `ProductCode`. Accepted for backward compatibility — if `ProductCode` is not set, `ProfileId` is used in its place. New templates should use `ProductCode`. | `838` | - | `ValidityYears` | Optional | Number | Subscription validity period in years: `1`, `2`, or `3`. Default: `1`. CERTInext certificates are issued within a subscription term at up to 390 days per certificate, with free renewals within the term. | `1` | - | `ValidityDays` | Deprecated | Number | Legacy validity field. If set, the value is divided by 365 and rounded up to derive a year count. New templates should use `ValidityYears`. | `365` | - | `AutoApprove` | Optional | Boolean | If `true`, the gateway will attempt automatic approval of certificates returned in a pending-approval state. Only set this if your CERTInext product is configured with automatic approval. Default: `false`. | `false` | - | `RequesterName` | Optional | String | Per-template override for the requestor name. When set, overrides the connector-level `RequestorName` for orders using this template. | `Keyfactor Automation` | - | `RequesterEmail` | Optional | String | Per-template override for the requestor email address. When set, overrides the connector-level `RequestorEmail` for orders using this template. | `pki-admin@example.com` | - | `RenewalWindowDays` | Optional | Number | Number of days before certificate expiration within which a renewal is attempted instead of a reissue. Default: `90`. | `90` | - | `KeyType` | Optional | String | Key algorithm to request at enrollment time. Valid values depend on what the target product supports. If omitted, the product default is used. | `RSA2048`, `RSA4096`, `EC256`, `EC384` | - | `DomainName` | Optional | String | Primary domain name for SSL/TLS orders. If omitted, the gateway derives the domain from the CSR `CN` field. | `example.com` | - | `SignerName` | Optional | String | Per-template override for the subscriber agreement signer name. When omitted, defaults to the connector-level `RequestorName`. | `Jane Smith` | - | `SignerPlace` | Optional | String | Per-template override for the subscriber agreement signer location. When omitted, defaults to the connector-level `SignerPlace`. | `Austin` | - | `SignerIp` | Optional | String | Per-template override for the subscriber agreement signer IP address. When omitted, defaults to the connector-level `SignerIp`. | `203.0.113.10` | +In the Keyfactor Command Management Portal, navigate to **Certificate Templates** and create a new template associated with the CERTInext CA connector. The following enrollment parameters are available: + +| Parameter | Required / Optional | Type | Description | Example / Default | +|---|---|---|---|---| +| `ProductCode` | Optional | String | Override the numeric CERTInext product code for this template. Product codes are provisioned per account by eMudhra — obtain the correct code from `GetProductDetails` for your account. If omitted, the built-in default code for the selected product name is used (see [Product Codes](#product-codes)). Set this explicitly when targeting the sandbox environment or a non-standard code. | DV SSL: `842` (sandbox) or `838` (production) | +| `ProfileId` | Deprecated | String | Legacy alias for `ProductCode`. Accepted for backward compatibility — if `ProductCode` is not set, `ProfileId` is used in its place. New templates should use `ProductCode`. | `838` | +| `ValidityYears` | Optional | Number | Subscription validity period in years: `1`, `2`, or `3`. Default: `1`. CERTInext certificates are issued within a subscription term at up to 390 days per certificate, with free renewals within the term. | `1` | +| `ValidityDays` | Deprecated | Number | Legacy validity field. If set, the value is divided by 365 and rounded up to derive a year count. New templates should use `ValidityYears`. | `365` | +| `AutoApprove` | Optional | Boolean | **Currently has no effect** — reserved for future use. The plugin does not call any approval endpoint against CERTInext regardless of this setting. See [issue tracking this](https://github.com/Keyfactor/certinext-caplugin/issues/25). | `false` | +| `RequesterName` | Optional | String | Per-template override for the requestor name. When set, overrides the connector-level `RequestorName` for orders using this template. | `Keyfactor Automation` | +| `RequesterEmail` | Optional | String | Per-template override for the requestor email address. When set, overrides the connector-level `RequestorEmail` for orders using this template. | `pki-admin@example.com` | +| `RenewalWindowDays` | Optional | Number | Number of days before certificate expiration within which a renewal is attempted instead of a reissue. Default: `90`. | `90` | +| `KeyType` | Optional | String | Key algorithm to request at enrollment time. The key type is carried by the submitted CSR. CERTInext accepts **RSA 2048 / 3072 / 4096 and ECC P-256 / P-384** only — larger RSA, ECC P-521, and the Ed25519/Ed448 curves are rejected by the CA (`Invalid key size`). If omitted, the product default is used. | `RSA2048`, `RSA3072`, `RSA4096`, `EC256`, `EC384` | +| `DomainName` | Optional | String | Primary domain name for SSL/TLS orders. If omitted, the gateway derives the domain from the CSR `CN` field. | `example.com` | +| `SignerName` | Optional | String | Per-template override for the subscriber agreement signer name. When omitted, defaults to the connector-level `RequestorName`. | `Jane Smith` | +| `SignerPlace` | Optional | String | Per-template override for the subscriber agreement signer location. When omitted, defaults to the connector-level `SignerPlace`. | `Austin` | +| `SignerIp` | Optional | String | Per-template override for the subscriber agreement signer IP address. When omitted, defaults to the connector-level `SignerIp`. | `203.0.113.10` | 3. Follow the [official Keyfactor documentation](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCA-Keyfactor.htm) to add each defined Certificate Authority to Keyfactor Command and import the newly defined Certificate Templates. 4. In Keyfactor Command (v12.3+), for each imported Certificate Template, follow the [official documentation](https://software.keyfactor.com/Core-OnPrem/Current/Content/ReferenceGuide/Configuring%20Template%20Options.htm) to define enrollment fields for each of the following parameters: - * **ProductCode** - OPTIONAL: Override the numeric CERTInext product code for this template. When omitted, the default production code for the selected product is used automatically (e.g. DV SSL → 838). Set this explicitly when targeting sandbox or a non-standard code. - * **ProfileId** - DEPRECATED: Use ProductCode instead. Kept for backward compatibility — mapped to ProductCode if ProductCode is not set. - * **ValidityYears** - OPTIONAL: Subscription validity in years: 1, 2, or 3. Default: 1. Note: CERTInext validates per 390-day certificate within the subscription; the 'validity' field in the order is the subscription term, not certificate lifetime. - * **ValidityDays** - DEPRECATED: Use ValidityYears instead. If set, value is divided by 365 and rounded up to get the subscription year count. - * **AutoApprove** - OPTIONAL: If true, the gateway will attempt automatic approval of certificates that are returned in a pending-approval state. Default: false. - * **RequesterName** - OPTIONAL: Default requester name to include in the enrollment request. Used when no requester name can be derived from the subject. - * **RequesterEmail** - OPTIONAL: Default requester email address. Used when no email can be derived from the subject. - * **RenewalWindowDays** - OPTIONAL: Number of days before certificate expiration within which a renewal is triggered. Certificates expiring further than this window are reissued instead. Certificates that have already expired also fall back to reissue. Default: 90. - * **KeyType** - OPTIONAL: Key algorithm to request (e.g. 'RSA2048', 'RSA4096', 'EC256', 'EC384'). If omitted, the profile default is used. - * **DomainName** - OPTIONAL: Primary domain for SSL/TLS orders. Derived from the CSR CN if omitted. - * **SignerName** - OPTIONAL: Per-template subscriber agreement signer name. Falls back to the connector-level RequestorName if omitted. - * **SignerPlace** - OPTIONAL: Per-template signer city/location. Falls back to the connector-level SignerPlace if omitted. - * **SignerIp** - OPTIONAL: Per-template signer IP address. Falls back to the connector-level SignerIp if omitted. - + * **ProductCode** - OPTIONAL: Override the numeric CERTInext product code for this template. When omitted, the default production code for the selected product is used automatically (e.g. DV SSL → 838). Set this explicitly when targeting sandbox or a non-standard code. + * **ProfileId** - DEPRECATED: Use ProductCode instead. Kept for backward compatibility — mapped to ProductCode if ProductCode is not set. + * **ValidityYears** - OPTIONAL: Subscription validity in years: 1, 2, or 3. Default: 1. Note: CERTInext validates per 390-day certificate within the subscription; the 'validity' field in the order is the subscription term, not certificate lifetime. + * **ValidityDays** - DEPRECATED: Use ValidityYears instead. If set, value is divided by 365 and rounded up to get the subscription year count. + * **AutoApprove** - OPTIONAL: If true, the gateway will attempt automatic approval of certificates that are returned in a pending-approval state. Default: false. + * **RequesterName** - OPTIONAL: Default requester name to include in the enrollment request. Used when no requester name can be derived from the subject. + * **RequesterEmail** - OPTIONAL: Default requester email address. Used when no email can be derived from the subject. + * **RenewalWindowDays** - OPTIONAL: Number of days before certificate expiration within which a renewal is triggered. Certificates expiring further than this window are reissued instead. Certificates that have already expired also fall back to reissue. Default: 90. + * **KeyType** - OPTIONAL: Key algorithm to request (e.g. 'RSA2048', 'RSA4096', 'EC256', 'EC384'). If omitted, the profile default is used. + * **DomainName** - OPTIONAL: Primary domain for SSL/TLS orders (for V2 private-pki orders, the primary hostname). Derived from the CSR CN if omitted. + * **SignerName** - OPTIONAL: Per-template subscriber agreement signer name. Falls back to the connector-level RequestorName if omitted. + * **SignerPlace** - OPTIONAL: Per-template signer city/location. Falls back to the connector-level SignerPlace if omitted. + * **SignerIp** - OPTIONAL: Per-template signer IP address. Falls back to the connector-level SignerIp if omitted. ## CERTInext API Setup @@ -221,28 +243,64 @@ If your CERTInext account has OAuth enabled, you can use OAuth client credential ## CA Configuration -The following fields are presented in the Keyfactor Command Management Portal when creating or editing the CERTInext CA connector. All fields marked **Required** must be provided before the connector can be saved in an enabled state. +The following fields are presented in the Keyfactor Command Management Portal when creating or editing the CERTInext CA connector. + +> Note: the connector's own save-time validation only enforces `ApiUrl`, `AccountNumber`, and the credential fields for the selected `AuthMode`. Other fields marked **Required** below are required by CERTInext for a successful order — the connector will save without them, but enrollment will fail or the order will be parked pending until they're set. | Field | Required / Optional | Description | Where to find it | Example | |---|---|---|---|---| -| `ApiUrl` | Required | CERTInext API base URL for your environment. Must include the `/emSignHub-API/` path segment. No trailing slash is required but is accepted. | See the environments table above. | `https://api.certinext.io/emSignHub-API` | -| `AccountNumber` | Required | Your CERTInext account number (numeric string). Included in the `meta` block of every API request. | Portal → click your name or avatar → **Account Settings** or **My Profile**. | `4461259728` | +| `ApiUrl` | Required | CERTInext API base URL. In V1 mode (default), must include the `/emSignHub-API/` path segment. When `UseV2Api` is `true` (see [V2 API](#v2-api-preview) below), this is instead the bare V2 host with no trailing slash or path suffix — the two APIs are hosted differently, so this value changes when `UseV2Api` is toggled. Must use `https` — the OAuth client secret (V2) or API key (V1) is sent to this URL on every request, and `http` would transmit it in cleartext. `http` is rejected at connection-validation time except for a loopback host (`localhost`/`127.0.0.1`/`::1`), which is allowed for local test servers only. | See the environments table above. | `https://api.certinext.io/emSignHub-API/` | +| `AccountNumber` | Required | Your CERTInext account number (numeric string). Included in the `meta` block of every API request. | Portal → click your name or avatar → **Account Settings** or **My Profile**. | `1234567890` | | `AuthMode` | Required | Authentication mode. `AccessKey` uses HMAC signing (recommended). `OAuth` uses a bearer token. | N/A — choose based on the credential type you created. | `AccessKey` | | `ApiKey` | Conditional | The REST API Access Key generated in the CERTInext portal. Used to compute `authKey = SHA256(accessKey + ts + txn)`. The raw key is never transmitted. Required when `AuthMode` is `AccessKey`. This field is masked in the UI. | Portal → **Integrations → APIs** → generate or view the credential row. | *(generated, masked in UI)* | | `OAuthTokenUrl` | Conditional | OAuth token endpoint URL. Required when `AuthMode` is `OAuth`. | Provided by eMudhra for your account. | `https://auth.certinext.io/oauth/token` | -| `OAuthClientId` | Conditional | OAuth client ID. Required when `AuthMode` is `OAuth`. | Portal → **Integrations → APIs** → the OAuth credential row. | `keyfactor-gateway` | -| `OAuthClientSecret` | Conditional | OAuth client secret. Required when `AuthMode` is `OAuth`. This field is masked in the UI. | Generated at OAuth credential creation time. | *(generated, masked in UI)* | +| `OAuthClientId` | Conditional | OAuth client ID. Required when `AuthMode` is `OAuth` (V1). Also required — and reused as-is — when `UseV2Api` is `true`; V2 does not have its own separate client ID field. | Portal → **Integrations → APIs** → the OAuth credential row. | `keyfactor-gateway` | +| `OAuthClientSecret` | Conditional | OAuth client secret. Required when `AuthMode` is `OAuth` (V1). Also required — and reused as-is — when `UseV2Api` is `true`. This field is masked in the UI. | Generated at OAuth credential creation time. | *(generated, masked in UI)* | | `RequestorName` | Required | Default name of the person or service submitting certificate orders. Sent in the `requestorInformation` block of every order request. | Use the name of the team or automation account responsible for these certificates. | `PKI Automation` | | `RequestorEmail` | Required | Default email address for the requestor. Must be a valid email address associated with your CERTInext account. Sent in the `requestorInformation` block of every order request. | Use a monitored team inbox or the account holder's email. | `pki-admin@example.com` | | `RequestorIsdCode` | Optional | International dialing code for the requestor phone number (digits only, no `+` prefix). Default: `1` (United States). | N/A — use the country code for your requestor. | `1` | | `RequestorMobileNumber` | Optional | Requestor mobile number (digits only, no country code). Included in the `requestorInformation` block. | N/A | `5551234567` | -| `SignerPlace` | Required | City or location of the person accepting the subscriber agreement on behalf of your organization. Required by CERTInext for all orders. | Use the physical city where the signer is located. | `Austin` | +| `RequestorDesignation` | Optional | Job title / role of the requestor (e.g. `IT Administrator`). Sent in the `requestorInformation` block (V1) and the `requestor.designation` field (V2 mode). Free text with no CA-side enum. Left blank by default, in which case the field is omitted from the order entirely rather than sent with a default value. | N/A | `IT Administrator` | +| `SignerPlace` | Required | City or location of the person accepting the subscriber agreement on behalf of your organization. Required by CERTInext for all orders. When `UseV2Api` is `true` the connector can't be saved with this blank, because the V2 Subscriber Agreement sent with every SSL order requires it (a template's `SignerPlace` enrollment parameter still overrides it). | Use the physical city where the signer is located. | `Austin` | | `SignerIp` | Required | Public IP address of the host accepting the subscriber agreement. Required by CERTInext for all orders. | Use the outbound IP of the AnyCA Gateway host, or the IP of the workstation from which the agreement was accepted. | `203.0.113.10` | -| `GroupNumber` | Optional | CERTInext group (delegation) number. When set, it is passed in the `productDetails.groupNumber` field of `GetProductDetails` requests. Some sandbox accounts return an empty product list from `GetProductDetails` unless this field is included. Available in the CERTInext portal under **Delegation → Groups**. | Portal → **Delegation → Groups**. | `2171775848` | -| `DefaultProductCode` | Optional | Default numeric product code to use when no product code is set on the certificate template. If omitted and the template also has no product code, enrollment will fail. Product codes are provisioned per account by eMudhra — contact your eMudhra account representative to obtain the numeric codes available to your account. | Call `GetProductDetails` against your account/environment (see product code table below). | `842` | +| `GroupNumber` | Optional | CERTInext group (delegation) number. When set, it is passed in the `productDetails.groupNumber` field of `GetProductDetails` requests *and* in `delegationInformation.groupNumber` on every SSL order. Some sandbox accounts return an empty product list from `GetProductDetails` unless this field is included. Available in the CERTInext portal under **Delegation → Groups**. | Portal → **Delegation → Groups**. | `2345678901` | +| `OrganizationNumber` | Optional, strongly recommended for OV/EV and faster DV | Numeric CERTInext organization number for a pre-vetted organization. When set, every SSL order is submitted with `organizationDetails.preVetting="1"` and this number, telling CERTInext to skip its manual organization-vetting queue. Without it, orders may sit in `Pending System RA` for extended manual review (observed: tens of hours). | Portal → **Organizations → Pre-vetted Organizations**. | `1234567` | +| `TechnicalContactName` / `TechnicalContactEmail` / `TechnicalContactIsdCode` / `TechnicalContactMobileNumber` | Optional | Populate `technicalPointOfContact` on every SSL order. Each defaults to the corresponding `Requestor*` field when blank. Some product configurations require a technical point of contact to be present; omitting it can cause CERTInext to park orders awaiting manual completion of the field. | N/A | *(defaults to Requestor fields)* | +| `AccountingModel` | Optional | CERTInext billing model sent in `orderDetails.accountingModel`. `2` = credit-based (most accounts). `1` = cash model. Default: `2`. | N/A | `2` | +| `EmailNotifications` | Optional | Whether CERTInext sends lifecycle-event emails to the requestor. `1` = full notification set (V1 sends it as-is; V2 maps it to `all`). `0` = silent on both V1 and V2 (V2 confirmed live 2026-09-28). Blank/unset stays silent on V1 (sent as `0`) but is omitted on V2, so the CA's own default (`all`, not silent) applies instead. Any other value fails V2 enrollment before any CA call. Default: `0` — V2 orders are now silent by default, matching V1 (previously V2 always sent `all`). | N/A | `0` | +| `SubscriptionValidityYears` | Optional | Connector-level default validity in years for SSL orders (`1`, `2`, or `3`). Overridden per template by the `ValidityYears` enrollment parameter. Default: `1`. | N/A | `1` | +| `SubscriptionAutoRenew` | Optional | Whether CERTInext should auto-renew certificates issued through this connector. `0` = disabled (recommended — renewal is driven by Keyfactor Command), `1` = enabled. Default: `0`. | N/A | `0` | +| `SubscriptionRenewCriteriaDays` | Optional | Days before expiry at which CERTInext auto-renews. Only honored when `SubscriptionAutoRenew` is `1`. Default: `30`. | N/A | `30` | +| `AutoSecureWww` | Optional | If `1`, CERTInext automatically adds the `www.` variant of the primary domain as an additional SAN. Default: `0`. | N/A | `0` | +| `SubmitNonDnsSans` | Optional | If `true` (default), SANs that aren't DNS names (IP address, email, URI) are submitted to CERTInext instead of silently dropped. CERTInext can't validate them, so such an order won't issue until they're removed. Set to `false` to restore the pre-1.0.1 behavior of submitting DNS names only. Default: `true`. | N/A | `true` | +| `DefaultProductCode` | Optional, but effectively required if you use renewals (V1), or ProductId-only templates against an ambiguous V2 catalog | **V1 mode:** used for renewals only — CERTInext's `TrackOrder` doesn't return the prior order's product code, so the renewal path sends this value verbatim, ignoring the template's `ProductCode`/`ProfileId`. If left blank, renewals go out with an empty product code. Has no effect on new V1 enrollments. **V2 mode:** also used to disambiguate a template that sets only `ProductId` (no explicit `ProductCode`) when the live V2 catalog has more than one product sharing the product's expected assurance level — if this value doesn't match one of the candidate codes, that enrollment (and template save-time validation) fails with an error listing them. See [issue tracking the V1 renewal behavior](https://github.com/Keyfactor/certinext-caplugin/issues/26). | Call `GetProductDetails` against your account/environment (see product code table below). | `842` | | `IgnoreExpired` | Optional | If `true`, expired certificates are skipped during synchronization and are not imported into Keyfactor Command. Default: `false`. | N/A | `false` | | `PageSize` | Optional | Number of orders to retrieve per page during synchronization. Default: `100`. Maximum: `500`. Reduce this value if synchronization requests time out. | N/A | `100` | | `Enabled` | Optional | Enables or disables the CA connector. Setting this to `false` allows the connector record to be created before all credentials are available, without triggering a live connectivity test. Default: `true`. | N/A | `true` | +| `LogSensitiveRequestData` | Optional | **Diagnostic escape hatch — off by default.** When `true`, this writes requestor personal data (name, email, phone, and other organization contact details) and full CA request/response payloads to the gateway logs. It's meant for temporary use while verifying a new deployment (confirming exactly what was sent to the CA and that the order succeeded) — turn it back off once verification is complete. When `false` (default), personal-data fields are redacted (email is masked but keeps its domain, e.g. `j***@example.com`) and the enrollment log line omits the requester name entirely. Email SAN values (rfc822Name) in log lines are masked the same way; DNS, IP and URI SANs are always logged in full. Credentials (API keys, OAuth secrets, tokens) are always redacted regardless of this setting. Default: `false`. | N/A | `false` | +| `PickupRetries` | Optional | Number of times `Enroll` polls CERTInext for the certificate after a successful order submission, before returning pending and leaving pickup to the next sync. Set to `0` to disable the wait entirely. OV/EV orders validate asynchronously (minutes to hours) and typically exhaust this wait regardless of the value. Default: `5`. | N/A | `5` | +| `PickupDelay` | Optional | Seconds between certificate-pickup retries. The total pickup budget is a fixed 5-second initial delay + (`PickupRetries` × `PickupDelay`), hard-capped at 180 seconds regardless of how the two values are set. Aim for well under ~90s total so the call doesn't run long enough to trip Command's own enrollment timeout. Default: `10` (a ~55s ceiling with default `PickupRetries`). | N/A | `10` | + +> **Pickup timing detail:** after a successful order placement, the plugin waits a fixed 5-second initial delay before the first poll attempt, then polls CERTInext every `PickupDelay` seconds up to `PickupRetries` times. Each poll calls `GetCertificate` to check whether the certificate has been issued. The total time budget is: **5s + (PickupRetries × PickupDelay) + API round-trip time per poll (~1s each)**. With defaults this is approximately 5 + (5 × 10) + 5 = **~60 seconds**. +> +> **Tuning for faster pickup:** if the CERTInext API typically issues certificates within a few seconds of order placement (as observed with DV and auto-approved orders), you can reduce per-enrollment wait time by lowering `PickupDelay` and raising `PickupRetries` to compensate — this polls more frequently without changing the total budget. For example: +> +> | Configuration | PickupRetries | PickupDelay | Total budget | Poll cadence | +> |---------------|:---:|:---:|---|---| +> | Default | `5` | `10` | ~55s | Every 10s | +> | Faster polling | `10` | `5` | ~55s | Every 5s | +> | Aggressive | `50` | `1` | ~55s | Every 1s | +> | Minimal wait | `0` | — | 0s | No polling; defers to sync | +> +> The 5-second initial delay before the first poll is not configurable. The 180-second hard ceiling applies regardless of configuration. +| `DcvEnabled` | Optional | When `true`, the gateway performs DNS-based Domain Control Validation (DCV) during enrollment for orders that require it. Requires a DNS provider plugin (e.g. `azure-azuredns-dnsplugin`) to be deployed on the gateway. Default: `false`. | N/A | `false` | +| `DcvTxtRecordTemplate` | Optional | Format string for the DNS TXT record hostname published during DCV. `{0}` is replaced with the domain being validated. Default: `_emsign-validation.{0}`. | N/A | `_emsign-validation.{0}` | +| `DcvPropagationDelaySeconds` | Optional | Seconds to wait after publishing the DNS TXT record before asking CERTInext to verify it. Increase for zones with slow propagation. Applies only to the `Enroll()`-time DCV path — DCV driven during sync uses its own fixed 3-second delay. Default: `30`. | N/A | `30` | +| `DcvTimeoutMinutes` | Optional | Maximum minutes to wait for the entire DCV flow (DNS publish + propagation + verify) before cancelling the enrollment. Can also be set via the `CERTINEXT_DCV_TIMEOUT_MINUTES` environment variable; the environment variable takes precedence when both are set. Default: `10`. | N/A | `10` | +| `DcvWaitForChallengeSeconds` | Optional | How long `Enroll()` waits for CERTInext to expose the DCV challenge after order placement, before giving up and deferring to the next sync. Set to `0` to disable the wait. Can also be set via `CERTINEXT_DCV_WAIT_FOR_CHALLENGE_SECONDS`. Default: `60`. | N/A | `60` | +| `DcvWaitForIssuanceSeconds` | Optional | How long `Enroll()` waits for CERTInext to finish generating the certificate after DCV verifies. Set to `0` to disable the wait. Can also be set via `CERTINEXT_DCV_WAIT_FOR_ISSUANCE_SECONDS`. Default: `60`. | N/A | `60` | +| `DcvSyncMaxOrderAgeHours` | Optional | During synchronization, only pending DV orders younger than this many hours are driven through DCV, so a large backlog of old/abandoned pending orders doesn't slow down every sync pass. Set to `0` to disable the age filter. Default: `24`. | N/A | `24` | +| `DcvSyncMaxPerPass` | Optional | Maximum number of pending DV orders driven through DCV in a single sync pass. Set to `0` to disable the cap. Default: `50`. | N/A | `50` | > Note: `AccountNumber` and group-level identifiers are distinct values. The `AccountNumber` is your top-level user account identifier. CERTInext groups (cost centers or departments) each have their own `groupNumber`, which is passed per-order and is separate from any organization number displayed on the Organizations page. @@ -262,53 +320,380 @@ To retrieve the exact codes available to your account, call the `GetProductDetai ### SSL/TLS -The product codes in this table were observed on the US sandbox account (`accountNumber=9374221333`) in April 2026. Your account will likely have different codes. Always call `GetProductDetails` to confirm the codes provisioned for your account. +The product codes in this table were observed on: +- the US sandbox environment (`sandbox-us-api.certinext.io`) in April–May 2026 +- the Production India environment (`api.certinext.io`) via the live draft-order coverage matrix in [development.md](development.md) -| Product | Sandbox Code (account 9374221333, April 2026) | Required fields beyond base (`domainName`, `csr`, `requestorInformation`, `subscriptionDetails`, `agreementDetails`) | -|---|---|---| -| DV (Domain Validated) | `842` | None. `domainName` is derived from the CSR CN if omitted on the template. | -| DV Wildcard | `843` | CSR CN must use wildcard format (e.g. `*.example.com`). `domainName` in the order must also use the wildcard format. | -| DV UCC (Multi-domain) | `844` | `certificateInformation.additionalDomains` — array of additional SAN values beyond the primary `domainName`. | -| DV Wildcard UCC (Multi-domain Wildcard) | `845` | Combines wildcard and multi-domain requirements. CSR CN and `domainName` must use wildcard format; `certificateInformation.additionalDomains` required. | -| OV (Organization Validated) | `846` | `organizationDetails.organizationNumber` (your CERTInext org ID); `certificateInformation.locality`, `postalCode`, and full organization address fields (`streetAddress`, `city`, `state`, `country`). | -| OV Wildcard | `847` | Same as OV (846). CSR CN and `domainName` must use wildcard format. | -| OV UCC (Multi-domain) | `848` | Same as OV (846) plus `certificateInformation.additionalDomains`. | -| OV Wildcard UCC (Multi-domain Wildcard) | `849` | Combines OV, wildcard, and multi-domain requirements. Same as OV (846) plus wildcard CN/domainName and `certificateInformation.additionalDomains`. | -| EV (Extended Validation) | `850` | All OV fields plus: `contractSignerInfo` object (`name`, `email`, `isdCode`, `mobileNumber`, `designation`, `employeeID`); `certificateApproverInfo` object (same fields); `certificateInformation.companyRegistrationNumber`; `streetAddress2` must be non-empty. | -| EV UCC (Multi-domain EV) | `851` | Same as EV (850) plus `certificateInformation.additionalDomains`. | +**Your account may still have different codes.** Always call `GetProductDetails` against your target environment before going live. + +| Product | Sandbox Code | Production Code | Required fields beyond base (`domainName`, `csr`, `requestorInformation`, `subscriptionDetails`, `agreementDetails`) | +|---|---|---|---| +| DV (Domain Validated) | `842` | `838` | None. `domainName` is derived from the CSR CN if omitted on the template. | +| DV Wildcard | `843` | `839` | CSR CN must use wildcard format (e.g. `*.example.com`). `domainName` in the order must also use the wildcard format. | +| DV UCC (Multi-domain) | `844` | `840` | `certificateInformation.additionalDomains` — array of additional SAN values beyond the primary `domainName`. | +| DV Wildcard UCC (Multi-domain Wildcard) | `845` | `841` | Combines wildcard and multi-domain requirements. CSR CN and `domainName` must use wildcard format; `certificateInformation.additionalDomains` required. | +| OV (Organization Validated) | `846` | `842` | `organizationDetails.organizationNumber` (your CERTInext org ID); `certificateInformation.locality`, `postalCode`, and full organization address fields (`streetAddress`, `city`, `state`, `country`). | +| OV Wildcard | `847` | `843` | Same as OV. CSR CN and `domainName` must use wildcard format. | +| OV UCC (Multi-domain) | `848` | `844` | Same as OV plus `certificateInformation.additionalDomains`. | +| OV Wildcard UCC (Multi-domain Wildcard) | `849` | `845` | Combines OV, wildcard, and multi-domain requirements. Same as OV plus wildcard CN/domainName and `certificateInformation.additionalDomains`. | +| EV (Extended Validation) | `850` | `846` | All OV fields plus: `contractSignerInfo` object (`name`, `email`, `isdCode`, `mobileNumber`, `designation`, `employeeID`); `certificateApproverInfo` object (same fields); `certificateInformation.companyRegistrationNumber`; `streetAddress2` must be non-empty. | +| EV UCC (Multi-domain EV) | `851` | `847` | Same as EV plus `certificateInformation.additionalDomains`. | + +> Note: SSL/TLS codes appear to be offset by 4 between the US sandbox and Production India in the snapshots we've observed — but treat that as a coincidence, not a guarantee. eMudhra controls the per-account mapping and may use different numeric codes for any new account. Always confirm via `GetProductDetails`. > Note: The CERTInext portal may display additional short-validity products (e.g. **DV SSL Certificate 1 Month**, **DV SSL Certificate Wildcard 1 Month**) that do not appear in the `GetProductDetails` API response and have no published product code. These products are not accessible via the API and are therefore **not supported by this plugin**. Contact eMudhra to determine whether API ordering is available for these products on your account. ### Private PKI -| Product | Example Code | Availability | -|---|---|---| -| Sandbox emSign Intranet SSL 1 year | `149` (sandbox account 9374221333, April 2026) | Requires special provisioning by eMudhra. Not available on standard production accounts. | -| emSign Intranet SSL 1 year (production) | `100` | Requires special provisioning by eMudhra. Not orderable on standard accounts. | -| IGTF Host 1 year | `104` | Requires special provisioning by eMudhra. Not orderable on standard accounts. | +| Product | Sandbox Code | Production Code | Availability | +|---|---|---|---| +| emSign Intranet SSL 1 year | `149` | `100` | Requires special provisioning by eMudhra. Not orderable on standard accounts. | +| IGTF Host 1 year | (not observed) | `104` | Requires special provisioning by eMudhra. Not orderable on standard accounts. | -> Note: Private PKI products are not available for ordering on standard CERTInext accounts. Attempting to place an order will return EMS-1162 (product not provisioned). The sandbox Private PKI code (`149` on account 9374221333) also returns EMS-1162 because the product is not provisioned even though it appears in the `GetProductDetails` list. Contact eMudhra to have these products enabled on your account. +> Note: Private PKI products need a separate entitlement. On an account without it, placing an order returns EMS-1162 (product not provisioned). Contact eMudhra to have these products enabled. An earlier V1 note recorded the sandbox code `149` returning EMS-1162. More recently, a read-only V2 catalog check on the plugin's sandbox account (2026-09-25) listed `149` ("Sandbox emSign Intranet SSL 1 Year", `productTypeID` `39`) as active. No order has been placed against it, so it's unconfirmed whether a V2 order for it is accepted. Check your own account's catalog rather than relying on either observation. ### S/MIME and Document Signing -| Product | Product Code | Availability | +The same numeric product codes have been observed for S/MIME and document-signing products on both the US sandbox and Production India in the snapshots we have. **Treat that as an empirical observation, not a contract** — eMudhra is free to assign different codes per account. Always confirm via `GetProductDetails`. + +| Product | Sandbox / Production Code | Availability | |---|---|---| | S/MIME | `894` | Requires a separate S/MIME entitlement on the account. Not available on standard SSL accounts. | -| Natural Person Doc Signer (tier 1) | `825` | Requires document signing entitlement. Not orderable on standard accounts. | -| Natural Person Doc Signer (tier 2) | `826` | Requires document signing entitlement. Not orderable on standard accounts. | -| Natural Person Doc Signer (tier 3) | `827` | Requires document signing entitlement. Not orderable on standard accounts. | -| Legal Person Doc Signer (tier 1) | `822` | Requires document signing entitlement. Not orderable on standard accounts. | -| Legal Person Doc Signer (tier 2) | `823` | Requires document signing entitlement. Not orderable on standard accounts. | -| Legal Person Doc Signer (tier 3) | `824` | Requires document signing entitlement. Not orderable on standard accounts. | -| Legal Entity Doc Signer (tier 1) | `819` | Requires document signing entitlement. Not orderable on standard accounts. | -| Legal Entity Doc Signer (tier 2) | `820` | Requires document signing entitlement. Not orderable on standard accounts. | -| Legal Entity Doc Signer (tier 3) | `821` | Requires document signing entitlement. Not orderable on standard accounts. | +| Document Signer | `819`–`827` | Requires document signing entitlement. Not orderable on standard accounts. See the code-to-product table below. | + +The two CERTInext references disagree on which Document Signer code is which product. The V2 API +spec's Product Codes table lists `819`–`821` as Natural Person and `825`–`827` as Legal Entity. The +earlier V1 Postman collection this table was first built from listed them the other way round. Both +list `822`–`824` as Legal Person. Neither mapping has been checked against a live catalog, so confirm +the product name for each code in your account's catalog before you use one. + +| Code | V2 API spec | Earlier V1 Postman collection | +|---|---|---| +| `819` / `820` / `821` | Natural Person, 1 / 2 / 3 year | Legal Entity, 1 / 2 / 3 year | +| `822` / `823` / `824` | Legal Person, 1 / 2 / 3 year | Legal Person, 1 / 2 / 3 year | +| `825` / `826` / `827` | Legal Entity, 1 / 2 / 3 year | Natural Person, 1 / 2 / 3 year | > Note: S/MIME (894) and document signing products (819–827) require a separate entitlement that is not included in a standard SSL/TLS account. Contact eMudhra to request access. To retrieve the full list of product codes available to your account, call the `GetProductDetails` endpoint against your target environment. The sandbox and production APIs each return their own set of codes. -> Note: SSL/TLS products (codes 838–846) are supported on standard accounts. Private PKI (100, 104), S/MIME (894), and document-signing products (819–827) require special provisioning by eMudhra and are not available on standard SSL/TLS accounts — ordering them returns EMS-1162. +> Note: SSL/TLS products are supported on standard accounts — see the SSL/TLS table above for the exact sandbox/production code pair for each product. Private PKI (Production `100`, `104` / Sandbox `149`), S/MIME (`894`), and document-signing products (`819`–`827`) require special provisioning by eMudhra and are not available on standard SSL/TLS accounts — ordering them returns EMS-1162. + +## V2 API (Preview) + +The plugin includes an opt-in CERTInext V2 REST API code path that uses modern OAuth2 `client_credentials` authentication and a new order-centric resource model. V2 is disabled by default; V1 remains the active path unless `UseV2Api` is explicitly set to `true`. When enabled, V2 is fully self-contained: Enroll, GetSingleRecord, Revoke, and Synchronize all route through the V2 API, and V1 credentials (`ApiKey`, `AccountNumber`, `AuthMode`) are not required. + +### V2 CA Connector Fields + +V2 mode reuses the connector's `ApiUrl`, `OAuthClientId`, and `OAuthClientSecret` fields (documented above) rather than separate V2-only credentials — `ApiUrl` becomes the V2 host and `OAuthClientId`/`OAuthClientSecret` authenticate against it, regardless of `AuthMode`. Only the fields below are specific to V2 mode: + +| Field | Required / Optional | Description | Example | +|---|---|---|---| +| `UseV2Api` | Optional | Enable the V2 API code path for enrollment, revocation, status checks, and synchronization. Default: `false`. | `false` | +| `V2SyncLookbackHours` | Optional | V2 mode only. During an incremental Synchronize, the plugin queries `from` = (last sync time minus this many hours) rather than the exact last-sync time, since it's not confirmed whether the API's `from`/`to` filter brackets order-placement date or issuance date — a lookback window keeps an order created before last sync but issued afterward (e.g. a slow DCV order) from being missed. Default: `72`. | `72` | + +#### V2 OAuth2 Setup + +1. Log in to the CERTInext portal for your environment. +2. Navigate to **Integrations → APIs**. +3. Click **+ Create API Credentials**, set **API Type** to `REST`, and select the **OAuth** auth type (not `Access Key`). The V2 spec requires the key to be generated in OAuth mode. A key that wasn't gets HTTP 403 `unauthorized_client` at token time. +4. Note the client ID and client secret. Enter them in `OAuthClientId` and `OAuthClientSecret`. The V2 spec's token example uses the account number as `client_id`, but the plugin never substitutes `AccountNumber` for it, so set `OAuthClientId` explicitly. See [Step 1 of the migration guide](#step-1--create-a-v2-oauth2-credential) for what hasn't been verified about reusing V1 OAuth keys. +5. Set `UseV2Api` to `true` and set `ApiUrl` to the V2 base URL (no trailing path suffix), e.g. `https://sandbox-us-api.certinext.io`. +6. V1-only fields (`ApiKey`, `AccountNumber`, `AuthMode`) are not required in this mode and can be left blank. + +#### V2 Token Caching + +The plugin obtains a V2 bearer token via the standard OAuth2 `client_credentials` grant (`grant_type=client_credentials`, form-encoded) against `{ApiUrl}/oauth/token`. Tokens are cached in memory and reused until 60 seconds before expiry (minimum 30-second cache). Token refresh is thread-safe. + +### V2 Certificate Template Fields + +When `UseV2Api` is `true`, two additional enrollment parameters become relevant: + +| Parameter | Required / Optional | Type | Description | Example / Default | +|---|---|---|---|---| +| `ProductFamily` | Optional | String | CERTInext V2 product family. Supported for enrollment: `ssl` (SSL/TLS) and `private-pki` (Private PKI — see [V2 Private PKI Orders](#v2-private-pki-orders)). `signature` (Document Signer) is accepted by the parameter, but Document Signer enrollment is not yet supported: a `signature` enrollment fails before any order is placed. Default: `ssl`. | `ssl` | +| `ProductVariant` | Optional | String | Product variant within the family. `ssl`: `dv`, `ov`, or `ev`. If omitted, the plugin derives it from the selected product (e.g. an OV product sends `ov`, an EV product sends `ev`) rather than always defaulting to `dv`; an explicit override that contradicts the product's derived variant fails enrollment with an actionable error instead of being sent as-is. `private-pki`: `intranet-ssl` or `igtf-host` — required, with no default. | `dv` | + +`ProductCode` continues to carry the numeric product code and is sent in the `X-Product-Code` header on V2 order placement. + +### V2 Product Code Resolution + +When `UseV2Api` is `true`, the numeric product code sent to CERTInext is resolved as follows: + +1. **Explicit `ProductCode` (or the deprecated `ProfileId` alias) on the template** — sent as-is in the `X-Product-Code` header, after template save-time validation confirms it exists in the live V2 catalog. +2. **No explicit code set** — the plugin maps the template's selected product to the catalog's expected `productTypeID` and looks for catalog entries sharing it: + - **Exactly one match** — used automatically. + - **No match** — enrollment (and template save-time validation) fails; the account may not be entitled to the product. + - **More than one match** — the live catalog can carry several entries at the same assurance level (e.g. two DV SSL entries with different billing terms). The connector's `DefaultProductCode` must name one of them, or enrollment fails with an error listing every candidate code and name. Set `ProductCode` explicitly on the template, or set `DefaultProductCode` on the connector, to disambiguate. + +This differs from V1, where `DefaultProductCode` only affects renewals (see the [`DefaultProductCode` field](#ca-configuration) above) — in V2 mode it also disambiguates new enrollments and template validation for a `ProductId`-only template. + +### V2 Private PKI Orders + +With `ProductFamily=private-pki`, the plugin places the order against CERTInext's Private PKI endpoint using the Private PKI request body, which differs from the SSL/TLS one: + +- **Product code is required.** Set `ProductCode` explicitly to your account's Private PKI catalog code. Private PKI codes vary per customer catalog, so the plugin can't look one up from the product selected on the template. Template validation checks that the code exists in the V2 catalog and is a Private PKI product (catalog `productTypeID` `39`). +- **Variant is required.** Set `ProductVariant` to `intranet-ssl` or `igtf-host`. +- **Hostname.** The order's primary `hostname` comes from `DomainName`, or from the CSR's CN when `DomainName` isn't set. +- **SANs, including IP addresses.** Additional SANs are sent in the order's `additionalHosts` field, which accepts DNS names and IPv4/IPv6 addresses. SANs come from the gateway's SAN list; the plugin falls back to the SANs in the CSR only when the gateway supplies none. Email and URI SANs can't be expressed in `additionalHosts`, so they're left off the order and a warning is written to the gateway log. `SubmitNonDnsSans` isn't consulted for Private PKI orders. +- **No DCV, organization, or subscriber agreement.** Private PKI orders have none of these steps, so DCV is never attempted for them, and `OrganizationNumber`, `AutoSecureWww`, `SignerName`, `SignerPlace`, and `SignerIp` aren't used. +- **Shared fields.** The requestor, technical contact, subscription, email-notification, and group settings are sent exactly as they are for SSL/TLS orders. + +### V2 Order Lifecycle + +V2 orders are identified by the `orderId` the V2 order placement endpoint returns, which the plugin stores unchanged as the `CARequestID` and uses for all later tracking, certificate download, and revocation calls. The V2 spec's examples show `ord_`-prefixed IDs, but orders placed through V2 on the sandbox so far have returned numeric order numbers in the same format as V1 (e.g. `6625262451`). Treat the ID as an opaque string. + +V2 status strings map to Keyfactor enrollment statuses as follows: + +| V2 Status | Keyfactor Status | Notes | +|---|---|---| +| `issued` | Issued | Certificate is immediately downloaded and returned to Command. | +| `pending-dcv` | Pending External Validation | Order is awaiting domain control validation. | +| `pending-csr` | Pending External Validation | Order is awaiting CSR submission or processing. | +| `pending-agreement` | Pending External Validation | Order requires subscriber agreement acceptance. | +| `pending-organization-verification` | Pending External Validation | OV/EV order is awaiting organization verification. | +| `pending-documents` | Pending External Validation | Order is awaiting supporting document submission. | +| `pending-approval` | Pending External Validation | Order is awaiting final CA/LRA approval before issuance. | +| `revoked` | Revoked | Order has been revoked. | +| `cancelled` | Failed | Order was cancelled; a new enrollment is required. | +| `rejected` | Failed | Order was rejected by the CA/LRA; a new enrollment is required. | +| `expired` | Issued | An expired-but-not-revoked order is reported as issued (GENERATED), matching V1's convention — it remains visible in Command's inventory rather than disappearing as a failure. | + +Any V2 status not in this table (e.g. a value CERTInext adds in the future) also maps to Failed, but the +plugin logs a warning distinguishing "unmapped status" from the statuses above that are deliberately +mapped to Failed — see the gateway trace log if certificates unexpectedly show as failed. + +V2 has no *renew* endpoint. CERTInext does document a `/reissue` endpoint (`mode: rekey|update-sans`, with optional `revokePrevious`/`revokeReason`), but the plugin does not use it by design — all three enrollment types (New, Reissue, RenewOrReissue) place a fresh V2 order, and the prior order/certificate is left issued rather than auto-revoked. + +### V2 Revocation Reason Handling + +CERTInext's V2 revoke endpoint accepts only a subset of its own documented reason enum. When Command's revoke reason maps to one CERTInext rejects, the plugin substitutes an accepted reason and retries once, rather than failing the revoke outright: CA-compromise and AA-compromise are retried as key-compromise; unspecified (Command's default when no reason is given) and certificate-hold are retried as cessation-of-operation. See [Revocation Reason Codes](#revocation-reason-codes) in the migration guide below for the full accepted/rejected matrix. + +## Migrating from V1 to V2 + +The CERTInext V2 REST API is an opt-in, order-centric API with modern OAuth2 authentication. It is +controlled entirely by the `UseV2Api` connector flag: `false` (default) keeps the connector on the +V1 API documented above; `true` switches **all** operations — Enroll, GetSingleRecord, Revoke, and +Synchronize — to V2. The two APIs cannot be mixed on a single connector. + +> **V2 is labeled Preview.** It has real functional gaps relative to V1 (see +> [Known Gaps](#known-gaps) below) — most notably that per-SAN DCV on multi-domain (UCC) orders +> hasn't been confirmed by CERTInext or verified end to end (see below), and that Document Signer +> (`ProductFamily=signature`) enrollment isn't supported yet. Read this whole document — especially +> that section — before migrating a production connector. + +### Before You Begin: Confirm V2 Will Work for Your Templates + +**V2 supports multi-domain (UCC) certificates**, including **DV UCC, DV Wildcard UCC, OV UCC, OV +Wildcard UCC, and EV UCC**. The plugin detects a UCC product from the live Catalog's `productTypeID` +and sends the extra SAN domains in the order's `additionalDomains` field. Per the CERTInext V2 spec, +a UCC order's SANs are taken from the order, not the CSR. `additionalDomains` takes DNS names only, +so any non-DNS SAN (IP, email, URI) is left off a UCC order and a warning is written to the gateway +log. For a non-UCC SSL product, a CSR that carries DNS SANs beyond the primary domain and its `www.` +variant is rejected with a `FAILED` result before any order is placed; UCC products are exempt from +that check. + +**UCC DCV: the plugin runs DCV for each SAN, but the behavior isn't confirmed yet.** For a UCC order, +the plugin's DNS-01 DCV flow publishes, verifies, and cleans up a TXT record for each domain that +CERTInext reports as not yet validated, not just the primary domain. CERTInext hasn't yet confirmed +how per-SAN DCV is meant to work on V2, and this path hasn't been verified end to end against a live +UCC order. Test each UCC template on the sandbox before you rely on it in production, and keep it on +a V1 connector if you need that guarantee today. + +### Step 1 — Create a V2 OAuth2 Credential + +V2 authenticates with an OAuth2 `client_credentials` token, and the CERTInext V2 spec requires the +API key to be generated in **OAuth mode**. The credential goes in the connector's +`OAuthClientId`/`OAuthClientSecret` fields: + +1. Log in to the CERTInext portal for your environment. +2. Navigate to **Integrations → APIs**. +3. Click **+ Create API Credentials**. +4. Set **API Type** to `REST` and select the **OAuth** auth type, not `Access Key`. +5. Complete the form and click **Generate**. +6. Note the client ID and client secret right away. + +A V1 `Access Key` credential won't work against V2. If the key wasn't generated in OAuth mode, the +token request fails with HTTP 403 `unauthorized_client`, and the plugin reports that the key wasn't +generated in OAuth mode. A wrong client ID or secret fails with HTTP 401 `invalid_client` instead. +Nobody has checked whether a key that was already created in OAuth mode for V1's `AuthMode: OAuth` +also works on V2. If you reuse one and get the 403, create a new OAuth-mode key. + +The V2 spec's token example sends the account number as `client_id`. It hasn't been verified whether +the portal ever shows a client ID that differs from the account number. The plugin never substitutes +`AccountNumber` for the client ID, so always set `OAuthClientId` explicitly, even if the value +matches your account number. + +### Step 2 — Update the CA Connector + +You can update the existing CA connector in place, or (recommended for a first migration) create a +second connector pointed at the same CERTInext account with `UseV2Api=true`, so you can validate V2 +behavior without disrupting V1 traffic. + +| V1 field | What happens when you set `UseV2Api = true` | +|---|---| +| `ApiUrl` | **Must change format.** V1 requires the `/emSignHub-API/` path segment (e.g. `https://us-api.certinext.io/emSignHub-API/`); V2 is the bare host with no trailing slash or path suffix (e.g. `https://us-api.certinext.io`). Using the V1-style URL under V2 (or vice versa) will fail every call. In both modes, `ApiUrl` must use `https` — `http` is rejected at connection-validation time except for a loopback host, which stays allowed for local test servers. | +| `AccountNumber` | Not required, and not read by any V2 code path. V2 authenticates with `OAuthClientId`; the plugin doesn't reuse `AccountNumber` as the OAuth `client_id` (see Step 1). | +| `AuthMode` | Not required. V2 always authenticates via OAuth2 `client_credentials`, regardless of this setting. | +| `ApiKey` | Not required. V2 never computes an `authKey`. | +| `OAuthClientId` / `OAuthClientSecret` | **Reused, but repointed.** Set them to the OAuth-mode credential from Step 1. It's unverified whether a V1 `AuthMode: OAuth` key also works on V2 (see Step 1). | +| `OAuthTokenUrl` | Not used. V2 always requests a token from `{ApiUrl}/oauth/token`; the token URL is derived, not configured. | +| `GroupNumber` | **Honored.** Sent as `groupNumber` on V2 order create (SSL/TLS and Private PKI) and as a `groupNumber` query parameter on the catalog and orders-report calls. Omitted when blank, so the account's default group applies. It hasn't been verified live whether the catalog and report filters actually narrow results on a multi-group account. | +| `OrganizationNumber` | **Required for OV/EV, otherwise unused.** V2 OV/EV orders now send `organization.organizationNumber` (with `preVetted=true`) from this setting — CERTInext hard-rejects an OV/EV order with no organization data (HTTP 422 `EMS-1180`), so `OrganizationNumber` must be set on the connector before enrolling OV/EV certificates via V2. DV orders never send an organization block, so this setting has no effect for DV. | +| `AccountingModel` | Not used by V2 order placement. | +| `EmailNotifications` | **Honored, with one default-value difference from V1.** `1` maps to `emailNotifications: "all"`; `0` maps to `"0"` (confirmed live 2026-09-28 to suppress order-creation emails, same as V1). Blank/unset is omitted on V2 (the CA's own default of `"all"` applies) rather than sent as `"0"` the way V1's own fallback does — set `EmailNotifications=0` explicitly if you want V2 orders silent. Any other value fails the V2 enrollment before any CA call. | +| `SubscriptionAutoRenew` / `SubscriptionRenewCriteriaDays` | Honored. `SubscriptionAutoRenew=1` sets `subscription.autoRenew=true`; `SubscriptionRenewCriteriaDays` sets `subscription.renewBeforeDays` (blank omits the field, so the CA's documented default of 30 applies). An unparseable or negative `SubscriptionRenewCriteriaDays` fails the enrollment before any CA call. | +| `DefaultProductCode` | Not used for V2 renewals (see [Renewals](#renewals-and-reissuance) below) — V2 has no separate renewal call to fall back to a default code for. | +| `TechnicalContactName` / `Email` / `IsdCode` / `MobileNumber` | **Honored.** Sent as the order's `technicalPointOfContact` block on V2 SSL/TLS and Private PKI orders. Each blank field falls back to the matching `Requestor*` value, the same as V1. `designation` is always sent as `Technical Contact`. | +| `IgnoreExpired` | **Honored during V2 Synchronize.** When `true`, a report row whose `certificateExpiryDate` parses and is in the past is skipped. A row with a missing or unparseable expiry date is kept. | +| `SubmitNonDnsSans` | **SSL family (`ProductFamily=ssl`):** not consulted. A non-UCC order carries only the primary domain (plus `www.` when `AutoSecureWww` is set). A UCC order's `additionalDomains` takes DNS names only, so non-DNS SANs are left off the order with a warning in the gateway log (see [Before You Begin](#before-you-begin-confirm-v2-will-work-for-your-templates)). **Private PKI family (`ProductFamily=private-pki`):** not consulted — the order's `additionalHosts` field accepts DNS names and IPv4/IPv6 addresses natively, so IP-address SANs are always submitted; email and URI SANs cannot be expressed there and are left off the order with a warning in the gateway log. | +| `PageSize` | Still used, now against V2's `/reports/orders` paging. | +| `RequestorName` / `RequestorEmail` / `RequestorMobileNumber` / `RequestorDesignation` | Still used — carried into the V2 order's `requestor` block. `RequestorDesignation` is omitted from the order when blank (the default) rather than sent with any value. | +| `SignerPlace` / `SignerIp` | Still used — carried into the V2 order's `agreement` block. | +| `SubscriptionValidityYears` | Still used as the fallback validity when the template's `ValidityYears` parameter is not set. | +| `AutoSecureWww` | Still used — controls whether V2 adds the `www.` variant. | + +New fields, `UseV2Api` and `V2SyncLookbackHours`, are documented in [V2 API (Preview)](#v2-api-preview) +above. + +### Step 3 — Update Certificate Templates + +For each template you're migrating: + +1. **If the template sets only `ProductId` (no explicit `ProductCode`), check whether the live V2 + catalog has more than one product at that assurance level.** The plugin resolves the numeric code + automatically from the catalog when exactly one entry matches; when the catalog has several (e.g. + two DV SSL entries with different billing terms), you must either set `ProductCode` explicitly on + the template or set the connector's `DefaultProductCode` to one of the candidates — otherwise every + enrollment against that template fails with an error listing the candidate codes. See + [V2 Product Code Resolution](configuration.md#v2-product-code-resolution) for the full resolution + order. +2. Add `ProductFamily` (default `ssl`) if not already present — this is a V2-only parameter with no + V1 equivalent. `ProductVariant` (`dv`/`ov`/`ev`) is optional for `ssl`: if left unset, the plugin + derives it from the selected product (an OV product sends `ov`, an EV product sends `ev`) instead + of defaulting to `dv`; set it explicitly only to override. For a Private PKI template, set + `ProductFamily=private-pki`, `ProductVariant` to `intranet-ssl` or `igtf-host` (required, no + default), and an explicit `ProductCode` (see [V2 Private PKI Orders](#v2-private-pki-orders)). + `ProductFamily=signature` (Document Signer) enrollment is not yet supported. +3. Re-verify `ProductCode` (if set explicitly) against the V2 catalog. V1 and V2 product codes are not + guaranteed to be the same numeric values on your account — call `GetProductDetailsV2Async` (or the + equivalent live probe) rather than assuming the V1 code carries over. Template validation + (`ValidateProductInfo`) automatically checks `ProductCode` against the V2 catalog once + `UseV2Api=true`, so an incorrect code will be caught at template save time, not silently at + enrollment. +4. If the template enrolls for a UCC (multi-domain) product, test it on the sandbox first. The plugin + runs DCV for each SAN, but CERTInext hasn't confirmed that behavior yet (see + [Before You Begin](#before-you-begin-confirm-v2-will-work-for-your-templates)). +5. If the product is OV or EV (whether `ProductVariant` is set explicitly or left to be derived), set + `OrganizationNumber` on the CA connector (a pre-vetted organization number from CERTInext's + Accounts → List Organizations). It is mandatory for OV/EV under V2 — enrollment fails fast with a + clear error if it's missing, rather than reaching the CA + and getting back an opaque 422. + +### Step 4 — Test Before Cutting Over + +Run a full enroll → sync → revoke cycle against the sandbox environment with `UseV2Api=true` before +pointing a production template at the V2 connector. At minimum, confirm: + +- A new enrollment issues (or parks pending DCV/approval as expected) and is retrievable via + `GetSingleRecord`. +- A full and an incremental `Synchronize` both pick up the order. +- `Revoke` succeeds for the Command revoke reasons you actually use. + +## Behavioral Differences After Migrating + +- **Order identifiers.** The V2 spec's examples show `ord_`-prefixed order IDs (e.g. + `ord_8K9mQ2vR8nP4bL`), but the orders placed through V2 against the sandbox so far have come back + with numeric order numbers in the same format as V1 (e.g. `6625262451`). V1-placed order numbers + also resolve through V2 Track Order and appear under the same number in the V2 orders report, so + existing `CARequestID` values carry over. The plugin stores whatever `orderId` CERTInext returns as + the `CARequestID`. Treat it as an opaque string in any external tooling rather than assuming either + format. +- **Status vocabulary.** V2 reports order status as strings (`issued`, `pending-dcv`, `pending-csr`, + `pending-agreement`, `pending-organization-verification`, `pending-documents`, `pending-approval`, + `revoked`, `cancelled`, `rejected`, `expired`) rather than V1's numeric CERTInext status codes. The + plugin maps both to the same Keyfactor `EndEntityStatus` values, so this is transparent to Command, + but it changes what you'll see in gateway trace logs. +- **Synchronization source.** V2 sync reads CERTInext's `/reports/orders` endpoint instead of V1's + `GetOrderReport`. Incremental sync queries a window starting `V2SyncLookbackHours` (default 72) + before the last sync time rather than the exact last-sync timestamp, because it isn't confirmed + whether the API's date filter brackets order-placement or issuance date — this trades a small + amount of redundant re-processing for not missing a slow-issuing order. + +### Renewals and Reissuance + +CERTInext V2 has no *renew* endpoint, but it does document a `/reissue` endpoint (`mode: +rekey|update-sans`, with optional `revokePrevious`/`revokeReason`). The plugin intentionally does not +use it. +**Every** Command `Renew`, `Reissue`, and `RenewOrReissue` enrollment instead places a brand-new V2 +order — the same call path as a new enrollment — rather than reusing V1's renewal-window logic or the +`/reissue` endpoint. The prior order and certificate are left issued, not auto-revoked; Command links +the old and new certificates via history only. If your CERTInext account is on a credit-based billing +model, **each renewal under V2 consumes a new credit**, unlike V1 where a renewal inside the +`RenewalWindowDays` window is billed as part of the existing subscription term. Factor this into your +migration decision if you rely on CERTInext's free-renewal-within-subscription behavior. + +### Revocation Reason Codes + +V1 sends CERTInext a numeric `revokeReasonId`; V2 sends a kebab-case string reason. The plugin +handles this translation automatically. On SSL/TLS orders, only `key-compromise` (1), +`affiliation-changed` (3), `superseded` (4), `cessation-of-operation` (5), and `privilege-withdrawn` +(9) were accepted in live sandbox testing. `unspecified` (0, Command's default when no reason is +given), `ca-compromise` (2), `certificate-hold` (6), and `aa-compromise` (10) are all rejected live +with `"Invalid Revoke Reason ID"` — `ca-compromise` and `certificate-hold` are listed in the spec for +SSL/TLS, `aa-compromise` isn't listed for SSL/TLS at all, but the live sandbox rejects all four the +same way. Rather than fail the revoke, the plugin retries each once with a close accepted +substitute: `ca-compromise` and `aa-compromise` retry as `key-compromise`; `unspecified` and +`certificate-hold` retry as `cessation-of-operation` (chosen over `key-compromise` for those two +because neither implies an actual key compromise, and `key-compromise` carries the spec's own BR +§4.9.1.1 24-hour CRL-turnaround obligation that would misrepresent the revoke). No customer action is +needed for any of these four cases. Any other revoke failure is surfaced as-is, without a retry. +Revoke reasons for Private PKI orders haven't been tested live. Separately, a revoke note containing +a semicolon (`;`) is rejected with `"Invalid Revoke Remarks"`. The plugin's own generated notes avoid +semicolons, but a future customer-supplied note would need to avoid them too. + +## Known Gaps + +As of this writing, the following V2 limitations are known and unresolved. None of them are +show-stoppers for a single-domain, credit-tolerant deployment, but you should decide with these in +mind rather than discover them after cutting over: + +- **UCC per-SAN DCV is unconfirmed.** The plugin runs DCV for each SAN on a UCC order, but CERTInext + hasn't confirmed the per-SAN DCV behavior and the path hasn't been verified end to end. See + [Before You Begin](#before-you-begin-confirm-v2-will-work-for-your-templates) above. +- **Document Signer (`ProductFamily=signature`) enrollment isn't supported yet.** It fails before any + order is placed. +- **Every renewal/reissue places a new order and consumes a new credit** — see + [Renewals and Reissuance](#renewals-and-reissuance) above. +- **`OrganizationNumber` is now required to enroll OV/EV via V2, but only unlocks acceptance, not + V1's pre-vetting speed benefit.** V2 OV/EV orders send `organization.organizationNumber` with + `preVetted=true` (mirroring V1's `organizationDetails.preVetting`), which is what makes CERTInext + accept the order at all — omitting it gets a hard 422 rejection. Whether this also grants V1's + observed vetting-queue speedup has not been independently confirmed for V2; if your account was + relying on `OrganizationNumber` to fast-path DV issuance under V1, note that DV orders under V2 + never send an organization block, so that specific benefit does not carry over. +- **`AccountingModel` has no V2 effect.** V2 order create has no equivalent field. `GroupNumber`, the + technical-contact fields, `EmailNotifications`, `SubscriptionAutoRenew`/`RenewCriteriaDays`, and + `IgnoreExpired` are all honored on V2 (see the table above). +- **V2 `TrackOrder` responses omit `_links`** — a spec-shape discrepancy observed live; no functional + impact has been identified so far, but it means any future feature that expects those links (e.g. + a direct download link) can't rely on them yet. + +## Rolling Back to V1 + +Rolling back is just setting `UseV2Api` back to `false` on the connector — the V1 credential fields +(`ApiKey`/`AccountNumber`/`AuthMode` or V1 `OAuth`) are unaffected by having been unused while V2 was +active, as long as you didn't overwrite them in Step 2. + +**Rollback caveat (unverified):** `Enroll`, `GetSingleRecord`, `Revoke`, and `Synchronize` choose V1 +or V2 purely from the connector's current `UseV2Api` flag, not from the stored `CARequestID`. What +has been observed on the sandbox runs in one direction only: V1-placed order numbers resolve through +V2 Track Order, and V2-placed orders so far have numeric order numbers in the same format as V1. The +reverse hasn't been tested. Nobody has checked whether V1 Track Order or `GetOrderReport` can see an +order that was placed through V2. Until that's confirmed, treat rolling back a connector that has +already issued V2 certificates as untested. Before you rely on it, check on the sandbox that sync, +revoke, and renewal still work for those certificates after switching back. Certificates enrolled +before the switch to V2 are V1 orders and are unaffected. ## Architecture @@ -338,9 +723,17 @@ This document describes how the CERTInext AnyCA Gateway REST plugin integrates w ┌────────────────────────────▼────────────────────────────┐ │ CERTInext REST API (eMudhra) │ │ │ -│ ValidateCredentials GenerateOrderSSL TrackOrder │ -│ GetCertificate RevokeOrder GetOrderReport │ -│ GetProductDetails SubmitCSR │ +│ V1 (HMAC) ValidateCredentials · GenerateOrderSSL │ +│ TrackOrder · GetCertificate · GetOrderReport │ +│ RevokeOrder · GetProductDetails · SubmitCSR │ +│ │ +│ V2 (OAuth2 Bearer) POST /oauth/token │ +│ POST /ssl-certificates │ +│ GET /ssl-certificates/{id} │ +│ GET /ssl-certificates/{id}/dcv │ +│ POST /ssl-certificates/{id}/dcv/verify │ +│ GET /ssl-certificates/{id}/certificate │ +│ POST /ssl-certificates/{id}/revoke │ └─────────────────────────────────────────────────────────┘ ``` @@ -356,6 +749,8 @@ A unique transaction ID (`requestTxnId`) is generated for each request. The time An OAuth client-credentials mode is also available as an alternative. When OAuth is configured, the plugin exchanges a client ID and secret for a short-lived bearer token and automatically refreshes it before expiry. +When `UseV2Api` is enabled, the plugin uses a dedicated OAuth2 `client_credentials` flow, reusing the connector's `OAuthClientId`/`OAuthClientSecret` fields regardless of the V1 `AuthMode` setting. The plugin posts `client_id` and `client_secret` (form-encoded) to `{ApiUrl}/oauth/token` (the same `ApiUrl` field, which becomes the V2 host in this mode), caches the resulting bearer token for its 1-hour lifetime, and automatically refreshes it 60 seconds before expiry. + ## Certificate Identifiers CERTInext assigns two different reference numbers to each order. Understanding the difference matters when tracing certificates across systems: @@ -425,6 +820,8 @@ sequenceDiagram **Expired certificates:** The `IgnoreExpired` connector setting controls whether expired certificates are included in synchronization. When enabled, expired certificates are silently skipped and will not appear in the Keyfactor Command inventory. +**DCV-during-sync:** on a DCV-enabled build, each sync pass also drives DNS-01 validation forward for pending DV orders that are still waiting on it, bounded by `DcvSyncMaxOrderAgeHours` (skip orders older than this) and `DcvSyncMaxPerPass` (cap how many are attempted per pass), so a large backlog of stalled pending orders can't slow down every sync. + --- ## Certificate Enrollment @@ -446,13 +843,27 @@ sequenceDiagram Plugin->>API: Place certificate order\n(CSR, domain, organization details,\nsubscriber agreement, requestor info) API-->>Plugin: Order accepted — order number assigned + opt DNS-01 DCV build, DCV enabled, and this order requires it + Plugin->>Plugin: Publish DNS TXT challenge\nvia the configured DNS provider plugin + Plugin->>API: Ask CERTInext to verify the record + API-->>Plugin: Domain validated (or still pending —\nfalls through to the pending path below) + end + Plugin->>API: Check order status API-->>Plugin: Order status and certificate details alt Certificate issued immediately Plugin-->>CMD: Certificate ready — PEM returned - else Certificate pending approval - Plugin-->>CMD: Pending — Command will pick it up\nduring the next synchronization + else Certificate pending or not yet downloadable + loop Synchronous certificate pickup\n(PickupRetries × PickupDelay, default 3 × 5 s; ceiling 180 s) + Plugin->>API: Poll order status\nand attempt certificate download + API-->>Plugin: Status / certificate PEM + end + alt Certificate became available during pickup + Plugin-->>CMD: Certificate ready — PEM returned + else Still not available + Plugin-->>CMD: Pending — Command will pick it up\nduring the next synchronization + end else Order rejected by CERTInext Plugin-->>CMD: Enrollment failed — see gateway logs end @@ -460,12 +871,18 @@ sequenceDiagram Plugin->>Plugin: Record enrollment outcome in audit log\n(order number, serial number, status) ``` +**DCV:** on a DCV-enabled build, DNS-01 validation runs inline for DV orders that require it, bounded by `DcvTimeoutMinutes`. When DCV isn't enabled, isn't built into this host, or the order doesn't require it, this step is skipped entirely and the order proceeds straight to the pending/pickup path like any other asynchronously-issued order. + +**Synchronous certificate pickup:** after placing an order (or after DCV completes), the plugin polls CERTInext a bounded number of times — `PickupRetries` attempts spaced `PickupDelay` seconds apart, with a hard ceiling of 180 seconds — before returning a pending disposition to Command. This lets fast-issuing DV certificates (and pre-approved renewals) come back in the same enrollment call. OV and EV orders undergo human review over minutes to hours and almost always exhaust this window; they are picked up by the next synchronization run. + ### Renewal When Command initiates a renewal, the plugin checks whether the existing certificate is within the configured renewal window. If it is, the prior order record is used as context for the new request. If it is outside the window (or the prior certificate cannot be located), the plugin falls back to issuing a new certificate. > **Note:** CERTInext does not have a dedicated certificate renewal endpoint. Both renewal and reissuance paths submit a new `GenerateOrderSSL` order. The distinction affects how Keyfactor Command tracks the certificate record, not what is sent to CERTInext. +> **Note:** If the prior-order lookup itself throws (rather than cleanly returning "not found" — e.g. a transient database error), the plugin falls back to issuing a new certificate rather than failing the enrollment. + ```mermaid flowchart TD A([Renewal requested]) --> B{Prior certificate\nserial number\nprovided?} @@ -481,6 +898,107 @@ flowchart TD C --> I ``` +### V2 API Path (UseV2Api = true) + +When `UseV2Api` is enabled, Ping, Enroll, GetSingleRecord, Revoke, and Synchronize all route through the V2 REST API — Synchronize calls V2 `/reports/orders` rather than the V1 `GetOrderReport` endpoint, and V1 credentials are not required in this mode. + +#### DCV required (DV SSL) + +```mermaid +sequenceDiagram + participant CMD as Keyfactor Command + participant Plugin as CERTInext Plugin + participant API as CERTInext API (V2) + participant DNS as DNS Provider + + CMD->>Plugin: Request new certificate\n(CSR, subject, SANs, product code, requester details) + Plugin->>Plugin: Record enrollment intent in audit log + + Plugin->>API: POST /oauth/token\n(client_credentials grant) + API-->>Plugin: Bearer token (1-hour TTL) + + Plugin->>API: POST /ssl-certificates\n(X-Product-Code header · Idempotency-Key · JSON body) + API-->>Plugin: 201 Created — orderId assigned\nstatus: pending-dcv + + Plugin->>API: GET /ssl-certificates/{orderId}/dcv + API-->>Plugin: DCV challenge\n(fileNameContent = TXT value,\ndcvMethod = "2" for DNS-TXT) + + Plugin->>DNS: Publish TXT record\n_emudhra-challenge.{domain} → fileNameContent + Plugin->>Plugin: Wait for DNS propagation + + Plugin->>API: POST /ssl-certificates/{orderId}/dcv/verify\n(domain, method: "dns-txt") + API-->>Plugin: { "overallStatus": "VERIFIED" }\n(multi-perspective check) + + Plugin->>DNS: Remove TXT record + + loop Poll until status leaves pending-dcv\n(bounded by DcvTimeoutMinutes) + Plugin->>API: GET /ssl-certificates/{orderId} + API-->>Plugin: Current status + end + + loop Synchronous certificate pickup\n(PickupRetries × PickupDelay, ceiling 180 s) + Plugin->>API: GET /ssl-certificates/{orderId}\nGET /ssl-certificates/{orderId}/certificate + API-->>Plugin: Status · certificatePem · chainPem[] + end + + alt Certificate issued + Plugin->>Plugin: Assemble full chain\n(leaf + intermediates from chainPem[]) + Plugin-->>CMD: Certificate ready — PEM chain returned + else Still pending + Plugin-->>CMD: Pending — picked up by next sync + else Order rejected + Plugin-->>CMD: Enrollment failed — see gateway logs + end + + Plugin->>Plugin: Record enrollment outcome in audit log +``` + +#### No DCV required (OV/EV/Private PKI) + +```mermaid +sequenceDiagram + participant CMD as Keyfactor Command + participant Plugin as CERTInext Plugin + participant API as CERTInext API (V2) + + CMD->>Plugin: Request new certificate + Plugin->>Plugin: Record enrollment intent in audit log + + Plugin->>API: POST /oauth/token + API-->>Plugin: Bearer token + + Plugin->>API: POST /ssl-certificates\n(or /private-pki-certificates · /signature-certificates) + API-->>Plugin: 201 Created — orderId assigned\nstatus: pending-csr or pending-agreement + + loop Synchronous certificate pickup\n(PickupRetries × PickupDelay, ceiling 180 s) + Plugin->>API: GET /ssl-certificates/{orderId} + API-->>Plugin: Current status + end + + alt Certificate issued + Plugin->>API: GET /ssl-certificates/{orderId}/certificate + API-->>Plugin: certificatePem · chainPem[] + Plugin->>Plugin: Assemble full chain + Plugin-->>CMD: Certificate ready — PEM chain returned + else Still pending (OV/EV human review) + Plugin-->>CMD: Pending — picked up by next sync + else Order rejected + Plugin-->>CMD: Enrollment failed + end + + Plugin->>Plugin: Record enrollment outcome in audit log +``` + +**Token caching:** the Bearer token is cached for its 1-hour lifetime and shared across all V2 calls in the same gateway process. A new token is fetched automatically 60 seconds before expiry. + +**Idempotency:** V2 order-create and revoke calls carry a fresh `Idempotency-Key` UUID, but the plugin can't rely on it to prevent duplicates. The V2 spec describes the header as "Parsed today; enforced in a future release", and a new key is generated on every call, so a retry never reuses one. In a live sandbox check (2026-09-25), two order-create calls sent with the same key created two separate orders. The second call returned a generic HTTP 500 even though its order had been created. The plugin never retries an order-create call, and the AnyCA Gateway doesn't retry a timed-out `Enroll`. The only revoke retry is the one-time reason fallback described under [Revocation](#revocation), which is sent after CERTInext has already rejected the first call. If an operator resubmits after an order-create error, check the CERTInext portal for an order that was created anyway. + +**Full certificate chain:** the V2 `/certificate` endpoint returns the leaf certificate in `certificatePem` and any intermediate certificates in `chainPem[]`. The plugin concatenates these into a single PEM before returning to Command. + +**Order IDs:** the plugin stores the V2 `orderId` unchanged as the `CARequestID`. The V2 spec's examples show `ord_`-prefixed IDs, but orders placed through V2 on the sandbox so far have returned numeric order numbers in the same format as V1 (e.g. `6625262451`), and V1 order numbers resolve through V2 Track Order unchanged. + +**Synchronize uses V2 reports:** with `UseV2Api = true`, Synchronize pages through V2 `/reports/orders` and downloads the certificate body for each issued order. An incremental sync starts `V2SyncLookbackHours` (default 72) before the last sync time. + --- ## Revocation @@ -518,6 +1036,29 @@ sequenceDiagram **Audit trail:** The revocation intent is written to the gateway log *before* the API call is made. This ensures that the intent is captured even if the API call subsequently fails, satisfying SOX audit requirements. +**Reason code fallback (V2 only):** the V2 spec documents 8 RFC 5280 reason values for the SSL/TLS +revoke endpoint. `aa-compromise` is listed only for the Document Signer and Private PKI endpoints, +which document 9. In live sandbox testing on SSL/TLS orders, independent of this plugin, only 5 values +succeeded: `key-compromise`, `affiliation-changed`, `superseded`, `cessation-of-operation`, and +`privilege-withdrawn`. Three documented values, `unspecified`, `ca-compromise`, and +`certificate-hold`, returned a 422 "Invalid Revoke Reason ID". So did the undocumented `aa-compromise`. +Revoke reasons for Private PKI and Document Signer orders haven't been tested live. + +Rather than surface any of these four rejections to the caller, the plugin retries each once with a +close accepted substitute: `unspecified` (CRL reason 0, Command's default when no explicit reason is +given — by far the most common revoke case) and `certificate-hold` (CRL reason 6) retry as +`cessation-of-operation`; `ca-compromise` (CRL reason 2) and `aa-compromise` (CRL reason 10) retry as +`key-compromise`. `cessation-of-operation` was chosen over `key-compromise` for the first pair +because neither `unspecified` nor `certificate-hold` implies an actual key compromise, and +`key-compromise` carries the spec's own BR 4.9.1.1 24-hour CRL-turnaround obligation, which would +misrepresent the revoke. Only these four specific rejections trigger a retry; any other revoke +failure is surfaced as-is. See `issues/0026` for the full reason-value test matrix and the open +question to CERTInext support about whether the documented reason enum is intentional. + +**Note field quirk:** CERTInext's revoke `note` (audit remarks) field rejects a semicolon (`;`) with a +separate 422, "Invalid Revoke Remarks." — confirmed live that comma, period, slash, and parentheses are +all accepted; only `;` triggers it. The retry note above avoids semicolons for this reason. + --- ## Connector Validation @@ -545,6 +1086,8 @@ flowchart TD The table below maps each Keyfactor Command operation to the CERTInext API endpoint it calls. +**V1 endpoints (default)** + | Operation | CERTInext API endpoint | |---|---| | Test connection / verify credentials | `POST ValidateCredentials` | @@ -556,6 +1099,22 @@ The table below maps each Keyfactor Command operation to the CERTInext API endpo | List available product codes | `POST GetProductDetails` | | Attach CSR to draft order | `POST SubmitCSR` | +**V2 endpoints (UseV2Api = true)** + +| Operation | V2 endpoint | +|---|---| +| Obtain Bearer token | `POST /oauth/token` | +| Test connection | `GET /api/certinext/v2/auth/me` | +| Issue / renew certificate | `POST /api/certinext/v2/{family}-certificates` | +| Check order status | `GET /api/certinext/v2/{family}-certificates/{orderId}` | +| Get DCV challenge (DV SSL) | `GET /api/certinext/v2/ssl-certificates/{orderId}/dcv` | +| Verify DCV | `POST /api/certinext/v2/ssl-certificates/{orderId}/dcv/verify` | +| Download certificate | `GET /api/certinext/v2/{family}-certificates/{orderId}/certificate` | +| Revoke certificate | `POST /api/certinext/v2/{family}-certificates/{orderId}/revoke` | +| List available products | `GET /api/certinext/v2/catalog/products` | +| Synchronize inventory | `GET /api/certinext/v2/reports/orders` (paginated) | + +`{family}` is `ssl-certificates`, `private-pki-certificates`, or `signature-certificates`. ## License @@ -563,4 +1122,4 @@ Apache License 2.0, see [LICENSE](LICENSE). ## Related Integrations -See all [Keyfactor Any CA Gateways (REST)](https://github.com/orgs/Keyfactor/repositories?q=anycagateway). \ No newline at end of file +See all [Keyfactor Any CA Gateways (REST)](https://github.com/orgs/Keyfactor/repositories?q=anycagateway). diff --git a/docs/reference/README.md b/docs/reference/README.md new file mode 100644 index 0000000..dca29a6 --- /dev/null +++ b/docs/reference/README.md @@ -0,0 +1,82 @@ +# Reference JSON — known-working lab state + +Sanitised JSON captures of a fully-configured CERTInext lab. Useful as +**wire-format reference** when you're writing or debugging +configuration scripts: every blob here is what the live gateway and +Command returned (POST/PUT bodies aren't shown — those are documented +in [`QUICKSTART.md`](../../QUICKSTART.md)). + +## Source + +Generated from the `kfclab` localhost-kind reference lab on +2026-05-22 via: + +``` +kfclab snapshot -f examples/localhost-kind/kfclab.yaml --out /tmp/snap +``` + +Then trimmed to the CERTInext-relevant subset, with sensitive fields +either already masked by the upstream API (`ClientSecret`) or omitted +entirely (no access keys, no PAM literals). + +## Layout + +``` +docs/reference/ +├── README.md (this file) +├── gateway/ +│ ├── certificate-profiles.json GET /AnyGatewayREST/config/certificateprofile +│ └── claims.json GET /AnyGatewayREST/config/claim +└── command/ + ├── certificate-authority.json GET /KeyfactorAPI/CertificateAuthorities (CERTInext record) + └── templates-certinext.json GET /KeyfactorAPI/Templates filtered by ConfigurationTenant +``` + +## `gateway/certificate-profiles.json` + +Eight profiles, one per CERTInext sandbox product. Each carries the +same `key_algs` block — the canonical "permit RSA 2048–8192 + ECDSA +P-256/384/521 + Ed25519/Ed448" policy. Match this `key_algs` shape on +new profiles to avoid Command's misleading `0xA0110004` "Key type +disallowed by policy" error. + +> **Note:** The gateway profile defines what Command permits; CERTInext itself only +> accepts RSA 2048/3072/4096 and ECC P-256/P-384. Orders using P-521, Ed25519, +> Ed448, or RSA larger than 4096 bits are accepted by Command but rejected by +> CERTInext with `Invalid key size`. + +The profiles **don't** carry CA-binding information; they're top-level +gateway resources. The CA configuration's `Templates[].CertificateProfile` +field is what binds a product to its profile by name. + +## `gateway/claims.json` + +The gateway authorisation table. Each row maps an OIDC subject (token +`sub`) to a gateway role. The lab seeds these on every +`init-gateway`: + +- Two for the gateway's own machine client (admin + user — defensive) +- One for `akadmin` (the Authentik admin's `nameClaimType=sub`) + +Production deployments add per-operator entries here. There are no +secrets in this file. + +## `command/certificate-authority.json` + +The single `LogicalName=certinext-caplugin` CA record after Command's +own redaction of the OAuth client secret (`ClientSecret.SecretValue` is +masked by Command on read). Useful as a shape reference for the +`POST /KeyfactorAPI/CertificateAuthorities` request body in +[QUICKSTART step 4](../../QUICKSTART.md#step-4--register-the-ca-in-command). +Read-only fields populated by Command (e.g. `Id`, `LastSyncTime`, +`SyncStatus`) are present but should not be set on create. + +## `command/templates-certinext.json` + +The eight Command templates created by `POST /KeyfactorAPI/Templates/Import` +(`ConfigurationTenant=certinext-caplugin`). Each is a 1-to-1 mapping +of a CERTInext sandbox product → a Command template named +`AnyCA_` and tied back to the CA by `ConfigurationTenant`. +Useful as a sanity check after running step 5 of the quickstart: the +template count and `CommonName` set should match this file (modulo +`Id` churn). diff --git a/docs/reference/command/certificate-authority.json b/docs/reference/command/certificate-authority.json new file mode 100644 index 0000000..f42dff9 --- /dev/null +++ b/docs/reference/command/certificate-authority.json @@ -0,0 +1,63 @@ +{ + "Agent": null, + "AgentName": null, + "AgentUsername": null, + "AllowOneClickRenewals": true, + "AllowedEnrollmentTypes": 3, + "AllowedRequesters": [], + "Audience": null, + "AuthCertificate": null, + "CAType": 1, + "CertificateCleanupEnabled": null, + "ClientId": "anygateway-gateway-certinext-client", + "ClientSecret": { + "Parameters": {}, + "Provider": null, + "SecretValue": "********************" + }, + "ConfigurationTenant": "certinext-caplugin", + "ConnectorPool": null, + "Delegate": false, + "DelegateEnrollment": false, + "DeleteWithArchivedKey": null, + "DenialMax": 0, + "EnforceUniqueDN": false, + "ExplicitCredentials": false, + "ExplicitPassword": null, + "ExplicitUser": null, + "FailureMax": null, + "ForestRoot": "certinext-caplugin", + "FullScan": { + "Interval": { + "Minutes": 720 + } + }, + "HostName": "https://gateway-gateway-certinext.127.0.0.1.nip.io/AnyGatewayREST/ejbca", + "Id": 4, + "IncrementalScan": { + "Interval": { + "Minutes": 5 + } + }, + "IssuanceMax": null, + "IssuanceMin": null, + "KeyRetention": 1, + "KeyRetentionDays": null, + "LastScan": "2026-05-22T19:20:01.2730000", + "LogicalName": "certinext-caplugin", + "MonitorThresholds": false, + "NewEndEntityOnRenewAndReissue": true, + "Properties": "{}", + "RFCEnforcement": false, + "Remote": false, + "Scope": "keyfactor-anyca-gateway", + "Standalone": false, + "SubscriberTerms": false, + "ThresholdCheck": null, + "TimeAfterExpiration": null, + "TimeAfterExpirationUnits": null, + "TokenURL": "https://auth.127.0.0.1.nip.io/application/o/token/", + "UseAllowedRequesters": false, + "UseCAConnector": false, + "UseForEnrollment": true +} diff --git a/docs/reference/command/templates-certinext.json b/docs/reference/command/templates-certinext.json new file mode 100644 index 0000000..948dcae --- /dev/null +++ b/docs/reference/command/templates-certinext.json @@ -0,0 +1,243 @@ +[ + { + "AllowOneClickRenewals": true, + "AllowedEnrollmentTypes": 3, + "AllowedRequesters": [ + "InstanceAdmin" + ], + "CommonName": "AnyCA_DV SSL", + "ConfigurationTenant": "certinext-caplugin", + "DisplayName": "AnyCA (DV SSL)", + "EnrollmentFields": [], + "ExtendedKeyUsages": [ + { + "DisplayName": "Client Authentication", + "Id": 2, + "Oid": "1.3.6.1.5.5.7.3.2" + }, + { + "DisplayName": "Secure Email", + "Id": 4, + "Oid": "1.3.6.1.5.5.7.3.4" + } + ], + "ForestRoot": "certinext-caplugin", + "FriendlyName": null, + "Id": 8, + "KeyArchival": false, + "KeyRetention": "Indefinite", + "KeyRetentionDays": 0, + "KeySize": "2048", + "KeyType": "RSA", + "KeyTypes": "ECC P-256/prime256v1/secp256r1, ECC P-384/secp384r1, ECC P-521/secp521r1, RSA 2048, RSA 3072, RSA 4096, RSA 6144, RSA 8192, Ed448, Ed25519", + "KeyUsage": 0, + "Manageability": 0, + "Oid": "1.1", + "RequiresApproval": false, + "TemplateName": "AnyCA (DV SSL)", + "TemplateRegexes": [], + "UseAllowedRequesters": true + }, + { + "AllowOneClickRenewals": true, + "AllowedEnrollmentTypes": 3, + "AllowedRequesters": [ + "InstanceAdmin" + ], + "CommonName": "AnyCA_DV SSL Multi-Domain (UCC)", + "ConfigurationTenant": "certinext-caplugin", + "DisplayName": "AnyCA (DV SSL Multi-Domain (UCC))", + "EnrollmentFields": [], + "ExtendedKeyUsages": [], + "ForestRoot": "certinext-caplugin", + "FriendlyName": null, + "Id": 10, + "KeyArchival": false, + "KeyRetention": "Indefinite", + "KeyRetentionDays": 0, + "KeySize": "2048", + "KeyType": "RSA", + "KeyTypes": "ECC P-256/prime256v1/secp256r1, ECC P-384/secp384r1, ECC P-521/secp521r1, RSA 2048, RSA 3072, RSA 4096, RSA 6144, RSA 8192, Ed448, Ed25519", + "KeyUsage": 0, + "Manageability": 0, + "Oid": "1.3", + "RequiresApproval": false, + "TemplateName": "AnyCA (DV SSL Multi-Domain (UCC))", + "TemplateRegexes": [], + "UseAllowedRequesters": true + }, + { + "AllowOneClickRenewals": true, + "AllowedEnrollmentTypes": 3, + "AllowedRequesters": [ + "InstanceAdmin" + ], + "CommonName": "AnyCA_DV SSL Wildcard", + "ConfigurationTenant": "certinext-caplugin", + "DisplayName": "AnyCA (DV SSL Wildcard)", + "EnrollmentFields": [], + "ExtendedKeyUsages": [], + "ForestRoot": "certinext-caplugin", + "FriendlyName": null, + "Id": 9, + "KeyArchival": false, + "KeyRetention": "Indefinite", + "KeyRetentionDays": 0, + "KeySize": "2048", + "KeyType": "RSA", + "KeyTypes": "ECC P-256/prime256v1/secp256r1, ECC P-384/secp384r1, ECC P-521/secp521r1, RSA 2048, RSA 3072, RSA 4096, RSA 6144, RSA 8192, Ed448, Ed25519", + "KeyUsage": 0, + "Manageability": 0, + "Oid": "1.2", + "RequiresApproval": false, + "TemplateName": "AnyCA (DV SSL Wildcard)", + "TemplateRegexes": [], + "UseAllowedRequesters": true + }, + { + "AllowOneClickRenewals": true, + "AllowedEnrollmentTypes": 3, + "AllowedRequesters": [ + "InstanceAdmin" + ], + "CommonName": "AnyCA_DV SSL Wildcard Multi-Domain (UCC)", + "ConfigurationTenant": "certinext-caplugin", + "DisplayName": "AnyCA (DV SSL Wildcard Multi-Domain (UCC))", + "EnrollmentFields": [], + "ExtendedKeyUsages": [ + { + "DisplayName": "OCSP Signing", + "Id": 9, + "Oid": "1.3.6.1.5.5.7.3.9" + } + ], + "ForestRoot": "certinext-caplugin", + "FriendlyName": null, + "Id": 11, + "KeyArchival": false, + "KeyRetention": "Indefinite", + "KeyRetentionDays": 0, + "KeySize": "2048", + "KeyType": "RSA", + "KeyTypes": "ECC P-256/prime256v1/secp256r1, ECC P-384/secp384r1, ECC P-521/secp521r1, RSA 2048, RSA 3072, RSA 4096, RSA 6144, RSA 8192, Ed448, Ed25519", + "KeyUsage": 0, + "Manageability": 0, + "Oid": "1.4", + "RequiresApproval": false, + "TemplateName": "AnyCA (DV SSL Wildcard Multi-Domain (UCC))", + "TemplateRegexes": [], + "UseAllowedRequesters": true + }, + { + "AllowOneClickRenewals": true, + "AllowedEnrollmentTypes": 3, + "AllowedRequesters": [ + "InstanceAdmin" + ], + "CommonName": "AnyCA_OV SSL", + "ConfigurationTenant": "certinext-caplugin", + "DisplayName": "AnyCA (OV SSL)", + "EnrollmentFields": [], + "ExtendedKeyUsages": [], + "ForestRoot": "certinext-caplugin", + "FriendlyName": null, + "Id": 12, + "KeyArchival": false, + "KeyRetention": "Indefinite", + "KeyRetentionDays": 0, + "KeySize": "2048", + "KeyType": "RSA", + "KeyTypes": "ECC P-256/prime256v1/secp256r1, ECC P-384/secp384r1, ECC P-521/secp521r1, RSA 2048, RSA 3072, RSA 4096, RSA 6144, RSA 8192, Ed448, Ed25519", + "KeyUsage": 0, + "Manageability": 0, + "Oid": "1.5", + "RequiresApproval": false, + "TemplateName": "AnyCA (OV SSL)", + "TemplateRegexes": [], + "UseAllowedRequesters": true + }, + { + "AllowOneClickRenewals": true, + "AllowedEnrollmentTypes": 3, + "AllowedRequesters": [ + "InstanceAdmin" + ], + "CommonName": "AnyCA_OV SSL Multi-Domain (UCC)", + "ConfigurationTenant": "certinext-caplugin", + "DisplayName": "AnyCA (OV SSL Multi-Domain (UCC))", + "EnrollmentFields": [], + "ExtendedKeyUsages": [], + "ForestRoot": "certinext-caplugin", + "FriendlyName": null, + "Id": 14, + "KeyArchival": false, + "KeyRetention": "Indefinite", + "KeyRetentionDays": 0, + "KeySize": "2048", + "KeyType": "RSA", + "KeyTypes": "ECC P-256/prime256v1/secp256r1, ECC P-384/secp384r1, ECC P-521/secp521r1, RSA 2048, RSA 3072, RSA 4096, RSA 6144, RSA 8192, Ed448, Ed25519", + "KeyUsage": 0, + "Manageability": 0, + "Oid": "1.7", + "RequiresApproval": false, + "TemplateName": "AnyCA (OV SSL Multi-Domain (UCC))", + "TemplateRegexes": [], + "UseAllowedRequesters": true + }, + { + "AllowOneClickRenewals": true, + "AllowedEnrollmentTypes": 3, + "AllowedRequesters": [ + "InstanceAdmin" + ], + "CommonName": "AnyCA_OV SSL Wildcard", + "ConfigurationTenant": "certinext-caplugin", + "DisplayName": "AnyCA (OV SSL Wildcard)", + "EnrollmentFields": [], + "ExtendedKeyUsages": [], + "ForestRoot": "certinext-caplugin", + "FriendlyName": null, + "Id": 13, + "KeyArchival": false, + "KeyRetention": "Indefinite", + "KeyRetentionDays": 0, + "KeySize": "2048", + "KeyType": "RSA", + "KeyTypes": "ECC P-256/prime256v1/secp256r1, ECC P-384/secp384r1, ECC P-521/secp521r1, RSA 2048, RSA 3072, RSA 4096, RSA 6144, RSA 8192, Ed448, Ed25519", + "KeyUsage": 0, + "Manageability": 0, + "Oid": "1.6", + "RequiresApproval": false, + "TemplateName": "AnyCA (OV SSL Wildcard)", + "TemplateRegexes": [], + "UseAllowedRequesters": true + }, + { + "AllowOneClickRenewals": true, + "AllowedEnrollmentTypes": 3, + "AllowedRequesters": [ + "InstanceAdmin" + ], + "CommonName": "AnyCA_OV SSL Wildcard Multi-Domain (UCC)", + "ConfigurationTenant": "certinext-caplugin", + "DisplayName": "AnyCA (OV SSL Wildcard Multi-Domain (UCC))", + "EnrollmentFields": [], + "ExtendedKeyUsages": [], + "ForestRoot": "certinext-caplugin", + "FriendlyName": null, + "Id": 15, + "KeyArchival": false, + "KeyRetention": "Indefinite", + "KeyRetentionDays": 0, + "KeySize": "2048", + "KeyType": "RSA", + "KeyTypes": "ECC P-256/prime256v1/secp256r1, ECC P-384/secp384r1, ECC P-521/secp521r1, RSA 2048, RSA 3072, RSA 4096, RSA 6144, RSA 8192, Ed448, Ed25519", + "KeyUsage": 0, + "Manageability": 0, + "Oid": "1.8", + "RequiresApproval": false, + "TemplateName": "AnyCA (OV SSL Wildcard Multi-Domain (UCC))", + "TemplateRegexes": [], + "UseAllowedRequesters": true + } +] diff --git a/docs/reference/gateway/certificate-profiles.json b/docs/reference/gateway/certificate-profiles.json new file mode 100644 index 0000000..08dfc6a --- /dev/null +++ b/docs/reference/gateway/certificate-profiles.json @@ -0,0 +1,314 @@ +[ + { + "id": 1, + "key_algs": { + "cert_profile_id": 0, + "dsa": null, + "ecdsa": { + "bit_lengths": null, + "curves": [ + "1.2.840.10045.3.1.7", + "1.3.132.0.34", + "1.3.132.0.35" + ] + }, + "ed25519": { + "bit_lengths": [ + 255 + ], + "curves": null + }, + "ed448": { + "bit_lengths": [ + 448 + ], + "curves": null + }, + "id": 0, + "rsa": { + "bit_lengths": [ + 2048, + 3072, + 4096, + 6144, + 8192 + ], + "curves": null + } + }, + "name": "DV SSL" + }, + { + "id": 2, + "key_algs": { + "cert_profile_id": 0, + "dsa": null, + "ecdsa": { + "bit_lengths": null, + "curves": [ + "1.2.840.10045.3.1.7", + "1.3.132.0.34", + "1.3.132.0.35" + ] + }, + "ed25519": { + "bit_lengths": [ + 255 + ], + "curves": null + }, + "ed448": { + "bit_lengths": [ + 448 + ], + "curves": null + }, + "id": 0, + "rsa": { + "bit_lengths": [ + 2048, + 3072, + 4096, + 6144, + 8192 + ], + "curves": null + } + }, + "name": "DV SSL Wildcard" + }, + { + "id": 3, + "key_algs": { + "cert_profile_id": 0, + "dsa": null, + "ecdsa": { + "bit_lengths": null, + "curves": [ + "1.2.840.10045.3.1.7", + "1.3.132.0.34", + "1.3.132.0.35" + ] + }, + "ed25519": { + "bit_lengths": [ + 255 + ], + "curves": null + }, + "ed448": { + "bit_lengths": [ + 448 + ], + "curves": null + }, + "id": 0, + "rsa": { + "bit_lengths": [ + 2048, + 3072, + 4096, + 6144, + 8192 + ], + "curves": null + } + }, + "name": "DV SSL Multi-Domain (UCC)" + }, + { + "id": 4, + "key_algs": { + "cert_profile_id": 0, + "dsa": null, + "ecdsa": { + "bit_lengths": null, + "curves": [ + "1.2.840.10045.3.1.7", + "1.3.132.0.34", + "1.3.132.0.35" + ] + }, + "ed25519": { + "bit_lengths": [ + 255 + ], + "curves": null + }, + "ed448": { + "bit_lengths": [ + 448 + ], + "curves": null + }, + "id": 0, + "rsa": { + "bit_lengths": [ + 2048, + 3072, + 4096, + 6144, + 8192 + ], + "curves": null + } + }, + "name": "DV SSL Wildcard Multi-Domain (UCC)" + }, + { + "id": 5, + "key_algs": { + "cert_profile_id": 0, + "dsa": null, + "ecdsa": { + "bit_lengths": null, + "curves": [ + "1.2.840.10045.3.1.7", + "1.3.132.0.34", + "1.3.132.0.35" + ] + }, + "ed25519": { + "bit_lengths": [ + 255 + ], + "curves": null + }, + "ed448": { + "bit_lengths": [ + 448 + ], + "curves": null + }, + "id": 0, + "rsa": { + "bit_lengths": [ + 2048, + 3072, + 4096, + 6144, + 8192 + ], + "curves": null + } + }, + "name": "OV SSL" + }, + { + "id": 6, + "key_algs": { + "cert_profile_id": 0, + "dsa": null, + "ecdsa": { + "bit_lengths": null, + "curves": [ + "1.2.840.10045.3.1.7", + "1.3.132.0.34", + "1.3.132.0.35" + ] + }, + "ed25519": { + "bit_lengths": [ + 255 + ], + "curves": null + }, + "ed448": { + "bit_lengths": [ + 448 + ], + "curves": null + }, + "id": 0, + "rsa": { + "bit_lengths": [ + 2048, + 3072, + 4096, + 6144, + 8192 + ], + "curves": null + } + }, + "name": "OV SSL Wildcard" + }, + { + "id": 7, + "key_algs": { + "cert_profile_id": 0, + "dsa": null, + "ecdsa": { + "bit_lengths": null, + "curves": [ + "1.2.840.10045.3.1.7", + "1.3.132.0.34", + "1.3.132.0.35" + ] + }, + "ed25519": { + "bit_lengths": [ + 255 + ], + "curves": null + }, + "ed448": { + "bit_lengths": [ + 448 + ], + "curves": null + }, + "id": 0, + "rsa": { + "bit_lengths": [ + 2048, + 3072, + 4096, + 6144, + 8192 + ], + "curves": null + } + }, + "name": "OV SSL Multi-Domain (UCC)" + }, + { + "id": 8, + "key_algs": { + "cert_profile_id": 0, + "dsa": null, + "ecdsa": { + "bit_lengths": null, + "curves": [ + "1.2.840.10045.3.1.7", + "1.3.132.0.34", + "1.3.132.0.35" + ] + }, + "ed25519": { + "bit_lengths": [ + 255 + ], + "curves": null + }, + "ed448": { + "bit_lengths": [ + 448 + ], + "curves": null + }, + "id": 0, + "rsa": { + "bit_lengths": [ + 2048, + 3072, + 4096, + 6144, + 8192 + ], + "curves": null + } + }, + "name": "OV SSL Wildcard Multi-Domain (UCC)" + } +] \ No newline at end of file diff --git a/docs/reference/gateway/claims.json b/docs/reference/gateway/claims.json new file mode 100644 index 0000000..66af60d --- /dev/null +++ b/docs/reference/gateway/claims.json @@ -0,0 +1,26 @@ +[ + { + "description": "Authentik machine client", + "id": 1, + "provider": "Authentik", + "role": "admin", + "type": "OAuth_sub", + "value": "ak-anygateway-gateway-certinext-client_credentials" + }, + { + "description": "Authentik machine client", + "id": 2, + "provider": "Authentik", + "role": "user", + "type": "OAuth_sub", + "value": "ak-anygateway-gateway-certinext-client_credentials" + }, + { + "description": "Authentik admin user", + "id": 3, + "provider": "Authentik", + "role": "admin", + "type": "OAuth_sub", + "value": "akadmin" + } +] \ No newline at end of file diff --git a/docs/reference/specs/CERTInext API v2.postman_collection (1).json b/docs/reference/specs/CERTInext API v2.postman_collection (1).json new file mode 100644 index 0000000..6572c15 --- /dev/null +++ b/docs/reference/specs/CERTInext API v2.postman_collection (1).json @@ -0,0 +1,7479 @@ +{ + "info": { + "_postman_id": "9c1afa3e-803f-4eab-81b3-214266638f18", + "name": "CERTInext API v2", + "description": "# CERTInext API v2 - REST APIs\n\nRESTful API for CERTInext certificate lifecycle management - issue, track, validate, renew, and revoke certificates programmatically. This collection is curated for **enterprise integrations**: BR-compliant SSL/TLS issuance, AATL-trusted Document Signer, and customer-owned Private PKI.\n\n---\n\n## Three-step setup\n\n1. **Pick your environment** (top-right env dropdown): Production, Sandbox, Demo, QA, or Localhost.\n \n2. **Set credentials** in the environment: `accountNumber` (your account number) and `clientSecret` (the OAuth client secret - generate under **Integrations -> APIs** in the portal, **OAuth mode**).\n \n3. **Get a Bearer token**: open **Authentication -> Get Bearer Token** and hit **Send**. The test script captures `access_token` into `{{accessToken}}` - every other request reuses it automatically.\n \n\nAfter that, run any folder top-to-bottom. Test scripts auto-capture `orderId`, `requestId`, `domainId` between calls so the next request works without editing.\n\n---\n\n## What's covered\n\n| Folder | Endpoints | What you do here |\n| --- | --- | --- |\n| **Authentication** | 2 | Mint + refresh OAuth2 Bearer tokens |\n| **SSL/TLS Certificates** | 22 | Public-trust SSL - 10 product variants (DV and OV in single / Wildcard / UCC / Wildcard UCC, plus EV and EV UCC) and full lifecycle |\n| **Document Signer Certificates** | 9 | AATL-trusted PDF signing - 3 subject types (Natural / Legal Person / Legal Entity) and lifecycle |\n| **Private PKI Certificates** | 7 | Customer-CA issuance - 2 variants (Intranet SSL / IGTF Host) and lifecycle |\n| **Domains** | 10 | Pre-register and validate domains: add, list, view, get DCV, change method, verify, deactivate, last attempt, attempt history, attempt details |\n| **Accounts** | 4 | Identity, billing groups, organizations |\n| **Catalog** | 2 | Product entitlements + per-product custom fields |\n| **Reports** | 2 | Orders report, ledger statement |\n| **Reference** | 3 | Error codes, product codes, country codes |\n\n---\n\n## SSL/TLS workflow\n\n```\n1. Get Bearer Token\n2. Pick the right Create - Variant (DV / DV Wildcard / DV UCC / OV / OV Wildcard / ...)\n3. Get DCV Challenges <- optional: defaults to order's primary domain + dns-txt\n4. Publish the TXT record / HTTP file / reply to email\n5. Verify DCV\n6. Submit CSR\n7. Accept Agreement\n8. Track Order until status = issued\n9. Download Certificate <- Accept: application/json | application/x-pem-file | application/pkix-cert\n\n ```\n\nOptional: **Cancel** before issuance, **Revoke** after, **Reissue** to rekey or add SANs.\n\n## Document Signer workflow\n\n```\n1. Get Bearer Token\n2. Create - Natural Person (or Legal Person, Legal Entity)\n3. Upload Documents (multipart)\n4. Submit CSR\n5. Accept Agreement\n6. Track until status = issued\n7. Download Certificate\n\n ```\n\n## Private PKI workflow\n\n```\n1. Get Bearer Token\n2. Create - Intranet SSL (or IGTF Host)\n3. Submit CSR\n4. Track until status = issued\n5. Download Certificate\n\n ```\n\n_No DCV, no Subscriber Agreement, no documents - your CA, your rules._\n\n## Domains workflow (pre-register)\n\n```\n1. Add Domain <- persists row; method-id resolved against your account\n2. Get DCV <- the TXT/HTTP challenge token is generated on first read\n3. Publish, then Verify DCV\n4. Domain becomes reusable in any later SSL order under the same account\n\n ```\n\nUse **Change DCV Method** to switch between DNS-TXT and HTTP-File (dns-txt / http-url).\n\n---\n\n## Conventions\n\n| Aspect | Standard |\n| --- | --- |\n| Auth header | `Authorization: Bearer` on every protected call |\n| Token endpoint | `POST /oauth/token` - `application/x-www-form-urlencoded` (RFC 6749 section 3.2) |\n| Timestamps | ISO-8601 with timezone - `2026-05-08T13:00:00Z` (UTC; `06:00 PT` / `09:00 ET`) |\n| Phones | E.164 - e.g. `+14155551234` |\n| Countries | ISO 3166-1 alpha-2 - `US` for United States |\n| Currencies | ISO 4217 - `USD`, `CAD`, `MXN` |\n| State codes | Two-letter state codes - `NY`, `CA`, `TX`, `WA`, `MA` |\n| Errors | RFC 7807 `application/problem+json` |\n| IDs | Opaque strings - never parse, never guess |\n| Pagination | Two styles - see per-endpoint docs:
\\- **Domains, DCV attempts** -> `?offset=0&limit=50`
\\- **Reports (orders + ledger)** -> `?page=1&size=50` (1-based; `size` clamped to 1-100) |\n| `X-Product-Code` | Required header on every order create |\n\n---\n\n## Environment variables (collection-managed)\n\n| Variable | Filled by | Purpose |\n| --- | --- | --- |\n| `accountNumber` | You | Account number from CERTInext portal |\n| `clientSecret` | You | OAuth client secret (long-lived secret) |\n| `accessToken` | Get Bearer Token script | Bearer JWT - auto on every call |\n| `refreshToken` | Get Bearer Token script | Future token refresh |\n| `orderId` | Create Order scripts | Threaded into lifecycle calls |\n| `requestId` | Create Order scripts | Draft / reissue requests |\n| `domainId` | Add Domain script | Threaded into domain lifecycle |\n| `organizationNumber` | You | Pre-vetted org number for OV orders |\n| `groupNumber` | You (optional) | Cost-centre allocation |\n| `productCodeSslDv / DvWildcard / ...` | Pre-filled per env | `X-Product-Code` for SSL variants |\n| `productCodeDocSignerNp1Y / Lp1Y / Le1Y` | Pre-filled per env | `X-Product-Code` for Document Signer |\n| `productCodePkiIntranet / Igtf` | Pre-filled per env | `X-Product-Code` for Private PKI |\n| `caProfileId / masterProductId` | You (optional) | Private PKI override - only set when you need to force a specific CA template / subscription slot. Otherwise the server derives both from `X-Product-Code`. |", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "54201257", + "_collection_link": "https://go.postman.co/collection/54201257-9c1afa3e-803f-4eab-81b3-214266638f18?source=collection_link" + }, + "item": [ + { + "name": "Authentication", + "item": [ + { + "name": "Get Bearer Token", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('200 OK', () => pm.response.to.have.status(200));", + "const body = pm.response.json();", + "if (body && body.access_token) {", + " const ttlMs = (parseInt(body.expires_in, 10) || 3600) * 1000;", + " pm.environment.set('accessToken', body.access_token);", + " pm.environment.set('refreshToken', body.refresh_token || '');", + " pm.environment.set('tokenExpiresAt', (Date.now() + ttlMs).toString());", + " pm.test('access_token captured', () => pm.expect(body.access_token).to.be.a('string').and.not.empty);", + " console.log('[auth] Bearer token saved to environment - valid for ' + body.expires_in + 's.');", + "} else {", + " pm.test('Token request failed: ' + (body.error_description || body.error || JSON.stringify(body)), () => false);", + "}", + "" + ] + } + } + ], + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "grant_type", + "value": "client_credentials", + "description": "Always client_credentials for machine-to-machine auth." + }, + { + "key": "client_id", + "value": "{{accountNumber}}", + "description": "Your account number from CERTInext portal." + }, + { + "key": "client_secret", + "value": "{{clientSecret}}", + "description": "OAuth access key (generated in Integrations -> APIs, OAuth mode). Long-lived secret - keep out of source." + } + ] + }, + "url": { + "raw": "{{v2BaseURL}}/oauth/token", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "oauth", + "token" + ] + }, + "description": "### What it does\nExchanges your `accountNumber` + `clientSecret` for a short-lived Bearer JWT (RFC 6749 section 3.2 - `client_credentials` grant).\n\n### Request shape\n`Content-Type: application/x-www-form-urlencoded`\n\n| Field | Value |\n|---|---|\n| `grant_type` | `client_credentials` |\n| `client_id` | `{{accountNumber}}` |\n| `client_secret` | `{{clientSecret}}` |\n\n*Alternative: HTTP Basic.* Send `Authorization: Basic base64(client_id:client_secret)` and omit the body fields. RFC 6749 section 2.3.1 prefers Basic; both are accepted.\n\n### Response (200)\n```json\n{\n \"access_token\": \"<43-char JWT>\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 3600,\n \"refresh_token\": \"\"\n}\n```\n`access_token` is captured into `{{accessToken}}` and `refresh_token` into `{{refreshToken}}` - downstream requests use them automatically.\n\n### Common errors\n| HTTP | `error` | Meaning | Fix |\n|---|---|---|---|\n| 400 | `invalid_request` | `grant_type` missing or unsupported value | Use `client_credentials` or `refresh_token` |\n| 401 | `invalid_client` | Wrong `client_id` / `client_secret`, or key revoked | Regenerate the key in the portal |\n| 403 | `unauthorized_client` | Access key not generated in **OAuth mode** | Recreate the key with the OAuth radio selected |" + }, + "response": [ + { + "name": "200 OK - token issued", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "grant_type", + "value": "client_credentials", + "description": "Always client_credentials for machine-to-machine auth." + }, + { + "key": "client_id", + "value": "{{accountNumber}}", + "description": "Your account number from CERTInext portal." + }, + { + "key": "client_secret", + "value": "{{accessKey}}", + "description": "OAuth access key (generated in Integrations -> APIs, OAuth mode). Long-lived secret - keep out of source." + } + ] + }, + "url": { + "raw": "{{v2BaseURL}}/oauth/token", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "oauth", + "token" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"access_token\": \"cybsqhmJqxHPC2B3GR2YVsbuViKAjBJz753RGkAmYLU\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 3600,\n \"refresh_token\": \"f8e6d1c4a7b3e0f29d8c6b5a4f3e2d1c\"\n}" + }, + { + "name": "401 Unauthorized - invalid client credentials", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "grant_type", + "value": "client_credentials", + "description": "Always client_credentials for machine-to-machine auth." + }, + { + "key": "client_id", + "value": "{{accountNumber}}", + "description": "Your account number from CERTInext portal." + }, + { + "key": "client_secret", + "value": "{{accessKey}}", + "description": "OAuth access key (generated in Integrations -> APIs, OAuth mode). Long-lived secret - keep out of source." + } + ] + }, + "url": { + "raw": "{{v2BaseURL}}/oauth/token", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "oauth", + "token" + ] + } + }, + "status": "Unauthorized", + "code": 401, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"error\": \"invalid_client\",\n \"error_description\": \"Client authentication failed: the account number or access key is invalid (or the key was revoked in the portal).\"\n}" + }, + { + "name": "403 Forbidden - access key not in OAuth mode", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "grant_type", + "value": "client_credentials", + "description": "Always client_credentials for machine-to-machine auth." + }, + { + "key": "client_id", + "value": "{{accountNumber}}", + "description": "Your account number from CERTInext portal." + }, + { + "key": "client_secret", + "value": "{{accessKey}}", + "description": "OAuth access key (generated in Integrations -> APIs, OAuth mode). Long-lived secret - keep out of source." + } + ] + }, + "url": { + "raw": "{{v2BaseURL}}/oauth/token", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "oauth", + "token" + ] + } + }, + "status": "Forbidden", + "code": 403, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"error\": \"unauthorized_client\",\n \"error_description\": \"The access key exists but was not generated in OAuth mode in the portal. Regenerate the key with the OAuth radio button selected.\"\n}" + }, + { + "name": "400 Bad Request - missing grant_type", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "grant_type", + "value": "client_credentials", + "description": "Always client_credentials for machine-to-machine auth." + }, + { + "key": "client_id", + "value": "{{accountNumber}}", + "description": "Your account number from CERTInext portal." + }, + { + "key": "client_secret", + "value": "{{accessKey}}", + "description": "OAuth access key (generated in Integrations -> APIs, OAuth mode). Long-lived secret - keep out of source." + } + ] + }, + "url": { + "raw": "{{v2BaseURL}}/oauth/token", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "oauth", + "token" + ] + } + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"error\": \"invalid_request\",\n \"error_description\": \"grant_type is required\"\n}" + } + ] + }, + { + "name": "Refresh Token", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('200 OK', () => pm.response.to.have.status(200));", + "const body = pm.response.json();", + "if (body && body.access_token) {", + " const ttlMs = (parseInt(body.expires_in, 10) || 3600) * 1000;", + " pm.environment.set('accessToken', body.access_token);", + " pm.environment.set('refreshToken', body.refresh_token || '');", + " pm.environment.set('tokenExpiresAt', (Date.now() + ttlMs).toString());", + " pm.test('access_token refreshed', () => pm.expect(body.access_token).to.be.a('string').and.not.empty);", + " console.log('[auth] Refresh OK - new access_token stored in environment.');", + "} else {", + " pm.test('Refresh failed: ' + (body.error_description || body.error || JSON.stringify(body)), () => false);", + "}", + "" + ] + } + } + ], + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "grant_type", + "value": "refresh_token" + }, + { + "key": "client_id", + "value": "{{accountNumber}}" + }, + { + "key": "client_secret", + "value": "{{clientSecret}}" + }, + { + "key": "refresh_token", + "value": "{{refreshToken}}" + } + ] + }, + "url": { + "raw": "{{v2BaseURL}}/oauth/token", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "oauth", + "token" + ] + }, + "description": "### What it does\nRotates the previous refresh token into a new Bearer + new refresh token. Refresh tokens are **single-use** - one call invalidates the prior one.\n\n### When to call\nWhen `{{accessToken}}` is close to expiry and `{{refreshToken}}` is still valid. Saves you from re-hashing credentials.\n\n### Common errors\n- `400 invalid_grant` - refresh token expired, already used, or revoked. Call **Get Bearer Token** to re-authenticate from `clientSecret`.\n\n## Response\n\nSame shape as **Get Bearer Token** - the server returns a new pair and revokes the previous access token.\n\n| Field | Type | Notes |\n|---|---|---|\n| `access_token` | string | Opaque bearer; place in `Authorization: Bearer ` for subsequent calls. |\n| `token_type` | string | Always `Bearer`. |\n| `expires_in` | integer | Seconds until the new access token expires. |\n| `refresh_token` | string | New refresh token. The previous one is revoked - use this from now on. |\n\n**Important**: the previous `access_token` is invalidated server-side as soon as this call succeeds. Don't fire `Refresh Token` proactively if you still have time on the current token; the collection's pre-request handles this automatically.\n" + }, + "response": [ + { + "name": "200 OK - token refreshed", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "grant_type", + "value": "refresh_token" + }, + { + "key": "client_id", + "value": "{{accountNumber}}" + }, + { + "key": "client_secret", + "value": "{{accessKey}}" + }, + { + "key": "refresh_token", + "value": "{{refreshToken}}" + } + ] + }, + "url": { + "raw": "{{v2BaseURL}}/oauth/token", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "oauth", + "token" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"access_token\": \"cybsqhmJqxHPC2B3GR2YVsbuViKAjBJz753RGkAmYLU\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 3600,\n \"refresh_token\": \"f8e6d1c4a7b3e0f29d8c6b5a4f3e2d1c\"\n}" + }, + { + "name": "401 Unauthorized - invalid refresh_token", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "grant_type", + "value": "refresh_token" + }, + { + "key": "client_id", + "value": "{{accountNumber}}" + }, + { + "key": "client_secret", + "value": "{{accessKey}}" + }, + { + "key": "refresh_token", + "value": "{{refreshToken}}" + } + ] + }, + "url": { + "raw": "{{v2BaseURL}}/oauth/token", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "oauth", + "token" + ] + } + }, + "status": "Unauthorized", + "code": 401, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"error\": \"invalid_grant\",\n \"error_description\": \"Refresh token is invalid or expired.\"\n}" + } + ] + } + ], + "description": "**Start every session here.** `Get Bearer Token` exchanges `accountNumber` + `clientSecret` for a 1-hour Bearer JWT. The test script captures it into `{{accessToken}}` - every other request uses it via `Authorization: Bearer {{accessToken}}`. When the token nears expiry, run `Refresh Token` (single-use refresh, rotates automatically)." + }, + { + "name": "SSL/TLS Certificates", + "item": [ + { + "name": "Create - DV (single domain)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "if (pm.response.code === 201 && body.orderId) {", + " pm.collectionVariables.set('orderId', body.orderId);", + " pm.collectionVariables.set('requestId', body.requestId || '');", + " pm.test('SSL DV order created, orderId=' + body.orderId, () => pm.expect(body.orderId).to.be.a('string'));", + " console.log('Next: run \"Get DCV Challenges\" to fetch the domain validation artefact.');", + "} else {", + " pm.test('Create FAILED: ' + (body.detail || body.title || JSON.stringify(body)), () => false);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodeSslDv}}", + "description": "Optional override. The wrapper resolves the product code from your catalog based on productVariant + domain shape. Enable this header only to force a specific product (e.g. a non-default emSign CA profile).", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"productVariant\": \"dv\",\n \"emailNotifications\": \"all\",\n \"saveAsDraft\": false,\n \"requestor\": {\n \"name\": \"John Smith\",\n \"email\": \"john.smith@example.com\",\n \"phone\": \"+14155551234\",\n \"designation\": \"IT Administrator\"\n },\n \"delegation\": {\n \"name\": \"Delegate Smith\",\n \"email\": \"delegate@example.com\"\n },\n \"certificate\": {\n \"domain\": \"www.example.com\",\n \"autoSecureWww\": false\n },\n \"subscription\": {\n \"validityYears\": 1,\n \"autoRenew\": true,\n \"renewBeforeDays\": 30\n },\n \"csr\": \"MIICljCCAX4CAQAwUTEYMBYGA1UEAwwPd3d3LmV4YW1wbGUuY29tMQkwBwYDVQQLDAAxCTAHBgNVBAoMADEJMAcGA1UEBwwAMQkwBwYDVQQIDAAxCTAHBgNVBAYTADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALZstzgwy2x1MWGVvNv3N02IZ+JoQjF/pzpv7C3F/E0sCv+kIB47jQfnDyu7XN+9qF6cLfasv2ZVG20azzgfudXLH8RmH+VN+Y0ESv0BTEggdJkH3LQkHNL0Bop5O2fjQANaknowSeD+e35R6Oo0Oc5RNSRlSXEWKsUiu5JvByNUVWJhbyIQaNNR9H/fnfgfSEulRuPJ0r4jVCDIt0V7yBV8Gau3yIyUSJ5P6JcEgQUyEhExGXWQoRn97PU7PswEugUkU5APD9oWDHPaWD0B9OCbbmR04BTY9EbrHm4llIkFY6gPVcCBLjhU2ZsFT9BIg9thNxumn0/DQZT4+yTGxWUCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQAgcN1SFZBeIopqHxVt4F7W3Yll655M6oGlKzCQK9zYwLmhwLqPx1rFSzM1bmlXN6KRwp4Ol0Trkmm6IzwDXDwIhIDjmO/y/wYhADrEFQPs5JABPN+J8lsYulztNk5+J12pY4s1S+aZ8a+Q50Ab4yeze18Fj6bMq5vamNwfxxiHTDvZMiBadio/1vV+2rFXKsdWNKreSLtLg/z3CLe0cqH4dEexo1Lno2WVRG0bvJtVJnch+1u8EXUVwnQuRf9+RdtADbHgzQzV/ctKqj3EY3lfw4wm2/7Eu7BU2BeimvXpKdgqqknyirAnYtfbSVQ/QgAEfg6nurFanHCrj3HDJmIF\",\n \"agreement\": {\n \"signerName\": \"John Smith\",\n \"signerPlace\": \"San Francisco, CA\",\n \"accepted\": true\n },\n \"technicalPointOfContact\": {\n \"name\": \"Tech Contact\",\n \"phone\": \"+14155550199\",\n \"email\": \"tech.contact@example.com\",\n \"designation\": \"Technical Contact\"\n },\n \"recipientEmails\": [\n \"admin1@example.com\",\n \"admin2@example.com\"\n ],\n \"tags\": [\n \"department:IT\",\n \"costCenter:CC1234\"\n ],\n \"remarks\": \"DV - single hostname\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates" + ] + }, + "description": "### When to use\nSingle hostname, no organization vetting required. Fastest path to issuance for an app or static site.\n\n### Header\n- `X-Product-Code: 842` - DV SSL.\n\n### Body - key fields\n| Field | Notes |\n|---|---|\n| `productVariant` | `\"dv\"` |\n| `requestor.phone` | E.164, e.g. `+14155551234` |\n| `certificate.domain` | Primary FQDN (no wildcard) |\n| `certificate.autoSecureWww` | `true` to auto-add `www.` |\n| `subscription.validityYears` | `1` (3-year SSL retired by BR; multi-year subs renew yearly) |\n| `agreement.accepted` | Must be `true` |\n\n## Response (`201 Created`)\n\n| Field | Type | Notes |\n|---|---|---|\n| `orderId` | string | Opaque V2 order ID. Use it on every follow-up call (`/dcv`, `/csr`, `/agreement`, track, cancel, revoke, reissue). |\n| `requestId` | string | Internal request reference (useful for support tickets). |\n| `status` | enum | Initial lifecycle state, typically `pending-dcv`. See status reference below. |\n| `productVariant` | enum | Echo of the create-request variant: `dv` / `dv-wildcard` / `dv-ucc` / `dv-wildcard-ucc` / `ov` / `ov-wildcard` / `ov-ucc` / `ov-wildcard-ucc` / `ev` / `ev-ucc`. |\n| `domain` | string | Primary (CN) domain on the certificate. |\n| `additionalDomains` | string[] | SAN list. Omitted when empty. |\n| `createdAt` | RFC 3339 datetime | UTC. |\n| `resolvedProductCode` | string | The catalog product code the wrapper picked for this order (e.g. `842` for DV). Useful for billing reconciliation. |\n\n### Status values\nStatus values are stable lowercase-hyphen strings: `pending-dcv`, `pending-organization-verification`, `pending-csr`, `pending-documents`, `pending-agreement`, `pending-approval`, `issued`, `revoked`, `cancelled`, `rejected`, `expired`, `unknown`.\n\n### Next steps\n\nAfter Create, capture `orderId` and call **Get DCV Challenges** to fetch the TXT/HTTP token to publish. Follow with **Verify DCV** -> **Submit CSR** -> **Accept Agreement** to reach `issued`.\n" + }, + "response": [ + { + "name": "201 Created - order in pending-dcv", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodeSslDv}}", + "description": "842 - DV SSL Certificate." + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"productVariant\": \"dv\",\n \"emailNotifications\": \"all\",\n \"saveAsDraft\": false,\n \"requestor\": {\n \"name\": \"John Smith\",\n \"email\": \"john.smith@example.com\",\n \"phone\": \"+14155551234\",\n \"designation\": \"IT Administrator\"\n },\n \"delegation\": {\n \"name\": \"Delegate Smith\",\n \"email\": \"delegate@example.com\"\n },\n \"certificate\": {\n \"domain\": \"www.example.com\",\n \"autoSecureWww\": false\n },\n \"subscription\": {\n \"validityYears\": 1,\n \"autoRenew\": true,\n \"renewBeforeDays\": 30\n },\n \"csr\": \"MIICljCCAX4CAQAwUTEYMBYGA1UEAwwPd3d3LmV4YW1wbGUuY29tMQkwBwYDVQQLDAAxCTAHBgNVBAoMADEJMAcGA1UEBwwAMQkwBwYDVQQIDAAxCTAHBgNVBAYTADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALZstzgwy2x1MWGVvNv3N02IZ+JoQjF/pzpv7C3F/E0sCv+kIB47jQfnDyu7XN+9qF6cLfasv2ZVG20azzgfudXLH8RmH+VN+Y0ESv0BTEggdJkH3LQkHNL0Bop5O2fjQANaknowSeD+e35R6Oo0Oc5RNSRlSXEWKsUiu5JvByNUVWJhbyIQaNNR9H/fnfgfSEulRuPJ0r4jVCDIt0V7yBV8Gau3yIyUSJ5P6JcEgQUyEhExGXWQoRn97PU7PswEugUkU5APD9oWDHPaWD0B9OCbbmR04BTY9EbrHm4llIkFY6gPVcCBLjhU2ZsFT9BIg9thNxumn0/DQZT4+yTGxWUCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQAgcN1SFZBeIopqHxVt4F7W3Yll655M6oGlKzCQK9zYwLmhwLqPx1rFSzM1bmlXN6KRwp4Ol0Trkmm6IzwDXDwIhIDjmO/y/wYhADrEFQPs5JABPN+J8lsYulztNk5+J12pY4s1S+aZ8a+Q50Ab4yeze18Fj6bMq5vamNwfxxiHTDvZMiBadio/1vV+2rFXKsdWNKreSLtLg/z3CLe0cqH4dEexo1Lno2WVRG0bvJtVJnch+1u8EXUVwnQuRf9+RdtADbHgzQzV/ctKqj3EY3lfw4wm2/7Eu7BU2BeimvXpKdgqqknyirAnYtfbSVQ/QgAEfg6nurFanHCrj3HDJmIF\",\n \"agreement\": {\n \"signerName\": \"John Smith\",\n \"signerPlace\": \"San Francisco, CA\",\n \"accepted\": true\n },\n \"technicalPointOfContact\": {\n \"name\": \"Tech Contact\",\n \"phone\": \"+14155550199\",\n \"email\": \"tech.contact@example.com\",\n \"designation\": \"Technical Contact\"\n },\n \"recipientEmails\": [\n \"admin1@example.com\",\n \"admin2@example.com\"\n ],\n \"tags\": [\n \"department:IT\",\n \"costCenter:CC1234\"\n ],\n \"remarks\": \"DV - single hostname\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates" + ] + } + }, + "status": "Created", + "code": 201, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"orderId\": \"ord_8K9mQ2vR8nP4bL\",\n \"status\": \"pending-dcv\",\n \"productVariant\": \"dv\",\n \"domain\": \"example.com\",\n \"createdAt\": \"2026-05-12T13:00:00Z\",\n \"resolvedProductCode\": \"842\"}" + }, + { + "name": "422 Unprocessable - invalid product code", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodeSslDv}}", + "description": "842 - DV SSL Certificate." + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"productVariant\": \"dv\",\n \"emailNotifications\": \"all\",\n \"saveAsDraft\": false,\n \"requestor\": {\n \"name\": \"John Smith\",\n \"email\": \"john.smith@example.com\",\n \"phone\": \"+14155551234\",\n \"designation\": \"IT Administrator\"\n },\n \"delegation\": {\n \"name\": \"Delegate Smith\",\n \"email\": \"delegate@example.com\"\n },\n \"certificate\": {\n \"domain\": \"www.example.com\",\n \"autoSecureWww\": false\n },\n \"subscription\": {\n \"validityYears\": 1,\n \"autoRenew\": true,\n \"renewBeforeDays\": 30\n },\n \"csr\": \"MIICljCCAX4CAQAwUTEYMBYGA1UEAwwPd3d3LmV4YW1wbGUuY29tMQkwBwYDVQQLDAAxCTAHBgNVBAoMADEJMAcGA1UEBwwAMQkwBwYDVQQIDAAxCTAHBgNVBAYTADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALZstzgwy2x1MWGVvNv3N02IZ+JoQjF/pzpv7C3F/E0sCv+kIB47jQfnDyu7XN+9qF6cLfasv2ZVG20azzgfudXLH8RmH+VN+Y0ESv0BTEggdJkH3LQkHNL0Bop5O2fjQANaknowSeD+e35R6Oo0Oc5RNSRlSXEWKsUiu5JvByNUVWJhbyIQaNNR9H/fnfgfSEulRuPJ0r4jVCDIt0V7yBV8Gau3yIyUSJ5P6JcEgQUyEhExGXWQoRn97PU7PswEugUkU5APD9oWDHPaWD0B9OCbbmR04BTY9EbrHm4llIkFY6gPVcCBLjhU2ZsFT9BIg9thNxumn0/DQZT4+yTGxWUCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQAgcN1SFZBeIopqHxVt4F7W3Yll655M6oGlKzCQK9zYwLmhwLqPx1rFSzM1bmlXN6KRwp4Ol0Trkmm6IzwDXDwIhIDjmO/y/wYhADrEFQPs5JABPN+J8lsYulztNk5+J12pY4s1S+aZ8a+Q50Ab4yeze18Fj6bMq5vamNwfxxiHTDvZMiBadio/1vV+2rFXKsdWNKreSLtLg/z3CLe0cqH4dEexo1Lno2WVRG0bvJtVJnch+1u8EXUVwnQuRf9+RdtADbHgzQzV/ctKqj3EY3lfw4wm2/7Eu7BU2BeimvXpKdgqqknyirAnYtfbSVQ/QgAEfg6nurFanHCrj3HDJmIF\",\n \"agreement\": {\n \"signerName\": \"John Smith\",\n \"signerPlace\": \"San Francisco, CA\",\n \"accepted\": true\n },\n \"technicalPointOfContact\": {\n \"name\": \"Tech Contact\",\n \"phone\": \"+14155550199\",\n \"email\": \"tech.contact@example.com\",\n \"designation\": \"Technical Contact\"\n },\n \"recipientEmails\": [\n \"admin1@example.com\",\n \"admin2@example.com\"\n ],\n \"tags\": [\n \"department:IT\",\n \"costCenter:CC1234\"\n ],\n \"remarks\": \"DV - single hostname\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates" + ] + } + }, + "status": "Unprocessable Entity", + "code": 422, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/problem+json" + } + ], + "cookie": [], + "body": "{\n \"type\": \"about:blank\",\n \"title\": \"Unprocessable Entity\",\n \"status\": 422,\n \"detail\": \"EMS-915 \\u2014 X-Product-Code value does not match the productVariant in the body, or the product is not entitled to this account.\"\n}" + } + ] + }, + { + "name": "Create - DV Wildcard", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "if (pm.response.code === 201 && body.orderId) {", + " pm.collectionVariables.set('orderId', body.orderId);", + " pm.collectionVariables.set('requestId', body.requestId || '');", + " pm.test('SSL DV Wildcard order created, orderId=' + body.orderId, () => pm.expect(body.orderId).to.be.a('string'));", + "} else {", + " pm.test('Create FAILED: ' + (body.detail || body.title || JSON.stringify(body)), () => false);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodeSslDvWildcard}}", + "description": "Optional override. The wrapper resolves the product code from your catalog based on productVariant + domain shape. Enable this header only to force a specific product (e.g. a non-default emSign CA profile).", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"productVariant\": \"dv\",\n \"saveAsDraft\": false,\n \"requestor\": {\n \"name\": \"John Smith\",\n \"email\": \"john.smith@example.com\",\n \"phone\": \"+14155551234\",\n \"designation\": \"IT Administrator\"\n },\n \"delegation\": {\n \"name\": \"Delegate Smith\",\n \"email\": \"delegate@example.com\"\n },\n \"certificate\": {\n \"domain\": \"*.example.com\",\n \"autoSecureWww\": false\n },\n \"subscription\": {\n \"validityYears\": 1\n },\n \"csr\": \"MIICljCCAX4CAQAwUTEYMBYGA1UEAwwPd3d3LmV4YW1wbGUuY29tMQkwBwYDVQQLDAAxCTAHBgNVBAoMADEJMAcGA1UEBwwAMQkwBwYDVQQIDAAxCTAHBgNVBAYTADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALZstzgwy2x1MWGVvNv3N02IZ+JoQjF/pzpv7C3F/E0sCv+kIB47jQfnDyu7XN+9qF6cLfasv2ZVG20azzgfudXLH8RmH+VN+Y0ESv0BTEggdJkH3LQkHNL0Bop5O2fjQANaknowSeD+e35R6Oo0Oc5RNSRlSXEWKsUiu5JvByNUVWJhbyIQaNNR9H/fnfgfSEulRuPJ0r4jVCDIt0V7yBV8Gau3yIyUSJ5P6JcEgQUyEhExGXWQoRn97PU7PswEugUkU5APD9oWDHPaWD0B9OCbbmR04BTY9EbrHm4llIkFY6gPVcCBLjhU2ZsFT9BIg9thNxumn0/DQZT4+yTGxWUCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQAgcN1SFZBeIopqHxVt4F7W3Yll655M6oGlKzCQK9zYwLmhwLqPx1rFSzM1bmlXN6KRwp4Ol0Trkmm6IzwDXDwIhIDjmO/y/wYhADrEFQPs5JABPN+J8lsYulztNk5+J12pY4s1S+aZ8a+Q50Ab4yeze18Fj6bMq5vamNwfxxiHTDvZMiBadio/1vV+2rFXKsdWNKreSLtLg/z3CLe0cqH4dEexo1Lno2WVRG0bvJtVJnch+1u8EXUVwnQuRf9+RdtADbHgzQzV/ctKqj3EY3lfw4wm2/7Eu7BU2BeimvXpKdgqqknyirAnYtfbSVQ/QgAEfg6nurFanHCrj3HDJmIF\",\n \"agreement\": {\n \"signerName\": \"John Smith\",\n \"signerPlace\": \"San Francisco, CA\",\n \"accepted\": true\n },\n \"technicalPointOfContact\": {\n \"name\": \"Tech Contact\",\n \"phone\": \"+14155550199\",\n \"email\": \"tech.contact@example.com\",\n \"designation\": \"Technical Contact\"\n },\n \"recipientEmails\": [\n \"admin1@example.com\",\n \"admin2@example.com\"\n ],\n \"tags\": [\n \"department:IT\",\n \"costCenter:CC1234\"\n ],\n \"remarks\": \"DV Wildcard - *.example.com\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates" + ] + }, + "description": "### When to use\nProtect every direct sub-domain of one apex with a single cert (`api.example.com`, `app.example.com`, `cdn.example.com`).\n\n### Notes\n- Primary `domain` **must** start with `*.`\n- The cert covers one level deep only - `*.example.com` does **not** cover `api.us.example.com`.\n- The apex itself (`example.com`) is **not** covered. Add it as a SAN if needed (use the **Wildcard UCC** variant).\n- `autoSecureWww` must be `false` for wildcard primaries.\n\n### Header\n- `X-Product-Code: 843`.\n\n## Response (`201 Created`)\n\n| Field | Type | Notes |\n|---|---|---|\n| `orderId` | string | Opaque V2 order ID. Use it on every follow-up call (`/dcv`, `/csr`, `/agreement`, track, cancel, revoke, reissue). |\n| `requestId` | string | Internal request reference (useful for support tickets). |\n| `status` | enum | Initial lifecycle state, typically `pending-dcv`. See status reference below. |\n| `productVariant` | enum | Echo of the create-request variant: `dv` / `dv-wildcard` / `dv-ucc` / `dv-wildcard-ucc` / `ov` / `ov-wildcard` / `ov-ucc` / `ov-wildcard-ucc` / `ev` / `ev-ucc`. |\n| `domain` | string | Primary (CN) domain on the certificate. |\n| `additionalDomains` | string[] | SAN list. Omitted when empty. |\n| `createdAt` | RFC 3339 datetime | UTC. |\n| `resolvedProductCode` | string | The catalog product code the wrapper picked for this order (e.g. `842` for DV). Useful for billing reconciliation. |\n\n### Status values\nStatus values are stable lowercase-hyphen strings: `pending-dcv`, `pending-organization-verification`, `pending-csr`, `pending-documents`, `pending-agreement`, `pending-approval`, `issued`, `revoked`, `cancelled`, `rejected`, `expired`, `unknown`.\n\n### Next steps\n\nAfter Create, capture `orderId` and call **Get DCV Challenges** to fetch the TXT/HTTP token to publish. Follow with **Verify DCV** -> **Submit CSR** -> **Accept Agreement** to reach `issued`.\n" + }, + "response": [ + { + "name": "201 Created - DV wildcard order", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodeSslDvWildcard}}", + "description": "843 - DV Wildcard." + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"productVariant\": \"dv\",\n \"saveAsDraft\": false,\n \"requestor\": {\n \"name\": \"John Smith\",\n \"email\": \"john.smith@example.com\",\n \"phone\": \"+14155551234\",\n \"designation\": \"IT Administrator\"\n },\n \"delegation\": {\n \"name\": \"Delegate Smith\",\n \"email\": \"delegate@example.com\"\n },\n \"certificate\": {\n \"domain\": \"*.example.com\",\n \"autoSecureWww\": false\n },\n \"subscription\": {\n \"validityYears\": 1\n },\n \"csr\": \"MIICljCCAX4CAQAwUTEYMBYGA1UEAwwPd3d3LmV4YW1wbGUuY29tMQkwBwYDVQQLDAAxCTAHBgNVBAoMADEJMAcGA1UEBwwAMQkwBwYDVQQIDAAxCTAHBgNVBAYTADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALZstzgwy2x1MWGVvNv3N02IZ+JoQjF/pzpv7C3F/E0sCv+kIB47jQfnDyu7XN+9qF6cLfasv2ZVG20azzgfudXLH8RmH+VN+Y0ESv0BTEggdJkH3LQkHNL0Bop5O2fjQANaknowSeD+e35R6Oo0Oc5RNSRlSXEWKsUiu5JvByNUVWJhbyIQaNNR9H/fnfgfSEulRuPJ0r4jVCDIt0V7yBV8Gau3yIyUSJ5P6JcEgQUyEhExGXWQoRn97PU7PswEugUkU5APD9oWDHPaWD0B9OCbbmR04BTY9EbrHm4llIkFY6gPVcCBLjhU2ZsFT9BIg9thNxumn0/DQZT4+yTGxWUCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQAgcN1SFZBeIopqHxVt4F7W3Yll655M6oGlKzCQK9zYwLmhwLqPx1rFSzM1bmlXN6KRwp4Ol0Trkmm6IzwDXDwIhIDjmO/y/wYhADrEFQPs5JABPN+J8lsYulztNk5+J12pY4s1S+aZ8a+Q50Ab4yeze18Fj6bMq5vamNwfxxiHTDvZMiBadio/1vV+2rFXKsdWNKreSLtLg/z3CLe0cqH4dEexo1Lno2WVRG0bvJtVJnch+1u8EXUVwnQuRf9+RdtADbHgzQzV/ctKqj3EY3lfw4wm2/7Eu7BU2BeimvXpKdgqqknyirAnYtfbSVQ/QgAEfg6nurFanHCrj3HDJmIF\",\n \"agreement\": {\n \"signerName\": \"John Smith\",\n \"signerPlace\": \"San Francisco, CA\",\n \"accepted\": true\n },\n \"technicalPointOfContact\": {\n \"name\": \"Tech Contact\",\n \"phone\": \"+14155550199\",\n \"email\": \"tech.contact@example.com\",\n \"designation\": \"Technical Contact\"\n },\n \"recipientEmails\": [\n \"admin1@example.com\",\n \"admin2@example.com\"\n ],\n \"tags\": [\n \"department:IT\",\n \"costCenter:CC1234\"\n ],\n \"remarks\": \"DV Wildcard - *.example.com\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates" + ] + } + }, + "status": "Created", + "code": 201, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"orderId\": \"ord_8K9mQ2vR8nP4bL\",\n \"status\": \"pending-dcv\",\n \"productVariant\": \"dv\",\n \"domain\": \"*.example.com\",\n \"createdAt\": \"2026-05-12T13:00:00Z\",\n \"resolvedProductCode\": \"843\"}" + } + ] + }, + { + "name": "Create - DV UCC (multi-SAN)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "if (pm.response.code === 201 && body.orderId) {", + " pm.collectionVariables.set('orderId', body.orderId);", + " pm.collectionVariables.set('requestId', body.requestId || '');", + " pm.test('SSL DV UCC order created, orderId=' + body.orderId, () => pm.expect(body.orderId).to.be.a('string'));", + "} else {", + " pm.test('Create FAILED: ' + (body.detail || body.title || JSON.stringify(body)), () => false);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodeSslDvUcc}}", + "description": "Optional override. The wrapper resolves the product code from your catalog based on productVariant + domain shape. Enable this header only to force a specific product (e.g. a non-default emSign CA profile).", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"productVariant\": \"dv\",\n \"saveAsDraft\": false,\n \"requestor\": {\n \"name\": \"John Smith\",\n \"email\": \"john.smith@example.com\",\n \"phone\": \"+14155551234\",\n \"designation\": \"IT Administrator\"\n },\n \"delegation\": {\n \"name\": \"Delegate Smith\",\n \"email\": \"delegate@example.com\"\n },\n \"certificate\": {\n \"domain\": \"www.example.com\",\n \"autoSecureWww\": true,\n \"additionalDomains\": [\n \"shop.example.com\",\n \"portal.example.com\",\n \"example.net\"\n ]\n },\n \"subscription\": {\n \"validityYears\": 1\n },\n \"csr\": \"MIICljCCAX4CAQAwUTEYMBYGA1UEAwwPd3d3LmV4YW1wbGUuY29tMQkwBwYDVQQLDAAxCTAHBgNVBAoMADEJMAcGA1UEBwwAMQkwBwYDVQQIDAAxCTAHBgNVBAYTADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALZstzgwy2x1MWGVvNv3N02IZ+JoQjF/pzpv7C3F/E0sCv+kIB47jQfnDyu7XN+9qF6cLfasv2ZVG20azzgfudXLH8RmH+VN+Y0ESv0BTEggdJkH3LQkHNL0Bop5O2fjQANaknowSeD+e35R6Oo0Oc5RNSRlSXEWKsUiu5JvByNUVWJhbyIQaNNR9H/fnfgfSEulRuPJ0r4jVCDIt0V7yBV8Gau3yIyUSJ5P6JcEgQUyEhExGXWQoRn97PU7PswEugUkU5APD9oWDHPaWD0B9OCbbmR04BTY9EbrHm4llIkFY6gPVcCBLjhU2ZsFT9BIg9thNxumn0/DQZT4+yTGxWUCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQAgcN1SFZBeIopqHxVt4F7W3Yll655M6oGlKzCQK9zYwLmhwLqPx1rFSzM1bmlXN6KRwp4Ol0Trkmm6IzwDXDwIhIDjmO/y/wYhADrEFQPs5JABPN+J8lsYulztNk5+J12pY4s1S+aZ8a+Q50Ab4yeze18Fj6bMq5vamNwfxxiHTDvZMiBadio/1vV+2rFXKsdWNKreSLtLg/z3CLe0cqH4dEexo1Lno2WVRG0bvJtVJnch+1u8EXUVwnQuRf9+RdtADbHgzQzV/ctKqj3EY3lfw4wm2/7Eu7BU2BeimvXpKdgqqknyirAnYtfbSVQ/QgAEfg6nurFanHCrj3HDJmIF\",\n \"agreement\": {\n \"signerName\": \"John Smith\",\n \"signerPlace\": \"San Francisco, CA\",\n \"accepted\": true\n },\n \"technicalPointOfContact\": {\n \"name\": \"Tech Contact\",\n \"phone\": \"+14155550199\",\n \"email\": \"tech.contact@example.com\",\n \"designation\": \"Technical Contact\"\n },\n \"recipientEmails\": [\n \"admin1@example.com\",\n \"admin2@example.com\"\n ],\n \"tags\": [\n \"department:IT\",\n \"costCenter:CC1234\"\n ],\n \"remarks\": \"DV UCC - primary + 3 SAN domains\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates" + ] + }, + "description": "### When to use\nMulti-domain (SAN) cert covering a primary FQDN plus extra distinct names. Common for SaaS multi-tenancy and Microsoft Exchange / Skype-for-Business deployments.\n\n### Notes\n- `additionalDomains[]` is the SAN list. Each entry must be a valid FQDN; wildcards are allowed only via the **Wildcard UCC** variant.\n- Each SAN goes through its own DCV step.\n- Pricing scales with SAN count - confirm via `Catalog -> List Products` for your account's `extraSAN` rate.\n\n### Header\n- `X-Product-Code: 844`.\n\n## Response (`201 Created`)\n\n| Field | Type | Notes |\n|---|---|---|\n| `orderId` | string | Opaque V2 order ID. Use it on every follow-up call (`/dcv`, `/csr`, `/agreement`, track, cancel, revoke, reissue). |\n| `requestId` | string | Internal request reference (useful for support tickets). |\n| `status` | enum | Initial lifecycle state, typically `pending-dcv`. See status reference below. |\n| `productVariant` | enum | Echo of the create-request variant: `dv` / `dv-wildcard` / `dv-ucc` / `dv-wildcard-ucc` / `ov` / `ov-wildcard` / `ov-ucc` / `ov-wildcard-ucc` / `ev` / `ev-ucc`. |\n| `domain` | string | Primary (CN) domain on the certificate. |\n| `additionalDomains` | string[] | SAN list. Omitted when empty. |\n| `createdAt` | RFC 3339 datetime | UTC. |\n| `resolvedProductCode` | string | The catalog product code the wrapper picked for this order (e.g. `842` for DV). Useful for billing reconciliation. |\n\n### Status values\nStatus values are stable lowercase-hyphen strings: `pending-dcv`, `pending-organization-verification`, `pending-csr`, `pending-documents`, `pending-agreement`, `pending-approval`, `issued`, `revoked`, `cancelled`, `rejected`, `expired`, `unknown`.\n\n### Next steps\n\nAfter Create, capture `orderId` and call **Get DCV Challenges** to fetch the TXT/HTTP token to publish. Follow with **Verify DCV** -> **Submit CSR** -> **Accept Agreement** to reach `issued`.\n" + }, + "response": [ + { + "name": "201 Created - DV UCC order", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodeSslDvUcc}}", + "description": "844 - DV UCC / SAN." + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"productVariant\": \"dv\",\n \"saveAsDraft\": false,\n \"requestor\": {\n \"name\": \"John Smith\",\n \"email\": \"john.smith@example.com\",\n \"phone\": \"+14155551234\",\n \"designation\": \"IT Administrator\"\n },\n \"delegation\": {\n \"name\": \"Delegate Smith\",\n \"email\": \"delegate@example.com\"\n },\n \"certificate\": {\n \"domain\": \"www.example.com\",\n \"autoSecureWww\": true,\n \"additionalDomains\": [\n \"shop.example.com\",\n \"portal.example.com\",\n \"example.net\"\n ]\n },\n \"subscription\": {\n \"validityYears\": 1\n },\n \"csr\": \"MIICljCCAX4CAQAwUTEYMBYGA1UEAwwPd3d3LmV4YW1wbGUuY29tMQkwBwYDVQQLDAAxCTAHBgNVBAoMADEJMAcGA1UEBwwAMQkwBwYDVQQIDAAxCTAHBgNVBAYTADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALZstzgwy2x1MWGVvNv3N02IZ+JoQjF/pzpv7C3F/E0sCv+kIB47jQfnDyu7XN+9qF6cLfasv2ZVG20azzgfudXLH8RmH+VN+Y0ESv0BTEggdJkH3LQkHNL0Bop5O2fjQANaknowSeD+e35R6Oo0Oc5RNSRlSXEWKsUiu5JvByNUVWJhbyIQaNNR9H/fnfgfSEulRuPJ0r4jVCDIt0V7yBV8Gau3yIyUSJ5P6JcEgQUyEhExGXWQoRn97PU7PswEugUkU5APD9oWDHPaWD0B9OCbbmR04BTY9EbrHm4llIkFY6gPVcCBLjhU2ZsFT9BIg9thNxumn0/DQZT4+yTGxWUCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQAgcN1SFZBeIopqHxVt4F7W3Yll655M6oGlKzCQK9zYwLmhwLqPx1rFSzM1bmlXN6KRwp4Ol0Trkmm6IzwDXDwIhIDjmO/y/wYhADrEFQPs5JABPN+J8lsYulztNk5+J12pY4s1S+aZ8a+Q50Ab4yeze18Fj6bMq5vamNwfxxiHTDvZMiBadio/1vV+2rFXKsdWNKreSLtLg/z3CLe0cqH4dEexo1Lno2WVRG0bvJtVJnch+1u8EXUVwnQuRf9+RdtADbHgzQzV/ctKqj3EY3lfw4wm2/7Eu7BU2BeimvXpKdgqqknyirAnYtfbSVQ/QgAEfg6nurFanHCrj3HDJmIF\",\n \"agreement\": {\n \"signerName\": \"John Smith\",\n \"signerPlace\": \"San Francisco, CA\",\n \"accepted\": true\n },\n \"technicalPointOfContact\": {\n \"name\": \"Tech Contact\",\n \"phone\": \"+14155550199\",\n \"email\": \"tech.contact@example.com\",\n \"designation\": \"Technical Contact\"\n },\n \"recipientEmails\": [\n \"admin1@example.com\",\n \"admin2@example.com\"\n ],\n \"tags\": [\n \"department:IT\",\n \"costCenter:CC1234\"\n ],\n \"remarks\": \"DV UCC - primary + 3 SAN domains\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates" + ] + } + }, + "status": "Created", + "code": 201, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"orderId\": \"ord_8K9mQ2vR8nP4bL\",\n \"status\": \"pending-dcv\",\n \"productVariant\": \"dv\",\n \"domain\": \"example.com\",\n \"createdAt\": \"2026-05-12T13:00:00Z\",\n \"resolvedProductCode\": \"844\",\n \"additionalDomains\": [\n \"shop.example.com\",\n \"portal.example.com\"\n ]\n}" + } + ] + }, + { + "name": "Create - DV Wildcard UCC", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "if (pm.response.code === 201 && body.orderId) {", + " pm.collectionVariables.set('orderId', body.orderId);", + " pm.collectionVariables.set('requestId', body.requestId || '');", + " pm.test('SSL DV Wildcard UCC order created, orderId=' + body.orderId, () => pm.expect(body.orderId).to.be.a('string'));", + "} else {", + " pm.test('Create FAILED: ' + (body.detail || body.title || JSON.stringify(body)), () => false);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodeSslDvWildcardUcc}}", + "description": "Optional override. The wrapper resolves the product code from your catalog based on productVariant + domain shape. Enable this header only to force a specific product (e.g. a non-default emSign CA profile).", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"productVariant\": \"dv\",\n \"saveAsDraft\": false,\n \"requestor\": {\n \"name\": \"John Smith\",\n \"email\": \"john.smith@example.com\",\n \"phone\": \"+14155551234\",\n \"designation\": \"IT Administrator\"\n },\n \"delegation\": {\n \"name\": \"Delegate Smith\",\n \"email\": \"delegate@example.com\"\n },\n \"certificate\": {\n \"domain\": \"*.example.com\",\n \"autoSecureWww\": false,\n \"additionalDomains\": [\n \"*.example.net\",\n \"example.com\",\n \"example.net\"\n ]\n },\n \"subscription\": {\n \"validityYears\": 1\n },\n \"csr\": \"MIICljCCAX4CAQAwUTEYMBYGA1UEAwwPd3d3LmV4YW1wbGUuY29tMQkwBwYDVQQLDAAxCTAHBgNVBAoMADEJMAcGA1UEBwwAMQkwBwYDVQQIDAAxCTAHBgNVBAYTADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALZstzgwy2x1MWGVvNv3N02IZ+JoQjF/pzpv7C3F/E0sCv+kIB47jQfnDyu7XN+9qF6cLfasv2ZVG20azzgfudXLH8RmH+VN+Y0ESv0BTEggdJkH3LQkHNL0Bop5O2fjQANaknowSeD+e35R6Oo0Oc5RNSRlSXEWKsUiu5JvByNUVWJhbyIQaNNR9H/fnfgfSEulRuPJ0r4jVCDIt0V7yBV8Gau3yIyUSJ5P6JcEgQUyEhExGXWQoRn97PU7PswEugUkU5APD9oWDHPaWD0B9OCbbmR04BTY9EbrHm4llIkFY6gPVcCBLjhU2ZsFT9BIg9thNxumn0/DQZT4+yTGxWUCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQAgcN1SFZBeIopqHxVt4F7W3Yll655M6oGlKzCQK9zYwLmhwLqPx1rFSzM1bmlXN6KRwp4Ol0Trkmm6IzwDXDwIhIDjmO/y/wYhADrEFQPs5JABPN+J8lsYulztNk5+J12pY4s1S+aZ8a+Q50Ab4yeze18Fj6bMq5vamNwfxxiHTDvZMiBadio/1vV+2rFXKsdWNKreSLtLg/z3CLe0cqH4dEexo1Lno2WVRG0bvJtVJnch+1u8EXUVwnQuRf9+RdtADbHgzQzV/ctKqj3EY3lfw4wm2/7Eu7BU2BeimvXpKdgqqknyirAnYtfbSVQ/QgAEfg6nurFanHCrj3HDJmIF\",\n \"agreement\": {\n \"signerName\": \"John Smith\",\n \"signerPlace\": \"San Francisco, CA\",\n \"accepted\": true\n },\n \"technicalPointOfContact\": {\n \"name\": \"Tech Contact\",\n \"phone\": \"+14155550199\",\n \"email\": \"tech.contact@example.com\",\n \"designation\": \"Technical Contact\"\n },\n \"recipientEmails\": [\n \"admin1@example.com\",\n \"admin2@example.com\"\n ],\n \"tags\": [\n \"department:IT\",\n \"costCenter:CC1234\"\n ],\n \"remarks\": \"DV Wildcard UCC - wildcard primary + apex + extra wildcard\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates" + ] + }, + "description": "### When to use\nMulti-brand portfolios where you need wildcards across multiple apexes plus the apexes themselves on one cert.\n\n### Notes\n- Both the primary `domain` AND any entry in `additionalDomains[]` may be wildcards.\n- Mix freely: the example covers `*.example.com`, `*.example.net`, `example.com`, `example.net` in a single cert.\n- DCV runs per distinct apex (a `*.example.com` SAN doesn't re-validate `example.com`).\n\n### Header\n- `X-Product-Code: 845`.\n\n## Response (`201 Created`)\n\n| Field | Type | Notes |\n|---|---|---|\n| `orderId` | string | Opaque V2 order ID. Use it on every follow-up call (`/dcv`, `/csr`, `/agreement`, track, cancel, revoke, reissue). |\n| `requestId` | string | Internal request reference (useful for support tickets). |\n| `status` | enum | Initial lifecycle state, typically `pending-dcv`. See status reference below. |\n| `productVariant` | enum | Echo of the create-request variant: `dv` / `dv-wildcard` / `dv-ucc` / `dv-wildcard-ucc` / `ov` / `ov-wildcard` / `ov-ucc` / `ov-wildcard-ucc` / `ev` / `ev-ucc`. |\n| `domain` | string | Primary (CN) domain on the certificate. |\n| `additionalDomains` | string[] | SAN list. Omitted when empty. |\n| `createdAt` | RFC 3339 datetime | UTC. |\n| `resolvedProductCode` | string | The catalog product code the wrapper picked for this order (e.g. `842` for DV). Useful for billing reconciliation. |\n\n### Status values\nStatus values are stable lowercase-hyphen strings: `pending-dcv`, `pending-organization-verification`, `pending-csr`, `pending-documents`, `pending-agreement`, `pending-approval`, `issued`, `revoked`, `cancelled`, `rejected`, `expired`, `unknown`.\n\n### Next steps\n\nAfter Create, capture `orderId` and call **Get DCV Challenges** to fetch the TXT/HTTP token to publish. Follow with **Verify DCV** -> **Submit CSR** -> **Accept Agreement** to reach `issued`.\n" + }, + "response": [ + { + "name": "201 Created - DV Wildcard UCC order", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodeSslDvWildcardUcc}}", + "description": "845 - DV Wildcard UCC." + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"productVariant\": \"dv\",\n \"saveAsDraft\": false,\n \"requestor\": {\n \"name\": \"John Smith\",\n \"email\": \"john.smith@example.com\",\n \"phone\": \"+14155551234\",\n \"designation\": \"IT Administrator\"\n },\n \"delegation\": {\n \"name\": \"Delegate Smith\",\n \"email\": \"delegate@example.com\"\n },\n \"certificate\": {\n \"domain\": \"*.example.com\",\n \"autoSecureWww\": false,\n \"additionalDomains\": [\n \"*.example.net\",\n \"example.com\",\n \"example.net\"\n ]\n },\n \"subscription\": {\n \"validityYears\": 1\n },\n \"csr\": \"MIICljCCAX4CAQAwUTEYMBYGA1UEAwwPd3d3LmV4YW1wbGUuY29tMQkwBwYDVQQLDAAxCTAHBgNVBAoMADEJMAcGA1UEBwwAMQkwBwYDVQQIDAAxCTAHBgNVBAYTADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALZstzgwy2x1MWGVvNv3N02IZ+JoQjF/pzpv7C3F/E0sCv+kIB47jQfnDyu7XN+9qF6cLfasv2ZVG20azzgfudXLH8RmH+VN+Y0ESv0BTEggdJkH3LQkHNL0Bop5O2fjQANaknowSeD+e35R6Oo0Oc5RNSRlSXEWKsUiu5JvByNUVWJhbyIQaNNR9H/fnfgfSEulRuPJ0r4jVCDIt0V7yBV8Gau3yIyUSJ5P6JcEgQUyEhExGXWQoRn97PU7PswEugUkU5APD9oWDHPaWD0B9OCbbmR04BTY9EbrHm4llIkFY6gPVcCBLjhU2ZsFT9BIg9thNxumn0/DQZT4+yTGxWUCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQAgcN1SFZBeIopqHxVt4F7W3Yll655M6oGlKzCQK9zYwLmhwLqPx1rFSzM1bmlXN6KRwp4Ol0Trkmm6IzwDXDwIhIDjmO/y/wYhADrEFQPs5JABPN+J8lsYulztNk5+J12pY4s1S+aZ8a+Q50Ab4yeze18Fj6bMq5vamNwfxxiHTDvZMiBadio/1vV+2rFXKsdWNKreSLtLg/z3CLe0cqH4dEexo1Lno2WVRG0bvJtVJnch+1u8EXUVwnQuRf9+RdtADbHgzQzV/ctKqj3EY3lfw4wm2/7Eu7BU2BeimvXpKdgqqknyirAnYtfbSVQ/QgAEfg6nurFanHCrj3HDJmIF\",\n \"agreement\": {\n \"signerName\": \"John Smith\",\n \"signerPlace\": \"San Francisco, CA\",\n \"accepted\": true\n },\n \"technicalPointOfContact\": {\n \"name\": \"Tech Contact\",\n \"phone\": \"+14155550199\",\n \"email\": \"tech.contact@example.com\",\n \"designation\": \"Technical Contact\"\n },\n \"recipientEmails\": [\n \"admin1@example.com\",\n \"admin2@example.com\"\n ],\n \"tags\": [\n \"department:IT\",\n \"costCenter:CC1234\"\n ],\n \"remarks\": \"DV Wildcard UCC - wildcard primary + apex + extra wildcard\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates" + ] + } + }, + "status": "Created", + "code": 201, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"orderId\": \"ord_8K9mQ2vR8nP4bL\",\n \"status\": \"pending-dcv\",\n \"productVariant\": \"dv\",\n \"domain\": \"*.example.com\",\n \"createdAt\": \"2026-05-12T13:00:00Z\",\n \"resolvedProductCode\": \"845\",\n \"additionalDomains\": [\n \"*.example.net\",\n \"example.com\"\n ]\n}" + } + ] + }, + { + "name": "Create - OV (single domain, vetted org)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "if (pm.response.code === 201 && body.orderId) {", + " pm.collectionVariables.set('orderId', body.orderId);", + " pm.collectionVariables.set('requestId', body.requestId || '');", + " pm.test('SSL OV order created, orderId=' + body.orderId, () => pm.expect(body.orderId).to.be.a('string'));", + "} else {", + " pm.test('Create FAILED: ' + (body.detail || body.title || JSON.stringify(body)), () => false);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodeSslOv}}", + "description": "Optional override. The wrapper resolves the product code from your catalog based on productVariant + domain shape. Enable this header only to force a specific product (e.g. a non-default emSign CA profile).", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"productVariant\": \"ov\",\n \"emailNotifications\": \"all\",\n \"saveAsDraft\": false,\n \"groupNumber\": \"{{groupNumber}}\",\n \"requestor\": {\n \"name\": \"Jane Doe\",\n \"email\": \"jane.doe@example.com\",\n \"phone\": \"+14155551234\",\n \"designation\": \"PKI Manager\"\n },\n \"delegation\": {\n \"name\": \"Delegate Smith\",\n \"email\": \"delegate@example.com\"\n },\n \"organization\": {\n \"organizationNumber\": \"{{organizationNumber}}\",\n \"preVetted\": true,\n \"preVettingToken\": \"{{preVettingToken}}\"\n },\n \"certificate\": {\n \"domain\": \"www.example.com\",\n \"autoSecureWww\": false\n },\n \"subscription\": {\n \"validityYears\": 1,\n \"autoRenew\": true\n },\n \"csr\": \"MIICljCCAX4CAQAwUTEYMBYGA1UEAwwPd3d3LmV4YW1wbGUuY29tMQkwBwYDVQQLDAAxCTAHBgNVBAoMADEJMAcGA1UEBwwAMQkwBwYDVQQIDAAxCTAHBgNVBAYTADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALZstzgwy2x1MWGVvNv3N02IZ+JoQjF/pzpv7C3F/E0sCv+kIB47jQfnDyu7XN+9qF6cLfasv2ZVG20azzgfudXLH8RmH+VN+Y0ESv0BTEggdJkH3LQkHNL0Bop5O2fjQANaknowSeD+e35R6Oo0Oc5RNSRlSXEWKsUiu5JvByNUVWJhbyIQaNNR9H/fnfgfSEulRuPJ0r4jVCDIt0V7yBV8Gau3yIyUSJ5P6JcEgQUyEhExGXWQoRn97PU7PswEugUkU5APD9oWDHPaWD0B9OCbbmR04BTY9EbrHm4llIkFY6gPVcCBLjhU2ZsFT9BIg9thNxumn0/DQZT4+yTGxWUCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQAgcN1SFZBeIopqHxVt4F7W3Yll655M6oGlKzCQK9zYwLmhwLqPx1rFSzM1bmlXN6KRwp4Ol0Trkmm6IzwDXDwIhIDjmO/y/wYhADrEFQPs5JABPN+J8lsYulztNk5+J12pY4s1S+aZ8a+Q50Ab4yeze18Fj6bMq5vamNwfxxiHTDvZMiBadio/1vV+2rFXKsdWNKreSLtLg/z3CLe0cqH4dEexo1Lno2WVRG0bvJtVJnch+1u8EXUVwnQuRf9+RdtADbHgzQzV/ctKqj3EY3lfw4wm2/7Eu7BU2BeimvXpKdgqqknyirAnYtfbSVQ/QgAEfg6nurFanHCrj3HDJmIF\",\n \"agreement\": {\n \"signerName\": \"Jane Doe\",\n \"signerPlace\": \"New York, NY\",\n \"accepted\": true\n },\n \"technicalPointOfContact\": {\n \"name\": \"Tech Contact\",\n \"phone\": \"+14155550199\",\n \"email\": \"tech.contact@example.com\",\n \"designation\": \"Technical Contact\"\n },\n \"recipientEmails\": [\n \"admin1@example.com\",\n \"admin2@example.com\"\n ],\n \"tags\": [\n \"department:IT\",\n \"costCenter:CC1234\"\n ],\n \"remarks\": \"OV - single domain, pre-vetted organization\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates" + ] + }, + "description": "### When to use\nB2B / customer-trust pages where the cert subject must show the **vetted organization** (e.g. `O=Acme Corp, L=New York, ST=NY, C=US`). Required for many enterprise SOC2 / HIPAA / FINRA workflows.\n\n### Required additions vs DV\n- `organization.organizationNumber` - pre-vetted org from `Accounts -> List Organizations`.\n- `organization.preVetted: true` - re-uses existing vetting (no re-vet emails).\n- `groupNumber` - optional, scopes billing.\n\n### Header\n- `X-Product-Code: 846`.\n\n## Response (`201 Created`)\n\n| Field | Type | Notes |\n|---|---|---|\n| `orderId` | string | Opaque V2 order ID. Use it on every follow-up call (`/dcv`, `/csr`, `/agreement`, track, cancel, revoke, reissue). |\n| `requestId` | string | Internal request reference (useful for support tickets). |\n| `status` | enum | Initial lifecycle state, typically `pending-dcv`. See status reference below. |\n| `productVariant` | enum | Echo of the create-request variant: `dv` / `dv-wildcard` / `dv-ucc` / `dv-wildcard-ucc` / `ov` / `ov-wildcard` / `ov-ucc` / `ov-wildcard-ucc` / `ev` / `ev-ucc`. |\n| `domain` | string | Primary (CN) domain on the certificate. |\n| `additionalDomains` | string[] | SAN list. Omitted when empty. |\n| `createdAt` | RFC 3339 datetime | UTC. |\n| `resolvedProductCode` | string | The catalog product code the wrapper picked for this order (e.g. `842` for DV). Useful for billing reconciliation. |\n\n### Status values\nStatus values are stable lowercase-hyphen strings: `pending-dcv`, `pending-organization-verification`, `pending-csr`, `pending-documents`, `pending-agreement`, `pending-approval`, `issued`, `revoked`, `cancelled`, `rejected`, `expired`, `unknown`.\n\n### Next steps\n\nAfter Create, capture `orderId` and call **Get DCV Challenges** to fetch the TXT/HTTP token to publish. Follow with **Verify DCV** -> **Submit CSR** -> **Accept Agreement** to reach `issued`.\n" + }, + "response": [ + { + "name": "201 Created - OV order", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodeSslOv}}", + "description": "846 - OV SSL Certificate." + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"productVariant\": \"ov\",\n \"emailNotifications\": \"all\",\n \"saveAsDraft\": false,\n \"groupNumber\": \"{{groupNumber}}\",\n \"requestor\": {\n \"name\": \"Jane Doe\",\n \"email\": \"jane.doe@example.com\",\n \"phone\": \"+14155551234\",\n \"designation\": \"PKI Manager\"\n },\n \"delegation\": {\n \"name\": \"Delegate Smith\",\n \"email\": \"delegate@example.com\"\n },\n \"organization\": {\n \"organizationNumber\": \"{{organizationNumber}}\",\n \"preVetted\": true,\n \"preVettingToken\": \"{{preVettingToken}}\"\n },\n \"certificate\": {\n \"domain\": \"www.example.com\",\n \"autoSecureWww\": false\n },\n \"subscription\": {\n \"validityYears\": 1,\n \"autoRenew\": true\n },\n \"csr\": \"MIICljCCAX4CAQAwUTEYMBYGA1UEAwwPd3d3LmV4YW1wbGUuY29tMQkwBwYDVQQLDAAxCTAHBgNVBAoMADEJMAcGA1UEBwwAMQkwBwYDVQQIDAAxCTAHBgNVBAYTADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALZstzgwy2x1MWGVvNv3N02IZ+JoQjF/pzpv7C3F/E0sCv+kIB47jQfnDyu7XN+9qF6cLfasv2ZVG20azzgfudXLH8RmH+VN+Y0ESv0BTEggdJkH3LQkHNL0Bop5O2fjQANaknowSeD+e35R6Oo0Oc5RNSRlSXEWKsUiu5JvByNUVWJhbyIQaNNR9H/fnfgfSEulRuPJ0r4jVCDIt0V7yBV8Gau3yIyUSJ5P6JcEgQUyEhExGXWQoRn97PU7PswEugUkU5APD9oWDHPaWD0B9OCbbmR04BTY9EbrHm4llIkFY6gPVcCBLjhU2ZsFT9BIg9thNxumn0/DQZT4+yTGxWUCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQAgcN1SFZBeIopqHxVt4F7W3Yll655M6oGlKzCQK9zYwLmhwLqPx1rFSzM1bmlXN6KRwp4Ol0Trkmm6IzwDXDwIhIDjmO/y/wYhADrEFQPs5JABPN+J8lsYulztNk5+J12pY4s1S+aZ8a+Q50Ab4yeze18Fj6bMq5vamNwfxxiHTDvZMiBadio/1vV+2rFXKsdWNKreSLtLg/z3CLe0cqH4dEexo1Lno2WVRG0bvJtVJnch+1u8EXUVwnQuRf9+RdtADbHgzQzV/ctKqj3EY3lfw4wm2/7Eu7BU2BeimvXpKdgqqknyirAnYtfbSVQ/QgAEfg6nurFanHCrj3HDJmIF\",\n \"agreement\": {\n \"signerName\": \"Jane Doe\",\n \"signerPlace\": \"New York, NY\",\n \"accepted\": true\n },\n \"technicalPointOfContact\": {\n \"name\": \"Tech Contact\",\n \"phone\": \"+14155550199\",\n \"email\": \"tech.contact@example.com\",\n \"designation\": \"Technical Contact\"\n },\n \"recipientEmails\": [\n \"admin1@example.com\",\n \"admin2@example.com\"\n ],\n \"tags\": [\n \"department:IT\",\n \"costCenter:CC1234\"\n ],\n \"remarks\": \"OV - single domain, pre-vetted organization\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates" + ] + } + }, + "status": "Created", + "code": 201, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"orderId\": \"ord_8K9mQ2vR8nP4bL\",\n \"status\": \"pending-organization-verification\",\n \"productVariant\": \"ov\",\n \"domain\": \"example.com\",\n \"createdAt\": \"2026-05-12T13:00:00Z\",\n \"resolvedProductCode\": \"846\"}" + } + ] + }, + { + "name": "Create - OV Wildcard", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "if (pm.response.code === 201 && body.orderId) {", + " pm.collectionVariables.set('orderId', body.orderId);", + " pm.collectionVariables.set('requestId', body.requestId || '');", + " pm.test('SSL OV Wildcard order created, orderId=' + body.orderId, () => pm.expect(body.orderId).to.be.a('string'));", + "} else {", + " pm.test('Create FAILED: ' + (body.detail || body.title || JSON.stringify(body)), () => false);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodeSslOvWildcard}}", + "description": "Optional override. The wrapper resolves the product code from your catalog based on productVariant + domain shape. Enable this header only to force a specific product (e.g. a non-default emSign CA profile).", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"productVariant\": \"ov\",\n \"saveAsDraft\": false,\n \"groupNumber\": \"{{groupNumber}}\",\n \"requestor\": {\n \"name\": \"Jane Doe\",\n \"email\": \"jane.doe@example.com\",\n \"phone\": \"+14155551234\",\n \"designation\": \"PKI Manager\"\n },\n \"delegation\": {\n \"name\": \"Delegate Smith\",\n \"email\": \"delegate@example.com\"\n },\n \"organization\": {\n \"organizationNumber\": \"{{organizationNumber}}\",\n \"preVetted\": true,\n \"preVettingToken\": \"{{preVettingToken}}\"\n },\n \"certificate\": {\n \"domain\": \"*.example.com\",\n \"autoSecureWww\": false\n },\n \"subscription\": {\n \"validityYears\": 1\n },\n \"csr\": \"MIICljCCAX4CAQAwUTEYMBYGA1UEAwwPd3d3LmV4YW1wbGUuY29tMQkwBwYDVQQLDAAxCTAHBgNVBAoMADEJMAcGA1UEBwwAMQkwBwYDVQQIDAAxCTAHBgNVBAYTADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALZstzgwy2x1MWGVvNv3N02IZ+JoQjF/pzpv7C3F/E0sCv+kIB47jQfnDyu7XN+9qF6cLfasv2ZVG20azzgfudXLH8RmH+VN+Y0ESv0BTEggdJkH3LQkHNL0Bop5O2fjQANaknowSeD+e35R6Oo0Oc5RNSRlSXEWKsUiu5JvByNUVWJhbyIQaNNR9H/fnfgfSEulRuPJ0r4jVCDIt0V7yBV8Gau3yIyUSJ5P6JcEgQUyEhExGXWQoRn97PU7PswEugUkU5APD9oWDHPaWD0B9OCbbmR04BTY9EbrHm4llIkFY6gPVcCBLjhU2ZsFT9BIg9thNxumn0/DQZT4+yTGxWUCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQAgcN1SFZBeIopqHxVt4F7W3Yll655M6oGlKzCQK9zYwLmhwLqPx1rFSzM1bmlXN6KRwp4Ol0Trkmm6IzwDXDwIhIDjmO/y/wYhADrEFQPs5JABPN+J8lsYulztNk5+J12pY4s1S+aZ8a+Q50Ab4yeze18Fj6bMq5vamNwfxxiHTDvZMiBadio/1vV+2rFXKsdWNKreSLtLg/z3CLe0cqH4dEexo1Lno2WVRG0bvJtVJnch+1u8EXUVwnQuRf9+RdtADbHgzQzV/ctKqj3EY3lfw4wm2/7Eu7BU2BeimvXpKdgqqknyirAnYtfbSVQ/QgAEfg6nurFanHCrj3HDJmIF\",\n \"agreement\": {\n \"signerName\": \"Jane Doe\",\n \"signerPlace\": \"New York, NY\",\n \"accepted\": true\n },\n \"technicalPointOfContact\": {\n \"name\": \"Tech Contact\",\n \"phone\": \"+14155550199\",\n \"email\": \"tech.contact@example.com\",\n \"designation\": \"Technical Contact\"\n },\n \"recipientEmails\": [\n \"admin1@example.com\",\n \"admin2@example.com\"\n ],\n \"tags\": [\n \"department:IT\",\n \"costCenter:CC1234\"\n ],\n \"remarks\": \"OV Wildcard - pre-vetted organization, *.example.com\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates" + ] + }, + "description": "### When to use\nVetted-org wildcard for one apex. Same `organization` / `preVetted` requirements as OV; primary `domain` must start with `*.`.\n\n### Header\n- `X-Product-Code: 847`.\n\n## Response (`201 Created`)\n\n| Field | Type | Notes |\n|---|---|---|\n| `orderId` | string | Opaque V2 order ID. Use it on every follow-up call (`/dcv`, `/csr`, `/agreement`, track, cancel, revoke, reissue). |\n| `requestId` | string | Internal request reference (useful for support tickets). |\n| `status` | enum | Initial lifecycle state, typically `pending-dcv`. See status reference below. |\n| `productVariant` | enum | Echo of the create-request variant: `dv` / `dv-wildcard` / `dv-ucc` / `dv-wildcard-ucc` / `ov` / `ov-wildcard` / `ov-ucc` / `ov-wildcard-ucc` / `ev` / `ev-ucc`. |\n| `domain` | string | Primary (CN) domain on the certificate. |\n| `additionalDomains` | string[] | SAN list. Omitted when empty. |\n| `createdAt` | RFC 3339 datetime | UTC. |\n| `resolvedProductCode` | string | The catalog product code the wrapper picked for this order (e.g. `842` for DV). Useful for billing reconciliation. |\n\n### Status values\nStatus values are stable lowercase-hyphen strings: `pending-dcv`, `pending-organization-verification`, `pending-csr`, `pending-documents`, `pending-agreement`, `pending-approval`, `issued`, `revoked`, `cancelled`, `rejected`, `expired`, `unknown`.\n\n### Next steps\n\nAfter Create, capture `orderId` and call **Get DCV Challenges** to fetch the TXT/HTTP token to publish. Follow with **Verify DCV** -> **Submit CSR** -> **Accept Agreement** to reach `issued`.\n" + }, + "response": [ + { + "name": "201 Created - OV wildcard order", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodeSslOvWildcard}}", + "description": "847 - OV Wildcard." + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"productVariant\": \"ov\",\n \"saveAsDraft\": false,\n \"groupNumber\": \"{{groupNumber}}\",\n \"requestor\": {\n \"name\": \"Jane Doe\",\n \"email\": \"jane.doe@example.com\",\n \"phone\": \"+14155551234\",\n \"designation\": \"PKI Manager\"\n },\n \"delegation\": {\n \"name\": \"Delegate Smith\",\n \"email\": \"delegate@example.com\"\n },\n \"organization\": {\n \"organizationNumber\": \"{{organizationNumber}}\",\n \"preVetted\": true,\n \"preVettingToken\": \"{{preVettingToken}}\"\n },\n \"certificate\": {\n \"domain\": \"*.example.com\",\n \"autoSecureWww\": false\n },\n \"subscription\": {\n \"validityYears\": 1\n },\n \"csr\": \"MIICljCCAX4CAQAwUTEYMBYGA1UEAwwPd3d3LmV4YW1wbGUuY29tMQkwBwYDVQQLDAAxCTAHBgNVBAoMADEJMAcGA1UEBwwAMQkwBwYDVQQIDAAxCTAHBgNVBAYTADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALZstzgwy2x1MWGVvNv3N02IZ+JoQjF/pzpv7C3F/E0sCv+kIB47jQfnDyu7XN+9qF6cLfasv2ZVG20azzgfudXLH8RmH+VN+Y0ESv0BTEggdJkH3LQkHNL0Bop5O2fjQANaknowSeD+e35R6Oo0Oc5RNSRlSXEWKsUiu5JvByNUVWJhbyIQaNNR9H/fnfgfSEulRuPJ0r4jVCDIt0V7yBV8Gau3yIyUSJ5P6JcEgQUyEhExGXWQoRn97PU7PswEugUkU5APD9oWDHPaWD0B9OCbbmR04BTY9EbrHm4llIkFY6gPVcCBLjhU2ZsFT9BIg9thNxumn0/DQZT4+yTGxWUCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQAgcN1SFZBeIopqHxVt4F7W3Yll655M6oGlKzCQK9zYwLmhwLqPx1rFSzM1bmlXN6KRwp4Ol0Trkmm6IzwDXDwIhIDjmO/y/wYhADrEFQPs5JABPN+J8lsYulztNk5+J12pY4s1S+aZ8a+Q50Ab4yeze18Fj6bMq5vamNwfxxiHTDvZMiBadio/1vV+2rFXKsdWNKreSLtLg/z3CLe0cqH4dEexo1Lno2WVRG0bvJtVJnch+1u8EXUVwnQuRf9+RdtADbHgzQzV/ctKqj3EY3lfw4wm2/7Eu7BU2BeimvXpKdgqqknyirAnYtfbSVQ/QgAEfg6nurFanHCrj3HDJmIF\",\n \"agreement\": {\n \"signerName\": \"Jane Doe\",\n \"signerPlace\": \"New York, NY\",\n \"accepted\": true\n },\n \"technicalPointOfContact\": {\n \"name\": \"Tech Contact\",\n \"phone\": \"+14155550199\",\n \"email\": \"tech.contact@example.com\",\n \"designation\": \"Technical Contact\"\n },\n \"recipientEmails\": [\n \"admin1@example.com\",\n \"admin2@example.com\"\n ],\n \"tags\": [\n \"department:IT\",\n \"costCenter:CC1234\"\n ],\n \"remarks\": \"OV Wildcard - pre-vetted organization, *.example.com\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates" + ] + } + }, + "status": "Created", + "code": 201, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"orderId\": \"ord_8K9mQ2vR8nP4bL\",\n \"status\": \"pending-organization-verification\",\n \"productVariant\": \"ov\",\n \"domain\": \"*.example.com\",\n \"createdAt\": \"2026-05-12T13:00:00Z\",\n \"resolvedProductCode\": \"847\"}" + } + ] + }, + { + "name": "Create - OV UCC (multi-SAN)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "if (pm.response.code === 201 && body.orderId) {", + " pm.collectionVariables.set('orderId', body.orderId);", + " pm.collectionVariables.set('requestId', body.requestId || '');", + " pm.test('SSL OV UCC order created, orderId=' + body.orderId, () => pm.expect(body.orderId).to.be.a('string'));", + "} else {", + " pm.test('Create FAILED: ' + (body.detail || body.title || JSON.stringify(body)), () => false);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodeSslOvUcc}}", + "description": "Optional override. The wrapper resolves the product code from your catalog based on productVariant + domain shape. Enable this header only to force a specific product (e.g. a non-default emSign CA profile).", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"productVariant\": \"ov\",\n \"saveAsDraft\": false,\n \"groupNumber\": \"{{groupNumber}}\",\n \"requestor\": {\n \"name\": \"Jane Doe\",\n \"email\": \"jane.doe@example.com\",\n \"phone\": \"+14155551234\",\n \"designation\": \"PKI Manager\"\n },\n \"delegation\": {\n \"name\": \"Delegate Smith\",\n \"email\": \"delegate@example.com\"\n },\n \"organization\": {\n \"organizationNumber\": \"{{organizationNumber}}\",\n \"preVetted\": true,\n \"preVettingToken\": \"{{preVettingToken}}\"\n },\n \"certificate\": {\n \"domain\": \"www.example.com\",\n \"autoSecureWww\": true,\n \"additionalDomains\": [\n \"shop.example.com\",\n \"portal.example.com\",\n \"example.net\"\n ]\n },\n \"subscription\": {\n \"validityYears\": 1\n },\n \"csr\": \"MIICljCCAX4CAQAwUTEYMBYGA1UEAwwPd3d3LmV4YW1wbGUuY29tMQkwBwYDVQQLDAAxCTAHBgNVBAoMADEJMAcGA1UEBwwAMQkwBwYDVQQIDAAxCTAHBgNVBAYTADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALZstzgwy2x1MWGVvNv3N02IZ+JoQjF/pzpv7C3F/E0sCv+kIB47jQfnDyu7XN+9qF6cLfasv2ZVG20azzgfudXLH8RmH+VN+Y0ESv0BTEggdJkH3LQkHNL0Bop5O2fjQANaknowSeD+e35R6Oo0Oc5RNSRlSXEWKsUiu5JvByNUVWJhbyIQaNNR9H/fnfgfSEulRuPJ0r4jVCDIt0V7yBV8Gau3yIyUSJ5P6JcEgQUyEhExGXWQoRn97PU7PswEugUkU5APD9oWDHPaWD0B9OCbbmR04BTY9EbrHm4llIkFY6gPVcCBLjhU2ZsFT9BIg9thNxumn0/DQZT4+yTGxWUCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQAgcN1SFZBeIopqHxVt4F7W3Yll655M6oGlKzCQK9zYwLmhwLqPx1rFSzM1bmlXN6KRwp4Ol0Trkmm6IzwDXDwIhIDjmO/y/wYhADrEFQPs5JABPN+J8lsYulztNk5+J12pY4s1S+aZ8a+Q50Ab4yeze18Fj6bMq5vamNwfxxiHTDvZMiBadio/1vV+2rFXKsdWNKreSLtLg/z3CLe0cqH4dEexo1Lno2WVRG0bvJtVJnch+1u8EXUVwnQuRf9+RdtADbHgzQzV/ctKqj3EY3lfw4wm2/7Eu7BU2BeimvXpKdgqqknyirAnYtfbSVQ/QgAEfg6nurFanHCrj3HDJmIF\",\n \"agreement\": {\n \"signerName\": \"Jane Doe\",\n \"signerPlace\": \"New York, NY\",\n \"accepted\": true\n },\n \"technicalPointOfContact\": {\n \"name\": \"Tech Contact\",\n \"phone\": \"+14155550199\",\n \"email\": \"tech.contact@example.com\",\n \"designation\": \"Technical Contact\"\n },\n \"recipientEmails\": [\n \"admin1@example.com\",\n \"admin2@example.com\"\n ],\n \"tags\": [\n \"department:IT\",\n \"costCenter:CC1234\"\n ],\n \"remarks\": \"OV UCC - pre-vetted organization, multi-SAN\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates" + ] + }, + "description": "### When to use\nVetted-org multi-SAN. Use for SaaS portals, healthcare patient portals, financial-services trading dashboards where org subject is required and you have multiple distinct hostnames.\n\n### Header\n- `X-Product-Code: 848`.\n\n## Response (`201 Created`)\n\n| Field | Type | Notes |\n|---|---|---|\n| `orderId` | string | Opaque V2 order ID. Use it on every follow-up call (`/dcv`, `/csr`, `/agreement`, track, cancel, revoke, reissue). |\n| `requestId` | string | Internal request reference (useful for support tickets). |\n| `status` | enum | Initial lifecycle state, typically `pending-dcv`. See status reference below. |\n| `productVariant` | enum | Echo of the create-request variant: `dv` / `dv-wildcard` / `dv-ucc` / `dv-wildcard-ucc` / `ov` / `ov-wildcard` / `ov-ucc` / `ov-wildcard-ucc` / `ev` / `ev-ucc`. |\n| `domain` | string | Primary (CN) domain on the certificate. |\n| `additionalDomains` | string[] | SAN list. Omitted when empty. |\n| `createdAt` | RFC 3339 datetime | UTC. |\n| `resolvedProductCode` | string | The catalog product code the wrapper picked for this order (e.g. `842` for DV). Useful for billing reconciliation. |\n\n### Status values\nStatus values are stable lowercase-hyphen strings: `pending-dcv`, `pending-organization-verification`, `pending-csr`, `pending-documents`, `pending-agreement`, `pending-approval`, `issued`, `revoked`, `cancelled`, `rejected`, `expired`, `unknown`.\n\n### Next steps\n\nAfter Create, capture `orderId` and call **Get DCV Challenges** to fetch the TXT/HTTP token to publish. Follow with **Verify DCV** -> **Submit CSR** -> **Accept Agreement** to reach `issued`.\n" + }, + "response": [ + { + "name": "201 Created - OV UCC order", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodeSslOvUcc}}", + "description": "848 - OV UCC / SAN." + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"productVariant\": \"ov\",\n \"saveAsDraft\": false,\n \"groupNumber\": \"{{groupNumber}}\",\n \"requestor\": {\n \"name\": \"Jane Doe\",\n \"email\": \"jane.doe@example.com\",\n \"phone\": \"+14155551234\",\n \"designation\": \"PKI Manager\"\n },\n \"delegation\": {\n \"name\": \"Delegate Smith\",\n \"email\": \"delegate@example.com\"\n },\n \"organization\": {\n \"organizationNumber\": \"{{organizationNumber}}\",\n \"preVetted\": true,\n \"preVettingToken\": \"{{preVettingToken}}\"\n },\n \"certificate\": {\n \"domain\": \"www.example.com\",\n \"autoSecureWww\": true,\n \"additionalDomains\": [\n \"shop.example.com\",\n \"portal.example.com\",\n \"example.net\"\n ]\n },\n \"subscription\": {\n \"validityYears\": 1\n },\n \"csr\": \"MIICljCCAX4CAQAwUTEYMBYGA1UEAwwPd3d3LmV4YW1wbGUuY29tMQkwBwYDVQQLDAAxCTAHBgNVBAoMADEJMAcGA1UEBwwAMQkwBwYDVQQIDAAxCTAHBgNVBAYTADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALZstzgwy2x1MWGVvNv3N02IZ+JoQjF/pzpv7C3F/E0sCv+kIB47jQfnDyu7XN+9qF6cLfasv2ZVG20azzgfudXLH8RmH+VN+Y0ESv0BTEggdJkH3LQkHNL0Bop5O2fjQANaknowSeD+e35R6Oo0Oc5RNSRlSXEWKsUiu5JvByNUVWJhbyIQaNNR9H/fnfgfSEulRuPJ0r4jVCDIt0V7yBV8Gau3yIyUSJ5P6JcEgQUyEhExGXWQoRn97PU7PswEugUkU5APD9oWDHPaWD0B9OCbbmR04BTY9EbrHm4llIkFY6gPVcCBLjhU2ZsFT9BIg9thNxumn0/DQZT4+yTGxWUCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQAgcN1SFZBeIopqHxVt4F7W3Yll655M6oGlKzCQK9zYwLmhwLqPx1rFSzM1bmlXN6KRwp4Ol0Trkmm6IzwDXDwIhIDjmO/y/wYhADrEFQPs5JABPN+J8lsYulztNk5+J12pY4s1S+aZ8a+Q50Ab4yeze18Fj6bMq5vamNwfxxiHTDvZMiBadio/1vV+2rFXKsdWNKreSLtLg/z3CLe0cqH4dEexo1Lno2WVRG0bvJtVJnch+1u8EXUVwnQuRf9+RdtADbHgzQzV/ctKqj3EY3lfw4wm2/7Eu7BU2BeimvXpKdgqqknyirAnYtfbSVQ/QgAEfg6nurFanHCrj3HDJmIF\",\n \"agreement\": {\n \"signerName\": \"Jane Doe\",\n \"signerPlace\": \"New York, NY\",\n \"accepted\": true\n },\n \"technicalPointOfContact\": {\n \"name\": \"Tech Contact\",\n \"phone\": \"+14155550199\",\n \"email\": \"tech.contact@example.com\",\n \"designation\": \"Technical Contact\"\n },\n \"recipientEmails\": [\n \"admin1@example.com\",\n \"admin2@example.com\"\n ],\n \"tags\": [\n \"department:IT\",\n \"costCenter:CC1234\"\n ],\n \"remarks\": \"OV UCC - pre-vetted organization, multi-SAN\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates" + ] + } + }, + "status": "Created", + "code": 201, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"orderId\": \"ord_8K9mQ2vR8nP4bL\",\n \"status\": \"pending-organization-verification\",\n \"productVariant\": \"ov\",\n \"domain\": \"example.com\",\n \"createdAt\": \"2026-05-12T13:00:00Z\",\n \"resolvedProductCode\": \"848\",\n \"additionalDomains\": [\n \"shop.example.com\",\n \"portal.example.com\"\n ]\n}" + } + ] + }, + { + "name": "Create - OV Wildcard UCC", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "if (pm.response.code === 201 && body.orderId) {", + " pm.collectionVariables.set('orderId', body.orderId);", + " pm.collectionVariables.set('requestId', body.requestId || '');", + " pm.test('SSL OV Wildcard UCC order created, orderId=' + body.orderId, () => pm.expect(body.orderId).to.be.a('string'));", + "} else {", + " pm.test('Create FAILED: ' + (body.detail || body.title || JSON.stringify(body)), () => false);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodeSslOvWildcardUcc}}", + "description": "Optional override. The wrapper resolves the product code from your catalog based on productVariant + domain shape. Enable this header only to force a specific product (e.g. a non-default emSign CA profile).", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"productVariant\": \"ov\",\n \"saveAsDraft\": false,\n \"groupNumber\": \"{{groupNumber}}\",\n \"requestor\": {\n \"name\": \"Jane Doe\",\n \"email\": \"jane.doe@example.com\",\n \"phone\": \"+14155551234\",\n \"designation\": \"PKI Manager\"\n },\n \"delegation\": {\n \"name\": \"Delegate Smith\",\n \"email\": \"delegate@example.com\"\n },\n \"organization\": {\n \"organizationNumber\": \"{{organizationNumber}}\",\n \"preVetted\": true,\n \"preVettingToken\": \"{{preVettingToken}}\"\n },\n \"certificate\": {\n \"domain\": \"*.example.com\",\n \"autoSecureWww\": false,\n \"additionalDomains\": [\n \"*.example.net\",\n \"example.com\",\n \"example.net\"\n ]\n },\n \"subscription\": {\n \"validityYears\": 1\n },\n \"csr\": \"MIICljCCAX4CAQAwUTEYMBYGA1UEAwwPd3d3LmV4YW1wbGUuY29tMQkwBwYDVQQLDAAxCTAHBgNVBAoMADEJMAcGA1UEBwwAMQkwBwYDVQQIDAAxCTAHBgNVBAYTADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALZstzgwy2x1MWGVvNv3N02IZ+JoQjF/pzpv7C3F/E0sCv+kIB47jQfnDyu7XN+9qF6cLfasv2ZVG20azzgfudXLH8RmH+VN+Y0ESv0BTEggdJkH3LQkHNL0Bop5O2fjQANaknowSeD+e35R6Oo0Oc5RNSRlSXEWKsUiu5JvByNUVWJhbyIQaNNR9H/fnfgfSEulRuPJ0r4jVCDIt0V7yBV8Gau3yIyUSJ5P6JcEgQUyEhExGXWQoRn97PU7PswEugUkU5APD9oWDHPaWD0B9OCbbmR04BTY9EbrHm4llIkFY6gPVcCBLjhU2ZsFT9BIg9thNxumn0/DQZT4+yTGxWUCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQAgcN1SFZBeIopqHxVt4F7W3Yll655M6oGlKzCQK9zYwLmhwLqPx1rFSzM1bmlXN6KRwp4Ol0Trkmm6IzwDXDwIhIDjmO/y/wYhADrEFQPs5JABPN+J8lsYulztNk5+J12pY4s1S+aZ8a+Q50Ab4yeze18Fj6bMq5vamNwfxxiHTDvZMiBadio/1vV+2rFXKsdWNKreSLtLg/z3CLe0cqH4dEexo1Lno2WVRG0bvJtVJnch+1u8EXUVwnQuRf9+RdtADbHgzQzV/ctKqj3EY3lfw4wm2/7Eu7BU2BeimvXpKdgqqknyirAnYtfbSVQ/QgAEfg6nurFanHCrj3HDJmIF\",\n \"agreement\": {\n \"signerName\": \"Jane Doe\",\n \"signerPlace\": \"New York, NY\",\n \"accepted\": true\n },\n \"technicalPointOfContact\": {\n \"name\": \"Tech Contact\",\n \"phone\": \"+14155550199\",\n \"email\": \"tech.contact@example.com\",\n \"designation\": \"Technical Contact\"\n },\n \"recipientEmails\": [\n \"admin1@example.com\",\n \"admin2@example.com\"\n ],\n \"tags\": [\n \"department:IT\",\n \"costCenter:CC1234\"\n ],\n \"remarks\": \"OV Wildcard UCC - vetted org, wildcard primary + mixed SANs\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates" + ] + }, + "description": "### When to use\nVetted-org, multi-brand portfolio with wildcards. The most flexible OV variant - any combination of wildcard / apex / sub-domain across multiple apexes.\n\n### Header\n- `X-Product-Code: 849`.\n\n## Response (`201 Created`)\n\n| Field | Type | Notes |\n|---|---|---|\n| `orderId` | string | Opaque V2 order ID. Use it on every follow-up call (`/dcv`, `/csr`, `/agreement`, track, cancel, revoke, reissue). |\n| `requestId` | string | Internal request reference (useful for support tickets). |\n| `status` | enum | Initial lifecycle state, typically `pending-dcv`. See status reference below. |\n| `productVariant` | enum | Echo of the create-request variant: `dv` / `dv-wildcard` / `dv-ucc` / `dv-wildcard-ucc` / `ov` / `ov-wildcard` / `ov-ucc` / `ov-wildcard-ucc` / `ev` / `ev-ucc`. |\n| `domain` | string | Primary (CN) domain on the certificate. |\n| `additionalDomains` | string[] | SAN list. Omitted when empty. |\n| `createdAt` | RFC 3339 datetime | UTC. |\n| `resolvedProductCode` | string | The catalog product code the wrapper picked for this order (e.g. `842` for DV). Useful for billing reconciliation. |\n\n### Status values\nStatus values are stable lowercase-hyphen strings: `pending-dcv`, `pending-organization-verification`, `pending-csr`, `pending-documents`, `pending-agreement`, `pending-approval`, `issued`, `revoked`, `cancelled`, `rejected`, `expired`, `unknown`.\n\n### Next steps\n\nAfter Create, capture `orderId` and call **Get DCV Challenges** to fetch the TXT/HTTP token to publish. Follow with **Verify DCV** -> **Submit CSR** -> **Accept Agreement** to reach `issued`.\n" + }, + "response": [ + { + "name": "201 Created - OV Wildcard UCC order", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodeSslOvWildcardUcc}}", + "description": "849 - OV Wildcard UCC." + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"productVariant\": \"ov\",\n \"saveAsDraft\": false,\n \"groupNumber\": \"{{groupNumber}}\",\n \"requestor\": {\n \"name\": \"Jane Doe\",\n \"email\": \"jane.doe@example.com\",\n \"phone\": \"+14155551234\",\n \"designation\": \"PKI Manager\"\n },\n \"delegation\": {\n \"name\": \"Delegate Smith\",\n \"email\": \"delegate@example.com\"\n },\n \"organization\": {\n \"organizationNumber\": \"{{organizationNumber}}\",\n \"preVetted\": true,\n \"preVettingToken\": \"{{preVettingToken}}\"\n },\n \"certificate\": {\n \"domain\": \"*.example.com\",\n \"autoSecureWww\": false,\n \"additionalDomains\": [\n \"*.example.net\",\n \"example.com\",\n \"example.net\"\n ]\n },\n \"subscription\": {\n \"validityYears\": 1\n },\n \"csr\": \"MIICljCCAX4CAQAwUTEYMBYGA1UEAwwPd3d3LmV4YW1wbGUuY29tMQkwBwYDVQQLDAAxCTAHBgNVBAoMADEJMAcGA1UEBwwAMQkwBwYDVQQIDAAxCTAHBgNVBAYTADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALZstzgwy2x1MWGVvNv3N02IZ+JoQjF/pzpv7C3F/E0sCv+kIB47jQfnDyu7XN+9qF6cLfasv2ZVG20azzgfudXLH8RmH+VN+Y0ESv0BTEggdJkH3LQkHNL0Bop5O2fjQANaknowSeD+e35R6Oo0Oc5RNSRlSXEWKsUiu5JvByNUVWJhbyIQaNNR9H/fnfgfSEulRuPJ0r4jVCDIt0V7yBV8Gau3yIyUSJ5P6JcEgQUyEhExGXWQoRn97PU7PswEugUkU5APD9oWDHPaWD0B9OCbbmR04BTY9EbrHm4llIkFY6gPVcCBLjhU2ZsFT9BIg9thNxumn0/DQZT4+yTGxWUCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQAgcN1SFZBeIopqHxVt4F7W3Yll655M6oGlKzCQK9zYwLmhwLqPx1rFSzM1bmlXN6KRwp4Ol0Trkmm6IzwDXDwIhIDjmO/y/wYhADrEFQPs5JABPN+J8lsYulztNk5+J12pY4s1S+aZ8a+Q50Ab4yeze18Fj6bMq5vamNwfxxiHTDvZMiBadio/1vV+2rFXKsdWNKreSLtLg/z3CLe0cqH4dEexo1Lno2WVRG0bvJtVJnch+1u8EXUVwnQuRf9+RdtADbHgzQzV/ctKqj3EY3lfw4wm2/7Eu7BU2BeimvXpKdgqqknyirAnYtfbSVQ/QgAEfg6nurFanHCrj3HDJmIF\",\n \"agreement\": {\n \"signerName\": \"Jane Doe\",\n \"signerPlace\": \"New York, NY\",\n \"accepted\": true\n },\n \"technicalPointOfContact\": {\n \"name\": \"Tech Contact\",\n \"phone\": \"+14155550199\",\n \"email\": \"tech.contact@example.com\",\n \"designation\": \"Technical Contact\"\n },\n \"recipientEmails\": [\n \"admin1@example.com\",\n \"admin2@example.com\"\n ],\n \"tags\": [\n \"department:IT\",\n \"costCenter:CC1234\"\n ],\n \"remarks\": \"OV Wildcard UCC - vetted org, wildcard primary + mixed SANs\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates" + ] + } + }, + "status": "Created", + "code": 201, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"orderId\": \"ord_8K9mQ2vR8nP4bL\",\n \"status\": \"pending-organization-verification\",\n \"productVariant\": \"ov\",\n \"domain\": \"*.example.com\",\n \"createdAt\": \"2026-05-12T13:00:00Z\",\n \"resolvedProductCode\": \"849\",\n \"additionalDomains\": [\n \"*.example.net\",\n \"example.com\"\n ]\n}" + } + ] + }, + { + "name": "Create - EV (single domain)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "if (pm.response.code === 201 && body.orderId) {", + " pm.collectionVariables.set('orderId', body.orderId);", + " pm.collectionVariables.set('requestId', body.requestId || '');", + " pm.test('SSL EV order created, orderId=' + body.orderId, () => pm.expect(body.orderId).to.be.a('string'));", + "} else {", + " pm.test('Create FAILED: ' + (body.detail || body.title || JSON.stringify(body)), () => false);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodeSslEv}}", + "description": "Optional override. The wrapper resolves the product code from your catalog based on productVariant + domain shape. Enable this header only to force a specific product (e.g. a non-default emSign CA profile).", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"productVariant\": \"ev\",\n \"emailNotifications\": \"all\",\n \"saveAsDraft\": false,\n \"groupNumber\": \"{{groupNumber}}\",\n \"requestor\": {\n \"name\": \"Jane Doe\",\n \"email\": \"jane@example.com\",\n \"phone\": \"+14155551234\",\n \"designation\": \"Authorized Signer\"\n },\n \"delegation\": {\n \"name\": \"Delegate Smith\",\n \"email\": \"delegate@example.com\"\n },\n \"organization\": {\n \"organizationNumber\": \"{{organizationNumber}}\",\n \"preVetted\": true,\n \"preVettingToken\": \"{{preVettingToken}}\"\n },\n \"certificate\": {\n \"domain\": \"example.com\",\n \"autoSecureWww\": true\n },\n \"subscription\": {\n \"validityYears\": 1,\n \"autoRenew\": true\n },\n \"csr\": \"MIICljCCAX4CAQAwUTEYMBYGA1UEAwwPd3d3LmV4YW1wbGUuY29tMQkwBwYDVQQLDAAxCTAHBgNVBAoMADEJMAcGA1UEBwwAMQkwBwYDVQQIDAAxCTAHBgNVBAYTADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALZstzgwy2x1MWGVvNv3N02IZ+JoQjF/pzpv7C3F/E0sCv+kIB47jQfnDyu7XN+9qF6cLfasv2ZVG20azzgfudXLH8RmH+VN+Y0ESv0BTEggdJkH3LQkHNL0Bop5O2fjQANaknowSeD+e35R6Oo0Oc5RNSRlSXEWKsUiu5JvByNUVWJhbyIQaNNR9H/fnfgfSEulRuPJ0r4jVCDIt0V7yBV8Gau3yIyUSJ5P6JcEgQUyEhExGXWQoRn97PU7PswEugUkU5APD9oWDHPaWD0B9OCbbmR04BTY9EbrHm4llIkFY6gPVcCBLjhU2ZsFT9BIg9thNxumn0/DQZT4+yTGxWUCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQAgcN1SFZBeIopqHxVt4F7W3Yll655M6oGlKzCQK9zYwLmhwLqPx1rFSzM1bmlXN6KRwp4Ol0Trkmm6IzwDXDwIhIDjmO/y/wYhADrEFQPs5JABPN+J8lsYulztNk5+J12pY4s1S+aZ8a+Q50Ab4yeze18Fj6bMq5vamNwfxxiHTDvZMiBadio/1vV+2rFXKsdWNKreSLtLg/z3CLe0cqH4dEexo1Lno2WVRG0bvJtVJnch+1u8EXUVwnQuRf9+RdtADbHgzQzV/ctKqj3EY3lfw4wm2/7Eu7BU2BeimvXpKdgqqknyirAnYtfbSVQ/QgAEfg6nurFanHCrj3HDJmIF\",\n \"agreement\": {\n \"signerName\": \"Jane Doe\",\n \"signerPlace\": \"San Francisco\",\n \"accepted\": true\n },\n \"technicalPointOfContact\": {\n \"name\": \"Tech Contact\",\n \"phone\": \"+14155550199\",\n \"email\": \"tech.contact@example.com\",\n \"designation\": \"Technical Contact\"\n },\n \"recipientEmails\": [\n \"admin1@example.com\",\n \"admin2@example.com\"\n ],\n \"tags\": [\n \"department:IT\",\n \"costCenter:CC1234\"\n ],\n \"remarks\": \"EV - single domain, pre-vetted organization.\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates" + ] + }, + "description": "## Response (`201 Created`)\n\n| Field | Type | Notes |\n|---|---|---|\n| `orderId` | string | Opaque V2 order ID. Use it on every follow-up call (`/dcv`, `/csr`, `/agreement`, track, cancel, revoke, reissue). |\n| `requestId` | string | Internal request reference (useful for support tickets). |\n| `status` | enum | Initial lifecycle state, typically `pending-dcv`. See status reference below. |\n| `productVariant` | enum | Echo of the create-request variant: `dv` / `dv-wildcard` / `dv-ucc` / `dv-wildcard-ucc` / `ov` / `ov-wildcard` / `ov-ucc` / `ov-wildcard-ucc` / `ev` / `ev-ucc`. |\n| `domain` | string | Primary (CN) domain on the certificate. |\n| `additionalDomains` | string[] | SAN list. Omitted when empty. |\n| `createdAt` | RFC 3339 datetime | UTC. |\n| `resolvedProductCode` | string | The catalog product code the wrapper picked for this order (e.g. `842` for DV). Useful for billing reconciliation. |\n\n### Status values\nStatus values are stable lowercase-hyphen strings: `pending-dcv`, `pending-organization-verification`, `pending-csr`, `pending-documents`, `pending-agreement`, `pending-approval`, `issued`, `revoked`, `cancelled`, `rejected`, `expired`, `unknown`.\n\n### Next steps\n\nAfter Create, capture `orderId` and call **Get DCV Challenges** to fetch the TXT/HTTP token to publish. Follow with **Verify DCV** -> **Submit CSR** -> **Accept Agreement** to reach `issued`.\n" + }, + "response": [ + { + "name": "201 Created - EV order", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodeSslEv}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"productVariant\": \"ev\",\n \"emailNotifications\": \"all\",\n \"saveAsDraft\": false,\n \"groupNumber\": \"{{groupNumber}}\",\n \"requestor\": {\n \"name\": \"Jane Doe\",\n \"email\": \"jane@example.com\",\n \"phone\": \"+14155551234\",\n \"designation\": \"Authorized Signer\"\n },\n \"delegation\": {\n \"name\": \"Delegate Smith\",\n \"email\": \"delegate@example.com\"\n },\n \"organization\": {\n \"organizationNumber\": \"{{organizationNumber}}\",\n \"preVetted\": true,\n \"preVettingToken\": \"{{preVettingToken}}\"\n },\n \"certificate\": {\n \"domain\": \"example.com\",\n \"autoSecureWww\": true\n },\n \"subscription\": {\n \"validityYears\": 1,\n \"autoRenew\": true\n },\n \"csr\": \"MIICljCCAX4CAQAwUTEYMBYGA1UEAwwPd3d3LmV4YW1wbGUuY29tMQkwBwYDVQQLDAAxCTAHBgNVBAoMADEJMAcGA1UEBwwAMQkwBwYDVQQIDAAxCTAHBgNVBAYTADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALZstzgwy2x1MWGVvNv3N02IZ+JoQjF/pzpv7C3F/E0sCv+kIB47jQfnDyu7XN+9qF6cLfasv2ZVG20azzgfudXLH8RmH+VN+Y0ESv0BTEggdJkH3LQkHNL0Bop5O2fjQANaknowSeD+e35R6Oo0Oc5RNSRlSXEWKsUiu5JvByNUVWJhbyIQaNNR9H/fnfgfSEulRuPJ0r4jVCDIt0V7yBV8Gau3yIyUSJ5P6JcEgQUyEhExGXWQoRn97PU7PswEugUkU5APD9oWDHPaWD0B9OCbbmR04BTY9EbrHm4llIkFY6gPVcCBLjhU2ZsFT9BIg9thNxumn0/DQZT4+yTGxWUCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQAgcN1SFZBeIopqHxVt4F7W3Yll655M6oGlKzCQK9zYwLmhwLqPx1rFSzM1bmlXN6KRwp4Ol0Trkmm6IzwDXDwIhIDjmO/y/wYhADrEFQPs5JABPN+J8lsYulztNk5+J12pY4s1S+aZ8a+Q50Ab4yeze18Fj6bMq5vamNwfxxiHTDvZMiBadio/1vV+2rFXKsdWNKreSLtLg/z3CLe0cqH4dEexo1Lno2WVRG0bvJtVJnch+1u8EXUVwnQuRf9+RdtADbHgzQzV/ctKqj3EY3lfw4wm2/7Eu7BU2BeimvXpKdgqqknyirAnYtfbSVQ/QgAEfg6nurFanHCrj3HDJmIF\",\n \"agreement\": {\n \"signerName\": \"Jane Doe\",\n \"signerPlace\": \"San Francisco\",\n \"accepted\": true\n },\n \"technicalPointOfContact\": {\n \"name\": \"Tech Contact\",\n \"phone\": \"+14155550199\",\n \"email\": \"tech.contact@example.com\",\n \"designation\": \"Technical Contact\"\n },\n \"recipientEmails\": [\n \"admin1@example.com\",\n \"admin2@example.com\"\n ],\n \"tags\": [\n \"department:IT\",\n \"costCenter:CC1234\"\n ],\n \"remarks\": \"EV - single domain, pre-vetted organization.\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates" + ] + } + }, + "status": "Created", + "code": 201, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"orderId\": \"ord_8K9mQ2vR8nP4bL\",\n \"status\": \"pending-organization-verification\",\n \"productVariant\": \"ev\",\n \"domain\": \"example.com\",\n \"createdAt\": \"2026-05-12T13:00:00Z\",\n \"resolvedProductCode\": \"850\"}" + } + ] + }, + { + "name": "Create - EV UCC (multi-SAN)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "if (pm.response.code === 201 && body.orderId) {", + " pm.collectionVariables.set('orderId', body.orderId);", + " pm.collectionVariables.set('requestId', body.requestId || '');", + " pm.test('SSL EV order created, orderId=' + body.orderId, () => pm.expect(body.orderId).to.be.a('string'));", + "} else {", + " pm.test('Create FAILED: ' + (body.detail || body.title || JSON.stringify(body)), () => false);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodeSslEvUcc}}", + "description": "Optional override. The wrapper resolves the product code from your catalog based on productVariant + domain shape. Enable this header only to force a specific product (e.g. a non-default emSign CA profile).", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"productVariant\": \"ev\",\n \"emailNotifications\": \"all\",\n \"saveAsDraft\": false,\n \"groupNumber\": \"{{groupNumber}}\",\n \"requestor\": {\n \"name\": \"Jane Doe\",\n \"email\": \"jane@example.com\",\n \"phone\": \"+14155551234\"\n },\n \"delegation\": {\n \"name\": \"Delegate Smith\",\n \"email\": \"delegate@example.com\"\n },\n \"organization\": {\n \"organizationNumber\": \"{{organizationNumber}}\",\n \"preVetted\": true,\n \"preVettingToken\": \"{{preVettingToken}}\"\n },\n \"certificate\": {\n \"domain\": \"example.com\",\n \"autoSecureWww\": true,\n \"additionalDomains\": [\n \"shop.example.com\",\n \"portal.example.com\",\n \"example.net\"\n ]\n },\n \"subscription\": {\n \"validityYears\": 1\n },\n \"csr\": \"MIICljCCAX4CAQAwUTEYMBYGA1UEAwwPd3d3LmV4YW1wbGUuY29tMQkwBwYDVQQLDAAxCTAHBgNVBAoMADEJMAcGA1UEBwwAMQkwBwYDVQQIDAAxCTAHBgNVBAYTADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALZstzgwy2x1MWGVvNv3N02IZ+JoQjF/pzpv7C3F/E0sCv+kIB47jQfnDyu7XN+9qF6cLfasv2ZVG20azzgfudXLH8RmH+VN+Y0ESv0BTEggdJkH3LQkHNL0Bop5O2fjQANaknowSeD+e35R6Oo0Oc5RNSRlSXEWKsUiu5JvByNUVWJhbyIQaNNR9H/fnfgfSEulRuPJ0r4jVCDIt0V7yBV8Gau3yIyUSJ5P6JcEgQUyEhExGXWQoRn97PU7PswEugUkU5APD9oWDHPaWD0B9OCbbmR04BTY9EbrHm4llIkFY6gPVcCBLjhU2ZsFT9BIg9thNxumn0/DQZT4+yTGxWUCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQAgcN1SFZBeIopqHxVt4F7W3Yll655M6oGlKzCQK9zYwLmhwLqPx1rFSzM1bmlXN6KRwp4Ol0Trkmm6IzwDXDwIhIDjmO/y/wYhADrEFQPs5JABPN+J8lsYulztNk5+J12pY4s1S+aZ8a+Q50Ab4yeze18Fj6bMq5vamNwfxxiHTDvZMiBadio/1vV+2rFXKsdWNKreSLtLg/z3CLe0cqH4dEexo1Lno2WVRG0bvJtVJnch+1u8EXUVwnQuRf9+RdtADbHgzQzV/ctKqj3EY3lfw4wm2/7Eu7BU2BeimvXpKdgqqknyirAnYtfbSVQ/QgAEfg6nurFanHCrj3HDJmIF\",\n \"agreement\": {\n \"signerName\": \"Jane Doe\",\n \"signerPlace\": \"San Francisco\",\n \"accepted\": true\n },\n \"technicalPointOfContact\": {\n \"name\": \"Tech Contact\",\n \"phone\": \"+14155550199\",\n \"email\": \"tech.contact@example.com\",\n \"designation\": \"Technical Contact\"\n },\n \"recipientEmails\": [\n \"admin1@example.com\",\n \"admin2@example.com\"\n ],\n \"tags\": [\n \"department:IT\",\n \"costCenter:CC1234\"\n ],\n \"remarks\": \"EV UCC - pre-vetted organization, multi-SAN. EV wildcards are forbidden by CA/B Forum - use OV Wildcard if a wildcard is required.\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates" + ] + }, + "description": "## Response (`201 Created`)\n\n| Field | Type | Notes |\n|---|---|---|\n| `orderId` | string | Opaque V2 order ID. Use it on every follow-up call (`/dcv`, `/csr`, `/agreement`, track, cancel, revoke, reissue). |\n| `requestId` | string | Internal request reference (useful for support tickets). |\n| `status` | enum | Initial lifecycle state, typically `pending-dcv`. See status reference below. |\n| `productVariant` | enum | Echo of the create-request variant: `dv` / `dv-wildcard` / `dv-ucc` / `dv-wildcard-ucc` / `ov` / `ov-wildcard` / `ov-ucc` / `ov-wildcard-ucc` / `ev` / `ev-ucc`. |\n| `domain` | string | Primary (CN) domain on the certificate. |\n| `additionalDomains` | string[] | SAN list. Omitted when empty. |\n| `createdAt` | RFC 3339 datetime | UTC. |\n| `resolvedProductCode` | string | The catalog product code the wrapper picked for this order (e.g. `842` for DV). Useful for billing reconciliation. |\n\n### Status values\nStatus values are stable lowercase-hyphen strings: `pending-dcv`, `pending-organization-verification`, `pending-csr`, `pending-documents`, `pending-agreement`, `pending-approval`, `issued`, `revoked`, `cancelled`, `rejected`, `expired`, `unknown`.\n\n### Next steps\n\nAfter Create, capture `orderId` and call **Get DCV Challenges** to fetch the TXT/HTTP token to publish. Follow with **Verify DCV** -> **Submit CSR** -> **Accept Agreement** to reach `issued`.\n" + }, + "response": [ + { + "name": "201 Created - EV UCC order", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodeSslEvUcc}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"productVariant\": \"ev\",\n \"emailNotifications\": \"all\",\n \"saveAsDraft\": false,\n \"groupNumber\": \"{{groupNumber}}\",\n \"requestor\": {\n \"name\": \"Jane Doe\",\n \"email\": \"jane@example.com\",\n \"phone\": \"+14155551234\"\n },\n \"delegation\": {\n \"name\": \"Delegate Smith\",\n \"email\": \"delegate@example.com\"\n },\n \"organization\": {\n \"organizationNumber\": \"{{organizationNumber}}\",\n \"preVetted\": true,\n \"preVettingToken\": \"{{preVettingToken}}\"\n },\n \"certificate\": {\n \"domain\": \"example.com\",\n \"autoSecureWww\": true,\n \"additionalDomains\": [\n \"shop.example.com\",\n \"portal.example.com\",\n \"example.net\"\n ]\n },\n \"subscription\": {\n \"validityYears\": 1\n },\n \"csr\": \"MIICljCCAX4CAQAwUTEYMBYGA1UEAwwPd3d3LmV4YW1wbGUuY29tMQkwBwYDVQQLDAAxCTAHBgNVBAoMADEJMAcGA1UEBwwAMQkwBwYDVQQIDAAxCTAHBgNVBAYTADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALZstzgwy2x1MWGVvNv3N02IZ+JoQjF/pzpv7C3F/E0sCv+kIB47jQfnDyu7XN+9qF6cLfasv2ZVG20azzgfudXLH8RmH+VN+Y0ESv0BTEggdJkH3LQkHNL0Bop5O2fjQANaknowSeD+e35R6Oo0Oc5RNSRlSXEWKsUiu5JvByNUVWJhbyIQaNNR9H/fnfgfSEulRuPJ0r4jVCDIt0V7yBV8Gau3yIyUSJ5P6JcEgQUyEhExGXWQoRn97PU7PswEugUkU5APD9oWDHPaWD0B9OCbbmR04BTY9EbrHm4llIkFY6gPVcCBLjhU2ZsFT9BIg9thNxumn0/DQZT4+yTGxWUCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQAgcN1SFZBeIopqHxVt4F7W3Yll655M6oGlKzCQK9zYwLmhwLqPx1rFSzM1bmlXN6KRwp4Ol0Trkmm6IzwDXDwIhIDjmO/y/wYhADrEFQPs5JABPN+J8lsYulztNk5+J12pY4s1S+aZ8a+Q50Ab4yeze18Fj6bMq5vamNwfxxiHTDvZMiBadio/1vV+2rFXKsdWNKreSLtLg/z3CLe0cqH4dEexo1Lno2WVRG0bvJtVJnch+1u8EXUVwnQuRf9+RdtADbHgzQzV/ctKqj3EY3lfw4wm2/7Eu7BU2BeimvXpKdgqqknyirAnYtfbSVQ/QgAEfg6nurFanHCrj3HDJmIF\",\n \"agreement\": {\n \"signerName\": \"Jane Doe\",\n \"signerPlace\": \"San Francisco\",\n \"accepted\": true\n },\n \"technicalPointOfContact\": {\n \"name\": \"Tech Contact\",\n \"phone\": \"+14155550199\",\n \"email\": \"tech.contact@example.com\",\n \"designation\": \"Technical Contact\"\n },\n \"recipientEmails\": [\n \"admin1@example.com\",\n \"admin2@example.com\"\n ],\n \"tags\": [\n \"department:IT\",\n \"costCenter:CC1234\"\n ],\n \"remarks\": \"EV UCC - pre-vetted organization, multi-SAN. EV wildcards are forbidden by CA/B Forum - use OV Wildcard if a wildcard is required.\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates" + ] + } + }, + "status": "Created", + "code": 201, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"orderId\": \"ord_8K9mQ2vR8nP4bL\",\n \"status\": \"pending-organization-verification\",\n \"productVariant\": \"ev\",\n \"domain\": \"example.com\",\n \"createdAt\": \"2026-05-12T13:00:00Z\",\n \"resolvedProductCode\": \"851\",\n \"additionalDomains\": [\n \"shop.example.com\",\n \"portal.example.com\"\n ]\n}" + } + ] + }, + { + "name": "Track Order", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/{{orderId}}", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + "{{orderId}}" + ] + }, + "description": "### What it does\nReturns the current state of the SSL order - `status`, `domain`, `requestId`, and lifecycle timestamps.\n\n### Status sequence\n`pending-dcv` -> `pending-csr` -> `pending-agreement` -> `pending-approval` -> `issued` (or `cancelled` / `revoked` / `expired`)\n\nPoll between lifecycle steps. Returns **404** if the order doesn't exist under your account.\n\n## Response\n\nSame shape as **Create**, plus the lifecycle and supporting blocks filled in as the order progresses.\n\n### Header fields\n\n| Field | Type | Notes |\n|---|---|---|\n| `orderId` | string | Always. |\n| `requestId` | string | Always. |\n| `status` | enum | Typed lifecycle state. Walks `pending-dcv` -> `pending-csr` -> `pending-agreement` -> `pending-approval` -> `issued` (or `cancelled` / `revoked` / `expired`). |\n| `orderState` | string | Human-readable order status from the legacy pipeline, e.g. `Order Accepted`, `Approved by System`. Complement to the typed `status`. |\n| `certificateState` | string | Human-readable certificate / request status, e.g. `Pending for Approver`, `Certificate Generated`. |\n| `productVariant` | enum | Echo of the create-request variant. |\n| `domain` | string | Primary (CN) domain. |\n| `additionalDomains` | string[] | SAN list. Omitted when empty. |\n| `interimDvIssued` | boolean | OV/EV only: `true` once the DV interim cert has been issued while OV/EV vetting is still in progress. |\n| `createdAt` | RFC 3339 datetime | When the order was placed. |\n| `issuedAt` | RFC 3339 datetime | When the certificate was issued; omitted before issuance. |\n| `expiresAt` | RFC 3339 datetime | Certificate `notAfter`. Populated once issued; remains populated after revocation / expiry. |\n\n### `requestor`\n\nThe person who placed the order; useful for support tickets and audit. PII fields are decrypted from storage.\n\n| Field | Type | Notes |\n|---|---|---|\n| `name` | string | Full name. |\n| `email` | string | Decrypted from storage. |\n| `phone` | string | E.164 normalised (`+`). |\n| `designation` | string | Job title / role. |\n\n### `csrSubmitted`\n\nBoolean. `true` once the integrator has POSTed a CSR via **Submit CSR**. The order can't progress to issuance until this flips.\n\n### `subscription`\n\nBilling entitlement window. Distinct from the certificate's own `notBefore` / `notAfter`: a single subscription\ncan host successive certificates (e.g. a re-issued cert during the subscription period).\n\n| Field | Type | Notes |\n|---|---|---|\n| `validityYears` | integer | Subscription length in years (typically `1`/`2`/`3`). |\n| `endDate` | RFC 3339 datetime | When the subscription entitlement expires. |\n| `status` | string | `active`, `expired`, or `cancelled`. Omitted when the underlying status is unknown or not yet classified. |\n\n### `subscriberAgreement`\n\nWhether the requestor has accepted the Subscriber Agreement. Returned for product families that require an SA.\n\n| Field | Type | Notes |\n|---|---|---|\n| `signed` | boolean | `true` once accepted. |\n| `signerName` | string | Acceptor's name; omitted until signed. |\n| `signedAt` | RFC 3339 datetime | Acceptance timestamp; omitted until signed. |\n| `signedPlace` | string | Acceptor's location; omitted until signed. |\n\n### `revocation` (populated only when `status = revoked`)\n\n| Field | Type | Notes |\n|---|---|---|\n| `status` | string | Human-readable revocation engine status (e.g. `Revoked`). |\n| `reason` | enum | RFC 5280 reason name: `key-compromise`, `affiliation-changed`, `superseded`, `cessation-of-operation`, `certificate-hold`, `privilege-withdrawn`, `unspecified`, etc. |\n| `processedAt` | RFC 3339 datetime | Effective revocation time (CA-recorded). |\n\n### `verifications`\n\nPer-verification-type state. Sub-blocks appear only when the product requires that verification type\nFor Private PKI this object is typically omitted entirely.\n\n#### `verifications.domain` (SSL/TLS)\n\n| Field | Type | Notes |\n|---|---|---|\n| `status` | enum | Overall DCV state across all domains on the order: `PENDING` / `VERIFIED` / `REJECTED`. |\n| `domains[]` | array | One entry per domain on the order (primary + SANs). |\n|   `.domain` | string | FQDN being validated. |\n|   `.domainStatus` | enum | `ACTIVE` / `INACTIVE` / `EXPIRED`. |\n|   `.dcvMethod` | enum | `dns-txt` / `http-url`. |\n|   `.dcvStatus` | enum | DCV state for this domain: `PENDING` / `VERIFIED` / `REJECTED`. |\n|   `.verifiedAt` | RFC 3339 datetime | When this domain was last successfully validated. |\n|   `.caaStatus` | enum | CAA pre-check: `PASSED` / `FAILED` / `SKIPPED`. |\n\nFor per-attempt MPIC diagnostics (consensus, DNSSEC, per-perspective resolver results), call the dedicated\n**Domains -> Last DCV Attempt** / **DCV Attempt History** endpoints.\n\n#### `verifications.organization` (OV / EV)\n\n| Field | Type | Notes |\n|---|---|---|\n| `status` | enum | `PENDING` / `VERIFIED` / `REJECTED`. |\n| `organizationName` | string | Display name as recorded on the order. |\n| `consentStatus` | `\"0\"` \\| `\"1\"` \\| `\"2\"` | Email-consent state when the OV flow requires consent from the organization's primary representative. Omitted when not applicable. |\n| `consentSentTo` | string | Email address consent was sent to (when `consentStatus` is present). |\n\nFor the full organization record (address, representatives, pre-vetted domains) call\n**Accounts -> Get Organization**.\n\n#### `verifications.email` (S/MIME)\n\n| Field | Type | Notes |\n|---|---|---|\n| `status` | enum | `PENDING` / `VERIFIED` / `REJECTED`. |\n| `email` | string | Email address being validated (decrypted from storage). |\n| `verifiedAt` | RFC 3339 datetime | When the email control challenge was last redeemed. |\n\n#### `verifications.individual` (personal certificates)\n\n| Field | Type | Notes |\n|---|---|---|\n| `status` | enum | `PENDING` / `VERIFIED` / `REJECTED`. |\n| `subjectName` | string | Full name as captured at identity check. |\n| `verifiedAt` | RFC 3339 datetime | When identity was last successfully verified. |\n\nPoll Track Order every 30 - 60 seconds while the order is in a `pending-*` state. Stop polling once `status`\nreaches a terminal state (`issued`, `cancelled`, `revoked`, `rejected`, `expired`).\n" + }, + "response": [ + { + "name": "200 OK - issued state", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/{{orderId}}", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + "{{orderId}}" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"orderId\": \"ord_8K9mQ2vR8nP4bL\",\n \"status\": \"issued\",\n \"productVariant\": \"dv\",\n \"domain\": \"example.com\",\n \"createdAt\": \"2026-05-12T13:00:00Z\",\n \"issuedAt\": \"2026-05-12T13:30:00Z\",\n \"expiresAt\": \"2027-05-12T13:30:00Z\"}" + }, + { + "name": "404 Not Found", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/{{orderId}}", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + "{{orderId}}" + ] + } + }, + "status": "Not Found", + "code": 404, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/problem+json" + } + ], + "cookie": [], + "body": "{\n \"type\": \"about:blank\",\n \"title\": \"Not Found\",\n \"status\": 404,\n \"detail\": \"Order ord_unknown not found under this account.\"\n}" + } + ] + }, + { + "name": "Get DCV Challenges", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/:orderId/dcv", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + ":orderId", + "dcv" + ], + "query": [ + { + "key": "domain", + "value": "", + "description": "Optional - defaults to the order's primary domain.", + "disabled": true + }, + { + "key": "method", + "value": "dns-txt", + "description": "Optional - defaults to dns-txt. Values: dns-txt | http-url | email.", + "disabled": true + } + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + }, + "description": "### What it does\nReturns the DCV challenge artefact (TXT record value, HTTP file path + content, or email recipient list) for the chosen method.\n\n### Query parameters (both optional)\n- `domain` - defaults to the order's primary domain (the one you supplied at create time).\n- `method` - defaults to `dns-txt`. Values: `dns-txt` | `http-url`.\n\n### Response\n```json\n{\n \"method\": \"dns-txt\",\n \"txtToken\": \"emudhra-dcv-\"\n}\n```\nFor http-url: `fileName` + `fileToken` instead of `txtToken`.\n\n### Next\n-> Publish the artefact (TXT record / file / reply to email), then call **Verify DCV**." + }, + "response": [ + { + "name": "200 OK - dns-txt challenge", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/:orderId/dcv", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + ":orderId", + "dcv" + ], + "query": [ + { + "key": "domain", + "value": "", + "description": "Optional - defaults to the order's primary domain.", + "disabled": true + }, + { + "key": "method", + "value": "dns-txt", + "description": "Optional - defaults to dns-txt. Values: dns-txt | http-url | email.", + "disabled": true + } + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"orderNumber\": \"ord_8K9mQ2vR8nP4bL\",\n \"domainName\": \"example.com\",\n \"dcvMethod\": \"2\",\n \"fileNameContent\": \"emudhra-dcv-7f3a8b9d2c1e4f5a6b7c8d9e0f1a2b3c\",\n \"tokenExpiryDate\": \"2026-05-14 13:00:00\"\n}" + }, + { + "name": "200 OK - http-url challenge", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/:orderId/dcv", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + ":orderId", + "dcv" + ], + "query": [ + { + "key": "domain", + "value": "", + "description": "Optional - defaults to the order's primary domain.", + "disabled": true + }, + { + "key": "method", + "value": "dns-txt", + "description": "Optional - defaults to dns-txt. Values: dns-txt | http-url | email.", + "disabled": true + } + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"orderNumber\": \"ord_8K9mQ2vR8nP4bL\",\n \"domainName\": \"example.com\",\n \"dcvMethod\": \"1\",\n \"dvFileToken\": \"7f3a8b9d2c1e4f5a6b7c8d9e0f1a2b3c\",\n \"dvFileName\": \"CHALLENGE.txt\",\n \"tokenExpiryDate\": \"2026-05-14 13:00:00\"\n}" + } + ] + }, + { + "name": "Verify DCV", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"domain\": \"www.example.com\",\n \"method\": \"dns-txt\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/:orderId/dcv/verify", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + ":orderId", + "dcv", + "verify" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + }, + "description": "### What it does\nAsks the CA to re-check the DCV artefact you published.\n\n### Body\n- `domain` - same domain as on the order.\n- `method` - `dns-txt` | `http-url` | `email` (whichever you actually published).\n\n### Response\n- **204** - DCV passed; order advances to `pending-csr`.\n- **422 EMS-1080** - domain already verified (no-op).\n- **422 EMS-1160 / 1161** - challenge not found / mismatch. Check DNS propagation (use `dig +short TXT _emudhra-challenge.example.com`) or HTTP path." + }, + "response": [ + { + "name": "200 OK - verified", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Idempotency-Key", + "value": "{{idempotencyKey}}", + "description": "Client-supplied opaque value (UUID v4 recommended) for safe retries. Parsed today; enforced in a future release.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"domain\": \"www.example.com\",\n \"method\": \"dns-txt\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/:orderId/dcv/verify", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + ":orderId", + "dcv", + "verify" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"overallStatus\": \"VERIFIED\",\n \"method\": \"dns-txt\",\n \"verifiedAt\": \"2026-05-08T13:10:00Z\",\n \"diagnostics\": {\n \"schemaVersion\": 1,\n \"method\": \"dns-txt\",\n \"overallStatus\": \"VERIFIED\",\n \"consensus\": {\n \"perspectiveCount\": 4,\n \"quorumCount\": 4,\n \"agreed\": true\n },\n \"perspectives\": [\n {\n \"resolverIp\": \"1.1.1.1\",\n \"region\": \"us-east\",\n \"status\": \"OK\"\n },\n {\n \"resolverIp\": \"8.8.8.8\",\n \"region\": \"us-west\",\n \"status\": \"OK\"\n }\n ]\n }\n}" + }, + { + "name": "422 Unprocessable - record missing", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Idempotency-Key", + "value": "{{idempotencyKey}}", + "description": "Client-supplied opaque value (UUID v4 recommended) for safe retries. Parsed today; enforced in a future release.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"domain\": \"www.example.com\",\n \"method\": \"dns-txt\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/:orderId/dcv/verify", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + ":orderId", + "dcv", + "verify" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "Unprocessable Entity", + "code": 422, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/problem+json" + } + ], + "cookie": [], + "body": "{\n \"type\": \"about:blank\",\n \"title\": \"Unprocessable Entity\",\n \"status\": 422,\n \"detail\": \"DCV verification failed. See diagnostics for per-resolver details.\",\n \"diagnostics\": {\n \"schemaVersion\": 1,\n \"method\": \"dns-txt\",\n \"overallStatus\": \"FAILED\",\n \"failureClass\": \"RECORD_MISSING\",\n \"consensus\": {\n \"perspectiveCount\": 4,\n \"quorumCount\": 0,\n \"agreed\": false\n },\n \"perspectives\": [\n {\n \"resolverIp\": \"1.1.1.1\",\n \"region\": \"us-east\",\n \"status\": \"FAILED\",\n \"failureClass\": \"RECORD_MISSING\"\n }\n ],\n \"nextSteps\": [\n {\n \"key\": \"dcv.nextStep.txt.recordMissing\",\n \"params\": {\n \"recordName\": \"_emudhra-challenge.example.com\",\n \"recordValue\": \"emudhra-dcv-...\"\n },\n \"severity\": \"ERROR\"\n }\n ]\n }\n}" + }, + { + "name": "204 No Content - verification succeeded", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"domain\": \"www.example.com\",\n \"method\": \"dns-txt\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/:orderId/dcv/verify", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + ":orderId", + "dcv", + "verify" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "No Content", + "code": 204, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "" + } + ] + }, + { + "name": "Submit CSR", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"csr\": \"-----BEGIN CERTIFICATE REQUEST-----\\n\\n-----END CERTIFICATE REQUEST-----\",\n \"attested\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/:orderId/csr", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + ":orderId", + "csr" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + }, + "description": "### What it does\nAttaches a PEM-encoded CSR to the order.\n\n### Generating the CSR\n```bash\nopenssl req -new -newkey rsa:2048 -nodes \\\n -keyout server.key -out server.csr \\\n -subj \"/CN=www.example.com/O=Acme Corp/L=New York/ST=NY/C=US\"\n```\nFor wildcard / UCC: include only the primary domain in `CN`. SANs are taken from the order, not the CSR.\n\n### Body\n- `csr` - full PEM with `-----BEGIN/END CERTIFICATE REQUEST-----` markers. Newlines must be escaped (`\\n`) in JSON.\n- `attested` - `true` only when the key was generated by an attested HSM. Defaults to `false`.\n\n### Response\n- **204** - CSR accepted.\n- **422 EMS-921** - malformed CSR (re-generate from scratch, don't reuse).\n- **422 EMS-922** - CSR subject CN doesn't match order's primary domain." + }, + "response": [ + { + "name": "204 No Content - CSR accepted", + "originalRequest": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"csr\": \"-----BEGIN CERTIFICATE REQUEST-----\\n\\n-----END CERTIFICATE REQUEST-----\",\n \"attested\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/:orderId/csr", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + ":orderId", + "csr" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "No Content", + "code": 204, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "" + }, + { + "name": "422 Unprocessable - invalid CSR", + "originalRequest": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"csr\": \"-----BEGIN CERTIFICATE REQUEST-----\\n\\n-----END CERTIFICATE REQUEST-----\",\n \"attested\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/:orderId/csr", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + ":orderId", + "csr" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "Unprocessable Entity", + "code": 422, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/problem+json" + } + ], + "cookie": [], + "body": "{\n \"type\": \"about:blank\",\n \"title\": \"Unprocessable Entity\",\n \"status\": 422,\n \"detail\": \"EMS-917 \\u2014 CSR is invalid or the embedded common name does not match the order's primary domain.\"\n}" + } + ] + }, + { + "name": "Accept Agreement", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"agreement\": {\n \"signerName\": \"John Smith\",\n \"signerPlace\": \"San Francisco, CA\",\n \"accepted\": true\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/:orderId/agreement", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + ":orderId", + "agreement" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + }, + "description": "### What it does\nRecords Subscriber Agreement acceptance - required by the CA/Browser Forum BR section 1.3 for every public-trust SSL.\n\n### Body\n- `signerName` - printed name of the signing officer.\n- `signerIp` - caller's IP at time of signing (for audit log).\n- `signerPlace` - physical location, e.g. `San Francisco, CA`.\n- `accepted` - must be `true`.\n\n### Response\n- **204** - recorded; CA proceeds to issuance.\n\n### Next\n-> Poll **Track Order** until `status=issued`, then **Download Certificate**." + }, + "response": [ + { + "name": "204 No Content - agreement recorded", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"agreement\": {\n \"signerName\": \"John Smith\",\n \"signerPlace\": \"San Francisco, CA\",\n \"accepted\": true\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/:orderId/agreement", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + ":orderId", + "agreement" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "No Content", + "code": 204, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "" + }, + { + "name": "422 Unprocessable - wrong state", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"agreement\": {\n \"signerName\": \"John Smith\",\n \"signerPlace\": \"San Francisco, CA\",\n \"accepted\": true\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/:orderId/agreement", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + ":orderId", + "agreement" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "Unprocessable Entity", + "code": 422, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/problem+json" + } + ], + "cookie": [], + "body": "{\n \"type\": \"about:blank\",\n \"title\": \"Unprocessable Entity\",\n \"status\": 422,\n \"detail\": \"Order is not in pending-agreement state; cannot accept Subscriber Agreement at this stage.\"\n}" + } + ] + }, + { + "name": "Download Certificate (PEM)", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/x-pem-file" + } + ], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/:orderId/certificate", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + ":orderId", + "certificate" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + }, + "description": "### What it does\nReturns the issued certificate. Choose format via the `Accept` header **or** the `?format=` query parameter (the query param wins when both are sent - handy for tools that can't override headers).\n\n| Accept | `?format=` | Body |\n|---|---|---|\n| `application/json` (default) | _omitted_ | JSON envelope: PEM + serial + subject + issuer + validity |\n| `application/x-pem-file` | `pem` | Plain PEM text |\n| `application/pkix-cert` | `der` or `pkcs7` | DER bytes (binary) |\n\n### Response (JSON)\n```json\n{\n \"orderId\": \"...\",\n \"serialNumber\": \"0A1B2C3D...\",\n \"subject\": \"CN=www.example.com,O=Acme Corp,L=New York,ST=NY,C=US\",\n \"issuer\": \"CN=CERTInext TLS Intermediate, O=eMudhra, C=US\",\n \"notBefore\": \"2026-05-08T10:00:00Z\",\n \"notAfter\": \"2027-05-08T10:00:00Z\",\n \"certificatePem\": \"-----BEGIN CERTIFICATE-----...\"\n}\n```\n\n### When it fails\n**422 EMS-1165** - order not yet `issued`. Poll **Track Order** first." + }, + "response": [ + { + "name": "200 OK - PEM body", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/x-pem-file" + } + ], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/:orderId/certificate", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + ":orderId", + "certificate" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "text", + "header": [ + { + "key": "Content-Type", + "value": "application/x-pem-file" + } + ], + "cookie": [], + "body": "-----BEGIN CERTIFICATE-----\nMIIFx... (leaf, truncated)...AQ==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIEv... (intermediate, truncated)...AQ==\n-----END CERTIFICATE-----\n" + }, + { + "name": "422 Unprocessable - certificate not yet issued", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/x-pem-file" + } + ], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/:orderId/certificate", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + ":orderId", + "certificate" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "Unprocessable Entity", + "code": 422, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/problem+json" + } + ], + "cookie": [], + "body": "{\n \"type\": \"about:blank\",\n \"title\": \"Unprocessable Entity\",\n \"status\": 422,\n \"detail\": \"Certificate is not yet issued for this order. Current status: pending-csr.\"\n}" + } + ] + }, + { + "name": "Download Certificate (JSON)", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/:orderId/certificate", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + ":orderId", + "certificate" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + }, + "description": "## Response (`Accept: application/json`)\n\n| Field | Type | Notes |\n|---|---|---|\n| `orderId` | string | Echo of the path parameter. |\n| `serialNumber` | string | Certificate serial in hex (CA-issued). |\n| `subject` | string | Subject DN, e.g. `CN=example.com,O=Acme,C=US`. |\n| `issuer` | string | Issuer DN. |\n| `notBefore` | RFC 3339 datetime | UTC. |\n| `notAfter` | RFC 3339 datetime | UTC. |\n| `certificatePem` | string | PEM-encoded leaf certificate (`-----BEGIN CERTIFICATE-----...`). |\n| `chainPem` | string[] | Intermediate + root certificates as PEM, in order from issuing CA down to trust anchor. Concatenate with `certificatePem` to build the full chain bundle. |\n\nReturns `409 Conflict` (`type: order-not-issued`) until the order reaches `status = issued`. Returns `404` for revoked/expired orders past the retention window.\n" + }, + "response": [ + { + "name": "200 OK - JSON envelope", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/:orderId/certificate", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + ":orderId", + "certificate" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"orderId\": \"ord_8K9mQ2vR8nP4bL\",\n \"serialNumber\": \"0E:1B:7F:2A:3C:4D:5E:6F:7A:8B:9C:0D:1E:2F:3A:4B\",\n \"subject\": \"CN=example.com\",\n \"issuer\": \"CN=emSign US RSA OV CA G2, O=eMudhra Inc., C=US\",\n \"notBefore\": \"2026-05-12T13:30:00Z\",\n \"notAfter\": \"2027-05-12T13:30:00Z\",\n \"certificatePem\": \"-----BEGIN CERTIFICATE-----\\nMIIFx... (truncated)...AQ==\\n-----END CERTIFICATE-----\",\n \"chainPem\": [\n \"-----BEGIN CERTIFICATE-----\\nMIIEv... (intermediate, truncated)...AQ==\\n-----END CERTIFICATE-----\",\n \"-----BEGIN CERTIFICATE-----\\nMIIDr... (root, truncated)...AQ==\\n-----END CERTIFICATE-----\"\n ]\n}" + } + ] + }, + { + "name": "Download Certificate (DER)", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/pkix-cert" + } + ], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/:orderId/certificate", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + ":orderId", + "certificate" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + }, + "description": "## Response (`Accept: application/pkix-cert`)\n\nBinary DER-encoded X.509 certificate. `Content-Type: application/pkix-cert`. The DER body **does not include the intermediate chain** - fetch the JSON or PEM variant if you need it.\n\nSame `409` / `404` semantics as the JSON variant.\n" + }, + "response": [ + { + "name": "200 OK - DER binary (base64-encoded preview)", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/pkix-cert" + } + ], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/:orderId/certificate", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + ":orderId", + "certificate" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "text", + "header": [ + { + "key": "Content-Type", + "value": "application/pkix-cert" + } + ], + "cookie": [], + "body": "" + } + ] + }, + { + "name": "Cancel Order", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{ \"reason\": \"Project cancelled - domain no longer required.\" }", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/:orderId/cancel", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + ":orderId", + "cancel" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + }, + "description": "### When to use\nWithdraw an order **before** it's been issued. Once issued, use **Revoke Certificate**.\n\n### Body\n- `reason` - required free-text. Persisted in the audit log .\n\n### Response\n- **204** - cancelled; visible via Track Order with `status=cancelled`.\n- **422** - order already in a terminal state." + }, + "response": [ + { + "name": "204 No Content - order cancelled", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{ \"reason\": \"Project cancelled - domain no longer required.\" }", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/:orderId/cancel", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + ":orderId", + "cancel" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "No Content", + "code": 204, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "" + }, + { + "name": "422 Unprocessable - already issued", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{ \"reason\": \"Project cancelled - domain no longer required.\" }", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/:orderId/cancel", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + ":orderId", + "cancel" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "Unprocessable Entity", + "code": 422, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/problem+json" + } + ], + "cookie": [], + "body": "{\n \"type\": \"about:blank\",\n \"title\": \"Unprocessable Entity\",\n \"status\": 422,\n \"detail\": \"Order is already issued; use POST /{orderId}/revoke instead of /cancel.\"\n}" + } + ] + }, + { + "name": "Reject Draft Request", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{ \"reason\": \"Draft no longer needed.\" }", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/requests/{{requestId}}/cancel", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + "requests", + "{{requestId}}", + "cancel" + ] + }, + "description": "### When to use\nOnly for draft requests (created with `saveAsDraft: true`) that returned a `requestId` but **no** `orderId`. After a draft promotes to a real order, use **Cancel Order** with the `orderId`.\n\n## Response\n\nReturns `204 No Content` on success. No response body.\n\nUse this when the order is still in a draft/pending state and you no longer want it. After the order has been forwarded to the CA (status past `pending-csr`), use **Cancel Order** instead.\n" + }, + "response": [ + { + "name": "204 No Content - draft rejected", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{ \"reason\": \"Draft no longer needed.\" }", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/requests/{{requestId}}/cancel", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + "requests", + "{{requestId}}", + "cancel" + ] + } + }, + "status": "No Content", + "code": 204, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "" + } + ] + }, + { + "name": "Revoke Certificate", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"reason\": \"key-compromise\",\n \"note\": \"Private key exposed in a CI log on 2026-05-07.\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/:orderId/revoke", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + ":orderId", + "revoke" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + }, + "description": "### When to use\nPermanently revoke an **issued** certificate. Reflected in CRL / OCSP at the next publication cycle (typically within 4 hours).\n\n### `reason` values (RFC 5280)\n`unspecified`, `key-compromise`, `ca-compromise`, `affiliation-changed`, `superseded`, `cessation-of-operation`, `certificate-hold`, `privilege-withdrawn`\n\nFor key-compromise revocations, BR section 4.9.1.1 mandates 24-hour CRL turnaround.\n\n### Body\n- `reason` - required.\n- `note` - optional free-text for the audit log.\n\n### Response\n- **204** - revocation queued.\n- **422** - certificate not yet issued or already revoked." + }, + "response": [ + { + "name": "204 No Content - revocation queued", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"reason\": \"key-compromise\",\n \"note\": \"Private key exposed in a CI log on 2026-05-07.\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/:orderId/revoke", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + ":orderId", + "revoke" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "No Content", + "code": 204, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "" + }, + { + "name": "404 Not Found", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"reason\": \"key-compromise\",\n \"note\": \"Private key exposed in a CI log on 2026-05-07.\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/:orderId/revoke", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + ":orderId", + "revoke" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "Not Found", + "code": 404, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/problem+json" + } + ], + "cookie": [], + "body": "{\n \"type\": \"about:blank\",\n \"title\": \"Not Found\",\n \"status\": 404,\n \"detail\": \"Order not found or not in a revokable state.\"\n}" + } + ] + }, + { + "name": "Reissue Certificate", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"mode\": \"rekey\",\n \"reason\": \"Rotating private key after staff offboarding.\",\n \"csr\": \"-----BEGIN CERTIFICATE REQUEST-----\\n\\n-----END CERTIFICATE REQUEST-----\",\n \"revokePrevious\": true,\n \"revokeReason\": \"superseded\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/:orderId/reissue", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + ":orderId", + "reissue" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + }, + "description": "### What it does\nReissues an **already-issued** SSL/TLS cert without creating a new order. The `orderId` stays the same; the cert behind it changes.\n\n### Modes\n- **`rekey`** - supply a new CSR; existing cert replaced. Use after key rotation, HSM migration, or algorithm/key-size upgrades.\n- **`update-sans`** - keep the existing key, add SAN domains via `additionalDomains[]`.\n\nOptionally revoke the previous certificate as part of the same operation by setting `revokePrevious: true` and `revokeReason`.\n\n### Body - rekey example\n```json\n{\n \"mode\": \"rekey\",\n \"reason\": \"Rotating private key after staff offboarding.\",\n \"csr\": \"-----BEGIN CERTIFICATE REQUEST-----...\",\n \"revokePrevious\": true,\n \"revokeReason\": \"superseded\"\n}\n```\n\n### Body - update-sans example\n```json\n{\n \"mode\": \"update-sans\",\n \"reason\": \"Adding new sub-domains for the Q3 launch.\",\n \"additionalDomains\": [\"api.example.com\", \"app.example.com\"]\n}\n```\n\n### Response (201)\n`Location` header points at the original order; `requestId` points at the new reissue request.\n\n### Common errors (422)\n- `EMS-940` malformed CSR\n- `EMS-941` reason missing\n- `EMS-1063` SAN limit exceeded (per the product's reissue SAN cap)\n- Order not in a reissue-able state (cancelled / revoked)" + }, + "response": [ + { + "name": "201 Created - rekey requested", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"mode\": \"rekey\",\n \"reason\": \"Rotating private key after staff offboarding.\",\n \"csr\": \"-----BEGIN CERTIFICATE REQUEST-----\\n\\n-----END CERTIFICATE REQUEST-----\",\n \"revokePrevious\": true,\n \"revokeReason\": \"superseded\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/:orderId/reissue", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + ":orderId", + "reissue" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "Created", + "code": 201, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"orderId\": \"ord_8K9mQ2vR8nP4bL\",\n \"requestId\": \"req_xR2pL5qN9mT3bK\",\n \"status\": \"pending-csr\",\n \"mode\": \"rekey\",\n \"requestedAt\": \"2026-05-12T15:00:00Z\"}" + }, + { + "name": "422 Unprocessable - rekey requires CSR", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"mode\": \"rekey\",\n \"reason\": \"Rotating private key after staff offboarding.\",\n \"csr\": \"-----BEGIN CERTIFICATE REQUEST-----\\n\\n-----END CERTIFICATE REQUEST-----\",\n \"revokePrevious\": true,\n \"revokeReason\": \"superseded\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/ssl-certificates/:orderId/reissue", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "ssl-certificates", + ":orderId", + "reissue" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "Unprocessable Entity", + "code": 422, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/problem+json" + } + ], + "cookie": [], + "body": "{\n \"type\": \"about:blank\",\n \"title\": \"Unprocessable Entity\",\n \"status\": 422,\n \"detail\": \"Reissue mode=rekey requires a CSR. Add `csr` to the request body or use mode=update-sans for SAN additions.\"\n}" + } + ] + } + ], + "description": "Public-trust SSL/TLS issuance - fully **CA/Browser Forum Baseline Requirements** compliant. Ten product variants, full lifecycle (DCV -> CSR -> Agreement -> Issuance -> Reissue/Revoke).\n\n**Variant guide for enterprises:**\n- **DV** - quick, single hostname (e.g. `app.example.com`). Best for static sites, internal tools.\n- **DV Wildcard** - single cert protecting `*.example.com` (any sub of one apex). Saves cost; doesn't cover the apex unless added as SAN.\n- **DV UCC** - multi-SAN, multiple distinct domains. Up to 250 SANs typical. Used for SaaS multi-tenant deployments.\n- **DV Wildcard UCC** - wildcard primary + extra SANs (`*.example.com` + `example.com` + `*.example.net`). Common for multi-brand portfolios.\n- **OV** / **OV Wildcard** / **OV UCC** / **OV Wildcard UCC** - same shapes as DV but include vetted organization details in the cert subject. Required for FINRA/HIPAA/PCI footprints, healthcare portals, and customer-trust pages.\n- **EV** / **EV UCC** - Extended Validation; strictest organization vetting, single hostname or multi-SAN. For high-assurance brand and customer-trust pages.\n\nPick **one** create variant, run it, then proceed through DCV -> CSR -> Agreement -> Download.\n\n---\n\n### Field requirements (in body order)\n\n| Field | Mandatory / Optional |\n|---|---|\n| `productVariant` | **Mandatory** |\n| `emailNotifications` | Optional (default `all`) |\n| `saveAsDraft` | Optional (default `false`) |\n| `requestId` | Conditional - only when promoting a `saveAsDraft` draft |\n| `groupNumber` | Optional |\n| `requestor` | **Mandatory** |\n| `requestor.name` | **Mandatory** |\n| `requestor.email` | **Mandatory** |\n| `requestor.phone` | Optional |\n| `requestor.designation` | Optional |\n| `delegation` | Optional |\n| `delegation.name` | Optional |\n| `delegation.email` | Optional |\n| `organization` | Conditional - **Mandatory for OV / EV** |\n| `organization.organizationNumber` | Conditional - required if `organization` sent |\n| `organization.preVetted` | Optional |\n| `organization.preVettingToken` | Optional |\n| `certificate` | **Mandatory** |\n| `certificate.domain` | **Mandatory** |\n| `certificate.additionalDomains` | Conditional - **Mandatory for UCC**, omit otherwise |\n| `certificate.autoSecureWww` | Optional (default `true`) |\n| `subscription` | Optional |\n| `subscription.validityYears` | Optional (default `1`) |\n| `subscription.autoRenew` | Optional (default `true` / ON) |\n| `subscription.renewBeforeDays` | Optional (default `30`) |\n| `agreement` | Optional - required before issuance |\n| `agreement.signerName` | Conditional - required if `agreement` sent |\n| `agreement.signerPlace` | Conditional - required if `agreement` sent |\n| `agreement.accepted` | Conditional - must be `true` if `agreement` sent |\n| `csr` | Optional - required before issuance |\n| `technicalPointOfContact` | Optional |\n| `technicalPointOfContact.name` | Optional |\n| `technicalPointOfContact.email` | Optional |\n| `technicalPointOfContact.phone` | Optional |\n| `technicalPointOfContact.designation` | Optional |\n| `recipientEmails` | Optional |\n| `tags` | Optional |\n| `customFields` | Conditional - only if product mandates |\n| `customFields[].fieldId` | Conditional - required if `customFields` sent |\n| `customFields[].value` | Conditional - required if `customFields` sent |\n| `remarks` | Optional |\n\n**Strictly mandatory** (400 if missing): `productVariant`, `requestor.name`, `requestor.email`, `certificate.domain`.\n\n> **Watch out:** omitting `subscription` does **not** disable auto-renew - it defaults to ON (1-year, 30-day window). To turn it off, send `subscription.autoRenew: false`." + }, + { + "name": "Document Signer Certificates", + "item": [ + { + "name": "Create - Natural Person", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "if (pm.response.code === 201 && body.orderId) {", + " pm.collectionVariables.set('orderId', body.orderId);", + " pm.collectionVariables.set('requestId', body.requestId || '');", + " pm.test('Doc Signer NP order created, orderId=' + body.orderId, () => pm.expect(body.orderId).to.be.a('string'));", + "} else {", + " pm.test('Create FAILED: ' + (body.detail || body.title || JSON.stringify(body)), () => false);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodeDocSignerNp1Y}}", + "description": "Optional override. The wrapper resolves the product code from your catalog based on subjectType + subscription.validityYears. Enable only to force a specific product.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"subjectType\": \"natural-person\",\n \"emailNotifications\": \"all\",\n \"requestor\": {\n \"name\": \"Sarah Johnson\",\n \"email\": \"sarah.johnson@example.com\",\n \"phone\": \"+12025551234\",\n \"designation\": \"Document Signer\"\n },\n \"subject\": {\n \"firstName\": \"Sarah\",\n \"lastName\": \"Johnson\",\n \"email\": \"sarah.johnson@example.com\",\n \"phone\": \"+12025551234\",\n \"identityDocumentType\": \"passport\",\n \"identificationNumber\": \"X12345678\",\n \"streetAddress1\": \"1600 Pennsylvania Avenue NW\",\n \"locality\": \"Washington\",\n \"state\": \"DC\",\n \"postalCode\": \"20500\",\n \"countryCode\": \"US\"\n },\n \"subscription\": { \"validityYears\": 1, \"autoRenew\": false },\n \"agreement\": {\n \"signerName\": \"Sarah Johnson\",\n \"signerPlace\": \"Washington, DC\",\n \"accepted\": true\n },\n \"remarks\": \"Document Signer - Natural Person, US\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/signature-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "signature-certificates" + ] + }, + "description": "### When to use\nIndividual signer - the cert subject is a single human (`CN=Sarah Johnson, C=US`). Used for personal PDF signatures, notarization, attorney workflows.\n\n### Required `subject` fields\n- `firstName`, `lastName`, `email`, `phone` (E.164)\n- `identityDocumentType` - `passport` | `driving-license` | `national-id` (US: state-issued ID)\n- `identificationNumber` - document number\n- `streetAddress1`, `locality`, `state`, `postalCode`, `countryCode` (US)\n\n### Header\n- `X-Product-Code: 819` (1Y), `820` (2Y), `821` (3Y).\n\n### Next\n-> **Upload Documents** (identity proof + address proof PDFs/images)\n\n## Response (`201 Created`)\n\n| Field | Type | Notes |\n|---|---|---|\n| `orderId` | string | Opaque V2 order ID. Use it on every follow-up call. |\n| `requestId` | string | Internal request reference (useful for support tickets). |\n| `status` | enum | Initial lifecycle state. See status reference below. |\n| `subjectType` | enum | Echo of the request: `natural-person` / `legal-person` / `legal-entity`. |\n| `subjectDisplayName` | string | Human-readable subject: full name for natural / legal person, organization name for legal entity. |\n| `createdAt` | RFC 3339 datetime | UTC. |\n| `resolvedProductCode` | string | Catalog product code the wrapper picked (e.g. `819` for natural-person 1Y NR). |\n\n### Status values\nStatus values are stable lowercase-hyphen strings: `pending-dcv`, `pending-organization-verification`, `pending-csr`, `pending-documents`, `pending-agreement`, `pending-approval`, `issued`, `revoked`, `cancelled`, `rejected`, `expired`, `unknown`.\n" + }, + "response": [ + { + "name": "201 Created - Natural Person order", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodeDocSignerNp1Y}}", + "description": "819 - Natural Person, 1 year." + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"subjectType\": \"natural-person\",\n \"emailNotifications\": \"all\",\n \"requestor\": {\n \"name\": \"Sarah Johnson\",\n \"email\": \"sarah.johnson@example.com\",\n \"phone\": \"+12025551234\",\n \"designation\": \"Document Signer\"\n },\n \"subject\": {\n \"firstName\": \"Sarah\",\n \"lastName\": \"Johnson\",\n \"email\": \"sarah.johnson@example.com\",\n \"phone\": \"+12025551234\",\n \"identityDocumentType\": \"passport\",\n \"identificationNumber\": \"X12345678\",\n \"streetAddress1\": \"1600 Pennsylvania Avenue NW\",\n \"locality\": \"Washington\",\n \"state\": \"DC\",\n \"postalCode\": \"20500\",\n \"countryCode\": \"US\"\n },\n \"subscription\": { \"validityYears\": 1, \"autoRenew\": false },\n \"agreement\": {\n \"signerName\": \"Sarah Johnson\",\n \"signerPlace\": \"Washington, DC\",\n \"accepted\": true\n },\n \"remarks\": \"Document Signer - Natural Person, US\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/signature-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "signature-certificates" + ] + } + }, + "status": "Created", + "code": 201, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"orderId\": \"ord_signer_8K9mQ2vR8nP4bL\",\n \"status\": \"pending-documents\",\n \"subjectType\": \"natural-person\",\n \"subjectDisplayName\": \"Sarah Johnson\",\n \"createdAt\": \"2026-05-12T13:00:00Z\",\n \"resolvedProductCode\": \"819\"}" + }, + { + "name": "422 Unprocessable - required field missing", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodeDocSignerNp1Y}}", + "description": "819 - Natural Person, 1 year." + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"subjectType\": \"natural-person\",\n \"emailNotifications\": \"all\",\n \"requestor\": {\n \"name\": \"Sarah Johnson\",\n \"email\": \"sarah.johnson@example.com\",\n \"phone\": \"+12025551234\",\n \"designation\": \"Document Signer\"\n },\n \"subject\": {\n \"firstName\": \"Sarah\",\n \"lastName\": \"Johnson\",\n \"email\": \"sarah.johnson@example.com\",\n \"phone\": \"+12025551234\",\n \"identityDocumentType\": \"passport\",\n \"identificationNumber\": \"X12345678\",\n \"streetAddress1\": \"1600 Pennsylvania Avenue NW\",\n \"locality\": \"Washington\",\n \"state\": \"DC\",\n \"postalCode\": \"20500\",\n \"countryCode\": \"US\"\n },\n \"subscription\": { \"validityYears\": 1, \"autoRenew\": false },\n \"agreement\": {\n \"signerName\": \"Sarah Johnson\",\n \"signerPlace\": \"Washington, DC\",\n \"accepted\": true\n },\n \"remarks\": \"Document Signer - Natural Person, US\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/signature-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "signature-certificates" + ] + } + }, + "status": "Unprocessable Entity", + "code": 422, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/problem+json" + } + ], + "cookie": [], + "body": "{\n \"type\": \"about:blank\",\n \"title\": \"Unprocessable Entity\",\n \"status\": 422,\n \"detail\": \"EMS-916 \\u2014 subject.email is required for natural-person.\"\n}" + } + ] + }, + { + "name": "Create - Legal Person", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "if (pm.response.code === 201 && body.orderId) {", + " pm.collectionVariables.set('orderId', body.orderId);", + " pm.collectionVariables.set('requestId', body.requestId || '');", + " pm.test('Doc Signer LP order created, orderId=' + body.orderId, () => pm.expect(body.orderId).to.be.a('string'));", + "} else {", + " pm.test('Create FAILED: ' + (body.detail || body.title || JSON.stringify(body)), () => false);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodeDocSignerLp1Y}}", + "description": "Optional override. The wrapper resolves the product code from your catalog based on subjectType + subscription.validityYears. Enable only to force a specific product.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"subjectType\": \"legal-person\",\n \"emailNotifications\": \"all\",\n \"requestor\": {\n \"name\": \"Michael Chen\",\n \"email\": \"michael.chen@acme.com\",\n \"phone\": \"+14155551234\",\n \"designation\": \"VP Engineering\"\n },\n \"subject\": {\n \"firstName\": \"Michael\",\n \"lastName\": \"Chen\",\n \"email\": \"michael.chen@acme.com\",\n \"phone\": \"+14155551234\",\n \"designation\": \"VP Engineering\",\n \"organizationName\": \"Acme Corporation\",\n \"organizationUnit\": \"Engineering\",\n \"organizationIdentificationNumber\": \"EIN-12-3456789\",\n \"identityDocumentType\": \"passport\",\n \"identificationNumber\": \"P98765432\",\n \"streetAddress1\": \"500 Market Street\",\n \"streetAddress2\": \"Suite 300\",\n \"locality\": \"San Francisco\",\n \"state\": \"CA\",\n \"postalCode\": \"94105\",\n \"countryCode\": \"US\"\n },\n \"subscription\": { \"validityYears\": 1, \"autoRenew\": false },\n \"agreement\": {\n \"signerName\": \"Michael Chen\",\n \"signerPlace\": \"San Francisco, CA\",\n \"accepted\": true\n },\n \"remarks\": \"Document Signer - Legal Person, employee of Acme Corp\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/signature-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "signature-certificates" + ] + }, + "description": "### When to use\nEmployee signing on behalf of an organization. Cert subject combines person + org: `CN=Michael Chen, OU=Engineering, O=Acme Corporation, L=San Francisco, ST=CA, C=US`.\n\nCommon US use cases: corporate PDF signing for procurement, HR letters, financial filings.\n\n### Additional `subject` fields vs Natural Person\n- `organizationName`, `organizationUnit` - go in the cert subject\n- `organizationIdentificationNumber` - US uses EIN (`EIN-XX-XXXXXXX`)\n- `designation` - job title\n\n### Header\n- `X-Product-Code: 822` (1Y), `823` (2Y), `824` (3Y).\n\n## Response (`201 Created`)\n\n| Field | Type | Notes |\n|---|---|---|\n| `orderId` | string | Opaque V2 order ID. Use it on every follow-up call. |\n| `requestId` | string | Internal request reference (useful for support tickets). |\n| `status` | enum | Initial lifecycle state. See status reference below. |\n| `subjectType` | enum | Echo of the request: `natural-person` / `legal-person` / `legal-entity`. |\n| `subjectDisplayName` | string | Human-readable subject: full name for natural / legal person, organization name for legal entity. |\n| `createdAt` | RFC 3339 datetime | UTC. |\n| `resolvedProductCode` | string | Catalog product code the wrapper picked (e.g. `819` for natural-person 1Y NR). |\n\n### Status values\nStatus values are stable lowercase-hyphen strings: `pending-dcv`, `pending-organization-verification`, `pending-csr`, `pending-documents`, `pending-agreement`, `pending-approval`, `issued`, `revoked`, `cancelled`, `rejected`, `expired`, `unknown`.\n" + }, + "response": [ + { + "name": "201 Created - Legal Person order", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodeDocSignerLp1Y}}", + "description": "822 - Legal Person, 1 year." + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"subjectType\": \"legal-person\",\n \"emailNotifications\": \"all\",\n \"requestor\": {\n \"name\": \"Michael Chen\",\n \"email\": \"michael.chen@acme.com\",\n \"phone\": \"+14155551234\",\n \"designation\": \"VP Engineering\"\n },\n \"subject\": {\n \"firstName\": \"Michael\",\n \"lastName\": \"Chen\",\n \"email\": \"michael.chen@acme.com\",\n \"phone\": \"+14155551234\",\n \"designation\": \"VP Engineering\",\n \"organizationName\": \"Acme Corporation\",\n \"organizationUnit\": \"Engineering\",\n \"organizationIdentificationNumber\": \"EIN-12-3456789\",\n \"identityDocumentType\": \"passport\",\n \"identificationNumber\": \"P98765432\",\n \"streetAddress1\": \"500 Market Street\",\n \"streetAddress2\": \"Suite 300\",\n \"locality\": \"San Francisco\",\n \"state\": \"CA\",\n \"postalCode\": \"94105\",\n \"countryCode\": \"US\"\n },\n \"subscription\": { \"validityYears\": 1, \"autoRenew\": false },\n \"agreement\": {\n \"signerName\": \"Michael Chen\",\n \"signerPlace\": \"San Francisco, CA\",\n \"accepted\": true\n },\n \"remarks\": \"Document Signer - Legal Person, employee of Acme Corp\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/signature-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "signature-certificates" + ] + } + }, + "status": "Created", + "code": 201, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"orderId\": \"ord_signer_8K9mQ2vR8nP4bL\",\n \"status\": \"pending-documents\",\n \"subjectType\": \"legal-person\",\n \"subjectDisplayName\": \"Michael Chen (Acme Corporation)\",\n \"createdAt\": \"2026-05-12T13:00:00Z\",\n \"resolvedProductCode\": \"822\"}" + } + ] + }, + { + "name": "Create - Legal Entity", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "if (pm.response.code === 201 && body.orderId) {", + " pm.collectionVariables.set('orderId', body.orderId);", + " pm.collectionVariables.set('requestId', body.requestId || '');", + " pm.test('Doc Signer LE order created, orderId=' + body.orderId, () => pm.expect(body.orderId).to.be.a('string'));", + "} else {", + " pm.test('Create FAILED: ' + (body.detail || body.title || JSON.stringify(body)), () => false);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodeDocSignerLe1Y}}", + "description": "Optional override. The wrapper resolves the product code from your catalog based on subjectType + subscription.validityYears. Enable only to force a specific product.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"subjectType\": \"legal-entity\",\n \"emailNotifications\": \"all\",\n \"requestor\": {\n \"name\": \"Acme Corporation Compliance\",\n \"email\": \"pki-ops@acme.com\",\n \"phone\": \"+14155551234\",\n \"designation\": \"PKI Operations\"\n },\n \"subject\": {\n \"organizationName\": \"Acme Corporation\",\n \"organizationUnit\": \"Compliance\",\n \"businessCategory\": \"Business Entity\",\n \"organizationIdentificationNumber\": \"EIN-12-3456789\",\n \"email\": \"pki-ops@acme.com\",\n \"phone\": \"+14155551234\",\n \"streetAddress1\": \"500 Market Street\",\n \"streetAddress2\": \"Suite 300\",\n \"locality\": \"San Francisco\",\n \"state\": \"CA\",\n \"postalCode\": \"94105\",\n \"countryCode\": \"US\"\n },\n \"subscription\": { \"validityYears\": 1, \"autoRenew\": false },\n \"agreement\": {\n \"signerName\": \"Acme Corp PKI Operations\",\n \"signerPlace\": \"San Francisco, CA\",\n \"accepted\": true\n },\n \"remarks\": \"Document Signer - Legal Entity (org-only subject)\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/signature-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "signature-certificates" + ] + }, + "description": "### When to use\nThe organization itself signs (no named individual). Used for automated invoice signing, batch document processing, system-attested signatures.\n\n### Subject\n`CN=Acme Corporation, OU=Compliance, O=Acme Corporation, L=San Francisco, ST=CA, C=US`\n\n### Required `subject` fields\n- `organizationName`, `organizationUnit`\n- `businessCategory` - `Business Entity` | `Government` | `Non-Commercial Entity` (per ETSI EN 319 412-3)\n- `organizationIdentificationNumber` - US EIN, state-of-incorporation registration\n- `email`, `phone` (org contact, not personal)\n- US address (street, city, state, ZIP)\n\n### Header\n- `X-Product-Code: 825` (1Y), `826` (2Y), `827` (3Y).\n\n## Response (`201 Created`)\n\n| Field | Type | Notes |\n|---|---|---|\n| `orderId` | string | Opaque V2 order ID. Use it on every follow-up call. |\n| `requestId` | string | Internal request reference (useful for support tickets). |\n| `status` | enum | Initial lifecycle state. See status reference below. |\n| `subjectType` | enum | Echo of the request: `natural-person` / `legal-person` / `legal-entity`. |\n| `subjectDisplayName` | string | Human-readable subject: full name for natural / legal person, organization name for legal entity. |\n| `createdAt` | RFC 3339 datetime | UTC. |\n| `resolvedProductCode` | string | Catalog product code the wrapper picked (e.g. `819` for natural-person 1Y NR). |\n\n### Status values\nStatus values are stable lowercase-hyphen strings: `pending-dcv`, `pending-organization-verification`, `pending-csr`, `pending-documents`, `pending-agreement`, `pending-approval`, `issued`, `revoked`, `cancelled`, `rejected`, `expired`, `unknown`.\n" + }, + "response": [ + { + "name": "201 Created - Legal Entity order", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodeDocSignerLe1Y}}", + "description": "825 - Legal Entity, 1 year." + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"subjectType\": \"legal-entity\",\n \"emailNotifications\": \"all\",\n \"requestor\": {\n \"name\": \"Acme Corporation Compliance\",\n \"email\": \"pki-ops@acme.com\",\n \"phone\": \"+14155551234\",\n \"designation\": \"PKI Operations\"\n },\n \"subject\": {\n \"organizationName\": \"Acme Corporation\",\n \"organizationUnit\": \"Compliance\",\n \"businessCategory\": \"Business Entity\",\n \"organizationIdentificationNumber\": \"EIN-12-3456789\",\n \"email\": \"pki-ops@acme.com\",\n \"phone\": \"+14155551234\",\n \"streetAddress1\": \"500 Market Street\",\n \"streetAddress2\": \"Suite 300\",\n \"locality\": \"San Francisco\",\n \"state\": \"CA\",\n \"postalCode\": \"94105\",\n \"countryCode\": \"US\"\n },\n \"subscription\": { \"validityYears\": 1, \"autoRenew\": false },\n \"agreement\": {\n \"signerName\": \"Acme Corp PKI Operations\",\n \"signerPlace\": \"San Francisco, CA\",\n \"accepted\": true\n },\n \"remarks\": \"Document Signer - Legal Entity (org-only subject)\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/signature-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "signature-certificates" + ] + } + }, + "status": "Created", + "code": 201, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"orderId\": \"ord_signer_8K9mQ2vR8nP4bL\",\n \"status\": \"pending-documents\",\n \"subjectType\": \"legal-entity\",\n \"subjectDisplayName\": \"Acme Corporation\",\n \"createdAt\": \"2026-05-12T13:00:00Z\",\n \"resolvedProductCode\": \"825\"}" + } + ] + }, + { + "name": "Track Order", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/signature-certificates/{{orderId}}", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "signature-certificates", + "{{orderId}}" + ] + }, + "description": "### Status sequence (Document Signer)\n`pending-documents` -> `pending-approval` -> `pending-csr` -> `pending-agreement` -> `issued` (or `cancelled` / `revoked` / `expired`).\n\n## Response\n\nSame shape as **Create**, plus the lifecycle and supporting blocks filled in as the order progresses.\n\n### Header fields\n\n| Field | Type | Notes |\n|---|---|---|\n| `orderId` | string | Always. |\n| `requestId` | string | Always. |\n| `status` | enum | Typed lifecycle state. Walks `pending-documents` -> `pending-csr` -> `pending-agreement` -> `pending-approval` -> `issued`. Legal Entity certs may also pass through `pending-organization-verification`. |\n| `orderState` | string | Human-readable order status from the legacy pipeline, e.g. `Order Accepted`, `Approved by System`. Complement to the typed `status`. |\n| `certificateState` | string | Human-readable certificate / request status, e.g. `Pending for Approver`, `Certificate Generated`. |\n| `subjectType` | enum | `natural-person` / `legal-person` / `legal-entity`. |\n| `subjectDisplayName` | string | Full name for Natural / Legal Person; organization name for Legal Entity. |\n| `createdAt` | RFC 3339 datetime | When the order was placed. |\n| `issuedAt` | RFC 3339 datetime | When the certificate was issued; omitted before issuance. |\n| `expiresAt` | RFC 3339 datetime | Certificate `notAfter`. Populated once issued; remains populated after revocation / expiry. |\n\n### `requestor`\n\nThe person who placed the order; useful for support tickets and audit. PII fields are decrypted from storage.\n\n| Field | Type | Notes |\n|---|---|---|\n| `name` | string | Full name. |\n| `email` | string | Decrypted from storage. |\n| `phone` | string | E.164 normalised (`+`). |\n| `designation` | string | Job title / role. |\n\n### `csrSubmitted`\n\nBoolean. `true` once the integrator has POSTed a CSR via **Submit CSR**. The order can't progress to issuance until this flips.\n\n### `subscription`\n\nBilling entitlement window. Distinct from the certificate's own `notBefore` / `notAfter`: a single subscription\ncan host successive certificates (e.g. a re-issued cert during the subscription period).\n\n| Field | Type | Notes |\n|---|---|---|\n| `validityYears` | integer | Subscription length in years (typically `1`/`2`/`3`). |\n| `endDate` | RFC 3339 datetime | When the subscription entitlement expires. |\n| `status` | string | `active`, `expired`, or `cancelled`. Omitted when the underlying status is unknown or not yet classified. |\n\n### `subscriberAgreement`\n\nWhether the requestor has accepted the Subscriber Agreement. Returned for product families that require an SA.\n\n| Field | Type | Notes |\n|---|---|---|\n| `signed` | boolean | `true` once accepted. |\n| `signerName` | string | Acceptor's name; omitted until signed. |\n| `signedAt` | RFC 3339 datetime | Acceptance timestamp; omitted until signed. |\n| `signedPlace` | string | Acceptor's location; omitted until signed. |\n\n### `revocation` (populated only when `status = revoked`)\n\n| Field | Type | Notes |\n|---|---|---|\n| `status` | string | Human-readable revocation engine status (e.g. `Revoked`). |\n| `reason` | enum | RFC 5280 reason name: `key-compromise`, `affiliation-changed`, `superseded`, `cessation-of-operation`, `certificate-hold`, `privilege-withdrawn`, `unspecified`, etc. |\n| `processedAt` | RFC 3339 datetime | Effective revocation time (CA-recorded). |\n\n### `verifications`\n\nPer-verification-type state. Sub-blocks appear only when the product requires that verification type\nFor Private PKI this object is typically omitted entirely.\n\n#### `verifications.domain` (SSL/TLS)\n\n| Field | Type | Notes |\n|---|---|---|\n| `status` | enum | Overall DCV state across all domains on the order: `PENDING` / `VERIFIED` / `REJECTED`. |\n| `domains[]` | array | One entry per domain on the order (primary + SANs). |\n|   `.domain` | string | FQDN being validated. |\n|   `.domainStatus` | enum | `ACTIVE` / `INACTIVE` / `EXPIRED`. |\n|   `.dcvMethod` | enum | `dns-txt` / `http-url`. |\n|   `.dcvStatus` | enum | DCV state for this domain: `PENDING` / `VERIFIED` / `REJECTED`. |\n|   `.verifiedAt` | RFC 3339 datetime | When this domain was last successfully validated. |\n|   `.caaStatus` | enum | CAA pre-check: `PASSED` / `FAILED` / `SKIPPED`. |\n\nFor per-attempt MPIC diagnostics (consensus, DNSSEC, per-perspective resolver results), call the dedicated\n**Domains -> Last DCV Attempt** / **DCV Attempt History** endpoints.\n\n#### `verifications.organization` (OV / EV)\n\n| Field | Type | Notes |\n|---|---|---|\n| `status` | enum | `PENDING` / `VERIFIED` / `REJECTED`. |\n| `organizationName` | string | Display name as recorded on the order. |\n| `consentStatus` | `\"0\"` \\| `\"1\"` \\| `\"2\"` | Email-consent state when the OV flow requires consent from the organization's primary representative. Omitted when not applicable. |\n| `consentSentTo` | string | Email address consent was sent to (when `consentStatus` is present). |\n\nFor the full organization record (address, representatives, pre-vetted domains) call\n**Accounts -> Get Organization**.\n\n#### `verifications.email` (S/MIME)\n\n| Field | Type | Notes |\n|---|---|---|\n| `status` | enum | `PENDING` / `VERIFIED` / `REJECTED`. |\n| `email` | string | Email address being validated (decrypted from storage). |\n| `verifiedAt` | RFC 3339 datetime | When the email control challenge was last redeemed. |\n\n#### `verifications.individual` (personal certificates)\n\n| Field | Type | Notes |\n|---|---|---|\n| `status` | enum | `PENDING` / `VERIFIED` / `REJECTED`. |\n| `subjectName` | string | Full name as captured at identity check. |\n| `verifiedAt` | RFC 3339 datetime | When identity was last successfully verified. |\n\nPoll Track Order every 30 - 60 seconds while the order is in a `pending-*` state. Stop polling once `status`\nreaches a terminal state (`issued`, `cancelled`, `revoked`, `rejected`, `expired`).\n" + }, + "response": [ + { + "name": "200 OK - issued state", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/signature-certificates/{{orderId}}", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "signature-certificates", + "{{orderId}}" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"orderId\": \"ord_signer_8K9mQ2vR8nP4bL\",\n \"status\": \"issued\",\n \"subjectType\": \"natural-person\",\n \"subjectDisplayName\": \"Sarah Johnson\",\n \"createdAt\": \"2026-05-12T13:00:00Z\",\n \"issuedAt\": \"2026-05-12T14:00:00Z\",\n \"expiresAt\": \"2027-05-12T14:00:00Z\"}" + }, + { + "name": "404 Not Found", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/signature-certificates/{{orderId}}", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "signature-certificates", + "{{orderId}}" + ] + } + }, + "status": "Not Found", + "code": 404, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/problem+json" + } + ], + "cookie": [], + "body": "{\n \"type\": \"about:blank\",\n \"title\": \"Not Found\",\n \"status\": 404,\n \"detail\": \"Order ord_unknown not found under this account.\"\n}" + } + ] + }, + { + "name": "Submit CSR", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"csr\": \"-----BEGIN CERTIFICATE REQUEST-----\\n\\n-----END CERTIFICATE REQUEST-----\",\n \"attested\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/signature-certificates/:orderId/csr", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "signature-certificates", + ":orderId", + "csr" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + }, + "description": "### What it does\nAttaches the CSR. The CN must match the subject - `CN=` for Natural Person, `CN=` for Legal Person, `CN=` for Legal Entity.\n\n### CSR generation (Natural Person example)\n```bash\nopenssl req -new -newkey rsa:2048 -nodes \\\n -keyout signer.key -out signer.csr \\\n -subj \"/CN=Sarah Johnson/emailAddress=sarah.johnson@example.com/C=US\"\n```\n\n### Body\n- `csr` - PEM with markers, newlines as `\\n`.\n- `attested` - `true` for HSM-backed keys (FIPS 140-2 Level 2+).\n\n### Response\n- **204** - accepted.\n- **422** - malformed or subject mismatch." + }, + "response": [ + { + "name": "204 No Content - CSR accepted", + "originalRequest": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"csr\": \"-----BEGIN CERTIFICATE REQUEST-----\\n\\n-----END CERTIFICATE REQUEST-----\",\n \"attested\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/signature-certificates/:orderId/csr", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "signature-certificates", + ":orderId", + "csr" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "No Content", + "code": 204, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "" + }, + { + "name": "422 Unprocessable - invalid CSR", + "originalRequest": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"csr\": \"-----BEGIN CERTIFICATE REQUEST-----\\n\\n-----END CERTIFICATE REQUEST-----\",\n \"attested\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/signature-certificates/:orderId/csr", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "signature-certificates", + ":orderId", + "csr" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "Unprocessable Entity", + "code": 422, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/problem+json" + } + ], + "cookie": [], + "body": "{\n \"type\": \"about:blank\",\n \"title\": \"Unprocessable Entity\",\n \"status\": 422,\n \"detail\": \"EMS-917 \\u2014 CSR is invalid or its embedded common name does not match the order's expected subject.\"\n}" + } + ] + }, + { + "name": "Accept Agreement", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"agreement\": {\n \"signerName\": \"Sarah Johnson\",\n \"signerPlace\": \"Washington, DC\",\n \"accepted\": true\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/signature-certificates/:orderId/agreement", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "signature-certificates", + ":orderId", + "agreement" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + }, + "description": "### Body\n- `agreement.signerName` - full legal name of the person clicking through.\n- `agreement.signerPlace` - city/state where the agreement was accepted.\n- `agreement.accepted` - must be `true`.\n\n**Note on `signerIp`:** the wrapper captures the originating client IP from the inbound request (X-Forwarded-For when behind a proxy). Do not send `signerIp` in the body - it will be ignored.\n\n### Responses\n- **204** - agreement recorded.\n- **422** - order is not in pending-agreement state." + }, + "response": [ + { + "name": "204 No Content - agreement recorded", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"agreement\": {\n \"signerName\": \"Sarah Johnson\",\n \"signerPlace\": \"Washington, DC\",\n \"accepted\": true\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/signature-certificates/:orderId/agreement", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "signature-certificates", + ":orderId", + "agreement" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "No Content", + "code": 204, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "" + }, + { + "name": "422 Unprocessable - wrong state", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"agreement\": {\n \"signerName\": \"Sarah Johnson\",\n \"signerPlace\": \"Washington, DC\",\n \"accepted\": true\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/signature-certificates/:orderId/agreement", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "signature-certificates", + ":orderId", + "agreement" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "Unprocessable Entity", + "code": 422, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/problem+json" + } + ], + "cookie": [], + "body": "{\n \"type\": \"about:blank\",\n \"title\": \"Unprocessable Entity\",\n \"status\": 422,\n \"detail\": \"Order is not in pending-agreement state; cannot accept Subscriber Agreement at this stage.\"\n}" + } + ] + }, + { + "name": "Cancel Order", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{ \"reason\": \"Signer left the company.\" }", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/signature-certificates/:orderId/cancel", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "signature-certificates", + ":orderId", + "cancel" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + }, + "description": "### What it does\nWithdraws an order that has not yet been issued. After issuance, use `/revoke` instead.\n\n### Body (optional)\n- `reason` - free-text reason (audit log).\n\n### Responses\n- **204** - order cancelled.\n- **422** - order is already issued (use `/revoke`)." + }, + "response": [ + { + "name": "204 No Content - order cancelled", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{ \"reason\": \"Signer left the company.\" }", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/signature-certificates/:orderId/cancel", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "signature-certificates", + ":orderId", + "cancel" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "No Content", + "code": 204, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "" + }, + { + "name": "422 Unprocessable - already issued", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{ \"reason\": \"Signer left the company.\" }", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/signature-certificates/:orderId/cancel", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "signature-certificates", + ":orderId", + "cancel" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "Unprocessable Entity", + "code": 422, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/problem+json" + } + ], + "cookie": [], + "body": "{\n \"type\": \"about:blank\",\n \"title\": \"Unprocessable Entity\",\n \"status\": 422,\n \"detail\": \"Order is already issued; use POST /{orderId}/revoke instead of /cancel.\"\n}" + } + ] + }, + { + "name": "Revoke Certificate", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"reason\": \"key-compromise\",\n \"note\": \"Token reported lost.\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/signature-certificates/:orderId/revoke", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "signature-certificates", + ":orderId", + "revoke" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + }, + "description": "### What it does\nPermanently marks an issued certificate as revoked with an RFC 5280 reason code.\n\n### Body\n- `reason` - one of: `unspecified`, `key-compromise`, `ca-compromise`, `affiliation-changed`, `superseded`, `cessation-of-operation`, `certificate-hold`, `privilege-withdrawn`, `aa-compromise`.\n- `note` - free-text audit note (optional).\n\n### Response\n- **204** - revocation queued; reflected in CRL/OCSP on next CA publish." + }, + "response": [ + { + "name": "204 No Content - revocation queued", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"reason\": \"key-compromise\",\n \"note\": \"Token reported lost.\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/signature-certificates/:orderId/revoke", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "signature-certificates", + ":orderId", + "revoke" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "No Content", + "code": 204, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "" + }, + { + "name": "404 Not Found", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"reason\": \"key-compromise\",\n \"note\": \"Token reported lost.\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/signature-certificates/:orderId/revoke", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "signature-certificates", + ":orderId", + "revoke" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "Not Found", + "code": 404, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/problem+json" + } + ], + "cookie": [], + "body": "{\n \"type\": \"about:blank\",\n \"title\": \"Not Found\",\n \"status\": 404,\n \"detail\": \"Order not found or not in a revokable state.\"\n}" + } + ] + }, + { + "name": "Download Certificate", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "description": "application/json | application/x-pem-file | application/pkix-cert" + } + ], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/signature-certificates/:orderId/certificate", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "signature-certificates", + ":orderId", + "certificate" + ], + "query": [ + { + "key": "format", + "value": "pem", + "description": "Convenience override: `pem`, `der`, or `pkcs7` (alias of `der`). Wins over `Accept` when supplied. Leave disabled to drive selection from `Accept`.", + "disabled": true + } + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + }, + "description": "### What it does\nReturns the issued certificate. Format negotiated via the `Accept` header:\n- `application/json` (default) - JSON envelope with PEM + metadata (serial, subject, issuer, validity, chain).\n- `application/x-pem-file` - raw PEM text.\n- `application/pkix-cert` - DER binary.\n\nOnly callable after issuance (`status=issued`).\n\n## Response\n\nSame shape as SSL **Download Certificate (JSON / PEM / DER)** - the response format is driven by the `Accept` header:\n\n| Accept header | Response | Body |\n|---|---|---|\n| `application/json` | `200 OK` | `{ orderId, serialNumber, subject, issuer, notBefore, notAfter, certificatePem, chainPem[] }` |\n| `application/x-pem-file` | `200 OK` | Raw PEM (`-----BEGIN CERTIFICATE-----...`) |\n| `application/pkix-cert` | `200 OK` | Binary DER (leaf only) |\n\nReturns `409 Conflict` until `status = issued`.\n" + }, + "response": [ + { + "name": "200 OK - JSON envelope", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "description": "application/json | application/x-pem-file | application/pkix-cert" + } + ], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/signature-certificates/:orderId/certificate", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "signature-certificates", + ":orderId", + "certificate" + ], + "query": [ + { + "key": "format", + "value": "pem", + "description": "Convenience override: `pem`, `der`, or `pkcs7` (alias of `der`). Wins over `Accept` when supplied. Leave disabled to drive selection from `Accept`.", + "disabled": true + } + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"orderId\": \"ord_signer_8K9mQ2vR8nP4bL\",\n \"serialNumber\": \"0E:1B:7F:2A:3C:4D:5E:6F:7A:8B:9C:0D\",\n \"subject\": \"CN=Sarah Johnson, O=Acme Corporation, C=US\",\n \"issuer\": \"CN=emSign Signing CA G3, O=eMudhra Inc., C=US\",\n \"notBefore\": \"2026-05-12T14:00:00Z\",\n \"notAfter\": \"2027-05-12T14:00:00Z\",\n \"certificatePem\": \"-----BEGIN CERTIFICATE-----\\nMIIFx... (truncated)...AQ==\\n-----END CERTIFICATE-----\",\n \"chainPem\": [\n \"-----BEGIN CERTIFICATE-----\\nMIIEv... (intermediate)...AQ==\\n-----END CERTIFICATE-----\",\n \"-----BEGIN CERTIFICATE-----\\nMIIDr... (root)...AQ==\\n-----END CERTIFICATE-----\"\n ]\n}" + }, + { + "name": "422 Unprocessable - certificate not yet issued", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "description": "application/json | application/x-pem-file | application/pkix-cert" + } + ], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/signature-certificates/:orderId/certificate", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "signature-certificates", + ":orderId", + "certificate" + ], + "query": [ + { + "key": "format", + "value": "pem", + "description": "Convenience override: `pem`, `der`, or `pkcs7` (alias of `der`). Wins over `Accept` when supplied. Leave disabled to drive selection from `Accept`.", + "disabled": true + } + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "Unprocessable Entity", + "code": 422, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/problem+json" + } + ], + "cookie": [], + "body": "{\n \"type\": \"about:blank\",\n \"title\": \"Unprocessable Entity\",\n \"status\": 422,\n \"detail\": \"Certificate is not yet issued for this order.\"\n}" + } + ] + } + ], + "description": "Digital-signature certificates for **AATL-trusted PDF signing** (Adobe Approved Trust List), code signing, and US ESIGN-Act / UETA workflows.\n\n**Three subject types:**\n- **Natural Person** - individual signer (e.g. CEO, attorney, notary).\n- **Legal Person** - employee acting on behalf of an organization. Subject combines person + org.\n- **Legal Entity** - the organization itself (no named individual). Subject is `O=...` only.\n\n**Workflow:** Create -> Submit CSR -> Accept Agreement -> Track until `issued` -> Download Certificate.\n\n---\n\n### Field requirements (in body order)\n\n| Field | Mandatory / Optional |\n|---|---|\n| `subjectType` | **Mandatory** (`natural-person` / `legal-person` / `legal-entity`) |\n| `saveAsDraft` | Optional (default `false`) |\n| `requestId` | Conditional - only when promoting a `saveAsDraft` draft |\n| `emailNotifications` | Optional (default `all`) |\n| `groupNumber` | Optional |\n| `requestor` | **Mandatory** |\n| `requestor.name` | **Mandatory** |\n| `requestor.email` | **Mandatory** |\n| `requestor.phone` | Optional |\n| `requestor.designation` | Optional |\n| `delegation` | Optional |\n| `delegation.name` | Optional |\n| `delegation.email` | Optional |\n| `subject` | **Mandatory** |\n| `subject.firstName` | Conditional - required for natural-person / legal-person |\n| `subject.lastName` | Conditional - required for natural-person / legal-person |\n| `subject.email` | **Mandatory** |\n| `subject.phone` | Optional |\n| `subject.designation` | Optional |\n| `subject.organizationName` | Conditional - required for legal-person / legal-entity |\n| `subject.organizationUnit` | Optional |\n| `subject.organizationIdentificationNumber` | Conditional - typically required for legal-entity |\n| `subject.businessCategory` | Conditional - legal-entity |\n| `subject.identityDocumentType` | Optional |\n| `subject.identificationNumber` | Optional |\n| `subject.streetAddress1` | Optional |\n| `subject.streetAddress2` | Optional |\n| `subject.locality` | Optional |\n| `subject.state` | Optional |\n| `subject.postalCode` | Optional |\n| `subject.countryCode` | Optional (ISO 3166-1 alpha-2) |\n| `subscription` | Optional |\n| `subscription.validityYears` | Optional |\n| `subscription.autoRenew` | Optional (default ON) |\n| `subscription.renewBeforeDays` | Optional |\n| `agreement` | Optional - required before issuance |\n| `agreement.signerName` | Conditional - required if `agreement` sent |\n| `agreement.signerPlace` | Conditional - required if `agreement` sent |\n| `agreement.accepted` | Conditional - must be `true` if `agreement` sent |\n| `csr` | Optional - required before issuance |\n| `remarks` | Optional |\n| `tags` | Optional |\n| `customFields` | Conditional - only if product mandates |\n| `customFields[].fieldId` | Conditional - required if `customFields` sent |\n| `customFields[].value` | Conditional - required if `customFields` sent |\n| `recipientEmails` | Optional |\n| `technicalPointOfContact` | Optional |\n| `technicalPointOfContact.name` | Optional |\n| `technicalPointOfContact.email` | Optional |\n| `technicalPointOfContact.phone` | Optional |\n| `technicalPointOfContact.designation` | Optional |\n\n**Strictly mandatory** (400 if missing): `subjectType`, `requestor.name`, `requestor.email`, `subject.email`. The `subject.*` fields beyond email vary by `subjectType` - the backend applies stricter per-type rules." + }, + { + "name": "Private PKI Certificates", + "item": [ + { + "name": "Create - Intranet SSL", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "if (pm.response.code === 201 && body.orderId) {", + " pm.collectionVariables.set('orderId', body.orderId);", + " pm.collectionVariables.set('requestId', body.requestId || '');", + " pm.test('PKI Intranet SSL order created, orderId=' + body.orderId, () => pm.expect(body.orderId).to.be.a('string'));", + "} else {", + " pm.test('Create FAILED: ' + (body.detail || body.title || JSON.stringify(body)), () => false);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodePkiIntranet}}", + "description": "Optional override. The wrapper resolves the product code from your catalog based on the request body's `variant`. Enable only to force a specific product.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"variant\": \"intranet-ssl\",\n \"hostname\": \"intranet.acme.local\",\n \"additionalHosts\": [\n \"portal.acme.local\",\n \"reports.acme.local\",\n \"10.0.0.50\"\n ],\n \"emailNotifications\": \"all\",\n \"subscription\": { \"validityYears\": 1 },\n \"requestor\": {\n \"name\": \"DevOps Team\",\n \"email\": \"devops@acme.com\",\n \"phone\": \"+14155551234\",\n \"designation\": \"Platform Engineering\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/private-pki-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "private-pki-certificates" + ] + }, + "description": "### When to use\nInternal-only SSL for servers behind a firewall. Subject can be anything your CA template allows - internal hostnames, RFC 1918 IPs, `.local` / `.internal` names.\n\n### Required body fields\n- `variant: \"intranet-ssl\"`\n- `hostname` - primary CN\n- `additionalHosts[]` - SAN list (DNS names or IPv4 / IPv6)\n\n### Optional body fields\n- `caProfileId` - CA template override. Server derives this from `X-Product-Code`; only supply it if you need to override the catalog default.\n- `masterProductId` - subscription-slot override; same default-derivation as `caProfileId`.\n\n### Notes\n- No DCV - your CA trusts you.\n- No Subscriber Agreement.\n- Skip directly from Create -> Submit CSR -> Download.\n\n### Header\n- `X-Product-Code` - varies per customer; the wrapper uses it to resolve the CA profile and product when body fields don't pin one uniquely.\n\n## Response (`201 Created`)\n\n| Field | Type | Notes |\n|---|---|---|\n| `orderId` | string | Opaque V2 order ID. Use it on every follow-up call. |\n| `requestId` | string | Internal request reference (useful for support tickets). |\n| `status` | enum | Initial lifecycle state. See status reference below. |\n| `variant` | enum | Echo of the create-request variant: `intranet-ssl` / `igtf-host` / `igtf-personal` / `device` / `vpn`. |\n| `hostname` | string | Primary hostname (intranet-ssl, igtf-host, vpn) or subject CN. |\n| `createdAt` | RFC 3339 datetime | UTC. |\n| `resolvedProductCode` | string | Private-PKI product code from your CA profile. |\n\n### Status values\nStatus values are stable lowercase-hyphen strings: `pending-dcv`, `pending-organization-verification`, `pending-csr`, `pending-documents`, `pending-agreement`, `pending-approval`, `issued`, `revoked`, `cancelled`, `rejected`, `expired`, `unknown`.\n" + }, + "response": [ + { + "name": "201 Created - Intranet SSL order", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodePkiIntranet}}", + "description": "Customer-specific. Look up via Catalog -> List Products." + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"variant\": \"intranet-ssl\",\n \"hostname\": \"intranet.acme.local\",\n \"additionalHosts\": [\n \"portal.acme.local\",\n \"reports.acme.local\",\n \"10.0.0.50\"\n ],\n \"emailNotifications\": \"all\",\n \"subscription\": { \"validityYears\": 1 },\n \"requestor\": {\n \"name\": \"DevOps Team\",\n \"email\": \"devops@acme.com\",\n \"phone\": \"+14155551234\",\n \"designation\": \"Platform Engineering\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/private-pki-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "private-pki-certificates" + ] + } + }, + "status": "Created", + "code": 201, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"orderId\": \"ord_pki_8K9mQ2vR8nP4bL\",\n \"status\": \"pending-csr\",\n \"variant\": \"intranet-ssl\",\n \"hostname\": \"intranet.acme.local\",\n \"createdAt\": \"2026-05-12T13:00:00Z\",\n \"resolvedProductCode\": \"950\"}" + }, + { + "name": "422 Unprocessable - caProfile not entitled", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodePkiIntranet}}", + "description": "Customer-specific. Look up via Catalog -> List Products." + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"variant\": \"intranet-ssl\",\n \"hostname\": \"intranet.acme.local\",\n \"additionalHosts\": [\n \"portal.acme.local\",\n \"reports.acme.local\",\n \"10.0.0.50\"\n ],\n \"emailNotifications\": \"all\",\n \"subscription\": { \"validityYears\": 1 },\n \"requestor\": {\n \"name\": \"DevOps Team\",\n \"email\": \"devops@acme.com\",\n \"phone\": \"+14155551234\",\n \"designation\": \"Platform Engineering\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/private-pki-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "private-pki-certificates" + ] + } + }, + "status": "Unprocessable Entity", + "code": 422, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/problem+json" + } + ], + "cookie": [], + "body": "{\n \"type\": \"about:blank\",\n \"title\": \"Unprocessable Entity\",\n \"status\": 422,\n \"detail\": \"EMS-915 \\u2014 CA profile 'internal-web-tier' is not entitled to this account.\"\n}" + } + ] + }, + { + "name": "Create - IGTF Host", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "if (pm.response.code === 201 && body.orderId) {", + " pm.collectionVariables.set('orderId', body.orderId);", + " pm.collectionVariables.set('requestId', body.requestId || '');", + " pm.test('PKI IGTF Host order created, orderId=' + body.orderId, () => pm.expect(body.orderId).to.be.a('string'));", + "} else {", + " pm.test('Create FAILED: ' + (body.detail || body.title || JSON.stringify(body)), () => false);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodePkiIgtf}}", + "description": "Optional override. The wrapper resolves the product code from your catalog based on the request body's `variant`. Enable only to force a specific product.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"variant\": \"igtf-host\",\n \"hostname\": \"compute01.hpc.example.edu\",\n \"additionalHosts\": [],\n \"emailNotifications\": \"all\",\n \"subscription\": { \"validityYears\": 1 },\n \"requestor\": {\n \"name\": \"HPC Operations\",\n \"email\": \"hpc-ops@example.edu\",\n \"phone\": \"+16175551234\",\n \"designation\": \"Research Computing\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/private-pki-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "private-pki-certificates" + ] + }, + "description": "### When to use\nIGTF (Interoperable Global Trust Federation) host certificates for research-grid compute nodes - Open Science Grid, OSG, EGI federations.\n\n### Notes\n- `hostname` typically follows the federation's naming convention (e.g. `compute01.hpc.example.edu`).\n- IGTF-accredited CAs cross-sign these for global research interoperability.\n- US R&E common usage: OSG, ESnet, NRP, university HPC clusters.\n\n## Response (`201 Created`)\n\n| Field | Type | Notes |\n|---|---|---|\n| `orderId` | string | Opaque V2 order ID. Use it on every follow-up call. |\n| `requestId` | string | Internal request reference (useful for support tickets). |\n| `status` | enum | Initial lifecycle state. See status reference below. |\n| `variant` | enum | Echo of the create-request variant: `intranet-ssl` / `igtf-host` / `igtf-personal` / `device` / `vpn`. |\n| `hostname` | string | Primary hostname (intranet-ssl, igtf-host, vpn) or subject CN. |\n| `createdAt` | RFC 3339 datetime | UTC. |\n| `resolvedProductCode` | string | Private-PKI product code from your CA profile. |\n\n### Status values\nStatus values are stable lowercase-hyphen strings: `pending-dcv`, `pending-organization-verification`, `pending-csr`, `pending-documents`, `pending-agreement`, `pending-approval`, `issued`, `revoked`, `cancelled`, `rejected`, `expired`, `unknown`.\n" + }, + "response": [ + { + "name": "201 Created - IGTF Host order", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Product-Code", + "value": "{{productCodePkiIgtf}}", + "description": "Customer-specific IGTF profile code." + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"variant\": \"igtf-host\",\n \"hostname\": \"compute01.hpc.example.edu\",\n \"additionalHosts\": [],\n \"emailNotifications\": \"all\",\n \"subscription\": { \"validityYears\": 1 },\n \"requestor\": {\n \"name\": \"HPC Operations\",\n \"email\": \"hpc-ops@example.edu\",\n \"phone\": \"+16175551234\",\n \"designation\": \"Research Computing\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/private-pki-certificates", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "private-pki-certificates" + ] + } + }, + "status": "Created", + "code": 201, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"orderId\": \"ord_pki_8K9mQ2vR8nP4bL\",\n \"status\": \"pending-csr\",\n \"variant\": \"igtf-host\",\n \"hostname\": \"compute01.hpc.example.edu\",\n \"createdAt\": \"2026-05-12T13:00:00Z\",\n \"resolvedProductCode\": \"951\"}" + } + ] + }, + { + "name": "Track Order", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/private-pki-certificates/{{orderId}}", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "private-pki-certificates", + "{{orderId}}" + ] + }, + "description": "### Status sequence\n`pending-csr` -> `issued` (or `cancelled` / `revoked`). Private PKI typically skips approval queues - the CA is yours.\n\n## Response\n\nSame shape as **Create**, plus the lifecycle and supporting blocks filled in as the order progresses.\n\n### Header fields\n\n| Field | Type | Notes |\n|---|---|---|\n| `orderId` | string | Always. |\n| `requestId` | string | Always. |\n| `status` | enum | Typed lifecycle state. Private PKI skips DCV; typical walk is `pending-csr` -> `pending-approval` -> `issued`. |\n| `orderState` | string | Human-readable order status from the legacy pipeline, e.g. `Order Accepted`, `Approved by System`. Complement to the typed `status`. |\n| `certificateState` | string | Human-readable certificate / request status, e.g. `Pending for Approver`, `Certificate Generated`. |\n| `variant` | enum | Echo of the create-request variant. |\n| `hostname` | string | Primary hostname / subject CN. |\n| `createdAt` | RFC 3339 datetime | When the order was placed. |\n| `issuedAt` | RFC 3339 datetime | When the certificate was issued; omitted before issuance. |\n| `expiresAt` | RFC 3339 datetime | Certificate `notAfter`. Populated once issued; remains populated after revocation / expiry. |\n\n### `requestor`\n\nThe person who placed the order; useful for support tickets and audit. PII fields are decrypted from storage.\n\n| Field | Type | Notes |\n|---|---|---|\n| `name` | string | Full name. |\n| `email` | string | Decrypted from storage. |\n| `phone` | string | E.164 normalised (`+`). |\n| `designation` | string | Job title / role. |\n\n### `csrSubmitted`\n\nBoolean. `true` once the integrator has POSTed a CSR via **Submit CSR**. The order can't progress to issuance until this flips.\n\n### `subscription`\n\nBilling entitlement window. Distinct from the certificate's own `notBefore` / `notAfter`: a single subscription\ncan host successive certificates (e.g. a re-issued cert during the subscription period).\n\n| Field | Type | Notes |\n|---|---|---|\n| `validityYears` | integer | Subscription length in years (typically `1`/`2`/`3`). |\n| `endDate` | RFC 3339 datetime | When the subscription entitlement expires. |\n| `status` | string | `active`, `expired`, or `cancelled`. Omitted when the underlying status is unknown or not yet classified. |\n\n### `subscriberAgreement`\n\nWhether the requestor has accepted the Subscriber Agreement. Returned for product families that require an SA.\n\n| Field | Type | Notes |\n|---|---|---|\n| `signed` | boolean | `true` once accepted. |\n| `signerName` | string | Acceptor's name; omitted until signed. |\n| `signedAt` | RFC 3339 datetime | Acceptance timestamp; omitted until signed. |\n| `signedPlace` | string | Acceptor's location; omitted until signed. |\n\n### `revocation` (populated only when `status = revoked`)\n\n| Field | Type | Notes |\n|---|---|---|\n| `status` | string | Human-readable revocation engine status (e.g. `Revoked`). |\n| `reason` | enum | RFC 5280 reason name: `key-compromise`, `affiliation-changed`, `superseded`, `cessation-of-operation`, `certificate-hold`, `privilege-withdrawn`, `unspecified`, etc. |\n| `processedAt` | RFC 3339 datetime | Effective revocation time (CA-recorded). |\n\n### `verifications`\n\nPer-verification-type state. Sub-blocks appear only when the product requires that verification type\nFor Private PKI this object is typically omitted entirely.\n\n#### `verifications.domain` (SSL/TLS)\n\n| Field | Type | Notes |\n|---|---|---|\n| `status` | enum | Overall DCV state across all domains on the order: `PENDING` / `VERIFIED` / `REJECTED`. |\n| `domains[]` | array | One entry per domain on the order (primary + SANs). |\n|   `.domain` | string | FQDN being validated. |\n|   `.domainStatus` | enum | `ACTIVE` / `INACTIVE` / `EXPIRED`. |\n|   `.dcvMethod` | enum | `dns-txt` / `http-url`. |\n|   `.dcvStatus` | enum | DCV state for this domain: `PENDING` / `VERIFIED` / `REJECTED`. |\n|   `.verifiedAt` | RFC 3339 datetime | When this domain was last successfully validated. |\n|   `.caaStatus` | enum | CAA pre-check: `PASSED` / `FAILED` / `SKIPPED`. |\n\nFor per-attempt MPIC diagnostics (consensus, DNSSEC, per-perspective resolver results), call the dedicated\n**Domains -> Last DCV Attempt** / **DCV Attempt History** endpoints.\n\n#### `verifications.organization` (OV / EV)\n\n| Field | Type | Notes |\n|---|---|---|\n| `status` | enum | `PENDING` / `VERIFIED` / `REJECTED`. |\n| `organizationName` | string | Display name as recorded on the order. |\n| `consentStatus` | `\"0\"` \\| `\"1\"` \\| `\"2\"` | Email-consent state when the OV flow requires consent from the organization's primary representative. Omitted when not applicable. |\n| `consentSentTo` | string | Email address consent was sent to (when `consentStatus` is present). |\n\nFor the full organization record (address, representatives, pre-vetted domains) call\n**Accounts -> Get Organization**.\n\n#### `verifications.email` (S/MIME)\n\n| Field | Type | Notes |\n|---|---|---|\n| `status` | enum | `PENDING` / `VERIFIED` / `REJECTED`. |\n| `email` | string | Email address being validated (decrypted from storage). |\n| `verifiedAt` | RFC 3339 datetime | When the email control challenge was last redeemed. |\n\n#### `verifications.individual` (personal certificates)\n\n| Field | Type | Notes |\n|---|---|---|\n| `status` | enum | `PENDING` / `VERIFIED` / `REJECTED`. |\n| `subjectName` | string | Full name as captured at identity check. |\n| `verifiedAt` | RFC 3339 datetime | When identity was last successfully verified. |\n\nPoll Track Order every 30 - 60 seconds while the order is in a `pending-*` state. Stop polling once `status`\nreaches a terminal state (`issued`, `cancelled`, `revoked`, `rejected`, `expired`).\n" + }, + "response": [ + { + "name": "200 OK - issued state", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/private-pki-certificates/{{orderId}}", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "private-pki-certificates", + "{{orderId}}" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"orderId\": \"ord_pki_8K9mQ2vR8nP4bL\",\n \"status\": \"issued\",\n \"variant\": \"intranet-ssl\",\n \"hostname\": \"intranet.acme.local\",\n \"createdAt\": \"2026-05-12T13:00:00Z\",\n \"issuedAt\": \"2026-05-12T13:15:00Z\",\n \"expiresAt\": \"2027-05-12T13:15:00Z\"}" + }, + { + "name": "404 Not Found", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/private-pki-certificates/{{orderId}}", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "private-pki-certificates", + "{{orderId}}" + ] + } + }, + "status": "Not Found", + "code": 404, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/problem+json" + } + ], + "cookie": [], + "body": "{\n \"type\": \"about:blank\",\n \"title\": \"Not Found\",\n \"status\": 404,\n \"detail\": \"Order ord_unknown not found under this account.\"\n}" + } + ] + }, + { + "name": "Submit CSR", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"csr\": \"-----BEGIN CERTIFICATE REQUEST-----\\n\\n-----END CERTIFICATE REQUEST-----\",\n \"attested\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/private-pki-certificates/:orderId/csr", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "private-pki-certificates", + ":orderId", + "csr" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + }, + "description": "### What it does\nAttach a PEM CSR. The customer CA signs immediately.\n\n### CSR generation\n```bash\nopenssl req -new -newkey rsa:2048 -nodes \\\n -keyout server.key -out server.csr \\\n -subj \"/CN=intranet.acme.local/O=Acme Corp/C=US\"\n```\n\n### Response\n- **204** - CSR accepted, certificate is being issued.\n- **422** - malformed CSR or order not in CSR-acceptable state." + }, + "response": [ + { + "name": "204 No Content - CSR accepted", + "originalRequest": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"csr\": \"-----BEGIN CERTIFICATE REQUEST-----\\n\\n-----END CERTIFICATE REQUEST-----\",\n \"attested\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/private-pki-certificates/:orderId/csr", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "private-pki-certificates", + ":orderId", + "csr" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "No Content", + "code": 204, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "" + }, + { + "name": "422 Unprocessable - invalid CSR", + "originalRequest": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"csr\": \"-----BEGIN CERTIFICATE REQUEST-----\\n\\n-----END CERTIFICATE REQUEST-----\",\n \"attested\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/private-pki-certificates/:orderId/csr", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "private-pki-certificates", + ":orderId", + "csr" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "Unprocessable Entity", + "code": 422, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/problem+json" + } + ], + "cookie": [], + "body": "{\n \"type\": \"about:blank\",\n \"title\": \"Unprocessable Entity\",\n \"status\": 422,\n \"detail\": \"EMS-917 \\u2014 CSR is invalid or its embedded common name does not match the order's expected subject.\"\n}" + } + ] + }, + { + "name": "Cancel Order", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{ \"reason\": \"Server decommissioned.\" }", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/private-pki-certificates/:orderId/cancel", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "private-pki-certificates", + ":orderId", + "cancel" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + }, + "description": "### What it does\nWithdraws a Private PKI order before issuance.\n\n### Body (optional)\n- `reason` - free-text reason (audit log).\n\n### Response\n- **204** - order cancelled." + }, + "response": [ + { + "name": "204 No Content - order cancelled", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{ \"reason\": \"Server decommissioned.\" }", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/private-pki-certificates/:orderId/cancel", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "private-pki-certificates", + ":orderId", + "cancel" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "No Content", + "code": 204, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "" + }, + { + "name": "422 Unprocessable - already issued", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{ \"reason\": \"Server decommissioned.\" }", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/private-pki-certificates/:orderId/cancel", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "private-pki-certificates", + ":orderId", + "cancel" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "Unprocessable Entity", + "code": 422, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/problem+json" + } + ], + "cookie": [], + "body": "{\n \"type\": \"about:blank\",\n \"title\": \"Unprocessable Entity\",\n \"status\": 422,\n \"detail\": \"Order is already issued; use POST /{orderId}/revoke instead of /cancel.\"\n}" + } + ] + }, + { + "name": "Revoke Certificate", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"reason\": \"superseded\",\n \"note\": \"Replaced by newer cert.\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/private-pki-certificates/:orderId/revoke", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "private-pki-certificates", + ":orderId", + "revoke" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + }, + "description": "### What it does\nRevokes a certificate issued from the customer CA with an RFC 5280 reason.\n\n### Body\n- `reason` - one of: `unspecified`, `key-compromise`, `ca-compromise`, `affiliation-changed`, `superseded`, `cessation-of-operation`, `certificate-hold`, `privilege-withdrawn`, `aa-compromise`.\n- `note` - free-text audit note (optional).\n\n## Response\n\n| Field | Type | Notes |\n|---|---|---|\n| `orderId` | string | Echo of path parameter. |\n| `status` | enum | `revoked` on success. |\n| `revokedAt` | RFC 3339 datetime | Effective time of revocation (CA-recorded). |\n| `reason` | enum | Echo of the request reason (RFC 5280 code name). |\n\nRevocation is published in the next CRL/OCSP refresh cycle. Allow 5 - 15 minutes for relying parties to see the change.\n" + }, + "response": [ + { + "name": "204 No Content - revocation queued", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"reason\": \"superseded\",\n \"note\": \"Replaced by newer cert.\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/private-pki-certificates/:orderId/revoke", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "private-pki-certificates", + ":orderId", + "revoke" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "No Content", + "code": 204, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "" + }, + { + "name": "404 Not Found", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"reason\": \"superseded\",\n \"note\": \"Replaced by newer cert.\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/private-pki-certificates/:orderId/revoke", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "private-pki-certificates", + ":orderId", + "revoke" + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "Not Found", + "code": 404, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/problem+json" + } + ], + "cookie": [], + "body": "{\n \"type\": \"about:blank\",\n \"title\": \"Not Found\",\n \"status\": 404,\n \"detail\": \"Order not found or not in a revokable state.\"\n}" + } + ] + }, + { + "name": "Download Certificate", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "description": "application/json | application/x-pem-file | application/pkix-cert" + } + ], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/private-pki-certificates/:orderId/certificate", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "private-pki-certificates", + ":orderId", + "certificate" + ], + "query": [ + { + "key": "format", + "value": "pem", + "description": "Convenience override: `pem`, `der`, or `pkcs7` (alias of `der`). Wins over `Accept` when supplied. Leave disabled to drive selection from `Accept`.", + "disabled": true + } + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + }, + "description": "Download the issued cert. Choose format via the `Accept` header **or** the `?format=` query parameter (same semantics as SSL - query param wins when both are sent).\n\n## Response\n\nSame shape as SSL **Download Certificate (JSON / PEM / DER)** - the response format is driven by the `Accept` header:\n\n| Accept header | Response | Body |\n|---|---|---|\n| `application/json` | `200 OK` | `{ orderId, serialNumber, subject, issuer, notBefore, notAfter, certificatePem, chainPem[] }` |\n| `application/x-pem-file` | `200 OK` | Raw PEM (`-----BEGIN CERTIFICATE-----...`) |\n| `application/pkix-cert` | `200 OK` | Binary DER (leaf only) |\n\nReturns `409 Conflict` until `status = issued`.\n" + }, + "response": [ + { + "name": "200 OK - JSON envelope", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "description": "application/json | application/x-pem-file | application/pkix-cert" + } + ], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/private-pki-certificates/:orderId/certificate", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "private-pki-certificates", + ":orderId", + "certificate" + ], + "query": [ + { + "key": "format", + "value": "pem", + "description": "Convenience override: `pem`, `der`, or `pkcs7` (alias of `der`). Wins over `Accept` when supplied. Leave disabled to drive selection from `Accept`.", + "disabled": true + } + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"orderId\": \"ord_pki_8K9mQ2vR8nP4bL\",\n \"serialNumber\": \"0E:1B:7F:2A:3C:4D:5E:6F:7A:8B:9C:0D\",\n \"subject\": \"CN=intranet.acme.local, O=Acme Corporation, C=US\",\n \"issuer\": \"CN=Acme Internal Web Tier CA, O=Acme Corporation\",\n \"notBefore\": \"2026-05-12T14:00:00Z\",\n \"notAfter\": \"2027-05-12T14:00:00Z\",\n \"certificatePem\": \"-----BEGIN CERTIFICATE-----\\nMIIFx... (truncated)...AQ==\\n-----END CERTIFICATE-----\",\n \"chainPem\": [\n \"-----BEGIN CERTIFICATE-----\\nMIIEv... (intermediate)...AQ==\\n-----END CERTIFICATE-----\",\n \"-----BEGIN CERTIFICATE-----\\nMIIDr... (root)...AQ==\\n-----END CERTIFICATE-----\"\n ]\n}" + }, + { + "name": "422 Unprocessable - certificate not yet issued", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "description": "application/json | application/x-pem-file | application/pkix-cert" + } + ], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/private-pki-certificates/:orderId/certificate", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "private-pki-certificates", + ":orderId", + "certificate" + ], + "query": [ + { + "key": "format", + "value": "pem", + "description": "Convenience override: `pem`, `der`, or `pkcs7` (alias of `der`). Wins over `Accept` when supplied. Leave disabled to drive selection from `Accept`.", + "disabled": true + } + ], + "variable": [ + { + "key": "orderId", + "value": "{{orderId}}", + "description": "Order identifier returned by the create-order call (auto-captured into the orderId collection variable)." + } + ] + } + }, + "status": "Unprocessable Entity", + "code": 422, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/problem+json" + } + ], + "cookie": [], + "body": "{\n \"type\": \"about:blank\",\n \"title\": \"Unprocessable Entity\",\n \"status\": 422,\n \"detail\": \"Certificate is not yet issued for this order.\"\n}" + } + ] + } + ], + "description": "Certs from your **own** PKI hierarchy - for intranet servers, IGTF/grid hosts, IoT fleets. No public DCV, no Subscriber Agreement, no documents - your CA, your rules.\n\nTwo variants:\n- **Intranet SSL** - internal hostnames (`intranet.example.local`, IPs, etc.)\n- **IGTF Host** - Interoperable Global Trust Federation host certs (research grid, HPC).\n\n---\n\n### Field requirements (in body order)\n\n| Field | Mandatory / Optional |\n|---|---|\n| `variant` | **Mandatory** (`intranet-ssl` / `igtf-host`) |\n| `caProfileId` | Optional (derived from `X-Product-Code`) |\n| `masterProductId` | Optional (derived from `X-Product-Code`) |\n| `saveAsDraft` | Optional (default `false`) |\n| `requestId` | Conditional - only when promoting a `saveAsDraft` draft |\n| `emailNotifications` | Optional (default `all`) |\n| `groupNumber` | Optional |\n| `requestor` | **Mandatory** |\n| `requestor.name` | **Mandatory** |\n| `requestor.email` | **Mandatory** |\n| `requestor.phone` | Optional |\n| `requestor.designation` | Optional |\n| `hostname` | **Mandatory** |\n| `additionalHosts` | Optional |\n| `subscription` | Optional |\n| `subscription.validityYears` | Optional |\n| `subscription.autoRenew` | Optional (default ON) |\n| `subscription.renewBeforeDays` | Optional |\n| `csr` | Optional - required before issuance |\n| `remarks` | Optional |\n| `tags` | Optional |\n| `customFields` | Conditional - only if product mandates |\n| `customFields[].fieldId` | Conditional - required if `customFields` sent |\n| `customFields[].value` | Conditional - required if `customFields` sent |\n| `technicalPointOfContact` | Optional |\n| `technicalPointOfContact.name` | Optional |\n| `technicalPointOfContact.email` | Optional |\n| `technicalPointOfContact.phone` | Optional |\n| `technicalPointOfContact.designation` | Optional |\n\n**Strictly mandatory** (400 if missing): `variant`, `requestor.name`, `requestor.email`, `hostname`. Private PKI has no DCV, no organization block, and no Subscriber Agreement." + }, + { + "name": "Domains", + "item": [ + { + "name": "Add Domain", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "if (pm.response.code === 201 && body.domainId) {", + " pm.collectionVariables.set('domainId', body.domainId);", + " pm.test('Domain added, domainId=' + body.domainId, () => pm.expect(body.domainId).to.be.a('string'));", + "} else {", + " pm.test('Add FAILED: ' + (body.detail || body.title || JSON.stringify(body)), () => false);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"domainName\": \"example.com\",\n \"organizationId\": \"{{organizationNumber}}\",\n \"dcvMethod\": \"dns-txt\",\n \"skipCAA\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/domains", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "domains" + ] + }, + "description": "### What it does\nRegisters a domain under one of your pre-vetted organizations and queues it for DCV. The wrapper resolves your organization number to the internal CA org and runs the CAA pre-check (unless `skipCAA: true`).\n\n### Body\n- `domainName` - FQDN. Wildcards (`*.example.com`) allowed only with dns-txt method.\n- `organizationId` - public organization number from `Accounts -> List Organizations`.\n- `dcvMethod` - `dns-txt` (recommended) | `http-url`.\n- `skipCAA` - `true` to bypass CAA pre-check (only for accounts authorised to do so).\n\n## Response (`201 Created`)\n\n| Field | Type | Notes |\n|---|---|---|\n| `domainId` | string | Opaque V2 domain ID. Use it for `/domains/{id}/*` operations. |\n| `domainName` | string | Domain name as stored. |\n| `organizationId` | string | Owning organization ID (opaque). |\n| `status` | enum | Always `ACTIVE` on a fresh insert. |\n| `dcvStatus` | enum | `PENDING` for fresh inserts; `VERIFIED` only if a synchronous CAA pre-check + ADN auto-verify succeeded. |\n| `dcv` | object | DCV instructions for the chosen method - TXT value for `dns-txt`, file token + path for `http-url`. Same shape as **Get Domain**'s `dcv` field. |\n| `autoVerify` | enum | Async auto-verify hint: `scheduled` (poll the resource), `noEligibleAncestor` (manual verify required), `ineligible` (method is HTTP / wildcard). |\n| `createdAt` | RFC 3339 datetime | UTC. |\n" + }, + "response": [ + { + "name": "201 Created - domain queued for DCV", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Idempotency-Key", + "value": "{{idempotencyKey}}", + "description": "Client-supplied opaque value (UUID v4 recommended) for safe retries. Parsed today; enforced in a future release.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"domainName\": \"example.com\",\n \"organizationId\": \"{{organizationNumber}}\",\n \"dcvMethod\": \"dns-txt\",\n \"skipCAA\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/domains", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "domains" + ] + } + }, + "status": "Created", + "code": 201, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Location", + "value": "/api/certinext/v2/domains/dom_8K9mQ2vR8nP4bL" + } + ], + "cookie": [], + "body": "{\n \"domainId\": \"dom_8K9mQ2vR8nP4bL\",\n \"domainName\": \"example.com\",\n \"organizationId\": \"8K9mQ2vR8nP4bL\",\n \"domainStatus\": \"ACTIVE\",\n \"dcvStatus\": \"PENDING\",\n \"dcvMethod\": \"dns-txt\",\n \"dcv\": {\n \"method\": \"dns-txt\",\n \"txtToken\": \"emudhra-dcv-7f3a8b9d2c1e4f5a6b7c8d9e0f1a2b3c\",\n \"tokenExpiry\": \"2026-05-10T13:00:00Z\",\n \"instructions\": \"Publish a DNS TXT record at _emudhra-challenge.example.com with the value above.\"\n },\n \"autoVerify\": false,\n \"createdAt\": \"2026-05-08T13:00:00Z\"\n}" + }, + { + "name": "409 Conflict - already verified in another org", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Idempotency-Key", + "value": "{{idempotencyKey}}", + "description": "Client-supplied opaque value (UUID v4 recommended) for safe retries. Parsed today; enforced in a future release.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"domainName\": \"example.com\",\n \"organizationId\": \"{{organizationNumber}}\",\n \"dcvMethod\": \"dns-txt\",\n \"skipCAA\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/domains", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "domains" + ] + } + }, + "status": "Conflict", + "code": 409, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/problem+json" + } + ], + "cookie": [], + "body": "{\n \"type\": \"about:blank\",\n \"title\": \"Conflict\",\n \"status\": 409,\n \"detail\": \"This domain is already verified under another organization in your account.\",\n \"existingDomainId\": \"dom_otherorg123\"\n}" + }, + { + "name": "422 Unprocessable - CAA pre-check failed", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Idempotency-Key", + "value": "{{idempotencyKey}}", + "description": "Client-supplied opaque value (UUID v4 recommended) for safe retries. Parsed today; enforced in a future release.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"domainName\": \"example.com\",\n \"organizationId\": \"{{organizationNumber}}\",\n \"dcvMethod\": \"dns-txt\",\n \"skipCAA\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/domains", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "domains" + ] + } + }, + "status": "Unprocessable Entity", + "code": 422, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/problem+json" + } + ], + "cookie": [], + "body": "{\n \"type\": \"about:blank\",\n \"title\": \"Unprocessable Entity\",\n \"status\": 422,\n \"detail\": \"Domain validation failed. See diagnostics for per-resolver details.\",\n \"diagnostics\": {\n \"schemaVersion\": 1,\n \"method\": \"CAA_PRECHECK\",\n \"overallStatus\": \"FAILED\",\n \"failureClass\": \"CAA_FORBIDS\",\n \"consensus\": {\n \"perspectiveCount\": 4,\n \"quorumCount\": 0,\n \"agreed\": false,\n \"consensusValue\": \"letsencrypt.org\"\n },\n \"perspectives\": [\n {\n \"resolverIp\": \"1.1.1.1\",\n \"region\": \"us-east\",\n \"status\": \"FAILED\",\n \"failureClass\": \"CAA_FORBIDS\",\n \"found\": {\n \"rawData\": \"0 issue \\\"letsencrypt.org\\\"\"\n }\n },\n {\n \"resolverIp\": \"8.8.8.8\",\n \"region\": \"us-west\",\n \"status\": \"FAILED\",\n \"failureClass\": \"CAA_FORBIDS\",\n \"found\": {\n \"rawData\": \"0 issue \\\"letsencrypt.org\\\"\"\n }\n }\n ],\n \"nextSteps\": [\n {\n \"key\": \"dcv.nextStep.caaPrecheck.notAuthorized\",\n \"params\": {\n \"foundCa\": \"letsencrypt.org\"\n },\n \"severity\": \"ERROR\"\n }\n ]\n }\n}" + } + ] + }, + { + "name": "Renew Domain", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "if (pm.response.code === 200 && body.domainId) {", + " pm.collectionVariables.set('domainId', body.domainId);", + " pm.test('Domain renewed, dcvStatus=' + body.dcvStatus, () => pm.expect(body.dcvStatus).to.eql('PENDING'));", + "} else {", + " pm.test('Renew FAILED: ' + (body.detail || body.title || JSON.stringify(body)), () => false);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"domainName\": \"example.com\",\n \"organizationId\": \"{{organizationNumber}}\",\n \"dcvMethod\": \"dns-txt\",\n \"mode\": \"renew\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/domains", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "domains" + ] + }, + "description": "### What it does\nRenews an existing **independently-verified (ADN)** domain that is expired or within the renewal window. Same endpoint as **Add Domain**, but with `\"mode\": \"renew\"`. The wrapper resets the domain to `PENDING`, rotates DCV tokens, and re-pends inherited (auto-verified) children. Fetch the fresh DCV tokens via **Get Domain** afterwards.\n\n### Body\n- `domainName` - FQDN of the ADN to renew (must already exist under the org).\n- `organizationId` - public organization number.\n- `dcvMethod` - DCV method for the new validation cycle (`dns-txt` | `http-url` | `dns-cname` | `dns-persist`).\n- `mode` - must be `renew`.\n\n### Responses\n- `200 OK` - renewed; body mirrors Add Domain (`status: ACTIVE`, `dcvStatus: PENDING`).\n- `404` - domain not found (add it first) / organization not active.\n- `409` - inherited domain (renew the parent ADN instead) / renewal could not be completed.\n- `422` - not eligible yet (only within the renewal window or after expiry).\n- `403` - not authorized to manage this domain.\n" + }, + "response": [ + { + "name": "200 OK - domain renewed", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"domainName\": \"example.com\",\n \"organizationId\": \"{{organizationNumber}}\",\n \"dcvMethod\": \"dns-txt\",\n \"mode\": \"renew\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/domains", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "domains" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"domainId\": \"dom_8K9mQ2vR8nP4bL\",\n \"domainName\": \"example.com\",\n \"organizationId\": \"8K9mQ2vR8nP4bL\",\n \"status\": \"ACTIVE\",\n \"dcvStatus\": \"PENDING\",\n \"dcv\": {\n \"method\": \"dns-txt\"\n }\n}" + }, + { + "name": "409 Conflict - inherited domain cannot be renewed directly", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"domainName\": \"sub.example.com\",\n \"organizationId\": \"{{organizationNumber}}\",\n \"dcvMethod\": \"dns-txt\",\n \"mode\": \"renew\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/domains", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "domains" + ] + } + }, + "status": "Conflict", + "code": 409, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/problem+json" + } + ], + "cookie": [], + "body": "{\n \"type\": \"about:blank\",\n \"title\": \"Conflict\",\n \"status\": 409,\n \"detail\": \"Inherited domains cannot be renewed directly. Renew the parent (ADN); its inherited domains re-validate automatically.\"\n}" + }, + { + "name": "422 Unprocessable - not eligible for renewal yet", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"domainName\": \"example.com\",\n \"organizationId\": \"{{organizationNumber}}\",\n \"dcvMethod\": \"dns-txt\",\n \"mode\": \"renew\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/domains", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "domains" + ] + } + }, + "status": "Unprocessable Entity", + "code": 422, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/problem+json" + } + ], + "cookie": [], + "body": "{\n \"type\": \"about:blank\",\n \"title\": \"Unprocessable Entity\",\n \"status\": 422,\n \"detail\": \"Domain is not eligible for renewal yet. Renewal is available within the renewal window or after the domain has expired.\"\n}" + } + ] + }, + { + "name": "List Domains", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/domains?offset=0&limit=50", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "domains" + ], + "query": [ + { + "key": "search", + "value": "", + "description": "Substring match on the domain name by default (e.g. `example` matches `sub.example.com`). Set `exactMatch=true` for an exact match on the full domain name.", + "disabled": true + }, + { + "key": "exactMatch", + "value": "false", + "description": "true or false (default false). When true, matches `search` exactly against the full domain name instead of a substring (e.g. `search=example.com` returns only `example.com`).", + "disabled": true + }, + { + "key": "domainStatus", + "value": "", + "description": "Comma-separated. Values: ACTIVE, INACTIVE, EXPIRED.", + "disabled": true + }, + { + "key": "dcvStatus", + "value": "", + "description": "Comma-separated. Values: VERIFIED, PENDING, REJECTED.", + "disabled": true + }, + { + "key": "dcvMethod", + "value": "", + "description": "Comma-separated. Values: dns-txt, http-url.", + "disabled": true + }, + { + "key": "fromDate", + "value": "", + "description": "Domain createdAt >= this. Format: YYYY-MM-DD.", + "disabled": true + }, + { + "key": "toDate", + "value": "", + "description": "Domain createdAt <= this. Format: YYYY-MM-DD.", + "disabled": true + }, + { + "key": "offset", + "value": "0", + "description": "0-based row offset." + }, + { + "key": "limit", + "value": "50", + "description": "Page size (defaults to 50; large values are not server-clamped - keep <=200 to stay performant)." + }, + { + "key": "organizationId", + "value": "", + "description": "Scope to one organization (use {{organizationNumber}}).", + "disabled": true + }, + { + "key": "groupNumber", + "value": "", + "description": "Customer billing group number (from GET /groups).", + "disabled": true + }, + { + "key": "includePending", + "value": "", + "description": "true | false - include domains awaiting first DCV attempt.", + "disabled": true + }, + { + "key": "sortBy", + "value": "", + "description": "Sort field. Allowable values: createdAt, domainName, verifiedAt, validTill. Default: createdAt.", + "disabled": true + }, + { + "key": "sortDir", + "value": "", + "description": "asc | desc. Default: desc.", + "disabled": true + } + ] + }, + "description": "### What it does\nPaginated list of all domains under your account, with status / DCV state / org / dates.\n\n### Filters\n- `search` - substring match by default; set `exactMatch=true` for an exact match on the full domain name (e.g. `search=example.com`).\n- `domainStatus`, `dcvStatus`, `dcvMethod` - exact-match enum filters (comma-separated for multi-value, e.g. `?domainStatus=ACTIVE,EXPIRED`).\n- `fromDate` / `toDate` - created-date range.\n- `offset` / `limit` - pagination (offset/limit style; **not** page/size like Reports).\n\n## Response\n\nPaginated list, Spring-style envelope:\n\n| Top-level field | Type | Notes |\n|---|---|---|\n| `content` | array | Page of domain summaries. |\n| `page` | integer | 0-based page index. |\n| `size` | integer | Page size. |\n| `totalElements` | integer | Total domains across all pages. |\n| `totalPages` | integer | Total page count. |\n\nEach `content[]` item (slim projection - use `Get Domain` for the full record including `dcv`):\n\n| Field | Type | Notes |\n|---|---|---|\n| `domainId` | string | Opaque ID. |\n| `domainName` | string | The domain. |\n| `organizationId` | string | Owning organization (opaque). |\n| `organizationName` | string | Owning organization display name. |\n| `status` | enum | `ACTIVE` / `INACTIVE` / `EXPIRED`. |\n| `dcvStatus` | enum | `PENDING` / `VERIFIED` / `REJECTED`. |\n| `dcvMethod` | enum | `dns-txt` / `http-url`. |\n| `validTill` | RFC 3339 datetime | Reuse-window expiry; null until first VERIFIED. |\n| `createdAt` | RFC 3339 datetime | UTC. |\n| `verifiedAt` | RFC 3339 datetime | Most recent successful verification; null if never verified. |\n| `isWildcard` | boolean | `true` when stored as `*.example.com`. |\n" + }, + "response": [ + { + "name": "200 OK - first page", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/domains?offset=0&limit=50", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "domains" + ], + "query": [ + { + "key": "search", + "value": "", + "description": "Optional. Substring match by default; set exactMatch=true for an exact match on the full domain name.", + "disabled": true + }, + { + "key": "exactMatch", + "value": "false", + "description": "Optional. true or false (default false). When true, matches search exactly against the full domain name instead of a substring (e.g. search=example.com).", + "disabled": true + }, + { + "key": "domainStatus", + "value": "ACTIVE", + "description": "Optional. ACTIVE | INACTIVE | EXPIRED.", + "disabled": true + }, + { + "key": "dcvStatus", + "value": "VERIFIED", + "description": "Optional. PENDING | VERIFIED | REJECTED.", + "disabled": true + }, + { + "key": "dcvMethod", + "value": "DNS_TXT", + "description": "Optional. Filter by DCV method.", + "disabled": true + }, + { + "key": "fromDate", + "value": "2026-01-01", + "description": "Optional. Created on/after this date (YYYY-MM-DD).", + "disabled": true + }, + { + "key": "toDate", + "value": "2026-12-31", + "description": "Optional. Created on/before this date.", + "disabled": true + }, + { + "key": "offset", + "value": "0", + "description": "0-based row offset." + }, + { + "key": "limit", + "value": "50", + "description": "Page size (defaults to 50; large values are not server-clamped - keep <=200 to stay performant)." + }, + { + "key": "organizationId", + "value": "", + "description": "Scope to one organization (use {{organizationNumber}}).", + "disabled": true + }, + { + "key": "groupId", + "value": "", + "description": "Scope to one billing group.", + "disabled": true + }, + { + "key": "includePending", + "value": "", + "description": "true | false - include domains awaiting first DCV attempt.", + "disabled": true + }, + { + "key": "sortBy", + "value": "", + "description": "Field to sort by. Default: createdDate.", + "disabled": true + }, + { + "key": "sortDir", + "value": "", + "description": "asc | desc. Default: desc.", + "disabled": true + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"content\": [\n {\n \"domainId\": \"dom_abc123\",\n \"domainName\": \"example.com\",\n \"organizationId\": \"8K9mQ2vR8nP4bL\",\n \"domainStatus\": \"ACTIVE\",\n \"dcvStatus\": \"VERIFIED\",\n \"dcvMethod\": \"dns-txt\",\n \"createdAt\": \"2026-04-12T10:00:00Z\",\n \"verifiedAt\": \"2026-04-12T10:05:00Z\",\n \"validTill\": \"2027-04-12T10:00:00Z\"\n },\n {\n \"domainId\": \"dom_def456\",\n \"domainName\": \"shop.example.com\",\n \"organizationId\": \"8K9mQ2vR8nP4bL\",\n \"domainStatus\": \"ACTIVE\",\n \"dcvStatus\": \"PENDING\",\n \"dcvMethod\": \"http-url\",\n \"createdAt\": \"2026-05-08T13:00:00Z\",\n \"verifiedAt\": null,\n \"validTill\": null\n }\n ],\n \"page\": 0,\n \"size\": 50,\n \"totalElements\": 2,\n \"totalPages\": 1\n}" + } + ] + }, + { + "name": "Get Domain", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/domains/{{domainId}}", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "domains", + "{{domainId}}" + ] + }, + "description": "Full row for a single domain - name, status, dcvStatus, dcvMethod, current DCV challenge artefact (if generated), validTill, verifiedDate. Returns **404** if the domain isn't owned by your account.\n\n## Response\n\n| Field | Type | Notes |\n|---|---|---|\n| `domainId` | string | Opaque domain ID. |\n| `domainName` | string | The domain. Wildcard domains use the `*.example.com` form. |\n| `organizationId` | string | Owning organization (opaque ID). |\n| `organizationName` | string | Owning organization display name. |\n| `status` | enum | `ACTIVE` / `INACTIVE` / `EXPIRED`. |\n| `dcvStatus` | enum | `PENDING` / `VERIFIED` / `REJECTED`. |\n| `dcvMethod` | enum | Current method: `dns-txt` / `http-url`. |\n| `dcv` | object | Method-specific instructions; see below. |\n| `validTill` | RFC 3339 datetime | Domain reuse-window expiry (BR section 3.2.2.5). `null` until first VERIFIED. |\n| `isWildcard` | boolean | `true` when stored as `*.example.com`. |\n| `createdAt` | RFC 3339 datetime | UTC. |\n| `verifiedAt` | RFC 3339 datetime | Most recent successful verification. Null if never verified. |\n\n### `dcv` sub-object\n\nOnly fields relevant to the active `dcvMethod` are populated:\n\n| Field | Type | Populated for | Notes |\n|---|---|---|---|\n| `method` | enum | always | Current method. |\n| `txtToken` | string | `dns-txt` | TXT record value to publish on `_certinext-challenge.`. |\n| `fileToken` | string | `http-url` | Token to place inside the well-known file. |\n| `fileName` | string | `http-url` | Relative path, e.g. `.well-known/pki-validation/.txt`. |\n| `tokenExpiry` | RFC 3339 datetime | always | After this, `Verify DCV` rejects until a new token is generated. |\n| `instructions` | string | always | Human-readable next steps. |\n" + }, + "response": [ + { + "name": "200 OK - verified domain", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/domains/{{domainId}}", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "domains", + "{{domainId}}" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"domainId\": \"dom_abc123\",\n \"domainName\": \"example.com\",\n \"organizationId\": \"8K9mQ2vR8nP4bL\",\n \"domainStatus\": \"ACTIVE\",\n \"dcvStatus\": \"VERIFIED\",\n \"dcvMethod\": \"dns-txt\",\n \"dcv\": {\n \"method\": \"dns-txt\",\n \"txtToken\": \"emudhra-dcv-7f3a8b9d2c1e4f5a6b7c8d9e0f1a2b3c\",\n \"tokenExpiry\": \"2026-04-14T10:00:00Z\",\n \"instructions\": \"TXT record published; verified on 2026-04-12.\"\n },\n \"createdAt\": \"2026-04-12T10:00:00Z\",\n \"verifiedAt\": \"2026-04-12T10:05:00Z\",\n \"validTill\": \"2027-04-12T10:00:00Z\"\n}" + }, + { + "name": "404 Not Found", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/domains/{{domainId}}", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "domains", + "{{domainId}}" + ] + } + }, + "status": "Not Found", + "code": 404, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/problem+json" + } + ], + "cookie": [], + "body": "{\n \"type\": \"about:blank\",\n \"title\": \"Not Found\",\n \"status\": 404,\n \"detail\": \"Domain not found under this account.\"\n}" + } + ] + }, + { + "name": "Get DCV", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/domains/{{domainId}}/dcv", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "domains", + "{{domainId}}", + "dcv" + ] + }, + "description": "### What it does\nReturns the DCV challenge artefact for this domain's current method. **The first call auto-generates and persists the token** - there's no separate \"send\" step for DNS/HTTP.\n\n### Response examples\n**dns-txt** - publish the value at `_emudhra-challenge.example.com TXT \"\"`:\n```json\n{ \"method\": \"dns-txt\", \"txtToken\": \"emudhra-dcv-\" }\n```\n**http-url** - host the file at `http://example.com/.well-known/pki-validation/`:\n```json\n{ \"method\": \"http-url\", \"fileName\": \".txt\", \"fileToken\": \"\" }\n```\n### Token lifetime\n48 hours. Re-call this endpoint after expiry; a fresh token is generated automatically.\n\n### Next\n-> Publish the artefact, then **Verify DCV**." + }, + "response": [ + { + "name": "200 OK - dns-txt challenge", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/domains/{{domainId}}/dcv", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "domains", + "{{domainId}}", + "dcv" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"method\": \"dns-txt\",\n \"txtToken\": \"emudhra-dcv-7f3a8b9d2c1e4f5a6b7c8d9e0f1a2b3c\",\n \"tokenExpiry\": \"2026-05-10T13:00:00Z\",\n \"instructions\": \"Publish a TXT record at _emudhra-challenge.example.com with the token above. Then POST /verify.\"\n}" + }, + { + "name": "200 OK - http-url challenge", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/domains/{{domainId}}/dcv", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "domains", + "{{domainId}}", + "dcv" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"method\": \"http-url\",\n \"fileToken\": \"7f3a8b9d2c1e4f5a6b7c8d9e0f1a2b3c\",\n \"fileName\": \".well-known/pki-validation/CHALLENGE.txt\",\n \"tokenExpiry\": \"2026-05-10T13:00:00Z\",\n \"instructions\": \"Host the file at http://example.com/.well-known/pki-validation/CHALLENGE.txt with the token as content. Then POST /verify.\"\n}" + } + ] + }, + { + "name": "Change DCV Method", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{ \"dcvMethod\": \"http-url\" }", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/domains/{{domainId}}/dcv/method", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "domains", + "{{domainId}}", + "dcv", + "method" + ] + }, + "description": "### What it does\nSwitches the domain's DCV method. The previous token is invalidated; the new method's artefact is generated on the next **Get DCV** call.\n\n### Body\n- `dcvMethod` - `dns-txt` | `http-url`.\n\n### Response\n```json\n{ \"method\": \"http-url\", \"fileName\": \".txt\", \"fileToken\": \"\" }\n```\n\n### When to switch\n- DNS not under your control -> `http-url`.\n- Web server not exposed to the internet -> `dns-txt`." + }, + "response": [ + { + "name": "200 OK - switched to http-url", + "originalRequest": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Idempotency-Key", + "value": "{{idempotencyKey}}", + "description": "Client-supplied opaque value (UUID v4 recommended) for safe retries. Parsed today; enforced in a future release.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{ \"dcvMethod\": \"http-url\" }", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/domains/{{domainId}}/dcv/method", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "domains", + "{{domainId}}", + "dcv", + "method" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"method\": \"http-url\",\n \"fileToken\": \"8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d\",\n \"fileName\": \".well-known/pki-validation/CHALLENGE.txt\",\n \"tokenExpiry\": \"2026-05-10T13:00:00Z\"\n}" + } + ] + }, + { + "name": "Verify DCV", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/domains/{{domainId}}/dcv/verify", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "domains", + "{{domainId}}", + "dcv", + "verify" + ] + }, + "description": "### What it does\nRuns the per-perspective MPIC verification. The CA queries multiple geographic vantage points, then checks the artefact you published.\n\n### On success\n- `dcvStatus` flips to `VERIFIED`.\n- Domain becomes reusable in any SSL order under this account (no more DCV per cert).\n\n### On failure\n- **422** with a `diagnostics` block in the ProblemDetail showing per-perspective results, DNSSEC chain, and recommended next steps.\n- Common causes: DNS not propagated yet (typical TTL 5-60 min), wrong record name (`_emudhra-challenge` vs apex), token mismatch, CAA record blocks emSign.\n\n## Response\n\n| Field | Type | Notes |\n|---|---|---|\n| `status` | enum | DCV result after this attempt: `PENDING` / `VERIFIED` / `REJECTED`. |\n| `validatedAt` | RFC 3339 datetime | Set only when `status = VERIFIED`. |\n| `diagnostics` | object | Per-perspective MPIC block (see _diagnostics shape_ below). Always populated. |\n\n### `diagnostics` shape\n\nThe block carries the BR-MPIC consensus output. Useful keys:\n\n| Key | Type | Notes |\n|---|---|---|\n| `consensus.agreed` | boolean | Whether perspectives reached quorum. |\n| `consensus.consensusValue` | string | The value perspectives agreed on (e.g. observed TXT token). |\n| `consensus.perspectiveCount` | integer | Number of vantage points consulted. |\n| `consensus.quorumCount` | integer | Quorum threshold (typically 3). |\n| `consensus.rirDiversityMet` | boolean | Whether the perspectives spanned enough Regional Internet Registries. |\n| `perspectives[]` | array | One entry per vantage point. Each has `resolverIp`, `responseUrlValue` / `txtValue`, `errorMessage`, `dnssec.{validated,evidence}`. |\n| `nextSteps[]` | array | Human-readable remediation hints. |\n| `method` | string | MPIC method name (`CAA_TXT`, `CAA_EMAIL`, `WEBSITE_CHANGE`, etc.). |\n| `failureClass` | string | One of `OK`, `DNS_NXDOMAIN`, `DNS_TIMEOUT`, `TOKEN_MISMATCH`, `CAA_MISSING`, `HTTP_4XX`, etc. Useful for programmatic retry logic. |\n\nSame `diagnostics` shape is returned by `/domains/{id}/dcv/attempts/last` and `/domains/{id}/dcv/attempts/{attemptId}`.\n" + }, + "response": [ + { + "name": "200 OK - verified", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Idempotency-Key", + "value": "{{idempotencyKey}}", + "description": "Client-supplied opaque value (UUID v4 recommended) for safe retries. Parsed today; enforced in a future release.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/domains/{{domainId}}/dcv/verify", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "domains", + "{{domainId}}", + "dcv", + "verify" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"overallStatus\": \"VERIFIED\",\n \"method\": \"dns-txt\",\n \"verifiedAt\": \"2026-05-08T13:10:00Z\",\n \"diagnostics\": {\n \"schemaVersion\": 1,\n \"method\": \"dns-txt\",\n \"overallStatus\": \"VERIFIED\",\n \"consensus\": {\n \"perspectiveCount\": 4,\n \"quorumCount\": 4,\n \"agreed\": true\n },\n \"perspectives\": [\n {\n \"resolverIp\": \"1.1.1.1\",\n \"region\": \"us-east\",\n \"status\": \"OK\"\n },\n {\n \"resolverIp\": \"8.8.8.8\",\n \"region\": \"us-west\",\n \"status\": \"OK\"\n }\n ]\n }\n}" + }, + { + "name": "422 Unprocessable - record missing", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Idempotency-Key", + "value": "{{idempotencyKey}}", + "description": "Client-supplied opaque value (UUID v4 recommended) for safe retries. Parsed today; enforced in a future release.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/domains/{{domainId}}/dcv/verify", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "domains", + "{{domainId}}", + "dcv", + "verify" + ] + } + }, + "status": "Unprocessable Entity", + "code": 422, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/problem+json" + } + ], + "cookie": [], + "body": "{\n \"type\": \"about:blank\",\n \"title\": \"Unprocessable Entity\",\n \"status\": 422,\n \"detail\": \"DCV verification failed. See diagnostics for per-resolver details.\",\n \"diagnostics\": {\n \"schemaVersion\": 1,\n \"method\": \"dns-txt\",\n \"overallStatus\": \"FAILED\",\n \"failureClass\": \"RECORD_MISSING\",\n \"consensus\": {\n \"perspectiveCount\": 4,\n \"quorumCount\": 0,\n \"agreed\": false\n },\n \"perspectives\": [\n {\n \"resolverIp\": \"1.1.1.1\",\n \"region\": \"us-east\",\n \"status\": \"FAILED\",\n \"failureClass\": \"RECORD_MISSING\"\n }\n ],\n \"nextSteps\": [\n {\n \"key\": \"dcv.nextStep.txt.recordMissing\",\n \"params\": {\n \"recordName\": \"_emudhra-challenge.example.com\",\n \"recordValue\": \"emudhra-dcv-...\"\n },\n \"severity\": \"ERROR\"\n }\n ]\n }\n}" + } + ] + }, + { + "name": "Last DCV Attempt", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('200 OK', () => pm.response.to.have.status(200));", + "const body = pm.response.json();", + "if (body && body.attemptId) {", + " pm.collectionVariables.set('attemptId', body.attemptId);", + " console.log('Captured attemptId:', body.attemptId);", + "}" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/domains/{{domainId}}/dcv/attempts/last", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "domains", + "{{domainId}}", + "dcv", + "attempts", + "last" + ] + }, + "description": "Returns the most recent DCV attempt with full diagnostics - per-perspective DNS results, DNSSEC chain, HTTP status codes, raw record data. Use this to debug a failed Verify DCV. **404** if no attempt has been made yet.\n\n## Response\n\n| Field | Type | Notes |\n|---|---|---|\n| `attemptId` | string | Opaque attempt ID, unique per domain. |\n| `domainId` | string | Echo of the path parameter. |\n| `method` | enum | DCV method attempted (`dns-txt` / `http-url`). |\n| `status` | enum | Result: `VERIFIED` / `REJECTED` / `PENDING`. |\n| `attemptedAt` | RFC 3339 datetime | UTC. |\n| `diagnostics` | object | Full MPIC diagnostics block - same shape as **Verify DCV** response. |\n\nReturns `404` when the domain has no attempts yet.\n" + }, + "response": [ + { + "name": "200 OK - most recent attempt", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/domains/{{domainId}}/dcv/attempts/last", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "domains", + "{{domainId}}", + "dcv", + "attempts", + "last" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"attemptId\": \"12345\",\n \"domainId\": \"dom_abc123\",\n \"method\": \"dns-txt\",\n \"overallStatus\": \"VERIFIED\",\n \"attemptedAt\": \"2026-05-08T13:10:00Z\",\n \"failureClass\": null,\n \"diagnostics\": {\n \"schemaVersion\": 1,\n \"method\": \"dns-txt\",\n \"overallStatus\": \"VERIFIED\",\n \"consensus\": {\n \"perspectiveCount\": 4,\n \"quorumCount\": 4,\n \"agreed\": true\n }\n }\n}" + }, + { + "name": "404 Not Found - no attempts yet", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/domains/{{domainId}}/dcv/attempts/last", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "domains", + "{{domainId}}", + "dcv", + "attempts", + "last" + ] + } + }, + "status": "Not Found", + "code": 404, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/problem+json" + } + ], + "cookie": [], + "body": "{\n \"type\": \"about:blank\",\n \"title\": \"Not Found\",\n \"status\": 404,\n \"detail\": \"No DCV attempts found for this domain.\"\n}" + } + ] + }, + { + "name": "DCV Attempt History", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('200 OK', () => pm.response.to.have.status(200));", + "const body = pm.response.json();", + "const first = body && body.content && body.content[0];", + "if (first && first.attemptId) {", + " pm.collectionVariables.set('attemptId', first.attemptId);", + " console.log('Captured first attemptId:', first.attemptId);", + "}" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/domains/{{domainId}}/dcv/attempts?offset=0&limit=20", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "domains", + "{{domainId}}", + "dcv", + "attempts" + ], + "query": [ + { + "key": "offset", + "value": "0", + "description": "0-based offset." + }, + { + "key": "limit", + "value": "20", + "description": "Page size (max 50)." + } + ] + }, + "description": "Paginated list of all DCV attempts on this domain - for compliance audit trails. Each row carries `attemptId` (use the next request to fetch full diagnostics), method, status, attemptedAt.\n\n## Response\n\nPaginated list, Spring-style envelope:\n\n| Top-level field | Type | Notes |\n|---|---|---|\n| `content` | array | Page of attempt summaries. |\n| `page` | integer | 0-based page index. |\n| `size` | integer | Page size. |\n| `totalElements` | integer | Total attempts across all pages. |\n| `totalPages` | integer | Total page count. |\n\nEach `content[]` item (lightweight - no full diagnostics block; fetch the individual attempt for that):\n\n| Field | Type | Notes |\n|---|---|---|\n| `attemptId` | string | Opaque. Pass to **Get DCV Attempt Details** for the full diagnostics block. |\n| `attemptedAt` | RFC 3339 datetime | UTC. |\n| `method` | enum | Method attempted (`dns-txt` / `http-url`). |\n| `overallStatus` | enum | `VERIFIED` / `REJECTED` / `PENDING`. |\n| `failureClass` | string | Consensus-engine failure class; `OK` when verified. e.g. `DNS_NXDOMAIN`, `TOKEN_MISMATCH`, `CAA_MISSING`. |\n" + }, + "response": [ + { + "name": "200 OK - paged history", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/domains/{{domainId}}/dcv/attempts?offset=0&limit=20", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "domains", + "{{domainId}}", + "dcv", + "attempts" + ], + "query": [ + { + "key": "offset", + "value": "0", + "description": "0-based offset." + }, + { + "key": "limit", + "value": "20", + "description": "Page size (max 50)." + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"content\": [\n {\n \"attemptId\": \"12345\",\n \"attemptedAt\": \"2026-05-08T13:10:00Z\",\n \"method\": \"dns-txt\",\n \"overallStatus\": \"VERIFIED\",\n \"failureClass\": null\n },\n {\n \"attemptId\": \"12344\",\n \"attemptedAt\": \"2026-05-08T12:55:00Z\",\n \"method\": \"dns-txt\",\n \"overallStatus\": \"FAILED\",\n \"failureClass\": \"RECORD_MISSING\"\n }\n ],\n \"page\": 0,\n \"size\": 20,\n \"totalElements\": 2,\n \"totalPages\": 1\n}" + } + ] + }, + { + "name": "Get DCV Attempt Details", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/domains/{{domainId}}/dcv/attempts/{{attemptId}}", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "domains", + "{{domainId}}", + "dcv", + "attempts", + "{{attemptId}}" + ] + }, + "description": "Full diagnostics for one specific attempt by ID. Drop in an `attemptId` from the History list.\n\n## Response\n\nSame shape as **Last DCV Attempt** - returns the full diagnostics block (consensus, perspectives, dnssec evidence) for the specified attempt. See **Last DCV Attempt** for field-by-field documentation.\n" + }, + "response": [ + { + "name": "200 OK - attempt detail", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/domains/{{domainId}}/dcv/attempts/{{attemptId}}", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "domains", + "{{domainId}}", + "dcv", + "attempts", + "{{attemptId}}" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"attemptId\": \"12345\",\n \"domainId\": \"dom_abc123\",\n \"method\": \"dns-txt\",\n \"overallStatus\": \"VERIFIED\",\n \"attemptedAt\": \"2026-05-08T13:10:00Z\",\n \"failureClass\": null,\n \"diagnostics\": {\n \"schemaVersion\": 1,\n \"method\": \"dns-txt\",\n \"overallStatus\": \"VERIFIED\",\n \"consensus\": {\n \"perspectiveCount\": 4,\n \"quorumCount\": 4,\n \"agreed\": true\n }\n }\n}" + } + ] + }, + { + "name": "Deactivate Domain", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/domains/{{domainId}}/deactivate", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "domains", + "{{domainId}}", + "deactivate" + ] + }, + "description": "### What it does\nSoft-deactivates the domain (`status` -> `INACTIVE`). Existing certificates remain valid; future SSL orders for this domain are blocked until it's re-added.\n\n### Why no hard delete?\nBR section 5.4.1 mandates audit retention. The deactivate endpoint replaces what would otherwise be a `DELETE`.\n\n## Response\n\n| Field | Type | Notes |\n|---|---|---|\n| `domainId` | string | Echo of the path parameter. |\n| `status` | enum | Always `INACTIVE` on success. |\n\nDeactivating a verified domain does **not** revoke any certificates issued for it - those continue serving until their own expiry/revocation. Deactivation just removes the domain from your eligible set for new orders.\n" + }, + "response": [ + { + "name": "200 OK - deactivated", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Idempotency-Key", + "value": "{{idempotencyKey}}", + "description": "Client-supplied opaque value (UUID v4 recommended) for safe retries. Parsed today; enforced in a future release.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/domains/{{domainId}}/deactivate", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "domains", + "{{domainId}}", + "deactivate" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"domainId\": \"dom_abc123\",\n \"domainName\": \"example.com\",\n \"domainStatus\": \"INACTIVE\",\n \"deactivatedAt\": \"2026-05-08T13:30:00Z\",\n \"message\": \"Domain deactivated. Existing certificates remain valid; new orders for this domain are blocked until re-added.\"\n}" + } + ] + } + ], + "description": "Pre-register domains and run Domain Control Validation (DCV) ahead of certificate ordering. Once a domain is verified under your account, every later SSL/TLS order against that domain skips DCV.\n\n**Workflow:** Add Domain -> Get DCV (auto-generates the TXT/HTTP token) -> Publish -> Verify DCV -> Use freely in SSL orders.\n\nMatches the Hub UI's *Manage Domains* page 1:1 - list, view, change DCV method, deactivate (BR section 5.4.1 audit-safe; no hard delete), DCV attempt history." + }, + { + "name": "Accounts", + "item": [ + { + "name": "Who am I (from token)", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/auth/me", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "auth", + "me" + ] + }, + "description": "### What it does\nReturns the account context the Bearer token resolves to.\n\n## Response\n\n| Field | Type | Notes |\n|---|---|---|\n| `accountNumber` | string | Account number bound to the Bearer token. Same value you sent as `client_id` when minting the token. |\n| `authType` | string | Token grant family: `oauth2` for OAuth2.1, `legacy` for legacy HMAC-signed credentials. |\n\nUseful as a health-check after obtaining a token. Returns `401` when the Bearer is missing or rejected.\n" + }, + "response": [ + { + "name": "200 OK - authenticated identity", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/auth/me", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "auth", + "me" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"accountNumber\": \"1234567890\",\n \"authType\": \"oauth2\"\n}" + } + ] + }, + { + "name": "List Groups", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/groups", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "groups" + ] + }, + "description": "### What it does\nReturns the billing groups your account can charge against. Each group has a `groupNumber` (use in `groupNumber` field on order creation), nested `organizationDetails`, and current account balance.\n\n### Use\nPick a `groupNumber` for cost-centre allocation in OV/EV SSL orders, or omit and let the wrapper default to your `isDefaultDivision=1` group.\n\n## Response\n\n| Top-level field | Type | Notes |\n|---|---|---|\n| `groups` | array | Groups (divisions) under your account. |\n\nEach group:\n\n| Field | Type | Notes |\n|---|---|---|\n| `groupNumber` | string | Opaque group ID. Pass on order creation when forwarding to a non-default group. |\n| `groupName` | string | Display name. |\n| `isDefaultGroup` | `\"0\"` \\| `\"1\"` | `\"1\"` when this is your account's default group. Order endpoints fall back to it when no group is specified. |\n| `finance` | `\"0\"` \\| `\"1\"` | `\"1\"` when the group has its own billing wallet; `\"0\"` when group billing rolls up to the account. |\n| `groupBalance` | string | Group-level wallet balance (post-paid customers only). Empty when `finance = \"0\"`. |\n| `accountBalance` | string | Account-level wallet balance (always present). Negative values indicate available credit. |\n| `organizationDetails` | array | Organizations mapped to this group. Each: `organizationNumber`, `organizationName`, `validationFor`. |\n\n### `validationFor` values\n\nApplies wherever `validationFor` appears across the API:\n\n| Value | Meaning |\n|---|---|\n| `\"1\"` | OV - Organization Validation |\n| `\"2\"` | EV - Extended Validation |\n| `\"3\"` | IV - Individual / S/MIME |\n" + }, + "response": [ + { + "name": "200 OK - two groups", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/groups", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "groups" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"groups\": [\n {\n \"groupNumber\": \"GRP-001\",\n \"groupName\": \"Default Group\",\n \"accountManager\": {\n \"name\": \"Pat Manager\",\n \"email\": \"pat.manager@example.com\"\n },\n \"status\": \"ACTIVE\"\n },\n {\n \"groupNumber\": \"GRP-002\",\n \"groupName\": \"Cloud Infrastructure\",\n \"accountManager\": {\n \"name\": \"Sam Lead\",\n \"email\": \"sam.lead@example.com\"\n },\n \"status\": \"ACTIVE\"\n }\n ]\n}" + } + ] + }, + { + "name": "List Organizations", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/organizations", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "organizations" + ], + "query": [ + { + "key": "groupNumber", + "value": "{{groupNumber}}", + "description": "Optional - filter to a specific billing group.", + "disabled": true + } + ] + }, + "description": "### What it does\nLists pre-vetted organizations under your account. Each entry's `organizationNumber` plugs into `organization.organizationNumber` on OV/EV SSL orders to skip re-vetting.\n\n## Response\n\n| Top-level field | Type | Notes |\n|---|---|---|\n| `organizations` | array | Organizations registered under your account. |\n\nEach entry:\n\n| Field | Type | Notes |\n|---|---|---|\n| `organizationNumber` | string | Opaque organization ID. Pass on order creation as `organization.organizationNumber`. |\n| `organizationName` | string | Legal name as registered with the CA. |\n| `organizationLocality` | string | City. |\n| `organizationCountryCode` | string | ISO 3166-1 alpha-2 (`IN`, `US`, `GB`, ...). |\n| `organizationPostalCode` | string | ZIP / postal code. |\n| `organizationStatusId` | `\"0\"` \\| `\"1\"` | Lifecycle status: `\"1\"` = active, `\"0\"` = inactive. |\n| `isPreVettingOrg` | `\"0\"` \\| `\"1\"` | `\"1\"` when the organization is pre-vetted; OV/EV orders can be placed against it without per-order organization verification. |\n\nUse `Get Organization` for the full record (address, representatives, pre-vetted domains, subscriber-agreement status).\n" + }, + "response": [ + { + "name": "200 OK - two pre-vetted organizations", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/organizations", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "organizations" + ], + "query": [ + { + "key": "groupNumber", + "value": "{{groupNumber}}", + "description": "Optional - filter to a specific billing group.", + "disabled": true + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"organizations\": [\n {\n \"organizationNumber\": \"2368754851\",\n \"organizationName\": \"Acme Corporation\",\n \"businessCategory\": \"Business Entity\",\n \"address\": {\n \"city\": \"San Francisco\",\n \"state\": \"CA\",\n \"country\": \"US\"\n },\n \"preVetted\": true,\n \"status\": \"ACTIVE\"\n },\n {\n \"organizationNumber\": \"2969772\",\n \"organizationName\": \"Acme Holdings Ltd\",\n \"businessCategory\": \"Business Entity\",\n \"address\": {\n \"city\": \"London\",\n \"state\": \"\",\n \"country\": \"GB\"\n },\n \"preVetted\": true,\n \"status\": \"ACTIVE\"\n }\n ]\n}" + } + ] + }, + { + "name": "Get Organization", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/organizations/{{organizationNumber}}", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "organizations", + "{{organizationNumber}}" + ] + }, + "description": "Full org details: address, representatives, linked verified domains, subscriber-agreement status. Returns **404** if the orgNumber doesn't belong to your account.\n\n## Response\n\n| Field | Type | Notes |\n|---|---|---|\n| `organizationNumber` | string | Opaque organization ID. |\n| `organizationName` | string | Legal name as registered with the CA. |\n| `organizationStreetAddress1` | string | Line 1 of registered address. |\n| `organizationStreetAddress2` | string | Line 2 (optional). |\n| `organizationLocality` | string | City. |\n| `organizationStateName` | string | Full state / region name. |\n| `organizationStateCode` | string | State code (ISO 3166-2 second-level). |\n| `organizationPostalCode` | string | ZIP / postal code. |\n| `organizationCountryCode` | string | ISO 3166-1 alpha-2. |\n| `organizationStatusId` | `\"0\"` \\| `\"1\"` | `\"1\"` = active, `\"0\"` = inactive. |\n| `validationStatusId` | string | OV/EV validation state. See **validationStatusId values** below. |\n| `validationFor` | string | `\"1\"` = OV, `\"2\"` = EV, `\"3\"` = IV. |\n| `businessCategoryId` | string | EV business category. Populated only for EV orgs. See **businessCategoryId values** below. |\n| `subscriberAgreement` | object | `{ signedDate, signedPlace, signerName, signed }`. `signed = true` when accepted. |\n| `domains` | string[] | Domains pre-vetted under this organization - usable on order creation without per-order DCV. |\n| `orgRepresentatives` | array | Authorized representatives. Each: `name`, `designation`, `emailId`, `mobileNumber`, `isdCode`, `representativeNumber`. |\n\n### `validationStatusId` values\n\n| Value | Meaning |\n|---|---|\n| `\"0\"` | Pending validation - no LRA/VA decision yet, or under review |\n| `\"1\"` | Validated - organization is approved and usable on OV/EV orders |\n| `\"2\"` | Rejected - the organization application was rejected |\n| `\"3\"` | Cancelled by user |\n| `\"4\"` | Expired - approval lapsed; must be re-validated |\n| `\"5\"` | Under discrepancy - LRA/VA flagged the application; awaiting correction |\n\n### `businessCategoryId` values (EV only)\n\nPopulated only when `validationFor = \"2\"`. Common values:\n\n| Value | Meaning |\n|---|---|\n| `\"1\"` | Private Organization |\n| `\"2\"` | Government Entity |\n| `\"3\"` | Business Entity |\n| `\"4\"` | Non-Commercial Entity |\n" + }, + "response": [ + { + "name": "200 OK - full organization detail", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/organizations/{{organizationNumber}}", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "organizations", + "{{organizationNumber}}" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"organizationNumber\": \"2368754851\",\n \"organizationName\": \"Acme Corporation\",\n \"businessCategory\": \"Business Entity\",\n \"address\": {\n \"streetAddress1\": \"500 Market Street\",\n \"streetAddress2\": \"Suite 300\",\n \"city\": \"San Francisco\",\n \"state\": \"CA\",\n \"postalCode\": \"94105\",\n \"country\": \"US\"\n },\n \"preVetted\": true,\n \"status\": \"ACTIVE\",\n \"linkedDomains\": [\n {\n \"domainId\": \"dom_abc123\",\n \"domainName\": \"example.com\",\n \"dcvStatus\": \"VERIFIED\"\n },\n {\n \"domainId\": \"dom_def456\",\n \"domainName\": \"acme.com\",\n \"dcvStatus\": \"VERIFIED\"\n }\n ],\n \"representatives\": [\n {\n \"name\": \"Jane Doe\",\n \"email\": \"jane@example.com\",\n \"role\": \"Authorized Signer\"\n }\n ],\n \"agreement\": {\n \"accepted\": true,\n \"acceptedAt\": \"2026-03-15T10:00:00Z\",\n \"signerName\": \"Jane Doe\"\n }\n}" + }, + { + "name": "404 Not Found", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/organizations/{{organizationNumber}}", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "organizations", + "{{organizationNumber}}" + ] + } + }, + "status": "Not Found", + "code": 404, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/problem+json" + } + ], + "cookie": [], + "body": "{\n \"type\": \"about:blank\",\n \"title\": \"Not Found\",\n \"status\": 404,\n \"detail\": \"Organization 9999999999 not found.\"\n}" + } + ] + } + ], + "description": "Identity, billing groups, and pre-vetted organizations. Call these **before** placing orders to discover your entitlements (org numbers, group numbers)." + }, + { + "name": "Catalog", + "item": [ + { + "name": "List Products", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/catalog/products", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "catalog", + "products" + ], + "query": [ + { + "key": "groupNumber", + "value": "{{groupNumber}}", + "description": "Optional - scope to a billing group. When omitted, defaults to your account's default group.", + "disabled": true + } + ] + }, + "description": "### What it does\nReturns every product your account is entitled to order, grouped by category (Document Signer / S/MIME / SSL/TLS / Private PKI). Each entry has a stable `productCode` + `productName` + pricing per validity year.\n\n### Behaviour\n- **No `groupNumber`** - wrapper defaults to your account's default billing group (`isDefaultDivision=1`). The previous behaviour of returning empty has been fixed.\n- **With `groupNumber`** - strictly scoped to that group's entitlements.\n\n### Sample response\n```json\n{\n \"products\": [{\n \"currencyType\": \"USD\",\n \"categoryName\": \"SSL/TLS Certificates\",\n \"categoryID\": \"3\",\n \"products\": [\n { \"productCode\": \"842\", \"productName\": \"DV SSL Certificate\", \"subscriptionPrice\": { \"1 Year\": { \"cost\": \"100.0\" } } },\n { \"productCode\": \"843\", \"productName\": \"DV SSL Certificate Wildcard\",... }\n ]\n }]\n}\n```\n\n## Response\n\n| Top-level field | Type | Notes |\n|---|---|---|\n| `products` | array | Product groups available to your account. One entry per category. |\n\nEach entry in `products[]`:\n\n| Field | Type | Notes |\n|---|---|---|\n| `categoryName` | string | Human-readable category name. |\n| `categoryID` | string | See **categoryID values** below. |\n| `currencyType` | string | ISO 4217 currency the prices are quoted in (`INR`, `USD`, etc.). Account-scoped. |\n| `products` | array | Individual products in this category. |\n\nEach entry in the nested `products[]`:\n\n| Field | Type | Notes |\n|---|---|---|\n| `productCode` | string | The catalog product code. Pass as `X-Product-Code` on Create to override auto-resolution. Used for billing reconciliation. |\n| `productName` | string | Full marketing name, e.g. `emSign DV SSL Certificate - 1 Year`. |\n| `price` | string | Per-unit price in `currencyType`. Decimal string (`\"1000.0\"`). Excludes taxes; the actual billed amount can differ for InCommon / contract customers. |\n| `productTypeID` | string | Product sub-type. See **productTypeID values** below. |\n\n### `categoryID` values\n\n| Value | Category |\n|---|---|\n| `\"1\"` | Document Signer Certificates |\n| `\"2\"` | S/MIME Certificates |\n| `\"3\"` | SSL/TLS Certificates |\n| `\"8\"` | Private PKI |\n\n### `productTypeID` values\n\n| Value | Product type | Family (categoryID) |\n|---|---|---|\n| `\"10\"` | Natural Person (NR) | Document Signer (`1`) |\n| `\"11\"` | Legal Person (NR) | Document Signer (`1`) |\n| `\"12\"` | Legal Entity (NR) | Document Signer (`1`) |\n| `\"13\"` | DV SSL | SSL/TLS (`3`) |\n| `\"14\"` | DV SSL Wildcard | SSL/TLS (`3`) |\n| `\"15\"` | DV SSL UCC | SSL/TLS (`3`) |\n| `\"16\"` | OV SSL | SSL/TLS (`3`) |\n| `\"17\"` | OV SSL Wildcard | SSL/TLS (`3`) |\n| `\"18\"` | OV SSL UCC | SSL/TLS (`3`) |\n| `\"19\"` | EV SSL | SSL/TLS (`3`) |\n| `\"20\"` | EV SSL UCC | SSL/TLS (`3`) |\n| `\"21\"` | DV SSL Wildcard UCC | SSL/TLS (`3`) |\n| `\"22\"` | OV SSL Wildcard UCC | SSL/TLS (`3`) |\n| `\"39\"` | Private PKI | Private PKI (`8`) |\n| `\"41\"` | S/MIME | S/MIME (`2`) |\n\nThe exact `productTypeID` for a product is also derivable from `productName`. Use `productTypeID` for programmatic routing (e.g. \"is this an EV SSL?\"), `productName` for display.\n\n### How to use\n\n- For most callers, the wrapper resolves `productCode` automatically from the request body. You can call this endpoint to confirm which exact product/price the wrapper will pick.\n- To pin a specific product (e.g. force an InCommon-branded SKU), copy the `productCode` and send it as `X-Product-Code: ` on the Create call.\n- To list custom fields required by a specific product, call **Get Custom Fields for Product** with the chosen `productCode`.\n" + }, + "response": [ + { + "name": "200 OK - entitled products", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/catalog/products", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "catalog", + "products" + ], + "query": [ + { + "key": "groupNumber", + "value": "{{groupNumber}}", + "description": "Optional - scope to a billing group. When omitted, defaults to your account's default group.", + "disabled": true + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"products\": [\n {\n \"masterProductId\": \"1\",\n \"masterProductName\": \"SSL/TLS Certificates\",\n \"productId\": \"842\",\n \"productName\": \"DV SSL Certificate (1 year)\",\n \"baseProductId\": \"100\",\n \"baseProductName\": \"Standard CA Profile\"\n },\n {\n \"masterProductId\": \"1\",\n \"masterProductName\": \"SSL/TLS Certificates\",\n \"productId\": \"843\",\n \"productName\": \"DV SSL Wildcard (1 year)\",\n \"baseProductId\": \"100\",\n \"baseProductName\": \"Standard CA Profile\"\n },\n {\n \"masterProductId\": \"1\",\n \"masterProductName\": \"SSL/TLS Certificates\",\n \"productId\": \"846\",\n \"productName\": \"OV SSL Certificate (1 year)\",\n \"baseProductId\": \"100\",\n \"baseProductName\": \"Standard CA Profile\"\n },\n {\n \"masterProductId\": \"2\",\n \"masterProductName\": \"Document Signer Certificates\",\n \"productId\": \"819\",\n \"productName\": \"Natural Person (1 year)\",\n \"baseProductId\": \"200\",\n \"baseProductName\": \"AATL CA Profile\"\n }\n ]\n}" + } + ] + }, + { + "name": "Get Custom Fields for Product", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/catalog/products/{{productCodeSslDv}}/custom-fields", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "catalog", + "products", + "{{productCodeSslDv}}", + "custom-fields" + ] + }, + "description": "Returns mandatory + optional custom fields configured for the given product (cost-centre tags, project codes, etc.). Include them in `customFields[]` on order creation.\n\n## Response\n\n| Top-level field | Type | Notes |\n|---|---|---|\n| `customFields` | array | Field groups required by this product. Empty array (`[]`) when the product has no custom fields beyond the standard request body. |\n\nWhen non-empty, `customFields` contains a single object with two sub-arrays:\n\n| Sub-field | Type | Notes |\n|---|---|---|\n| `certificateInformation` | array | Standard cert subject fields the product needs (CN, O, L, etc.). Driven by the CA profile. |\n| `additionalInformation` | array | Account-defined extra fields the product enforces (PO number, cost-centre, custom dropdowns, etc.). |\n\nEach entry in either array:\n\n| Field | Type | Notes |\n|---|---|---|\n| `name` | string | Display label, e.g. `First Name`, `Business Category`, `PO Number`. For Business Category entries on EV products, this is the field-group label; the dropdown values appear in `values[]` as `\":\"` pairs. |\n| `fieldId` | string | Present on `additionalInformation` only. Stable field number to send back on Create as `customFields[].fieldId`. |\n| `type` | string | One of `text`, `dropdown`, `datePicker`, `checkbox`. Renders the appropriate UI input. |\n| `maxlength` | string | Maximum length in characters. Empty for `checkbox` / `datePicker`. |\n| `isMandatory` | `\"0\"` \\| `\"1\"` | `\"1\"` when the field is required on Create; `\"0\"` when optional. |\n| `values` | string[] | Present only when `type = dropdown`. Allowed values. For Business Category entries on EV: each entry is `\":\"` (e.g. `\"1:Private Organization\"`) - submit the ID. |\n\n### How to use\n\nOn a Create call, send each non-empty additional field as:\n\n```json\n\"customFields\": [\n { \"fieldId\": \"\", \"value\": \"\" }\n]\n```\n\nStandard `certificateInformation` fields (First Name, Email, etc.) are sent as their normal request-body fields, not inside `customFields`.\n\nThe `type` field on each entry uses these values:\n\n| Source value | `type` value | Notes |\n|---|---|---|\n| `\"1\"` | `text` | Free-text input. |\n| `\"2\"` | `dropdown` | Pick one of `values[]`. |\n| `\"3\"` | `datePicker` | Send ISO 8601 date string. |\n| `\"4\"` | `checkbox` | Send `\"true\"` / `\"false\"`. |\n| `\"5\"` | `text` | Multi-line free-text. |\n" + }, + "response": [ + { + "name": "200 OK - two custom fields", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/catalog/products/{{productCodeSslDv}}/custom-fields", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "catalog", + "products", + "{{productCodeSslDv}}", + "custom-fields" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"customFields\": [\n {\n \"fieldId\": \"12\",\n \"fieldName\": \"costCenter\",\n \"displayName\": \"Cost Center\",\n \"isMandatory\": \"1\"\n },\n {\n \"fieldId\": \"13\",\n \"fieldName\": \"projectCode\",\n \"displayName\": \"Project Code\",\n \"isMandatory\": \"0\"\n }\n ]\n}" + } + ] + } + ], + "description": "Discover what your account can order. Call once per integration build and cache the `productCode` values - they are the source of truth for the `X-Product-Code` header." + }, + { + "name": "Reports", + "item": [ + { + "name": "Orders Report", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/reports/orders?page=1&size=50", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "reports", + "orders" + ], + "query": [ + { + "key": "groupNumber", + "value": "{{groupNumber}}", + "description": "Optional - filter to a billing group.", + "disabled": true + }, + { + "key": "status", + "value": "issued", + "description": "Filter by order status. Allowed values: pending-dcv, pending-organization-verification, pending-csr, pending-documents, pending-agreement, pending-approval, issued, revoked, cancelled, rejected, expired. Unknown values return 422.", + "disabled": true + }, + { + "key": "from", + "value": "2026-01-01", + "description": "Optional - start date (YYYY-MM-DD).", + "disabled": true + }, + { + "key": "to", + "value": "2026-12-31", + "description": "Optional - end date.", + "disabled": true + }, + { + "key": "page", + "value": "1", + "description": "1-based page number." + }, + { + "key": "size", + "value": "50", + "description": "Page size (1-100, clamped server-side)." + } + ] + }, + "description": "### What it does\nPaginated, filterable order history across SSL / Document Signer / Private PKI.\n\n## Response\n\nPaginated list, Spring-style envelope:\n\n| Top-level field | Type | Notes |\n|---|---|---|\n| `content` | array | Page of order rows. |\n| `page` | integer | 1-based page index (mirrors the `page` query param). |\n| `size` | integer | Page size (mirrors the `size` query param). |\n| `totalElements` | integer | Total orders matching the filter, across all pages. |\n| `totalPages` | integer | Total page count. |\n\nEach `content[]` row:\n\n| Field | Type | Notes |\n|---|---|---|\n| `orderNumber` | string | Customer-visible order number. Use it with **Track Order** for full lifecycle detail. |\n| `requestNumber` | string | Most-recent request identifier on the order (reissues create new requests). |\n| `productCode` | string | Catalog product code. Cross-reference with **List Products** for human-readable name + category. |\n| `orderStatus` | string | Human-readable order state: `Order Accepted`, `Approved by System`, `Pending for Approver`, `Issued`, etc. |\n| `certificateStatus` | string | Human-readable request state: `Pending for Approver`, `Approved by System`, `Certificate Generated`, `Certificate Downloaded`, etc. |\n| `certificateSerialNumber` | string | Hex serial assigned by the CA. Empty until issuance. |\n| `certificateExpiryDate` | datetime | Certificate `notAfter`. Empty until issuance. |\n| `issuerCA` | string | Issuing CA's CN. Empty until issuance. |\n| `domainName` | string | Primary CN for SSL/TLS orders. Empty for non-SSL families. |\n| `organizationName` | string | Organization on the order (OV/EV). Empty for unverified / personal orders. |\n| `countryName` | string | Subject country (full name, not ISO code). |\n| `groupNumber` | string | Billing group the order was filed under. |\n| `customFields` | array | Customer-defined custom-field values on the order. Each: `{ fieldId, value }`. |\n| `tags` | array | Customer-defined tags applied to the order. |\n| `orderDate` | datetime | Order creation time (UTC). |\n" + }, + "response": [ + { + "name": "200 OK - orders report (paged)", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/reports/orders?page=1&size=50", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "reports", + "orders" + ], + "query": [ + { + "key": "groupNumber", + "value": "{{groupNumber}}", + "description": "Optional - filter to a billing group.", + "disabled": true + }, + { + "key": "status", + "value": "issued", + "description": "Optional - filter by order status (e.g. issued, revoked, cancelled).", + "disabled": true + }, + { + "key": "from", + "value": "2026-01-01", + "description": "Optional - start date (YYYY-MM-DD).", + "disabled": true + }, + { + "key": "to", + "value": "2026-12-31", + "description": "Optional - end date.", + "disabled": true + }, + { + "key": "page", + "value": "1", + "description": "1-based page number." + }, + { + "key": "size", + "value": "50", + "description": "Page size (1-100, clamped server-side)." + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"content\": [\n {\n \"orderNumber\": \"ORD-2026-001\",\n \"orderDate\": \"2026-04-12T10:00:00Z\",\n \"account\": \"Acme Corporation\",\n \"group\": \"Default Group\",\n \"product\": \"DV SSL Certificate (1 year)\",\n \"identifier\": \"example.com\",\n \"tags\": [\n \"env:prod\",\n \"team:platform\"\n ],\n \"state\": \"issued\",\n \"organizationName\": \"Acme Corporation\"\n },\n {\n \"orderNumber\": \"ORD-2026-002\",\n \"orderDate\": \"2026-05-08T09:00:00Z\",\n \"account\": \"Acme Corporation\",\n \"group\": \"Default Group\",\n \"product\": \"OV SSL Wildcard (1 year)\",\n \"identifier\": \"*.acme.com\",\n \"tags\": [\n \"env:prod\"\n ],\n \"state\": \"pending-dcv\",\n \"organizationName\": \"Acme Corporation\"\n }\n ],\n \"page\": 1,\n \"size\": 50,\n \"totalElements\": 2,\n \"totalPages\": 1\n}" + } + ] + }, + { + "name": "Ledger Statement", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/reports/ledger?page=1&size=50", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "reports", + "ledger" + ], + "query": [ + { + "key": "groupNumber", + "value": "{{groupNumber}}", + "description": "Optional - filter to a billing group.", + "disabled": true + }, + { + "key": "page", + "value": "1", + "description": "1-based page number (matches /reports/orders)." + }, + { + "key": "size", + "value": "50", + "description": "Page size (1-100, clamped server-side)." + } + ] + }, + "description": "### What it does\nDebit / credit ledger movements for the account. Pair with `groupNumber` to isolate a cost centre.\n\n## Response\n\nPaginated list, Spring-style envelope:\n\n| Top-level field | Type | Notes |\n|---|---|---|\n| `content` | array | Page of ledger statement rows. |\n| `page` | integer | 1-based page index. |\n| `size` | integer | Page size. |\n| `totalElements` | integer | Total ledger entries matching the filter. |\n| `totalPages` | integer | Total page count. |\n\nEach `content[]` row is a statement entry: transaction date, description, debit / credit amount in the account's billing currency, balance after the entry, and the originating order / request number when the entry is tied to a specific order.\n" + }, + "response": [ + { + "name": "200 OK - ledger statement (paged)", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{v2BaseURL}}/api/certinext/v2/reports/ledger?page=1&size=50", + "host": [ + "{{v2BaseURL}}" + ], + "path": [ + "api", + "certinext", + "v2", + "reports", + "ledger" + ], + "query": [ + { + "key": "groupNumber", + "value": "{{groupNumber}}", + "description": "Optional - filter to a billing group.", + "disabled": true + }, + { + "key": "page", + "value": "1", + "description": "1-based page number (matches /reports/orders)." + }, + { + "key": "size", + "value": "50", + "description": "Page size (1-100, clamped server-side)." + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"content\": [\n {\n \"invoiceNumber\": \"INV-2026-001\",\n \"date\": \"2026-04-12\",\n \"dueDate\": \"2026-05-12\",\n \"creditNoteNumber\": \"\",\n \"orderId\": \"ord_abc123\",\n \"product\": \"DV SSL Certificate (1 year)\",\n \"totalAmount\": \"150.00\",\n \"currencyId\": \"2\",\n \"status\": \"PAID\",\n \"invoiceDetailsId\": \"5821\"\n },\n {\n \"invoiceNumber\": \"\",\n \"date\": \"2026-04-01\",\n \"dueDate\": \"\",\n \"creditNoteNumber\": \"CN-2026-001\",\n \"orderId\": \"\",\n \"product\": \"Account credit top-up\",\n \"totalAmount\": \"10000.00\",\n \"currencyId\": \"2\",\n \"status\": \"POSTED\",\n \"invoiceDetailsId\": \"5800\"\n }\n ],\n \"page\": 1,\n \"size\": 50,\n \"totalElements\": 2,\n \"totalPages\": 1\n}" + } + ] + } + ], + "description": "Operational and financial reports. Both endpoints support filtering and pagination (max page size **100**)." + }, + { + "name": "Reference", + "item": [ + { + "name": "Error Codes", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "about:reference/error-codes", + "host": [ + "about" + ], + "port": "reference", + "path": [ + "error-codes" + ] + }, + "description": "## CERTInext API v2 - Error Codes\n\nEvery error response follows **RFC 7807** and carries an `EMS-xxxx` code in `detail`.\n\n### OAuth2 (`/oauth/token`) - RFC 6749 errors\n| HTTP | `error` | Meaning | Fix |\n|---|---|---|---|\n| 400 | `invalid_request` | Missing/empty `grant_type`, `client_id`, or `client_secret` | Populate the form fields |\n| 400 | `unsupported_grant_type` | Value other than `client_credentials` or `refresh_token` | Use a supported grant |\n| 400 | `invalid_grant` | Refresh token expired / used / revoked | Call **Get Bearer Token** to re-auth |\n| 401 | `invalid_client` | Wrong `client_id` or `client_secret`, or key revoked | Regenerate the client secret in portal |\n| 403 | `unauthorized_client` | Access key not generated in **OAuth mode** | Recreate with the OAuth radio selected |\n\n### Order create (POST `/{product}-certificates`)\n| Code | HTTP | Meaning |\n|---|---|---|\n| `EMS-915` | 422 | Invalid / unknown product code |\n| `EMS-916` | 422 | Requestor info missing |\n| `EMS-917` | 422 | Certificate info missing |\n| `EMS-918` | 422 | Additional information missing |\n| `EMS-919` | 422 | Subscription info missing |\n| `EMS-920` | 422 | Agreement not accepted |\n\n### DCV / CSR / Cancel / Revoke\n| Code | HTTP | Meaning |\n|---|---|---|\n| `EMS-921` | 422 | CSR malformed |\n| `EMS-922` | 422 | CSR subject mismatch |\n| `EMS-940` | 422 | Reissue: invalid CSR |\n| `EMS-941` | 422 | Reissue: reason missing |\n| `EMS-964` | 422 | Rejection remarks cannot be empty |\n| `EMS-969` | 422 | Revoke reason ID missing |\n| `EMS-984` | 422 | Cancellation remarks cannot be empty |\n| `EMS-1063` | 422 | SAN limit exceeded on reissue |\n| `EMS-1080` | 422 | Domain already verified (Verify DCV no-op) |\n| `EMS-1157` | 422 | Domain name cannot be empty |\n| `EMS-1158` | 422 | DCV method cannot be empty |\n| `EMS-1165` | 422 | Order not in a state that supports this operation |\n\n### Domains (POST/GET `/domains/**`)\n| Code | HTTP | Meaning |\n|---|---|---|\n| `EMS-DOMAIN-001` | 422 | CAA record blocks emSign issuance |\n| `EMS-DOMAIN-002` | 409 | Domain already registered under your account |\n| `EMS-DOMAIN-003` | 422 | Domain validation failed (PSL block, invalid format, etc.) |\n| `EMS-DOMAIN-101` | 409 | Domain owned by another organization in your account (BR section 4.1) - `existingDomainId` returned |\n| `EMS-DOMAIN-102` | 422 | Revalidation reset failed |\n| `EMS-DOMAIN-103` | 422 | CAA pre-check failed; per-perspective `diagnostics` returned |\n\n### Transport (RFC 7807)\n| HTTP | Condition |\n|---|---|\n| 400 | Body malformed, header/param missing, field validation failed (`errors[]` itemised) |\n| 401 | Missing or invalid Bearer token |\n| 403 | Caller lacks permission |\n| 404 | Resource doesn't exist under your account |\n| 405 | Method not supported (`Allow` header lists what is) |\n| 406 | Accept header can't be satisfied |\n| 415 | Content-Type not supported |\n| 422 | Body parsed but failed business validation |\n| 429 | Rate limited (`Retry-After` header) |\n| 500 | Internal error |\n| 501 | Endpoint exists but the underlying handler isn't wired yet |\n\n### Response body shape\n```json\n{\n \"type\": \"https://api.certinext.io/errors/\",\n \"title\": \"Human-readable title\",\n \"status\": 422,\n \"detail\": \"Order not in a state that supports this operation\",\n \"instance\": \"/api/certinext/v2/ssl-certificates//revoke\",\n \"errors\": [\n { \"field\": \"certificate.domain\", \"message\": \"must not be blank\" }\n ]\n}\n```" + }, + "response": [] + }, + { + "name": "Product Codes", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "about:reference/product-codes", + "host": [ + "about" + ], + "port": "reference", + "path": [ + "product-codes" + ] + }, + "description": "## Product Codes\n\nThe numeric `productCode` goes in the `X-Product-Code` header on every order create. The canonical source is **Catalog -> List Products** - these reference values match most environments but are authoritative only in the catalog response.\n\n### SSL / TLS\n| Code | Product | Use |\n|---|---|---|\n| `842` | DV SSL | Single FQDN, no org vetting |\n| `843` | DV SSL Wildcard | `*.example.com` |\n| `844` | DV SSL UCC | Multi-SAN |\n| `845` | DV SSL Wildcard UCC | Wildcard primary + extra SANs |\n| `846` | OV SSL | Single FQDN, vetted org subject |\n| `847` | OV SSL Wildcard | `*.example.com` with vetted org |\n| `848` | OV SSL UCC | Multi-SAN with vetted org |\n| `849` | OV SSL Wildcard UCC | Wildcard + SANs with vetted org |\n| `850` | EV SSL | Single FQDN, EV vetting |\n| `851` | EV SSL UCC | Multi-SAN, EV vetting |\n\n### Document Signer\n| Code | Product |\n|---|---|\n| `819` | Natural Person - 1 year |\n| `820` | Natural Person - 2 year |\n| `821` | Natural Person - 3 year |\n| `822` | Legal Person - 1 year |\n| `823` | Legal Person - 2 year |\n| `824` | Legal Person - 3 year |\n| `825` | Legal Entity - 1 year |\n| `826` | Legal Entity - 2 year |\n| `827` | Legal Entity - 3 year |\n\n### S/MIME (email signing / encryption)\n| Code | Product |\n|---|---|\n| `903` | S/MIME Simple MV-S - 1 year |\n| `904` | S/MIME Simple MV-S - 2 year |\n| `905` | S/MIME Simple MV-S - 3 month |\n\n### Private PKI\nPrivate PKI codes vary per customer catalog. Query **Catalog -> List Products** with your token; entries with `categoryName=\"Private PKI Products\"` enumerate what your account can order.\n\n### Environment variables\nPre-filled in the env file as `productCodeSslDv`, `productCodeSslDvWildcard`, `productCodeSslDvUcc`, `productCodeSslDvWildcardUcc`, `productCodeSslOv`, `productCodeSslOvWildcard`, `productCodeSslOvUcc`, `productCodeSslOvWildcardUcc`, `productCodeDocSignerNp1Y`, `productCodeDocSignerLp1Y`, `productCodeDocSignerLe1Y`, `productCodePkiIntranet`, `productCodePkiIgtf`." + }, + "response": [] + }, + { + "name": "Country & State Codes", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "about:reference/country-codes", + "host": [ + "about" + ], + "port": "reference", + "path": [ + "country-codes" + ] + }, + "description": "## Country Codes (ISO 3166-1 alpha-2)\n\nAll `countryCode` fields use **two-letter uppercase**. For integrations:\n\n| Code | Country |\n|---|---|\n| `US` | United States |\n| `CA` | Canada |\n| `MX` | Mexico |\n| `GB` | United Kingdom |\n| `DE` | Germany |\n| `FR` | France |\n| `JP` | Japan |\n| `AU` | Australia |\n| `IN` | India |\n| `BR` | Brazil |\n\nFull list: https://www.iso.org/obp/ui/#search\n\n## US State Codes\n\nFor US `state` fields use the standard two-letter postal code:\n\n| Code | State / Territory |\n|---|---|\n| `AL` | Alabama |\n| `AK` | Alaska |\n| `AZ` | Arizona |\n| `AR` | Arkansas |\n| `CA` | California |\n| `CO` | Colorado |\n| `CT` | Connecticut |\n| `DE` | Delaware |\n| `DC` | District of Columbia |\n| `FL` | Florida |\n| `GA` | Georgia |\n| `HI` | Hawaii |\n| `ID` | Idaho |\n| `IL` | Illinois |\n| `IN` | Indiana |\n| `IA` | Iowa |\n| `KS` | Kansas |\n| `KY` | Kentucky |\n| `LA` | Louisiana |\n| `ME` | Maine |\n| `MD` | Maryland |\n| `MA` | Massachusetts |\n| `MI` | Michigan |\n| `MN` | Minnesota |\n| `MS` | Mississippi |\n| `MO` | Missouri |\n| `MT` | Montana |\n| `NE` | Nebraska |\n| `NV` | Nevada |\n| `NH` | New Hampshire |\n| `NJ` | New Jersey |\n| `NM` | New Mexico |\n| `NY` | New York |\n| `NC` | North Carolina |\n| `ND` | North Dakota |\n| `OH` | Ohio |\n| `OK` | Oklahoma |\n| `OR` | Oregon |\n| `PA` | Pennsylvania |\n| `RI` | Rhode Island |\n| `SC` | South Carolina |\n| `SD` | South Dakota |\n| `TN` | Tennessee |\n| `TX` | Texas |\n| `UT` | Utah |\n| `VT` | Vermont |\n| `VA` | Virginia |\n| `WA` | Washington |\n| `WV` | West Virginia |\n| `WI` | Wisconsin |\n| `WY` | Wyoming |\n| `PR` | Puerto Rico |\n| `VI` | US Virgin Islands |\n| `GU` | Guam |\n\n## Phone format (E.164)\n\nUS / Canada -> country code `+1`, then a 10-digit number, no spaces or dashes:\n\n```\n+14155551234 (San Francisco)\n+12025551234 (Washington DC)\n+16175551234 (Boston)\n+15125551234 (Austin)\n+17185551234 (New York / Brooklyn)\n```\n\n## Currency\n\nUS pricing returned as `currencyType: \"USD\"` in the catalog. Other ISO 4217 codes (CAD, MXN, EUR, GBP) appear when ordering from non-US billing groups." + }, + "response": [] + } + ], + "description": "Read-only reference material. The requests in this folder are not real HTTP calls - they're stubs that anchor the detailed tables in their descriptions. Open any item to view." + } + ], + "variable": [ + { + "key": "accessToken", + "value": "" + }, + { + "key": "refreshToken", + "value": "" + }, + { + "key": "orderId", + "value": "" + }, + { + "key": "requestId", + "value": "" + } + ] +} \ No newline at end of file diff --git a/docs/reference/specs/CERTInext APIs.postman_collection.json b/docs/reference/specs/CERTInext APIs.postman_collection.json new file mode 100644 index 0000000..8e02f5b --- /dev/null +++ b/docs/reference/specs/CERTInext APIs.postman_collection.json @@ -0,0 +1,5984 @@ +{ + "info": { + "_postman_id": "297220d3-6792-46da-b395-b7001c326a5f", + "name": "CERTInext APIs", + "description": "Welcome to the CERTInext API Documentation. If you are new to APIs, we suggest familiarizing yourself with the fundamentals before diving into this service. CERTInext is a unified platform providing simplified access to all certificate lifecycle management operations. It is a user-friendly certificate management platform that allows customers to easily manage and monitor their certificates.\n\n**Audience**\n\nThis document is intended for developers who want to offer and integrate CERTInext Certificate Lifecycle Management services in external applications by utilizing our complete set of APIs. This document can be further followed to learn more about API interactions.\n\n**Before You Begin**\n\nTo get started with CERTInext REST APIs, you need the following:\n\n- A CERTInext account\n \n- An API Key (Access Key) generated from within the platform\n \n\nFor every request you have to pass an API key as authentication, which can be generated using your CERTInext account. These APIs are available to use for both partners and enterprises.\n\nAs you become familiar with CERTInext, you interact with different environments, each of which may have different sign-in URLs, API base URLs, and account credentials. To know more about Accounts and environments, please see the below table.\n\n**Accounts and environments**\n\n| **Environment** | **Sign-in URL** | **API Base URL** |\n| --- | --- | --- |\n| Sandbox | [Sandbox Sign-in URL](https://sandbox-us.certinext.io/) | [https://sandbox-us-api.certinext.io/emSignHub-API/](https://sandbox-us-api.certinext.io/emSignHub-API/) |\n| Production - India (Global) | [Sign-in URL (India)](https://in.certinext.io/) | [https://api.certinext.io/](https://api.certinext.io/) |\n| Production - US | [Sign-in URL (US)](https://us.certinext.io/) | [https://us-api.certinext.io/](https://us-api.certinext.io/) |\n\n**Go Live**\n\nOnce you have successfully tested the APIs in sandbox environment, please switch to production API URLs and proceed with Go live.\n\nBefore sending your requests to the production environment, please ensure to change the product codes.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "54201257", + "_collection_link": "https://go.postman.co/collection/54201257-297220d3-6792-46da-b395-b7001c326a5f?source=collection_link" + }, + "item": [ + { + "name": "REST APIs", + "item": [ + { + "name": "Overview", + "item": [], + "description": "CERTInext is the unified platform enabling users to avail digital transformation products & all the portfolio of offerings under emSign in self-managed manner. This documentation covers the following:\n\n- Accounts API\n \n- Orders API\n \n- Certificates API\n \n- Reporting API\n \n\n**What is included**\n\nThis document intends to describe the emSign Product Order Life Cycle with the following API services:\n\n- **ValidateCredentials**: This API allows you to validate the API access credentials of your CERTInext user account.\n \n- **GetGroupDetails:** This API allows you to get group details associated with the respective account user. Groups can be your cost centers, departments, etc. within your Business. It acts as a feature / tool for managing account user access to organizations, domains and use its finances.\n \n- **GetOrganizationDetails**: This API allows you to get detailed organization information and its status.\n \n- **GetDomainDetails:** This API allows you to get detailed domain information and its status.\n \n- **GenerateOrderSSL**: This API allows you to generate emSign SSL – DV / OV / EV certificate orders.\n \n- **GenerateOrderSMIME**: This API allows you to generate emSign S/MIME – Simple certificate orders.\n \n- **GenerateOrderSignature**: This API allows you to generate emSign signature - Natural Person / Legal Person / Legal Entity certificate orders.\n \n- **SubmitCSR**: This API allows you to submit CSR for all emSign (SSL/TLS, S/MIME, Signature) certificates orders.\n \n- **SubmitDocument**: This API allows you to submit the documents for all emSign (SSL/TLS, S/MIME, Signature) certificates orders.\n \n- **GetDcv**: This API allows you to get DCV (Domain Control Validation) details for all emSign SSL/TLS certificate orders.\n \n- **VerifyDcv**: This API allows you to verify DCV (Domain Control Validation) for all emSign SSL/TLS certificate orders.\n \n- **TrackOrder**: This API allows tracking the order status of all emSign (SSL/TLS, S/MIME, Signature) certificates orders.\n \n- **GetCertificate**: This API allows download of all emSign (SSL/TLS, S/MIME, Signature) certificates orders.\n \n- **RevokeOrder**: This API allows revocation of all emSign (SSL/TLS, S/MIME, Signature) certificates orders.\n \n- **RejectOrder**: This API allows cancellation of all emSign (SSL/TLS, S/MIME, Signature) certificates orders.\n \n- **GetOrderReport**: This API allows you to get order report of all emSign (SSL/TLS, S/MIME, Signature) certificates orders." + }, + { + "name": "How to Get Started", + "item": [], + "description": "To get started with CERTInext REST APIs, follow the steps below to generate your API credentials and begin integrating your platform.\n\n#### **Step 1: Navigate to the APIs Page**\n\nLog in to your CERTInext account and navigate to:\n\n**Integrations → APIs**\n\nThe APIs page provides access to credentials required for integrating with CERTInext certificate management protocols including ACME, REST API, EST, SCEP, CMP and WAEP. From this page you can create new credentials, manage access, view or revoke credentials as needed.\n\n\"Fig%201:%20APIs%20page%20showing%20the%20credentials%20list%20with%20the%20different%20API’s%20created\"\n\n#### Step 2: Create New API Credentials\n\nClick + Create API Credentials at the top right of the APIs page.\n\nThe Create API Credentials dialog will open.\n\n\"Fig2:%20Create%20API%20Credentials%20modal%20with%20API%20Type%20dropdown\"\n\n#### Step 3: Configure REST API Credentials\n\nIn the Create API Credentials dialog, fill in the following fields:\n\n| Field | Description | Required |\n| --- | --- | --- |\n| API Type | Select REST from the dropdown | Required |\n| Description | Enter a meaningful label for this credential set (e.g. rest_api, my-app-integration) | Required |\n| User | Select the CERTInext user account this credential will be associated with | Required |\n| Auth Type | Select the authentication method - Access Key or OAuth | Required |\n| Tags | Optionally add tags for filtering and organization | Optional |\n\nOnce all fields are completed, click **Generate**.\n\n\"Fig%203:%20Create%20API%20Credentials%20modal\"\n\n#### Step 4: Copy Your Access Key\n\nOnce generated, the View API Key Credentials dialog will display your Access Key. Copy and store this key securely - it will be used in the Authorization header of all your API requests.\n\n\"Fig4:%20View%20API%20key%20credentials%20modal%20showing%20the%20Access%20Key%20value%20with%20the%20copy%20icon\"\n\n#### Step 5: Confirm Credential Status\n\nYour newly created REST API credential will now appear in the APIs list with status Active. A confirmation message - REST API Credentials generated successfully will be displayed at the top of the page.\n\n\"Fig5:%20REST%20API%20Creation%20success%20message%20%20\"\n\n#### Step 6: View Status History\n\nTo verify the activation status and history of any credential, select Status History from the Action dropdown next to the credential. The View Status History dialog shows the status update date, access key status and the user who last updated it.\n\n\"Fig6:%20View%20status%20History%20Modal%20\"\n\n#### Managing API Credentials\n\nAll created credentials are listed on the Integrations → APIs page. The following columns are shown for each credential:\n\n\n\n#### Authentication\n\nAll REST API requests must include authentication details passed in the Meta object of every request. The following fields are required in the Meta for each API call:\n\n\n\n**authKey Generation**\n\nThe authKey is not your raw Access Key. It must be computed as follows:\n\nauthKey = SHA256(accessKey + requestTs + requestTxnId)\n\nExample:\n\nIf your values are:\n\n- accessKey = your REST API Access Key from the CERTInext portal\n \n- requestTs = the ts value used in the request\n \n- requestTxnId = the txn value used in the request\n \n\nThen:\n\nauthKey = SHA256(accessKey + ts + txn)\n\n**Note:**\n\n- The Access Key used in the authKey computation is the value generated in the CERTInext portal under Integrations → APIs.\n \n- Refer to the steps above to generate your Access Key.\n \n- OAuth is also supported where enabled on your account. Select OAuth as the Auth Type during credential creation to use this method." + }, + { + "name": "Orders", + "item": [ + { + "name": "Generate SSL", + "item": [ + { + "name": "SSL/TLS - DV", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "if (status === \"1\") {", + " pm.test(\"Request Successful\", () => {", + " pm.expect(status).to.equal(\"1\");", + " if (res.orderDetails && res.orderDetails.orderNumber) {", + " pm.collectionVariables.set(\"orderNumber\", res.orderDetails.orderNumber);", + " }", + " if (res.orderDetails && res.orderDetails.requestNumber) {", + " pm.collectionVariables.set(\"requestNumber\", res.orderDetails.requestNumber);", + " }", + " });", + "} else {", + " pm.test(\"Request FAILED: \" + (errMsg || \"Unknown error\"), () => {", + " pm.expect(status).to.equal(\"1\");", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"productCode\": \"{{SSL_DV}}\",\r\n \"accountingModel\": \"2\",\r\n \"saveAndHold\": \"0\",\r\n \"emailNotifications\": \"1\",\r\n \"requestorInformation\": {\r\n \"requestorName\": \"{{requestorName}}\",\r\n \"requestorIsdCode\": \"1\",\r\n \"requestorMobileNumber\": \"{{requestorMobileNumber}}\",\r\n \"requestorEmail\": \"{{requestorEmail}}\",\r\n \"requestorDesignation\": \"{{requestorDesignation}}\"\r\n },\r\n \"subscriptionDetails\": {\r\n \"validity\": \"3\",\r\n \"autoRenew\": \"1\",\r\n \"renewCriteria\": \"60\"\r\n },\r\n \"delegationInformation\": {\r\n \"contactName\": \"\",\r\n \"email\": \"\"\r\n },\r\n \"organizationDetails\": {\r\n \"preVetting\": \"\",\r\n \"organizationNumber\": \"\"\r\n },\r\n \"certificateInformation\": {\r\n \"domainName\": \"{{domainName}}\"\r\n },\r\n \"csr\": \"\",\r\n \"additionalInformation\": {\r\n \"remarks\": \"\",\r\n \"tags\": []\r\n },\r\n \"technicalPointOfContact\": {\r\n \"pocFirstName\": \"\",\r\n \"pocLastName\": \"\",\r\n \"pocEmail\": \"\",\r\n \"pocIsdCode\": \"\",\r\n \"pocMobileNumber\": \"\",\r\n \"pocDesignation\": \"\"\r\n },\r\n \"autoSecureWWW\": \"1\",\r\n \"agreementDetails\": {\r\n \"signerIP\": \"{{signerIP}}\",\r\n \"signerPlace\": \"{{signerPlace}}\",\r\n \"signerName\": \"{{requestorName}}\",\r\n \"acceptAgreement\": \"1\"\r\n }\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}GenerateOrderSSL", + "host": [ + "{{baseURL}}GenerateOrderSSL" + ] + }, + "description": "This API facilitates to generate new order for all emSign - SSL / TLS - DV certificates.\n\n**Prerequisites**\n\neMudhra provides following values for generating SSL DV Orders. It is highly recommended to keep these values configurable, as they change for sandbox environment and Live environment.\n\n- Generate SSL Order Base URL Endpoint\n \n- Product Codes\n \n\n| **Region** | **URL** |\n| --- | --- |\n| US | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n| Global | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n\n**API Endpoint**\n\n| Header | Header |\n| --- | --- |\n| URL | http(s)://(url host port and path)/GenerateOrderSSL |\n| Protocol | HTTP/ HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"accountNumber\": \"\",\n \"authKey\": \"\" \n },\n \"orderDetails\": {\n \"productCode\": \"\",\n \"accountingModel\": \"\",\n \"saveAndHold\": \"\",\n \"requestNumber\": \"\",\n \"emailNotifications\": \"\", \n \"groupNumber\": \"\",\n \"requestorInformation\": {\n \"requestorName\": \"\",\n \"requestorIsdCode\": \"\",\n \"requestorMobileNumber\": \"\",\n \"requestorEmail\": \"\",\n \"requestorDesignation\": \"\"\n },\n \"delegationInformation\": {\n \"contactName\": \"\",\n \"email\": \"\"\n },\n \"organizationDetails\": {\n \"preVetting\": \"\",\n \"organizationNumber\": \"\",\n \"prevettingToken\": \"\"\n },\n \"certificateInformation\": {\n \"domainName\": \"\",\n \"additionalDomains\": [\n \"\",\n \"\",\n ],\n \"autoSecureWWW\": \"\"\n },\n \"agreementDetails\": {\n \"acceptAgreement\": \"\", \n \"signerName\": \"\", \n \"signerPlace\": \"\",\n \"signerIP\": \"\"\n },\n \"subscriptionDetails\": {\n \"validity\": \"\", \n \"autoRenew\": \"\", \n \"renewCriteria\": \"\"\n },\n \"csr\":\"\",\n \"additionalInformation\": {\n \"tags\":[\n \"(Tag Name:Tag Value)\",\n \"(Tag Name:Tag Value)\"\n ],\n \"customFields\":[\n {\n \"fieldID\": \"\",\n \"fieldValue\": \"\"\n },\n {\n \"fieldID\": \"\",\n \"fieldValue\": \"\"\n }\n ],\n \"remarks\": \"\",\n \"recipientEmail\": \"\",\n \"technicalPointOfContact\": {\n \"pocName\":\"\",\n \"pocEmail\":\"\",\n \"pocIsdCode\":\"\",\n \"pocMobileNumber\":\"\",\n \"pocDesignation\":\"\"\n }\n }\n}\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order. Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | 1234567890 (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | 6288275133 (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | 952a95057654aa1c26a0f287b6368579f5f3ec99f213543df5e0f7eb7f798b3d (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| productCode | 844 (mandatory)
Unique Product Code of the respective product. Product Code values are provided by eMudhra. |\n| accountingModel | 1 (optional)
Accounting Model of the Account. The allowed value for this field is: 1 - Cash Model. The default value is '1' (in case if it is not set). |\n| saveAndHold | 0 (mandatory)
Should be set to any of the numeric values.
1 - Only Request ID will be generated and request will be pending for payment.
0 - Both Request ID & Order ID will be generated. |\n| requestNumber | 676565676 (mandatory)
Request Number of the existing request which was saved as a draft.
This field is 'mandatory' only for the requests (Request ID is available & Order ID is yet to generate). |\n| emailNotifications | 1 (mandatory)
Can contain one of the numeric values as under. Default is '1'.
0 - Pre-vetting Organization (Organization & Subscriber Agreement info.) re-use consent notification will be sent to the applicant on order initiation, if \"preVetting\" value is set to '1'.
1 - All Order Notifications will be sent to the applicant on order initiation. |\n| groupNumber | 8194218742 (optional)
Group Number of the group. This is auto generated by the system. Groups can be your cost centers, departments, etc. within your Business. It acts as a feature / tool for managing account user access to organizations, domains and finances. If group number is not given in the request, then system will consider the default group number and amount will be deducted from the account balance. If group is having its own balance, then amount will be deducted from the group balance else the amount will be deducted from the account balance. |\n| requestorInformation | (mandatory)
Specified below. |\n| delegationInformation | (optional)
Specified below. |\n| organizationDetails | (optional)
Specified below.
This is 'required' to re-use the pre-verified domain under the pre-vetted organization (if \"preVetting\" value is set to '1'). For pre-verified domains, fresh DCV is not required. |\n| certificateInformation | (mandatory)
Specified below. |\n| agreementDetails | (conditional mandatory)
Signer details of the respective order to complete the Subscriber Agreement automatically. This is mandatory, if \"preVetting\" value is set to '0'. Specified Below. |\n| subscriptionDetails | (conditional mandatory)
Subscription Details of the order. Specified below. |\n| csr | (optional)
CSR means Certificate Signing Request. CSR file has to be generated, preferably in the web server, or with any standard tools. You should ensure that the corresponding private key resides in same web server / tool, so that you can import the SSL / TLS certificate in the same, once it is issued. |\n| additionalInformation | (optional) |\n\n**Requestor Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestorName | John Doe(mandatory)
Name of the requestor. |\n| requestorIsdCode | +1 (mandatory)
ISD Code of the requestor. |\n| requestorMobileNumber | 8280098898 (mandatory)
Mobile number of the requestor. |\n| requestorEmail | [johndoe@example.com](https://mailto:johndoe@example.com) (mandatory)
Email of the requestor. |\n| requestorDesignation | CFO (optional)
Designation of the requestor. |\n\n**Delegation Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| contactName | John Doe(mandatory)
Contact Name of the delegated person. |\n| email | [johndoe@example.com](https://mailto:johndoe@example.com)(mandatory)
Email ID of the delegated person. |\n\n**Organization Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| preVetting | 1 (mandatory)
This can be set to any of the numeric values as under. Default is '0'.
0 - No (New Organization)
1 - Yes (Pre-verified Domain of pre-vetted Organization). |\n| organizationNumber | 566245635 (conditional mandatory)
Organization Number (Org. ID) of the existing organization associated to the respective emSign account of the certificate requester. This is mandatory, if \"preVetting\" value is set to '1'. |\n| prevettingToken | 0947CBA3C2DF42B0B303590541C8E5B1(optional)
Please specify the unique pre-vetted token value associated to the respective organization. Pre-vetting Organization re-use consent email notification will not be sent on order initiation, if the organization re-use token is submitted with your certificate order.
To get the prevetting token, please contact your Account administrator. |\n\n**Certificate Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| domainName | emudhra.com (mandatory)
Domain Name of the website / server. If the pre-verified domain under the pre-vetted organization (if \"preVetting\" value is set to '1') is provided then fresh DCV is not required. |\n| additionalDomains | emass.emudhra.com (mandatory)
Additional domain names of the website / server. If any new Sub domain of pre-verified base domain is provided under the pre-vetted organization (if \"preVetting\" value is set to '1') then fresh DCV is not required. This field is required only for SSL multi-domain / UCC products as specified below.
SSL / TLS - DV UCC
SSL / TLS - DV Wild card UCC |\n| autoSecureWWW | 1 (optional)
This is set to '1' by default. 1 - Enabled (Secure 'www' variant of website) 0 - Disabled (Doesn't secure 'www' variant of website). If user has chosen to secure website with both www and without www variants, then File-based (HTTP / HTTPs URL) DCV method is not allowed to verify DCV. This is not applicable for emSign - SSL / TLS - DV Wildcard products. |\n\n**Subscriber Agreement Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| acceptAgreement | 1 (mandatory)
This can be set to any of the numeric values as under.
1 - Accept Subscriber Agreement
0 - Reject Subscriber Agreement |\n| signerName | Sipra (conditional mandatory)
Name of the Signer.
It is mandatory, if 'acceptAgreement' is 1. |\n| signerPlace | GOA (conditional mandatory)
Place of the Signer.
It is mandatory, if 'acceptAgreement' is 1. |\n| signerIP | 10.24.108.199.182 (conditional mandatory) IP Address of the Signer.
It is mandatory, if 'acceptAgreement' is 1. |\n\n**Subscription Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| validity | 1 (optional)
Validity of the Subscription (1 / 2 / 3 Years).
Default value is '1' Year.
emSign is providing subscription validity upto 3 years where maximum Lifetime of 390 days per certificate is available with free renewals. |\n| autoRenew | 1 (conditional mandatory)
Auto-renew certificates until coverage.
Default value is '1'.
1: Allow Renewal of Certificate
0: Decline Renewal of Certificate |\n| renewCriteria | 30 (conditional mandatory)
Time duration to for renew certificate before expiry.
Default value is '30' Days.
30: Automatically reissue before 30 days of certificate expiry.
60: Automatically reissue before 60 days of certificate expiry. |\n\n**Additional Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| tags | (optional)
Reporting tags are labels with different values, which can be associated with your certificate orders in CERTInext. These tags can then be used to filter reports, giving you a categorized insight into your business.
Tag Name and Tag Value must be colon separated values.
e.g.: Department:Technology
Multiple tag names and tag values can be associated with the order.
e.g.: Department:Technology,
Department:Legal,
Branch Location:India
Specified below. |\n| customFields | (conditional mandatory)
CERTInext allows you to use custom fields in your certificate order form. The Custom Field can be used to simplify record-keeping and makes order management efficient.
To enable Custom Fields feature for your CERTInext account, please contact your Account Manager.
Specified below. |\n| remarks | (optional)
Additional Information related to the order. |\n| recipientEmail | (optional)
The additional email recipients can be provided for receiving notifications related to Order Confirmation, Revocation and Renewal of certificates. |\n| technicalPointOfContact | (optional)
Technical Point of Contact will be notified for emails which are technical in nature such as Order Confirmation with order status tracking link, CSR & Certificate Download notification based on the email notifications configuration set by your account administrator.
Specified below. |\n\n**Tags**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| Tag Name | Dept (mandatory)
Reporting Tag Name. |\n| Tag Value | Admin (mandatory)
Reporting Tag Value. |\n\n**Custom Fields**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| fieldID | 456 (mandatory)
Specify the Custom Field ID. This is a unique field identifier of a custom field.
This is mandatory, if Custom Fields are mandated by your account administrator.
This information will be available within CERTInext online portal. |\n| fieldValue | Dept (mandatory)
Specify the Custom Field value(s). You have to provide the right input based on the custom field type set by your account administrator.
NOTE: The allowed date format for date picker based Custom Field is: YYYY-MM-DD
This is mandatory, if Custom Fields are mandated by your account administrator.
The detailed Custom Field information will be available within CERTInext online portal. |\n\n**Technical Point of Contact**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| pocName | John Doe (mandatory)
Name of the Technical Point of Contact. |\n| pocEmail | [johndoe@example.com](https://mailto:johndoe@example.com) (mandatory)
Email ID of the Technical Point of Contact. |\n| pocIsdCode | +91 (optional)
ISD Code of the Technical Point of Contact's Mobile Number. |\n| pocMobileNumber | 9676462551 (optional)
Mobile number of the Technical Point of Contact. |\n| pocDesignation | Senior Developer (optional)
Designation of the Technical Point of Contact. |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n },\n \"orderDetails\": { \n \"requestNumber\":\"\",\n \"orderNumber\": \"\",\n \"trackingUrl\": \"\"\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order.
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-09-08T12:29:36+05:30 (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | 1234567890 (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | 1 (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestNumber | 5787262867 (conditional mandatory)
Unique Request ID of the respective request. This is 'Mandatory', if 'saveAndHold' is set to '1'. |\n| orderNumber | 5374489755 (conditional mandatory)
Unique Order ID of the respective order.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |\n| trackingUrl | [https://sandbox-emsignsubscriber.emudhra.net/PublicLink/publicLinkVerification.jsp?a=Q29VT3BlTWdHcnlQNjh2Y3l5SVQ0Zz09](https://sandbox-emsignsubscriber.emudhra.net/PublicLink/publicLinkVerification.jsp?a=Q29VT3BlTWdHcnlQNjh2Y3l5SVQ0Zz09) (mandatory)
Order status tracking URL of the respective order.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |" + }, + "response": [ + { + "name": "SSL/TLS - DV", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{authKey}}\"\r\n },\r\n \"orderDetails\": {\r\n \"productCode\": \"{{DV SSL Certificate 1 Year}}\",\r\n \"accountingModel\": \"2\",\r\n \"saveAndHold\": \"0\",\r\n \"emailNotifications\": \"1\",\r\n \"requestorInformation\": {\r\n \"requestorName\": \"John Green\",\r\n \"requestorIsdCode\": \"1\",\r\n \"requestorMobileNumber\": \"9481081094\",\r\n \"requestorEmail\": \"john.green@example.com\",\r\n \"requestorDesignation\": \"Manager\"\r\n },\r\n \"subscriptionDetails\": {\r\n \"validity\": \"3\",\r\n \"autoRenew\": \"1\",\r\n \"renewCriteria\": \"60\"\r\n },\r\n \"delegationInformation\": {\r\n \"contactName\": \"\",\r\n \"email\": \"\"\r\n },\r\n \"organizationDetails\": {\r\n \"preVetting\": \"\",\r\n \"organizationNumber\": \"\"\r\n },\r\n \"certificateInformation\": {\r\n \"domainName\": \"emsigndev.emudhra.net\"\r\n },\r\n \"csr\": \"\",\r\n \"additionalInformation\": {\r\n \"remarks\": \"API Test from Postman\",\r\n \"tags\": []\r\n },\r\n \"technicalPointOfContact\": {\r\n \"pocFirstName\": \"\",\r\n \"pocLastName\": \"\",\r\n \"pocEmail\": \"\",\r\n \"pocIsdCode\": \"\",\r\n \"pocMobileNumber\": \"\",\r\n \"pocDesignation\": \"\"\r\n },\r\n \"autoSecureWWW\": \"1\",\r\n \"agreementDetails\": {\r\n \"signerIP\": \"103.156.134.109\",\r\n \"signerPlace\": \"Provo\",\r\n \"signerName\": \"John Green\",\r\n \"acceptAgreement\": \"1\"\r\n }\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}GenerateOrderSSL", + "host": [ + "{{baseUrl}}GenerateOrderSSL" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "338" + }, + { + "key": "Date", + "value": "Thu, 04 Apr 2024 06:07:00 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"orderDetails\": {\n \"requestNumber\": \"2127434294\",\n \"orderNumber\": \"5917192968\",\n \"trackingURL\": \"https://sandbox-emsignsubscriber.emudhra.net/PublicLink/publicLinkVerification.jsp?a=TVJmU1AvV0RheHZVTmc5enBVY3ZPdz09\"\n },\n \"meta\": {\n \"ver\": \"1.0\",\n \"errorMessage\": \"\",\n \"errorCode\": \"\",\n \"txn\": \"66273914773378630\",\n \"ts\": \"2024-04-04T11:36:55+05:30\",\n \"status\": \"1\"\n }\n}" + } + ] + }, + { + "name": "SSL/TLS - DV - UCC", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "if (status === \"1\") {", + " pm.test(\"Request Successful\", () => {", + " pm.expect(status).to.equal(\"1\");", + " if (res.orderDetails && res.orderDetails.orderNumber) {", + " pm.collectionVariables.set(\"orderNumber\", res.orderDetails.orderNumber);", + " }", + " if (res.orderDetails && res.orderDetails.requestNumber) {", + " pm.collectionVariables.set(\"requestNumber\", res.orderDetails.requestNumber);", + " }", + " });", + "} else {", + " pm.test(\"Request FAILED: \" + (errMsg || \"Unknown error\"), () => {", + " pm.expect(status).to.equal(\"1\");", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"productCode\": \"{{SSL_DV_UCC}}\",\r\n \"accountingModel\": \"2\",\r\n \"saveAndHold\": \"0\",\r\n \"emailNotifications\": \"1\",\r\n \"requestorInformation\": {\r\n \"requestorName\": \"{{requestorName}}\",\r\n \"requestorIsdCode\": \"1\",\r\n \"requestorMobileNumber\": \"{{requestorMobileNumber}}\",\r\n \"requestorEmail\": \"{{requestorEmail}}\",\r\n \"requestorDesignation\": \"{{requestorDesignation}}\"\r\n },\r\n \"subscriptionDetails\": {\r\n \"validity\": \"3\",\r\n \"autoRenew\": \"1\",\r\n \"renewCriteria\": \"60\"\r\n },\r\n \"delegationInformation\": {\r\n \"contactName\": \"\",\r\n \"email\": \"\"\r\n },\r\n \"organizationDetails\": {\r\n \"preVetting\": \"\",\r\n \"organizationNumber\": \"\"\r\n },\r\n \"certificateInformation\": {\r\n \"domainName\": \"{{domainName}}\",\r\n \"additionalDomains\": [\r\n \"dv.emsign.com\",\r\n \"ov.emsign.com\",\r\n \"ca.emsign.com\"\r\n ]\r\n },\r\n \"csr\": \"\",\r\n \"additionalInformation\": {\r\n \"remarks\": \"\"\r\n },\r\n \"technicalPointOfContact\": {\r\n \"pocFirstName\": \"\",\r\n \"pocLastName\": \"\",\r\n \"pocEmail\": \"\",\r\n \"pocIsdCode\": \"\",\r\n \"pocMobileNumber\": \"\",\r\n \"pocDesignation\": \"\"\r\n },\r\n \"autoSecureWWW\": \"1\",\r\n \"agreementDetails\": {\r\n \"signerIP\": \"{{signerIP}}\",\r\n \"signerPlace\": \"{{signerPlace}}\",\r\n \"signerName\": \"{{requestorName}}\",\r\n \"acceptAgreement\": \"1\"\r\n }\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}GenerateOrderSSL", + "host": [ + "{{baseURL}}GenerateOrderSSL" + ] + }, + "description": "This API facilitates to generate new order for all emSign - SSL / TLS - DV - UCC certificates.\n\n**Prerequisites**\n\neMudhra provides following values for generating SSL DV Orders. It is highly recommended to keep these values configurable, as they change for sandbox environment and Live environment.\n\n- Generate SSL Order Base URL Endpoint\n \n- Product Codes\n \n\n| **Region** | **URL** |\n| --- | --- |\n| US | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n| Global | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n\n**API Endpoint**\n\n| Header | Header |\n| --- | --- |\n| URL | http(s)://(url host port and path)/GenerateOrderSSL |\n| Protocol | HTTP/ HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"accountNumber\": \"\",\n \"authKey\": \"\" \n },\n \"orderDetails\": {\n \"productCode\": \"\",\n \"accountingModel\": \"\",\n \"saveAndHold\": \"\",\n \"requestNumber\": \"\",\n \"emailNotifications\": \"\", \n \"groupNumber\": \"\",\n \"requestorInformation\": {\n \"requestorName\": \"\",\n \"requestorIsdCode\": \"\",\n \"requestorMobileNumber\": \"\",\n \"requestorEmail\": \"\",\n \"requestorDesignation\": \"\"\n },\n \"delegationInformation\": {\n \"contactName\": \"\",\n \"email\": \"\"\n },\n \"organizationDetails\": {\n \"preVetting\": \"\",\n \"organizationNumber\": \"\",\n \"prevettingToken\": \"\"\n },\n \"certificateInformation\": {\n \"domainName\": \"\",\n \"additionalDomains\": [\n \"\",\n \"\",\n ],\n \"autoSecureWWW\": \"\"\n },\n \"agreementDetails\": {\n \"acceptAgreement\": \"\", \n \"signerName\": \"\", \n \"signerPlace\": \"\",\n \"signerIP\": \"\"\n },\n \"subscriptionDetails\": {\n \"validity\": \"\", \n \"autoRenew\": \"\", \n \"renewCriteria\": \"\"\n },\n \"csr\":\"\",\n \"additionalInformation\": {\n \"tags\":[\n \"(Tag Name:Tag Value)\",\n \"(Tag Name:Tag Value)\"\n ],\n \"customFields\":[\n {\n \"fieldID\": \"\",\n \"fieldValue\": \"\"\n },\n {\n \"fieldID\": \"\",\n \"fieldValue\": \"\"\n }\n ],\n \"remarks\": \"\",\n \"recipientEmail\": \"\",\n \"technicalPointOfContact\": {\n \"pocName\":\"\",\n \"pocEmail\":\"\",\n \"pocIsdCode\":\"\",\n \"pocMobileNumber\":\"\",\n \"pocDesignation\":\"\"\n }\n }\n}\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order. Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | 1234567890 (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | 6288275133 (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | 952a95057654aa1c26a0f287b6368579f5f3ec99f213543df5e0f7eb7f798b3d (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| productCode | 844 (mandatory)
Unique Product Code of the respective product. Product Code values are provided by eMudhra. |\n| accountingModel | 1 (optional)
Accounting Model of the Account. The allowed value for this field is: 1 - Cash Model. The default value is '1' (in case if it is not set). |\n| saveAndHold | 0 (mandatory)
Should be set to any of the numeric values.
1 - Only Request ID will be generated and request will be pending for payment.
0 - Both Request ID & Order ID will be generated. |\n| requestNumber | 676565676 (mandatory)
Request Number of the existing request which was saved as a draft.
This field is 'mandatory' only for the requests (Request ID is available & Order ID is yet to generate). |\n| emailNotifications | 1 (mandatory)
Can contain one of the numeric values as under. Default is '1'.
0 - Pre-vetting Organization (Organization & Subscriber Agreement info.) re-use consent notification will be sent to the applicant on order initiation, if \"preVetting\" value is set to '1'.
1 - All Order Notifications will be sent to the applicant on order initiation. |\n| groupNumber | 8194218742 (optional)
Group Number of the group. This is auto generated by the system. Groups can be your cost centers, departments, etc. within your Business. It acts as a feature / tool for managing account user access to organizations, domains and finances. If group number is not given in the request, then system will consider the default group number and amount will be deducted from the account balance. If group is having its own balance, then amount will be deducted from the group balance else the amount will be deducted from the account balance. |\n| requestorInformation | (mandatory)
Specified below. |\n| delegationInformation | (optional)
Specified below. |\n| organizationDetails | (optional)
Specified below.
This is 'required' to re-use the pre-verified domain under the pre-vetted organization (if \"preVetting\" value is set to '1'). For pre-verified domains, fresh DCV is not required. |\n| certificateInformation | (mandatory)
Specified below. |\n| agreementDetails | (conditional mandatory)
Signer details of the respective order to complete the Subscriber Agreement automatically. This is mandatory, if \"preVetting\" value is set to '0'. Specified Below. |\n| subscriptionDetails | (conditional mandatory)
Subscription Details of the order. Specified below. |\n| csr | (optional)
CSR means Certificate Signing Request. CSR file has to be generated, preferably in the web server, or with any standard tools. You should ensure that the corresponding private key resides in same web server / tool, so that you can import the SSL / TLS certificate in the same, once it is issued. |\n| additionalInformation | (optional) |\n\n**Requestor Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestorName | John Doe (mandatory)
Name of the requestor. |\n| requestorIsdCode | +1 (mandatory)
ISD Code of the requestor. |\n| requestorMobileNumber | 8280098898 (mandatory)
Mobile number of the requestor. |\n| requestorEmail | johndoe@example.com (mandatory)
Email of the requestor. |\n| requestorDesignation | CFO (optional)
Designation of the requestor. |\n\n**Delegation Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| contactName | John Doe(mandatory)
Contact Name of the delegated person. |\n| email | johndoe@example.com (mandatory)
Email ID of the delegated person. |\n\n**Organization Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| preVetting | 1 (mandatory)
This can be set to any of the numeric values as under. Default is '0'.
0 - No (New Organization)
1 - Yes (Pre-verified Domain of pre-vetted Organization). |\n| organizationNumber | 566245635 (conditional mandatory)
Organization Number (Org. ID) of the existing organization associated to the respective emSign account of the certificate requester. This is mandatory, if \"preVetting\" value is set to '1'. |\n| prevettingToken | 0947CBA3C2DF42B0B303590541C8E5B1(optional)
Please specify the unique pre-vetted token value associated to the respective organization. Pre-vetting Organization re-use consent email notification will not be sent on order initiation, if the organization re-use token is submitted with your certificate order.
To get the prevetting token, please contact your Account administrator. |\n\n**Certificate Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| domainName | emudhra.com (mandatory)
Domain Name of the website / server. If the pre-verified domain under the pre-vetted organization (if \"preVetting\" value is set to '1') is provided then fresh DCV is not required. |\n| additionalDomains | emass.emudhra.com (mandatory)
Additional domain names of the website / server. If any new Sub domain of pre-verified base domain is provided under the pre-vetted organization (if \"preVetting\" value is set to '1') then fresh DCV is not required. This field is required only for SSL multi-domain / UCC products as specified below.
SSL / TLS - DV UCC
SSL / TLS - DV Wild card UCC |\n| autoSecureWWW | 1 (optional)
This is set to '1' by default. 1 - Enabled (Secure 'www' variant of website) 0 - Disabled (Doesn't secure 'www' variant of website). If user has chosen to secure website with both www and without www variants, then File-based (HTTP / HTTPs URL) DCV method is not allowed to verify DCV. This is not applicable for emSign - SSL / TLS - DV Wildcard products. |\n\n**Subscriber Agreement Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| acceptAgreement | 1 (mandatory)
This can be set to any of the numeric values as under.
1 - Accept Subscriber Agreement
0 - Reject Subscriber Agreement |\n| signerName | Sipra (conditional mandatory)
Name of the Signer.
It is mandatory, if 'acceptAgreement' is 1. |\n| signerPlace | GOA (conditional mandatory)
Place of the Signer.
It is mandatory, if 'acceptAgreement' is 1. |\n| signerIP | 10.24.108.199.182 (conditional mandatory) IP Address of the Signer.
It is mandatory, if 'acceptAgreement' is 1. |\n\n**Subscription Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| validity | 1 (optional)
Validity of the Subscription (1 / 2 / 3 Years).
Default value is '1' Year.
emSign is providing subscription validity upto 3 years where maximum Lifetime of 390 days per certificate is available with free renewals. |\n| autoRenew | 1 (conditional mandatory)
Auto-renew certificates until coverage.
Default value is '1'.
1: Allow Renewal of Certificate
0: Decline Renewal of Certificate |\n| renewCriteria | 30 (conditional mandatory)
Time duration to for renew certificate before expiry.
Default value is '30' Days.
30: Automatically reissue before 30 days of certificate expiry.
60: Automatically reissue before 60 days of certificate expiry. |\n\n**Additional Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| tags | (optional)
Reporting tags are labels with different values, which can be associated with your certificate orders in CERTInext. These tags can then be used to filter reports, giving you a categorized insight into your business.
Tag Name and Tag Value must be colon separated values.
e.g.: Department:Technology
Multiple tag names and tag values can be associated with the order.
e.g.: Department:Technology,
Department:Legal,
Branch Location:India
Specified below. |\n| customFields | (conditional mandatory)
CERTInext allows you to use custom fields in your certificate order form. The Custom Field can be used to simplify record-keeping and makes order management efficient.
To enable Custom Fields feature for your CERTInext account, please contact your Account Manager.
Specified below. |\n| remarks | (optional)
Additional Information related to the order. |\n| recipientEmail | (optional)
The additional email recipients can be provided for receiving notifications related to Order Confirmation, Revocation and Renewal of certificates. |\n| technicalPointOfContact | (optional)
Technical Point of Contact will be notified for emails which are technical in nature such as Order Confirmation with order status tracking link, CSR & Certificate Download notification based on the email notifications configuration set by your account administrator.
Specified below. |\n\n**Tags**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| Tag Name | Dept (mandatory)
Reporting Tag Name. |\n| Tag Value | Admin (mandatory)
Reporting Tag Value. |\n\n**Custom Fields**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| fieldID | 456 (mandatory)
Specify the Custom Field ID. This is a unique field identifier of a custom field.
This is mandatory, if Custom Fields are mandated by your account administrator.
This information will be available within CERTInext online portal. |\n| fieldValue | Dept (mandatory)
Specify the Custom Field value(s). You have to provide the right input based on the custom field type set by your account administrator.
NOTE: The allowed date format for date picker based Custom Field is: YYYY-MM-DD
This is mandatory, if Custom Fields are mandated by your account administrator.
The detailed Custom Field information will be available within CERTInext online portal. |\n\n**Technical Point of Contact**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| pocName | John Doe(mandatory)
Name of the Technical Point of Contact. |\n| pocEmail | johndoe@example.com (mandatory)
Email ID of the Technical Point of Contact. |\n| pocIsdCode | +1 (optional)
ISD Code of the Technical Point of Contact's Mobile Number. |\n| pocMobileNumber | 9676462551 (optional)
Mobile number of the Technical Point of Contact. |\n| pocDesignation | Senior Developer (optional)
Designation of the Technical Point of Contact. |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n },\n \"orderDetails\": { \n \"requestNumber\":\"\",\n \"orderNumber\": \"\",\n \"trackingUrl\": \"\"\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order.
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-09-08T12:29:36+05:30 (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | 1234567890 (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | 1 (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestNumber | 5787262867(conditional mandatory)
Unique Request ID of the respective request. This is 'Mandatory', if 'saveAndHold' is set to '1'. |\n| orderNumber | 5374489755 (conditional mandatory)
Unique Order ID of the respective order.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |\n| trackingUrl | [https://sandbox-emsignsubscriber.emudhra.net/PublicLink/publicLinkVerification.jsp?a=Q29VT3BlTWdHcnlQNjh2Y3l5SVQ0Zz09](https://sandbox-emsignsubscriber.emudhra.net/PublicLink/publicLinkVerification.jsp?a=Q29VT3BlTWdHcnlQNjh2Y3l5SVQ0Zz09) (mandatory)
Order status tracking URL of the respective order.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |" + }, + "response": [ + { + "name": "SSL/TLS - DV - UCC", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n\"meta\": {\r\n \"ver\":\"1.0\",\r\n \"ts\":\"{{ts}}\",\r\n \"txn\":\"{{txn}}\",\r\n \"accountNumber\":\"4397827229\",\r\n \"authKey\":\"{{hash}}\" \r\n },\r\n \"orderDetails\": {\r\n \"productCode\":\"845\",\r\n \"accountingModel\":\"2\",\r\n \"saveAndHold\":\"0\",\r\n // \"groupNumber\": \"\",\r\n // \"emailNotifications\":\"0\",\r\n \"requestorInformation\": {\r\n \"requestorName\":\"Viña del Mar Viña Green\",\r\n \"requestorIsdCode\":\"1\",\r\n \"requestorMobileNumber\":\"7094940185\",\r\n \"requestorEmail\":\"john.green@example.com\",\r\n \"requestorDesignation\":\"Manager\"\r\n },\r\n \"subscriptionDetails\": { // Applicable only for SSL DV / OV\r\n \"validity\": \"3\", //1/2/3 (Default Value: 1)\r\n \"autoRenew\": \"1\", //1/0 (Default-1)\r\n \"renewCriteria\": \"60\" //30/60 (Default 30)\r\n },\r\n \"delegationInformation\": {\r\n \"contactName\":\"Ben Dover\",\r\n \"email\":\"ben.dover@example.com\"\r\n },\r\n \"organizationDetails\": {\r\n \"preVetting\": \"0\",\r\n \"organizationNumber\":\"\"\r\n },\r\n \"certificateInformation\": {\r\n \"autoSecureWWW\":\"1\",\r\n \"domainName\":\"*.digitaltrust.ae\",\r\n \"additionalDomains\": [\r\n \"dv.digitaltrust.ae\",\r\n \"ov.digitaltrust.ae\",\r\n \"ca.digitaltrust.ae\",\r\n \"temp.digitaltrust.ae\"\r\n ]\r\n },\r\n \"csr\":\"\",\r\n \"additionalInformation\": {\r\n \"remarks\": \"test\"\r\n },\r\n \"technicalPointOfContact\":{\r\n \"pocFirstName\":\"Ben\",\r\n \"pocLastName\":\"Dover\",\r\n \"pocEmail\":\"ben.dover@example.com\",\r\n \"pocIsdCode\":\"+1\",\r\n \"pocMobileNumber\":\"7094940185\",\r\n \"pocDesignation\":\"testing\"\r\n },\r\n \"agreementDetails\": {\r\n \"signerIP\": \"103.156.134.109\",\r\n \"signerPlace\": \"Provo\",\r\n \"signerName\": \"John Green\",\r\n \"acceptAgreement\": \"1\"\r\n }\r\n}\r\n}\r\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/GenerateOrderSSL", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "GenerateOrderSSL" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "353" + }, + { + "key": "Date", + "value": "Tue, 02 Apr 2024 10:51:03 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"orderDetails\": {\n \"requestNumber\": \"4692552142\",\n \"orderNumber\": \"3939394765\",\n \"trackingURL\": \"https://sandbox-emsignsubscriber.emudhra.net/PublicLink/publicLinkVerification.jsp?a=UnQ4Z1EyTVFmcXFrdUo1WHBkMVdjZz09\"\n },\n \"meta\": {\n \"ver\": \"1.0\",\n \"errorMessage\": \"\",\n \"errorCode\": \"\",\n \"txn\": \"5335ebf2e063445081832d4a1171a1a9\",\n \"ts\": \"2024-04-02T16:20:51+05:30\",\n \"status\": \"1\"\n }\n}" + } + ] + }, + { + "name": "SSL/TLS - DV Wildcard", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "if (status === \"1\") {", + " pm.test(\"Request Successful\", () => {", + " pm.expect(status).to.equal(\"1\");", + " if (res.orderDetails && res.orderDetails.orderNumber) {", + " pm.collectionVariables.set(\"orderNumber\", res.orderDetails.orderNumber);", + " }", + " if (res.orderDetails && res.orderDetails.requestNumber) {", + " pm.collectionVariables.set(\"requestNumber\", res.orderDetails.requestNumber);", + " }", + " });", + "} else {", + " pm.test(\"Request FAILED: \" + (errMsg || \"Unknown error\"), () => {", + " pm.expect(status).to.equal(\"1\");", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"productCode\": \"{{SSL_DV_Wildcard}}\",\r\n \"accountingModel\": \"2\",\r\n \"saveAndHold\": \"0\",\r\n \"emailNotifications\": \"1\",\r\n \"requestorInformation\": {\r\n \"requestorName\": \"{{requestorName}}\",\r\n \"requestorIsdCode\": \"1\",\r\n \"requestorMobileNumber\": \"{{requestorMobileNumber}}\",\r\n \"requestorEmail\": \"{{requestorEmail}}\",\r\n \"requestorDesignation\": \"{{requestorDesignation}}\"\r\n },\r\n \"subscriptionDetails\": {\r\n \"validity\": \"3\",\r\n \"autoRenew\": \"1\",\r\n \"renewCriteria\": \"60\"\r\n },\r\n \"delegationInformation\": {\r\n \"contactName\": \"\",\r\n \"email\": \"\"\r\n },\r\n \"organizationDetails\": {\r\n \"preVetting\": \"\",\r\n \"organizationNumber\": \"\"\r\n },\r\n \"certificateInformation\": {\r\n \"domainName\": \"{{wildcardDomainName}}\"\r\n },\r\n \"csr\": \"\",\r\n \"additionalInformation\": {\r\n \"remarks\": \"\"\r\n },\r\n \"technicalPointOfContact\": {\r\n \"pocFirstName\": \"\",\r\n \"pocLastName\": \"\",\r\n \"pocEmail\": \"\",\r\n \"pocIsdCode\": \"\",\r\n \"pocMobileNumber\": \"\",\r\n \"pocDesignation\": \"\"\r\n },\r\n \"autoSecureWWW\": \"1\",\r\n \"agreementDetails\": {\r\n \"signerIP\": \"{{signerIP}}\",\r\n \"signerPlace\": \"{{signerPlace}}\",\r\n \"signerName\": \"{{requestorName}}\",\r\n \"acceptAgreement\": \"1\"\r\n }\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}GenerateOrderSSL", + "host": [ + "{{baseURL}}GenerateOrderSSL" + ] + }, + "description": "This API facilitates to generate new order for emSign - SSL / TLS - DV Wildcard certificates.\n\n**Prerequisites**\n\neMudhra provides following values for generating SSL DV Orders. It is highly recommended to keep these values configurable, as they change for sandbox environment and Live environment.\n\n- Generate SSL Order Base URL Endpoint\n \n- Product Codes\n \n\n| **Region** | **URL** |\n| --- | --- |\n| US | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n| Global | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n\n**API Endpoint**\n\n| Header | Header |\n| --- | --- |\n| URL | http(s)://(url host port and path)/GenerateOrderSSL |\n| Protocol | HTTP/ HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"accountNumber\": \"\",\n \"authKey\": \"\" \n },\n \"orderDetails\": {\n \"productCode\": \"\",\n \"accountingModel\": \"\",\n \"saveAndHold\": \"\",\n \"requestNumber\": \"\",\n \"emailNotifications\": \"\", \n \"groupNumber\": \"\",\n \"requestorInformation\": {\n \"requestorName\": \"\",\n \"requestorIsdCode\": \"\",\n \"requestorMobileNumber\": \"\",\n \"requestorEmail\": \"\",\n \"requestorDesignation\": \"\"\n },\n \"delegationInformation\": {\n \"contactName\": \"\",\n \"email\": \"\"\n },\n \"organizationDetails\": {\n \"preVetting\": \"\",\n \"organizationNumber\": \"\",\n \"prevettingToken\": \"\"\n },\n \"certificateInformation\": {\n \"domainName\": \"\",\n \"additionalDomains\": [\n \"\",\n \"\",\n ],\n \"autoSecureWWW\": \"\"\n },\n \"agreementDetails\": {\n \"acceptAgreement\": \"\", \n \"signerName\": \"\", \n \"signerPlace\": \"\",\n \"signerIP\": \"\"\n },\n \"subscriptionDetails\": {\n \"validity\": \"\", \n \"autoRenew\": \"\", \n \"renewCriteria\": \"\"\n },\n \"csr\":\"\",\n \"additionalInformation\": {\n \"tags\":[\n \"(Tag Name:Tag Value)\",\n \"(Tag Name:Tag Value)\"\n ],\n \"customFields\":[\n {\n \"fieldID\": \"\",\n \"fieldValue\": \"\"\n },\n {\n \"fieldID\": \"\",\n \"fieldValue\": \"\"\n }\n ],\n \"remarks\": \"\",\n \"recipientEmail\": \"\",\n \"technicalPointOfContact\": {\n \"pocName\":\"\",\n \"pocEmail\":\"\",\n \"pocIsdCode\":\"\",\n \"pocMobileNumber\":\"\",\n \"pocDesignation\":\"\"\n }\n }\n}\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order. Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | 1234567890 (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | 6288275133 (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | 952a95057654aa1c26a0f287b6368579f5f3ec99f213543df5e0f7eb7f798b3d (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| productCode | 844 (mandatory)
Unique Product Code of the respective product. Product Code values are provided by eMudhra. |\n| accountingModel | 1 (optional)
Accounting Model of the Account. The allowed value for this field is: 1 - Cash Model. The default value is '1' (in case if it is not set). |\n| saveAndHold | 0 (mandatory)
Should be set to any of the numeric values.
1 - Only Request ID will be generated and request will be pending for payment.
0 - Both Request ID & Order ID will be generated. |\n| requestNumber | 676565676 (mandatory)
Request Number of the existing request which was saved as a draft.
This field is 'mandatory' only for the requests (Request ID is available & Order ID is yet to generate). |\n| emailNotifications | 1 (mandatory)
Can contain one of the numeric values as under. Default is '1'.
0 - Pre-vetting Organization (Organization & Subscriber Agreement info.) re-use consent notification will be sent to the applicant on order initiation, if \"preVetting\" value is set to '1'.
1 - All Order Notifications will be sent to the applicant on order initiation. |\n| groupNumber | 8194218742 (optional)
Group Number of the group. This is auto generated by the system. Groups can be your cost centers, departments, etc. within your Business. It acts as a feature / tool for managing account user access to organizations, domains and finances. If group number is not given in the request, then system will consider the default group number and amount will be deducted from the account balance. If group is having its own balance, then amount will be deducted from the group balance else the amount will be deducted from the account balance. |\n| requestorInformation | (mandatory)
Specified below. |\n| delegationInformation | (optional)
Specified below. |\n| organizationDetails | (optional)
Specified below.
This is 'required' to re-use the pre-verified domain under the pre-vetted organization (if \"preVetting\" value is set to '1'). For pre-verified domains, fresh DCV is not required. |\n| certificateInformation | (mandatory)
Specified below. |\n| agreementDetails | (conditional mandatory)
Signer details of the respective order to complete the Subscriber Agreement automatically. This is mandatory, if \"preVetting\" value is set to '0'. Specified Below. |\n| subscriptionDetails | (conditional mandatory)
Subscription Details of the order. Specified below. |\n| csr | (optional)
CSR means Certificate Signing Request. CSR file has to be generated, preferably in the web server, or with any standard tools. You should ensure that the corresponding private key resides in same web server / tool, so that you can import the SSL / TLS certificate in the same, once it is issued. |\n| additionalInformation | (optional)
Specified below. |\n\n**Requestor Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestorName | John Doe (mandatory)
Name of the requestor. |\n| requestorIsdCode | +91 (mandatory)
ISD Code of the requestor. |\n| requestorMobileNumber | 8280098898 (mandatory)
Mobile number of the requestor. |\n| requestorEmail | [johndoe@example.com](https://mailto:johndoe@example.com) (mandatory)
Email of the requestor. |\n| requestorDesignation | CFO (optional)
Designation of the requestor. |\n\n**Delegation Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| contactName | John Doe (mandatory)
Contact Name of the delegated person. |\n| email | [johndoe@example.com](https://mailto:johndoe@example.com) (mandatory)
Email ID of the delegated person. |\n\n**Organization Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| preVetting | 1 (mandatory)
This can be set to any of the numeric values as under. Default is '0'.
0 - No (New Organization)
1 - Yes (Pre-verified Domain of pre-vetted Organization). |\n| organizationNumber | 566245635 (conditional mandatory)
Organization Number (Org. ID) of the existing organization associated to the respective emSign account of the certificate requester. This is mandatory, if \"preVetting\" value is set to '1'. |\n| prevettingToken | 0947CBA3C2DF42B0B303590541C8E5B1(optional)
Please specify the unique pre-vetted token value associated to the respective organization. Pre-vetting Organization re-use consent email notification will not be sent on order initiation, if the organization re-use token is submitted with your certificate order.
To get the prevetting token, please contact your Account administrator. |\n\n**Certificate Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| domainName | emudhra.com (mandatory)
Domain Name of the website / server. If the pre-verified domain under the pre-vetted organization (if \"preVetting\" value is set to '1') is provided then fresh DCV is not required. |\n| additionalDomains | emass.emudhra.com (mandatory)
Additional domain names of the website / server. If any new Sub domain of pre-verified base domain is provided under the pre-vetted organization (if \"preVetting\" value is set to '1') then fresh DCV is not required. This field is required only for SSL multi-domain / UCC products as specified below.
SSL / TLS - DV UCC
SSL / TLS - DV Wild card UCC |\n| autoSecureWWW | 1 (optional)
This is set to '1' by default. 1 - Enabled (Secure 'www' variant of website) 0 - Disabled (Doesn't secure 'www' variant of website). If user has chosen to secure website with both www and without www variants, then File-based (HTTP / HTTPs URL) DCV method is not allowed to verify DCV. This is not applicable for emSign - SSL / TLS - DV Wildcard products. |\n\n**Subscriber Agreement Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| acceptAgreement | 1 (mandatory)
This can be set to any of the numeric values as under.
1 - Accept Subscriber Agreement
0 - Reject Subscriber Agreement |\n| signerName | Sipra (conditional mandatory)
Name of the Signer.
It is mandatory, if 'acceptAgreement' is 1. |\n| signerPlace | GOA (conditional mandatory)
Place of the Signer.
It is mandatory, if 'acceptAgreement' is 1. |\n| signerIP | 10.24.108.199.182 (conditional mandatory) IP Address of the Signer.
It is mandatory, if 'acceptAgreement' is 1. |\n\n**Subscription Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| validity | 1 (optional)
Validity of the Subscription (1 / 2 / 3 Years).
Default value is '1' Year.
emSign is providing subscription validity upto 3 years where maximum Lifetime of 390 days per certificate is available with free renewals. |\n| autoRenew | 1 (conditional mandatory)
Auto-renew certificates until coverage.
Default value is '1'.
1: Allow Renewal of Certificate
0: Decline Renewal of Certificate |\n| renewCriteria | 30 (conditional mandatory)
Time duration to for renew certificate before expiry.
Default value is '30' Days.
30: Automatically reissue before 30 days of certificate expiry.
60: Automatically reissue before 60 days of certificate expiry. |\n\n**Additional Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| tags | (optional)
Reporting tags are labels with different values, which can be associated with your certificate orders in CERTInext. These tags can then be used to filter reports, giving you a categorized insight into your business.
Tag Name and Tag Value must be colon separated values.
e.g.: Department:Technology
Multiple tag names and tag values can be associated with the order.
e.g.: Department:Technology,
Department:Legal,
Branch Location:India
Specified below. |\n| customFields | (conditional mandatory)
CERTInext allows you to use custom fields in your certificate order form. The Custom Field can be used to simplify record-keeping and makes order management efficient.
To enable Custom Fields feature for your CERTInext account, please contact your Account Manager.
Specified below. |\n| remarks | (optional)
Additional Information related to the order. |\n| recipientEmail | (optional)
The additional email recipients can be provided for receiving notifications related to Order Confirmation, Revocation and Renewal of certificates. |\n| technicalPointOfContact | (optional)
Technical Point of Contact will be notified for emails which are technical in nature such as Order Confirmation with order status tracking link, CSR & Certificate Download notification based on the email notifications configuration set by your account administrator.
Specified below. |\n\n**Tags**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| Tag Name | Dept (mandatory)
Reporting Tag Name. |\n| Tag Value | Admin (mandatory)
Reporting Tag Value. |\n\n**Custom Fields**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| fieldID | 456 (mandatory)
Specify the Custom Field ID. This is a unique field identifier of a custom field.
This is mandatory, if Custom Fields are mandated by your account administrator.
This information will be available within CERTInext online portal. |\n| fieldValue | Dept (mandatory)
Specify the Custom Field value(s). You have to provide the right input based on the custom field type set by your account administrator.
NOTE: The allowed date format for date picker based Custom Field is: YYYY-MM-DD
This is mandatory, if Custom Fields are mandated by your account administrator.
The detailed Custom Field information will be available within CERTInext online portal. |\n\n**Technical Point of Contact**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| pocName | John Doe (mandatory)
Name of the Technical Point of Contact. |\n| pocEmail | [johndoe@example.com](https://mailto:johndoe@example.com) (mandatory)
Email ID of the Technical Point of Contact. |\n| pocIsdCode | +1 (optional)
ISD Code of the Technical Point of Contact's Mobile Number. |\n| pocMobileNumber | 9676462551 (optional)
Mobile number of the Technical Point of Contact. |\n| pocDesignation | Senior Developer (optional)
Designation of the Technical Point of Contact. |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n },\n \"orderDetails\": { \n \"requestNumber\":\"\",\n \"orderNumber\": \"\",\n \"trackingUrl\": \"\"\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order.
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-09-08T12:29:36+05:30 (mandatory)

Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | 1234567890 (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | 1 (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestNumber | 5787262867 (conditional mandatory)
Unique Request ID of the respective request. This is 'Mandatory', if 'saveAndHold' is set to '1'. |\n| orderNumber | 5374489755 (conditional mandatory)
Unique Order ID of the respective order.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |\n| trackingUrl | [https://sandbox-emsignsubscriber.emudhra.net/PublicLink/publicLinkVerification.jsp?a=Q29VT3BlTWdHcnlQNjh2Y3l5SVQ0Zz09](https://sandbox-emsignsubscriber.emudhra.net/PublicLink/publicLinkVerification.jsp?a=Q29VT3BlTWdHcnlQNjh2Y3l5SVQ0Zz09) (mandatory)

Order status tracking URL of the respective order.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |" + }, + "response": [ + { + "name": "SSL/TLS - DV Wildcard", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n\"meta\": {\r\n \"ver\":\"1.0\",\r\n \"ts\":\"{{ts}}\",\r\n \"txn\":\"{{txn}}\",\r\n \"accountNumber\":\"4397827229\",\r\n \"authKey\":\"{{hash}}\" \r\n },\r\n \"orderDetails\": {\r\n \"productCode\":\"843\",\r\n \"accountingModel\":\"2\",\r\n \"saveAndHold\":\"0\",\r\n \"emailNotifications\":\"0\",\r\n \"submitAgreementByEmail\": \"0\",\r\n \"requestorInformation\": {\r\n \"requestorName\":\"Viña del Mar Viña Viña Viña\",\r\n \"requestorIsdCode\":\"1\",\r\n \"requestorMobileNumber\":\"7094940185\",\r\n \"requestorEmail\":\"john.green@example.com\",\r\n \"requestorDesignation\":\"Manager\"\r\n },\r\n \"subscriptionDetails\": { // Applicable only for SSL DV / OV\r\n \"validity\": \"3\", //1/2/3 (Default Value: 1)\r\n \"autoRenew\": \"1\", //1/0 (Default-1)\r\n \"renewCriteria\": \"30\" //30/60 (Default 30)\r\n },\r\n \"delegationInformation\": {\r\n \"contactName\":\"Ben Dover\",\r\n \"email\":\"ben.dover@example.com\"\r\n },\r\n \"organizationDetails\": {\r\n \"preVetting\": \"0\",\r\n \"organizationNumber\":\"\"\r\n },\r\n \"certificateInformation\": {\r\n \"domainName\":\"*.emudhra.com\",\r\n \"additionalDomains\": [\r\n \"\",\r\n \"\"\r\n ]\r\n },\r\n \"csr\":\"\",\r\n \"additionalInformation\": {\r\n \"remarks\": \"test\"\r\n },\r\n \"technicalPointOfContact\":{\r\n \"pocFirstName\":\"Jenne\",\r\n \"pocLastName\":\"Flex\",\r\n \"pocEmail\":\"jenne.flex@example.com\",\r\n \"pocIsdCode\":\"+1\",\r\n \"pocMobileNumber\":\"7094940185\",\r\n \"pocDesignation\":\"testing\"\r\n },\r\n \"agreementDetails\": {\r\n \"signerIP\": \"103.156.134.109\",\r\n \"signerPlace\": \"Provo\",\r\n \"signerName\": \"John Green\",\r\n \"acceptAgreement\": \"1\"\r\n }\r\n}\r\n}\r\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/GenerateOrderSSL", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "GenerateOrderSSL" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "339" + }, + { + "key": "Date", + "value": "Tue, 02 Apr 2024 10:51:25 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"orderDetails\": {\n \"requestNumber\": \"4753353932\",\n \"orderNumber\": \"2412454236\",\n \"trackingURL\": \"https://sandbox-emsignsubscriber.emudhra.net/PublicLink/publicLinkVerification.jsp?a=UnQ4Z1EyTVFmcXExQzVHZzNwaTZHdz09\"\n },\n \"meta\": {\n \"ver\": \"1.0\",\n \"errorMessage\": \"\",\n \"errorCode\": \"\",\n \"txn\": \"282305825471270460\",\n \"ts\": \"2024-04-02T16:21:21+05:30\",\n \"status\": \"1\"\n }\n}" + } + ] + }, + { + "name": "SSL/TLS - DV Wildcard - UCC", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "if (status === \"1\") {", + " pm.test(\"Request Successful\", () => {", + " pm.expect(status).to.equal(\"1\");", + " if (res.orderDetails && res.orderDetails.orderNumber) {", + " pm.collectionVariables.set(\"orderNumber\", res.orderDetails.orderNumber);", + " }", + " if (res.orderDetails && res.orderDetails.requestNumber) {", + " pm.collectionVariables.set(\"requestNumber\", res.orderDetails.requestNumber);", + " }", + " });", + "} else {", + " pm.test(\"Request FAILED: \" + (errMsg || \"Unknown error\"), () => {", + " pm.expect(status).to.equal(\"1\");", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"productCode\": \"{{SSL_DV_Wildcard_UCC}}\",\r\n \"accountingModel\": \"2\",\r\n \"saveAndHold\": \"0\",\r\n \"emailNotifications\": \"1\",\r\n \"requestorInformation\": {\r\n \"requestorName\": \"{{requestorName}}\",\r\n \"requestorIsdCode\": \"1\",\r\n \"requestorMobileNumber\": \"{{requestorMobileNumber}}\",\r\n \"requestorEmail\": \"{{requestorEmail}}\",\r\n \"requestorDesignation\": \"{{requestorDesignation}}\"\r\n },\r\n \"subscriptionDetails\": {\r\n \"validity\": \"3\",\r\n \"autoRenew\": \"1\",\r\n \"renewCriteria\": \"60\"\r\n },\r\n \"delegationInformation\": {\r\n \"contactName\": \"\",\r\n \"email\": \"\"\r\n },\r\n \"organizationDetails\": {\r\n \"preVetting\": \"\",\r\n \"organizationNumber\": \"\"\r\n },\r\n \"certificateInformation\": {\r\n \"domainName\": \"{{wildcardDomainName}}\",\r\n \"additionalDomains\": [\r\n \"www.anand.com\",\r\n \"ov.anand.ae\",\r\n \"ca.anand.ae\",\r\n \"temp.anand.ae\"\r\n ]\r\n },\r\n \"csr\": \"\",\r\n \"additionalInformation\": {\r\n \"remarks\": \"\",\r\n \"tags\": []\r\n },\r\n \"technicalPointOfContact\": {\r\n \"pocFirstName\": \"\",\r\n \"pocLastName\": \"\",\r\n \"pocEmail\": \"\",\r\n \"pocIsdCode\": \"\",\r\n \"pocMobileNumber\": \"\",\r\n \"pocDesignation\": \"\"\r\n },\r\n \"autoSecureWWW\": \"1\",\r\n \"agreementDetails\": {\r\n \"signerIP\": \"{{signerIP}}\",\r\n \"signerPlace\": \"{{signerPlace}}\",\r\n \"signerName\": \"{{requestorName}}\",\r\n \"acceptAgreement\": \"1\"\r\n }\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}GenerateOrderSSL", + "host": [ + "{{baseURL}}GenerateOrderSSL" + ] + }, + "description": "This API facilitates to generate new order for emSign - SSL / TLS - DV Wildcard - UCC certificates.\n\n**Prerequisites**\n\neMudhra provides following values for generating SSL DV Orders. It is highly recommended to keep these values configurable, as they change for sandbox environment and Live environment.\n\n- Generate SSL Order Base URL Endpoint\n \n- Product Codes\n \n\n| **Region** | **URL** |\n| --- | --- |\n| US | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n| Global | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n\n**API Endpoint**\n\n| Header | Header |\n| --- | --- |\n| URL | http(s)://(url host port and path)/GenerateOrderSSL |\n| Protocol | HTTP/ HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"accountNumber\": \"\",\n \"authKey\": \"\" \n },\n \"orderDetails\": {\n \"productCode\": \"\",\n \"accountingModel\": \"\",\n \"saveAndHold\": \"\",\n \"requestNumber\": \"\",\n \"emailNotifications\": \"\", \n \"groupNumber\": \"\",\n \"requestorInformation\": {\n \"requestorName\": \"\",\n \"requestorIsdCode\": \"\",\n \"requestorMobileNumber\": \"\",\n \"requestorEmail\": \"\",\n \"requestorDesignation\": \"\"\n },\n \"delegationInformation\": {\n \"contactName\": \"\",\n \"email\": \"\"\n },\n \"organizationDetails\": {\n \"preVetting\": \"\",\n \"organizationNumber\": \"\",\n \"prevettingToken\": \"\"\n },\n \"certificateInformation\": {\n \"domainName\": \"\",\n \"additionalDomains\": [\n \"\",\n \"\",\n ],\n \"autoSecureWWW\": \"\"\n },\n \"agreementDetails\": {\n \"acceptAgreement\": \"\", \n \"signerName\": \"\", \n \"signerPlace\": \"\",\n \"signerIP\": \"\"\n },\n \"subscriptionDetails\": {\n \"validity\": \"\", \n \"autoRenew\": \"\", \n \"renewCriteria\": \"\"\n },\n \"csr\":\"\",\n \"additionalInformation\": {\n \"tags\":[\n \"(Tag Name:Tag Value)\",\n \"(Tag Name:Tag Value)\"\n ],\n \"customFields\":[\n {\n \"fieldID\": \"\",\n \"fieldValue\": \"\"\n },\n {\n \"fieldID\": \"\",\n \"fieldValue\": \"\"\n }\n ],\n \"remarks\": \"\",\n \"recipientEmail\": \"\",\n \"technicalPointOfContact\": {\n \"pocName\":\"\",\n \"pocEmail\":\"\",\n \"pocIsdCode\":\"\",\n \"pocMobileNumber\":\"\",\n \"pocDesignation\":\"\"\n }\n }\n}\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order. Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | 1234567890 (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | 6288275133 (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | 952a95057654aa1c26a0f287b6368579f5f3ec99f213543df5e0f7eb7f798b3d (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| productCode | 844 (mandatory)
Unique Product Code of the respective product. Product Code values are provided by eMudhra. |\n| accountingModel | 1 (optional)
Accounting Model of the Account. The allowed value for this field is: 1 - Cash Model. The default value is '1' (in case if it is not set). |\n| saveAndHold | 0 (mandatory)
Should be set to any of the numeric values.
1 - Only Request ID will be generated and request will be pending for payment.
0 - Both Request ID & Order ID will be generated. |\n| requestNumber | 676565676 (mandatory)
Request Number of the existing request which was saved as a draft.
This field is 'mandatory' only for the requests (Request ID is available & Order ID is yet to generate). |\n| emailNotifications | 1 (mandatory)
Can contain one of the numeric values as under. Default is '1'.
0 - Pre-vetting Organization (Organization & Subscriber Agreement info.) re-use consent notification will be sent to the applicant on order initiation, if \"preVetting\" value is set to '1'.
1 - All Order Notifications will be sent to the applicant on order initiation. |\n| groupNumber | 8194218742 (optional)
Group Number of the group. This is auto generated by the system. Groups can be your cost centers, departments, etc. within your Business. It acts as a feature / tool for managing account user access to organizations, domains and finances. If group number is not given in the request, then system will consider the default group number and amount will be deducted from the account balance. If group is having its own balance, then amount will be deducted from the group balance else the amount will be deducted from the account balance. |\n| requestorInformation | (mandatory)
Specified below. |\n| delegationInformation | (optional)
Specified below. |\n| organizationDetails | (optional)
Specified below.
This is 'required' to re-use the pre-verified domain under the pre-vetted organization (if \"preVetting\" value is set to '1'). For pre-verified domains, fresh DCV is not required. |\n| certificateInformation | (mandatory)
Specified below. |\n| agreementDetails | (conditional mandatory)
Signer details of the respective order to complete the Subscriber Agreement automatically. This is mandatory, if \"preVetting\" value is set to '0'. Specified Below. |\n| subscriptionDetails | (conditional mandatory)
Subscription Details of the order. Specified below. |\n| csr | (optional)
CSR means Certificate Signing Request. CSR file has to be generated, preferably in the web server, or with any standard tools. You should ensure that the corresponding private key resides in same web server / tool, so that you can import the SSL / TLS certificate in the same, once it is issued. |\n| additionalInformation | (optional) |\n\n**Requestor Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestorName | John Doe (mandatory)
Name of the requestor. |\n| requestorIsdCode | +1 (mandatory)
ISD Code of the requestor. |\n| requestorMobileNumber | 8280098898(mandatory)
Mobile number of the requestor. |\n| requestorEmail | [johndoe@example.com](https://mailto:johndoe@example.com) (mandatory)
Email of the requestor. |\n| requestorDesignation | CFO (optional)
Designation of the requestor. |\n\n**Delegation Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| contactName | John Doe (mandatory)
Contact Name of the delegated person. |\n| email | johndoe@example.com (mandatory)
Email ID of the delegated person. |\n\n**Organization Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| preVetting | 1 (mandatory)
This can be set to any of the numeric values as under. Default is '0'.
0 - No (New Organization)
1 - Yes (Pre-verified Domain of pre-vetted Organization). |\n| organizationNumber | 566245635 (conditional mandatory)
Organization Number (Org. ID) of the existing organization associated to the respective emSign account of the certificate requester. This is mandatory, if \"preVetting\" value is set to '1'. |\n| prevettingToken | 0947CBA3C2DF42B0B303590541C8E5B1(optional)
Please specify the unique pre-vetted token value associated to the respective organization. Pre-vetting Organization re-use consent email notification will not be sent on order initiation, if the organization re-use token is submitted with your certificate order.
To get the prevetting token, please contact your Account administrator. |\n\n**Certificate Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| domainName | emudhra.com (mandatory)
Domain Name of the website / server. If the pre-verified domain under the pre-vetted organization (if \"preVetting\" value is set to '1') is provided then fresh DCV is not required. |\n| additionalDomains | emass.emudhra.com (mandatory)
Additional domain names of the website / server. If any new Sub domain of pre-verified base domain is provided under the pre-vetted organization (if \"preVetting\" value is set to '1') then fresh DCV is not required. This field is required only for SSL multi-domain / UCC products as specified below.
SSL / TLS - DV UCC
SSL / TLS - DV Wild card UCC |\n| autoSecureWWW | 1 (optional)
This is set to '1' by default. 1 - Enabled (Secure 'www' variant of website) 0 - Disabled (Doesn't secure 'www' variant of website). If user has chosen to secure website with both www and without www variants, then File-based (HTTP / HTTPs URL) DCV method is not allowed to verify DCV. This is not applicable for emSign - SSL / TLS - DV Wildcard products. |\n\n**Subscriber Agreement Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| acceptAgreement | 1 (mandatory)
This can be set to any of the numeric values as under.
1 - Accept Subscriber Agreement
0 - Reject Subscriber Agreement |\n| signerName | Sipra (conditional mandatory)
Name of the Signer.
It is mandatory, if 'acceptAgreement' is 1. |\n| signerPlace | GOA (conditional mandatory)
Place of the Signer.
It is mandatory, if 'acceptAgreement' is 1. |\n| signerIP | 10.24.108.199.182 (conditional mandatory) IP Address of the Signer.
It is mandatory, if 'acceptAgreement' is 1. |\n\n**Subscription Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| validity | 1 (optional)
Validity of the Subscription (1 / 2 / 3 Years).
Default value is '1' Year.
emSign is providing subscription validity upto 3 years where maximum Lifetime of 390 days per certificate is available with free renewals. |\n| autoRenew | 1 (conditional mandatory)
Auto-renew certificates until coverage.
Default value is '1'.
1: Allow Renewal of Certificate
0: Decline Renewal of Certificate |\n| renewCriteria | 30 (conditional mandatory)
Time duration to for renew certificate before expiry.
Default value is '30' Days.
30: Automatically reissue before 30 days of certificate expiry.
60: Automatically reissue before 60 days of certificate expiry. |\n\n**Additional Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| tags | (optional)
Reporting tags are labels with different values, which can be associated with your certificate orders in CERTInext. These tags can then be used to filter reports, giving you a categorized insight into your business.
Tag Name and Tag Value must be colon separated values.
e.g.: Department:Technology
Multiple tag names and tag values can be associated with the order.
e.g.: Department:Technology,
Department:Legal,
Branch Location:India
Specified below. |\n| customFields | (conditional mandatory)
CERTInext allows you to use custom fields in your certificate order form. The Custom Field can be used to simplify record-keeping and makes order management efficient.
To enable Custom Fields feature for your CERTInext account, please contact your Account Manager.
Specified below. |\n| remarks | (optional)
Additional Information related to the order. |\n| recipientEmail | (optional)
The additional email recipients can be provided for receiving notifications related to Order Confirmation, Revocation and Renewal of certificates. |\n| technicalPointOfContact | (optional)
Technical Point of Contact will be notified for emails which are technical in nature such as Order Confirmation with order status tracking link, CSR & Certificate Download notification based on the email notifications configuration set by your account administrator.
Specified below. |\n\n**Tags**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| Tag Name | Dept (mandatory)
Reporting Tag Name. |\n| Tag Value | Admin (mandatory)
Reporting Tag Value. |\n\n**Custom Fields**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| fieldID | 456 (mandatory)
Specify the Custom Field ID. This is a unique field identifier of a custom field.
This is mandatory, if Custom Fields are mandated by your account administrator.
This information will be available within CERTInext online portal. |\n| fieldValue | Dept (mandatory)
Specify the Custom Field value(s). You have to provide the right input based on the custom field type set by your account administrator.
NOTE: The allowed date format for date picker based Custom Field is: YYYY-MM-DD
This is mandatory, if Custom Fields are mandated by your account administrator.
The detailed Custom Field information will be available within CERTInext online portal. |\n\n**Technical Point of Contact**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| pocName | John Doe(mandatory)
Name of the Technical Point of Contact. |\n| pocEmail | johndoe@example.com (mandatory)
Email ID of the Technical Point of Contact. |\n| pocIsdCode | +91 (optional)
ISD Code of the Technical Point of Contact's Mobile Number. |\n| pocMobileNumber | 9676462551 (optional)
Mobile number of the Technical Point of Contact. |\n| pocDesignation | Senior Developer (optional)
Designation of the Technical Point of Contact. |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n },\n \"orderDetails\": { \n \"requestNumber\":\"\",\n \"orderNumber\": \"\",\n \"trackingUrl\": \"\"\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order.
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-09-08T12:29:36+05:30 (mandatory)

Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | 1234567890 (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | 1 (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestNumber | 5787262867(conditional mandatory)
Unique Request ID of the respective request. This is 'Mandatory', if 'saveAndHold' is set to '1'. |\n| orderNumber | 5374489755 (conditional mandatory)
Unique Order ID of the respective order.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |\n| trackingUrl | [https://sandbox-emsignsubscriber.emudhra.net/PublicLink/publicLinkVerification.jsp?a=Q29VT3BlTWdHcnlQNjh2Y3l5SVQ0Zz09](https://sandbox-emsignsubscriber.emudhra.net/PublicLink/publicLinkVerification.jsp?a=Q29VT3BlTWdHcnlQNjh2Y3l5SVQ0Zz09) (mandatory)

Order status tracking URL of the respective order.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |" + }, + "response": [ + { + "name": "SSL/TLS - DV Wildcard - UCC", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n\"meta\": {\r\n \"ver\":\"1.0\",\r\n \"ts\":\"{{ts}}\",\r\n \"txn\":\"{{txn}}\",\r\n \"accountNumber\":\"4397827229\",\r\n \"authKey\":\"{{hash}}\" \r\n },\r\n \"orderDetails\": {\r\n \"productCode\":\"844\",\r\n \"accountingModel\":\"2\",\r\n \"saveAndHold\":\"0\",\r\n \"emailNotifications\":\"0\",\r\n \r\n \"requestorInformation\": {\r\n \"requestorName\":\"Viña del Mar Viña Viña Viña\",\r\n \"requestorIsdCode\":\"1\",\r\n \"requestorMobileNumber\":\"7094940185\",\r\n \"requestorEmail\":\"john.green@example.com\",\r\n \"requestorDesignation\":\"Manager\"\r\n },\r\n \"subscriptionDetails\": { // Applicable only for SSL DV / OV\r\n \"validity\": \"3\", //1/2/3 (Default Value: 1)\r\n \"autoRenew\": \"1\", //1/0 (Default-1)\r\n \"renewCriteria\": \"60\" //30/60 (Default 30)\r\n },\r\n \"delegationInformation\": {\r\n \"contactName\":\"\",\r\n \"email\":\"\"\r\n },\r\n \"organizationDetails\": {\r\n \"preVetting\": \"0\",\r\n \"organizationNumber\":\"\"\r\n },\r\n \"certificateInformation\": {\r\n \"autoSecureWWW\":\"0\",\r\n \"domainName\":\"nandhu.com\",\r\n \"additionalDomains\": [\r\n \"www.kumaran.com\",\r\n \"ov.digitaltrust.ae\",\r\n \"ca.digitaltrust.ae\",\r\n \"temp.digitaltrust.ae\"\r\n ]\r\n },\r\n \"csr\":\"\",\r\n \"additionalInformation\": {\r\n \"remarks\": \"test\",\r\n \"tags\":[\r\n \"AAA:A\"\r\n ]\r\n },\r\n \"technicalPointOfContact\":{\r\n \"pocFirstName\":\"Ben\",\r\n \"pocLastName\":\"Dover\",\r\n \"pocEmail\":\"ben.dover@example.com\",\r\n \"pocIsdCode\":\"+1\",\r\n \"pocMobileNumber\":\"7094940185\",\r\n \"pocDesignation\":\"Production Engineer\"\r\n },\r\n \"agreementDetails\": {\r\n \"signerIP\": \"103.156.134.109\",\r\n \"signerPlace\": \"Provo\",\r\n \"signerName\": \"John Green\",\r\n \"acceptAgreement\": \"1\"\r\n }\r\n}\r\n}\r\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/GenerateOrderSSL", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "GenerateOrderSSL" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "353" + }, + { + "key": "Date", + "value": "Tue, 02 Apr 2024 10:51:45 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"orderDetails\": {\n \"requestNumber\": \"3447687125\",\n \"orderNumber\": \"3496554498\",\n \"trackingURL\": \"https://sandbox-emsignsubscriber.emudhra.net/PublicLink/publicLinkVerification.jsp?a=UnQ4Z1EyTVFmcXJRc05tRWlFQU9tUT09\"\n },\n \"meta\": {\n \"ver\": \"1.0\",\n \"errorMessage\": \"\",\n \"errorCode\": \"\",\n \"txn\": \"651b09440984472089defaf50c0cffbf\",\n \"ts\": \"2024-04-02T16:21:33+05:30\",\n \"status\": \"1\"\n }\n}" + } + ] + }, + { + "name": "SSL/TLS - OV", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "if (status === \"1\") {", + " pm.test(\"Request Successful\", () => {", + " pm.expect(status).to.equal(\"1\");", + " if (res.orderDetails && res.orderDetails.orderNumber) {", + " pm.collectionVariables.set(\"orderNumber\", res.orderDetails.orderNumber);", + " }", + " if (res.orderDetails && res.orderDetails.requestNumber) {", + " pm.collectionVariables.set(\"requestNumber\", res.orderDetails.requestNumber);", + " }", + " });", + "} else {", + " pm.test(\"Request FAILED: \" + (errMsg || \"Unknown error\"), () => {", + " pm.expect(status).to.equal(\"1\");", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"productCode\": \"{{SSL_OV}}\",\r\n \"accountingModel\": \"2\",\r\n \"saveAndHold\": \"0\",\r\n \"emailNotifications\": \"1\",\r\n \"requestorInformation\": {\r\n \"requestorName\": \"{{requestorName}}\",\r\n \"requestorIsdCode\": \"1\",\r\n \"requestorMobileNumber\": \"{{requestorMobileNumber}}\",\r\n \"requestorEmail\": \"{{requestorEmail}}\",\r\n \"requestorDesignation\": \"{{requestorDesignation}}\"\r\n },\r\n \"subscriptionDetails\": {\r\n \"validity\": \"3\",\r\n \"autoRenew\": \"1\",\r\n \"renewCriteria\": \"60\"\r\n },\r\n \"delegationInformation\": {\r\n \"contactName\": \"\",\r\n \"email\": \"\"\r\n },\r\n \"organizationDetails\": {\r\n \"preVetting\": \"\",\r\n \"organizationNumber\": \"\",\r\n \"prevettingToken\": \"\"\r\n },\r\n \"certificateInformation\": {\r\n \"organizationName\": \"eMudhra Inc.\",\r\n \"organizationUnit\": \"CA\",\r\n \"streetAddress1\": \"1712 South East Bay Blvd\",\r\n \"streetAddress2\": \"Suite 360\",\r\n \"locality\": \"Provo\",\r\n \"state\": \"Utah\",\r\n \"countryCode\": \"US\",\r\n \"postalCode\": \"84606\",\r\n \"domainName\": \"emain.idemo-ppc.co.in\"\r\n },\r\n \"csr\": \"\",\r\n \"additionalInformation\": {\r\n \"remarks\": \"API Testing\",\r\n \"tags\": []\r\n },\r\n \"autoSecureWWW\": \"1\",\r\n \"agreementDetails\": {\r\n \"signerIP\": \"{{signerIP}}\",\r\n \"signerPlace\": \"{{signerPlace}}\",\r\n \"signerName\": \"{{requestorName}}\",\r\n \"acceptAgreement\": \"1\"\r\n }\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}GenerateOrderSSL", + "host": [ + "{{baseURL}}GenerateOrderSSL" + ] + }, + "description": "This API facilitates to generate new order for emSign - SSL / TLS - OV certificates.\n\n**Prerequisites**\n\neMudhra provides following values for generating SSL OV Orders. It is highly recommended to keep these values configurable, as they change for sandbox environment and Live environment.\n\n- Generate SSL Order Base URL Endpoint\n \n- Product Codes\n \n\n| **Region** | **URL** |\n| --- | --- |\n| US | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n| Global | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n\n**API Endpoint**\n\n| Header | Header |\n| --- | --- |\n| URL | http(s)://(url host port and path)/GenerateOrderSSL |\n| Protocol | HTTP/ HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n\"meta\": {\n \"ver\":\"\",\n \"ts\":\"\",\n \"txn\":\"\",\n \"accountNumber\":\"\",\n \"authKey\":\"\" \n },\n\"orderDetails\": {\n \"productCode\": \"\",\n \"accountingModel\": \"\",\n \"saveAndHold\": \"\",\n \"requestNumber\": \"\",\n \"emailNotifications\": \"\",\n \"groupNumber \": \"\",\n \"requestorInformation\": {\n \"requestorName\": \"\",\n \"requestorIsdCode\": \"\",\n \"requestorMobileNumber\": \"\",\n \"requestorEmail\": \"\",\n \"requestorDesignation\": \"\"\n },\n \"delegationInformation\": {\n \"contactName\": \"\",\n \"email\": \"\"\n },\n \"organizationDetails\": {\n \"preVetting\": \"\",\n \"organizationNumber\": \"\",\n \"prevettingToken\": \"\",\n \"representativeNumber\": \"\"\n },\n \"certificateInformation\": {\n \"organizationName\":\"\",\n \"organizationUnit\":\"\",\n \"streetAddress1\":\"\",\n \"streetAddress2\":\"\",\n \"locality\":\"\",\n \"state\":\"\",\n \"countryCode\":\"\",\n \"postalCode\":\"\",\n \"domainName\":\"\",\n \"additionalDomains\": [\n \"\",\n \"\",\n ],\n \"autoSecureWWW\": \"\"\n },\n \"agreementDetails\": {\n \"acceptAgreement\": \"\", \n \"signerName\": \"\", \n \"signerPlace\": \"\",\n \"signerIP\": \"\"\n },\n \"subscriptionDetails\": {\n \"validity\": \"\", \n \"autoRenew\": \"\", \n \"renewCriteria\": \"\"\n },\n \"csr\":\"\",\n \"numberOfDocuments\":\"\",\n \"documentsAttached\": [\n {\n \"id\":\"\",\n \"fileName\":\"\",\n \"description\":\"\",\n \"base64Value\":\"\"\n },\n {\n \"id\":\"\",\n \"fileName\":\"\",\n \"description\":\"\",\n \"base64Value\":\"\"\n }\n ],\n \"additionalInformation\": {\n \"tags\":[\n \"(Tag Name:Tag Value)\",\n \"(Tag Name:Tag Value)\"\n ],\n \"customFields\":[\n {\n \"fieldID\": \"\",\n \"fieldValue\": \"\"\n },\n {\n \"fieldID\": \"\",\n \"fieldValue\": \"\"\n }\n ],\n \"remarks\": \"\",\n \"recipientEmail\": \"\",\n \"technicalPointOfContact\": {\n \"pocName\":\"\",\n \"pocEmail\":\"\",\n \"pocIsdCode\":\"\",\n \"pocMobileNumber\":\"\",\n \"pocDesignation\":\"\"\n }\n }\n}\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order.
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | 1234567890 (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | 6288275133 (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | 952a95057654aa1c26a0f287b6368579f5f3ec99f213543df5e0f7eb7f798b3d (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| productCode | 841 (mandatory)
Unique Product Code of the respective product. Product Code values are provided by eMudhra. |\n| accountingModel | 1 (optional)
Accounting Model of the Account.
The allowed value for this field is: 1 - Cash Model. The default value is '1' (in case if it is not set). |\n| saveAndHold | 0 (mandatory)
Should be set to any of the numeric values.
1 - Only Request ID will be generated and request will be pending for payment.
0 - Both Request ID & Order ID will be generated. |\n| requestNumber | 8785645678 (mandatory)
Request Number of the existing request which was saved as a draft.
This field is 'mandatory' only for the requests (Request ID is available & Order ID is yet to generate). |\n| emailNotifications | 1 (mandatory)
Can contain one of the numeric values as under. Default is '1'.
0 - Pre-vetting Organization (Organization & Subscriber Agreement info.) re-use consent notification will be sent to the applicant on order initiation, if \"preVetting\" value is set to '1'.
1 - All Order Notifications will be sent to the applicant on order initiation. |\n| groupNumber | 3589463147 (optional)
Group Number of the group. This is auto generated by the system. Groups can be your cost centers, departments, etc. within your Business.
It acts as a feature / tool for managing account user access to organizations, domains and finances. If group number is not given in the request, then system will consider the default group number and amount will be deducted from the account balance. If group is having its own balance, then amount will be deducted from the group balance else the amount will be deducted from the account balance. |\n| requestorInformation | (mandatory)
Specified below. |\n| delegationInformation | (mandatory)
Specified below. |\n| organizationDetails | (optional)
Specified below. |\n| certificateInformation | (mandatory)
Specified below. |\n| agreementDetails | (conditional mandatory)
Signer details of the respective order to complete the Subscriber Agreement automatically. This is mandatory, if \"preVetting\" value is set to '0'. Specified Below. |\n| subscriptionDetails | (conditional mandatory)
Subscription Details of the order. |\n| csr | (optional) |\n| numberOfDocuments | (optional)
Number of Documents attached. |\n| documentsAttached | (optional)
Uploading documents during order creation is recommended for sending us any additional / supporting documents so that they are automatically associated with your certificate order. This additional documentation would help emSign to speed up the certificate validation process. (E.g., incorporation letter, registration document, etc.). Specified below. |\n| additionalInformation | (optional)
Specified below. |\n\n**Requestor Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestorName | John Doe (mandatory)
Name of the requestor / Org. Representative. |\n| requestorIsdCode | +1 (mandatory)
ISD Code of the requestor's / Org. Representative's mobile number. |\n| requestorMobileNumber | 8978945116 (mandatory)
Mobile number of the requestor / Org. Representative. |\n| requestorEmail | [johndoe@example.com](https://mailto:johndoe@example.com) (mandatory)
Email of the requestor / Org. Representative. |\n| requestorDesignation | PM (optional)
Designation of the requestor / Org. Representative. |\n\n**Delegation Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| contactName | John Doe (mandatory)
Contact Name of the delegated person. |\n| email | [johndoe@example.com](https://mailto:johndoe@example.com) (mandatory)
Email ID of the delegated person. |\n\n**Organization Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| preVetting | 1 (mandatory)
This can be set to any of the numeric values as under. Default is '0'.
0 - No (New Organization)
1 - Yes (Pre-verified Domain of pre-vetted Organization). You are allowed to use Pre-verified EV Organizations in OV Orders as well. |\n| organizationNumber | 2943849 (conditional mandatory)
Organization Number (Org. ID) of the existing organization associated to the respective emSign account of the certificate requester.
This is mandatory, if \"preVetting\" value is set to '1'. |\n| prevettingToken | 0947CBA3C2DF42B0B303590541C8E5B1 (optional)
Please specify the unique pre-vetted token value associated to the respective organization. Pre-vetting Organization re-use consent email notification will not be sent on order initiation, if the organization re-use token is submitted with your certificate order.
To get the prevetting token, please contact your Account administrator.
See [Organization Details API](https://dev.emsign.com/organization-detail) |\n| representativeNumber | 21023 (optional)
Organization Representative Number of the existing Organization representative. It will consider default representative details of the respective organization (in case if it is not set). |\n\n**Certificate Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| organizationName | emudhra (mandatory)
Organization Name of Organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| organizationUnit | Bangalore Branch (optional)
Organization Unit of the respective Organization. This field is not required, if \"preVetting\" value is set to '1'. |\n| streetAddress1 | KIADB (mandatory)
Street Address 1 of an organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| streetAddress2 | Near Airport (optional)
Street Address 2 of Organization. This field is not required, if \"preVetting\" value is set to '1'. |\n| locality | 3rd Cross (mandatory)
Locality of the respective Organization. This field is not required, if \"preVetting\" value is set to '1'. |\n| state | Karnataka (mandatory)
State of an organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| countryCode | IN (mandatory)
Country Code of Organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| postalCode | 560064 (mandatory)
Postal code of the Organization. This field is not required, if \"preVetting\" value is set to '1'. |\n| domainName | emudhra.com (mandatory)
Domain Name of the website / server. If the pre-verified domain under the pre-vetted organization (if \"preVetting\" value is set to '1') is provided then fresh DCV is not required. |\n| additionalDomainNames | support.emudhra.com (mandatory)
Additional domain names of the website / server. If any new Sub domain of pre-verified base domain is provided under the pre-vetted organization (if \"preVetting\" value is set to '1') then fresh DCV is not required. This field is required only for SSL multi-domain / UCC products as specified below.
SSL / TLS - OV UCC
SSL / TLS - OV Wild card UCC |\n| autoSecureWWW | 1 (mandatory)
This is set to '1' by default.
1 - Enabled (Secure 'www' variant of website)
0 - Disabled (Doesn't secure 'www' variant of website)
If user has chosen to secure website with both www and without www variants, then File-based (HTTP / HTTPs URL) DCV method is not allowed to verify DCV.
This is not applicable for emSign - SSL / TLS - OV Wildcard products. |\n\n**Subscriber Agreement Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| acceptAgreement | 1 (mandatory)
This can be set to any of the numeric values as under.
1 - Accept Subscriber Agreement
0 - Reject Subscriber Agreement |\n| signerName | Sipra (conditional mandatory)
Name of the Signer.
It is mandatory, if 'acceptAgreement' is 1. |\n| signerPlace | GOA (conditional mandatory)
Place of the Signer.
It is mandatory, if 'acceptAgreement' is 1. |\n| signerIP | 10.24.108.199.182 (conditional mandatory)
IP Address of the Signer.
It is mandatory, if 'acceptAgreement' is 1. |\n\n**Subscription Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| validity | 1 (optional)
Validity of the Subscription (1 / 2 / 3 Years).
Default value is '1' Year.
emSign is providing subscription validity upto 3 years where maximum Lifetime of 390 days per certificate is available with free renewals. |\n| autoRenew | 1 (conditional mandatory)
Auto-renew certificates until coverage.
Default value is '1'.
1: Allow Renewal of Certificate
0: Decline Renewal of Certificate |\n| renewCriteria | 30 (conditional mandatory)
Time duration to for renew certificate before expiry.
Default value is '30' Days.
30: Automatically reissue before 30 days of certificate expiry.
60: Automatically reissue before 60 days of certificate expiry. |\n\n**Document Attached**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| id | (mandatory)
ID of the document. |\n| fileName | ID Proof (mandatory)
File Name of the document. |\n| description | Driving License (mandatory)
Description of the document. |\n| base64Value | (mandatory)
Base 64 encoded value of the document. |\n\n**Additional Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| tags | (optional)
Reporting tags are labels with different values, which can be associated with your certificate orders in CERTInext. These tags can then be used to filter reports, giving you a categorized insight into your business.
Tag Name and Tag Value must be colon separated values.
e.g.: Department:Technology
Multiple tag names and tag values can be associated with the order.
e.g.: Department:Technology,
Department:Legal,
Branch Location:India
Specified below. |\n| customFields | (conditional mandatory)
CERTInext allows you to use custom fields in your certificate order form. The Custom Field can be used to simplify record-keeping and makes order management efficient.
To enable Custom Fields feature for your CERTInext account, please contact your Account Manager.
Specified below. |\n| remarks | (optional)
Additional Information related to the order. |\n| recipientEmail | (optional)
The additional email recipients can be provided for receiving notifications related to Order Confirmation, Revocation and Renewal of certificates. |\n| technicalPointOfContact | (optional)
Technical Point of Contact will be notified for emails which are technical in nature such as Order Confirmation with order status tracking link, CSR & Certificate Download notification based on the email notifications configuration set by your account administrator.
Specified below. |\n\n**Tags**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| Tag Name | Dept (mandatory)
Reporting Tag Name. |\n| Tag Value | Admin (mandatory)
Reporting Tag Value. |\n\n**Custom Fields**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| fieldID | 456 (mandatory)
Specify the Custom Field ID. This is a unique field identifier of a custom field.
This is mandatory, if Custom Fields are mandated by your account administrator.
This information will be available within CERTInext online portal. |\n| fieldValue | Dept (mandatory)
Specify the Custom Field value(s). You have to provide the right input based on the custom field type set by your account administrator.
NOTE: The allowed date format for date picker based Custom Field is: YYYY-MM-DD
This is mandatory, if Custom Fields are mandated by your account administrator.
The detailed Custom Field information will be available within CERTInext online portal. |\n\n**Technical Point of Contact**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| pocName | John Doe (mandatory)
Name of the Technical Point of Contact. |\n| pocEmail | johndoe@example.com (mandatory)
Email ID of the Technical Point of Contact. |\n| pocIsdCode | +1 (optional)
ISD Code of the Technical Point of Contact's Mobile Number. |\n| pocMobileNumber | 9676462551 (optional)
Mobile number of the Technical Point of Contact. |\n| pocDesignation | Senior Developer (optional)
Designation of the Technical Point of Contact. |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n },\n \"orderDetails\": { \n \"requestNumber\":\"\",\n \"orderNumber\": \"\",\n \"trackingUrl\": \"\"\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order.
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | 1234567890 (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | 1 (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestNumber | 6725625162 (conditional mandatory)
Unique Request ID of the respective request. This is 'Mandatory', if 'saveAndHold' is set to '1'. |\n| orderNumber | (conditional mandatory)
Unique Order ID of the respective order.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |\n| trackingUrl | (mandatory)
Order status tracking URL of the respective order.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |" + }, + "response": [ + { + "name": "SSL/TLS - OV", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"4397827229\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\":{\r\n \"productCode\":\"846\",\r\n \"accountingModel\":\"2\",\r\n \"saveAndHold\":\"0\",\r\n \"requestNumber\":\"\",\r\n \"emailNotifications\":\"1\",\r\n //\"groupNumber\":\"2977506110\",\r\n \"requestorInformation\":{\r\n \"requestorName\":\"John Green\",\r\n \"requestorIsdCode\":\"+1\",\r\n \"requestorMobileNumber\":\"9535455617\",\r\n \"requestorEmail\":\"john.green@example.com\",\r\n \"requestorDesignation\":\"\"\r\n },\r\n \"subscriptionDetails\": { // Applicable only for SSL DV / OV\r\n \"validity\": \"3\", //1/2/3 (Default Value: 1)\r\n \"autoRenew\": \"1\", //1/0 (Default-1)\r\n \"renewCriteria\": \"60\" //30/60 (Default 30)\r\n },\r\n \"delegationInformation\":{\r\n \"contactName\":\"Ben Dover\",\r\n \"email\":\"ben.dover@example.com\"\r\n },\r\n \"organizationDetails\":{\r\n \"preVetting\":\"0\",\r\n \"organizationNumber\":\"\",\r\n \"prevettingToken\":\"\"\r\n },\r\n \"certificateInformation\":{\r\n \"organizationName\":\"eMudhra Inc.\",\r\n \"organizationUnit\":\"\",\r\n \"streetAddress1\":\"1712 South East Bay Blvd\",\r\n \"streetAddress2\":\"\",\r\n \"locality\":\"Provo\",\r\n \"state\":\"Utah\",\r\n \"countryCode\":\"US\",\r\n \"postalCode\":\"84606 \",\r\n \"domainName\":\"emain.idemo-ppc.co.in\",\r\n \"additionalDomains\":[\r\n \"test1.emain.idemo-ppc.co.in,test2.emain.idemo-ppc.co.in\"\r\n ]\r\n },\r\n \"csr\":\"\",\r\n \"additionalInformation\":{\r\n \"remarks\":\"new testing through Postman\",\r\n \"tags\":[\r\n\r\n ]\r\n \r\n // \"customFields\":[\r\n // {\r\n // \"fieldID\":\"875\",\r\n // \"fieldValue\":\"jenne.flex@example.com\"\r\n // },\r\n // {\r\n // \"fieldID\":\"766\",\r\n // \"fieldValue\":\"Jenne Flex\"\r\n // }\r\n // ]\r\n \r\n },\r\n \"agreementDetails\": {\r\n \"signerIP\": \"103.156.134.109\",\r\n \"signerPlace\": \"Provo\",\r\n \"signerName\": \"John Green\",\r\n \"acceptAgreement\": \"1\"\r\n }\r\n }\r\n}\r\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/GenerateOrderSSL", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "GenerateOrderSSL" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "353" + }, + { + "key": "Date", + "value": "Tue, 02 Apr 2024 10:51:58 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"orderDetails\": {\n \"requestNumber\": \"8212586682\",\n \"orderNumber\": \"8669572198\",\n \"trackingURL\": \"https://sandbox-emsignsubscriber.emudhra.net/PublicLink/publicLinkVerification.jsp?a=UnQ4Z1EyTVFmcXJtTDg1azBiaEJQZz09\"\n },\n \"meta\": {\n \"ver\": \"1.0\",\n \"errorMessage\": \"\",\n \"errorCode\": \"\",\n \"txn\": \"9bac6ab9791f4f23b3fce6c2339577a6\",\n \"ts\": \"2024-04-02T16:21:55+05:30\",\n \"status\": \"1\"\n }\n}" + } + ] + }, + { + "name": "SSL/TLS - OV UCC", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "if (status === \"1\") {", + " pm.test(\"Request Successful\", () => {", + " pm.expect(status).to.equal(\"1\");", + " if (res.orderDetails && res.orderDetails.orderNumber) {", + " pm.collectionVariables.set(\"orderNumber\", res.orderDetails.orderNumber);", + " }", + " if (res.orderDetails && res.orderDetails.requestNumber) {", + " pm.collectionVariables.set(\"requestNumber\", res.orderDetails.requestNumber);", + " }", + " });", + "} else {", + " pm.test(\"Request FAILED: \" + (errMsg || \"Unknown error\"), () => {", + " pm.expect(status).to.equal(\"1\");", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"productCode\": \"{{SSL_OV_UCC}}\",\r\n \"accountingModel\": \"2\",\r\n \"saveAndHold\": \"0\",\r\n \"emailNotifications\": \"1\",\r\n \"requestorInformation\": {\r\n \"requestorName\": \"{{requestorName}}\",\r\n \"requestorIsdCode\": \"1\",\r\n \"requestorMobileNumber\": \"{{requestorMobileNumber}}\",\r\n \"requestorEmail\": \"{{requestorEmail}}\",\r\n \"requestorDesignation\": \"{{requestorDesignation}}\"\r\n },\r\n \"subscriptionDetails\": {\r\n \"validity\": \"3\",\r\n \"autoRenew\": \"1\",\r\n \"renewCriteria\": \"60\"\r\n },\r\n \"delegationInformation\": {\r\n \"contactName\": \"\",\r\n \"email\": \"\"\r\n },\r\n \"organizationDetails\": {\r\n \"preVetting\": \"\",\r\n \"organizationNumber\": \"\"\r\n },\r\n \"certificateInformation\": {\r\n \"organizationName\": \"eMudhra Inc.\",\r\n \"organizationUnit\": \"Technology\",\r\n \"streetAddress1\": \"1712 South East Bay Blvd\",\r\n \"streetAddress2\": \" Suite 360\",\r\n \"locality\": \"Provo\",\r\n \"state\": \"Utah\",\r\n \"countryCode\": \"US\",\r\n \"postalCode\": \"84606\",\r\n \"domainName\": \"emudhra.com\",\r\n \"additionalDomains\": [\r\n \"dv.emudhra.com\",\r\n \"nandhu.emudhra.com\",\r\n \"kumar.emudhra.com\"\r\n ]\r\n },\r\n \"technicalPointOfContact\": {\r\n \"pocFirstName\": \"\",\r\n \"pocLastName\": \"\",\r\n \"pocEmail\": \"\",\r\n \"pocIsdCode\": \"\",\r\n \"pocMobileNumber\": \"\",\r\n \"pocDesignation\": \"\"\r\n },\r\n \"csr\": \"\",\r\n \"numberOfDocuments\": \"\",\r\n \"documentsAttached\": [\r\n {\r\n \"id\": \"\",\r\n \"fileName\": \"\",\r\n \"description\": \"\",\r\n \"base64Value\": \"\"\r\n }\r\n ],\r\n \"additionalInformation\": {\r\n \"remarks\": \"APi Testing\"\r\n },\r\n \"autoSecureWWW\": \"1\",\r\n \"agreementDetails\": {\r\n \"signerIP\": \"{{signerIP}}\",\r\n \"signerPlace\": \"{{signerPlace}}\",\r\n \"signerName\": \"{{requestorName}}\",\r\n \"acceptAgreement\": \"1\"\r\n }\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}GenerateOrderSSL", + "host": [ + "{{baseURL}}GenerateOrderSSL" + ] + }, + "description": "This API facilitates to generate new order for all emSign - SSL / TLS - OV - UCC certificates.\n\n**Prerequisites**\n\neMudhra provides following values for generating SSL OV Orders. It is highly recommended to keep these values configurable, as they change for sandbox environment and Live environment.\n\n- Generate SSL Order Base URL Endpoint\n \n- Product Codes\n \n\n| **Region** | **URL** |\n| --- | --- |\n| US | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n| Global | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n\n**API Endpoint**\n\n| Header | Header |\n| --- | --- |\n| URL | http(s)://(url host port and path)/GenerateOrderSSL |\n| Protocol | HTTP/ HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n\"meta\": {\n \"ver\":\"\",\n \"ts\":\"\",\n \"txn\":\"\",\n \"accountNumber\":\"\",\n \"authKey\":\"\" \n },\n\"orderDetails\": {\n \"productCode\": \"\",\n \"accountingModel\": \"\",\n \"saveAndHold\": \"\",\n \"requestNumber\": \"\",\n \"emailNotifications\": \"\",\n \"groupNumber \": \"\",\n \"requestorInformation\": {\n \"requestorName\": \"\",\n \"requestorIsdCode\": \"\",\n \"requestorMobileNumber\": \"\",\n \"requestorEmail\": \"\",\n \"requestorDesignation\": \"\"\n },\n \"delegationInformation\": {\n \"contactName\": \"\",\n \"email\": \"\"\n },\n \"organizationDetails\": {\n \"preVetting\": \"\",\n \"organizationNumber\": \"\",\n \"prevettingToken\": \"\",\n \"representativeNumber\": \"\"\n },\n \"certificateInformation\": {\n \"organizationName\":\"\",\n \"organizationUnit\":\"\",\n \"streetAddress1\":\"\",\n \"streetAddress2\":\"\",\n \"locality\":\"\",\n \"state\":\"\",\n \"countryCode\":\"\",\n \"postalCode\":\"\",\n \"domainName\":\"\",\n \"additionalDomains\": [\n \"\",\n \"\",\n ],\n \"autoSecureWWW\": \"\"\n },\n \"agreementDetails\": {\n \"acceptAgreement\": \"\", \n \"signerName\": \"\", \n \"signerPlace\": \"\",\n \"signerIP\": \"\"\n },\n \"subscriptionDetails\": {\n \"validity\": \"\", \n \"autoRenew\": \"\", \n \"renewCriteria\": \"\"\n },\n \"csr\":\"\",\n \"numberOfDocuments\":\"\",\n \"documentsAttached\": [\n {\n \"id\":\"\",\n \"fileName\":\"\",\n \"description\":\"\",\n \"base64Value\":\"\"\n },\n {\n \"id\":\"\",\n \"fileName\":\"\",\n \"description\":\"\",\n \"base64Value\":\"\"\n }\n ],\n \"additionalInformation\": {\n \"tags\":[\n \"(Tag Name:Tag Value)\",\n \"(Tag Name:Tag Value)\"\n ],\n \"customFields\":[\n {\n \"fieldID\": \"\",\n \"fieldValue\": \"\"\n },\n {\n \"fieldID\": \"\",\n \"fieldValue\": \"\"\n }\n ],\n \"remarks\": \"\",\n \"recipientEmail\": \"\",\n \"technicalPointOfContact\": {\n \"pocName\":\"\",\n \"pocEmail\":\"\",\n \"pocIsdCode\":\"\",\n \"pocMobileNumber\":\"\",\n \"pocDesignation\":\"\"\n }\n }\n}\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order.
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | 1234567890 (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | 6288275133 (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | 952a95057654aa1c26a0f287b6368579f5f3ec99f213543df5e0f7eb7f798b3d (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| productCode | 841 (mandatory)
Unique Product Code of the respective product. Product Code values are provided by eMudhra. |\n| accountingModel | 1 (optional)
Accounting Model of the Account.
The allowed value for this field is: 1 - Cash Model. The default value is '1' (in case if it is not set). |\n| saveAndHold | 0 (mandatory)
Should be set to any of the numeric values.
1 - Only Request ID will be generated and request will be pending for payment.
0 - Both Request ID & Order ID will be generated. |\n| requestNumber | 8785645678 (mandatory)
Request Number of the existing request which was saved as a draft.
This field is 'mandatory' only for the requests (Request ID is available & Order ID is yet to generate). |\n| emailNotifications | 1 (mandatory)
Can contain one of the numeric values as under. Default is '1'.
0 - Pre-vetting Organization (Organization & Subscriber Agreement info.) re-use consent notification will be sent to the applicant on order initiation, if \"preVetting\" value is set to '1'.
1 - All Order Notifications will be sent to the applicant on order initiation. |\n| groupNumber | 3589463147 (optional)
Group Number of the group. This is auto generated by the system. Groups can be your cost centers, departments, etc. within your Business.
It acts as a feature / tool for managing account user access to organizations, domains and finances. If group number is not given in the request, then system will consider the default group number and amount will be deducted from the account balance. If group is having its own balance, then amount will be deducted from the group balance else the amount will be deducted from the account balance. |\n| requestorInformation | (mandatory)
Specified below. |\n| delegationInformation | (mandatory)
Specified below. |\n| organizationDetails | (optional)
Specified below. |\n| certificateInformation | (mandatory)
Specified below. |\n| agreementDetails | (conditional mandatory)
Signer details of the respective order to complete the Subscriber Agreement automatically. This is mandatory, if \"preVetting\" value is set to '0'. Specified Below. |\n| subscriptionDetails | (conditional mandatory)
Subscription Details of the order. |\n| csr | Optional |\n| numberOfDocuments | (optional)
Number of Documents attached. |\n| documentsAttached | (optional)
Uploading documents during order creation is recommended for sending us any additional / supporting documents so that they are automatically associated with your certificate order. This additional documentation would help emSign to speed up the certificate validation process. (E.g., incorporation letter, registration document, etc.). Specified below. |\n| additionalInformation | (optional)
Specified below. |\n\n**Requestor Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestorName | John Doe (mandatory)
Name of the requestor / Org. Representative. |\n| requestorIsdCode | +1 (mandatory)
ISD Code of the requestor's / Org. Representative's mobile number. |\n| requestorMobileNumber | 8978945116 (mandatory)
Mobile number of the requestor / Org. Representative. |\n| requestorEmail | johndoe@example.com (mandatory)
Email of the requestor / Org. Representative. |\n| requestorDesignation | PM (optional)
Designation of the requestor / Org. Representative. |\n\n**Delegation Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| contactName | John Doe (mandatory)
Contact Name of the delegated person. |\n| email | johndoe@example.com (mandatory)
Email ID of the delegated person. |\n\n**Organization Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| preVetting | 1 (mandatory)
This can be set to any of the numeric values as under. Default is '0'.
0 - No (New Organization)
1 - Yes (Pre-verified Domain of pre-vetted Organization). You are allowed to use Pre-verified EV Organizations in OV Orders as well. |\n| organizationNumber | 2943849 (conditional mandatory)
Organization Number (Org. ID) of the existing organization associated to the respective emSign account of the certificate requester.
This is mandatory, if \"preVetting\" value is set to '1'. |\n| prevettingToken | 0947CBA3C2DF42B0B303590541C8E5B1 (optional)
Please specify the unique pre-vetted token value associated to the respective organization. Pre-vetting Organization re-use consent email notification will not be sent on order initiation, if the organization re-use token is submitted with your certificate order.
To get the prevetting token, please contact your Account administrator.
See [Organization Details API](https://dev.emsign.com/organization-detail) |\n| representativeNumber | 21023 (optional)
Organization Representative Number of the existing Organization representative. It will consider default representative details of the respective organization (in case if it is not set). |\n\n**Certificate Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| organizationName | emudhra (mandatory)
Organization Name of Organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| organizationUnit | Bangalore Branch (optional)
Organization Unit of the respective Organization. This field is not required, if \"preVetting\" value is set to '1'. |\n| streetAddress1 | KIADB (mandatory)
Street Address 1 of an organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| streetAddress2 | Near Airport (optional)
Street Address 2 of Organization. This field is not required, if \"preVetting\" value is set to '1'. |\n| locality | 3rd Cross (mandatory)
Locality of the respective Organization. This field is not required, if \"preVetting\" value is set to '1'. |\n| state | Karnataka (mandatory)
State of an organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| countryCode | IN (mandatory)
Country Code of Organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| postalCode | 560064 (mandatory)
Postal code of the Organization. This field is not required, if \"preVetting\" value is set to '1'. |\n| domainName | emudhra.com (mandatory)
Domain Name of the website / server. If the pre-verified domain under the pre-vetted organization (if \"preVetting\" value is set to '1') is provided then fresh DCV is not required. |\n| additionalDomainNames | support.emudhra.com (mandatory)
Additional domain names of the website / server. If any new Sub domain of pre-verified base domain is provided under the pre-vetted organization (if \"preVetting\" value is set to '1') then fresh DCV is not required. This field is required only for SSL multi-domain / UCC products as specified below.
SSL / TLS - OV UCC
SSL / TLS - OV Wild card UCC |\n| autoSecureWWW | 1 (mandatory)
This is set to '1' by default.
1 - Enabled (Secure 'www' variant of website)
0 - Disabled (Doesn't secure 'www' variant of website)
If user has chosen to secure website with both www and without www variants, then File-based (HTTP / HTTPs URL) DCV method is not allowed to verify DCV.
This is not applicable for emSign - SSL / TLS - OV Wildcard products. |\n\n**Subscriber Agreement Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| acceptAgreement | 1 (mandatory)
This can be set to any of the numeric values as under.
1 - Accept Subscriber Agreement
0 - Reject Subscriber Agreement |\n| signerName | John Doe (conditional mandatory)
Name of the Signer.
It is mandatory, if 'acceptAgreement' is 1. |\n| signerPlace | GOA (conditional mandatory)
Place of the Signer.
It is mandatory, if 'acceptAgreement' is 1. |\n| signerIP | 10.24.108.199.182 (conditional mandatory)
IP Address of the Signer.
It is mandatory, if 'acceptAgreement' is 1. |\n\n**Subscription Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| validity | 1 (optional)
Validity of the Subscription (1 / 2 / 3 Years).
Default value is '1' Year.
emSign is providing subscription validity upto 3 years where maximum Lifetime of 390 days per certificate is available with free renewals. |\n| autoRenew | 1 (conditional mandatory)
Auto-renew certificates until coverage.
Default value is '1'.
1: Allow Renewal of Certificate
0: Decline Renewal of Certificate |\n| renewCriteria | 30 (conditional mandatory)
Time duration to for renew certificate before expiry.
Default value is '30' Days.
30: Automatically reissue before 30 days of certificate expiry.
60: Automatically reissue before 60 days of certificate expiry. |\n\n**Document Attached**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| id | (mandatory)
ID of the document. |\n| fileName | ID Proof (mandatory)
File Name of the document. |\n| description | Driving License (mandatory)
Description of the document. |\n| base64Value | (mandatory)
Base 64 encoded value of the document. |\n\n**Additional Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| tags | (optional)
Reporting tags are labels with different values, which can be associated with your certificate orders in CERTInext. These tags can then be used to filter reports, giving you a categorized insight into your business.
Tag Name and Tag Value must be colon separated values.
e.g.: Department:Technology
Multiple tag names and tag values can be associated with the order.
e.g.: Department:Technology,
Department:Legal,
Branch Location:India
Specified below. |\n| customFields | (conditional mandatory)
CERTInext allows you to use custom fields in your certificate order form. The Custom Field can be used to simplify record-keeping and makes order management efficient.
To enable Custom Fields feature for your CERTInext account, please contact your Account Manager.
Specified below. |\n| remarks | (optional)
Additional Information related to the order. |\n| recipientEmail | (optional)
Email recipients can be provided for receiving notifications related to Order Confirmation, Revocation and Renewal of certificates. |\n| technicalPointOfContact | (optional)
Technical Point of Contact will be notified for emails which are technical in nature such as Order Confirmation with order status tracking link, CSR & Certificate Download notification based on the email notifications configuration set by your account administrator.
Specified below. |\n\n**Tags**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| Tag Name | Dept (mandatory)
Reporting Tag Name. |\n| Tag Value | Admin (mandatory)
Reporting Tag Value. |\n\n**Custom Fields**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| fieldID | 456 (mandatory)
Specify the Custom Field ID. This is a unique field identifier of a custom field.
This is mandatory, if Custom Fields are mandated by your account administrator.
This information will be available within CERTInext online portal. |\n| fieldValue | Dept (mandatory)
Specify the Custom Field value(s). You have to provide the right input based on the custom field type set by your account administrator.
NOTE: The allowed date format for date picker based Custom Field is: YYYY-MM-DD
This is mandatory, if Custom Fields are mandated by your account administrator.
The detailed Custom Field information will be available within CERTInext online portal. |\n\n**Technical Point of Contact**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| pocName | John Doe (mandatory)
Name of the Technical Point of Contact. |\n| pocEmail | [johndoe@example.com](https://johndoe@example.com) (mandatory)
Email ID of the Technical Point of Contact. |\n| pocIsdCode | +1 (optional)
ISD Code of the Technical Point of Contact's Mobile Number. |\n| pocMobileNumber | 9676462551 (optional)
Mobile number of the Technical Point of Contact. |\n| pocDesignation | Senior Developer (optional)
Designation of the Technical Point of Contact. |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n },\n \"orderDetails\": { \n \"requestNumber\":\"\",\n \"orderNumber\": \"\",\n \"trackingUrl\": \"\"\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order.
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | 1234567890 (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | 1 (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestNumber | 6725625162 (conditional mandatory)
Unique Request ID of the respective request. This is 'Mandatory', if 'saveAndHold' is set to '1'. |\n| orderNumber | (conditional mandatory)
Unique Order ID of the respective order.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |\n| trackingUrl | (mandatory)
Order status tracking URL of the respective order.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |" + }, + "response": [ + { + "name": "SSL/TLS - OV UCC", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n\"meta\": {\r\n \"ver\":\"1.0\",\r\n \"ts\":\"{{ts}}\",\r\n \"txn\":\"{{txn}}\",\r\n \"accountNumber\":\"4397827229\",\r\n \"authKey\":\"{{hash}}\" \r\n },\r\n\"orderDetails\": {\r\n \"productCode\":\"848\",\r\n \"accountingModel\":\"2\",\r\n \"saveAndHold\":\"0\",\r\n \"emailNotifications\":\"0\",\r\n \"requestorInformation\": {\r\n \"requestorName\": \"Viña del Mar Viña ViñaViña\",\r\n \"requestorIsdCode\": \"1\",\r\n \"requestorMobileNumber\": \"7094940185\",\r\n \"requestorEmail\": \"John Green\",\r\n \"requestorDesignation\": \"Manager\"\r\n },\r\n \"subscriptionDetails\": { // Applicable only for SSL DV / OV\r\n \"validity\": \"3\", //1/2/3 (Default Value: 1)\r\n \"autoRenew\": \"1\", //1/0 (Default-1)\r\n \"renewCriteria\": \"60\" //30/60 (Default 30)\r\n },\r\n \"delegationInformation\": {\r\n \"contactName\": \"\",\r\n \"email\": \"\"\r\n },\r\n \"organizationDetails\": {\r\n \"preVetting\": \"0\",\r\n \"organizationNumber\":\"\"\r\n },\r\n \"certificateInformation\": {\r\n \"organizationName\":\"eMudhra Inc.\",\r\n \"organizationUnit\":\"Technology\",\r\n \"streetAddress1\":\"1712 South East Bay Blvd\",\r\n \"streetAddress2\":\" Suite 360\",\r\n \"locality\":\"Provo\",\r\n \"state\":\"Utah\",\r\n \"countryCode\":\"US\",\r\n \"postalCode\":\"84606 \",\r\n \"domainName\":\"emudhra.com\",\r\n \"additionalDomains\": [\r\n \"ex1.emudhra.com\",\r\n \"ex2.emudhra.com\",\r\n \"ex3.emudhra.com\"\r\n ]\r\n \r\n },\r\n \"technicalPointOfContact\":{\r\n \"pocFirstName\":\"Ben\",\r\n \"pocLastName\":\"Dover\",\r\n \"pocEmail\":\"ben.dover@example.com\",\r\n \"pocIsdCode\":\"+1\",\r\n \"pocMobileNumber\":\"7094940185\",\r\n \"pocDesignation\":\"Production Engineer\"\r\n },\r\n \"csr\":\"\",\r\n \"numberOfDocuments\":\"\",\r\n \"documentsAttached\":[\r\n {\r\n \"id\":\"\",\r\n \"fileName\":\"\",\r\n \"description\":\"\",\r\n \"base64Value\":\"\"\r\n }\r\n\t\t \r\n ],\r\n \"additionalInformation\": {\r\n \"remarks\": \"\"\r\n },\r\n \"agreementDetails\": {\r\n \"signerIP\": \"103.156.134.109\",\r\n \"signerPlace\": \"Provo\",\r\n \"signerName\": \"John Green\",\r\n \"acceptAgreement\": \"1\"\r\n }\r\n\r\n}\r\n}\r\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/GenerateOrderSSL", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "GenerateOrderSSL" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "339" + }, + { + "key": "Date", + "value": "Tue, 02 Apr 2024 10:52:20 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"orderDetails\": {\n \"requestNumber\": \"6768752771\",\n \"orderNumber\": \"3748391225\",\n \"trackingURL\": \"https://sandbox-emsignsubscriber.emudhra.net/PublicLink/publicLinkVerification.jsp?a=UnQ4Z1EyTVFmcW9WRjZPZExVbmRaZz09\"\n },\n \"meta\": {\n \"ver\": \"1.0\",\n \"errorMessage\": \"\",\n \"errorCode\": \"\",\n \"txn\": \"481935038810396500\",\n \"ts\": \"2024-04-02T16:22:12+05:30\",\n \"status\": \"1\"\n }\n}" + } + ] + }, + { + "name": "SSL/TLS - OV Wildcard", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "if (status === \"1\") {", + " pm.test(\"Request Successful\", () => {", + " pm.expect(status).to.equal(\"1\");", + " if (res.orderDetails && res.orderDetails.orderNumber) {", + " pm.collectionVariables.set(\"orderNumber\", res.orderDetails.orderNumber);", + " }", + " if (res.orderDetails && res.orderDetails.requestNumber) {", + " pm.collectionVariables.set(\"requestNumber\", res.orderDetails.requestNumber);", + " }", + " });", + "} else {", + " pm.test(\"Request FAILED: \" + (errMsg || \"Unknown error\"), () => {", + " pm.expect(status).to.equal(\"1\");", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"productCode\": \"{{SSL_OV_Wildcard}}\",\r\n \"accountingModel\": \"1\",\r\n \"saveAndHold\": \"0\",\r\n \"emailNotifications\": \"0\",\r\n \"requestorInformation\": {\r\n \"requestorName\": \"{{requestorName}}\",\r\n \"requestorIsdCode\": \"1\",\r\n \"requestorMobileNumber\": \"{{requestorMobileNumber}}\",\r\n \"requestorEmail\": \"{{requestorEmail}}\",\r\n \"requestorDesignation\": \"{{requestorDesignation}}\"\r\n },\r\n \"delegationInformation\": {\r\n \"contactName\": \"\",\r\n \"email\": \"\"\r\n },\r\n \"organizationDetails\": {\r\n \"preVetting\": \"\",\r\n \"organizationNumber\": \"\"\r\n },\r\n \"certificateInformation\": {\r\n \"organizationName\": \"eMudhra Inc.\",\r\n \"organizationUnit\": \"Technology\",\r\n \"streetAddress1\": \"1712 South East Bay Blvd\",\r\n \"streetAddress2\": \" Suite 360\",\r\n \"locality\": \"Provo\",\r\n \"state\": \"Utah\",\r\n \"countryCode\": \"US\",\r\n \"postalCode\": \"84606\",\r\n \"domainName\": \"{{wildcardDomainName}}\"\r\n },\r\n \"technicalPointOfContact\": {\r\n \"pocFirstName\": \"\",\r\n \"pocLastName\": \"\",\r\n \"pocEmail\": \"\",\r\n \"pocIsdCode\": \"\",\r\n \"pocMobileNumber\": \"\",\r\n \"pocDesignation\": \"\"\r\n },\r\n \"additionalInformation\": {\r\n \"remarks\": \"API Testing\"\r\n },\r\n \"autoSecureWWW\": \"1\",\r\n \"agreementDetails\": {\r\n \"signerIP\": \"{{signerIP}}\",\r\n \"signerPlace\": \"{{signerPlace}}\",\r\n \"signerName\": \"{{requestorName}}\",\r\n \"acceptAgreement\": \"1\"\r\n },\r\n \"csr\": \"\",\r\n \"numberOfDocuments\": \"\",\r\n \"documentsAttached\": [\r\n {\r\n \"id\": \"1\",\r\n \"fileName\": \"DTC.pdf\",\r\n \"description\": \"DTC Licence\",\r\n \"base64Value\": \"JVBERi0xLjMKJcTl8uXrp%2FOg0MTGCjMgMCBvYmoKPDwgL0ZpbHRlciAvRmxhdGVEZWNvZGUgL0xlbmd0aCA3MjggPj4Kc3RyZWFtCngBpVZLcxMxDL77V4gCqQOt4%2Fd6edMHpYUDndkZDiwHJpNSmKTQFv4%2FsrNSNgnTadrpwapWkvX49DmXcAqXMNq%2FNjC%2BBl3%2Brseo0sr6%2Bf9ZSMrVOkKonapi7WE8g70GwtweDxsrpbV24KsomhmMmsaCgeYMvoB8sDXEcB7kwyHsFuERCfTlcVEYkINtklq5PRRonpU3mQ3J4QkF5RBP873oP3cXcqfLY7BbTANIVQQLkjXk0y5u5XhsvTMUpY5RcXcgdY6MYdiUMqYPpruaPWznsdWFotw4gus8yNMP4Ss0J3DYlJHdOB%2BxOh9jvVFVMnk%2BsDSfnHLzcx5VK23qqKuEk1cuJeuCw0KrEFO0Ce6FCxPWcBFoYINBJJGBUHXlJ5oQCzZ3DHtNHaSzphitfEbicxJorGz0Isd3QvJAXhYFSLZoO9yA5KtfUTIMlwWUXtMk39CdNEG6m07CBtfKQu%2BiDH0skjeFbQac8Sq2WjkvigDPiQv5lnLixFeT4az5Ikpzj4qeK4QctIt12S8fcZPYrdc%2F1vF4D%2Fog1iLzxx1QZaroVUIkexMIzaKwTR%2FNoyMkte9Iav9BNSp7qK6s8hXSW6yNqm3txQy8QzFTHulguqyzGmlwCn3XTnUOZ0ykApOwOYk7E6oLfrlEJNTD0nSkHR4aM%2BQajTCfMHbf4QzELYlkjeh9SMomH2AtL3m0IJJNq%2FWVU6G2Jkddo4n3eTFxFY6786Q7P6zoP67o%2BfloF%2BDkbjBub70QgumAg3D7eUtpJZl3aIlwLszeYtP2GG1rZZzvN728rqKP902j8ptta4cQE4s3%2B15Rralg1%2BiqXh5kP2b%2ByYETLz8pYsz7GSLMIOAbtaybsk4451TKdrhznWtPdQ6f4SJ%2FMHlnnUN6yPvpY1I%2BgbNBxQRXk2wlRp8mV%2BPJ7z9%2Fv03h6gemglZlYZ2NiLO16PiWjo5nBg5%2B4cN7%2Bg8KcLz%2FCmVuZHN0cmVhbQplbmRvYmoKMSAwIG9iago8PCAvVHlwZSAvUGFnZSAvUGFyZW50IDIgMCBSIC9SZXNvdXJjZXMgNCAwIFIgL0NvbnRlbnRzIDMgMCBSIC9NZWRpYUJveCBbMCAwIDYxMiA3OTJdCj4%2BCmVuZG9iago0IDAgb2JqCjw8IC9Qcm9jU2V0IFsgL1BERiAvVGV4dCAvSW1hZ2VCIC9JbWFnZUMgL0ltYWdlSSBdIC9Db2xvclNwYWNlIDw8IC9DczEgNSAwIFIKPj4gL0V4dEdTdGF0ZSA8PCAvR3MxIDkgMCBSIC9HczIgMTAgMCBSID4%2BIC9Gb250IDw8IC9UVDIgNyAwIFIgPj4gL1hPYmplY3QKPDwgL0ltMSA4IDAgUiA%2BPiA%2BPgplbmRvYmoKOCAwIG9iago8PCAvVHlwZSAvWE9iamVjdCAvU3VidHlwZSAvSW1hZ2UgL1dpZHRoIDE0MzIgL0hlaWdodCA5OTggL0ludGVycG9sYXRlIHRydWUKL0NvbG9yU3BhY2UgNSAwIFIgL1NNYXNrIDExIDAgUiAvQml0c1BlckNvbXBvbmVudCA4IC9MZW5ndGggMTc2MjcwIC9GaWx0ZXIKL0ZsYXRlRGVjb2RlID4%2BCnN0cmVhbQp4AeydB3gV1bqGufcc7ymeI3ZUFDuKgFIVRFApCoiKDaWEGnoX6b333nvvTXrvECCQACGB0EISQgIhkN4TuO%2FOwjn77EBIIAIJX555NrNnVvnXu9Zs5v%2FmX2vO%2B108r00EREAEREAEREAEREAEREAEREAEHjIC0TExN7L%2B3%2FXrN4JDQn18A%2BR6i4AIiIAIiIAIiIAIiIAIiIAIiIAIZEsCPn4Xr4WGX0cCyBZ%2FiYlJAYGXJWVky7GqRomACIiACIiACIiACIiACIiACDzKBFAwfPwCQq6FZRsRwygx8fEJkjIe5YGttouACIiACIiACIiACIiACIiACGQzAoQrsAVcvBwZFZ0tojAcG5GUlIQ%2B4%2BcfmNJSrfAgAiIgAiIgAiIgAiIgAiIgAiIgAiKQJQn4%2Bgf6B1y6FHw1IjI6KSnZ0f%2FPXt%2Fj4uNDQyMuBgX7XQjKZkrUfWsO83S0iYAIiIAIiIAIiIAIiIAIiIAIiMADI0CkQlJyNptIkrb6kpycTJsfGPAsrgOkzVZnRUAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAERCC7EuCNxNpEQAREQAREQAREQAREQAREQARE4KEmkF19crUrHQRYPSYxKYlFYqNj46KiYyKiorWJgAiIgAiIgAiIgAiIgAiIgAiIwMNMgNfRRsXExMbFJSQkJiVn87e6pMOzf1SSsABuXHxCZHRMeGRUWGQUn9pEQAREQAREQAREQAREQAREQAREIAsRCIuIRG%2BJjolFz3hUnPlHsp3EYBCAQV9nocEpU0VABERABERABERABERABERABEQgDQJRMbFMN3gkvfxs3ujk5OtIVWl0vU6JgAiIgAiIgAiIgAiIgAiIgAiIQBYlwLyDbO7VP2LNS0pKYhpRFh2NMlsEREAEREAEREAEREAEREAEREAE7kggNjaOaQiPmLufPZuLiKG5JHcc8EogAiIgAiIgAiIgAiIgAiIgAiKQ1QlIysgGugbTScySnll9NMp%2BERABERABERABERABERABERABEbgjAZaFzAa%2B%2FCPbBCJqtCbGHQe5EoiACIiACIiACIiACIiACIiACKRBgIfj9z%2FIPyIqKjLqLl%2BvmZCY%2BMjqAFm94bxRN42hqFMiIAIiIAIiIAIiIAIiIAIiIAIikDYB3gYSEBgUHHKVnbRTZuLZqOioE36hm9yuREbfjZSB8JK5C2Vcvnx5%2Bsw54ydOOXX6zJ8kFGDwITf3fS4HXPYfTM9mmeF%2FIeC454n4%2BxKFEhcX53XCe%2BGiJSNGjxsweNjUabN27dl39eo1y5jk5OT9B12trxndAUIWmlGSkJQcl6K6hEVExcTFJz5i02HQNmPjE2i4%2FYVP90XHxrGl7kcOkvh2iijpOXs%2Ff2RuZ4l9cx6GfcgwzBx48hWe4MoqrcBO%2B%2B0%2Bg4UVDA2r2xHjuBmi99m29Fd3nwEyurgkzcCjagDCJ%2F3WKqUIiIAIiIAIiIAIPEAC3MZ4eHp17tJt%2BIhRgUGX75uXER8XM3bdxbK9T4WGR0VEZljKCIuMikvItNeX4JsPHzmmWo3av9Sq265D5%2Bjo6Iy65%2BlJz6tjGzVv9WN1p2o161Srcevt55p1rM0q0%2Buk97oNmw4ddkNksA7%2BGTuenid69RtY3alejdr1a9V1dqrXsGadBtjTrGXbFb%2BvZmVOKp2%2FcHH3Xv3uuvb4hAT67i4GvBmZoeER3G9HRcfcRQkZykIt18LCx46fOG%2FBIiqNjY%2FftmNX%2FwGDzvqcv2%2FXSIYMzvTEEAi5Frry99U7d%2B0Ji4ikfPwdDvr6X0COO3jo8Hk%2Ff77ae9%2Fbd%2ByaO29B4KXLHCc9PpHlFpEMdDNmzT7gesg%2BS6abbQrEALrv8pUQY%2FmfVEumFAuNk6dOz5k3n09DhgGG8d6nzxw4eMj96LGgy8EcMUjvsUbKSUNousfCGS2Xgq9cuXrNbFw1NOc%2B9DVmA8flwMElS5djAPViQOp%2BJw3HFy5esnrtOs5mCs97JOaQ3VhuAWQMYCddxuaQMo2vAE9nF5PysPuR6TNnnTpzlip4lrFo8dL9B1wfQjJptFenREAEREAEREAEHk0CeBlHjnm0bvOrU%2B26NWvVxk27GHQpQ3dNd8eN6STB1yIr9Dn1YiOPrUdC4uLu5uWb3IZlVkhGQkJC15598Nlr12%2FUqFmroKCgu%2FbT08iIjtHq1%2FaIA3Wdm9Rr2PSWW50Gja3NKgrf38396PqNmw%2B7HfnzojL27nNp2LRF9dr1h48au3ff%2FoCAi4RhHPM4PnfBoiYt2qBmEK%2BycNFSJI4eve9ex7i7lTG4tT54yK1Hn%2F70zsixE876%2BP7Zo5TRhRf8UYlS1WvUuhoalnT9%2BpChI3K%2F%2FKrLAVfchLsb9lkrFz8OCGgNGzVZsGixUST8Ay5OmjyVn4smTZuztWrddvzESRcuBnKWDoJSt%2B49a9eph8oBIrywo8cIIzpp3CKOrF6z7pfqNUeOGgPbPxsFw2PK1Olt2rbbtXvvnz1U7rEtRLwsWrKUX%2BBD7u6QxFrXQ279Bw5q0bJ14ybNmjZr8Vv7jqhJ4RE2b%2F1e6gL7mXM%2B9A4D%2Bx6LSm0GZo8aM7bdbx1%2BbdfebF279Zg5ew4%2BMo1KnT4Tj9AWBtuAgYPbd%2BzEIFyybHmXLt1Pnz3n0O98PXvufN16DeB5ITDwPgzCDLURSkc9jgOwfYdOBiB29urdd9mKlT6%2BfulkCApkRjRGS0tMwwaAjJswkUty%2Bcrf4xOTkNG4qOmyh41MGk3QKREQAREQAREQgUeTALcr3GS2bNWGjbvlbj16Nm%2FRirtBHmll%2Bl2uA%2BHY2Og1B4NfbOzxZL1jbWf6MrXkLkIyKDPxHlbJCA0N27Z958ZNWzZt3rp23YbW7TqiMKBjNGjcfPHS5Zu2bOUU24WAAEtPuMcdo2MQ9TFj1pxr10JvubXr0IVACCNl2FeH0sKTWaSMQ4fd%2F4xlTj29Tjg3acG2Y%2BduolPsq2YfTWP46HHEaSD1sPXs098hQTq%2FUnJ4xn0x7rdd3Y%2BgpUydMXvL9p1EjHTt0YeH1Pdyv03e2w1yTpkNd6%2FMp2VxzHGOmFEyYuToN956Z%2F%2FBQ7jkJoHDqDZfKRaDb1e4Qxb7cm6ZxSRwOOXwlTJTH0mjIodT5L2lwTSTwVC%2FQUNELdwc%2F4sXe%2FbqU6%2B%2B88BBQ9at38hD7YGDh3C2d59%2Bp8%2FYfEbK4fHuhk2bzQw1PKm2v%2F7G7wnlGAsvBASuWbfeUjYczEj9lQJpfhpNMwlSZ8QYHHZcQpxWei0y5tayiT18h0LSrjrts2nYnPoURbGNGj22Q8fOyEQx8fH79h9o1rwlCsbkqdM2bd2KxNGpc1c4T5s%2B8xoP7NN9%2BRgj7dvFrIGZs%2BbUa%2BCMvOTgF5vEaRSeujSHtsAcGRyzp04n6GYu1vbrP7CBcyOMR1IgsWWJQ0brODupa7E%2Fyz557YsyZ8lFLAGyD6oagw1PHIBEs2CSfXaScSHza7%2FXZX9YpC2%2ByNpuWaxVeBoGkyYNm%2B94igSWDfQIYjXXV4%2BevQEIxTFjx3fs1IUjjA1%2B%2FB26jIypzUYTW7FyVS2nOkgZ5rqzyjfG2NfIPtoFF7LfhQAKhxjKJHFB9oRNLquQtHfSAJV2OWmfTbtSnRUBERABERABEXgECXCf43Hcc%2BjwEW7uR7hfmj1nLjMXRowc5R8Q4HAHmLlwWN4Tz6n%2B%2BHNPNziWq5FHvrZeJ%2FzCmLic0VqYnhATe5fzLAjkmDRl%2Bk%2FVa%2BObp2z1UTCsQAhCDsxxNIduvfoSqJtOPz3tZEbH%2BKm604JFS26Xsn2nbrfUMUhvkzLcj67bsJnb3fiEzHxjC6%2By7dlnQA2n%2BswguJ1hy1b8bnSee9ExWJ01o71MegYqi3UMHDqC%2B2R8Mf%2BAwBZtftu2czc37RktjRtmSiNa28fXH72Ou3f7oU75fCUkibPs4%2FWk1jFcD7vj1OOS%2B%2FoHUJp9dr5SIGHh5877EmPvULiDqWSkCjwINlMvTqp9MDzZycLcDTaOm68c4dEzKanLKpB9jrBZR6wdY1JKRRdNqJVVDmnMWeQanlNDAzL2xZJr9px5RF%2FwOJhTs%2BbMRRYgCh0DKISNGuctWIjThMNlSiO7teGV81h50OChlGPaxQ5n%2BTTmGZsdPi3L4QN%2F4Fy6zBI6%2F6VmmJaSAMGEaBCy2PcCX7Ft4%2BYtPFzGiSYkw4TNWyWzY5QNnF%2BYYBLZ%2BTQJzFe6j6oxgFOWwbaMKZagoXEWDvb1moy0lIaT3T6jOYXZnHIokwIxg0ACpvVhNiUTxoAasH3nLgOZT56wIwehpxFbwlfMoCj7oWKOGPuNkdRu4JCMfdM6WyfOnou2cOSoh9Uo%2B%2FYabdA6ZVVECTQW1KY0vrLDV9NMUzgHMZLRwnBi31ZIRBQ%2BNQrMxElTMNseggMfYzMJ0L5AZL7aBkbEf4a0qZSzpsvsjeQUk5UaNW6K%2BBwdF8dARdNIrWNQLFWQ0RjMVzby8pUy0x4JNMohI%2BaRkezBV65aeU2ZfJKYU1xTdK6pxTplbKB3%2FPwD%2BK0wBnAWRPyw16lbn2uKXxiSsVEvcz2IieresxdFccSUQy4znBxoUMiy5Su5TpmRZCEyxsAcembQWuWYRpkEDjqGOchwJRd1WXZaDbF2KI2zZkgYDZO81llO8RUUqSlZDDlr1UJGisJOPq1yqILEHLSK1Y4IiIAIiIAIiMCjSYDbg9Bw240oLiG3N9xNte%2FQkSdo3HIEXgrmILcckdG2%2B6hM4WNbry4WMSCaHTZWxjhyJjRva8%2FnGx0jJOOZBseGrAhITuSc7SzJzIbcccfa8Ueu37h%2BO9c7jeMEcvQbOAS9wtIubrmD547LTOBEGkWl%2F5SlY7DExO1ypaFjkAWzjx7zWL9xCw%2B%2BM3GCyXFPr1%2Bc6qEV3HKeDstiEKACCqNj3Mu8ktiMz8hgrNLBxGDMnDMfAvGJiQSLdOjcbcPmrRy%2F4wixT8B4ZqiPHjOudJnP3%2F%2BgSJGiH9Z3bsTMB4Y9yfhk2PPos1jxEgXfL1zl629%2FX7Xm87IV7OMx8r7z3uKly3AzSVCocLEaNWsfO%2B6Fe0h27OTGG2%2F040%2FKcLZEyU9%2B%2Fa0DroFxPO3NYJ8sZKxdp36hwkVJXMupLpE%2F1MWcC4Qak4AVUb7%2B5jvsZKtY6SsiGdBtyNitR6%2FPPi9vPVLnID5vyY9LozA41EV7MYkw%2B0qVv%2F6gUNHCRYr%2F8NPPhFiYZCk%2FAhFDh40o8fEnBQoWYgZN334DroRc4zgGkBcXhhitXr36QgYthXD3Tp264kwZXKRhBwdkxKjRRGik%2FGLEEDzAQ3ncYWbZE4zBY3E8SuIiCC2g2ONeJ5h4snnrNlrBg2DW5OnQqTMKKhv7nOrTtz%2Flk5La12%2FYhEff5td2RNdPmjKV8qmODaSoEzQKjcU2i%2BG39pjN5WDMxio2Wk0wAMbv3rsPH5AnzvZkKISVbBFYkFkogUpZ1oMaTatxl1hmgSqwn0988PMpopZpL5LCmHHjycVZDAamkUQMLuLzu3TtjnLCKGICDq5ZRMqvKM7d0mUraKPtVKfOxCuYZpoyCVBxbtiYcZV0%2FcbO3Xtw%2FPHE7Q2mi5lxQHUUwnHSU9SWbdtNGpqDF9%2BlW3dW3wUCG%2BuQjB034bcOHaEHww0bNwMEgDSZ0DvkHZBiIWobJXAKz7dTF1t7OU5vkp0yaRFmo1Ax9WDN2vXUCC6UihPep3jW37N3H9oCH0zCuyQxWYyOwZAwMLGEfXIRxkMtXHq0kXKoiNqxEH3M9Bp5ceqpCAMolr5zPezWp19%2FhrSVgDb27t2PvPQ4g9Z%2B2gh88P3paGyjRbfTMWz2XL4yeMiwKVOmUyPwScyYHDJsOB3KxiQOqHKcjSZg8%2FIVv5uRgMHTZswECIWwER0BWC5JetnkhS39YoolgefJk8NHjjLjhOajD5tiOUUhrCHDMDAAYWLceYyxdAzzI2DMoEyiaJAmVq1eS0s5yBEWA%2BndN4VGu%2FaDhw7jaiI7BvMDYrqYqrms3I4c5VrjP%2Fqp02ZQI%2FRoDvEql4NtE4soZ%2Bv2HV2792CBLEq21zGwkyFKvZadXBQoNqZnTVvMJ0e4heCnEiCUz68E04usCBzK4ZcK5pxp2%2B43Yrf27HMxVdOhZNm2Y%2BeMmbOxlnHSq09fDmIwQ4WB171HL%2B9TtqAamgZwRg6XPG1MbYO9PdoXAREQAREQARHIxgS4teBh4qAhQ5lFwjMsbtVSdIxOM3jSGho2eOjwxk2acy80d%2F4C7q%2FunUNYRJTrqWtzdlyavvVSvyX%2Bneb4EYlRse%2Bp5xt6IGKwsVOwnVfdsedaTTvfa6H%2F%2BPWBM7dd2uB2Jegqdyx3kDK4pUk9CeJ2EoH9cQQBXsZB0MUt5QvrIJ57q187PCgdAyMd%2FpAUYmJjWSVjw6YtzHPPrAkmc%2BcvQp3Yf%2BCgPSKzj7Kxeu16VqVo3LwVMzvYmHsCutQp03ME4zM0ohir5%2F0uzJq3gIVGUXgmTJ42duLk8ZOmNm7emvVMfl%2B7PiQ0NP23tdwhDxg05Kmnn%2F%2F2ux%2BGDh%2FZuWv31994%2B8uKX%2BFZUAh3yL%2B26%2FDc8y9%2B%2Fe13%2FQYMQqPAu3%2Fz7Xfq1W%2BIR8%2B8kpGjx76S53UOfvFl5b79Bzo3bPJS7jyVv%2FrG57wfziymDhk6%2FIUXX%2BZIvwED69ZvcPOsrx%2B34vat5quPry%2FaAgkaNGzMSgjoFagiL770ChoFj2Kxc%2FPW7WgmKBj4xWxILu%2B8m3%2Ft%2Bg0s08FChRiJMoDrgdkkxv9i4Y5de%2FYaN8eqi1O4qM88m%2BvjUmWQGvr0G0CBH370cYq7Z5saM2TYCAz%2B4cdqeKDVazhRLM4C2SmWs%2Fi5CBGz587Dq2Kw8aR4wsTJHLfKNynxYnBXocdXrMI7w39nWgdOByIGfhOaA%2F4%2BrcaratSo6cpVq3lojm%2FFxH9OseH%2Bk5E4ASQFHkDjtqB12J5B9%2Bi1YOFidCdO4RyhsdAiHE%2B8fn67OIsPOGXadH7E8IP4TTO2URGuE946vi2%2FYLQIPsbXxkLO4vThltK0yVOmYSS%2BMwUyU4bCaTg1oiQQ28%2BqIKhSnMIqswAy%2BgygGjZugsc9f%2BEi%2FERq2blnD2Wy0S7sxMVmZg0lUwjDAANozrz5C5kdMG78RE7xWadOfSaSGI%2BMjHRr48bN8NToPjrXyakuvh4TTOw5s88o5Yea9GCsV88ZrxaDOU75qB9UbUI4SIaRpvk42mgCrLyxZet2CFA7mg9dADTgE%2FwAMfxT8qJL8IoiJBpg0l56kGLpC%2FYpCueUYYCkQ6fg2OJy0il0HJ1LXnxhEmOYg44BTEoAL448HHCKsYS5SKa7KQpWXFnYgM3oABDD4SX8gLGKAkMC%2BHOWkhl%2BTHmgXhQAlj1hFiQdRI9TBRuTRPDlWRPDdFMaOgZiDvZQF2Wy4W7TNBo4YdJkRgJNw4ZVa9bSHOolmITWQQAyw4aP5BRVmw5FMsI8TEJMgAPzWRo0aIRJICUvhnHN8n8oIg8OPgIUKgTKA%2F1Fp6OHUBQ2UCyiH%2Fvs0ArOptYxbP0bF4fOQHUoPySjfIaKkQfpFEMDyQ6FDZu3btsBPQbtxMlTuPZRiuh3%2BoXhN3rsOFZZQehgH%2BnAdBkSH4nBa69jQAY7bY1yboQAsm7DxmEjRhJ2RTAPpxyGJZVCgJ5ltDOoaB0yL6OO3wSq4DfEJj21a89tBhvXHdUhcvIrh7ZDSk6huACfS4aLmnge5KDYhATmslE7WhCW8AtDgS1btkF%2BTG2Agz36KgIiIAIiIAIikI0JcCfAvSJ3y9y8LVi4iEeZ3EZyOzd9xkzuGTZu2rxw0WLuhXBbeO5277cNxIYu2XP5jZae%2F65zlNALtmedjz3X0BaJYW3MLuEgG2efqn%2FsiTpHWTQj8EokbscdO8K8RCM9TrR9GvSBgUOGm9Ue7GeUWAqG2TE6Rnh4hH3eu97PaDzG1u07U2%2B8NYOD%2BLlIGcc8PO%2FaGPuMQ0eMAkLgbVY3jbH9xdr%2F3XUoSEaHUzwz%2BufM%2F%2FGXWnQE0214mwzS0y%2B16mFtjdq2lTqOeBznbvmOg4QEuAB84rmjFeDOoEvw%2BHvQ4GFIE%2Fv2H%2BT9qkycf%2B21t2rWqsMduPEQETpw7es3aGR0jFFjxj351LNVv%2FvR%2F8JFEnADP3jIcFQCRA8iQ3bv2ffqa2%2F%2B8OPPPMfEKaCl%2FQcMfvOtd5YuX%2BkgL%2BCYjJsw6dnnXkBYIBlng4KvEJKR64XcPXr1wRLu2yt8Uem9%2FO8zj4CzbKyZwNdy5b%2FAU2MrWuwjIit41h%2BZ8pYBJr98W%2FV7PCzTRosGFs6eNx8%2FAteAxvK3aMkyhAuc5eTrN3bvdcnz6ht16zvj6XCWXHjoNGHPvv3USAN5No3TwWICOB141rgqOO%2FWk2KrFiq16mVtBBwTXCqygxF5AW2HLFhCS3HA8Rl%2FX72GfbLwyWa6D%2B8e1wx%2FjYzEfuAD8jiebiIvafBe8QT37HXBEtqCg8OTXDPbhfQ4QTiDvMiYlFjFkTlz55PG%2Fcgx0qMh4C6xmjFnjalGncDr5wgbs07wbQcOHMKvH8mwEL8Ph9SUhjOIa09HgGj%2BgkVURHACVWAYPhoZeYiMt05injXj2LJPD9JeNAS8RSBwBA8XZ5YdRh214MhjIV%2Bxh%2BajaeC%2BBV0KxnjcUgxAh0k9qo3xVIQb6%2BzcmEVTMYMspETCwsgdu3ZjFZEb7M%2Bbt5B9mo8igRmMbbJTO%2FXSp8QgcZaMJKY6fu2RaCiZNJhNG5GA%2BIqF%2BI%2BUhlNJXRxByqhbtwFGkpIjZMe%2F5qA5a3QMxBa%2BUjgbs3uozuaAR0bTEWhEjG2qJgF9ipSEpMZXE4VCsQzFqFjbHBZCJuhBnG4KwQzYoo%2BR2FwOjEzOspIDeUFNwAyjjlqwkK9p6xi40oxJk5LyKYfZKFRKRcyuQvWiLprgddIb85h0yT6VwtnoD7j8UCVsgIwMUZtwEWcTPZD40AcYwPFJSUgWeP1cLESOMRgYYAQUcR1RDgIIQPjKMMB4aCDmMD7R1uISE2%2BpY1A4wgiiGYIn6fktYjhxUduyJCRSJoIAZaJHkZLqECu4WNC6DRwuIq5KRgtdhuVkRyxCZqHH6QW6m9q5B6AcKx4DFFxcjElAmTLpApqDLEN2yoGG2djnCPIFA4ahThPYiPfAHsYG1XHhYAxqDij4ClVAMVzNKarmJ%2BLEKVsUDRvRUww2xDSKpYPoR5CikpEd2kSsYbBVtXZEQAREQAREQAQeQQLcJAwdNpxnH1fDbLO%2Fo7hRDL5i5pVwU8x9C%2Fc%2F3EjXqlWHJ4yZcufA60i2Hg0p2uEEC2JY2sUtd55v5MEbTJhmYtbQuGPvcDeYmGh7GWhG%2FwgzOHHSG%2Fdw8tQZDtqF%2FVf85cbNWvHmTZ5%2B%2Bvicz2gtDukzqmPgX6feXA647ty9d9OWbRs3b%2FU%2Bddqhirv72nfAYFodGhZ2d9nTn4vxdsc%2BtU%2FA7e6U6bOQL%2Bw7xdrnuKvbkZi4dOkYplhupxOTk%2FGUj3udxJsbPmI0OgbjnCYQofF8rpdYSZVKScyNPbfi%2BQu8X6duA%2B7V8QHRMf71xFOLlyxjnwRcGqdOny1UqGjdes4JSUlEp5N95ao1XD6c5TrCA%2BI6wqm3v%2FM3p5wbNclf4APMMECoi9t14jF69OqN3uB62J1gjFZtfuXe3uSlxuYtWpOAU5jXvWfvvO%2Fmp3DqxafjOJ4RhVC4w0ZjKRBpBfff0%2BvE7Lnz0THGjJ1Ae3Fncr34MkvOnvA%2BTVF4MQibFIWra%2FwjJprhyuGF8RUfGWGTB6%2BmdQ61WF8tHQPLcaBwUqiFfRLQUnsdw2QxDgseLn4KMRikZAMF3jEHffz8eGKOS4WfyyN4vGCY0xA8oxkzZptuIj1uI%2B7P%2Bo2b8EbBjoOGN9q374Cw8Ei44TpRGr48ebGB4Jl27TsQlkCfkhgzsIE%2BOnPWh7NoFMg1iDYGJglIhsOIu4pMhJ%2BOUoGMgE2kp%2FvwnZFfSEB6EzdC1AE10nY8Smo3JTA2sJAAD1JiHsc5Gx5lqxoPEWtxqCmfbfyESWg4OOxmYFhgrR2Op6FjYAbEcPnbt%2B%2B0ecs2bMTzZcyb5tB3aD74mOifcOMggxlXEf%2FR%2FM5jLfbgMiMTISYwjwCnHq8ZF5VTpOdnkOxM%2BiA7RxhXACFWgVMYBh%2B6iY6jWD5pFI4qUs9Jb9v7ZGk1uXDJidUBHVOQ6HSKAgUTbUjJCDSt5pNRx9ijBGzmMoQeag%2FCEdiBzxHaSL0AxHLcf4rCf8cMjqRHxyAXfYR6T6yIbSRE2UYCRmIeVWMAQ92mWa1bz%2FAD6Xl%2FfyOeMMxw1dExjHRgLgfqJaiGI8gUDEtjMN2KF092upVrkLZTKat3MhoJTzLFMvDQH6jIqIW30zHgzLwhIkPoTaZaAJm2mxIwjdECZDQHKsIewki4QHjlDTWa4Y0Eh44EcxLTOjRDLkxgYvYtdQyaDxzGADcJxJPQfEYC%2F8uaxlpD0dqhB2kaSwaZnoW%2FeQjCcdM1QEYKwwCyGBTYBhy6lRgP8ho76TtSslYMzaQvuFJoMpcDG9NM%2BL%2BeBFal2hEBERABERABEXgECXAzMGjwEJuOEcqdflQE9652OgZHuMfAr%2BE1rIRtc6uTKYh43uV25tqXfU89Xf%2B2UgbxGG%2B28Jy1LYh1Mu44o8RYddc6huV6s7zGqLET0lgoAymDGAAW%2FGzdrkNg4D29jzWjOoZlpP0OD%2FC5WyYYw%2FaQPeluNBz70sw%2B8zWIdvA6cTL1qcw9ktGXruIU8I6S2%2BkY9EuGdAzuz3l5K7pEiZKl8r1X8LXX3yIg4Y038yI%2BJF%2B%2F3rxl67fz5uPG29xXcyPNzXypTz61Xx%2Fj1VffsJxcriMCmZhjQuwE45BJ%2F0gE6Awmu%2B3KSnnaTjmpr6AvK33F%2FA5cRXNnbq64l17Kg44B8K3bdqCuMI3FuvrwIEaOHoPIwLNjTEVVIHCC59ck5kk0bbml50t78Sx4dPtJ6c8QZEx7iTAZO34iGVu1%2FvXFF19BMKHVbOwQPUIVTJmhCYgwOPv4EexgBo4bDj6PpI1zZN8ijlgHM6pjUBFxETiA5i0J0OCICYHAWbPfcHnwamkRwx6%2FD1fZwOEIj8g5S%2FQ7OkZKFx%2BmQJxxfEbswWvDI%2BYBtImTITvFsqyBfb%2BwT0ZaQYQ%2FDjW%2FflaLMIlTbAwGnCwqsreK%2FYYNmxC3RgfhYjNZgAQ4qtRO1fiMpmTmwkCS42ydu3bD0cOL5BS14PNSo1n6gLrQGXBCbS%2Bb%2BO95JYaMsSQNHYMEpGT84KgCgVgL6sUVxTHkOEba6xh8RaqiOpiQ0fQpVjENh2fx%2FO%2FA8EbHICTDZKdreKxPsSa4hZRMDkJPIL6FvqCEAYMGW3yoGoWHLkPVoZkkZnFRpicwGukOzrI1a9bS6Bh45ZCEMMnMhYOcQnYW67BEA3LZk6ci7ERfwio8d0rzOO5lmp8eHQOD6S%2BayUiwH8wYQCGQYfCgjThUSvdROM3hcmCfKBczTgjJMDOhEMY5wq8Bk1NoqSmBUcESJYgMVEqLyJi6WEJ0qPSWOgb2oCSgF8GZTqRqh%2BxgofloMlyqFJJaxyD%2BgU6kBFvKJs0Zb4zPNHQMILBREVIG3U16tCwuDa4g00EOxOhiOpofIgwjMcYYhYr28hPH5UzVlMMtB2ILchB8oGR0DNQ2GmiNPWZsoVog0dBSsjM8KI2M%2FI5Zyexr174IiIAIiIAIiMAjRYA7hNQ6Bqvp4RahYYCCewyjY%2FBMinuJzIITFxt9JiC85sizuVKW93SIx3i%2B4bEPO59YdyiYZOkUMYxh9%2B7Ls%2F4DMxSsp%2Fy33EHNcKrXyGW%2F67349feuYzDB48BBV9b59DxxIjk5c0QMWsRiaz%2FXrLN02cq0WxccfGX4yDE8WUs7WRpnYzMSO0H%2FZqKOwR04btcXFSu%2F9XY%2BZrXjOPCUHxfDisfg%2BenLr7zGg1F8AXMV8OS3%2BIclWY0Tn46nydyrIwJs2rLFRAJQYMDFwNKffv79Dz9xyTBdhXiMHbv24FiZkcmNNymt2%2B%2FIPxbOJWOzFq3ezVcAP5fEXI8kW7t%2BIxqC0THwO9AcmHViFUXtTDlh4gk2c0levRb2xZeVMIzHx6xZitJCIWymXsvX4EjtuvWxCn%2BNaxnbJk%2BdbtMxxtniMfBlKJBADuIfiP9nw6dgIQW8WpqD44BXwm8C1fGVmel85YE7KKyKqI59vBj8L%2BPqZkjHoFgeLmMbvYALZsymyTj1%2BHrMO0CgOOh6mI2Hy3zatJrYO%2BgYlMnKAGgLzOPAC2NjGQ18bdwowjboWSIiWrVqiyiEM2UawieVkpGW4nPh1xNmb5HHKkOAp8M49ViL8MK7j41hNtsOu%2BFNUwgl4LUR5LN0%2BQrWPcCVo2rj%2BlEaC7ngyBOEgLDAKfxK1lrEHoQpvuIzIlxQEY%2F10YvMYh2mNw1k8AIZXAwno2NAyRhJvZiEX4%2B6xVljCSYhuBGFQi8TccFEQo5TnYOOwdNw2kuBFGKqAwvokEFsOkZKPEZqHYP0VAQZRx0jJV6FIBAUCRrOJ0CMSXwlZgCHFAGK4c3%2FL0zSoVOMjkFfYz%2BTMjDD2G%2FkJq44bCYNTSDchYlCN7Efsg0JCqF8zMA7xmCWTyEvW3p0DNARHoDrjYRijQQIoEhwikophEqBbA1CxCUqRVKgRUbH4JIx3Ox1DEOG4zSZuAuWfUDQQHlDyuAyZ9IEYxvRwxpClEkDiVCh3lvqGIwKhCkiHFgNgxIYnMgRhDkxc8SiwQ7ikpFKjI5B%2BdiAMRwHO7NpiDrjxw2GkEw7HgOkYMQehCxaTYAWQ4JxQlCEdfmTwIxMrgt%2BOTGJDrX1rIcnq69gLY2iBIrCBn6m%2BJEh3IsrkeazhC%2Bzb4yOgchmGFIaiVnnhHVQzAVFRhO0w9hwWKrXjFV9ioAIiIAIiIAIPGoEuAP5Lx0jKpq7QVwJ7n%2B4c4AG9xXcIhKPkbk6BiXHxsYcPXftlaYeLzB%2FxG59DPafrHdszNqLN5IzNu%2BABztJScRN3%2F0fC2UMHT6KxRZuKV9wMGUphvrVatbhHRk48ndfEzMakpJa%2Fdqe967e3ftKCFNhhTQmPjBBICn5nlrt0AraxUqevJnl0qVLDqfsvxIaUbVajZWr19ofzNB%2BXAZlMe7bMyseA3dg5%2B69eV59HX0Am4l5JsKZR7ovp8wrYZ85F%2Fj1%2BPiEYXMJwBcv5pU8b1jrfNrmlfz7SVbDMBHyJEP%2FIaKjdZt2ZOc2HsUAB5aSyc6nywHX9h07s54JlxW36DgFXGhcfXg9%2BPusj8Ea%2Fjx35gjO0Tfffm%2FWx8AuNAReL8Lin8EhtlgIvCrEgypfVyVkAjec0jhImD1hGBMmTiGIYu78BYAyv2OUxiwAPqnR1%2B8CL0P58aefOYvNtJpJ7jmffMbEYzADAoOnTJvBcRLQHMSEQ25HzBqPeP24mSgbOCOUhuV4izzh3%2BdygFX4zLqm5Dpx8hSyAA4%2BeUmWWsdg7QVoYDOb%2FbwSzMNbgT%2BOtm09jXiby8NGMlwwqmaWPeVTF82HGpoPXjNF4eHeLh4DLBSFv9y3%2FwBcJzjgZEEYL4%2BnuvQskgTqgVlegGe%2BpKdA3FjkC%2FxrdlhqABeMaRGcwhKqRqXhkT2WcwS3mnLoAljxFfMwidkQiAzwoddAx3GbwSx5OmUaz6bBSLGkwau1TrEkAk2gRVSB08cwICNtxxi0Gp5HEz1CyaQ3QCiQ6QwYhivKQUOACBbGGCVgBmMPMQQdgxJosjGJXJzCWwcIeg5G8tXEexAEwll6lv61YJr2YgCrPOPd0wTQ3TIeIw0dA52HXBSF5YwHNsqhIkQVHFjkFNBRNa1AY%2BEI3i5W4V%2FT4wxppA%2FS8wlqWsQneU00wuDBwzhFYvLSTGjTg9RCe%2FHK6VzOmhqNjnHGx4eKSGltJKZka30MvH4CbAgVsEUmJNjKJDuTU1jqAW%2BdsAHIABzI1Ei9pKfrGbSkTEPH4Cx9jc0MCXKRHdeePqUtlI98Srt4P441hPhZYN4N0Gip0TGYf8RZyuEIG2dZT7VFi9YoEnxlcR4iVVgrg%2BYYkhw863MeJQQCmGp0DCJDDCuYI8jwHwdlcoTJpKiRDIm04zG4nBlFsKUKcmEe0VnUy3Cic6kIjYtP9hlL9B29yQ8mifmd4S0zKKtGx8BaeopLgFOgQLSh0%2BksfgNZUAiBAuGCcmgslsMNZYkhB2oaZS5brj5uTpBijL5KYm0iIAIiIAIiIAKPLAHupux1DDhwhFsIbqjY4Ss3FX%2BSjsELVVcfDGZVTwcRw6Zj1D%2F222xfKs9QMAb3crd8VegdnWvecuLmfoTb%2BJGjx6FUGBGDHWvfOsKrMZh3gDcadJtlMO9Yl5XgXnQMXkbLzTMihsdxz3sPQbFMsnYWLVnO3BkkndDQW6%2BSwb03c086desZdg%2FLaEAgQ9cdt9CZpWOwjAa33KxKUe2XGtxjc3eNT8dLQIh8wFuhohPep5lFQpgEEdR4rzw0JNQBT99%2BnU%2FEB6Zg4K2TACC86wQdw2THcWaOCaoCk%2F1PnjrD08YKX1Qk2GPXnn3cwJP%2Bx59%2BQULBReLmH3%2Fq5%2Bq1KLxylW%2BaNmvJW1NLlCyNJQRdYAkXYOcu3ZE1kEFwXti6dO1B4vYdOpurlQQcRMcoVLgYL3g1XjCnKJngom%2Bqfo9ji1%2FAFU2LSpUqs237TtwWYt0%2F%2FuTTp59%2BHh0DUQM3kDe9UsiixcvwqXHVazrVISKFyBAMRhshAAP%2FiEvM%2FCbgCuGY42jwaJXGohgQmYMQUa%2BBM3M6qI5k9joG77jkMS6OLf41Py%2FYZukY2I%2BXN3nqNDwgPHR8dvwjNorF5cHjRv1AMWAWAwbgEqIJ4FQSpYA3lIaOATo6BX9%2F7boN2IPlZsMtIqgAy7GZEngETGlE6TPdg2GAWovbNXb8BNMvtIiHy%2FQ%2B0Tj4lSzjibfF7yEZccYhQDAPD7UpClcRpxgfE%2Fvx%2BxAfePLOnC%2F0E3jyYg68V5agJCXHSUZp%2BIO4h2DhwTS5GAwcR%2FsykAHIDvoSTcDZRC6AGxZiMC6keYaOf0eBvM6DjJjBogSE6OCWQhIRBgIITfituIpkpDokAnxAJtcAlrM46cR7MPKhyhEjJZGA0AJQ00HUS3sphIrIfhc6hpHRaIu1AZZYCzqU108gBdDRXDJANvNK6Ck4EKiA2TBkZgFNY5YECfD9yUs5uMn16zckMAB6jFsWlqS9SEDMOiHKBYMZgQwqUvJfGDoGQxchgpkm5vIhDXEynLXXMSiZEYWqgJtMSnCZaJwRI0eT0iLD%2FxFcO5iNYQwbvG8z1QV%2F%2FJbxGIwTBg%2BroeKA01PQIBmJFy5cAlKKorMYivQaHBiBBjh20juMEMIeqIhBzhF%2B8FnikvTUi7hhUNBAG42Ul%2FPaaJz3Y3hAgClCnAImtXOW2AkkKRrC5BfaSI%2FTv7SRHdClPa8EO9EnGerEDmEJw4ChiBlcGuzzNIIVd3v27INGR0rKpGdpBT9xtJdLj2GPAfzGggLdhn0iSfwCAvhBMJcea6fwC0PwBl2M5UTjcL14ep3EZl7Ew9igK5FQCGeiUi4Beo%2BUhKDwy2BuUWiXNhEQAREQAREQgUeQAHcCDjoGELh%2Ftu4QuIv4k3QMFvzsv%2BSCeVkJIRnmHSXmBazs8zLWwCsR6XlHidVrUTExLHBhOePp3yEMo9%2BgoSy8ULOus5EscNIbNm3Je0XZMUf4fLDvK7Gag4jB6xh4bSI3e3f3nlmrqNvtxMcnjBk38acaTl269z7s5s67L0xKNJOAgIvTZ82tVbchMRv3uLIoopPlslmdmMYO9%2FaTp81EYDESk8Pnz7XqZGh9DEY499toC7xuFf%2B9SNEPeVsH8yxwi7j%2FZ2P11EJFinEk77vvsW4GN%2FzERfxSvSZ%2BFk8zeSUxrzdt1qIlr15FzcDlR8TgzSPGgeKWHm%2BofIWKuV54CTGEU%2Fnyv48vQ3s5xfSBJ3I%2BzQtHAi7a5phzBJ8df61c%2BS%2FLlvuCF6Ow1uJbb72Lh0uTSYBf3Kx5K17M%2BuZbec2yFTh9%2BESGHg3BCa1e0%2Bmvj%2F2ja%2FeelGYYsoOv8be%2FP86ioJSDYbPmzDOm5stf8INCRVgbJFeu3LjwyTdukJjoi08%2FK8uyHtCASd538g8ZOoKieISOt44x9o4DbhQKBi45%2FiZuOBs7uMB4LggF5teDOR0cwUEjMUdwSXijKK8ZxUPBckLiyYJrA2pGMnj5ygNxs7VE8kiJTMBs4PD8l7P47%2FikODuIHiFXQ7EZj5hQMWQWCsFUKiL0HcefJTqp0Qgg%2BHfG6bOw4OfSj8zaMLmIuGDuCZ4a1uJn4cmah%2FvkwvsjegE%2FFHcM95MExLSb1nEW1QWvjbwYi%2F3oGAcOHuI4G5M7YOLcqDGtwGYsZxFFWs2Gv0xptAItAo%2BMZHjHNBM7KY1QELIbU6mIrkcOIjG1k553UdI6TMKxNb3PJ%2B98IQH2G3S4eLAiOogyyc5sIGM5%2FjLJGBJmpU1qwSVk1OEtYgaOLTRYqxaHkSMYTC7KhBJVYAlOK%2FUSvmKGAfAJV0AGoR8pijRIMZQDPcrhiE0CatoitY5B0xiueN8QM2SwgaAU2oWrTrHkvRwSwjqujDqq45UWOMWMNAYqtbBhCbMSaCPZIYljTsQI8h2DHGvpDhxtyqEizKYijKQttN1s7Ldp0w5ZD3UC%2BAhH1EhKNio1Kc1IQO9C4eEsG73DJUCl1Eg3QQYViKEOZFx4tC9LPCG6gB9njqB0YQZqCbElNJa%2BoxWUz7DEx6chFIuDb6QJTjai3BatoIolZCQSCctpnTGbHQrBBi4xAJKGBlIIXcwFhbDDbwK9ZtEwrJDO6AjMxmAmxdDqYcNG8pWU1EVf00ZaZHqfgYRyQjJ6EK2GLMiwlEN1BHLQuRjIeOY4Qx0lExGDs0ht1X6ujt4LClKyg52kpFiaRvm8roXrAh2DEcIit7xeh8YaFHQcKKgOpZcs9DXHoUAVvKuaaW6mpUiX8ESABQu1cI3Yrvp5C6jdXCn6FAEREAEREAEReAQJcDuUWsew58Btw5%2BkYxCP8cOwM7y1BCnjhYYeNUed7TrP753WnrxuFVkjT7PjHj6hrKtnb0za%2B8xTuJ1jnvZxdIwBg4dZy3uyPsbIMeMDg4JCQq4u4KW0f0RoGB3j2rXQtEtL51krHoMFVG%2BXpUPn7paQYqUhMnndhk226SSZtLCnVbL9TlRU9MzZ6BXOP9eq27Frj5FjxrH%2BJ2%2BnRd75sbpTxy49mJ5vn%2F7u9lkig9cDpN2t1lmG4n7Xw207dG7SvHXTlm3sN94j03fQEO6T039ny8jnJnnLNmZqT0FhMFEZCxcv5ZGiKQQvw%2F2oB24gbgJOSvDVq2DfvnM3uTiFI8mbB3GF9roc4Jnp%2BImT8Bwx1fgX7GAtRfF2G7wYHkEad4%2BzbLgePFrl4bj5igvGJYY8QpQIRyCJQ4dIwtqe3PxTFPaQBa%2BZB%2Btsq9esxx%2FBBnsy2M%2BN%2FemztsUkzXHexMoDX1qEC2a1aP%2FBQ1OnzaAzcRz8AwIZezizmEoWfARSogmwXgQLGxL1TRVsuEK4eyyegG1WjexwigZChkfe5OKRN14%2FB61kPL0lSgHLTTOZDsOjcDQcTOX1HPh3yDWGNs9b2efpNg%2Bmb2679nDE5KVMnsMSyMEDXCBA3ng3FEtGjlsN5AgpebZLw0kDYWQNe5vZBwVpCFfA2puGhUfQO%2BgMeIj4pLje1GhysYOfRc9SNQ1kYRCyk4uzJi9CCgSQaDADvcU%2BIy4wASFMR0LwMWSsMnESt27bgduI3MHTZ%2FgD38QDAMcqxNRCQ3DMkdcI0We1DbAwYEyXmQR8AhYyNIHexAwIYLaxkOwsnoBnjQLDo3OwW%2BXTFhxYbGAhFMQB0lvtpUU8yge1KYQqiJnBs8bJxXnnqy3vufN0FgWaNAxggGOqQQRSvpKLxA4b6TlO1yA3UQtjAGvpEfoFA7AEdHQ9bcR45iYQZ4LTTcSFsZzyOUuUCCE6lEDQC9IKp1geg7e%2BoopQDmlMpZDHSIAw4G2fKTsYRiuwAWecsBPsITGfVMdXMxJIw%2BgyNXKWHTMSIANMOJtW88nPDoWjfVnlsM8RzOYIlrAcClc0%2FcsA44qggfbF0nz4I18gZyExccqUfDHw0k2bUyyn34HP4ASLqcg0kPL5NYAGEVZYTouAYzWfHWZzcGVRuBk2lIDAwthjXQ6GCtcOJlECKRkM8KFD2cdIIqysa4Qj%2FEaRhfEMTyNOGmh%2B%2FgEMIQonDYbBEDtpKe2lcK5ELg3zy0wCKsJC5CauGsQNTKW9tIiWItdQOzR4pyqXG%2FxNQxhXXMemy6iRQmgC6Rk%2FtxxdBos%2BRUAEREAEREAEsj0BbjwGD7FNOGUHpyn1xqNn%2FBHeu8pMW4dl8%2B8FDgsDnL4QXqzTiZx1j37U%2BeS8HZdCw6NY1fPAyWs1Rp59odExXmUyd%2Ftl3mySzlqw%2F66DE9Ax%2Bg4cYnQMxIrmrX%2B13kWSkJDQq%2B8AIyZwilUjMlfHoFJUAiSCW26459bcFksoYP0K3lV41421yknPDrHNo8dNaNn2N7SdH1A0nJv07NP%2F9zXrMuutrKxnks7%2BNcmQB7it5f429YZjwhjIUGn2Yx5HkjtqIpwtF4OiOMgVweNCPrl%2FNvumCu69ScxBczYuwRbC4WAARZksPCmmKMs2U685Qi5u6QnYqF7DyfWwO74S9%2Bc%2F%2FVzdrDJKLSaXVbtljFWa2aE0PD574znOV6q2P%2FiHtbZGUSZNsKogPfsOBnPJ4yIRpYBLThaHSimZLDSHojDAvihTGgctJuyQwErGVwo0tpl9vjpsVl5TPolNduu4fSHGNlJSCJ98JTGbg818NWksLBTCM3RsM%2BZZhZuMplKraofSTC4rjf1ZK4vVZOusxYFTJOM4OzyM5hE2fp85YiW2mZeyqAg7pkzTOiuByW4s4dOeAKfIReHGBj4d8poCOW612hzh0xToUAsprSOkAbWV0VZR%2FH%2FGACnT%2BP%2FCWEUVJKMcy2Z2bKvItmjJHBYkEaQAfGHWoiROAGeclKZ2K7spga9sOOAEABC2wUEHIx3GldUKduwTUwhfzcapW7LCBodclvFWpQ5HUltrpWTHFHi7Yu0tT22SKSft8jGGjFZeK7E5YurloLHE6lCOsM9Zy1SwkMV82h%2B3lZ%2ByoE1qY0jGRjmkSX2W0ky9lGl0DBQPEnOcXNZZMrJvGcZXYxsHTZn6FAEREAEREAEReDQJcIPBGnoEmvKAmAeOPLVx2HiYSGAzAa48K%2BF%2BI7Mo4Tdscb%2FydgvP9rP9eHEJX7mho3CEi2vhkTO3Br3%2Fm1eb6b7cF6ezRu5q0uOY3zINExxmzJprm1dSpwGf7Tp2uRZ6M%2BiCmAdiM6rVqGNO9RkwODY29paFZPQg8RgIJugYvzjVY7XPW25UinbAbBe2jJafielDrl71v3AB8YRXcsTGxmViyRQVm8HbUW5ib72leuybzpHzYJNxAaLJsMg%2FE0aYvVK4SHEW8GRmB5HzGGbu8x%2BghVzyRHETDI93mYmX%2FwNs0UNYNWOAR%2F%2BMAYLwkekeeKc%2FWEQ80Gf9B%2BaMMOqYC4PGzowDwoHSJoPnS8AAk1MIXNFAfbA9mNHa6S8UDPqOwCf6MaPZlV4EREAEREAERODRJMBtA7GjrKhWt54zc05Tb7Vq1WbKLbHxBHNm4g02jxZ3Hb%2B65uAVZgnzEMwePmt7Imsc9wmdvf1SSCgTgR3Dku0Tm30i%2Fe%2FxnR28%2FoNAeh49E0zrfuRofPxNVQSJw9v7FAcJvnUhSD4kJLO8eAwePGxE1x69u%2FXqm54ts%2Bp92MohsCQTx1XqsfHwH6H5bCwBynxwFgpgegiB2dzbPyRYmFyAhmm%2FOMbDjzRrWUhHE2%2BPK2cmZWQt4%2F8MaxF2mLDDkpgsn8JEpKPHjjMRKe3LgbNMdjBzJdJO%2BWcYrDLvhQDdTd8xucaam3MvpSmvCIiACIiACIjAo0MAKYMJxUc9PJkbnno7cNCVeSV4MdxsZC4TBAreLogEcctiETcc9I1bJjMHeab%2FsLnnsif9BBJSVoFIo38fhVMETjPLg2kdfD5UIdMoKvZB3Y9CX9z%2FNuJ6AxnU97%2Fqh7NGM%2Bqsz%2FQYyf9QXD6Z%2Fv9UeqpWmnskoL67R4DKLgIiIAIiIAKPLAHuIrhjxHu65caph%2FkJF2rI3b1uNf2OtlL%2B2QTi4jXZ%2BdaC3iP7o6SGi4AIiIAIiIAIiIAIiIAIiEC2JMDahKwV%2BWd72Sr%2FPhBgjclsOUTVKBEQAREQAREQAREQAREQAREQAREwBBAxEhOT7oOLrSruD4F4Xn9wm3lGGvMiIAIiIAIiIAIiIAIiIAIiIAIikKUJMJ1EkRj3R164n7XwGhd6NkuPTBkvAiIgAiIgAiIgAiIgAiIgAiIgAg4EYmLjtCbG%2FZQX7mdd9CzLZWi9Pocxr68iIAIiIAIiIAIiIAIiIAIiIAJZkUB0TCyP7O%2BnW626HggB1IyExMSY2NjIaNtrSRmrYRGR2kRABERABERABERABERABERABETgoSaQsloCbixP53m5apIUjAeiKTzQShE0mEDEQijxCYnaREAEREAEREAEREAEREAEREAEROBhJsATeaIvkpP1RpIHKiWochEQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQARG4E4GLgYHaREAEREAEREAEREAEREAEREAEREAERCBLELh0%2BbI2ERABERABERABERABERABERABERABEcgSBO4Ur6HzIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACD55A8vXrl6Lj1vsFT%2FcO2H7xanh8YkLS9RPXIheeCxp23G%2F%2BmSDf8JiE5OQHb6gsEAEREAEREAEREAEREAERyLIErqf8OZh%2Fy4MOafRVBERABOwJJCZf942ImX0q4Metx4qudq2%2Fy2uNb7BfROwKn6Cftx17fbnL91uP7bp4LSohyT6X9kVABERABERABERABERABEQg%2FQSio2J%2B%2BKbFsIHT7bMkJyd3bDesacNekZHR9se1LwL2BK4HByfs3h2%2FbUfith0he3etP7N%2F4%2Bl0b6dctp85eCn8in2B2s%2FSBOKTkk%2BFRo339C%2B7wa34GteP1x4uusb1263Hpp8IGOFx%2FstNR%2F5vyd7ym45suRASmZCYpVsq40VABERABERABERABERABB4ggfCwyFxPlnSu08XehqSk5ErlnT8q%2FFPotXD749oXAYtAkrd3eMUqIa%2B8ceXFVyOez7Ox8Ns5epfK0f3j9G%2BP9fik2DinoxdPWmXe407o9IXR%2Bw9RyNXQ6N5D19VsOqvhrwsatrNt9dvMGzd%2Ff%2FjkRf6VnIPrdD55zKdJx8XOKWcb%2FbawTss5DTos2bX3VPi0%2Bf5VG0Vu3H6PljyC2RExPEMiBh3xKYV8sdq1tcvJAW5n6%2B7yZL%2FUusPfbDlSaLVrjsV7y26UjvEIjg41WQREQAREQAREQAREQAQykwA6xsvPlW5cv7t9oegYX1dsXKr4Lw46xtWrYQnx%2F%2FUgleknJmN8fAJnrUKSkpOvXAlNuNVTV5LFxsZZKbWTRQlEte8YkitPyGt5Q15%2FJyJP3k0lCuYYUPb%2F%2Bnyezu1vKSlzdC9Zb0mPzCJwafJCn8Llro6anBQd4xsU3qzTolcKd325ULfXi%2Ffk07n7yuAWvb1yvOqbq8zere5vl%2Br7arEerxbt8cL7XUv%2FNH7puFXnq9Y%2F%2FW6Z6BVrEmLirIGdWbZl73LikpL2BF7tfPBUybWHCMPoefjs4CM%2Bg46cG3bUp63LyfdXueZZtj%2FnMhfpGNl7GKh1IiACIiACIiACIiACInB%2FCBgdo0mD%2F9IxWKnPXsdA1li2ZNNnpZzefLlcgbxfdek44trVm3EandoP%2B%2BHrFj06jyr4TpW3XilPriNuXnNmrixSoOobuct%2BXKzaiuVbrIbs33ekypeN3ni5bIG8lbtSiII9LDRZcCe8Ru0ruV9HxLDXMVAnMrT9T8%2FSZac2zazW9x%2B%2FbUTnWeer1r1Q8ZcYF1eK3bTL%2B%2FPvR%2Bcp0v2ND3s17b36Stv%2BJ3Lk9Xu1vMv2owU%2BH%2Fh6sZ5vl%2B7ffeCqM31G%2B75TOqh1txvhoW7elxq0nR8dE59ZVmX7ciITEzf4Bzffe4LQiwob3fu7nevvdvaX7R7sN9x9YqD7uW4Hz3y0zu2vS%2FYZHWNrxueVXLgQ5Ol5xkH%2FRGs65X2eLdsTVgNFQAREQAREQAREQAREQATsCRgdo75T56ioGGsLD4%2BqXMHZFo8RGkHipYvXP%2FH3IpXLN5w7a1WXDsNz%2Fr1IrWrtTKxF3Zod%2F5YjPyknjF0wsN%2Fk3M%2BVfjtP%2BQ%2FyfT1s8IxJExay885rX5w%2FH0Ah7m5eb%2BYu%2B3np2vNmr%2B7WadST%2FyjatkX%2F5OSb4Rz2JmWV%2FYSEhJiY2CzdhHtBHV6rTqboGOWmNbsXM%2Bzztu%2B9Mleh7k4tZh3sPPJC0QqXO%2Fe%2FERl5OTyuU79VrxTp5txtpaVj7Nt29PWSfSo4TV0%2FZXVAFafzJSon7Nwbd%2F3G2Fl73yvdt3D5QbFx%2FxV3ZF%2BL9v9D4PqN4Nh4VvKst9OzyCrXKluO9nY7i3BRdcvRPMv351i87%2B2VB5x2eA454tPV9XS5De5PLdlXYt3hWd4BwRmUiZo37f3m6%2BWRMv5T9Y0b%2FAQVLfw9W1KiVg21B6N9ERABERABERABERABEcjmBNAxXnvp85ee%2BaRowe%2BKFKxq2wpULZT%2F2%2Bdzlij7iVNYWATPQD8q%2FOPHxX62wicG9Z30xN8Kb9uyHzT1nDoxLeW4x03%2Fol3rQf967IPZM1YaavPmrM7596JrV%2B%2Fga91anZA4%2FHwvmlMtm%2FYlo5%2Ffza%2FmYNb6HD5ketnStc%2Be8ctaZmeWtf%2BtY7yz8aP8OXqVZJ5IxrbOxUpPapBZJrXtsfy5fJ2eL9D5vQrDxvScc%2FKr2uc%2BrBi9eeuN5OQ1m48Pmrrrcut%2BxGP45il%2FcNfxbj0Xn%2Bo5zC9f6aA23ZPDwl09L1apNem5fB1zv9%2B1ULmBsXEJmWVVdi2HV5NciIxdeCaIV5MUW%2B364zaPAUfO9XM%2F99n6w88vcyH64n%2BW7OPzleX7q24%2BgpTR6%2FDZipuOFlrlWm%2BXJ29lDYmNJ%2B4rnXAaOXd%2F%2BaUyx4%2Bftk%2BPjlEwfxU26Rj2WLQvAiIgAiIgAiIgAiIgAtmeADpGnhc%2BLVOyxpgRs0cNn8U2evjsEUNmfPDuN5%2BWrMn7Sk6eOPvCUx%2BPGDLTQuF5%2FAxHRg6bxRHiMYoV%2FM5aRmP8mHkIINZj0%2B1bD%2FzrsUKL5q%2BLjYkt9eEvxT%2F4YcrExWzTJi9tWK8bp%2Fa7HLWKzXI7bZv3f%2FbfH3kcO5XlLM8Ug%2B11jKsvv%2BlbsdK6swfXe%2B295bbhxK2Pr%2FPavd8n08bAlv3nRs5xGbfg4Ji5%2B4fNO3D4dDArdoZMnJMYco0mE2AR9OuAEznePpu7bFxUTILXmWtjpkcfPMKpK7HJU1e4DZ%2B5l7xj5x%2BYttwtMSk5Uyhl40J8wqPHefp%2FscG92OpDtXYc540kXV3PFFlz6Iml%2B%2F7CFJIUHYPPx5bse27Z%2Fs%2FWu%2FdzY7mM81W32N7HivSx6GzQ5XRHZTRu2OOV3J%2Bm1jHeL%2FA1m72OwSQ4AsC8vM5ER8faww8NDQ8Pj7T1dfC1Y8e8r127uZhPUlISUqSX5xliq%2BzTs8%2BaP97ePufO%2Bicq3sMBjb6KgAiIgAiIgAiIgAiIwAMlYOaVNHXu6WDFN5WaMFuEt7Lu2X0Yb33e7FVWggv%2Bl%2FK%2B%2BkWPLqM5go5RJP%2B3IVdCzdmbOsbxm%2BEZxGwgVixesD748tWCeavkyfXph4V%2BLPb%2B92zEeLC%2Fb6%2B7VWyW22nXaiB6znGP%2F3pGbLWCyfuXL4WcO3fB3j%2FCM4qNsa1xyhQe%2FCPjWFlZ2EE4Ou8TEJOSBvg4ZRwkJMbkMinj4xJiomPtF6Jk6VR8MbrSvij2mfNywT8Ix439uLh4NvsEly5duXDhEu%2FYtQ5Sr7211vFb7tjrGCG5X79a9aeohMSo8MjbbBG3OR4ZnXnv9o3ZsitsxKTwMdPDx04PGzM9ZNTUq%2BNmhoyeHjJiCtvV0dPOl651MkfBUzk%2FvjJw3NWRU6%2BOnckWMnIqZ0NHTSOXLe%2FoaRFT59%2BQ63rLXrc7OOf0xXIb3EqsOdR494nhx3yb7zv5wWrXf%2F6hYBCMYTakDLanl7p8vO5wh%2F2nRx31rb7N46M1h5y2H9%2Fgn9637hodw8vrrF39N7g6HHSMAweOflm%2B%2Fou5SuV6rmT%2B974aPWqONearfNX480%2BdWrXom%2BflT3M9W%2FLtN7%2BYMnnx3r1uZUrVyPXcx2xlPql55MgJq%2FzNm%2FeW%2FLDa88%2BW5NTnZZz27XWzTmlHBERABERABERABERABETgwRIwOsbt3lcSGRl15rTvi09%2FPLDvZMvOo%2B4nn3vio7Gj5nIkPTrGovnrcdvRLr6r0jwyIjoiIooNfxkXg4ehVrFZbicNHQNxg%2BVPcz9b%2BsWnSxXO%2F%2B3iheuN7DB04LQPP%2Fihd49x771VCQ3k%2FXe%2FXr50k2k4z3zHjZ6X%2F%2B3KHC%2F03jcdfh2a781KBLRwtuZP7X74poX18pdOvw39qNCPrHzIKWQK57rdXslVhopYZ5VwGqQSUyCBIt9WbpL72U%2BYv9PEuSfRNRhsVvMgl9Mvv730TCm2Cp%2FWdT3oQRYyVqvaunyZOmGp9BBToMOng44R8u2P12JjrmX8j8fkDiXf9deLtbp65Xj7ZI73b7d55yjs%2FT9F2W6XIOV4gdNPlbmeIiXdtSWPQsZN%2FiGdXc%2F0OHx23HG%2FJntO5F%2Fl%2BpfFN8MwLBHD2kHKQOIotuZQu%2F2npnj5dzxwepC7z6Hgm0ERd8SFjsG8EsK3kP74yTJbSEhogfe%2BsuIxuOjefKPCW29UGDNq7qKF6yp96fyvfxQaOdwWNsbfZ2Wc%2Fv5YgeJFf5g4ceHECQuZjYLWwZob9et2WbZsU5fOI5%2F8d9Hvq7YwOt7BA8defrH0R8V%2Fmjd31aSJCykzf76vfM5dMEXpUwREQAREQAREQAREQARE4MESSFvHCAuNYDXLzz%2BpzYqdvilLW6A8dGw39Kl%2FFnPZZwvIr5OOeIwFc9eSEsUDr9kKwMDpmDltueV0P1gId1f77XSMS0FX8r1ZMf9blZYsXL900XoiT5hrQ%2BA6tfTuPvZvOQoUe%2F%2B7CWPms97pB%2Fm%2BQeW4fDmEU6t%2F38YCqqgKK5ZumjB2Pm91efbfH65fu4tTlcs7f%2F6JU%2Fwfb7xtWLcr74Lx8w2kLyp8VpdomamTl6xdvf3byk2f%2FEcRkwUX7%2FNPalEvU4RWLt9S9atm1NuoXg8WJKBPv%2F6ycd7Xvpg9Y8WKZZvRl%2FK9UTEoMBghpdNvw1o06YPolB4gD6GOEVijm1eOvCdzfHC7zTtHEZuOkQMdo9Dt0hCwcTrnp9Ix7jgGLkfHH78asS%2FoGut2lljnxrtI7FULFvnkiO3zj8AM29nFeyttPrrF%2F4pLUOjJa5HhfwzpO9bVpHHPp3IWK170R%2BSITz%2BpZbYypWo%2B90yJwh9UJTADnbBp417%2F%2FmfhNau3m9K4rEqXqoH%2BYBS%2Fz8rUypP7U0%2FPm9FTCBR%2F%2Bd9831Rp%2Bp%2BAjcqNcr%2FwCUFKlFbHqdMrL396ytvHFLV0yYbH%2F1lo3Nh5d7RTCURABERABERABERABERABO4DAXQMgit4X4l9XdzJVyxbn%2BUszMIXmzfszfVUSfzxgX0n1avViZU8mzfpbQIMalRrV%2BDtyleCb84rGTNyNs64NdViy6Z9j%2BV4b85M25wUojjee6vyW3nK9%2Bg6plunkbzHBBee%2BSb29Wat%2FdvpGOd9LnRsN%2BSAi03n4Q%2BtgGVRFy%2FcwH7vbmORHXbvPJRy5saYkXOey1nC7ZAXX7%2F%2FugUBFUwqMaemTlr89OPFN6zbzdcqXzQqV6aOpWMQPENKf79AZpdA8vflW00W1i2hK%2Fv0GM%2FXNb9v%2F%2BdfCtJf5lTgxWCkFec6Xfm6dNFG7Fm1cps5tXf3YSqaPmWZ%2BZr%2BTwcd4%2Bq3P%2FH%2BzciM%2F0VHR6e%2F0rRThnQZ5fvel%2F4FvzWbb4Fvz%2BWrci7f1z7vf%2BdT8Ltz%2Bb85%2FXQZ7%2F8p7P1Y8bN5v7I%2F5fPeN1Yu%2FwLfBJSudT32v%2BbgpF3vo3z2UnTc1JP%2BxdYeMjoGqsUTS13e%2B%2F3gZxvckSxKr3d%2FY%2BWBfyx1MWqGee%2FqlgshEQmsVpKBP3SMZ5%2F%2BqGb1di2a92n%2Bx9asaa%2FcL5Yu9P63%2FBaxInHJj34uU7pmlN00pbFj5%2F7fX%2FNv2byPmjhFSmvaFDNKHvtL%2Fi6dR1hG8EqUJ58oxkLEFMWskw%2BL%2Fbhli8vGDXs2bdo7Z84q5qqgk1iJtSMCIiACIiACIiACIiACIvAACbAIw8%2FftTaLdlpmMPuga8eRrZr2s5wCIrprV29fsli1Lz6rO2n8Qush5pCBU5s06BGRsoAe2Vev3Pb91819z998CwnvWv2qQkPzZhPOep%2F0adW0T%2BmPqrPyxq%2BtBmb1OO3b6Ri0lEUn1q3Z2avbmGYNe%2F1UtdXTjxebMc0mFPTsOgYJ4vKlm%2BrNogVrn%2FlX8b273WJj44sV%2FB6BwqhDpFy3dhdBL2noGNabX3bvOjSgz8QWjfogRiGSdPx1KNlZlzX3M594HPVmn7%2FY2NjiH3zfoHYX9jv8OoSX3vL%2B3MH9pwwZMKVz%2B2HoGK2b909JmIEPex0j%2FLW8e%2FPmL1wiw3%2FFihWrX79%2BBmpNM2mAT%2BDx%2FSe8XE96uXofc%2FH0PuydFBqWFBYWtfdA9K59N8LCApv0OJHjnTMvfJbo58%2FXxEvBkVt3xxw8fPVisPteT09bxpN8eruffmRfp5sm4FucvJxKx8i9fL%2FTjuNTvAJ4H%2BtYD7%2BvNx19btl%2BBx0jMoM6BvNKWNfi9GlfBws%2BKPgN80o4SHjSO299We2nNtYVxMHfV2xBx1gwfw376BgkZqKcKYHAsH%2F8X0F7HaNli75P5Szu7x%2BIDsmaGKyMwUa8BxtfX8hVqq5TR5NXnyIgAiIgAiIgAiIgAiIgAlmIQKas208IeJLd2pJZqPkOpt5Ox4iKimYSTa4nS%2Fz4bcvuXUY3bdjLpmNMX052dIw3Xy5HdIQpCh2DySO4VLxMoeA7Vdq3HWJVsWbVjjvpGIEkZrVVSqhYrkGXDiO6dhhBPEandsM4TuDH6y997nPuZnRHdDTrk3xndIzqP7Z96h%2FFkJI%2B%2FbhmmZI1P%2Fu4VpkSNfr3nmhVnc4dex0j4vV3N7%2Fyxt9y5nwyg3%2BPP%2F54uXLl0lnjHZP1GLY%2B%2F2cDi1Yc%2Bt5nA76pO%2BWAV9D1qKjANj38P%2F4qaMHKAyeDgtrY3rvq83K5816%2BZ%2FxsalL4klXnSlQ%2B3bb3oGFrC301qmC5QUUrDiv38%2FjYdE95uKNV2TuBo46xeB8BGF1dTx8LiYhOSN4fFOq8ywtl4951jDTeVwLh4OBrRQt%2FX7liQ%2FuparNnrXzsf99bs3oHCdKpY%2Fj5BQYGBrN0xhfl6%2FHepSNHTrIdPXrSw%2BO0j4%2FWx8jeY1mtEwEREAEREAEREAEREIHsT8DoGMzmcGjq%2BjU7%2F%2F1%2FhaZOXGyOex4%2FTZjEjGm31TGIx2Cli5JFf6r%2BQ1vr7SHr1%2B22dIyvKzYua1sf4%2BYCnrxchqCOgAuXeUfJS0%2BX6tB2iMnFa01Y1ZPVS6h34rgFaBoue9yMDfHx8fbxGCzcyttSyIUwZf%2F82qEhaX8Nr1n7Su7XQ15%2Fhw0dY0ueNx9%2F5plnM%2FiXM2fOChUqpF1R%2Bs926PP7C%2Fk7v168R4f%2Bq4NCY%2BJcDvqU%2BDqwipP7yp3VW8517rbiStv%2B6Bh%2Br5bfs%2FnIh5WGzl58MPH6jQSvk%2F6Va%2Fp%2B9v3ivrM%2BqzEpd%2BHuxb8cEhuXsYkP6Tcym6VMrWO89fuBPofPnA2zTRfyCIlosvvEyysO%2FKk6BiOZqXA%2F%2FdDqhedLHfvjPciM7Zo1fmPiiXkNdDp1DF%2FfAK4js7DGpUu2hWvMH%2BFP1kK7fxzTvyIgAiIgAiIgAiIgAiIgAiKQxQigY%2BR6suSxI97MQcCNMhttmD1j5eN%2FfX%2FenNXsR0fH9us9IeffCrOoKV9vGY9hlsto0aQ3kRVbNruQjDerElZhWx9jrW19jFq%2F%2FMa6Iqe9z7Pvd%2F7ipyVqvvtGRYI6Dh30ePaJD1k7lBAXvLb5s9cQ%2BMG0EZK57HUnu3OdLuYVrrw8l5feNqzXjVNMeGFeSfdOo8zkoKDAK0wOOpVSeFDQlYsXL5MmPX%2FhdRtceem1e9QxnnjiiUqVKqWnuvSkadV1aYnKwzbuOX09IiK4y0CfvJ9cHDh64owdH3wx9KUPujbtvdrSMVy2H81Xpn%2Bewt1qN5996jzuavLVibP9i1Xwatr1t07zi387Kib2pmqUnnof5TToGFPs18dYvA8do%2FfhM2dSdAyiMu6DjpGU8pLcTRv35Px30bKf19m54yDaRbt2gx7%2F%2BwetW%2FY3Kl86dQwTdDF71u%2BP%2F71QlcqNuHAoasigqbwtZdbMFY9yR6vtIiACIiACIiACIiACIiAC2YBA62b9%2FpLj3bKlnHgbyLeVmrDxYpFlSzZe8L%2FEEqa5nytd7fs2rM%2FJe1T%2FliN%2F35TlN5n9wUtbLgbc1ArmzVn1%2BF%2Fe37n9IDS8T5zjRasEVNSs1o5FRQiZQCRBc%2BDU3NmrnvxnUdZZRdDg9SKP%2F%2FWDvK9%2BwYIArG3y1RcNCdvAAGI2eL%2FqP%2F63QLXvWpGFZ8dtmvdnRdYKn9WjwPferPTvxwo3rt%2BD6Iv4hAQWJyHXl2UbMNOEAl95vgxaCrJGpfLOpUvUuHYtXS9CjV%2BxIuTVt6%2B8kOfKi69GvPjaxudy%2F%2B8%2F%2F8k8kQz9EY8xbdq0zBoMu%2FafuxgSFe%2Fi6vNh5cDKNQ8t2VqzzYJXi%2FfMW7L3mx%2F1ctAxCnw%2BMG%2FJPq8V61G47MBpC%2Fcno2X4nA%2Bs3cK3zLdbRi%2BOT0jKLKuydzlXY%2BMXnw38atORt1cceGflAT4%2FXnt44JFz51J0jONXI9u6eBdbfSjvyptnf9nmsS8wNCZFeUg%2Fmfp1O7POp4fHKfssDPK8b33BZnQMTk2atOj1V8s9%2FeSHzz1T8pknP6zj1NGKqfiw%2BI%2FvvP2ltT4GAkWOHG%2B3%2F%2B0%2FM7lYSvTvjxU8d86fcoh96td3ArEcTz9ZnFUyWDfj55%2FaWGvw2tugfREQAREQAREQAREQAREQARHIQgTmzvodicDpl%2FZ8mu3n79uYt4cccT%2FBop0sW8HrXTau383%2BhLELaNrSRRt4sal5Cwxf9%2B1xa1C788kT50yrPY6dat6od%2BVyzq2a9R3UbzKzUXjhC6cQH2ZNX8FrVat80ZBXkIwbM%2B%2FXVgOuBF%2FjlL9fEKJEpfINbFWv2MrqrF07jSQ8g1O832T86HnkYpmOyRMWFcr3TbOGPc0sksTExBlTl%2F3yfWssRMpwO2x7YQrxJAP6Ture%2BWacBkfu8Icksnx5ZOt2kW3axbb57XirtnUbNmyUkT9nZ%2BdZs2Yxp%2BYOFaX%2FdFhYSN8Rp18rea7ToDETNr%2F72aBc%2BTu%2FVrTHq0V7EI%2FRoNvKK817e%2BbI4%2Ft86b1bjrxVss8rRbqjY7xSuFuu%2FJ1%2Bbjj92KlLsL46aU5AuWrXo2PTX%2B2jnDIqMeng5bCeh8412O3FVn%2BXV2sX73lnLl6MsgE8Fx4zztOv%2Bd6T9VPOkmDQUR9CNRKS0I0y8Oft7cMbRhzeCMxgPnjgGJsZ1aY4JkytXbNj6ZKNboc9reWIOeXm5ul68Jg12HgpyY7tB86etakW5u%2F0qfO7drkyqeSPAzdOnDi7evX2hfPXuuxztz9uJdCOCIiACIiACIiACIiACIiACGQzAvbu1R2bxntUd%2B1wtZKZxTdYAcM6kqEdHlWjn1iCiesBj%2BefKDFkYKZFPmTImPuWmLeTRG7cFe%2FhHRmfvOvAuX2u5%2FYfPm82l0M%2B3j5XEk77Rm3bH7vPPexa5AF33%2F2HfawE2%2Fee8j57M04mIeDi9QwGDNy3NqoiERABERABERABERABERABERABEXgYCCxeuO65Jz6sU6PDwL6T69bs9MTfCrdu3s96fJxRC4nWYB5KsYLf9e05rlunkfnfqpz31Qpn7lYVyWjtSi8CIiACIiACIiACIiACIiACIiACIpC9CRC4Pnn8orKlnYoWqFqq%2BM8D%2BkyMiIi66yYTCnL4kCevf%2BV1q6yAUf2nXw%2B5Hr%2Fr0pRRBERABERABERABERABERABERABERABFITSEpOJpTCesVq6gQZPRIaGhEeFpnRXEovAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAlmCwPd122oTgexHoErNFj0HT8gS16CMFAEREAEREAEREAEREAEREAERSD%2BBlwqW0yYC2Y%2FAM3lLV2vwW%2FovBKUUAREQAREQAREQAREQAREQARHIEgReK1JRmwhkPwIvFihbo3GnLHENykgREAEREAEREAEREAEREAEREIH0E8h%2BDqxaJAIQkI6R%2Fh8BpRQBERABERABERABERABERCBLERAPq8IZEsC0jGy0K%2BQTBUBERABERABERABERABERCB9BN44bmPtYlA9iPw1BPFfvy%2BVfovBKUUAREQAREQAREQAREQAREQARHIEgSez1lCmwhkPwI5%2F17kh29aZIlrUEaKgAiIgAiIgAiIgAiIgAiIgAikn8DzT5bQJgLZj0DOf0jHSP%2FPwEOa8vr16xEREWFhYYmJiQ%2BpiTJLBERABERABERABERABETgvhPIfg6sWiQCEHhodYyEhARPT68j7kcjIyIdLnfcdh%2Bf8%2B5uR2JiYhxOPTxfAwIuHjp02NfXz94kLD939tzhQ25XroTYH7%2FH%2Fbi4%2BKVLlk%2BaNDUwMOgei1J2ERABERABERABERABERCBbENAPq8IZEsCD62OER0dPWni1CGDhvv7X3D4GYmJiZ02dWbvXv28T55yOPXwfN29a0%2FfPgMmjJ9kL1kkX7%2B%2BbOmKPr36HzvmkYmmxsXFzZo5Z8jg4RcuBGRisSpKBERABERABERABERABEQgSxPIlj6sGiUCD7OOMWXy9GFDR6b2zZOvJ7u6Hlq7dn1YaNhD%2B6uyZ%2Ff%2Fs3cecHZU9du%2FqaSH3oUg0hGQpqKiIAoqRaSD0qUq4J%2BqFCEhvffee%2B%2B9b3ovm832ku3l3r29l3m%2Fcydc900gJoiSbJ75XC9zZ86cOed7Juvn98yvrO%2FcqVvHz7vOn78wFe6BjjFr5pyOHbrs3Zv%2BDY4cHWPsmPGwwgnkG%2BxWXYmACIiACIiACIiACIiACJzQBGTwikC9JHBC6hjxuN3uqKioCIfDqb8q1dXV%2BzMyMzIyKyurCN9IHWfH6XJlZWVnZOyvqqz%2B4lSCHkpLSn0%2BX1VVFQEs2dk5nv8%2FgAXxobi4OD19X052rsftqdthPB4nguNLr0o1s3SMLp274yaR8r44RMdwuz14mzidTusqumUwaBHMi7CasrJy5kLii7y8%2FH3p%2Bxgtl9PSGjAzcn4h46R0jLzc%2FAMHzDEXFhbRQ2ow7NAnQS6cKigopL11ivtwl4qKyurqGugxmP%2BfXN0OtC8CIiACIiACIiACIiACInCCEaiXNqwmJQInoo6BGT5%2B3ET0AWx2%2Fo4gOKxfv6F7t14dP%2B%2FCh%2BOrV62xrHhUiz179vTvNyh1au3adUnviMTs2XPxlxiTdGMwz3boMmzYSHQD6w%2BTs9Y5Y8asTh278uEs4SE5ObnWqWAwuHTJ8q5denD88w6dBw8elpuTd%2FifM0vHwEeiU8duOJbU1tbS5hAdY9OmzfSwfPlK63LmNWa06VaBRONw1DLs3r36Dxw4xBoDksiSxctWrljFBJO37kIDsm1wraljjJ3QtXMPxpl0AunCJTh%2BeL0HU4vYaxxTJk%2FjKqur8eMnWtEu3Khv3wHdu%2FXu2aMPcTq0icVih89FR0RABERABERABERABERABE5EAjJ4RaBeEqgHOsbWrdsxzwcPGrpx46bNm7aQVQODnYP8ncHLAqu%2Fb58B69I2bNm8jVO03LFjF6fmzJ6HdtG9a8%2B5s%2BcRpTJu3AQkBWJAkD6QBaZOmdGhfecpk6bu3Llr2bIVdNKv30BUjng8sWL5KlqOGzth%2B%2FadyznVpcfAAf9fEgzr7xs6BsOYO3f%2BtGkzSZSxcMEi3C3ovG5cyZF1jAH9ByNKjBo5dtPGLdy0d6%2B%2BdIiaMXPmnB3bd5LYk5%2FIFyT5ZGM8%2FOzfd%2BDq1WmbNm5GOeGmixcv5Y4%2Bn3%2F8%2BEmcnTVrDnlTZ86czf7UKdPRc%2FDEYF4wGTRo6NSp09et28AgT8S%2FzxqzCIiACIiACIiACIiACIjA4QTqpQ2rSYnACa1jEAeBd8SokWPwYUhVBiGkAtlh0sQpxIxMmzoDw5%2BICetfdH5%2BIX4UEydMxvCfO2c%2BcsTaNWnWKbJw4NGBaECoBXEcNBs9epzff7AeCjICssbmzVurKqv69O4%2FfNjIVBWVpUuX08%2BWLVsP%2BaOBjsHxFStWVZRXokjQITEdtJk1a24qP8aRdYz%2B%2FQYOGDCY%2BBer58WLltLh7FlzLJcJomCQZfr1GUADnE%2FGjpnALVIzJTilT2%2BuH0IKkd279qBU4H%2BCpkFXyBcQYLK0qaqqRuTBrwNB45Dx66cIiIAIiIAIiIAIiIAIiMCJTkAGrwjUSwIntI6B%2BECyCGIikDJworD%2ByCBEkEWTRBmcIs4CiWP58hUbNmzks3zZSn4OGTyMsBF0DNwS0tMzrKuIs0CgoAxKKBjCqQNnBhqn%2Fmo57A6KpXK73btNTWDE8FHr123YsJ4%2BN1l%2BEYsWLkk1tnYsHQNfDn5u27qde6F%2B1NY658wx%2FUCsPJ9H1jH69R1IqAtqjNVhsgBK57S1662f1JylRknvXv2YpqVjMLVUnk%2BOTBg%2FCT0nP79g4YLFpgPG1BkbN2wiAGf9%2Bo1cyCzQVdAxmDX0%2FMdxBVtrvvoWAREQAREQAREQAREQARE4VgL10obVpETgxNUxCAkh9SWpOLHWJ06cgpvBIf%2BoCQNBCiA0gw9mOx92cFoYOnSEvcZOxAfW%2Fd69po8EW01NjaVj4OCxZk0ang%2FY%2B9aput%2BbNm2hE3w8Un3SIR8CUg6JyKirY%2BD%2BgdxBn0SXENCRvK9Zr%2BTf6hgM1ftFjgtcR%2FAJQc2wxkNd2sN1DJQW6yyDmTF9NoNEzzEjWZIBKZ07wqEb3xDDHwM%2FDTQQZj1yxGif3193mtoXAREQAREQAREQAREQARGoBwRk8IpAvSRw4uoYGOOY7ZUVlfhjjB41LuWPQfQEdUDYTB2j30CCJoi2KCo8YH6KDhCKUl5WjrBg5sf4Mh2DfjZuJP1mp5UrV6f%2BcOHjQVURXCC2bTM9K9AiDhQVW32Sa5Q%2BcedINbZ26uoYHCkvqyC6BAGE0fJd1x%2BD2BPrEkZFeAhuFVaeT0SYY9UxUv4Y0UgUbadr157kILUiaEgQylCtMRcnx0xkCjci%2FEQ6xiFrp58iIAIiIAIiIAIiIAIiUD8I1EsbVpMSgeNfx6DC6SF%2FQ5AarHolJcUlpLAgXqNXz76pZtU1dsJJKL1BEAflP5A7cnP%2FVU8E6QOJgw5nf6WOEc7JzunSqduE8ZOtoic03rZ1Ow4MOGNYyTdGjRqH24Y1KrJVFBUVHV7m4xAdg8Zbt2y1fDlSOgbd4mKxcOEiqytCSIYPG%2FW1dQzcQnK%2BqJxChVlkkP79BzkcDkJgcAVZMP%2FgXbgXPh5lZWXsSMewyOtbBERABERABERABERABOolARm8IlAvCRzPOgZGPbY5gsPq1Wsppcpn1crV2P5ul9tK%2FmDVXbXsdIIs9u%2FPzM7KmThhCmY7eSRwzNi2bUfHz7uSLXP79h04Y9BPl849ZkyfhRJi5cc4LK5kFAIFfhfoJPhdLFiwCOGCTtAEkBdwY0DZoFwI2TMmT5rKvXKycynzQcTKrp1mDZS62%2BE6BjedPGkajVM6Rn5BIfIInXOL9L37qGzCKTSZr%2BGPYdUrGTpkBHk89u3LGD9uEoOcP88sv0LIzOBBwwgnQcooLCjauyed%2FB6Ek%2BCvUqm4krprpn0REAEREAEREAEREAERqF8E6qUNq0mJwPGsY1A8FBUCwx9JwfpgmyNukJ2SQqJIHJaOgfJAgdGDCSs%2BNxuT2TIQMP0lCK8grQQuGRykH%2FQNTHjrqtmz5vJzzx4zTwUbxj45M3HtQMTgJ0oCvhypq9AW9uzZiybAKZfLRVzJ5x0Y0sGxzZu7IJXFwuwruSXTcnaimskXB8z%2FktCjX98BXEhv%2FIxEoozcmiDfzKhrFzPwxNIxqCQyZMjwVM9rVq9t%2F1mnVIEV8mOMHjWWgVl5PseMGY8GQpCIBarj550nTJhcW1tr3R01ZuiQ4czXvBfVZrv1IvsHGUW4EeVcYaL8GBYofYuACIiACIiACIiACIhAfSIgg1cE6iWB41bHwMrOycnFX4KyGqkPP0n4gHBBGY596Rle78FaHsngjgPU41iXtqGgoLBulAfiAyEnmzdvwRkDRwWr%2FAcHUTNIUkHsifVnij7378%2FkjtEv8oUSsbI%2FIwvdYOvWbagcdf%2BahUJhIjjWrl1P%2BQ%2BCSqLRWN2z1j6JO%2FB8sMI3Ume5L24hyfseVBhw8OCma9akUdSV7BaEwGRk7EejwHlj%2F%2F6s7OycVGwLpVFRXfi2emOcVmMUG7J65uXmZ2ZmVVfX4NeB4sG3Jcikbk00DRg5tXXLNjqxNJmAOcf9DIDeUi21IwIiIAIiIAIiIAIiIAIiUD8I1EsbVpMSgeNWx6gffzc0CxEQAREQAREQAREQAREQARH4tgjI4BWBeklAOsa39SdF9xUBERABERABERABERABERCB%2FyqBemnDalIiIB3jv%2Fp3Q52LgAiIgAiIgAiIgAiIgAiIwLdFQAavCNRLAsetjkHOh5KSkrFjx07UJgLfKgEewnXr1imFyLf1f766rwiIgAiIgAiIgAiIwNcmUC9tWE1KBI5bHWPTpk2ffPLJqFGjRmsTgW%2BVAA9h7969%2B%2Fbt%2B7X%2F70MXioAIiIAIiIAIiIAIiMC3QkAGrwjUSwLHp47hdrs7d%2B6cmZn5rfxj101F4BAClLPp3r377t27DzmunyIgAiIgAiIgAiIgAiJwPBOolzasJiUCx6eOYbfb33nnHafzYFHU4%2Fkvg8Z2khDAJWP16tUnyWQ1TREQAREQAREQAREQgfpBQAavCNRLAsenjuFwON577z3UjPrx10OzqAcE0DHWrl1bDyaiKYiACIiACIiACIiACJw8BOqlDatJiYB0jJPnj5hm%2Bp8QkI7xn9DTtSIgAiIgAiIgAiIgAt8KARm8IlAvCUjH%2BFb%2BnuimJxwB6Rgn3JJpwCIgAiIgAiIgAiIgAvXShtWkREA6hv64icDREJCOcTSU1EYEREAEREAEREAEROC4IiCDVwTqJQHpGMfV3xkN5rglIB3juF0aDUwEREAEREAEREAEROCrCNRLG1aTEgHpGF%2F1T17HRaAuAekYdWloXwREQAREQAREQARE4IQgIINXBOolAekYJ8TfHw3yWycgHeNbXwINQAREQAREQAREQARE4FgJ1EsbVpMSAekYx%2FqnQO1PTgLSMU7OddesRUAEREAEREAEROCEJiCDVwTqJQHpGCf03yUN%2Fn9GQDrG%2Fwy1biQCIiACIiACIiACIvBNEaiXNqwmJQLSMb6pPxHqp34TkI5Rv9dXsxMBERABERABERCBekng1OY36iMC9Y9As4bX3v%2BbV463f7MOh%2BO9996z2%2B3H28A0npOWgHSMk3bpNXEREAEREAEREAEROHEJ3Hv3S%2FqIQP0jcPedz334fq%2Fj7R%2BmdIzjbUU0HukYegZEQAREQAREQAREQAREQAREQAS%2BioB0jK8io%2BPfFgHpGN8Wed1XBERABERABERABERABERABI5%2FAtIxjv81OtlGKB3jZFtxzVcEREAEREAEREAEREAEREAEjp7A19YxYrFYeXn51i3bVqxYtXnzloKCQp%2FPl7pveXnF%2Fv1Z%2BfkFsVg8dZCdA0XF%2BzMyKysq6x780n2Xy5WZmbV61Zotm7dWV1Uf0iaRSBQUFHKLmuqauqeKig5wVVZWtvVhv6CgqG4Dut21c9fKFas3bdpS8WXDcDqdXJWXlx8OR%2BpeyOys8WzevLWm5kvSiXi9vj179q5cuXrD%2Bo3FxSWMsO7l2j96AtIxjp6VWoqACIiACIiACIiACIiACIjAyUbg6%2BkY1dU106fN7NypW8fPu3Ro3%2BnzDp07dew6aNDQjRs3R6NRGE6bNsM6uHv3nrpIhw4Z8c9POixcsLjuwcP3t23d0ad3PzqnE767d%2Bu5edOWVDMkguXLV3L3Du07L1%2B2ou7xwYOG0t66kGvbf9ZxyODhqQbFxcX9%2Bg5Mddu1Sw9EidRZdpBfBg0cyoV9%2Bwyw2x2pU%2BgSQwYPS13Ys0eftWvXpc6ygzIzeNC%2FGjC21avWSsioi%2Bjo96VjHD0rtRQBERABERABERABERABERCBk43A19AxSkpKB%2FQfjFGPdoEsMG7sxFEjx6AJIGj06N7b8p2YPn0mZzHnBw4c4na5U1SHDxv12acdFy1ckjpy%2BA5eDZ06dkNMQC5AhejSuTv98J2bm0djdJIli5fROUf4XrF8ZaqHSCQycMAQGg8aMGT8uIljx0wYPWrcnNnzrQZer3fggMFc0rVrjxHDR%2FXq2ZeWfHAasRqUlpZxuTXs%2Fv0GpXQMvz8wfPgoxtOta89Ro8b27tWPNkx%2F%2B%2Fad1oXcd8Tw0Rzp0qX78GEj%2B%2FTuT7efd%2BiyLz0jNTbtHD0B6RhHz0otRUAEREAEREAEREAEREAEROBkI3CsOkYoFBo7doJl1K9YsbKmpgYrPhAIHCg6MHnS1BXLV1nxFJaOgbjRsUOXBfMXpYIsjkbHIDZk8ODhKAN4Qbjd7p07d3fr1hORZOXK1azO6tVr2UcoQFU4RMeoddTiR4G%2BsWP7zng8zsDCBIdEDoaH4ONhaRTr128gSCQnOxfVBfFh5ow5dFtbW9uvz0DmxZjpvK6OkZGRyRG63bRpczAYLD5QjNzBhfif8JNrM%2FbtPyiqrFiFWpL0%2BhhAgwkTJhF9QwNtx0RAOsYx4VJjERABERABERABERABERABETipCByrjrF3bzrOEhjpRHYcAgrpgM06aOkYSA1dOvfAxieRhXX8aHQMWno8HqfTZV2CEIELBArDksVLOZKbm9%2B3T%2F%2BZM2YPHTqCYdT1xzhwoBgXju7deu3dk44ugYhh9WB9z5gxizCTYUNHWuIDB2fPnoskgm8JLdFnZs2ai0AxZ%2FbcQ3QMlBPuzilGZXW1edNWJBEUj8zkvBYvWkrPSB8e98EGa9eso%2BeePXq7vzhSdyTaPzIB6RhH5qOzIiACIiACIiACIiACIiACInAyEzhWHWPG9FkY9f37DeTCI3BDx6DZ6FFjichAbSB3BJ4VtD9KHSPVM44cZKJACaG3XTt3W8fJ1elyu0eOHMPBujoGfhEoJ8gLeGXwGTRwyLy5C2jLVQgskyZMQW3AaSTV%2BZo1aWTYIAykuspMForvBkoFvhwMuK4%2FBnk7US1QSAg8sa7FJYOfNOMUR2bPmkvPI0aMZt%2Fa9u3LIK4ERaWkuOSLY%2Frv0RKQjnG0pNROBERABERABERABERABERABE4%2BAseqY0yaaKoB5MQ4csQEOgYOCTTO2JeJtoDgsGD%2BQugeomOEQqYjRGqzcoSmFoHjixYtRZfgcvwoKAiSOuX3%2Bwk8OUTH2LZtB2EseFMgIJg3TaYJHTN6HI3piiQeDAkdJtXJpo2bLYGioKAwdXDb1u2H6BhVVdV02OnzrqNHj0tP34ezx8QJUyxpZdWqNVzINOmZcJtUJ1RLYRiMfP%2F%2BzNRB7RwlAekYRwlKzURABERABERABERABERABETgJCRwrDoG%2FgzY7DhaoAwcAZelY0wYNwlpYvGiJQgOGPXULeXCVJ5PlJBhQ0cQM4JHBB%2ByVaxLW5%2Fqk6Ko48dP4kI%2BVCGhkkjqFDskuDhcx8DlIy1tPTVTcJwoLCyaOmU6agOj3bFjJ%2Ffi1uxPr6NjbNiwCcmChJ9k5Eh1friOwSn0ChQPnDf4tj5MB6WCsrOctZiMHTs%2B1UlWZpalY2RlZqcOaucoCUjHOEpQaiYCIiACIiACIiACIiACIiACJyGBY9Ux5s1biO2fDLIoPQIuS8egaAg6BprDsCEjEAFGjBhNvAZiglWvBG2BAiIIAvhO8MHwX7M6zeqTTBeErhCawcE5s%2BdZsSF1b%2FelOkbdBuwH%2FMF%2BZiemK0jCSEyZNA1PEnwnUs1WrVzDYA4psfqlOkY0Gtu5Y9eE8ZModDJp4lQkEdSM7l17WbVO5s6eT8%2B4mqR6JosI98WLo6ysPHVQO0dJQDrGUYJSMxEQAREQAREQAREQAREQARE4CQkcq45RkF%2BAttDx867Tp81IlQKxuNXWOq3SqPxM6RhWss3s7BzLgcEKx7B0DHJfUNqjoKAw9bFyaJCHkygSUy3p2Xf37r1W54d8UyHF8sdYuWJV3VN1FQ%2FyXfTvn5RNFpllXq2snmTq8Hq81iXTppnBL6TRqDuR7QSnJPNjMJ26Paf2ubU1PFJ%2FWLNbunS5pYc47AdzhixbthIdA08P9JbUhdo5SgLSMY4SlJqJgAiIgAiIgAiIgAiIgAiIwElI4Fh1DCx3wj2w9HFImDJ5WkFBIT1UVVYhOAwePKx3r77FxaafxiE6BpLFwgWLuQo1AwPf0jG%2BivbWrdvonMbz5y%2Bkagn9s9ntdkqaconfH7A7HCUlJUMGDzfbzFvIWTQHblGQX0jxEUJL8ILgw%2BVILigMxJVwIbk3%2BUnPy5auqKysJGtoMnFHF2swJAKtdTrpas3qtbTBSSMvr4Cf5NbgWrOa6oFi7kLeTqJIuC%2FuJTt27LKmgFcGXVmDIRyGnBgoGEyTCimpAi5WS30fDQHpGEdDSW1EQAREQAREQAREQAREQARE4OQkgKn%2B3nvvoRIc%2FfRrauzDh43ETsdyx7%2BC2BAKoSZN%2B04IBcgFdHWIjsERfC1QHpAI%2Fq2OsXTJMlQCeiZpBtVOBwwYzKdf34EICIgVVEFln%2FgUpANUESJcGADNKiuriED59J8dGEmvHn04bt0rmSDUFECCwRA5PznLIOmZ%2FmlAs7JkFRK73UHLAf0HIcVwqluXHtyFDzVVuZa6JFyF5wahIvTAZ%2FbseSQp5RQbYgWSDgfpkHQfjIodvvPy8q0G%2Bj4mAtIxjgmXGouACIiACIiACIiACIiACIjASUXga%2BgY8KHu6YIFi1ADLA8HzHZUhUmTpmZl5Vj0ZiTrlZAfw4q8sA5mZmZj3eMgsSgZ6PFVnJcuMcM0aImeQP%2FWB5UAGQTFYOHCxSghHORsqg1xLjhgIGXMnDGbkTAeqwEDqJuhArmG1BadO5peGTRApsB3whoGRUlINMpxq1t6pgE3mjZ1Bg2mTp3OvnUVzfDoqBuKQgPiWSZOmMwltOGDP4blBPJVc9TxIxCQjnEEODolAiIgAiIgAiIgAiIgAiIgAic5ga%2BnY1jQ3G5PcXFJenoG9T6cTmfdSqw%2Br6%2FWUevxePCgSBFmHwGE41awRur4ITtEjtSam%2FOQj5U9g2u%2F9KxVs5VbMBICPaiNgt%2FIIWoDN6LMCrEhlE%2FNzyuwAlWsuzN4YlgOuSM3soq98k3qUa4iAQj9HzJg6yf3ok4K7ii0qZum40sb6%2BARCEjHOAIcnRIBERABERABERABERABERCBk5zAf6JjnOToNP3%2FEgHpGP8lsOpWBERABERABERABERABERABOoBAekY9WAR69kUpGPUswXVdERABERABERABERABERABETgGyQgHeMbhKmuvhEC0jG%2BEYzqRAREQAREQAREQAREQAREQATqJQHpGPVyWU%2FoSUnHOKGXT4MXAREQAREQAREQAREQAREQgf8qAekY%2F1W86vxrEJCO8TWg6RIREAEREAEREAEREAEREAEROEkISMc4SRb6BJqmdIwTaLE0VBEQAREQAREQAREQAREQARH4HxOQjvE%2FBq7b%2FVsC0jH%2BLSI1EAEREAEREAEREAEREAEREIGTloB0jJN26Y%2FbiUvHOG6XRgMTAREQAREQAREQAREQAREQgW%2BdgHSMb30JNIBDCEjHOASIfoqACIiACIiACIiACIiACIiACKQISMdIodDOcUJAOsZxshAahgiIgAiIgAiIgAiIgAiIgAgchwSkYxyHi3KSD0k6xkn%2BAGj6IiACIiACIiACIiACIiACInAEAtIxjgBHp74VAtIxvhXsuqkIiIAIiIAIiIAIiIAIiIAInBAEpGOcEMt0Ug1SOsZJtdyarAiIgAiIgAiIgAiIgAiIgAgcEwHpGMeES43%2FBwSkY%2FwPIOsWIiACIiACIiACIiACIiACInCCEpCOcYIuXD0etnSMery4mpoIiIAIiIAIiIAIiIAIiIAI%2FIcE7Hb722%2B%2F7Xa7%2F8N%2BdLkIfFME0DFWrVr1TfWmfkRABERABERABERABERABERABOoTgXA4PGHChEmTJnm9Xo82EfhWCfAQ7tmzp3379shr9elfmeYiAiIgAiIgAiIgAiIgAiIgAiLwDRIoKCgYMmTIO%2B%2B88742EfhWCfAQdu7cefny5d%2Fg462uREAEREAEREAEREAEREAEREAE6h%2BBRCJRVlZWoU0EvlUCPIT4g9S%2Ff1%2BakQiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAj8VwkkDIPPv9%2F%2BTaMjnT7aW%2FybQRy8hdVb3e9%2FXXewyb8OfO29RCIRi8US8fjX7sFIJOqSTQ34YIfm729uuF9%2FlN%2FAlczzi6las%2FzyPo9htl80tbr74teRn9R%2Ftfry23%2FJURbZXIO6H1oduSPrwTCfjfqyfF8CRodEQAREQAREQAREQAREQARE4L9DAEsqEolgUsXjcXZCoZC1bxlZfFtnaVZ3MyIxIxyPeEM5Wfmr1m3dm1PsCBm%2BhBFJGKbVHjcSkUQ8GDbiMSMeNWJxIxI3AlHDEzOChhHhk4iHo7FwKB4NxGN%2BI%2BE34r5EzGPEvEbMZ0QDRiRoRLicexrhhOE3DI9heA0jEEvEEkY0EacD81wibt4iHDYCwUQoZEQ5zR25PcfjCe7LxQHD8CeMYNQIBhIBTzwR9CcCTiPoMCJ2I%2BpM9uyKhP2hcCJKs7DhDxsRc8yJaCQWCSYSUVMsSKBGmJu1COxEk5v10zprEbNO8R0IBDL2ps%2BbNit7T0bYF%2BBsKGZOKWTEmR5jjRixcCLsD%2FujsVAkGgyHmVw0moj6o6FQAkbMK2S4%2FSY0CMUNn2HUGEaFYVSaF0aNSMTw%2Bg17rREIhKMhtxGlAXMFsDliGEcijMGkxIIkt9RorcFbpzjI2DifOhsOh2nAWWvjLD9pwE926NZqzD4tU7NmvOYnnlxta5GTIzFXg9kwJj7m0n%2FxCRsGnxBXJcJGuNbweAxvyGAu3ljQzxIk6I0ViRmxSJxF51cwEfeFQwhD5m2iXBQ0whEjFOPpYsF8NKQVz0MoavgihodT4UAiWGtEaoxotdkgHo3Fwv5AnPWN8xhFjHDAfNjiYYYYi%2FhjEZ%2FBWMxhhRMJ7hY0T8WSNwoGDB6wYMjw%2BA2X2%2FB5jLCXa8MhT23A5TKilUaYdak1DDePm2H4wuYAzcmy3kFmGI8EguEQi2P%2BcystLZ0%2Ff%2F7KlSurq6tZo7q0rVXQtwiIgAiIgAiIgAiIgAiIgAiIwBEIWCYqDbBJLSuV72AwaFmvlpHLEUvi4Jvjppkfjuzfm%2F7Cn1%2F5xT339h81odQVQMdAq0BIMK1oXjVHTRM0GvBF%2FL44UgMaRxDFIh6we8vzivZu27Fj65bCwpzc3H2VlYWRiCsWc0VRF6Ie09CPmYa%2BKURgFyeNXYx0zMMwikg0hlEdRmIIBaNY%2BtyFD%2FYsdr0pm8Sw%2Bc2X%2B8mNYZhSRpCRIVD4jLA%2FkeAaTNCI04hyM5eRcKKexLBI0TIQQ7BtUTwwXc3e4rFwJBpCEAGO1aHFytpPGfiY8%2BCyLHoLF%2B0dDseq5SuGDxi8ee16rxPrFgAxTGhvLOSOoS%2BEg%2FGQL%2BwLRZmHLxoOII2APxgOBNGTzEmjakTMkfhMCSMeMVwJRIxEqZGoMhJIIkYwmPAHDZcn4XKxXLWJcG08ho6BMBCMxlOrxn3ZGDBH2KyfzILN2rfO8s0RGtDSWne%2BWWs2DmJrM8dUJyn5gh2fz8ezkURuqgtoDFHW6AvdgnuYOoo5m%2BTHUjMO0TRi6BwRt%2BHxGl4EqUg8kIgmJalQNB4wSYCfFaUDRhA2JQ165MGImEoBikSYB8DUMZDADuoYrKClY4QjvnigJFJbFHPlR5w1Ub8pxiDBwScUjvMgIFPEw4lYOB4NxqLBOCKTKZ1EYvEgzzdXB33upKgSMpWnYNBVUpK3dVvFvoxAyQHDW4uOEYv6%2FfEgj6zDiFcZcYQmVpp%2FCIgrqCwBV7iisHLPll15Gdnu2lr%2BDfn9vqqqqv37948fP37WrFkIGia9L7bUimhHBERABERABERABERABERABETgcAKWxco3p7BVsVstc4qfmLSYqGzscNw6yw727N69ezMy9lU6ykuqC0dMGXn%2BFd%2BxNbM99MIza3Zs98SiYWz%2BGEZhMBbCpMZeDUVjvnDUH0lgW8ZcXm96ekb3Tt1fePLZJx954vGnnnzmzy88%2Ftwf%2F%2Fz26xPnTynzVnhjnmiCt9d4QWClIikkDfq44feEqqtqKypqiotLCwuLvF5vOBLBgsY5wPS7wHHCHCpyBm4KCd55I3rgvGH5J%2FBWHNPUPGk6bPDmPhyJR9AKPPGwJxHBEcTLq%2FJohMmi3ZiSSCQa9QdiNE4gZHAB%2BouJyNoAYhHjJzsYoRaiFCWrAac8Hk9WZuaW9RuL8wtDuIskbfAQ7hZxNAtEjDAKBh4NiVikvLjIba9JMARezTORRBy7HJZJZ4C46WARMCfGjKoNo8ww7BwIBRIen4Hqgprh8%2BLRgT%2BGMxbzoQnghIIDAxJQch39fn95eTnE%2BMk6Mjzrm%2FGwWZOy5sIUmA5nTRTJHb7RMaxvhBp2rMvZp1trypbQASJ0BsvVAtqmxwR9J7vn%2BBfqBYeTN%2BW%2BfNAmkoITl8bigWjCh9%2BJ1%2BcoKsw9UJjv93jQwWJIGXQXNZwOV0VppcPhjCBccDGEkLJw5jEdKnCuwGci5jZiQUv1YBFxv%2FGbeoUn7M2oLNhVnpfvqXaZ4KP0FjOfl0goiTiEZIaQkWAlEEPM54fu8BPyRYKekN8XDiIZcQQXIH8ktGXXjr6DBowdOzpj29aow266DOFjEw3nl5emFxfuLSrIqa72JmWzUCRRXlYza%2BrcN15989mnnvnzcy8OHTp0bVraipUrFi9ZnJOTs3Pnzj179jidLBqETC6wZUutiHZEQAREQAREQAREQAREQAREQAQOIYDphClqmU6YqBinljHFQbfbzVtjPAo4iFVrmVrsZGRkdOjQoX3HDgvWLNhatLXXlD5nX3MeOsYvH%2F3N3NWLvNGk6RjFm9%2BFYR0Ju6ocB0qqC4rtB4qcpSW%2BmmKfI23Xtj%2F%2F%2BZUfXH3jFZdddfYFFzZs1dLWvEmDM1r%2B5vknlmdsrU6EcD0gqAAVA3UiTgiAL%2BQurlw9Z9Hgrr26fdbxs08%2B7dS504IFC3bu2pWbm8uwMQEZcCgaDibMABXCTxyGUWUGX1ghGOY%2BR%2FDDQJHBsEVQSUozmLFIBqaJyncE%2BSCEX0QAqxTXCj5Y%2B5jRgQghCqa1j2dGXXuTfdMgTzo5AJABAMdiZR2kAcfNLjCcsdaRFMxYmJhlJuNfwE3xvnA7HMsXLuzZufOw%2FgM2r01zVxHpgqcB7aIMCVPd9G4J8Xaf0ZsxI66klEHwQhCZhaCSEH4juAoEowkzIgI3AD%2B6iBlSY6o73JRR7du3b%2BDAgYsXL66trWVI5qiSG%2BgYduqRqDtsrrLkC2QrngGeBEussHQMrqIDhBG73Y5WY%2FUQwS8hQeCGOUjTLMcc52PuIRugLCVYVq%2B5sqbggJCQdK8wFQozJoQ4jqjfCPs81WVrli%2F5%2FLN%2FfvzxR4uWLHHUYuMTDBQpKSydMGp8h4%2FbTxw9viivAM0J6SqcCAViuIIgRHEHU8FwGbijJHvmMpw0gqaO4Qp6M8sK95cVVgc8TDiBwxCaBT4fsYQnziVxJmC5%2BiAXmYoRsJOzcEci6HJQdSUYuUm%2BPBpKy8kYPGfamHmz0tP3RoguiRAwErFXVS9cvHjC1Gnjp05btGJVWY0Df5hwNFFSUt6v36Drrrvx9NPObN68xc233vrm%2F%2F1fn359J06aBFLrmbHWIrUu7Fg8v61vnmHGZsksaWlpCxcunDlz5vTp0%2FletGjRunXrkF84W1lZScuvN0ikGxTIr3etrhIBERABERABERABERABERAB0xRPvgjGRMV6xYzCYnW5XDt27MBs2bhxY0lJCccBxSns1smTJ996660%2F%2F%2Fntg4YPyCvNXLtl%2BZ2%2Fuf3Syy%2F5xyd%2Fz8rJNF9xJ216Ml8Eo8HC0sJ5S%2BdPmjN1yvxZ0xcvWLtze5nHXe31rUvbMHrIyH9%2B%2FOlv73%2Bgxeln2Jo0tp3S6JZ7fjVp6aLqkGkdETtA2Irp3e8LRGpdWdt3tH%2FvvZ%2FeeOPll7T73uWX%2Fuint73y%2BmtdunadO3euDzcD693%2BwVQEZuyJZXVi7CNf4LrAh30P%2FZluGUnjGvua%2Fs2PGfmCTcx30uXfjQTjD%2FndPo8%2FRNRK1I2mkbQtsTrZrAeGAykRAHocx5zfvXv30qVLN23aBCWIfdHAostsrJuZ7%2FqTQRK8fCdewb92%2Bcpf%2FvT2c9ue9p3Tz3r9uRezd6fHgnihMCoMWjI1IBmYdjeODqgTSARMjYQe2NQAMkNOAjiQ4GQS9IZ8zgSRD6aCYaoH6BhJ5QUVwlqyV199devWrYgPlhbBRKwVZ4ch8o1BnZziwfQXKBhZWVnkcCD8YfTo0cuWLSssLOTZYO54YuTl5a1evZqep06dysTpNhAKumNhj5HAc8Ts0JQokklRmGfSTYJcEiSRIJyHvCfJGTJYYi%2BYbTBuppAgfMafy0J%2F8Pfrrrzq6iuu%2BvSz9lk5efTh9vg3rtv85KNPXXbJZc8%2B9fSGtHUBP14nBB1Z%2FSBkmdoFCgY6BikyuJ0ZWGTe3Yxs8oYD1T6XJxQk5Qc5W8zUF2bYUKw2HHZFcGph7eFMbAlqiJl0BSeNiJnPI4GfBtkx8NWpCfrskUCtEa6I%2BkrC7opIsCYS8gQDcVw%2BuCYYLS0smTlt5piRY0aPGD1zxuzc7PxAAFAJrz%2BwZNnyB%2F7wUMs2bW2NGjVp1uyXd%2F967Phxu3btsrxZrCcKXBb85INmPWL%2F02%2FuywNcXFyMqxUKIarXe%2B%2B99%2FTTT%2F%2FqV7%2F6%2Fve%2Ff8EFF5x99tl8X3vttb%2F%2B9a85ztl%2B%2FfrRMj09navQPI9y5EyT9tOmTdu2bdv%2FdIa6mQiIgAiIgAiIgAiIgAiIQP0igA2CfYrpjT3FPhYWuQeXLFnSu3fvUaNGbd%2B%2BnZenHMfm4kXtyJEjf%2FCDHzz04B%2BWzZkfrnZ6CsoWj506Y8iY3C3pUWcw5kUTIJIk4Y3Ey1yeNdt39R05pkv%2Fwd37D%2B09cOS0mUty8yrJN%2BB3%2Bt01juryylkzZn7%2F%2B9e3atX21LPOffqFV3fuyTKlFIxarxlcYubTwJmBDAy11YsWzPjnp%2B%2B9%2Bc7rb%2F397Q49u3bp1eOzDu0nTZxIHAK2KjY7ljvWMwa5pU%2BYlq1p%2FR9MJIk2YprNydf%2FppRBz%2BYHaSAYqyXlp5lWNE4qhXjQH%2FEVlh9YlrZ6xYZ1hRUVHtxUyKWA4JF0YzBN9OTGz9QOZIqKij7%2B%2BGOMvt%2F%2B9regw64HGt%2BmD0YsEiA2AYUn6SiB3wLShOldkUiUHSjp273npRdc3NLWpLmt0d0%2Fu2PZ3IURbzARxEZnSmgy5NMgoMHUJYiw8ZD1Iek84MNMJ6LE7U14Cb7Aj8SzO3vfvpICL54zZOnwY%2BWbS8kbc9IvIET88Ic%2FfO2113i3zvt0jGjEDSxKGrAxC%2BsbS5bwE9rX1NTwMPDdvXv3yy%2B%2F%2FDvf%2Bc6FF1541VVXtW%2FfPj8%2Fn1OIGG%2B%2B%2BeYNN9zQrl27733ve4888sj69et9wQCOEe6k64UZVQLbEEoLHhGmooHUgoLhMiJeM7YHFxfylbAmfhYALcaMnCGzhTdUsG13n087%2FekPj33wf%2B8tX7rS7nCZASLhaFlZVc%2BefZ977sU%2BffqRToUUE0HyiJiRSqaHDUuNdgErS8dAlLCeBjMhbBzpglwiZB8Jm%2BPh0XJGqoqrNu%2FeuzUnq7qWwBBTCEoEvfEgQouVLoMnBbEjRLrRYMTrj3hKqos37FyfW5bnjuEBg86GXkQoSgJfGEaNahL2hqsKy2sOVNpLKmvLqsO%2BIFFByEQV1VX9hw669KorGjZramvYwGaz%2Ffbee9emrePBgD%2Ff%2FFvjb4n1IKW%2B%2F5d%2FXaxhsKBk6nj%2F%2Ffd%2F97vfXXnlleeff%2F6ZZ57Ztm3bFi1aNG7cuEEDc%2BR8s88RjnOWNldfffW9996LpoGrBj3wwNPbEQbPBBFFu3Xrdttttw0ePPgILXVKBERABERABERABERABERABI5AwLKesLnYaIZJi1WOrFFQUIBLBg7k2LwcsTb0jWHDhmGtP%2F7Io5uXrDUcMaM6Gi90hTIrE2WU1TArf%2FAmm1ScmGf2WKLI6dtXVJ6eX5qVU5aVcaAou9JPezfqhBlXwXvxmoqyCePGvvW3%2F%2Fv7Pz6ZN3dJTYXHLBaBHYnngfkdT%2FgwM3lhHnA4S%2FJK9mUe2JdRkpNVVjRv6aKOnTuNGT3a5%2FFi8pspITChcJfgmw9TsT7WETSD5AFcMlzYnsgCHCdwxe6uzsgu2rHLVVQYdFbjTRAIu9dtW%2FfUi09f%2F8Nbrv%2FRrW988PesohJiNriCDVZYaoCCkvWTI%2BxjweGG8eijj2LrXXzxxbyqRhBIEkUrQVEgPgRPELOSiumVgdqC1MKAEobH4ZwyduJ1V1zTsnGzlo2aPnTv7zev3YCOYWosSa%2BCZIgNfgKmZQ4zDH7LxmaffJdm6pGAv7a2ate%2BHW988H%2FvtP94yYqVO9K2Z2xOLy0oYVSsmuVW0aNHj0GDBiFE%2FOIXv%2Fj973%2BP0YrTSHJO5qRoxrzQKObNm4fWYWVsyM7O%2Futf%2F9qyZctmzZo1atSoadOmf%2FrTn5BBEDpwg7n55puxbRs2bNikSZOLLrqInjOzMsnlyoDxUDg4XNwVCOKgCkwyd2qAuBGDch3mUauEjBnrY%2FhJY5HUMUxto3hX5oju%2FTr9%2FbNt6zZ7XKgypMBAhUjkF5e%2B8tc3r7z%2Bhhdfe23b7l1%2BAkpYiqSCkcwqagaq4I9BtRv8MXCisEKBrAkic5CIJIHTR1LMyt6U%2FsbLb%2F7orl%2F99P57hw4b6ikpNqiN4kdf8ePWYnZDhAtDSVBExltWWbBh65qRYwc%2F9Pj97Tt%2FvD93TyThD8e9obiZ%2FMWLxsKdrMfJUsp46lgXxBfz31F4f07m2x%2B%2B3%2Bqs0xqc0qhh08b4Y9zzm98uWLjIy0P7BXaen9QTZT0z1rD%2F298MgGeDVeZf9GOPPXbdddchTSBTsKxHv9Geq1C0nnrqKbJ%2FkLyUPs0n%2FLCNg2VlZTyEqKCnnnpq3759D2uiAyIgAiIgAiIgAiIgAiIgAiLw7wlYxhTfGFPWhknLDnElWOKoFrwvttrwZp9THJ84ceL111%2F%2F8EMPz5s1r6qoinKppmZRGzXc5GjEVDSLbpIFwhsniIBX5OaLbdOqwdbDGcIsfsobePI58Ga%2B0qyEGa91Oku279myNyujstbpDyXTVWC3W%2FqD%2BSqdQhbBWMQTitSG4rVBw4fnhCseStuysU%2FfvpPGTwy4fearf9PUTyoY7GBLmq%2F6k2KIpYdwMJn0gDCT2qQUYMY9uINbFi%2F%2F8OXX%2Fvzgg28%2B89TSOdP83pr0rJ3P%2FeWFVuecappyDW23%2FPyOeUtXebBczdCV5DySOg%2BIsNeQAgDCcfZXrFiBJwYXoWMMHDiA7AEWt0Aw4PK6yOdpChMMkU7oBjEjKbygceRl5XXv0uOPT%2F7pwQf%2B0P7TDum7001HCyYUIvUoqUIiyDQOv7%2FG5fOEktJPMlzDTEZpJkM1821UO6tmLZl9y50%2Fuejqy%2B6774Gn7nvsj7977LUXXp40aRKaA8NAlSKrCSN86aWXUB7OPffcXr164UBirTUNEDHY8NMgoGD48OFWGg1WH7njlVdeue%2B%2B%2B%2B6%2B%2B%2B5nnnlmwoQJBAXgkzN79my0LCaLxME7ejrs0qVLQWFBmLwVBOmYAkPSH8NUWsykqabXQjKiAzGG4ZgRG6bPDXoDMSisEB8zgIZFLMkuGDdo5IAufUrzS3hq8FsJRGJ2n39x2tof3vVLW%2BOGP7zrjtnLFrsChNeYLjJEqJjBNGYMiRkahJSRrJNqimE8BaayhdMNihAj4ZFwxT1FjnH9R3%2FnwkttjRvZWjd%2F5oXn96%2FfnOARCuNHZCbZwHEjGKHsboxqMjll%2BT2H9nnyz0%2B%2F8f5bTz77VK9%2BPfPys83QE9K%2FRFiXL4KVrAeP%2Fvlw40Ak6nJHAl4WsNxROW%2Flor%2B897e77vvtr%2B%2F93R%2BffqZzx87r160P%2BJkyMzY3yPNtSRnJdWDU%2F4uNZ2PMmDHPPvss%2F5xRq8wH%2Fj%2FYWrduTT%2F0Rp%2F0fPgE8MQYMGAAfkE8M7hzIJ4c3kZHREAEREAEREAEREAEREAERODfErBsbb5pyTfmOSYV8QUEsPOGvWPHjiNHjuQ1PSIG0QQYWVjEOB4QIP%2F5559v2LI1p7jU5fIFK52xkppESaVRVmVUVgUrS8I%2BezCOB77pyo8da9bHJBdlMGQEAoaPF9%2BIGOVGpMCIFhmJcqSFQNTujbnJEol1R4lR3P8xP03pAAvRHFYoGnGHw45QpCaQcHuNoCsezC7IXbxw0ZoVq%2FDhN%2B1VXoCbVmvyg8sCIkbSOcRMJ0Gn2LSIKMkMmUgZeGXgG%2BGutA%2Fr0uO7bU7lDXTbhg0%2BfOcvubl7ps2ZdOGVF9maNyT1aJvzz3%2Fs2RfWbtzhD5Ab1BwIlEDEDjTIHbFy5UpcVtgHC5kxnnzySV5q%2F%2FGPf1y1aiW%2BEDQDJjqG2%2BOkDkog4HXW2v1uMkNisBM7EyMAhDHDtaCweNrMWa%2B%2F%2BRZ2bo9evffsTcfOJVlJAGcKI1QTdG3Zt2f5%2Bo3ZheVWTVtsdezlBC4J5Jag3GegdsriGdf99CZbU1uLZi1OtTVraWvUunHzF55%2FnoQYDI9hoEchXPTp0%2Bf2229%2F6KGHCARAabFOmYCTITOZmZkzZszAH4N0B9bzgO1Jy549e5IEg4QGlgsHAhcBR1dccQUKximnnIIzxnPPPUdcidfjiZJ1gjqwjMpUlpKCiykw4DqBlJFUMxA0zJwW5CalXirahRmfYUai4IBiDsKoLq9Zuzxt%2FvT59rIay5HFH4kVVVWNmDntyttusTWx%2FeBXt09eOKfWxwiTOoYZoYJ0AQfuxz3YQ8RAKDEdV%2Fjg2IJzBD5C5mPgjjtyK%2Ft16tPm1LNsTRrZWp3y8BNP7FixPuHCAcbsgBUmKMcVoSYvJW4T2w%2Fkvt25%2Fe%2BeemzAmJELVy7PzMouO1BasD%2FnQEZuyBeiDSVWHQnDx4U8e6aPCZ4fiYjdWbQ%2FY%2B%2BurVWO8qARckQ9%2B4qyZy2aP2z0qH%2F8%2FR9%2Fefm1%2Fr37Zmbst4RBFDAeHmhbagYrxQ4%2F%2F6sbN%2BW57dSp049%2B9CNW8D9QLw69tHnz5vTJHwcrGC01C54ZhIsf%2F%2FjHOPZwDS4cI0aMSJ3VjgiIgAiIgAiIgAiIgAiIgAgcKwHsNzbrXTDfxJI88cQTVmj8NddcQ6kCvA7okzZ8E2ZC7seMffvIOFBRlLt93uyMseNLhoyo6TMg2HdQZOgwd%2F8%2BoUljoovmRJfNjy5ZEF2yKLZkYXTR%2FOj82ZF5UyPzJkUXT4ktnBiZPy68YHxo6dTAhsWJ4sy4u5KgBN7Wk1ECC9GqH4EHR1LJMJ0PDDw%2FzJO8KydRhJn5obqkvOpAaSIUj0Wo%2FYERya6ZTNOUNXgtTukOyngGkvkZzHf2UacRp2gpCT9dJI6gHKfdMWX4iJ%2FfcP3l559zw2XtPvno7bSNy3sP7dX23FOxlxu0anbtrbf2HzrCQ7YKU8QwoSY5meoEHvJz5szBewH5AsnC8sf48MMPP%2Fnkk1WrVmHvg9FqzAyIL3A4qpctWTR0QP%2FZU6eW5RXE%2FeHaSntOdn5JWSVpFpyh4Jb0PS%2F89fVLr77ynvt%2BN3nKpFp7dbKKKTwC1Y6KJWtXjpk2bdOuDLQeMzVDSpyJkgEiVhv1rNqz%2FqFnH7%2Fmput%2BdNMt111w2dVnt7v%2Bsqs%2F%2BvBD0jBaGhQiARsrS0gIGTsZP7Ngww3g4DgTCYaNZlVRUcFx6yA%2F8cHA5OQglzMpjO41a9bccccdp59%2BetMmTZAyfvazn%2FE84MJRUV6euy%2BzKr847gnibQIxekFVMNGxKgga6BuBSDK1ZzK7J6E2lr5hqhBmoA7xI75gtLramV9Q7CV0JByt9fn35uUOnzzxlfffOffKy5qfc%2FrDzz%2B9dstGP%2FlM2DD5cbZAZDIFDNObAuULlxe6Y%2F0t%2FwiULU6augaHQOcKL563%2BJf3%2FOaSH3y%2F3S3X%2F%2BPT9gX7D5h6BzoYmVSRlXiQGF3ycrs%2FnLZ119xFK6m4E%2FTH4sFE9o6cqcMmzxk901PtoQ0JV51xw0P1VyKNEO18ZP4gUUbhtAnjunfvtDdzN%2FoIsh1ldLyhwM7du17580vXXHr54394ePmSZVZuTHgiMbFGfIOX5UBcsp40c4Lf9MYi8u%2BX%2FC141yAmsHyHKhH%2F8W%2FLPwdpiyzB3Is7opghYvCcWLcj9urcc8%2FF7eebnpz6EwEREAEREAEREAEREAEROIkIYLdibmBDYVWxEV%2BAx8V5552HrUppEnwzSPmIOWpZsjTGbI%2BZ%2FhWuffMmfXb7zQOvuWLxVddsu%2Fh7hee1s198WXW7S0svbnfgokuKv%2Fu9ssuuqLjsyorLrij53iUFl16Ydfk5Gdedk33D%2BeVXfbf44ovTv%2Fe9rTf%2BYM1dd%2FjGjDSK8gyz4EbAE4s4jASCg%2Bk4Yb5Fp6wlL%2B6T7%2B4jyWycZJvA4AxFEj6MYrMmhdeHh0YMgcJpFl014xYSpqMDRqWPXAfk6vAYvmrDV4Vvg5mIEscMMywD%2BaM4L3vU4P7PPfHQ88881rtP57lLZn7S7Z8tz2qNjmFr2uiO3%2FxmybKVlF3BzYNEn9bGY4HJuWHDBhIVjh07loQAcGPjBfSQIUPQfMzcnskNUEQMQC3g9%2BzZue3l5569ot0lj973wIp5izzVzu2btg0fPnr%2B0mWOcMiViFaFfbPXLH3jw3deefPVKdMm1FSVmGEXJGoIusPO6gMlBTszMw5U2T3kxLTiZSw0IaJPzLQOZQHH9GVzJ86YMnf6rCHte%2FZ497MxA4dRbtXyuMBAZkSMh8Vl7czlS770T83Ietat9bUa05L2yCDk%2BmAjlsRqg6BBdADpPYlPsd6to3QhdCCMbN%2B6bcqo8evmLgu7%2FCgSDNBuxKrMhBWmGGWWXCE1iRNjP2F4Mf6TFUtQO5Llbfgv1jsfbzRW4nDsLympDgediVi%2Bs7rfhNFX33bLOd%2B9%2BMwLz7vn%2FnsnTp5U66g1E6aSa4RngIotJPtEzTC9XAggMSN3LB3L0iLM8B36RdrgE6I4a6yq2r56w%2Fp%2Bo4cNnjR2R0Y2MUOmzwjPEYoIHaQUkaRjR5j0ny5yayR1DY%2Bxf132yG6jx%2Fee6C5jAobbiJMPhOfN5Xbv3bFj85o1Jbk5u7dufuOvr%2F3h0QfXbEpLCnDc34xFcdY6Fs%2Bb369Tt%2BnjJ1WUlkGYxwb9h5Cf5cuXoy2wXvwEO%2BvCKb4t5t%2FUN%2BuLpEBc2M9%2F%2FnMCSazsnUfWLay8KHhZoGqy4bxxNNIHPdP4rrvuIsEsk0KyILEn%2BVWse3GW6ieKK%2FmmllX9iIAIiIAIiIAIiIAIiMDJSQADB6MJAwqzFzMW1QJj5x%2FJjdx9vJTnuGXzWt9JSrzZLV7W96OHWzd%2BzWab2bhxUcu2oUYtI7YGUZstYrN5bLZam81raxiwneK3NXbbGjgb2apb2Era2Mpa2bxNGscanlrT7IxdzU%2FbculVkd79jYz9RnlpwlkTiUcwgfHYJw1lMgaELJ7JnI7EI%2FAW3iwWEY34gwk%2FP5PGKYpFLIG%2FSKURrzQidiMcSJj2qhH2GyGCOJyJhCNgVNcaVR7DzSlfhFoXnhgZHUnPUV40avjAO%2B647bLLL776%2Bstfeuulf3T6%2BOxLzm%2FS4pTmbVu%2F%2FNrrBQVFMaSScCIWNqUe6%2FFAqVi8eDEpJsiKmXqrjqWPlGH5P%2BChATHag9TMGBEOVhQXjR069B9v%2Fm1U%2F4HFmXmBWu%2BalWu6dOs5avLkYg%2FlUmJOI5pbW9KpX5frb77mkYd%2Bu2XlIsNdY%2FhdCZfD8LmiIU9twOUmPMRMWZkMkfAh2cTwAaASR7KeabjSV1vjcnjtTldBpSO3zFlh1n7l%2FrzrZ03ZwXC2NAps5NRcDn%2FgWWIa0J7LmQ4PABuzs5a%2BsLCwa9eulLRI2bOXXnopYSYEDpQUlyyYMWfFjAVBt1mEoyIRLjOipUYc7SgZOHFQCkg4o0ZN0EylgmLAdPCwwT3m4EoauaUlE%2BbPGTFnWpajCmGqIhZcsXfLPzq3f%2FXNv7777jsLZs9xVlYnFYcEMTXhQNDtcvsdtaanB5lP8aVAtkgmfEW6sFwwqFhiRhshJYTieE2YAT2JqCfoqagqqbZX%2BsP49pjnaYbmRG0YqJoyCxPgg9%2BL5Rtk7bgTOSt3Dv2419iOgzzFdkYfDLpCARcZa%2FOy973wzFM%2FvPGGzz%2F7ZMHCuS%2B99tJ9Dz%2BwfMNqHECo68odzWo1hKvU2HN27N6yJi03K9vj9bA0ZCC5%2F%2F77b0xu99xzDylNiOqq86%2Fs8PX5mkfok0VEefvJT36CLnEE%2BcLSLshiccYZZxA9REaLX%2F7yl7%2F5zW%2BowfrjH%2F%2BYsjXnnHMOMgi6hCVkfVVXtLnllltQRMkCiqyRaiYd42suoS4TAREQAREQAREQAREQARE4jIBlunIYa5dMfRQyyM3N5T07Vq1lCLPDZprnCAvxSOH%2BTe3fePAnLWyP2WzDG9r2NG5c26Bh2GYzGjVNNGgUsDVw2Rq4bQ39tqZRW%2FO4rVm0YWN%2FY5u7ic3TtEGoQeNwg5bupmcVt70g%2FaKroh17GDvTjdIyo6bGINYhYXpNIE0kfSdMM5OX7Gb%2BC2Iq8M0geASjlZgTYgsw7Ml%2BGUvYE5EKI%2Bw0i2gGE1Gf4XYSAGPU1hiOCsNTYgQLjSifiri9tCJ9Z%2F72jd5yfnqDPvv6jave%2Fcfbv3%2F8wfsee7Dn4H7LN6z9%2BPP2Tz77zGt%2F%2Bcuc2XMCZOPAxOW1OsELX7wfx9Ik0SXpBfCcB5pFDKufBBQY%2FsRu7N69m1wTZvVSpINIOBLwhTyuyqLCgoz9NUUlcW8Qf4x1aRt6DRg4fvbM8pDXZSRqEv59pVnvffS3ti0aXnvBmdN7dY4WZBkOu1FRlXBip7vDMS8WMbY1ngPmxPkEE16HK6%2BoYF9JTrGvys%2FcuZ%2Fbbzo8MCjyW5JhIxBgyRg5q2bJGuywmqm5HPYUmAcslYMLWf2NGzeS%2BwK5hq7ogVIm48aNoxgrOoZlxt50002oOjBB7%2BrcocNfX35lw6aNdmKOEoFyI1xl6hhm2hMz6QO5KvyJuDMUqHQzeFwnOI5CgGBVFYtQm4SVzcrP6z9uRMcRA3ZUFpQZwUojWBaq3V%2BYnZmenrt9V4AcLMgTfIgsCsdKyytWbtywcf2GqIc8IshNpnNOSsdAoDBvaukYZsxIAjmK%2FwYTgZAfpD7zE3LGIjVGohaXCjP3rM9huOwGwB1uw%2B42ql1GpdOocBiVDqPGaVRW58yc0ffVl4a8%2B5Y7c7cRqDXdfDxV0aoDK2dN%2BsGV38VUf%2FCh%2B8ZPm%2Fh5z66vvfO3tds3B8jtgn8I%2FJPuIp6q6lnjJjzz2OMvPvc8j9DKlStffvllq7wp%2Fi1IB4R74OqD4nTkBTIX6Rg3QjwIEULEOHJCDMaAgw0ZVKjBiscRwVNETq1evTotLY14IiKSODJy5MhPPvmEPDAsfatWrY7g10EtG3y6DpE7pGMc49KpuQiIgAiIgAiIgAiIgAiIwFcSMAWKZIAJdi6b1c4yezme2g4awvFoTuamj95%2B%2BEetG%2FzBZuvVwLapgc3ZqFG8ceNokyahho2ctgbltoZVtlPcDdv4GrYJNGgVatTC35jjNofNhr7hbNCs8pQzC9peuPuCK8J%2F72Cs32KUVBhOLNxgDAOccIzkK%2FGkzUq4AOIF7%2B%2FN8hxRAgowTjFTTTM4HnT67bXO6ojfbhB2gqjhipfkR9ZvSCxZYSxbYSxeZCyfa6ydFd88J7xxUdnsqav79FjQq0vemqXh6gM0dnqrt2XsWLx%2B1dKNafuLC2sDgZyCgvUbNu7euaumrCJKXQmz3AaJF0wpAAgYmNjsCxYsoJIpsQAcARQHsROpSYofC%2FUaRowYgc2I%2BV9dVRWPRSnRmQgTJJKMeiAbZTjur3VnZebMWbJ4xbaN9njIQcBL3J22Y%2B2Lzz56ekPbNa2b93vhj%2B6li4yMTGPHPiMrx6itjEVdxMkQEYOlbqo33oSnzD532sy%2FvPGXNz95b%2FbqRZVOO%2FZv0OVLuAi3MduFgiGUB8ZmbQwVacIa8Fc%2BBMkTrD7N2HAYQLggbwY7lpMJMTWbN2%2F%2B6KOPiBrAKKaUCXpOQUEBx7dv3%2FbkU0%2FccON1oyaMqiR%2FhOkNE3YbMT5W8AVKgsvu3Ldr37aN2z1uP4EmzoTpeEOm13Iz4CPmtzvWr0%2FrNqhXp%2BG9d1Xnlxv%2BGsPnT3pamDJEWXU4p9g4YDdcUdO%2FIhLfsntPjxHDx06cGKz1mnErfJI6BkuCP4ap5cAK2SepaKBFUZWE3LOkio3SscdjOF1GbUHcsdOo2WaUbkrkrDF2LUtsWBBbOjM8Y0Jwwsjg%2BBHeEYNq%2Bnar6N25clD3kiFdZ73%2B5Ls%2FvvzT392SNb5XdNMSI2%2BvYS%2BO5uya1bfTDZec16SB7Y5f%2FWzoxJHz1i4fO3vmvqIiUquYuhPDQMsIhgv2Zbz%2F17%2B0btrk3LPPxt1pypQpuCsgHeAggY6Bh8MjjzyCuME6HnmBjvUsjwFPLA4VKS%2BalHdEagdFgoqozz%2F%2FPKVRV61axZqy4l96I4ZH2Roe79GjR7%2F55ptU8qWOaqqff7sjHeNLqeqgCIiACIiACIiACIiACIjA0RPAPKfxF8aume0Tqyd1kB3LB4M2liGPeUtj0ipGyguX9Prs6VNbvWWzTWnUOLdZ61CL0zzNWpW3aR247Wbvfb%2Bt%2FP0DNQ8%2FVnv%2F45k3%2F3znNTcW336n55EHqx%2F5TeVDd9sfe6D2qcfLnnwi%2B7FHs155JThpopGZES%2FK9x4ojJPwMOlDgFUajPMOnVwWRBIgB2Cjcu%2BYP0oEQNI49Rll%2B4umjZ08dOjwDbu2VQYdEV6pVxzwLl504IN%2F1jz9uv9Pr4Qffyb45JO1zzxa8eoT%2Ba8%2Bt%2B6ppybed9%2FEZ%2F5YOGNyrDg3EbCTfTJgRB2JSInHlV9ZVW53Bqn0SX4HHDDCkTg5GiOBOGU1eJUfiVgxGngmUO6BQh5Y9JbJCTQyD%2FDCGvkCHYNvkoogaxBtgfxhll0hQwUv2UnmQOADekyIypyevLLSAkeF03Qj8Qfinoy9G9999tF2DW0%2Fatyg%2Fa03p7%2F7vr9L78Cn3YPDxiSy042II2L4iZMJ8Ho%2FSOxDrCrvQPfPO11z7dU%2Fu%2FsXI6eOK61M3ov%2BCdkwczsc9LtgsVi4lJSRWtyvekJoyRJbigfzYrOeAY5g2FKthqQHlEFhgjhmYMwyR9LAwiQjY98HH7376JP3L5w3OVhbYbrGIOAEcY9h9ULhGNVko1mFeaPGjRs6ZFh5WSWPnTeWqEzEio14XsCzZuO6kf37vPvGq48%2Bcv8rrz2%2FeXtakPq6cR%2FeNRFnTda69XMGDBn5z47Tug%2FYsXhVoNpFlpS0Ldva9%2B07fORoPzoGYgUPCuk6k%2FErBKqYjzX%2F4zg%2F8N8xg3co1%2BqNknbFXRlK2xycOi84dkhoWIdA%2F%2Fc9nd9wfPCC442nHC88XPX474rvvj33pzfl%2F%2BTmjJuuWXvpecsvOWfV1Rctv67dmAtbdjrF1u%2B8Uzb%2B%2Fvbivz4X6tfdWLvU2LVhY99OP734nGY22x133jZh7vQ8e1VeVY0jSCZTM%2F%2BoOTAWPRAqzs3u2aXDbbfedPevf4W3w969e5ECUISIzUHEQNAgtGTy5MloYuY%2FsW9oY%2Fm2bNny1FNPfZWIgbBAseBHH30URwu8iXjIraflCPe3GvBIsPqExqB%2BEG9ylMVbpWMcAaxOiYAIiIAIiIAIiIAIiIAIHA0BDFXLKrFMJ8totaxX9tkw1a02mLEEGrAFSFIRChsl1ZUDxgw754rpttNyml9c2%2FaS2sanZzZtlXHVZbHhfY19W42izPienZkjR497%2Ba%2F9Hnl87ttvl8%2BbZhzYbVTsMSr3GlX7jPI9RjFvw7Pj%2FtKS0v2z50zsN6j3us0balxOYiHISBEwUyGaER34Q2AI4hhBiU2qpmKYYqKGqwOTh068%2BpIrLjj3vLfff3vzzvWhqmIjNyswbFT6L%2B7PPu%2B6sjOvqGn1HXvb88pOPzP%2F3LN3X9xu1aVXzrr2hjWPPBZesdTw4MHhpiAINnBJwLtq%2B47JcxetTtviwpXAzAzJy32iEPDwp%2FInqTsRAMwNSpj51dXVBN1QlhQ4ILKsfmx5LHpCMHBOYB97kONJFYFclpGQxxshSoU6oWaBUP4bQa%2FxmzEwhMowLW%2FUXjy9%2FT%2FuPKXJ%2FTZbl%2FO%2FM%2Bvam1deefOGdjfsfODR6JqlRrAiYrhJluGhMSEZpsoT2b15S58%2BvUaMG5FVlOMLeElpYdYICSdCDDli6g8pe5MhkdaDb4ZkreZXPRtcYq56Ut2ipSVb8ZN9IkfwIiDpQd%2B%2BfQmcYbKWsMMpGrjczo1bVy9YMrEyZ7tRUWwUlhiZhcb%2BIqOsGv0gFnQFEv49pdkjpowdMXJEZVm5VTeVIjK5Ee%2BCPVvu%2Bf0957RtdU6LZmee0uT6C86d3LNr6EC%2BWaXXbi9el%2FbWgw9ef%2BZZF7Vo%2Bb3Tz3ry%2Fj9sWrfR5fGn5%2BSNmzZr1sx5AaJpcCBCMTqob5nJLczKNQgIwYThMZN44qtjN7xe6qniBpK%2FP%2BO1dzdf9%2FM937s8v91ZBy5oXXhOy8LTmxef2qzytBb201o6WreoaXmKs02L6manHGhkK2vRuKpt85I2TbOb2fY0tO1rZss9p03Gd7%2BTeetN7r%2B8bEwbXzVu6PO3XnvpGa1feemZ7VkZtfG4N1mF1aTIs2Sl%2F8Axx%2B%2Fen7Fj2rQJU6dOLiws5OEhZwVJPt96660zzzwTTwbUDGI6KHHLSn3VAh3TcdaFf7B4TZBa80s9JQgzufrqqz%2F%2B%2BGOkOW5qrfvR34KnhWeABwMvDkSYs84665AoksNvKh3j6PGqpQiIgAiIgAiIgAiIgAiIwJcSsExd7Bc29lOma%2Bo4liyn%2BMbyIpKCpBAVlZUJqkwW1%2FgHT15y%2BU%2FWt%2FhuXuMLixqeVtKgxd7Wp2%2B75brIgimGu5RKp7s2rrn%2F1%2Fdcc%2BnVl1%2Fw3cu%2Fc%2FFrr7xQWJVL6oGA4Ygazojh8htOMlkUuQ%2B81%2FWjS2666txLv%2FOze%2B6av2SJ1%2BfH7yBOKQrSU2D3Y0%2BjHxjRADpGMqAEo7Uqt7Lbh53PaHFq04aNf3n3naPGDXbk7DHys%2BPDRuXdeOeBUy52NT43aGsbt7UJN2rmatw0v2nLbS1OX3X2xdvvuicyf4HhrDEoFRLzu2OhXKdjzqq0Hv2Hjx41pZBCnL644QvH3e5EGBuYN%2Fi%2BUNRPelGLCSjY0DSwQ1PKBqfAa4GyOLMPzFg8FgwFIngP4N0RRGKIJkjnEeO%2FkWCcKiyxEHEmRDwE7Ya7omjyuJ433%2FJxizZjzr54%2FhntVre%2BaGurdjtuuyu%2BdJ4RLIUYlrjbCFBpJeHBg8NP1EdNdWWNqyYQ491%2FJBowc4XUOjxbtuwsKy1HTbFkFgSHnTt34i6CAwD7DOxLn4TUsLkqqcCY02Wm%2FOSbI3v27HnjjTeuvfbaDh06EFmQmi8d0hJ3FZevrNadGS9NN1avjg%2BeGO82yugx3pi%2B1CirIMoklPAUBMu35%2B1K37sj5Ma1xFRjcJcoCnkmpy392T0%2FbdOyyVlNGl3cqNFPTj19wit%2FDa%2FeZBRXG%2Bm5m%2FoNvOM7F51us7VubGvcxHbZ1Vf2Hz68oLSiqsaVvb%2BwMKuQMrp1RQzKofKQmJFRpo6RrMAbiVKzpsSw1xqVCaPayNiZ%2FdCL%2B864ruiUs1y2JuFGjSNNmgabNPY2auRq2MjVqKGzcUNH04b2Jg2qGzawN2robXWKr2Wz2oYNCInyNmrsb9KkpkHDkubN97Vus%2F3Si8ufeSIxevDqz%2F%2Fe%2B93XF8%2Bd5iC4x8oPysOLiGF9zNgWkmT4PSF7tZN%2FQBUWVZ4fSsDgCPH973%2BfZJi4ZDzwwANr164lQc2Rl%2BkIK1j3FAVQRowYQRpR4lYOlxSIZyHHBXc%2FcOAAI6l74THt85hzI%2F44UK%2B5TZs2h9%2Bo7hHpGMfEVo1FQAREQAREQAREQAREQAS%2BlABGqGV0m%2Fa5WS3UfL2easlZ9vnOysqihGL%2F%2Fv0RNFAWjOKSqoFDZl930%2FI252S3PLes1ZmVzdvsOf3UtBsvD88fZ3hK%2FeX5IwYOuODcSxo3aNvYhnXT9OYf3j592eISjz0cJ%2FIBJwtyd8bdQf%2Fi1avuefDBhi1aYu%2Bce%2F6FfXr1Ky8qTaY7oBXRJGgBBHfwZp0Yj0hNOMrLbkI98nfmf%2Fy3j05reRrm2F333TV9waSws9gozomPGJF3%2FU%2BLG5%2FjaXhGyNbcaNTSaHpKpHEjb%2BNWtS3PzT6j3f6bbotOmGxUlBleB9IIkRoV4WhmSfWSlVumT16wO21vqCqQcCEUuBMxT9Qg34QnGPVBxuLAjsUESlj3%2FLRw8c1mahdJNwYMVSrGMkF%2FJFBeU0kW0JIDxS57LYkdUGboIhiPBGKBIM4eEcQJMkmWRJYu2fviK1MvumJh24u2tblkf8t2uU0vyr7lF4m5Uw1vQdRMXeqtitYWFeUV7NmXu2tvUWaWvbIiQA0WM3NIPEEcjD%2B8du3m555%2FZfqMWQ6HWcQT%2B5RgAaIVqCpCCAA%2BJAwvtbiH7zA1GlgTQfQgTwK1ZXnhTlYQflKi5dNPP8WFALcTWtI5r%2BOTJjlePdjqSAglidwt3k87Ff%2Fs4cKrfltx1f1Vj7xpLF5lOMrjhqvGsFcFK4JOquAGDFfQcJoqjicR2VddMGzGiFffevqZe%2B9882e3d%2F7JXbN%2B%2B8fslz6s7jC4utvwac%2B%2FfnPbU9s0tjVp1cDWwnb6VRd%2F0Kd7TlllxBePVgTjdrxQTE8M7HCyhjICcm6gJJj%2BGIyIrKduCrOGvYavzKj1GFVGotJI3%2Bl%2F%2FA3PWT%2F2Nm0Xtp0RsZ0WaHh6bZNTy5q3KWhzavaZp%2B8559Rt57TZfu6puy44Y%2B8FZ%2Bw767Ss09oWtmpd3qKto%2BUZvtbn%2Blud5WnW2tmm7Z62rbZecan%2Fo%2Fdiqxc6923xVBWTw8SJDEYCWhKikMKFJBOWlIFXDs4LhlktOMruF5x5hPiXRbqVF1544cUXX0RVwNeFSBCoHr46x3SEW5B49t577%2F3SAiV4YlAOlSghfEKOqduvaswscCyhykld1eLwfekYXwVQx0VABERABERABERABERABI6SQNL4Nt%2BnWzt8s4%2F9a1m7lrXFT3rLy8ujBCdlNwvQMQyjYPmifg%2F86p02LXo2bLimefPs008raN1642ktVt58eXjBBMNbbs%2Ff36Vj5zNOb2eznWazYd2cdtMP7x43f1mpJ4A4Yb4rT34iocTGTXvuvf%2BxBg0oztj4%2FHMu6tutb2VhuWmEmskbKVMSTfiTORoTIWxBdyQeIOFEGDcOR9%2FOfU5tdWrzli3%2B%2FNZLO7K3JuKuRHmuMX5M8c23VzU919ewbcjWyGjYxGjaKN7QFmvQJN74tOoW5x24%2FIb4yHGUR0m47eTO9ONq4vKNmrrgpdf%2B%2FsTDf37xoRc%2Fff3vmxatDDuoZOENGQ6%2F4Qgn%2FBDCvsdytxBhfrKxDyg2QKU2i57VwB8K7tmf%2FknHz55%2B7tkPPvhg2ZKlYVxZzOqgTI48HKFozJcIOg2fPVFdZqxcVfvGexuvuGVp0zOX2ppvaNA819a2%2FNY7DeJxPAWhRFWWr3D6mrlvvfWX5x557JmHH33msUc%2F%2B%2FAfW7ds8vkpvmpm3nA7veMmTr3y2hveff8D5AuGxDCILMAfg7f8%2BGOQt9Ma7ZEfD%2BaFnwBG7nPPPcdL9ldeeQUNhGgaDmIak8DBVGkoWsqWdNXgF0kwzOIkRmFi98rS3%2F8xv811JQ0ur7JdUXLpndHOfY2svYloDYVAvAnSmpKmImjYfYad6iohj8%2BbUZ6flrF%2B1rIJ47v9c8zTT8%2F69R%2BWXX932qV3rrnut3Nvvvf9dt%2B%2FtlGTUxrjkGGztbWd8cPLu04bV%2BJB%2FKHYScIUClAMkllhETGoflJt6RjMkEeInKhoUeGQz%2FBXG%2B4gJ2NlRvou44UPE2f%2B1N%2FwMm%2FD75Q1OCOrQZt9jc%2FMufDKitt%2F6XvqKffrLzn%2B9qrzk3c93T71df3U%2B9mHgY8%2BjH%2FwUeJPL0du%2FqXnzCu8rc4PNmoRPaVZSetWW88%2BM%2BvBexPL5hlOaub4%2FPEYoU%2FmI4peRXQIriH4hJhJS%2FhXxMw9pGilFi8PCZCtDZLIXKRboXQvi0ViVaI8EIiOvEb%2F9iwCxYABA9q14x%2Fgl2w333wzuVzwo%2Fi3%2FRxNA7QX%2FjLceuutuJR8yc3qHJKOcTQ81UYEREAEREAEREAEREAEROAIBCxLqm4DjF%2BMLDYOWvuWxcqrfCpsYs%2BWlpYFve6x3Tvc2rrJLTYb9Uo%2Bb2Qb1azJlCaNJrduufjWG8PzZxnO6nBl2YTho84%2Bp52Nl%2Bi21u3OueZvr3%2B0e09eACXA9LKPma4MZL%2BIU1%2FU26vHgJ%2Ff%2Fusbf3DbYw%2F9aemi1R4nJSsROnBcwEbFDg2a1UioOxoNcxxXDgzXRCiyYuGCe3555y9%2B%2FpOxE8b8P%2Fa%2BO7yJK%2B%2F6jrpsudu40YupCaETaiAECCkESCUJIST0FkJoIaGGXkINofeO6R1sg6kuuOHeuyzL6r3e74yU5c3ut%2B%2FuJnn%2F25nHjxhJI2nm3Ds8z%2B%2Fc8ztHpVFQu4kqa%2BjFizV9hyrF4UZGZiEMZRg3n3HyGBvhWYlYyQ%2BubdKB%2FnqE1iioUQulh0pvScope%2BeTiUQQwuMFSok0TCBb890iRWEeulhsVG1wKxt0inplPYo%2B1O9ADOcNBwzA4oUI1TyA8mKInd%2BqU%2Fzjdjdo1Mdjz7Z%2F5WVUcp07dNq74xeLx8wB122iLlhdsJ0zuDSzju0aiX%2Bon77ocdteJ8T%2BGwk5QEgG4dX1HuC%2BFkv1lQ6Hpqih8vDFM7Bk7NG5c%2Bd2bbu%2F3GniF%2BMeJdy3GEwwPwVU1bWKTVu3topp%2B%2BWECaiFvaeC08O5eCiH32QAoDJwtnjRu%2FPizF9cBd5CawMkHH1efRU%2FNXjQ4D179mACoLjGhk%2BxFIZn8HDZaADyGJmgUQYsQanryY2CgW9W%2BrRR8RsbmDBloxjzh5%2FRa1eoVq6lZhMuGpaf4If0YDMMboOpoKj8WOzFg8cPJN6NLb58ImXhN1d69n4Y1T49KOZKcMwySfhQwmtNSFSArEXH5u3f6D524ZR7eakqFwxZ3Q4L65zCToa%2F6TEgyWBDe50uCwQquEpQGRYX7FysbMyqwQmewyF3IzV13kpFs76JwuizvMDtjGgVw1%2FB993SLObga6%2FHTZqiP3GSPk2iJcVUXk1huqIso%2FVltKqI3oujy9bYeg%2Bvk0UqhMJ6HsmTiR%2BER9zqO0B96hRVoH3GZIMEBsACaUTBgscA2cL2tiAuFvMBdIpO79aCv%2FIOByaSdwiAvHdG4RGk06ZNm%2F46wwBiBJ4V%2F9R%2Bs2XLlmgOAmHyYsS9c%2BBPPOIb4BWzYsUKtMb8W3MM3AUcj%2FEnQOY%2BwiHAIcAhwCHAIcAhwCHAIcAhwCHwewT%2BRSHjrXNxAKot1O8orOCKgPVirPPGx90e9%2BHbITwSTUhHQt7mk7EMmULIKl%2B%2FC7362y%2FdoCqkbtozHz4ZNuzt1jGdX27bbd7X3z2%2Fn2HXsFYG0FgY2TpWZ3GbWK8Iq7WuSn73Rvyh%2FScfJKYqNei2oPhDDcqyA6zBp5X1xrDqUQejE4F9w4nOFGNtdeG1i6fOnzpaUVSChA4KPw2tjsbdqxr0tpwfZCASB2HcPGJlGAuPZycCJ5Fo%2BIGKsBi6%2BQCtVMAQ02p3KLW2pMzyr75ZEtW2e6OotjGRMX3bvrJl8bKazHTqNBjt8rSCxyfOHNm5cyeCMqFG8Hp4AhCWhfmbfMXbdgG1A1CCISooDu9bSo369KVLb4x4q1XLVmNHv3%2F3%2FBUn%2BinQ6OB06WDuCTLGQxZRmG%2BotTT%2BqWnakjstu%2BzyD%2FiBIb%2BCx2BITe8e7itnqLYWHI7Z4apSqO7dS9y3Z%2Ff2LRsO79%2F1JCHeqFBTqxvGq5AelFRX%2FbTup9ZtW06dNiU9PR01snf48IjCGWeFV3Bi3jP3vvJilLGDDXjjYGw4BovsJ06c2LN798kTJwry8u1WNgQFnwL3xNJQTgcYDFAIdjfbJoReIacT7EKp7frptK59KqTRKoGPifB0EomudRu6ei0tKcH1mrzKCR1OV0PtDQ6TIT2nat%2BhSwd37s67e52mJ5p2rYp%2Fqflzf%2F96%2F4i4gMhv%2BBJwZW14gndf7b%2Fyh4Wnzh%2FJKEzVOdSwFKl3O%2BF2AeICZBCmBBpJLC5YZbDUgMFk1ttBD7E6DTawFwE0LtiSgAdTU1c9Lcm0LF3xpGOvJQKfdwjp6ZnDLQlp5yfr1LzpR2%2B9%2B%2FjKXZtc79IiV8ZktcFb1WByq9zOWqoroQ%2Fj6cxF8uhWxRJeOo9c5THHJH7Hur8mv3TLoda7bVZEyLrtyIE1ejxZEdICBsPDY0DRA%2FaLIj%2FX6IR65m%2FMEnZwygAcwGJ0oHVBE9DAgQPBGv3%2BPv2j%2BzDtRG5OeHj476QQv%2B2C2Zg%2BfTpmspdC%2BaPf%2FPvj8Q3FxcUgMWAW%2Bp%2BQGF4eIyIiArTY77%2BH2%2BcQ4BDgEOAQ4BDgEOAQ4BDgEOAQ4BD4P0EA5ZV38d1TGrLRFSiOUGeBzZgyZbK%2Fn0QgII0Ylsd4j2E%2BJWQyIeulgXe7DnDuOUFTc2h2oe3egzvr1x9ZuOD89z%2BUHsaL6TQ9i%2Bbn0pJsWoG8kkKqrKS6epdW5TKZjVqDol6rMztMDmqGvQDsMFhFhtsJDQZr8IlMCoR7wFEBzSZ4z2h3Nlis9QZttVFbD0EAezT0EWYLTU6rHPqeXBhsYnxcfL5byDMQRkMYHWFMjFgh8a%2BKaOne8AutqHQ5jEhUNTtprdr%2BMKNky8Ez38xdumreiou7j1SmZDhVDS6nIbssbfKCSW1fjomMimzbtu2kSZPgAwBCAIUnHsFmYAfMBjImbt%2B%2BDS9NbNhH%2FwXKUmwILymtkR89dWbn1h0J124ZKuuojr0ExKGg4cDrWcpyB2hC0Ojpncf6Lxdcb9xxm79sCZ85QkgBn1H2f5VePUc1dZAxOHCVDmoyWRrqFSpljVZda9Vr2XhaE4XNh9burtNpT58%2F%2BWrf7stXLEVfCU7AWyCjWH5xwtj3PkURigNwFTh%2F7OMA7%2BYdbhyDt%2FQ6PagZrVpjRZsDy1%2F8xmNgYugNBhiJeiQPrPCBZadcOqc8p%2ByXjbHNWydJAuukEgefmPlE4yujH35C7z8C8YH2F7auN1mpSem2yWF7ojbROqVFW6N0KWrpkzjdnAlpTYNqJSKnb1BWSMQakXQEIYMDw7bOW1yUnqrXK2wOuF%2BACoC2AT0qbBoJOC0X2jnsbpyPyQimy%2BG2I9qGwtJUa7MZzDYLNEDguMxo4VGxzS9lz1XLlsa2f%2BkDAb8pYa1b0LAiZkjTJk3eHzvu%2BMlLilqTqs6a%2BjQ3JSWnAjOMWmuoxkYRJltBnydBklEf0yFdRC4QZi0hPxCyu8tr6oRUTy4JunvAUyEDR0sdemCIgWajV0GpgOQw6Q1O2HkYHXB99TirAHCAjCgZL6UA5EGIwU91%2F%2F79cCD5K3cxZuCGDRsCAgL%2BgcdA%2BipsP8HI4Xf%2Fyvezc9bJRtgsX768Y8eOQqHwH37of3sKPUZkZCScdv7ir3Mf5xDgEOAQ4BDgEOAQ4BDgEOAQ4BDgEPj%2FEUAVhgoX7o6gL1D1YEP1ildQyI8aNQoCcSJiohnyNiEbiP9%2BEnSc539THPEgoEldr9fNw9%2FXvzGy8vWhKQNevd2r0%2BM%2BL5e81U8%2BcmDt4B7yof3Khg3PHjYm4%2BOvatdupumZaCyhNhSiNhg3QomPP1S6bGmMYtViM5gMFkRxUCu6L2xIFUVpbnHYLNoKed6zrPvZOU912joX%2BjRsWOy3o56nWc9Lh40s4%2FtriNjM8MwMoyZExWc0Er7KV1QaJMtt1dyxeQOtzLW4lMhYRcELC1GVyVVRpysrqlHkV1vLlVQL%2BYe1tq5iy8lfo7u3IUIikUqwkI11Z5iEwCkCcAENIIPaEzXj6dOn4UWAt9CLsW3btsTERGgzUKXivKwud71aW1ddZ4ZGBb0GaHOwuY1Wu8JuaXAid5W9UtY3FVGb8UnaT2ef841cxjBzCKvHSCOkvMvL7vMnqBpFNKQUrLGGZ4NQxbPSjxoejI%2BV5TfgLQm6Jys%2Fc8nyRffux0MWgnPzUhPez3iJC7zi5TG8b2HfS2Lg8cWOd%2BjB0kAQ4z0YlA0yS%2FCU%2FRQ6e0BoQPiA91gGg%2B0BYrt9HAZL%2BsMrU79cIJPuFgmzA2R2qcDCYxQMo%2BzQybHnAK1Hdc8KIxBlS21KalO47UDQDdoK4g5q0rnPnzUPfUMXGsbGgjC8UrHspjBwa2D0vtGfFsbfd1p0ZhhqoEkDxA0cUnBxYDDQWmJmdS02q9NiA9XjBI9hN1sVet2T%2FNyLd%2B5evXk37Vmmuq7ebdIjaZe6Gmh5tmHNqgtdug%2BRCUWwc5ASCY8EMrzhXfud23bEXG5w1jifXkme9unMaV%2FPjnv0QOXSW2Ad6qx2q%2FNo4g06e25Vy9ZZfoKbPLKVkM3%2BIXfHTrOnFFK1i%2BrtbqS9WPAP%2BBQzUmXBtLBDDM4A9iHQNcFxxQUeg20nwQB5eULcWQAS%2Bzk5OQ8fPoRSorS0FAd4R%2B3PPaJnBBPy%2F3fdRJ7Ijz%2F%2ByFr1%2FoUNUwCnBwNYWL60a9fuPycxXugxOB7jL8DPfZRDgEOAQ4BDgEOAQ4BDgEOAQ4BD4H9FAJU4chOuXr3qTYFEUYxFZKwdJycnfTfvu1btY%2FgSfleeeDkJesJvlyJo9YAfnuQb%2FlDomy72qRb6GXj%2BaolPmS8%2FW0yKZaQ6gKhkxCQlBgFR8nxLeE2fhXSs%2FmAifZBE4ahparDb0RNi02JR3%2B2CEyhIDFTqCBKFYsDqxhq8tchSl1VVVFFWKa%2Bounzl3JzFU0d%2BPHTMh2%2F%2BuGR%2BZmYWlBouK6JZbbSwsHzkh5W%2BoRqhD2QYckLKCFOHAE1fgTyAn99Imt6%2Bie3nFbQyVe%2BuVlGUxqgyWVUGGlNYbgBlJ4waYaVgdZRUlc%2Fbsda3dQThE5FYLBKJ4JqIZW4AAtSAhncD1ZOdnQ0jzWvXrqEp4MKFC6B6UKWi1kOzA6JWPUwFKlk8YUkM%2FIrZ4VS62CYHA1bkcZgZZhE6mvDU%2BOms635RMMdYScgpQrIJKXm5vfvEQdb3A5wIG3TiMZB0Yx9kDzxDrNRgx3fi3BHjotBrM3LSbt29WlZeDKkFTs%2Fb%2F4LCE5Uyez5%2F2zy8FNtmggvxvouD8RTjC89JsFXe4cbheB2P%2BB7seAtYfLGXIcHXWe02B9tggssAwaDV37qybXCf9xkyX0Du%2B4lNIpGNYeNKywKClBOnuhKTqMWMlpBae71BV%2B7WV1Eb6nqWx7AjwkOlsP28zdC8PRX42QhjBPvEiCrE4U9bdc9esMyWV%2Bi2G7Vo9mCvGrkfoBbQRuQZL1BRdjAhLGvEdiI5XJoG1aFTJz%2BcPLHfsOGjPxy7Y%2Fuu3IwsqxpJu2qQJ7Q4w7pxw9VefV4PFPN9CSMhCEJpyvCXjfm6IS6H1rpMuapnl5%2BsmPHjyjk%2FZiQ%2BdICQwFjpK%2Bizu871K9SDBueHh2UG8pMCmNgQ2ekuPXOWbnLn1FAtpg3rTGt3oAPKWk9ttdSN5hBwXibMMFAZuEy7ES0vsKsFmAAZj8Af8OJeO3fu3MSJE0eMGDFu3DiEwvxFPQa%2BGQKhHj16YNK%2BkEZAjPHyyy%2Fjjsbw%2Fa%2B3%2Fb97A0ONM4fuaPLkyS1atPinia4vfvGf7oAPBNf3736He59DgEOAQ4BDgEOAQ4BDgEOAQ4BDgEPgDyNQUVFx%2BPDh9evXFxQU4MOofdALD5uI2bNnvz%2FqvS6tmrfgk8%2FEvufFzfPFMReEgWt4zHIxs0RI1sI3gCGVjMAglmjFArWYp5USrYCYBYT6wnKTWIhfA79pfkiH%2Bg8n0KQUqqqh5lqrE9kJZpSaOrfDhkQTVHisVIIN9oB1YpVacTvz8cHzp86ePX%2FpwqXPJ4wNbOIDA1GelDRpEbF1xw65QouVflbdUF5a8eFnNSHRWl%2F%2FeoZXSEgmVA2QBAh5SiEp8xUWNo90rFxMCx%2FbXZVGqoaGw%2BKyItgV0gqTxmCrVFKlmY0ENTnr1Zotp4%2BFdIohAsZbkcXExAAT6DGAhtcNw0sCgNlAHYqVboCzZMmShIQEGGWglgQ3YMUSPXgGNBiAKLHAsxRMBL7bWapX5ylqSurlFdVV1aX5tuoyeuee44tv0qLbX%2FDzjfUVpEiFcrGoqstLrsN7qbwCpf4%2F8BhuyBLsFoo%2BHFTxbpqSW7Bl96%2FfzJu14Ptvd%2B7chmV9%2FDxKTohGvAzGi0fsgMfAu2AnQFygl8HLS%2BAp3EE3b94Mi0h8EIOOI71v4WB8BE%2BxYQcfQaENrsZsYRkPtmsFjSdalebMiXUd2rxPyEKG3BeJDTyhnfAsAqZSKirs3sv8825aV2%2Bwa7IU%2BY%2BfxdWUZVB4VoBjQaOFydiQmVYz%2B9ta%2F3B8xMkQp4Bn5wt0wuCCZp2KJsxwPH4Gxwkb4l3QPoSeCEgkNHY2ZhXyHdaeA2zAb2IVt91RUlj44RfjhCFBhC9o1qrtiuWr8rKe23QaaoUphwI9Ta59ex8PHDIvIOgdkfhNnngMTzpWFHxxyqKK2Lir2w%2Bt%2BWHl4rkL530%2Bcf%2FcJfVnrtI7D93Hz9p%2B3mqbOV03cGBNeGSpr09OkOhplPTJoB5Fy37UXr9NYWLKtrc47SYLDE8R6FtHXXlGQ2JxRXJ%2BlQ5RrKCdWL7M6jRhrrE%2BsV5gQWIATwzWp59%2BGhgYiGkGeUN0dLSXK%2FvDN%2B3vPgArG9aptU8ffC3YDPSYIE9k1apV4Kl%2Bd9Qf3sVQY%2F5DejRkyJDevXu%2F%2Buqrffv27d%2B%2F%2F4D%2FZcNbOACngQ0HYxs0aNCZM2f%2B8A9zH%2BAQ4BDgEOAQ4BDgEOAQ4BDgEOAQ4BD4dwigkoqNjUU4IwofVFsoeBFW0qxZMx6PL2WYDmLeBwGiNX4%2BSX5R2fzA9TzytoD0E5PuEvKGkKwRkCQBkQsYHZ9n5zEWQsyE2D1%2FNgIew1dNIguCW9eO%2BpjevGrLfmTWFFioWkENdfBURFEOQgLr1%2FCP0CFYE79traquvvX0wb4Tx7bv3L1m7four3ZBrwc6Ang%2BROIv%2BXrq1GdZBR4hvotWlFR%2BNr6yUWOlb4CCx8snJJUhJQyjYdglfgXh14Y1cs%2BdQzPvu50VLqp0UZ3NZTC7zPnVxRfOxx7fvjv%2B2HllbjnW1iEESczO%2F3DGjPZdu7Vs1QpNJePHj4fu4smTJ5cvX4Y2HnaFjx8%2FhvEp9Bjff%2F99t27dQHSgXsPrtbW1KFThggDJgxURH%2BAxoKUAlcFafrgVBt311McHLp87df1y7JXzN6%2BeqXmaQGMvuD6fUdLq5aSI8NQI%2F5JAmVIsrurysuvQHloLHgMiFZavgMgDpT8LEMtjoOEGGg%2BqbDCs3Ly1VacOIl%2BRWCJo3brFxk0bvefgbX7x8i1gITCULPPgaRrKyMg4cuQIDCHRI4DKGuYn%2B%2FfvR9W5ceNGuKC8OBKf9ZIe%2BDh2YEGJmYBrvHnzJoplvMiqIMAtKOt1B%2FbsbNViOmE2CSQpkkAjkTgJz%2B3DV4l5xY2iTJ9Now%2BTDA1Vt9MTdhzb%2FigtHlSMy6RXV1SkJd6%2F%2BPPme8PelvuF4yMUlJcP4xLwzERYGhCRPXCE7dQVqgQLgRYaKDLAMnkaaaCcgUEGTEqQYot%2BFc%2BUthmMDxLu9ezXjwj4hMdv2brdtq07FVUQtIDuMVCnktaX0NhzuUPePu%2Ff5Iq4yUVB0%2FOClkf9291%2B%2F%2Btfv5jYo0VTsZ9IKBO2lEhmdeyeNXaG4%2F3Ztk7DNWEd9P5NzJJQE1%2FSIBSXhAXk9WirXPEdzU2hDTVUrXOZHBa9RafT69ngFnctpYlFpVsPnd29L1ZerGOlI%2FAkBeuCcXOythjADRvwBCEGjQS0EyAxIG%2BAYSaEE3%2BRbQASGDUQDgcPHpw6depHH30EaxcMGcYLr%2F%2B7W%2F9fvY%2BPK5XKuLg4zH%2Boj8BIgMGDBunixYt4%2FIfN%2B%2BLZs2dxGHqvvNulS5dKSkr%2B1W9w73EIcAhwCHAIcAhwCHAIcAhwCHAIcAj8OwRQm6BofbF5D8dqOwQY6NlH34S32kL1inTF4JCQFo3CJvbodPD17mciQ%2FP8w8r5fud9eD%2BFkzlNyKQmZGGM8GS7wLSYqKKWTUuaNi2LjK6IjFa1aa3r1LYmpomqcwddlx41HXqmdemd%2FslHVdvXPTq0OT%2Fvnomq1GyIhZU1lESlBT0G6j6vFaYV0Z42nclYUFxy7uzFVavXduzakfAIEaDwIz7gMaZMe5SSqTdZrDYTLc5XTp1R07hVrY9vHcOUEZJLSBVhDAKxhe%2BjYHwg1XBPnUWT4qmtwqP617rd4DEMRy6f6NWvV9OQsF6tO145eNqtsWOVX2NzPS%2Bv3Xfk%2BJo1a1HyIwQEigWUhK1atYL5APIgsCqNugyV3YMHD6BkgHwFhyGJEnYHLF0A7gHdMVDxg3gAPWNDjCycS53lDfWn791ef2DXxr07d%2Bzdcer47oK4S%2FT4cdfHE8sbx2SFBGcH%2BRQL%2BJWEVLzUyX10P1VUszwGMlwc1GKFRgItHmhT%2Ba2vxKF3JidnjfzkM75UIhDz4FwikYhGjx6NzgIMHAYXjyiZ%2F2GUUSkjfDMiIqJp06ZoliktLcUra9asadOmzfz589Ea4%2BFh2I9jPODxiR1MDCgxQF%2B8%2FfbbrVu1%2Bvqrr9B5BKEG3oSdCJQwps3rLrdps0%2Fof9M3qtwv0sL4OghxixmDmJHLAnSv9Hdt2GEryr9w%2BcyqbatvpyRYHQZdTcXDSxeWzJrx5YD%2B%2B1rFKEMauxmhi8c4%2BQyCZoyEqfUNKHqpl%2F2n7TSnmJrMrJGIBZIMKFuQagPbUzuybcBjwK7DDrjdLotGe%2BfGze59XiViIRFL2rZ%2Fec%2FuAw1yZMKi8wQWGQpaX0zPx2YPGHqLH5zNCy0SRGczjR9Kmh9v2mFqREQboMdjbT9bEfIZX3ImsHWFb0cTv4VT3JhKwq18XzWfqRPzqwIDHO%2BPoqcOO9MTyxKupV69XJ2T7zCaAROELEpKwRckZOYuX7Vt9owfY4%2Ffeno3vbZE4UAMD1ifv3mS4I4DyPCyQKUP%2B03wGBBOiMVimGGWlZX9u7v237%2BPIQOLBXoKhAZ%2BBXe0dxD%2F%2FSf%2F5RGYS5DusGocz4ap%2Fq8372EvHnEwvuFf%2FgL3JocAhwCHAIcAhwCHAIcAhwCHAIcAh8C%2FQYCtVT21FeoL7HiPxg6qFRQd3uV77CCI89dff124aNHq7xcm7dhQ%2F9O8jK4d68OaGHiBFXxpho8owY8fFx1QMrx%2FyTvDSj98X%2FvtXPmCRRVzF9bP%2B8G%2BcqNz607jji32g7%2B6j%2BzTb1qdu2z%2Bg5%2Fmn1n6zY4Vs5NTb6JVwEjhE4F1fQ%2BPgdofkgz0lZhdqFXdCMDUG%2FKf5%2B7auWfJ0uU9%2B%2FWQ%2BIuFPnypTNi%2Bfas169bfuPPgbvy9J0kP6xPuGOfO03Z4pcbXX8HnwR%2BjgnXJ4OnEfjpxaKUktDSklfPr2fR%2BHDVWsa6PTi3K28q64m9%2FWuAT4iPikTCJ7Ke5i2uzy8EToDpHraxs0GIhG%2FUgFA4HDhyAyYC3zQT1ZlRUFCIbUHWiYIThJw7wdpR4SQC2Y8LocFpRr6JihS0CzDIQWurWOu1FWmVWbXluTWlhZUFdbb5NUUjj7ri%2FmlUb2bI4IKDCT1onFKr4wrpuXeGP4a5DaAY%2BDzIDDT4YLpgw4HsNbvhjwM7T6Eh8kDx01AdEKGAEWNMnAQGyd999F4vjGEEcjUcvoYF9nJh3wzkvXbpUJpOBkAGhge6houIiZGi2btV6%2Frx5hQWFmAaYFuyPQTbAunqyShB8DxiPnTt2fDd37oF9%2B6srKuCg4bBYXaAX0lKs82Y9a9Y8RRJR6dvSIG3s8PAYLnQSIbVEIKwPbqIfP50mJt06cmz9pg0XHiXoLAZ9VeXZnTvGvjX0teaRPwUHlQWEmgnPRIhDwNiFxMQjcqm4KKyF6ePp9NYjqmctTXEmBjtCSMzIsoEbrNuOthT2AmGyAYoHJhSlBYXvfzpWEhYsDQ4Z8sbb167cMmkNrG%2Bsy4gRdzeU0iuXng964zKPn8bw66QBCmFIhU%2FkVWnAIoQIE%2FIKITGEDCBkNmGuCMJq%2BE3NvAgLE2LgSWtEvDwZKQrk1Upk2sZta94ddXX8R593bvtepw6Xdv1qb2gAZJi2sPesctkTU7NmT1%2FULLpDmzZduvR%2Bbe2uXwvUCoTPWpxs9M%2BLEcGdhYybMWPGBAUF%2Bfj4YESwoavr39y03NscAhwCHAIcAhwCHAIcAhwCHAIcAhwC%2F60IoFD1khgorFDhomjFK78H48XrWFGtq6srLCqqLshz5aVZ921O7BzzTBZYTKR1giCVMKyEF1gdHkO%2FnP2o3xunh47QnTrrTEm3ZeU7M4tpRjnNr3RW17gUVbS%2B6Nbe1VPeffXdvu2H92k%2FZ8anqSnxMI%2BAAMKGVXPKpnOi6GSbJ7Bsi3YMVO%2FwmDBbdIqG3Mzc5KTUi1djv1%2F53eiP3vxi3JjlSxYtXPj9Rx%2BPHzxk6Nsj39o8acLzMR%2FIW7crE0ugx1AzBD6TSp6wUuKf6RP6QBr%2BuFE7%2FVff0vgEt15OHRCA6NxWfXZu6rjZXzK%2BPIZP%2FCTiyZ99mXE%2Flc2YsKId4X%2FQQAsAem28PAZaAMBjQMwAGQa6MLyIeZHEPssIoe7HVemsJXmlGc%2ByyiurLBarAyaZbpfGYcuuq0ouzs2uLq6qL7Pb66lDQZMe0CnfKMKaVPv4NkjEWpFAyxPWdensPrSXwj0D8GAxH8igu4T9drPLZXTDoxRnaHDmFpRNnD1XFhrC4xM%2Bn8hk0mnTpiHBE2OHFgY8%2Fp6e8p4k1tPv3LkzY8YMeHpgB5cGHmbLz1s6dui49MclZSWluAoILTAl2OPx5yU1qBvfWFNdXVRYWFdT64A0AvMFaSMI6Lh9w%2F7JqLLQsFpBmFXQxMk0chCpnTBOwjgYYmR49eKAmgFvqjbtebT7%2BM6fd1yBC4fbVZ2XM%2BezjztEh7YW8ecIBc99%2FCwioZlH3ALGJWHMYqIU8ap9Gxn7vkePXnIrdQj2QCxLPbVr3JDuQJbh6d6xYeKC4AG0kL%2FANtVw%2BsL5CbNnfTLhq5%2B37qosrQHXweazoq%2BEqqm6AjxGyqv9TqLniBCdAKEqEp3I%2F7lf0Fk%2F36U%2Bgo98yAgxGS8kP4kkl0RBebwwBT9M6RNS4yPL9hM8DmMyGonqJAEaUfT90CbfBsm6gPrg8zZP%2BbomPQXJO%2FgZPXXXuxyZWQULpy7yFwSxyiFfv%2FE%2FLkisK1VQh4l1fGHTfzAu3rsP4IN0mjVrFtiMjz%2F%2BeMqUKSDNfn8PcvscAhwCHAIcAhwCHAIcAhwCHAIcAhwCHAJeBFCiYoPmHA4PkFvABAPuB6jKsYGy8NawWDv2Lut7P4IXWbJBpyo7uHt%2B08gpfP58wvvVJ%2FC0b8henvRykw6KGQvWdu05uWePnEuXqVLHZn9onbTBTrXIykQFblY0lH6z8Cu%2FYAZFd6Cv8MORw5PuJ6CfH1aW8MNE6wR6SrRWa6VaVWfQwU4AyZ7sojsbsYlqFMwGFBv28uq8hIQrD%2B5evXLu1Jh3Rwf6R%2FL4ErFUODgq7GS7DgXhjcslUpWQp%2BdjTZ%2BvEIivCYWLRcJpAvEi%2F9A7n32tv5fgNsOOEfGYFqozVJcVfbd0njAAphvEx0c8%2F9u5Rc8L8Fvwo2BLZZAqHjID1w41Apov0Fjx2muv4XHevHlwxUQdysLiyZ5gOQbPZjFb8p7n%2FLrll9lTvxn%2FxYQZs785cOhwfn5B5vPnW%2Fft%2BXLu7E%2Bmfv3l7KmLli%2BIj4%2B11OfRpEQ6ZXZ9cESdQGIQiUwCoZbwqzt0dO7aRstLqNnuQoX8G5WB80KfidEJn0%2B4XJrdGp311OXrX06ZMmToa4MH9R33%2Bafnz8firHAiKJZxSv8w4fE6KmiMO9pk0CyDqhlPwVNdv3b9qy8nnDt1WqVsYD8COw5YfeKK2F22OYZ9BBbsH7vn7ZdhuSa9iZ487hrQS%2BMr0%2FADzCTISmRO4uMiAogrnBgFkUAu9U9u3Sn%2Bk0lZB2Nvnrpy61FKeY084caVPi%2FFSBkSzpCJhDwWiMxiqV0A6oO1hLUJGQ0PVEZgQ5ve9o2%2F0spal8WkozCgcOnZoBkrm8lrsXgoFlBfuEw8sK4TNUrFw4z0uMdP8gvKHbAQwdmy8xZpNBqqraZxcVmDhlxmSB5hTPAUJQIjz6ckMORJkyZH2zefHhMywJ%2B8LiZjxILJhL%2BeiO63aFfV%2FzXVkEHVA3pmdWv1rFlouV8jhU%2FTixL%2FLwjpTUg%2FPrNg8KvpsSeoBUYYoOPctVpNbnLW2Q37Jr899q0Bw94bNWbfqePFetwPDhiBescFbAbYJDR9oHWrsLDw%2BPHjsLBAm9LRo0dxJ%2F7DkHFPOQQ4BDgEOAQ4BDgEOAQ4BDgEOAQ4BDgEvGvBKMBRycK1Dy58oC%2BuX7%2BOeurUqVMI3YDtA4pC78Kxpzpkl%2FXZJgWL1apQXti2s2NIkC%2BPBPLJAD%2Bfj3xk0OTPjm598utpH%2FXu1a9vjzOnT1q1JngbunUuNswUUR3sruVuYeqAie%2BTAB6RMJDRj37z3SdXEyiSKOAsAP8Doyvpee6vZ04v3LFpyb6tR%2B5eTK%2FJr3eo8Q7rkgl1BGgO%2BGa6oNA3WVR1V0%2Bd7Nu1N59IBXwxT8hrR8iORhG5oVE1Mn%2B9RGhkiI0nqBQKVgtYx4MghrQXCuaOGJF695bFaXbZbVRnp0oLHDLOXDg9cNiAl7p2HPh6%2F7PnzhgNRshC4GeAP%2BSnsA4RHk4AtScKz8TERCygw%2FOzzNNRAkzwLiBCigdoHy%2BnoVapD%2Bzd1yKimRCOkzwRsjN69ulz6vTZW3fuTpg%2BPaZ71xYvd2zXrfNrwwfs279RW%2FGMJt2nk2c2BEU0MEK7UODgCw2EXxnT1rplI8tjWNCfwtbinr4ftKdYbfDFsJmpBxCcap3BnPr8%2BYVLZ87HnszMSAM1AQYDkxyjjBPD6XknPJ5iB0%2Bx4TyxeXfwiLcgtLgXF19bWYVWERv%2BcF3oJQH3ZEPrhgUpqywngiPxbfhJVjfD0kogoKjW4Ny2xd400gF5A19qIj5WIqWCICoLtgsEdgHP4COq8ve7FBTyU7P2h6csiD9%2B9cKFu6dOnd%2BwZnmzqBCYnYTzyZeE3GF4DTyRyUNiuAXExWcdYrVEUh3UwvDN9zQr222DiQoiTZ0ICLG6TTa72Wa14DxtdiTDsMEw1A6zDLvFade7nCZWT%2BLhX3CebCorZpiaGuU0Jbl09IfJEoESJAZD3AhV4fuomjZXDR9R%2Be2Miwsmjh%2FerX%2FHyFdjGg1vE%2Fldr1eezptj2fMLPbjfvXObffFC5chRVc1fLpI1OSvxnU7I656%2FKS0j7m9dQ3V1Wnn5teuXtmzZsnXhsr0zFu%2Bds%2BTUhm1Pb9xugPUrhgJ2KVCOeGYLfFRgsrp%2B%2FTrEf2A64R7E3YeOEmh%2B4EPC%2FQfFIcAhwCHAIcAhwCHAIcAhwCHAIcAhwCHwTxFAtYuqfPXq1bt27cKiMJaDt23bhsLqxo0bMH58IX0Hm4E1YrYEQxuB1VqVU7h5wdII%2FwAiJkRC2vtJh4l9BhIyPqLZngmTRvXu0fvVrqeOHbE1wPQCNhcgMdgCDhyE3m2%2BW5jx2qRxxN8XcRKygEbzZ36f9zDbrYIPBhvBUVoin%2Frtgsh27cSRIeKIgGY926%2Fet7lYWcJmVMB7AEvubM6mlRXvo9dCVR93NvaN3oNExJcQ9FSQLj7iozHti6KaV0hlWoHAzDB2IqzgCX7kM6EMG2HRVCT4oGev0wcP1aoa7GYHVTmoBuflLiovPnHx9L7j%2B89fiy0pL2Y9IZwuk9VlhR7jt%2FV8B1bPgQNKeRAX2AenAUBYYgedIfX1yC4BF4Qok%2BTkZISYYDt%2B%2BGjfbr2bRTZpHN2keZs2oz74IDb2QnWN%2FOmz9F8OHFqzZcv2Pb8eOXkot%2FCpw1ROk%2B7RKTM1oVEaRmhjiANSAcKvahNj3byBlpWwuSRouWFJCIgLcFYmG9u%2BYEdah13v0BvtKqtdZdA3qORaTZ1Br8G5eSkLPHp32A96%2FDFw%2Ft5X2Dc8Gy4Bx7PUhtNlt9hcdhABVq1ag3RVvGYwGVPT067dupmc9kyt06L7iJU3gEkxmHT1KrNKy3I99SrTkh%2FkYmhIiJHHN4klRqHMGRpNX36F9nhFGeJbJRaW%2BvgeEYhGQr3gF7xu1sITh88d3XNw3cJv24cFBBPSkpCpDHnAiGsFftU%2BElvLUNok0MXH8BEjEdZKwxUjP3Gdv0rVWqvD0kBtWrfZ5sLcAg%2FjNJkMeqPOZMP8tLBOsQ4HAkwMbpeZ7Zn6PY%2BB9FMtNdXRtNSyDz5J95UZhchG4VsJXyP0s%2FcbTH%2FeRnMynNXZTxMv7Ny9bs3mZQd3%2F5xy5YK9II9WV9OqalpaRtMz6ZmLxlHj8sLb3vAP%2FlnEn8yQrwhZFhb4ZPF3jpy0uHPHu3bpJPOTRUplbUS%2Bb3XscvaXX7W11RR5wlBiQFTk2XA3wW2mXbt2%2Fv7%2B6E6aOXPm2rVrMXkwbRCIA20MO9LcxiHAIcAhwCHAIcAhwCHAIcAhwCHAIcAh8PcIoIpFJZ6amgruYv%2F%2B%2FSjG0YwAs0psWNBHzY4DUHWhzoXMALUVXsFT1LCK%2FJJtc3%2BIkspAHTAi0sPf92Ox37uEzGnc%2Bt73yxa%2F9964EW88jo11K7QwonSbjRa7zuY2uKkBgRtVBu2UH1f6RTSX%2BIQ2b%2F7S0b2xhlqLV4yhrzNfuXS3z4AhRAAVA0MQHhEinbVoZk5xhtWh9bQUePQYyNBkPRtN1KAtfpoyZ8KMlhFtfaUBgSFBIzvExL8%2BtDqmY5nUV8NaH%2FBshF9OeCt4pAlDZIS05glGt%2B%2ByeMqsq9dvW%2FVokPGkoiDG02mvM2pr4MFhVJvtJsSKsJ6cUKA43CZPMgPICuDAXr5Hz%2FCCDcAr6AtAR8DAgQORY9K%2BffvXX3993759FaCBiktvX7q%2BefWGmTNnL1390%2B2EeHmdAg6VBqNFqdLW1jfUKuoVKrnZoXQ76%2BjTeDppujosWsMXWWF0SRgrEVS3irFtWk%2FLil%2FwGJ6WDpwUeA2zy2kzKLU5z%2FLiEh5fS0h89jxLr1chsgOjioHzDrWHqPifffAVGEcMOt71vuUdYlwdrohtGrE7oPXAI9tP4mFsMp9nTZ0xvdMrnafMmJ6Tl4sC3G611dfUJt6JO773wI0z50vTc0ypWQ3TppbwePWENKATRMav8ffRt42hn31EF8ws694220%2F6nMffRZh3COkiEsx8%2F8OEW3F6uTzr4rmRzaJgrTmIkFWEl0eC5b6N67t1dk54z%2FFaNyOCSwjPxUjr%2BYF5bbpqf1xPS%2BXIXfXkwKC7Cc4p2MVpwgHEZnXB%2BRMkBtJMrOCboMeA5SYLA0YM9BerxwCPoaMmBc1IL%2FtkXJp%2FoJZA9yI18PzLxcGKAUPp7v20poratUpzQ7lGUdSgqFKqLGiZsThdesxfCzXZKCRGhRXulT9XdhmY3Khpgn%2FAUYYcJuRSaHjupElFB%2FaunzWtbZsWApnERyYJ9ZX0eqXDvv076jU1uG1gS2p1OSFrwRljdnz33XdSqRStTBKJZNiwYXgKFtHbaYID%2Fv5O5Z5xCHAIcAhwCHAIcAhwCHAIcAhwCHAIcAiwZSyqWlRM8EnIysp6%2Fvw5nnpxwVsodfEWdvCIyhctJ4i0QJHF1vKoeavk139a30vmH0hIBJ98Ehy4wq%2FRPOLzS8fu2j2HHq%2FfeH79an1WJlUZqMHqthn0VG8Go0F1VodebTTfe5qzYu0vX038bs2aXYW5tYgmYZ0qtC6z1vEoMXX4myNlfoEyf38%2Bnx8SErB82aLy0mykirAyDNTf7B9KWL0bXgRGva2uIfFy3E%2BL10%2F6evrkmdOOLFus%2FXGZacCQKv9AjVhoYtjwi3JCNjGkOyFtCB6Zz5p0%2BP6Try6euWjToKnECcsEVJjwI4B7gokiAsQO%2Fw32JVh1ONw2o6UwvyAtLQ3dN0AATA4gAibYAbfjpQWgxPjss8%2F8%2FPy8OSZInfjyyy%2BfPnliMZp0ClVtWWVObl5%2BRZnWZGK7NzwuDqissYMqG%2BWtAx4i9lr66DadPEPVqImGEcEe0034DiKEYal90waIVKjFxQpRvD6fIFlYXsOi16nuXr311WcTB7w2rMeAwV9OmpQQf8tsVHtKd3YkwbFgHL3cC55iNMFXYPO%2BgqvA%2BXufeqkq2JCCwXBbWScT72XKFXVXr1%2Fr078fLq13376xFy5gtpQWFa9duer1vv17tn9pcNfeY99879QPy%2FM%2B%2BaTcV4JOjToBqfQh2YFCRb8edNNKeul43defZDaJTiO8fYR8QMirAmbuyBHZ9%2B9Ro1Z38%2Bq6rh0m8sgihlzgS8oEjSsbtbfPnEZP77J9PRaJLU4idvNkenFQUXhr3Rff0OR8qjZSG%2BsVYnHa1AatskFhMhusTrMVRiFoLbFZdMr6WkWd0mxCJxPLY%2BAPcwaeImwEDgRBSvo8q3Lc15kR0fVimZJI6iQhBYFNSoe86zhwhNYr7C4LEMQf4lPhtonAHPTugMCwePBnv61ebfhlf1r%2FN55GNCsOjswV%2BmaJZCnBkanD3z708UcfdO8ye%2BbU0V%2BNe%2BfjUZNmfLX1143pBcl6pwaTCQHCZjiveoYA0wmTRCBAeDDLY%2FTv3%2F%2Bbb75BXwne9N537PhxG4cAhwCHAIcAhwCHAIcAhwCHAIcAhwCHwN8jgCLXU1TZUZWDrEAB5a1qvTwGalsQFxC6l5WVoZF%2F%2Fvz5CLbAUzZXpKa2ZtPmxeFhnwjIeIbs8gu459%2F4sqRRYte%2BzsPHbQ8SjRnJVNXALl7Dw8DtaKB2hdskNzVUK%2BU6gw3pFuUVDanP8nPzqjQ6VNqsN6LZAmMDWlvVcPTgiRmTp3%2F07qixo0avmD8%2FOf6ORaWg8LSE3yS7sI4%2FT4amGZ6Namq2WZSmsrzaZ%2Bk5yTmZyuxn9Fys%2Be33Sv3864V8A4PuDEZJ%2BHE84QqGN42QmUS4uWWPqwtWFCc9c7HMigv8BbpeNGzLgRtcicVhQa4pG7lqtiLttL6q9vKFizt37ty%2BfTvaRmBfgNrfiw92ACDEGFeuXBk%2BfLhYLAbxgrIUj5%2BP%2BzwpKclpgxbF7jBZ7C4nUjZMKGHZNhW2NQY5I2gJAZUBYsNGTU67nCbfc8%2Beq4xoquKJnYRPiRA8Rl2bdo5NG2lpMbpqoCZAVe6hPlCuIw8WlhWGjCep82YuGD5iVP%2BhI%2BbMn%2F%2F8%2BTO7zeDRY%2BCL2Q1j%2BnseAyfsPW28iLfwFBsGHcGyMHotKigohjNmYZG2QcUmxbpcao3mTkJ839cQQ0p69nn1zLmzEOqkJqWMeusdVOAi2KISEsITT%2Bo3%2BFzfAamBftVSvlLM1IhJSoi4asxweu0MzU91Ht5dNXR4rk%2FoFUawnJCJAmbt4L7lcMXMz3YdPvi4T49zfqI4mSDDPzRP2CynXT%2FH4f20JNW5bqUpqoVVEqjhSerFvkV%2BjTQDRtEjV93lCriegsuSqxqepqU%2BfPxApa63ua0YM7vdVJiVvnbFssnTpx49f06hQV8MO2HAS73gMahFRXNzqyfNSGrVItPfJ4UhGUKfx7LQB9371m%2FcQmsqbS4T4ngV1K3wsBlGp8eDxA1OC%2BhDAGIzFOcdmDxhdqPQbSJJhjRQ7h9RGRieFhL5eMiwDW8O6904askPP5y5dSP%2ByYP0589qakptcJRFCww4LNibgENCS47Viok0d%2B5coZC1lkXQ6tSpU2HviQBWvPX39yj3jEOAQ4BDgEOAQ4BDgEOAQ4BDgEOAQ4BD4H8QQKGKMhaFrbe2fcFjeF8HiQEdAur3DRs2jB07tkOHDps2bYIwA1aKtLLMunndzeaRJ%2FyE54S8TLG%2FRhxZKokoe7mna89%2BWlxA9QpkjlKTxW1zqmzW%2BOL8rbFnlm7dumTthoMHj2en5Zl0VqvFrTfZ9VYXSkWoHFBrou63mh0ahTo3Of3ehWupt%2BIb8oupWkeR74n6zuqAj6MFRIMFLRVGaje6DUhNdWCZHcQDTDhgsEGNDfTWTf07I3N9fWpEjEEE20mRhSet4ful8f3iBIFXBBF3YvoXrd5qKihElis%2BC9cNmIxi5R08htGDB6tGMFmowYzfVdbIExPuwXrx3Llzd%2B%2Fera6uBufjJQfAEqCoRy8ACtIuXbpERUXhsW%2FfvqNGjTpw4ACOhNGE02QFa2O22xqsxgadzgH6AaIAmB%2Fgz8r2yOBLWOsPt5JmPXXPW1Qb0bSeEbuIkBIRy2O0buvYCD3G73gMT10OHsPuhn7EadWZMpOfX7525%2FTla0lpaXa7Ed0T6IdBOxDKbmwsl%2FG7DSOLsUYDEU7PG2iCN7Hz4MGD4ydOHDty9OiBQ%2BdPnUlLTlEq6sFvOFzOovLSOfO%2F6zNwwJx532U8zzKbTcUFhWuWrRjW77W%2BHbu89lK3Ia%2F0ejem0%2ByA4MN8JkPEbxDzVVJeUlRg2ZRP6bNEqiijWWm6mXNLo9smSQNvyHyPBMr2dm5bvHoxvXCGfr%2B4tl27Sn9flcy3zDc0y69t0TvjnU8eUKuCnj1N%2Bw%2FX%2BEfUSH2qZJJCsb%2BySRfXzGU08RmtUzt0huzi4mPnzh46crBOUYMLBbINDbVnjx7s3C6GEQrGfv1VckaW84UZ6W96DAO1qmhBYe2sb%2BPaNL%2FhL7gEc1Ee7xxPuC80InX6TFArDrtORS1yt03J%2BrWwJqYgQ0BhsDPUbXHq5E9vnBnWo10TQr4mMPSQNAREVfiFJgWGPhg6bP1773aNiv52ztznJRUYZzvUPWh1sYEpQ08K0mlZSYjDxnqqYBZhUo0YMaJz584jR46EMwY6TUASAvP%2FuT%2F%2F%2BB5GE7ctHHrxVX90Q%2FAu2BUEFYHS%2FOO%2F%2FI%2BfwJfk5OTcunXr%2FPnz0JlgHxPvHw%2FinnMIcAhwCHAIcAhwCHAIcAhwCHAIcAj8QQRQj6PwQemEwgqEhrfIxYvYUGehroHS4N133%2F3ggw%2FGjBkzaNCgkydPqlRqtq2jqsy%2BdV1Ky8ikEP88P3%2B1JNTJhFULgivadXHt2UeriqgDVaABK9CwFiioqlm2eVvLTt0kfmG%2BfqEtmrX8acnSssJCm8kEQQDqbQtOAPaRrBGHzWGH1yRaWawOg8UFRQQYDDQRwF8U3RB2Jzoeauu1FdWVaq3cjV4UlIcIIGWPwo%2FB%2B8DlNChp3C3d6NHZAbJKMaMTC8x8MXgMNRErBX51Qc2e%2BTU%2BH9bixJj375w6UFlZCC0Cgi50bjf6CFheAL4EsCO1QaSBNgUbls5xbohngQ8mUEIhhkfvjlfGgNzSd955JyAgAE0lvXv3%2FuGHH06cOAHyB80XOMxps9t0BovekJSWcuTiuTv37xnUqKPZ9hX27288BqJY7OBRclKc8xaVhUXXEYmTiMBjwNmjpkUb27q1tKTot74Sjx7D6ekrsTh0Wp1KV69xWyh8PtUWO%2FgLuC9Qh9ntgubDw2L8bT5gZHFpeMQGg4tHjx6BfoFiBOeJi0LBGx8ff%2FjIkUMHDhzau%2F%2FciVOPEx9WlpejcQZyBiSCPEh6cvbi%2BaRnqSYLvtxlMRirikvvX791Yufe0zv3HVy%2F7b2OXfoTsoCQmzxSJxJopaJnraIrF4IWyHArFLRG6dixr%2BrVwemNIgvCwzPDQ%2B40CS96e6hr6kT30KH1fv4GscAiEeT6%2BKe372Nc8wutqGQFDPfvW8ZOeBYSlhoiyQwVZwX45fhFZb06tHTj9qLb8XlPUuNuJ2zf%2FsvadWuyczO1BpXWrKquKtm%2FY0ur6CiIHEaMGX0r4b4DNASQYGU8%2BAeNJnpqVdPiYuV3Cx%2B0aZ4g5d1D2CshVwk5LBRnf%2Fo5TU%2Bl5gYn2kDMGpfZyNJZDQZa20Br5LS8mOama66d3j1tbLtoGbxWxhJyjxEqpI3yJf5J4VEPx4xe9fH7XVu1Wr5sVWGFAiocG4gykH7oh4LliAGkFvQYeMZ2jmD%2BAPa4uLh9%2B%2FYlJCRgHxMGNyNGDNPNu%2FO30fsD%2F%2BKbMbjw7IXjzR%2FdwFIiU%2FjatWuguf7AT%2F6zQ0FiYHZ9%2B%2B23%2Ffr1A%2F%2FZq1evOXPm4MT%2BTxiSf%2FaD3GscAhwCHAIcAhwCHAIcAhwCHAIcAv8tCKBcwobaFuUPdlBbYWkeDAbyQ1GMY40YC6lbt25FzQtCA0agEGOwcRVo%2F5BX2HZuSG3aKCcgQOkbYhU2cpCgCn5AYZuX3Dt30IpMo6tGTfWgPOR2S3Jq9ozxs%2F0ZfymRBgsC%2FIXCsR%2B88zD%2BmlWnsBvQcWJyWfROs9ENdwYrCh0kcTiMNosKIRQuuxm9Hi67wY2%2BDDY1E8ac5XUNz4vyKuXFNgtKeANLqrhYSgD5JSro%2Fm1a%2BizJ8uX45yF%2BuTxSw2M0hK8jPCPDtwnF1UJJAo%2BH1oa3pMy4z9%2B4kXRNZdOyX8v6P7JCCfaLzBB1wPcTZ4G6k30dxIOXFABKqDFRbwIxHAtCAMh06tQJVTM2FGuQM3gbT36DlPWasJYUFHz%2B1RfBjcPffX908sOnbh2aWNxsHwu%2B12O5YGDdF1T0ebJjzvzisCb1Yj87I3IToY0IKpu2NP%2B0khYVsnITNKHAq8GJkFhjdX3l02cPYmNP3zh%2FNSc9%2F%2BGTtKzCUrh1sE0qLvSfsOKDF384Ve8oe4cYgwiyBUUusj7z8%2FO9QbHojgGnodNq9RqtUauHswdYG1wFvgTVtc5i0pqNOrMJPAbmClJZXRabw2g1K7XakuqU6%2FGf9xncB6m7DHOBEdYKpVqpT84rHRTrltOyclpnpgo7fZZr%2BH5xTts2iuCgeoGwxs%2BvNjRcHx7uCg4x8XhWtP8IyLNAv%2BShI2ncE6qxIFLVnJCY8cX4vSE%2Bu6XkmJTclPHviKWHI5seGTf%2B6LqNx37edXDTnnVLVs%2F9ZvaRYwdvxl3Lyk9XKavvXrnQr1vXsPCw2YsW5haXoQPJ04jkHULwUzpWj1FSql%2BwJL99mwp%2FoZxP5IQpI%2FzCRlHWydPoo%2Fu08BktSbOnP7Y8vEfvJtDLd%2BiJC%2FTQMfrzZvO8b1JGDJgdLIriEx6PwNj2BsNUMLICWah88OCGtT8dXP79W0OH7N1zrKYWcxa%2BHJ6kHk%2BDEjxJIfuB7ghjwwLr4Q%2BhnQDyeMS89953xcXF9%2B7d%2B9P1PubnkiVLGjduHPLHt8jISHBxhw4dgsroL%2F4PmJGRMW3atPDwcDTOoM0Kj6GhoeBC8Tpuor%2F45dzHOQQ4BDgEOAQ4BDgEOAQ4BDgEOAT%2BaxHwVrXekspbd6PEwOo8Clu06kPx3r17d4gNjh8%2FDmEG%2BI2SkhIEs4LiYNeXq8vs29ZlNYsuDW6k8m2kEoaqheGFftGPW3eoWbSg5Nrxh7l3s7TFcre53mEryC5ePm1xM2lEiDAgWCgL8ZF%2B8dmoqxeOZibfq8hPZ6kMh5lajdSKlBA4TkAfwVpU6Nx26Ctgv4l2DzV14hGUgtZFNTaHyqQ32LRO9JWYoNhneQxQEDrqrofRhUNLs9M0X46%2FJxXfJSSDkEqBUCHxVQqESoYpEQoey6TbRfylXVse2jwvP%2Beetq7EZda5HDAvhZOjDUaktMFENWaqt0IHgqRXi90AmoWa0NticNtMbiv4FgvbxOB2KJSKg4cPtY6Beyi79ezR88yp04grxYxC1gnqfdAHZoPp%2BrVrA4YMIgLS%2FqUOh3bvbSitKU8rrM2ssKqsTlwn8lAcRodTTTOTHd8uKIlsrvENsjEiGyM0MqLypi0sS5bAl5LqLSyVYXE5dQZFWfH6lT8M7NO1e8d2fTp2Hta7%2F6DefZcu%2FrEgI91t0sIyFYwQRXsCa9rJSllgDwqyCkQVyBc86vT6xMQHDxIfVFVWGfQGlsJiDUTZv9%2FuBTzHIEAGw37SaXZaEHPL9t%2FgULRpwC%2FCBg9Yz%2BEgVjS6%2BnuPNo0YPUPkv0PglygNqZcGqqQBhQP6qPdup%2FI6ikRdpZvWqhyxp2tGvFknC9ITPjxA1DJ%2FnUisJ8TKJ2qGVPsKbkeHXhw1xpCUbleZNAp94ZXrZz%2F7eE20%2F88ysotHzgnIHYngWru26T8uLr1xPfPS9TtHzu9et33ilxN%2BWLJ48%2FbNV25fUSiqa0qLdm%2FfvmzlilsPEvWQ6Xj8MTxUBhtX63ZhHNW0vFK7ZGVB29ZKKeOS8hxCgUngowgMNfTpZ5o6KW32lw8WTrny8ajjvXtd7tIjufdreb0HVfUeWP1Kt4K2bZKahG4NEPTkkQBCRiOmhAgKpJGVHbo5l%2FxInz7KuHt9w7p1GZlF0HGAQmGpDKDrQKiK2%2BTpXcJ8hlbGe9N5b0DccXiKDdwF%2FGe%2B%2BOILEGKwK%2Flz%2Fy%2BBx1i0aBGyXL1z8j9%2FhLtLz549f%2BMqMW3%2BwoZpcvDgwSZN0HnzdxvSfCAUgfLkL3w391EOAQ4BDgEOAQ4BDgEOAQ4BDgEOgf92BFA9oZhC3eHdoMdAHVRUVDR58mRkKKAIwYrqypUr0bmPIgvuELNnz7567ZpV2UArK12bN%2BU1aZEf1LgkoEmuf7O0oJbn%2FKO%2Bl%2FjOatXy8%2F7dh7874JvFs55lPzOZTVaNIeXW%2FTWLl074YvxHn3y8YP7cud9O%2F%2ByTMe%2B%2F99bUr8fnZqax9TJqYzSJeOp%2F7NngU%2BERSEBboMayv9mgsbFJmhBOsJYF7Bo7pAl2qCbYhgsIFdjXwXvo3Q41LSmomzrjpl%2FQLYaJY0hKoO%2FTYNlTmU%2BKryxRKrsVEHA2vFHWF2P1h36p3bejbMc2V9xdmpZO0zPo81xaUExLK2h1Ha1TUmU9hb9ofRWtLaHVBVReQJUl1FBFLXXUxbah6M2amwl3uvXuxfCEAkb01pC3Hscl2jR61lIBDhsIFYGyw2qLf%2Fh4yIg3kSHbrUvHnRtWr%2F1x0efvvz92zIcb129OSsnMzy8tfP7cXFlKn6U5v19W3rRlg1Tm4AmMEt8amX9x61aOeXPpo4e0tpYq1bRBjWDQmrhbk17vH0oI%2FiIIiSSkMbwaBg1IP3mUKqqoXkU1atAL1GCkRmg99HDRgJjD4rIjWxYdC9XyuuXLf9qxdae8osbTbcFapZrB2LBkBVv3s2hanWiFYF0pqcUKbQQrwWHdQ1kNCbJdrG4dCA5WTWJw11e6z519PuTNmwHBaT6yMrFMI%2FSTB4YVv%2F%2BO9uoxqq2jOgvVYaQMtCzfumZDTXiHOhKSJhLf9xOkiXgVhNSKmDQfwRERb7pY%2BF501Pzx4%2BNv3FHIG0yYiIf33hr95tXIwAcCJkMsyPMVpbdsJp8ykT687yopVObkpD16fDH2wp24%2BCcpqYVlpXpMNoulqrq6uKK8wYBOI48MA4wLSyaA7QI3hVwcI61RyDdsze3aWSsTOKU8nYCnEgrrhVJVUHhOq%2BZ7WwVvjWl0KDjoNBHvJWQ9IUcFoiTf4FLfsCJZcHZgwM3QoLki0duE%2BYYvOS2LftK2t272QnonAaSNVV6bl52TV1SeVVRZoVSBJPKKbjCNNW7E66JbhvULfSHpwQ2Im449Tbcbl7t48WLIKHDflZWV4cU%2FseH%2B%2FfHHH4ODgxmG%2BTsS4V8%2Bwc0%2BcODAU6dOqdWY2H9pwxUhimXChAlez9vf%2FywkGTNnzoQE6C%2F9APdhDgEOAQ4BDgEOAQ4BDgEOAQ4BDoH%2FbgRAXwAAlB7YvIQGxO0oQ8aOHetNUoDzw8KFCyHDgC3k2rVrY2JilixdKs8vQLHvWrc5p0mblKAmySGtboe2ORHcarFvKGItmnvqax4hndvHXDp2zFBTB8kETBkqy4rikhOuPbz1KOXRrFkzQoKD%2BXxeZGT4wQMHtBoNKxjwGHSgywUBGljv956YvK7u8JHDK35aicRPuRoxri64WKAYh7yAdTuAUt8Ixwez0w4DTTPkFNSuoqVFtd8uuhPd5g5Pdpohe4S89SLmFxH%2FjNjnlMDnmEB62i%2BoYMhw9RcTnvYdcKNT58pPx2lnz9HMnqNdsEi%2FfLlh7Trjz5uNWzbrf96k%2B3mDfsMq408LbavnGTcsVGxcoDi6yZF7j1oq3c4GWB4U1pTOW7z49aEjhg4esW7JuvoSOTUgNwMcC9t9A1R1Dlpcr161Yf1bw4dM%2F%2FqzLau%2B7%2FVSSxFSTXikfdfO361Yue%2FQsUsHj8lv36Pxj1xLV1e2jKkTiCwMUy%2BW5AT65bRoahzzHl27yr3vV8ehfdYTh00nDucg9HRQ35EhfkN9xEOlwmES0WsMmdqy8c0ZE7WnDpsuxRovX3ImpdBaOdVqWFrDAYtJh8Fl1bhsWrv9wZPkIUPe%2FPTDz1ISn7rAV8AZ0mbSu6w6HMM2peAFD1UEA1WQH9RoZ%2B07wMuACvB4iMCOw8FmklpYUqOBVmTQ9attnV5pkEotQr6Rz9fzfMobRVXMmGBKu0mtcmrWsX6qTrPbaXTfemB9bUKd38tXRdKtfBIrIGUypjhIeNJPOIGQlwkrcvD39xn94QfnYs%2Frqstpaa7%2B5w1FHV9WigK0EpnKR1ISFlj1ak96%2BjiVl7n0dQZ1nVqtNVlcoFgwY0BweUxHPDmrHg6DvRg0x4DEQJ4JdCX4wzSp15T%2Fuj%2Fr1d4aqdAmIkoJr0YiUPPgB%2BufL5P96kM2isgdviSX%2BJ4lzHxCfiYkRShTi8PrJKHFvoHpoRHHfYK3BESe6tDz2egvahavcd9%2BQKsUVN5Q%2BOjprq3bZs%2BfPx6U34olh65fzFPL65ysSkZDbSqXGaPgcLN5JV76wvvfD249TBUEH8%2BaNQvZJTweD8zhn%2FufCd8Mk5agoKDfEwj%2Fet%2FX13fw4MEIJEKf1J%2F70d9%2FCm0y69evx38UuIp%2F%2BF2Qot9%2F%2Fz1kXb8%2FntvnEOAQ4BDgEOAQ4BDgEOAQ4BDgEOAQ%2BM8RQPXktRb0llHeD6KeAmWBgJIBAwa0b99%2ByJAhZ86cwSptaWnp0qVLoQyfOWtWEaQLlZXOA%2Fvz3no7c%2FDw9Nffie3ab33LjuPDomIICUQWJ0MkUuGg%2Fv2un401VMmphe1xsNtMGqe%2Bwa5PyUr76OOP2TKHYVDafPfdd7m5ufh1L5HiZVSwjw2FVWJiIooskUj03siRD5OfahxYUKeegFhWDsCKMwxOu0ZvsbKZJ3ZqoHY1LcyXL1oW377HHZ%2BIA4Q3g5DPCFlGyDkkUxAmjsfDX0pA4LOgoGs83mnYPPr7Z0RGPItolBYdntEkKrN548zmTTKaRT9rEpUcHZ4aEZYdGlQcGZbWPPJS00YX%2B3fTntxD64upWemwabUWfXJW1oUbdy5euZ2VUQDhg9vuRh%2BGE1ksCAZxO1VOd73VgSSRi%2BdOXT17ZO%2FmFS%2B1CmfrO4Y0bd%2F663nz9hw6evvUefntRPAYzlUbi9t2qBCLdeAxBKRcyisPldW3aqx9pZ2q1yvyfj0rBvfPHzooeXD%2Fu316nnqp%2Fe6mUb9Eh%2B2KCl0i4G2QCG51iskdOujZsCEJH36k2HuAFhVRrYoawGPA78Nuog49skQNhhNnz%2FfrN3jkiFE3z1%2BzG6AUgAMGGnsc4IB0YC48cgyWCEDhj54SakCcix22HC94DBMrgal3O%2FUOrctR6y5MojOn2Ru3MAslbj7PSvhGIq0Ka6ZZtsBd%2FYy6lOwvW6G0QbatjeaU0SUH6tsNjZX6byQklsfUBwgrAn2O%2BflOFIj6MExLP5%2BWzZt07dZt1eo1ldmZVF1DL19U9BqoF4WZhH4GobhKKi2Jbupat4EW5lKT0m7WWOHECprG06%2BBGYHmG48Egn3Fo8fwxI3AJxa6EvhUgMTAn1JXfvB45qsDFIzIzBc2BPpXymQKnszAC8nxC9khISv55JpPwDNR6AHCzGDIMh%2F%2B3ajo8uYdilt3yO7UMbt%2F36dDh6d9MVG%2BeqPr0g2aU0jrVFSlMxaXbV%2B1NjQ4hCcQMFIJz8%2FntVHvXkmMr9YqDQ7kyyB0FyYY4N1Yl5UXPAYmPG5DMIpw1zx8%2BDACfNHP9Vf6SiDqAI%2FxH%2BoxQJsMHToUgcJ%2F2pHj9%2F%2Fh4EIQUPL666%2F%2F01%2BHkwwiWvR6iKq4jUOAQ4BDgEOAQ4BDgEOAQ4BDgEOAQ%2BBPIgCuAJ9ESeUlNFBSYUNJBbNKVBxbtmyJi4tDeYVXYP134MCB%2Fv37I9GgqqSY6pU0%2FYHt5H7H6eP2EyeyVq8%2BMWnKgnfe7tqiaUhYkH94cNteXRavWFZRXI6kStTJWNJHWqodfhdOW1pWxkcffQQeA1ujRo3AYyCTEaeBk8EPeTecBs4KrfQw0uzbty806nAgPHLyeIPZCMNPC2waUBZj6R08BnwHUAWyPREWuFpQ%2BGNUlNUuWxffbWB8ZNtjIhl4jC8J2cGwXhlyQjQigdFHgj6CSkJyPQYa8AJVIy1UxGsQMPVCnoLPr%2BPxFTxeHY8n5zEKAd8oDtT7RlT4RSX7R8S36WRYt5Hm57kNWjQp2J12g8OhtNpVVuy49A5nTUNDYXlpjbrO7Mb5sJ4eFRpd%2FIPES7GnH8ddi7t44pN3Xm%2FeNDyiSfiYL8Zee%2FJYrtbaNCZaB1uPIvvmneltO%2BT5%2BOolIquM7%2FBjHAF8g4RRCXh6X5E5LEAbGlTpJyvylxWFBBU2CisIC8kPCc7397%2FHMKkCRhkarAoJTvKVnWrXoWDTzxQL37oGdqQ8PIaVuoBWrVZ76NjJbl16jXj9rRvnrli1yKpFLwlkGEh%2FwR9rpgoGgBU3wB%2FDhcFD6w6rx2A5AbiQoAY1sO9UanVVNcUWVSHNfGD6%2BCNDWGMD38dEGBMRWIhMG9Xetm2TS1tgNlQixdTSoNLrNVaYttZp6JlE9bBx10Ki9jPkNiEqPr9a6hPfrOWhnn1XDRw88623f5w9Z8%2FOX7PSs5x6rduiog%2Fv6Ya8Y%2FJrbGUCzURUS0SV%2FlGur7%2BhDx65zfBNwWWxfAX%2BcNqYaJgUOE0QGh4egxXueEw%2BMFs8PAbmIHgMlU5%2B%2BkLJG6P0vpE2YagqJApxt5qQVuaQ9nnRbXdFhKwI9bvSqn3OKwNud%2B21s3%2FXQ%2B%2B99uzzjxRff1U3Y3Ltopman1c6Lp1BmCytKqeKWqpVU4PerWqoKy7auX1b65i2AqmUiEWEz2%2FXufPOXbtU9UqH0egyIV4HviXo1mHnNmY4S9V5lBigNTDt8SJkGDdu3IA7zZ%2BWRvznegzcU2g%2FGTZsGG6x%2FxMSA5eQl5f36aef%2FlN3DlAr48aNg4spLhlTids4BDgEOAQ4BDgEOAQ4BDgEOAQ4BDgE%2FjQCKCtAYmDDN6C2elFSQR8OBgP1lLfmwgFgG%2Fbt24ccExvcNbF6ry2hVVlUXkRry10lBYaCnILkR7%2Fs3jZpwTfj581esXvn%2FfR0E1sBe0pKCBVsLtgv6G3WKnntgvkLoqOjAwMDW7Zsia58eId66zjvOjVb3Xk2OI4iqxHLu1Kp9O133om7n6CzoTGANat0Yw3ejKYHt1tl%2Fn%2Fs3Qd8VFXexvEgSFcUpSMggiCKigUIuq7ttbxr29W1rm3XdV17AQs2EFQUlF6l996l9w4JvYckhPTepvf3d%2BbivFlWEFDRkGd2Nkxm7tx77vdO%2FHzOM%2Bf8j9fudAdMaUsXoYa%2FKJSZmfJVnwXR%2F7Og6TWjL6r3fuUK71cpP71aheSK5fOionKioooqRBWfE8Xj9KioFH6l1OQ55Vgyw82DqChXFA%2FKecud4z%2FnnGD5CsEKVXzlLnScUzf1nPqx5eusanhVxuvvhzbEhgopTMphKUlqFmyl%2B8%2FP5Lz85Rs3TJ47c3vcbnvIRSzAhJkDaZljxo%2F%2F6P13vurywawxQ%2BZOHP7N159179F11pKF6U6GCIR9Cl2h%2FYmuvoPWXNk69qKLUs6rll2lfGHFcrbKUfnlo7Lp75eLKihfLpMGE7BUr5ZatVpCpUqJFSumVKmac94FaZUq5VauZK9a1Vbh3EMXXDi1Zcs93%2FYOJR8xk0p%2BGI%2FBkAhamO2wL1%2B97s477vnH0y%2Fs3rw94KTGBf9jOVAzJIMGk3UczTGYGvNDjsEkGdP5ZKwGRUxZIsbNqjE5CXG7ipN3hTYsTb%2Fv%2FswL6hVUON8dVdkTdZ67fF3vlX8IjvguN2nLppjF29aviV29dtOmTQmJ8aHs%2FFBsvLdLn9VXXje6XNRK8MtVzqp20aLGl8V98LFz1bqcmB2Ze%2BMK0nO9NMzNgrS5oZ1bQ692tNW7PL9cjWDFmp7zGhbWapkbfZ939KRQXnY4FjBzRyg7Si0PJvTgWSLHCIc0VPIgzSBD4LNjZpd4QqzSunj5wWf%2Bdbj5tYmXXL6zRav8Bx8O%2Ff310N87Fr3w%2BrLnnhr%2FxJ8TO38UHDrWP2OGbeMix65Vwe1rQ0vmuqaNzJ02zL5yRih5T8ieHXLmhey5IWdByGsP0WKXY9%2Bhg1%2F06tnsqtbnVK0WdU6FGjUufPbJvyXt3R%2FiOlP60%2BE2E47CAQZ%2Fa5Ebf1x8%2FvlQ87fGXxw1dXnp9P6iTz7H4K%2FvwQcfXLJkyWlnJiVbSIMPHjzYqVMnynv%2B92AMniGHnD179i9yrJLH1WMJSEACEpCABCQgAQlIoEwJmDDAy6Ka%2F7%2FuKo%2FpT3GjSoYVKTAMg64HzyDD6pCM02BYeCDo8wdYCyLV40oO%2BHKDjpwAXbmAx%2Bv3JOZlbTx8aH3iod05WRkuj52iCPTw6VjSNw4P%2Fme6hdPjYZjHu%2B%2B%2By1e3H374IXX%2FOC5HtBYA5THHolsUboifIRmscUCxAYbcp%2BdkFXvphYa7eNTHcPq8GYVpuw%2Ft2bnrSFaajaVEQk4vAUtebsbwsfPufXhs06v71r2kc4NaXZrWmnlp3QN1LjpUseKWqKiNUVFxlSpmnn9harXzkipWzqxSPavyedkVq2VXqJpbvmpe%2BWoF51QrPKdaUfnq9grn2yrULChfp6Bqs31VGs%2BKOm%2FiRU33vPi2f31MqNhJjzjAKqzhvjMxEIt6JqdlTp02%2FaueX2%2FYtJ5%2BuM3nznI69yenvv7mG1c2b3pdy6aP3nvbjNFD9u2MOZx8OKu4iHoEDlOFkn%2F8ofgjuf0Hzby2zeJGDWPq1tx2YaWtVcptqRi1rWq5A7WqxdWrubfWhdsvvmBb7Yu316%2B3qdbFa847b3XVaqurVlldudKOi2rur11rR%2FWqO6pUjG3SeGybNjHffBNKSgxRmMFdjK6HxWBMTGGCn5SMrO5dvxjz3RhHDiVJiQDoQVOTlFVYmLNjxmyEIy0uGXVKKZ9hZmLwqvnT4DXqYjA7xUuK47Tlpvsz4oKLZu279Y69lWumla%2FhLl%2FTcW6d7EoNnTff7xs9MnbjvCGj%2Bw7u22dInwFDhg6bOXt21s49oZSc0Jot8c%2B%2FOKd2%2FQ3nVissVyOlap2tt91VPHlKKCM7xNAUmy9EhU7yCJ836MwN7IwJdf8y68obU6rW9lSpb6%2FeKLFSo51N2tkHjQ4VMCTGBGV8PIkHzJ2xOeH28zP8iTMRDWdniKlUylgI8jq3O2RzFGzdOuLZv%2F3rgmrPV6%2F0j0Y113z2TnDFotCGDf6Na%2BLXfb9p0RTbtk2hw4mhzOSQn0VI8wO5iTGjB372%2BJ%2Beu%2B26t5%2B6N27LklCAyp0FQW9BMAAqCYr5I0nLzRk4YmSzK1uXO7dSVFT5ClHl21517YwRY12UjQUy%2FJkxy5eE%2F9Cs%2BAJX61cru7A%2B%2F0b7tG4nn2MwFIqlUX%2BRahW0mZDzvffea9asWYUKFY4pi8GvtWrVovQHk2V%2B5tmdFoneJAEJSEACEpCABCQgAQmcVQJWQEEHii%2BCI10MHrAWJB0iIgu%2BY924cWNycjKnTbeL0hl0RrLzsvM8eXkh1jbIYypHsSfP5mWZUj%2Fd3MJQiF4fCyuG%2B7vECma%2BB11KVrx0uwLuAD1gE0MwAIOOD2Mt2D%2FxBXvmJ0e0cMk0uNEqWkILCU8Yr56RmeHxeVlWw4zL9%2Fu8xfZd6zd%2F1enj15554em%2FPf38q%2F%2FuM2bIrtSDLI4SKirMmTFv9P8%2B%2FFrVC16oWPmxqpWeOL9K%2F6YN4m%2B5peh%2F7k66oe3Ops2OXHNd4U23Zl7b9sjlVyU3uyrz8mtyW7TJu7xNYbM2RZdda2t6re3Sa%2ByNr3Y0utrW6Jr8Bm0yGrWNqXf1hOqNRjW9dte7n%2Fo3bwsVOlnl1QwCoAdLFhG%2B52blzZk%2Bq9eXPTZvXEew4%2FQ4893uvYmHH3roIVaPoGdbq2L5h%2B%2B6dcn82Q4H9UDNLI1CMgJrUkRqVt6kKaueemrPo39Jf%2BSBw%2Ff8MaZNiyVN62689vLD996W%2FvD9yX9%2BIOnBBw786X%2FXtms%2F7PwLukZFfRYVxU%2Fu8xpcknj7Hal335V65x0Jf%2FnziuefPzRhfCg5KWQrIL3geoYX6jA5BsRun3%2Fn9t1HDiWZrj7jLMz8EZrAgBaPk4oY4diJ%2Fr%2FplwfJBkwGYpaE4ZkfxmMEPSzGQvFMVzAroWDCyC0337b1%2FHoHK9ZMr3BRcuV6By9o5nny5eCShYlJ21ZsWb5yxYp1K9etW7dp645deckpTMEIpSS7h4%2FcfdefYi5unFGxTnLdyzPf7OjbsjGUVxDKd4QKvaFcb6go6He4YjavGvTaC%2BNvuXlx46Zba9RNrVL3cMX6m6LqbGjUNqNbH8%2FeuCCjYrxm7gh1SDkHC5LT4kPH4B%2BCGIZiELuF%2F%2FUF%2FYQelGqhSoar6HD8G%2F94rFK5qHPKRZ1ft%2BLXQz7Oyj0YCBSyEktaICfOmVrkLQhQPcTDARh%2BZEtL3del40uNL65Mida6dar1G%2Fx5Wn6CM1RkC%2FI5cLlMRmToEjIyvuzbr16Ty6LKnXtu%2BUrnRpW%2FvGGTbz7%2BjEEmIVt4PIZZVdjkGNZnm787PuE8th7w2IoWT%2Fu%2FMiefY1DekwVeP%2F%2F88x07dkT%2B9E7juIweYVAHkUiTJk3%2Be40SK9O49957V6xYwamdxv71FglIQAISkIAEJCABCUhAAscI0HWybuGulRmhQYhhZQiU91y9ejVzSfjJYgoTJkygOAYlQPv07TN7yZzDBUmFgUK%2BrHf77UwZYWCHwxewM9XDfHsdKmCVSyIL5izQS%2BabcFfA4fI6AkwtMaUXuHEIjstBeUBqQU%2BKXzk0DWDgh%2FUrP63unmkh4YUZNhDg63ef21WQnjmy76DGNWpViSp%2FzrnnRp1b7vo7b5m2akGBIz%2FkcGQtWv7N3Q%2F%2BMSqqfVRU66iollFRr13eZG%2Fn90PjxgXHjCnu09fZt793wCDb173yun6e%2F0l3W9ceru49vZ%2F19HXt6e%2FSM%2FBJz8DHXwc%2B%2FCrYuUeg85fu9z4reP39I6%2B8s%2FP1jls%2B7JIxc04wMYkaC36KS3AydJWdwaDTLOfhLnDErt04Y%2BKkg3t3U9nSGzCnvO%2Fw4Y8%2F%2FvCmG667%2BrLGLevXanJRja4fvn%2FoUJzN483zBgrD5UPMftjhtm0FUyb5p4wPTRoVGDU4u9cX8R%2B%2Fn%2FbV584Rw%2F3jxvvHTfRPnOocPeHIl9%2BOv%2FvBt%2Bte%2BnKN2m%2FXavBp0xarn%2F67a9DQ0KSpoQkT%2FRMmFsya6d6xPZSTZXIMD6MFzKgFCknQ0aY9LsbNsGgGq72YchI0mmTILPdBoMIjgg7T2zQ5BtEMYzIIjjyYm2cICLi6RWZH5AZmBE5GfNrEEXuf%2BNuRm%2B5IvvHmpOvax7f7Y9zt9%2Ft69qcOpz9Y7GAoCHMmOIgvVGB3m2kFAVeoMDu0LSb1gw8WXXnt6hr1trZplz9yWDD7ME315xblHTySdzA9eWfivh27%2B%2FT7umXN81gEp3vdi6c3brS0Zt351Wp9F1VtdL0rFj73auK8JcFcKqKYaIxRPHzYyDFoF0%2BYaUzmo0eEQUZjzs489FODlc8is0vc7tzsgb2%2FuOWWNte1bfmHe9qNnz06vyiTYRUUWiHMsQVZYsdHisMnkvqcDnfxgV1bX%2F7bYxdWiGI14loXVn3tzX9u3x%2FrCjmL%2FTZHkCVrOStDl55XMHTU2CbNWkaVq1i%2BfEVikiuat%2FhuwKCMhEQzDsTjZuAR2RyfautPAJDMzEwGHfGT2NBKM8yfx%2BneOLOTX6%2BEsRNU7iWCoEQGWaXVqpM%2FMn%2B2BIwjRoy4%2F%2F77GXHx39NJTDnbcuVatWo1YMAAgsqT37O2lIAEJCABCUhAAhKQgAQkcDwBei50fOg9sYGVGNDdoG9lBQjUx2CxEoILan7yvW2jRo34DvfCC6kOWPORxx5ZsXZZYXEuc1PMBARvgOKbXpvXSy%2BNrmN4kD3fktN99JmRGHzLTJhBEYkgVRo4GMfioEQWhBhW341n%2BJVD0zmy8g3SjIyMDMp0MHiDG6UI%2FWbfdAK9VPEoTs%2BaNmLsLde2bVKvYd1LLqnboun%2FPvP4jDVL811F9LyLYndOfPuDJ5o0v%2F2CmtE1L2jfsM47Tz%2B6deVid15mwFZAic4gNSSLixi5ESpgDECBGQnAPbcwlFMYyi40VTe5Z1j3vFBGWujw%2FlDS%2FlDWkVBuasieH3QUH9i%2BffWSFYUUruSsCCMKA%2BaBzZ93JCN%2B7%2F687EyCHAYIuAL%2B9NycvXt3z5s%2BpWeXj%2F%2F9t8evbnLJi88%2BPWnixLmLlixatzm5gMk3zImgZinzHYpDuZmhjKRQ0oFQ2uFQdlYoPdM0rMAWyiow7clzhbKdgfishO%2FXzuzef%2Fhbn37%2F9cAto6bkr40JHckM0anPKQhl54YYLMMJOmwhF0U5WSCDqhAmo6CBdp%2B%2F2Olm5QyTVrj8QatiA6MUwp19rhhPhz8NXDuTZbBd%2BAITEoQzAnsomBdgIIaZj%2BR3%2BfKOONYt9Xz3XfDr3sHe3%2FoH9fYOHeAeMTy4bk0oN51wh88C%2B%2BRo6Tm2Jas3Tvt%2BfnzWYTe1WIuz82dNWffi31fec1fMSy8UbVvtChbY3AWJ%2B%2FYvnDJ38aT54%2FuPGTN8dKcPO9WsXuWSclFvtW8x%2FfH7Ft3%2FP0NbXv7PqKgP6l468flXkhauMkupcIBwjmGijHAbPQGmeZhiLJwIJ%2BbymxyGD0%2BQz6DJ1ChS4Qk6HYf37106b%2FbUMaPnTpuWlphoFlQh2DHxTng8B91ua%2FyKK%2BjLKU7YtO3fDz%2FGaqbVo6LqVK3y7tuv79m%2Fm6EV5h7we%2F1mRAtHd3p86zfGPvHUc5dd3qpuvYaNLm366JNPbNiyyebik12UnH74SEoiH3zwuPFXlpiYuGzZsvnz5y9atIhMgL8%2B66Xj%2FcH%2B5POnlGOQM1Brt2rVqtHR0V999RU1TBj7RHrJX%2BIJDsQhcnNzDx06REnSd955p0WLFqzRfLwQo2HDht26deM0Oa8T7FMvSUACEpCABCQgAQlIQAISOEkBOhdWjGDlCdZja1wEQyP4jnj69OmMwWABhc6dO7NGKvU2iTLOP%2B%2B8h%2F%2F80KoVSz1Ou8ks6CUzraSYCormi%2FAfen%2BhAEuheujtmpH9fB3OxBNWRXWaARV8dW4GY3BQq500g1%2BtG09y4%2Bjp6emxsbHMPWElx1WrVsXFxRXbixn4wQwI0yu1O7PjjyydOe%2BLrt1fe7dTt8H95m1em1iUQ47Cq5RvKNi%2BZ%2B3QkcPe6dS%2F09tje3%2B1cd2KLBdLjASyvU4GFNAd584X6GZ5VMaEEK0cbUr4kdUfDucwZlgCRSMCeaFgfngiiD0QdKSnJ3%2FxefeHH3pk6fxlrny%2Blv%2Bhz0v%2Fz%2B33ut12j8NBIRAzq4bv%2BL0up91RkHtgW2yvTz%2B%2Bqknjh%2B%2B%2F%2F6%2BPPNKy9bV3PPCX2cvW5Dq8JqNhAgdTHkgeWGGkKCvkZGaFiRK4mUkrVr7Aahu57v0xBzYt3bR0xtJ136%2FOO5xdlJoXt3V3yoEEr535DSzM4TIrtzAZgqvDKAx68SwEa6aWsKaqGYXBtAa3k8oeXJ3wtaPYCavEknOYRMD83xzVCPAPIxi8TqYZGYXwk1y%2FAj4mJo9iN2Z1mNyUUPzB4O5dob3bQvGxoSO7Q0f2hTKPmKVS3E5G27BDuz%2B4dV%2FCa%2B9%2FcttfHho2e1JaUWqIcqzZScED24Lb14cOxgZcaQUhW7Itc9369SMGj%2Bj1ac%2Bub3fp33vAWx90rFK9So1KUZ2euyfh%2B9GOZVNnvfL84xee%2F%2FXdf0qa%2Bb0JdviMmc%2BT%2BRgStdBkH4maz%2BfwetzEC%2BZ8A04fw1EYzMMSIQyy8DhshY7iArMwrsvjyM7PSUx1ZheZ%2BiQA8AEmu%2BBqMuyEOw%2F4iJhAI%2BjIzOv23odN69avc%2F55LZo0njhubE5OtpvBFYQn%2FqDT7vLhyWUKhGxFrtiYnX37Duz4Xucve%2FVavHJFjq3QGfTuTNg7dvbEWQvn2J12PuogktRt376d8QzffvvtwIEDV6xYQXJoff7NVT%2Bt2%2FFyDHKG4036IM2oVKkS9TlvvfVWCnXyx75161YiRAIN8kNGiZAu8pNEkWo5KSkpzBEaNmzYv%2F71rzZt2jAM40cLYlgjMerWrfvKK6%2BwN873tM5Gb5KABCQgAQlIQAISkIAEJHCsAAECN57lJx0ouhs8sDpZ%2FKTbQobQt2%2FfpUuX8F1t7969X3rppeeff77zBx%2FMnTE9NzXFFE40Mw98IYfHfDNOzUq6fsVEGRTh5KfP1DY0XZgAwzGcfhdLkVJpkRiCo%2FCdb6QIBo%2B5RVpCX4wOlNXFY0R6r%2FCNoe%2FpGWlu0yt38x04a63yNb%2BroDj%2BUPyu%2BLi4vKwsn4sKlHRGTYeWshV2VyA9uyDuUNaB%2FblHEpzFBcxx8QUDLuZU%2BOm605E0Ex44Z074aDfY6gnTuvDd1I6gaojZwB6kXkOISTN0d10Or33L9thnnn%2B%2BVavWQwYMy2ekhOn8mzuW%2FEvftzjkN5UpmHrBdAOviTYKszO2rFnV4%2BOPrmp66V%2Fuu79D%2Bw58GV6vafNuvfsnZObQgeY7%2FaAZKuAMuYtCbpYcLXZ7WPbUYDG%2Fgc4yUYvP5U%2BOT5k5aeaQvkOGDxw2f9bc%2BAOHtsXEvPrqK%2B93fnf7rq0eZkWYeIEpDC4z8IDhB%2FTfjRedejOph04%2Fe%2BK8sTBthdGUlgiPmzBxDoFF0Glz%2BVmfFBW%2FL7c4f%2B%2Fhg1nF%2BezIfFAolmH3Ovx%2BClzazHqsxUFHlpknkpvhTthxeOfSuD0rd8Us3rN5RV7cwVCene05DuVME%2FNs%2FSdMebPbZyt2xOa6CgLESIGikD8%2F5MsmW3KbWiuOPL8zMy937964WdPm9%2Fy8z4TxU77t3%2BeC2jXOr1Xpk64vpR5aWxi%2FYewXnW67tPanTz%2BRtinGfN4oCspZYEMk4%2FE5nG7mseQXFsUnJaVlZ5Jm4G%2FuxHXmhAN5eTnr1q1ZvGRhakqyyWpcgWARFTPCAzBIikwMFV5%2BhpgGACIzEwv5Pf6ALeBftGXdB726%2F%2F3d1z78utu%2BgwdcRQ53EXNQjKnfwRZsGvZlf3ZvRlp2QmJSUmpadnEh2Zot6F25bf1n%2FXsMHD2syE7lVXPjT4ziEocPH6ZKTHx8PLNLGHTEnyEvIX16tx%2FNMQgxGEPVvHnz6tWr%2F%2BjACWIHbqQZ9erVu%2FLKK2%2B%2F%2FXZq8FJct2vXrmQs%2Ffr140%2Fwk08%2BIZT461%2F%2FevPNNzMG4%2BKLLz5BMMJRCDFefPHFDRs2%2FCKLup6eht4lAQlIQAISkIAEJCABCZyVAnSarPEPfDvMt67kCXSvrM4UHRC6V4yFSExMpH%2FEV7GkGdyIDRz0O63VH6hxyNfdZhVUb8jmMV3LItaboDxk%2BLt%2BXqWKgtfh9NkYyu9hqoO7wONnJgtjFtwMuuAQSUlJ9OPoxHHoyHFpDBP214ZvDMngtmvXrqycLJff7Q%2FQX7X7C%2BhB02GnGKSLLnEB0x2CXlMbki46382TTzjddGtND91MRgnPqzCPw312floPCE%2BoWmBmVYS%2F0TfjHsJ369ejT7IpvXqnM2gv9he76Mf73Wu3bHr0qadaXtl64KBhuUzlMD1pc6cfbIIcszxpMC8UoBgEAYHP5ynMz92wauXA3t983PGdpx5%2B%2BPMuXR%2B8%2F8Fy5Ss2at7qq%2F6DD2eZHIOv9sk7Aqaugyvkt%2Ft9DncA4oCT4RxMXDAdX1rqz8%2FN3xqzdd3adZs3b96zb098UsLsBXNv%2BMONdz5019zV83O8%2BU4TITH4gpMK17c03XXOxAyJMSFGuJ2mrTxtEg3GyoRjHHPiJiohw%2FEQAZmJJwGX3b55W8x7XT6auej7zKJ8FoNx2F3xiUmxB%2FduPLQ7Lj%2BZKMPnYyRFjic5bt2Sqb1GdO81%2FuseI3sMHjtw6%2FIVzH8h52DCBSDUfd2Znb2J3rrTXBU3xVSCxbwSCJGQFFBIk4VViGBoHovAxB1KWbBoVWzsztVr1j753BMPPnPf1OXjM2wHk9K3Dhn6eetWDTq%2B9kLirp3hERSMCmGURaC4yJ6VlVNQWGSzO3bv3Ttp6pTvlyw%2Bkp7GqAwUsDCjJUKhlLSUMePH9vym5y4GkIDA8QgxSI8KvPY8lszxGyPiCFY1Me0LksDYiCjM1QwdCtjWZR76%2FuCWmLT4Qj4OTkal8EEKZxfWsA2uIm%2FnQ8A%2B2XnYOBwfsSv3im3rvhrSe8SUccUMlTn%2BjU%2BvGe9yulHGj%2BYYRArPPvssgyjIIVnv2EotTvCTFILJJixoQunOK6644qqrriK4aNy48UUXXUTWcYI3Rl7iKJTdIMQgqzz%2BueoVCUhAAhKQgAQkIAEJSEACpyNAj4nggqHjjCTnxgx9ulF0prjRJ6Ibwlh3XmXX5AzEDqaTFQg47EUFeZluVzFdW4IF%2BsOmdgXdbsZm2FgPgq%2FtGadBvkG33uPx2ewe4oviQMDucua7nDb2S1LBVJGxY8eyXONbb7312WefTZkyJTExkQZY1T45OsflJ4emMYzQSElNSc%2FOsDltXrvNn19EFUlyDJfHQ185N%2BTLp6gmMyS8ZpwFnWefm5DERZtZeYVKHOG1Kjx%2Bl6MgJ9NVXEg3n2kHXjff2HNGfCtv1h%2BN3MMrkPBtvMk7wkt1MPXE6%2FfxZX%2BxL0ApC%2F%2BhpMQe3%2FT6x79fXrJ6TaHDafVlrS6s1Yt1m1ER9IkZ4WEihOL8vO1bNs%2BdPo1SDN%2FPnr11S8zXX%2FX84613PPXs35etWZ%2FvdNsCARvZjlUAxAzNcFMGhK43vWCn181oFhOvhMeQMCWHcRpeH9DkL75CV%2FGareuefvm5Vz9%2Bc83eDVmBgiIz14L8IzLEgv67SSjMHsLda5O3eLzFhUXOYls4x7CqmbAJjTVjUY6uw%2BINFOTlT54%2B9dr2bTt98tHu%2BLh8uy0uIXHOwgVDJoweMGnkgthVqe4cR5CimIX2jMSNm5aMmz9mwtKJ45ZMnrV0TsKWbcGE%2FFAhq3NQGTSUHV7FhuVsqDIaZHQJeVSQwS2M42E%2BEg0OXwLTAFOPI9%2FmTaSqSF5xXlbeqlUrFqyaf6QgwRcqLMhPmDZ12H0P3DZqzJDc7AzmGAU8fh8jbJzuObPm8BGaOnVaVlZ2XHz8wsWL12xYn5aVyWIm5BgmyjCBSjAtI23MuLHdPu%2B2bed2rhqfLSIHmpSWnh2XlJKeXwg0bSD1cFK01hQLNaVfyM4cwQCjfRhjkxt0mWE2Jv%2Fh8gQ9Do%2BXk4gs%2BGp9FPgQ8GkgjWAMCDczosa998CeOQvmrlm%2Fxs0Un%2BPf%2BPBTfYI%2FyeNvcqJX%2FjvHYDrYM888Q6le%2Fpq2bdv29ttvszrqCYZSRLKI03tAwY2WLVtSN4N1jvjrO1Fb9ZoEJCABCUhAAhKQgAQkIIHTFSDKoI%2FDgqoUxCBeoA%2FFM%2BQVdKl4THZB54gkgd3zk%2BcLCguWr1k%2Bdvq4nUl7coN2W8jH%2FAGHGYFB95kqGdSLYHIEtRqYBWIrLMpyeYtc%2FiKXixEKdoo0eAgiPJ6cnJyRI0e2bt2ab3ipE1i5cuUOHTpQUJSBGVauQn0MtqErxBEpKjhnzuxBgwcN%2Bm7w%2FEXzM44cYZkUsyJouPJGUcBjTeKg%2F08nlF4%2BYz34It5BAU6PKzMnp7iomJVaM9OT58%2BdOWRQ34kTRu3cGZNfkGVzFnqDLqffzjISZC50R%2FlmnvkYzh%2FujnCVSzO2gdiAZIZKIOD4yEg8e%2BIOrNyyKcNu4yh85Wz1iOl8mo4r%2F0eLX8yIDrq7zGnwsEYIi1%2F43G6vi1zFt2f33ukzZi1bsSq%2F2MZytIUuF0VS2Umhx51tL7KZ0pS8md4yC3Oy%2BAa9YgbJYMG2BEleNyUtAhAwhsGdVpwxY8WchTFLU1xZhZTVMCNj6D9GQotjPxbsiM7%2Bju3bDx08yOwacxCzngntJMTgMSkIkZQZm5GdlTNg8OCmV7R84vlnN27fVuCw79q%2Fb86iBSOmjR86bfS8TcviClNyAkWMUbEYbf7iIr8j21%2FEzBGzHmke03FM2kJ8kclYCJZkIYoh5nIx8YeIiEvkdAdNiGE%2BNua44YEkHvN6YTDk4qRJdlxej89Z7Mg9uDd20czxX3R595HH7x80atCeuH2MvjAiLndCfMITTzxRqVJF1veMjYl1ulxMHSJ4orJnOJagMiwfO8Zt%2BPMK8petXDF6zJj9h%2BKI2Kh4YpYJDnjjcjN3pScn2RgZYsaEMI%2BHtO7o1cTL5aZiC08wdoldEvzxoSTb4jCFFG0ptpMzmfEuhCDA8zbrbk6HoUGmZgfna8vKTok7lJmSSohz7CX54Xf%2B7phpwtpA%2FOn98Nyp%2FVsyx7Amdzz33HMrVqzgj4gd8ddEeEjFm5tuuomJISeYY3IaIQYJBvvkr5hJKAcOHLCOeGqt19YSkIAEJCABCUhAAhKQgAR%2BSoB%2BkxVQ0P1hwANjIawQg5%2FWA16lS0WUQQ%2FICjcIOnbt2fVaxzdu%2FtPtI%2BZNPOxibQx%2FdsiZE7LTpzZD%2BPm62V4cYrEMvzs%2FP2Puwplbd29xeItCQSseoGICX5IHUtPSBg8efMMNNzBzn%2FHqDGK%2F7bbbJk6cSFEOWk2IQX2MxMREDs2AkD59%2BrRv3%2F7iOrVrXVIv%2BpabJowe7cjKMV1vUxAjkOdxFATcDh8VPG2uQntOelZ2Tm6Rx5Xtcx9MT926Y2d8XHxOVvaUSRNbXdGi5oU16tau9fZbb%2BzZu5sFSOlOM1clXHEjXMsivICmtYwmX7tzZ0SEOZAJabibahKU8Cy225Iy0g6kHWG4QWRdC9NRtBIMOrNEEibd4C3mfMNphhmFQG7ASp50hemAO5z0rcklKFnqKKCOot1%2BJDNj2cb1M5csPpiaanGHYxMSBoYShAdL0I8O%2Bj1eSkFQCIJKHQx88RG8ZBTnZrvogxMo8SsjY0z%2FnbaYUMW6Wb%2BEf%2Bda79ixffyECUuXLqUB7JlcxgybYN9U4nCwP07T5Bjp6Zk9e%2FdtcnmLh598cu3mzaZmpgfg4nxHQUZxdpYzPz%2FozAs6yCqY2hOuMUpP3qxAynomLIZi8gsGMTDHKJwYUNwz20P6wu%2BUT6GaipOPCkcimTHTf9jeSVUT8gszRoIBQDTKhByG3LvvwO63X%2FxHy4tr1q9Rvfp5lWs3qtvp0492Hjxg93qKbMXTp0%2Fr0CGarvd1bdpQppIClZw08YOpf8KnlhNkSV%2FETSjnSk5J2btvX04BE3BMA5kUlB8KZgdcWX5nQRA9RlKYsStkINzDSRQpBM0j4qIxJpQIMtSHAUWMnzGVN0gvTD0Vdu9hSgxjZzw%2Br93tZ9IT4zTIYggxTALmD9ndgQJbwEal1P%2B%2FLD9cnqP%2F0vcnu9u5c%2BfPzzFIFSh28cILLzC5o%2BTe%2BAwy3oOlRnjJqpjBlqeRWpR8C6M7qLzB3JN%2F%2FOMfLLzyc8aTHAOiXyUgAQlIQAISkIAEJCABCRwjYI2v4Cc3Ojj8pNPHNtavPOBXHjO7hJv1K19%2F79y96x8vvXh1h%2BsHThkZ78jNCvkzQ%2B7skJvlSJgNYUYv0MNzMVmiYP3mNQ8%2F%2FfBr776%2BdstaF%2F1dhyPkoCdL%2F93jcDoPHTpEcMG8EuoHfvTRR8wroSYGB6I3R3xBrkJmQp%2BUihx%2F%2B9vfWCfFdJ3KR1WtUf21l17au3EL81ZonTPAvBIPY0JMSYzc%2FJ1rN82bPnP%2Fvv3OYDDVbVuxdcuY8RPGjRgzbcKUjm91uvD8i8qfc27VStVefunVvbv3s4gFfUoXZSl4EP4WnSyi5N36dt1UDTVLe1APgdQk4PN6DhyKm7Vg3ryVS7PcR8dj8C4DR7JCJ5weMv1wZlPY6LCH62%2FQKSbBCIdBbnrXtJuN6S%2F7PAW52fv37l65fCkDTvr073fPgw9ef8sto6ZMc1ijKtgbd4pP0hSOEf6in7EC1kwJv4fdmn2ZWqTELXSXGUFjxqSYba0owxyHG4374Y7qylUrv%2B7Va%2BKUyTYHwQFlQJl3Q47B20yfPcBQBjOaIZCVVzBi7IRrb2zXqfOH%2B%2BMOmZExZj4ODWL3oJgaHIywoXwEd9IZM5CDD5IJl1jDlFkj5BIB%2BvK0iZIQOaFgMT1%2BuvbkFeRCTMcxZT%2BId%2FjAhHv6PE%2BRWNIB2DhXTpY4yBu0OR0xO2JfeerpyypUqVuuwgWVq5SvVOH2P93bd%2BTwzTt3JKemjB8%2F9sYbb%2BDTcc3VV48cMSInO9tM5SB%2FC89RMkEGN9YxcbkZiHIkOaWwqNjFiB0%2F5VAJr8wMIFPGJZxZhauIoMvpc8Hc3E0GxZsN8dGJR%2FypsOZIRkFOrp0ioXziKZXB0CVyEi9TXw7s3bth1ZqYdRsO7dxdmJJuqriYiIbcJnwnCgtfe%2BuyHPOTZpIysTIIf3THvHSSv%2FL2zp0716hRg2CQkRgUlrH%2Bco95u0kjd%2B0aMWLEI488wnrKjIkiizjV4Rlsz7t4L2U0Hn300eHDh%2B%2FZs4c9H3Ms%2FSoBCUhAAhKQgAQkIAEJSOAXFAh38MwP9knXie9t%2BUlvlRtP8piet%2FWz5IOMzMxRo0d%2F8OlHy2PWpfnsWaEAUUYWowI8hdl5WdSgYF%2BkAztiNj%2F9z2cvaFirYcvLHnj8kZEjhtvTs0N2D4UZrcyEw%2FHtM0U%2B6VLt37%2BfcRc8z4Gso9Mk2kCfjkKj9913nwkxKpxTrlrFc6tVevrJJzYvXxlgcYeQnxyDEQisG0oH2pGevWj8lAFf9YrZvIXYJSfo25uWPGXqtBeefPam6zvcevMdD933yF%2F%2F8tS%2FX3x9zqyFOVmFThu91fDwBTOy4%2Fh3kgG3KVbKCitm9ILfu37z%2Bm8H9h09dXyOs4heMF1g%2Brrmulg5BkM0KG3Jl%2F2kGUxYMaua0qvm63oPU0SKPB4bOQ4FPNzOrVs2dHzj5Yfvv%2Fee2275403RV1%2Fd%2BrwLa1a5qNZr7314YF%2BC6cXTnyfE4AE%2FOSV61Tygza6Aj5SARMHKN8gVTLQQnuJCa36IPY52hmkaj7iH20jHds36dX0G9J86a2aRg6DBDMQw7%2BHOG12u%2BLhD23bs3LZ7b%2ByuPTPmfv9mp%2FcXLFpiszvpv5syI%2BQYVPJgaVfGcnC9wut7cKLkJ6aFRBsU2ODX8AfIpBn04hmnEKAeaCCzqCBu9z5Hana4EiynQyjkJdUi2jGfGYav2PjkQBwkNzITPNibh7VCfKn5OctmzOn1wpv%2FvOvB%2B2%2B9s1atiy%2BsV6dVh7Z3PPinf%2F77X717f%2FOHm2%2FiA9Khfft5c%2BfmZueEm0qZVMatmOiJjzKnlp6avnTJskWLlhTkFxL%2BkCsxisJkFKRMDL4IfxQILDgrm5cBSB6m25gowxTWCE%2FxoSgGE2gYxON2H0qMX7F25bY9Ox0%2BBltwTdiRPzUzbfS4MU88%2Bfj%2F3nv3vXff9ciDD3zx8Se7N2z25BWZgRnkNqYQbvjymM%2FKj9wws%2F40fuS1k3uKi%2Fv6668zv4OAYs2aNT8aYlh74u%2BagRNUsWBYFGMz2rZtS%2FRxvEVUzV%2Fff97Yksob7dq1%2B%2Bc%2F%2F%2Fndd99RdZa%2FZdp%2Fcs3UVhKQgAQkIAEJSEACEpCABH6WQLjHGaBExuHDhxkRceTIEUZBWE8yLsJKFawe1tEinG5PakJS3Nbdhbn5joC%2FMBRk0kiqqyh2766Na9YWpWZQ6KA4JXNwvwG1m1wSVa1SVPXK5c%2Bvdvfd965fsMKXR80JE4%2BYrISv98O1AqydW8%2FQ3%2BdJ6%2Bh0teiIUZyQ6gfnVjw3ivHvlSucW6PqKy%2B9tGfTFr%2BDHCNAr9Me9LjMl%2Bp%2BR3LGvO9G9%2F7s8y0bNzrMop5Bin%2Fu3rWn8%2Bsd%2F9DupmefeW7K1OnLlq%2BMjd2em5tPVMPqG05WBaEHTvcrcre6%2FCV%2B8gpfuNvcTr5zp0tsd9hWrFzWq3fPCVPGFzpZncXkGPwkyjDdON5oOuDhzjyZg6njQY5Bf91rKooyJsHvtfH1fdCXnZMxctiAZg1rV%2BC0oqLOjYoqx1Ks5c%2BtWrPW48%2B%2FuHbNpiD9eu5EShzY4XLbnWY%2Bg8vkCQQhHmY6mKVESTBYGNYbKKanHD6o6bgfTTh48eh5RU6HzX2%2BPfv3zV0wf92WjTYPhmYT2m7iDL8%2FMyNj1uzZQ0eOHDp23MDhI%2FsOHjZt1ryU1Awzd4KOPyEVCQZ5DrOI6JITN4SXG%2BU7eFNOghzDY4Yt8CufCoIL%2Bvim6oXDZGT5Htu2fbumTpu6O2arJ5eZR%2F6AiyqxLOHKlQpQepURIGbFXkpUUGSTPYerc9pcntSAK8FTnJGSnrx22%2BoJM7%2Ft%2BkWzZs1Nr7riOdxr1avz0UedX375pTvuuO3djh337d3jsNmIw4wCkYoZnhJkzZyAz39g74Hhw4YP%2F25ERhrlOrhG4UogfNjMYAqPiSLMu8gxfEU%2BJ%2FOHeEzA4g4yt8gM5%2BBkqfhJxuK0O9esXDl4QP%2FFi773uFnjhOE8ZsZRSmbKiLHDH3zkgehbom9of8Mtt97ywQfv7dga6yqmFCplb83QFJNrsavj3CIf%2B%2BO8%2FtNP8%2BfTo0ePp59%2Bet68eYzN%2BOk3cKUKC2NjY8ePH9%2BtWzeGcNx9993M9mratClhCKuWkFcw8YSfPOaZSy%2B9lFfZhvWXP%2F%2F8c961detW1jk6mQNpGwlIQAISkIAEJCABCUhAAj9fINzxNh1buseJiYmU2Zw2bRo%2FiQ6INejF0RWyUgW2tDajP06XMLy%2BKv3TcD0MBsOHQvGpqdOmzhg%2FeHjGnniez9x%2FuPsn3apffHFUtcpRVatElT%2F3ytbXjxk42p5RxHftTE4x%2BwkviWLNH7F%2BtaIMnudmPeZ5CpAOGDDgrrvuuvzKK5pdc9Uf%2F%2Feu0SNH5B9JofQEY%2FTNvBJWEiFLoLeakrF8zKSBX3wVu2mTM%2BRnERPKM1AjcufazbOmTF25ZkV2Ya6Hb%2BnJUZg7wQOvm8VmzdwBvp43HXn6vOFv3un88iW8dacLHvJTR7SYeh8czucqLMpft2bVoH69J40fY7MV8DbGR9DpDVfVCA%2BdCM8FMXNSGAlg6iqYPjXdZWph0NEPj6ogZ%2FBn5qTOnjb%2BqT%2F%2Fqd0Vza9oULdxrYsuOu%2F8clHnVKhy%2FnP%2FenVz7DZr8AMd6%2Fy8rOSUxOy8NKfXxgq21J5gvVj6zuHExMwEKUjPzE1MdWcWmnkcptdtcgzuR3MM%2FiFk4B7OLBhykFeYn5iSlJaT4WL5lfDT9PV5G4MQklOSFyxdPGnWzFHTpr7z8adPPPv3BYuXFxYUm3U7XO6grTjExAFqaNg9ZpkRYgcGIoSniaDAuXGSrqBZBpdqGIkEAhyVcQgFvlCRPS09Zc2WdfOWLty%2Ba4cjn4qkHI0NQhmh4OFQMI61T9kXyQ9rsYYXMDFGbn9cZka%2FpbM7jhvU57uh62YsiFsbO33MxL899UzrG29ocs2Vl19%2F7X0P3T9y5PAlSxYtXLggJmazg9os4U9PuOJH%2BNyoc4GW13%2F4UOKsaTNnTZ%2BVQ3EVKLi%2BxER2F4sI%2B0lmOA0CCapckIxRk8NMLiGSoaqLGRRjEipOEEOiqWLHjHET33%2F1tWljRvvcLJrDsBInaYjLb0%2FKSFy6ftm4GRNGTh47cfa0Lbu2ObwkF5Rt5fNGYEZQZK7JCW78oXE7wQYnfom%2F1i3hG38%2BJ96y5KscETNCS6qALl%2B%2BfNSoUV988QUrmzBO48knn3z88cefeuopal%2FwDM%2BPHj2ayipMCuM%2FGrzr57S2ZBv0WAISkIAEJCABCUhAAhKQwE8K0AGxMgrCClKLffv2jRkzZtgwvrEevmLFCmZ5RDop4X6hyRV4CzcqGfJlOmGFGQAQ7jXTOUs4fGTa5GmTho%2FJjktmaoAv1zZv1rzrO3SoUa%2FuhQ0bNry85RNPPbdu2TpPId19eoWmp0aCQe2LmTNnfv%2F99yxwQLfIep7DcWMDmseTRBkZ6RlLlizpO7B%2Fv%2B%2BGLl69Ijsr03yZTseQkQiMUWAZTHqY9FYzsjfNWzhhyHe7t28nRjBf7LMRXVKWV6Uggpdii7SUr8NJMZjl4fR4XUyMcDntbqedDqxJM47OLwhvSFzDKdO%2F83uKTAXIIJM5bC67y%2BU4sG%2FPpPFjZ0%2Bb7LTTETdvgyFcacHEFJGwgl4vxzfdas6ESTr%2FH2KYYQssRFKYm7F59fKRA%2Fp8%2FPYbr73wj0cfeqh5s8svbX5Fj2%2F6pKSlh2dWMJDDSYix7%2BDu9OxkE18cXRWVeiBuh7PI6Sj2Oh3ZKalH9h%2FKP5IWKKbcpul0W9NN6IcfHY%2FByA3TKTcdchrjYuFbsySKKbMRrv4Qns5AI80qpi44Ct2uPQkJH3Tpen37DsO%2BG5WTnWvea4IdAoHwui1Ue7BT8IEaF0cPhwJnRZkMBjAQHzHbKCUUsHGx7R5%2FesH%2BDZunTJ44c97shNSkAnuxiU%2BIFwgtQsEMRjKE%2FPEhdx55GFeSnCY8d8bkXU7Pii2bW91z6zn1a1SreeGDt98z8psBwwcMWbhg0cTp07r3%2Bebr%2Fn0WLlmUmZnucjNUxelhlRzTDs7UDC85eufEwxmOrbA48VBCQlyCkxwmHFLZM3O3rl67eN68LRs25KalBRxO6qfy4SUEC884Mn7hyT2mqSb6MJ%2FKEPsZ0rvvkw8%2BOLT3tz4GqFjFSgyNx%2Bl3Fnttufb8tPzM%2FUmHEtOTnQEWlzGVbWmCh0vOSJzjxxTmjyv8YTGHOd0bfy%2BnFGKUPA6fAN5OoMF%2FEPjzZKgGQ7NYQoUbj63IkVcZ9WH9hZZ8rx5LQAISkIAEJCABCUhAAhI4AwKR3ID%2BC90TsguWXmXlU%2FosdGciHS4eWDerSaZTRw%2BPrjLD7PmanoINPhZByCOLOLTvgI3iA3SRvf7M9MxJU6b88%2BWXn33hhS969ly9Zm1hXhHbmx6x6U8HKYvxzDPPtGrV6rrrrmOMOuMurM7RD0djeIApK8GT3KgfmJGVdSQ1pYheMN8Cm6UxzaIaRBnhaMKEMnmp6cvmL1i5dGkGuzLdS6Z3mHqPLFZh%2BuhkFNxM59b8ZN9OJ9VGHQf279u8aVNiQryX8hVsZuZ8mHIN%2FDRzDUxaYmo5Wl1%2Burdut4sg5VBcXFJSIvNSrB1yOAIB%2Bqol7%2BZEOVT4Hj606QeHu9ThnjaOfq%2FbUZyTkZ52JOlIYuKGdev69u37dc9eMVu30xk1%2FXFaS8UGt4t2Uuvhh5452QzDVFI3bFi%2FcuWKHdu2ZqSk2lmE1BleaiScV3AgFtagwAPnwW5Y4YQ7e8nMyOSb9JjYmEPx8RT5pJwnIzHo9nLetNDAhi8P%2Fe5NsbHPPP%2F8Zc2bf9atWzKjX2iNkTZ3tgvnAOGPgUmlzHnxkxkutIx8go8G3XtyJBaCcRUUJu7Y886rr1111ZV%2FffzR%2BQu%2Fz8zOMkN6eBcTNajOauKOYK7fVcz0IIpl2hyMnQjvkbqwzgVLl7aOblv5vCqVq1bpcGO7zh3f7dH9C%2Bq4skxJcnrawYT4hMQEcMJtN6344U6eAW%2F4w%2FbDczzDEBw%2BUeYDHH7D0gUL7%2FzjrVddccXNN900ZODA7LR0LDh2%2BPKHT%2ByHCxr%2ByIb3TY5RVDxkwMCnHntscP%2F%2BfIbMNQoPteB4rOeSmZN1MD4uZlvs1BnTlixfymI0pmSombxkriXCZlfHv5nPiaHUTQISkIAEJCABCUhAAhKQgAR%2BYQF6W6b3Fu5z8ZOBE4VFhXyNawIQemsBU3Bj9%2B7dkyZPnjBhAqsxklpQCdDqMrMNc%2BqnT5%2FeuHFjq3zgvffeu3btWsIKs1t6oOFpLPzKTngLz9B6vssmZrFm%2FdPvNj3mHzbmVWp3MIzkxXDVQSIR62zZgO2TkpJYyzIhIcEaCW%2FtkJccTgczaJ55%2BmmClLfefJPHNMw6Om%2FngQk%2F%2FqvjybMci4axN6v7bx3rNH7ShvC7jvZbiZISExMZGEOOdIK90STG%2F%2Ffs2fOWW265%2Buqr77zzzv79%2B3OO7I0es3WOvJ228dgKgjivosIiCj9SBPLWW2%2FlfB%2B4%2FwFWsz14MA6f%2Fz5HiFavXv3ggw9SEqFTp47QscP%2F3uyYRtIAbtaT5hKGb4QwtI3W%2FvnPf3733Xcpp2BdZauFHMhsZS6umzsNZrURMyYivB%2Be4cJ9%2BumnTz%2FzzFNP%2Fe2NN97o3r17jy97kF7wdj4Mq1avWrBgQUZGunUh%2BGldQV4NH%2FxoY6wmHfMz6XBS584fRKpXPvnEkxs3bLA%2BXdYn5JjtI7%2ByzYb1G7799ttZs2aZVOSHG4fmTOfPn8%2FMC0pf0tTJkydzRiY0CZ8OgOw5sh89kIAEJCABCUhAAhKQgAQkIIHfUICeGn20SL%2BeX%2BniMRydSCE9PT0jI4PBHnRg6cpxs7rYrFQyduxYal9wmzNnDvVFeQunEOn08Ss3tucZbuzfijWsbXgmsjEP6EXS3Z47d%2B727dvZjGesG8dibHxqaiopCtvwZLgJJhjhJZ6ns9mvb1%2FemJKSwjNH3xb%2BJ3zYH%2BkLW6dgNaDk9j%2FzsXWO1imfYFdsBubKlSuHDh3ar18%2FusxEQHl5ebyFhqFkNYyfnA43HnCj9x0fH0%2Fxk4EDBzLqY8SIEeyBsTc%2F2rNmey4Zc3m4QMz9sQo58uQJWnW8l6xD08EnJjp48AARUGQ%2FPKDB1huti0Jj2CDSJJ7kUpLt7Nm9m0CDZT25EY5Z7WGaA2fEp4hfrX3y0zrf4zWm5POs1kFWM2TIED5%2BgwYNImrjw2AdmuNGGlnyLdZjXqJVhw8f5oMdaT8vcWg%2BaYxK2rFjB62NiYmheSU34I0lf%2F3vPesZCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAr%2BggM%2FnO3jw4CHdJPCbCvAhzM7O%2FgU%2F2NqVBCQgAQlIQAISkIAEJCABCZx9AnFxcX369Hn%2F%2Ffc%2F1E0Cv6kAH8LPP%2F987ty5Z99fmc5IAhKQgAQkIAEJSEACEpCABH4RAbfbPWrUqNmzZ3t1k8DvQIBUrXv37hkZGb%2FIx1s7kYAEJCABCUhAAhKQgAQkIIGzTCA3N%2Fftt98uKio6y85Lp1N6BRgdtGLFitLbfrVcAhKQgAQkIAEJSEACEpCABH49gby8vHfffZc049c7hPYsgVMS6N279%2BrVq0%2FpLdpYAhKQgAQkIAEJSEACEpCABMqIgHKMMnKhS9FpKscoRRdLTZWABCQgAQlIQAISkIAEJHCGBZRjnGFwHe4nBZRj%2FCSRNpCABCQgAQlIQAISkIAEJFBmBZRjlNlL%2F7s9ceUYv9tLo4ZJQAISkIAEJCABCUhAAhL4zQWUY%2Fzml0ANOEZAOcYxIPpVAhKQgAQkIAEJSEACEpCABCICyjEiFHrwOxFQjvE7uRBqhgQkIAEJSEACEpCABCQggd%2BhgHKM3%2BFFKeNNUo5Rxj8AOn0JSEACEpCABCQgAQlIQAInEFCOcQIcvfSbCCjH%2BE3YdVAJSEACEpCABCQgAQlIQAKlQkA5Rqm4TGWqkcoxytTl1slKQAISkIAEJCABCUhAAhI4JQHlGKfEpY3PgIByjDOArENIQAISkIAEJCABCUhAAhIopQLKMUrphTuLm60c4yy%2BuDo1CUhAAhKQgAQkIAEJSEACP1NAOcbPBNTbf3EB5Ri%2FOKl2KAEJSEACEpCABCQgAQlI4KwRUI5x1lzKs%2BZElGOcNZdSJyIBCUhAAhKQgAQkIAEJSOAXF1CO8YuTaoc%2FU0A5xs8E1NslIAEJSEACEpCABCQgAQmcxQKnlGP4fD673e5wOILB4DEmbrebl1wu1zHP%2F%2BSvfr%2BfHTqdzmP2yTMFBQU8ae3Z6%2FX%2B5K5OaYNAIGAdlwen9MZT3Tg3N%2FfIkSOZmZmcyKm%2Bt%2BT2VoNp86%2Fd4JIH%2FU0eK8f4Tdh1UAlIQAISkIAEJCABCUhAAqVC4JRyjP3793%2F22WdffPHFwYMHS54dmcOwYcO6du06ZcqUks%2BfzONt27Z169Zt4MCBhYWF1vZZWVljxoz5%2FPPPu3fvnpKSMmHChC5duixcuPBk9na8bUhgioqKSiYw8fHxHPebb74hYTjeu37O8yQwO3bs6Nu3Lywffvjhp59%2B%2BuWXX06aNCk1NfX0dgsFIOyEB6e3h9LyLuUYpeVKqZ0SkIAEJCABCUhAAhKQgATOvMAp5RjZ2dlECq%2B99hr98ZJNJd9455133njjjfXr15d8%2FmQeb9iw4a233iIbyc%2FPZ3uihj59%2BrArnnz%2F%2FfcPHz48ZMiQ119%2FfcaMGSezt%2BNtQ2pBCNC%2Ff3%2FSDGubffv2cYiPP%2F44LS3teO867ec9Hs%2FUqVM7depEy99%2B%2B%2B333nvv3Xff5aSg44inF2UkJia%2B%2BeabOPPgtBtWKt6oHKNUXCY1UgISkIAEJCABCUhAAhKQwG8icEo5Bi0cPXo0ffOvvvqquLg40uC5c%2BfyJEMOImMqIi%2F95AOCi%2BTk5PT0dCaYsDGpAl11%2Bv7sMycnh2eseRnMMfnJXZ1ggwMHDrzyyisffPBBpIXM8uC4HO4Xn7FCM1auXAkIOcm3335LtkNwkZSUtGjRIkaA9OvXj%2BErJ2jq8V5icgosZCNkO8fb5ux4XjnG2XEddRYSkIAEJCABCUhAAhKQgAR%2BDYFTzTF2795Nb5rbnj17rPYQCDAHhG77tGnTrBoXjEbYtGkT80EYSjFr1qzIxA2iD%2Fry33%2F%2FPaEEQzgY1EHfnARj%2Fvz5dPx5F%2F193kKOQW998uTJCxYsmDdvHtuzQeRwHJQdLl%2B%2BnETlu%2B%2B%2BYwPmWVjHZazF5s2bx48fz06YmcK0DisbIa%2BYPn26NcBj2bJlMTExlJggHmG3S5YsiQQybLx3714GfgwdOnTcuHFbtmyJBA5MS6GFRCs0mKEd1qmxZeTUSl6ajIwMBl0wdmLQoEGRnVsbcFArnLF%2BpZwIR8GBIzIlhyEiHCiyK04KJRx4dc6cORs3brRkIjkGG3OOvJHzZfgHbYu8t1Q%2FUI5Rqi%2BfGi8BCUhAAhKQgAQkIAEJSOBXFTjVHINinlZqESmFQbJB7NCxY0dCAJpKRkGxC0IDOvLcmExBp95KIcgTGBHBbBHCDeZZvPrqq%2FTiSR7IQCi7YbPZ%2BPXll19mb9wie%2BABO6E7bzmwzSeffMIzvMv6ya62bt1KNDFgwAD2yZMc1xoOQfLAuzhcZLe8yvZkL4QGbEN7rHITBA4EIwQFkd2yEzrU1jgQAg1mvvBMr169aD%2FTQ9iMXxlfYU2HKXmNKOXBS%2Bz50KFDJZ8%2F5jFxB6M1OLvIEXlMphEpB0qAQ3tosHXDBGR%2BWjkGXKNGjYoosRPOi2IjxxylNP6qHKM0XjW1WQISkIAEJCABCUhAAhKQwJkRONUcg1YxtoFeM114a3USEgb69T169OBXhjTQE%2BdX5pgQOOzcuZNCl3TD%2Bcn0DYZbfPTRRyak6NSJNIBqFYxtIMegM042QmLAWAUGQjDYgxCAEQirVq1i%2BAQvEQsw3oBDswdCDCtAYCTG6tWrqS9KBmJlEXT8yRmWLl3KaBCGKLAfjsI%2BiSyIOPiVOhXsn1iDkQwMdeC4tId9sueZM2dyUjRs5MiR7JbD0QZazlgIggVuTKVhDxya9jDyhOOSKvArp3nMlRo7diwCX3%2F9dWQ4xzEb8CtWNIkjchSOxZkOHz6co7PD2bNnswFFMGgtLezZs%2BfixYtJjfi15HgMBqvwdiqI8l6iJNpptS1SAOS%2FD1panlGOUVqulNopAQlIQAISkIAEJCABCUjgzAucRo5BAkA%2BQC9%2B%2B%2Fbt9MdZQYMONbEDjWepEZbnoENNGkC2wAAMetlWnsCvjEAgN6BvzjaRORRWnU%2FCAVrCHhi5wdsJKyLzLwYPHsz%2BrRyDpIKIgM57ZG4FwzA4qOVGOsFwEdIDpnuwW1pIEEFlDF6lp89OOHpkogcNsHIMJrbQ97fiESaMRFY1ZSIJLWcbxpmQz5Bj8JjNrDKbpC7kJ%2ByfPME6euSnlWOQqJyg8gb7ZOfkEmvXrrXeyHHJWKxzh4LsggaXLEPKNBYaQNbBubMxZUvZgKEvTCfBliocjMdgn9b5RhpTGh8oxyiNV01tloAEJCABCUhAAhKQgAQkcGYETiPHoFNPJ508gUECcXFxVufa6t3zqzVmgO42D7gRJtC5ZmMmWVg5Bn1%2Fa8iBdYLH5BgM4bD68mzMBkQTVJmI5BgUxGBXVMD4URzawFQLRjhYEQFtoG0kGGxM5GLlGFZawjORHIPQg2YTAjDggfEhkT2TeJB7cLg1a9aQSJBj8Jigw9qAERpU7GSf1tSVyLt4QODAOTIi5UerZ1hbsk%2FeS0xB8BJ5L0Q0g2YT5kycOJHDMaoEbWsD4gvOi1cJLjgLEhV4I8485lXoKKMR2WEpfaAco5ReODVbAhKQgAQkIAEJSEACEpDAGRA4jRyDVq1YsYJeNl17xl3Qd2aGCP16nj948CDRAdkFQwsYUUBVT27U0uTGAAYWByEZ4NXY2NjIqZ1SjmGNzaCQRWTURGQ%2FdPOtmSDMcGHmi1WCg7ZZpTmsHIOBHJFyFpEcg%2BEcPKZV5BgEGpEdsrKJlWOQDBCncLIlR1%2BcIMcgOSFVwIE6opG9RR5YwQV1SgEki6DMReQlhlKQS9AMGMlq2IDZIpEcIyEhwcoxGOXCYBUyEA5ByMM8mpLOv8YyspEWnpkHyjHOjLOOIgEJSEACEpCABCQgAQlIoDQKnF6OQWeZTMDq%2B9ObJqawzj07O7tLly6kB1TJiGgwsIF1OviV6Q8kAww5sCqCWhucUo5BTQyGMXTu3Dkye4JAg9yAuST05TluZBQEwQW9ftpm5Ri7du3ijTQ48sZIjsHADxCIBXg76QGRhdUwToo9kEgwTCKSY3AU69UT5Bi81KdPH%2FbGmbITK%2BFh8REcaD%2BjOJjGYh2dnVMAxNohQz6YkMK7mK7ClhQLpcFwRXIJinJwOgQdLOFKe6g7Sq5C0ME6L9YeWME2MhnHeqaU%2FlSOUUovnJotAQlIQAISkIAEJCABCUjgDAicXo7BIAEKXdKtJhlgFESkQgUNtmZV0EOncOW6deuoaEGywYANa1lVK8ewsgXr7E4pxyAJIT%2Bhs89IBipyUFyCo9AMxn5Q25MHFO6gYAUjKDioNSjCOhZjGHiJDSjNwWgNkoT%2FrvPJbgku2CG7ZVoHiQFBwYgRI0gYKANijcc4mRyD82JwiNVOdsgbSXXIKDg0DWDlFOqIosGB2D9H4VWOyJQZNuYZsg72wOgLYhCaxNt5lTZDzY3t2TkbMAzDajD1QtkAAZINioKSgfBqqb4pxyjVl0%2BNl4AEJCABCUhAAhKQgAQk8KsKnF6OQZMoLMkip%2F%2F%2B978JNErO8mBoBEUk6HG%2F8sorvMqN7jblKJlAQQphhQOMjoicFH1wtiGXsMZsMI7ipZdeYm4F5TfZhoEHrHXy4osvEixYb2FpUTIKxioQCPBGjkI2QrkJogmWCGEuBs%2BzB3ZIDsAD61gELyQq7JY2815awogI3k57eGwdiFKi%2FGo1m52QKlCewpqHwkmxPsu%2F%2FvWvyFQRRllQJISGsdBJ5FxKPqDUBqMyyFI4IvvkRtsY9UHOY0UN%2FLSyIOtE%2BEnzWPzFGl8BKWM5OAXexVmwExpAy%2FmViIMDka4wQoMN2DMnws0KPUpmSiXbU4oeK8coRRdLTZWABCQgAQlIQAISkIAEJHCGBU47x6Brz1AKAoTIxIdIy%2BmDM3OEoQuMIuAnSYI1t4Iog1ETvIuDRjamGCbDNqiYwZgHniTNINlgMVZmSfAr0zGYNsJRSlbgZPYK21ABg3qhPLAqglrvZVc8T%2FkOWkXJUN5Y8lgkAIyF4FUyBMpfsDHtiVTa5FjUx2CcA9EEP2l2pDYFD8hPOBY1Nq2W84y1f2t0hPXkMT85axrPrsguGCXCiUeaam3JBuyEKSQckeVRmMByzB4olMHwDF5lhAlnzU%2Fio5IjLmgwcQfO7B9DC%2B2YnZS6X5VjlLpLpgZLQAISkIAEJCABCUhAAhI4YwKnnWOcsRbqQGVNQDlGWbviOl8JSEACEpCABCQgAQlIQAInL6Ac4%2BSttOWZEVCOcWacdRQJSEACEpCABCQgAQlIQAKlUUA5Rmm8amd3m5VjnN3XV2cnAQlIQAISkIAEJCABCUjg5wgox%2Fg5enrvryGgHOPXUNU%2BJSABCUhAAhKQgAQkIAEJnB0CyjHOjut4Np2Fcoyz6WrqXCQgAQlIQAISkIAEJCABCfyyAsoxfllP7e3nCyjH%2BPmG2oMEJCABCUhAAhKQgAQkIIGzVUA5xtl6ZUvveSnHKL3XTi2XgAQkIAEJSEACEpCABCTwawsox%2Fi1hbX%2FUxVQjnGqYtpeAhKQgAQkIAEJSEACEpBA2RFQjlF2rnVpOVPlGKXlSqmdEpCABCQgAQlIQAISkIAEzryAcowzb64jnlhAOcaJffSqBCQgAQlIQAISkIAEJCCBsiygHKMsX%2F3f57krx%2Fh9Xhe1SgISkIAEJCABCUhAAhKQwO9BQDnG7%2BEqqA0lBZRjlNTQYwlIQAISkIAEJCABCUhAAhIoKaAco6SGHv8eBJRj%2FB6ugtogAQlIQAISkIAEJCABCUjg9ymgHOP3eV3KcquUY5Tlq69zl4AEJCABCUhAAhKQgAQkcGIB5Rgn9tGrZ15AOcaZN9cRJSABCUhAAhKQgAQkIAEJlBaB3Nzcd955p7CwsLQ0WO086wXIMVauXHnWn6ZOUAISkIAEJCABCUhAAhKQgAROQ8Bms%2FXq1Wvbtm2n8V69RQK%2FuEBRUdFXX321d%2B%2FeX3zP2qEEJCABCUhAAhKQgAQkIAEJnB0CsbGxXbt27devX3%2FdJPCbCvAh%2FPrrr4cOHXp2%2FGXpLCQgAQlIQAISkIAEJCABCUjgVxLIz89fsmTJct0k8JsK8CHUSIxf6W9cu5WABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAESr9AMBTwlcV70F%2F6r53OQAISkIAEJCABCUhAAhKQgAQkULYEgvZU7%2FRrytx96pW%2BpQ%2BXrSuts5WABCQgAQlIQAISkIAEJCABCZR%2BgaDtiHdsrTJ3H32Bb%2B4tpf%2Fq6QwkIAEJSEACEpCABCQgAQlIQAJlS8DkGOPqlrn7mIt8824tW1daZysBCUhAAhKQgAQkIAEJSEACEij9AsoxSv811BlIQAISkIAEJCABCUhAAhKQgATKioByjLJypXWeEpCABCQgAQlIQAISkIAEJCCB0i%2BgHKP0X0OdgQQkIAEJSEACEpCABCQgAQlIoKwIKMcoK1da5ykBCUhAAhKQgAQkIAEJSEACEij9AsoxSv811BlIQAISkIAEJCABCUhAAhKQgATKioByjLJypXWeEpCABCQgAQlIQAISkIAEJCCB0i%2BgHKP0X0OdgQQkIAEJSEACEpCABCQgAQlIoKwIKMcoK1da5ykBCUhAAhKQgAQkIAEJSEACEij9AsoxSv811BlIQAISkIAEJCABCUhAAhKQgATKioByjLJypXWeEpCABCQgAQlIQAISkIAEJCCB0i%2BgHKP0X0OdgQQkIAEJSEACEpCABCQgAQlIoKwIKMcoK1da5ykBCUhAAhKQgAQkIAEJSEACEij9AsoxSv811BlIQAISkIAEJCABCUhAAhKQgATKisBp5Bi%2B8XWDE%2BuEJoXvE%2Bv4x9f1jjujd%2F%2BEuqGJdWjD6R93zEW%2BebeWlWus85SABCQgAQlIQAISkIAEJCABCZwtAqeaYwQn1HGOqb%2Ble7MJr7Qe%2Fe%2FWaz5pkTusYWjSmcsx%2FBPqJPZtvPqTFrTBM%2FZ0j6sc42z5AOs8JCABCUhAAhKQgAQkIAEJSKBMCZxSjkGIQYbwcNsbateIPr9q9HlVomtWj25%2FefvJr7UMTDzdSOEUx3KEptbu8tdWlSvedG2Tto7R9Xyn%2BPajQziUY5SpT7lOVgISkIAEJCABCUhAAhKQgATOFoGTzzFIDDzj6j9%2F6%2FVVKkaTY7Rr3u72K9s2ujj63HM7dHu0dWjy%2F8%2FyYOIJk03%2Be74Je7Ce52fJ%2FIHHvIU7IUPkgRU4RN5ivcqT5Bhd%2F9qqSqUObS41OcaP7vBoWHGCiEM5xtnyAdZ5SEACEpCABCQgAQlIQAISkECZEjj5HIOSFPu%2BaXpFw%2BgLqkV3fujazCGNi0ZcsvqT5m%2F%2B7%2FWpAxpZqUVgAmUr6tpG1c8Z2qBgeAMe84yVKlBPwz%2B%2BHk9mD22QP7xBYEK9yEuuMfXsoxo4R9fnEK4x9YtH1j%2F6lol1fOPrFY1owN74yd5MXY4SOYZ3XD33GHOsvO8asGVkh8oxytRnWCcrAQlIQAISkIAEJCABCUhAAmVH4BRyjEl1tn15WbO6JscY%2BHzr0PTaoUncTdRgDZYgRsgb3vDbp1vff327Fg3a39SiXY%2FHr84Z1pDnPWPrr%2Fm02Tt%2Fuu4PV7RrVrd9dIv2Xf96bdZQ81JwQt2xr1x5b5t2A%2F%2FehpIXj99047%2FuvNE2sj573tfr0o73XXdH63YtG7S%2Fs3W7z%2F56dXyfJhzXGo9xY7Mbt3S77OlbbrzykvY3Nmv%2F0Z%2Fb5Ay9hOoZPx1iME5D4zHKzkdcZyoBCUhAAhKQgAQkIAEJSEACZ5HAyecY%2FvF1ikZecvc1N1avEt2kFkHENQe%2BaRKaWM9aN4RX87%2B75OF2N%2FBqvQvbX9%2B0Xa3zo5n98dwfryPEsI9u8Kc2baOiOjSuFd2yfvSF1aKrVop%2B4fbrKRkamlTvoz9fc0656D9d3444IiqKghvRrtH1F3%2FQokX99lUqRdc8r8Pl9aMvPr9DVLkOL95xfWh63c8ebVW9Crtq3%2BqS9pUrdqhdo0ONqu2rVOzwxePXBCbWU45xFn08dSoSkIAEJCABCUhAAhKQgAQkIIH%2FEDj5HIN8gMhi42eXXde0PWFFtcrRl9WNfv2eNvF9LjVrsE6u%2B%2BUTVxMvXFq73aL3rygc0XjKG1cSaBAyLO3cPDSt1ux3rujyyNV7ezY93K9pjydbk3LUvaA9v4am1P304WuqV6ZkaPsGF0W%2FdOf1szu2TBnQ%2BOYW7XjyxsvazX6n5b6eTRe%2Bd%2Fmj7W9c%2BXHz0EwzHoMDXVi9PeM0Rr7UaumHze%2B%2Bpm31Ku3vbdO%2BYHhDa4bLT6QZGo%2FxH58C%2FSIBCUhAAhKQgAQkIAEJSEACEigdAqeUYxAOUMLicL8mH%2F3l6msatydnIM1gvZIdPZp6xzV46MYb%2BfXaJu36Pde6%2F%2FNX9X7m6kvrtK94boe%2Bz7YKTa3D2qwpAy5d%2FMFlJA8fPNim4UXtiTKWdW4ZmmpyDN5I7dBhL1wVnFgvNKP2wvebXVC9AxssfK9FaJqZvUIdUQpoMIHFqo9RrQrDMKKHv3hVaHodZpr0f741IzduuKx91pCG%2FgknsXKKcozS8fFUKyUgAQlIQAISkIAEJCABCUhAAv8hcKo5hm88lTxNKEGa0f2xq5ncUa1S9JM33ZAxuMkfWrZjjET9mu3rXNChTo0O%2FGxSi3SiAxUzvOMbEG60adK%2BaqWbLqjWod6F0XUuaF%2BnRjQjN6wcgxSideN2hSOo%2F2l2PvzFVqyK0qph%2B8zB5hlrcIVVhcPKMapW6tCqQVtmsjD6IjSlzqTXL69cMZqBIpmDlWP8x%2FXVLxKQgAQkIAEJSEACEpCABCQggbNJ4JRyjHARjEa2UQ1Dk2ub%2B%2FTaH%2F%2BlDUMpKOm5%2F5vL%2Fqd1W0ZoPHHTjdu%2FbEbFzi3dmm37ollM9%2BaZQxrNfLtljWodLjov%2Br0HrlnVpdnUN6%2B6tDYlL%2F4%2Fx6h0bvQDN9zoHluPhVYZejHulZYU0GDMxu6vL%2BVAPGnyDbP6yX%2Bsu%2BocE95%2BSp2JrynHOJs%2BlToXCUhAAhKQgAQkIAEJSEACEpDAjwucfI7BcAj7qPrP%2FfH6xzvcsK5Ls4zBjQ9%2B2%2BSv0TcQONxzzY220Y1ev%2Bdahkm0bNBu2YeXhybX486CIxNfbUUt0J5PXcOIC8ZXZA1pHJpT%2B6snr6pRNbrkeAxyjMei2x7NMSbV2f7lpQQdbPPsLdenDmwcmli%2FYPglw1%2B8cvLrV1j1Magg2ubSto7RyjF%2B%2FLLqWQlIQAISkIAEJCABCUhAAhKQwFkpcPI5BkUqFr7XnGEVJBL1a0bf3JLlQqIvqN7%2B%2FKod%2BjxzFRUwdn3VtHUjZo5EN7o4%2Bi9tr3%2B47fXN60XXqHbTpm7N5nVqTvJQ6%2Fz2D9143RM3XVv7AgZj%2FMe8EnKMR3%2FIMQhMfBPqffSXa2pU7XB%2B1ei2zdo9Fn3d7Ve1JSRp27ydbUyD7o%2B1Uo5xVn4adVISkIAEJCABCUhAAhKQgAQkIIETC5x8jkFljOKRDfo%2Fd2X75tTBiGbJkvOqRDeuHc2qqfZRDXiVeR9ruzS%2F%2B9q2NaubZVUpcFH3wuinbrph%2FzeNbSMb%2FPt%2Frq9zgXm%2BWuUOrDxyRYN2FMpY%2BJ6pj%2FHxX64555wOD7drZ43HoBoGE1g4VrfHrmlR35QSJTnhcDc0bTfo71cFJ9fr8kircyvcRD2NyHiMCa9eXqF8h6ubRKs%2Bxokvt16VgAQkIAEJSEACEpCABCQgAQmUaoGTzzGIF8xyIZPrZg65JObzS2d3vGJJ5xZxvRsHJtQjdrBKcTJmo3BEw83dms58u9WC91rs%2FrqJe0z94IQ6bOAYXX9HjyZzOl7Bq7aRDQ%2F1vmRnj0b53xGA1E0d2HD7l40S%2Blxi7cT6yVuCE%2Bol9Gm8%2BIMWM95qta7rZRmDL6EEqG9CndSBDdh%2BX69LPGPN0iTsIWdo%2FfAzjVxj6pXcyXEfa72SUv2pVeMlIAEJSEACEpCABCQgAQlIoKwKnFKOYcUC4ZKbjL4wd9YuoQhnybiA%2FMF66eir44%2B%2BajKQ8Fv4aTKK8GOe5L2meie7%2BmFdksje2DNPRt4VWbjk6PYTj4YnbG9WLQk3JvLen3igHKOsfuB13hKQgAQkIAEJSEACEpCABCRQqgVOI8f4iYjgP2ON3%2BnGyjFK9adWjZeABCQgAQlIQAISkIAEJCCBsiqgHKOsXnmdtwQkIAEJSEACEpCABCQgAQlIoPQJKMcofddMLZaABCQgAQlIQAISkIAEJCABCZRVAeUYZfXK67wlIAEJSEACEpCABCQgAQlIQAKlT0A5Rum7ZmqxBCQgAQlIQAISkIAEJCABCUigrAooxyirV17nLQEJSEACEpCABCQgAQlIQAISKH0CyjFK3zVTiyUgAQlIQAISkIAEJCABCUhAAmVVQDlGWb3yOm8JSEACEpCABCQgAQlIQAISkEDpE1COUfqumVosAQlIQAISkIAEJCABCUhAAhIoqwLKMcrqldd5S0ACEpCABCQgAQlIQAISkIAESp%2BAcozSd83UYglIQAISkIAEJCABCUhAAhKQQFkVUI5RVq%2B8zlsCEpCABCQgAQlIQAISkIAEJFD6BJRjlL5rphZLQAISkIAEJCABCUhAAhKQgATKqoByjLJ65XXeEpCABCQgAQlIQAISkIAEJCCB0iegHKP0XTO1WAISkIAEJCABCUhAAhKQgAQkUFYFgsVJ3lHnlbn7iMq%2BWe3K6jXXeUtAAhKQgAQkIAEJSEACEpCABEqrQNCd69%2FwRpm7r3s1sKNHab1marcEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAR%2BawGv13tQNwlIQAISkIAEJCABCUhAAhL4GQK%2FdcdOx5dAGRLw%2BXxJuklAAhKQgAQkIAEJSEACEpDAzxAoQ31InaoEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCfwfe3cBbUeRvX8fd3d3d4K7u7u7u7u7h%2BDuwd3d3YIECwka3J2BkfV%2BhvpR%2F37PvbkEy4TwnMW6U6e6rL%2BnOmv203vvCoEQCIEQCIEQCIEQ6JDAd999%2F847H3zw%2Fsf%2F%2Bte%2FOmyYiyEQAiEQAiEQAiEQAiEQAiEQAiEQAr%2BawL%2F%2B9e9HH37mmqvv6PXqW%2B12fv65HpdefNNHH37a7tX%2BsPLrr7%2B9%2FZYHrr36zuuuueu6a%2B686YZ7H3rg6Z6vvvnvf%2F%2B7H6z22WdenneOtdZfZ48vvvi6H0yXKUIgBEIgBEIgBEIgBEIgBEIgBELgb0Xgxx%2F%2FudmG%2B4441MwXXXBD2xv%2F7rt%2FrLvmroMPNM1ZZ1zZ9mr%2FWdO79wfTTr70CEPMPOxgMw49yPTDDDK9v%2BOOPt%2BWmx7wxmu9%2F%2Bw1P%2FlE9wnGWnCJRTb%2B%2FPOvzPXZZ1%2FcecfDD9z35Lfffv9nT53xQyAEQiAEQiAEQiAEQiAEQiAEQmCAJ0DH2HKT%2FUcdbraLL7qx7c3yYTj5hK7LLrHFo4880%2FZq%2F1kjrGOGqZYba%2BS5l19qy%2FXW3NV%2Fa6y8o5qhB55%2BrdV2%2FvTTL%2F%2FUZT%2F15AuTjLfI0otvVnSMF1%2FoOf2Uyyww9zoff%2FzZnzpvBg%2BBEAiBEAiBEAiBEAiBEAiBEAiBvwOBjnUMBH784cevvvqmJSjj%2B%2B9%2FeLfPWSB%2B%2BOHH99776IMPPmn2%2Btc%2F%2F%2FWP738oWSO%2B%2Furbd3p%2F8Pnn7UsKH3%2F02bvvfGjSdvl%2F%2BOGn7777oQW0e1UlHWP6KZedfKLFevR4s7Z5pttL88y25jijznPbrQ%2FWSgWzWMlnn37RrCzl%2F%2FznPx9%2B%2BImVfPPNt%2FWqSnf3%2FT%2F%2B70ZKPUTW4xBbX6uO8dlnX2r59JMvTDbBonN0Wq332%2B%2F7qntj8E8tVRRMHbxtQZd%2F%2FKOPd9q2fWpCIARCIARCIARCIARCIARCIARCYMAm0LGO4eqJnS9ca9WdH3rgqcLhnz%2F%2B64Zr71552W1GHmbW8Uafb7ON9pVeo4norjsfWWvVncYYac4Jxpx%2Fmy0Okl6jXH3qie7rrL7LQfuffNYZVyw097ojDdVp9plWOe2US7799rva%2FZVXXt91p6OmnHgJg%2FNhOO3kS7%2F55v9dfenFXjtvf%2FjE4y482vCzr7z8ttdfe1dTJ6mDFB1jsgkXffbZV2qlwm47Hj3MYDOcctIlpZKCcdbpVyw4z7ojDtWp03QrHHnoGR99%2BElt3%2B2pF7fd4uCJxl1olGFnW3yhjc4566qyzu%2B%2F%2F4dbEGtDGCmNreHM0y5fdcXtxY%2BoKTrGsktu8frrvffevbPxrXaCsRZYYuGN992rS4kuee7ZV7bf%2BpBJxl14pKFnWWT%2B9c849bJ21Qzqyhab7r%2F26ru89eZ7dWEphEAIhEAIhEAIhEAIhEAIhEAIhMDfmcAv6hibb7zfSEPOcv21d6MkKehJx180xohzjjvqvIvMt%2F58c649wpCdyA533%2FWYq5wNBKeMPfLcY486z8Lzrj%2Fv7GsON%2FiMs8%2B0qtSXrt5956MuCWAZfYQ5KRgLzLXO2KPMM%2Bpws7PiC38hGBpLajHHzKsutsCGk0%2B4mLwWe%2B12HC8ODegDs820yohDzWLYhedbX19SyeWX3FL6Nv9WHeO5nyWUcnWHbQ4ddvAZTzvlUl%2FJI9tve5gcGlNNsuRiC27Ef8Nc6625W0nO2e3pF2eedgX6xryzr7Xo%2FBtQIZSPPOxMziR0jOWW2nLMkeZ64P4ny7Duetedjhx20Bku6frfwJyqY7z22tubbrDPNJMtNdbIc40z6rxSdmy28b7cNro%2F%2F%2BrsM6868jD%2FvRGDTzzOQsMPMdP%2B%2B5zghygD1r89XnljwrEXJOlIr1ErUwiBEAiBEAiBEAiBEAiBEAiBEAiBvzOBX9Qxtt78wDFGmPPG6%2B9BiTLA8J98gsVuvfkBIQ%2B8CI4%2F7jxmuAQUwh%2FeeP2d2WZcZZJxFyF6%2FPDDP7%2F66tv99upCythxu8P%2F%2Fe%2F%2F3HvP4xOMucDYo8wt4cbnn335zdffntTlolGGnXX5pbckDmi%2FwzaHkQv23qPzJx9%2FTjDhETHXrKtrzxWElMEtYeRhZzvq8LNM%2BsOP%2F7zislvJCwvOve5HH7UepFJ0jEknWKRbt5fM6z%2FZSh1fYtmTjrfIY48860auuuI2bhJLLbrpyy%2B99u%2F%2F%2FOf113pTJ7heXHX5ba4ecuApboo3Bbnjnz%2F%2B87ZbHrAMck2PHm%2FAxRVk%2FDHmf%2FBnBxU6xh67HkNtuOySm%2FWtcSWffPLFF19%2B%2FdCDT08%2B4aKUmR6vvP7ll18LK6GHDDfYjHvtflwZ%2FM47Hpl0%2FEWQ4W2ie%2FOD8E033Hf1Fbc3nVKaDVIOgRAIgRAIgRAIgRAIgRAIgRAIgb8bgb7VMW64F5nTT710%2BCFn3mev4yulTz75fJ01dpUI9P33P77o%2FOsIEZwQjMl1gYFPJZhuimXm7LSaLJf33fsEJ4q1V9vlH9%2F%2Fo3R%2FoXuvKSdenHOFRBmv9eo987QrzjTNCm%2B%2F9f9iKM487TKuC1defuuz3V6WZWKe2dd8%2F%2F2PxHEYXFTImqvuNN7o8z%2F80P8vqsXIdAxZPUcfYQ55Pjdef69N1t97kfk2GH3EOUYddraDDzjlxx%2F%2BSR%2FYfJP9LPXyy%2F7rzvHPf%2F7L364X3jDS0J322PVY4%2B%2B9%2B3HcQogwxBaXpLTo0vmCnbY97PXX3ta3L3WMkufz5Zd6TTXJEvPMvsann35uKJ8D9j1xuEFn3GfPzmVwNaefcumuOxzZq2f7597%2B1Cl%2FQiAEQiAEQiAEQiAEQiAEQiAEQiAE%2FkugL3WMm37SMfbds8tIQ83C5K%2FseDtQIagZDPx99ug8wpAzC6BYaJ71eEosNM%2B6QidoFzNOs0LPXm8JjuDVQOX4%2Fh%2F%2Fp2O8%2BEKvaSZdav451%2BG68MhD3QRrrLbS9k3fg%2B%2B%2B%2Ff6jjz6TFJSrA4eHicZZSDSKkct%2FlI1xRpvnumvurIspBTrGjFMvN8ows4rm4Ocwzmjzkixk%2BJToo%2BTM%2FOjDT%2BX81EAYi0X%2BtNT1Zp1hZVNstN6eEoCIGeF2opcGW292gCm4UpTBv%2Fv%2BH79Kx3ih%2B6t0jLlnW6P6jTz84NNTTLiYueaYeTVyyrXX3NVultGWm8rXEAiBEAiBEAiBEAiBEAiBEAiBEAgBBH6VjrHzDkdITXnlFf8Nvmj5cGPYabvD2f7kCL4Qow3%2F3%2F8U%2FDfLdCu98sobRcfgHSGKpPRt6Bif33P3Y1JnbLDO7v%2F4WeVojn%2FuWVe1jjz8HGOMONeEYy1w9ZW3N1sql7gSUSeXXXKLLKNPPP786ivvYNnHHHVOadm79wedpltJag6JPkb%2FaZ2W%2Bt%2FyCHNssPbuZXmPP%2FbcFpvsP%2B5o8%2BoodQZB4%2BYb79Pd1d%2BpYxjkySe6UzAEpxh8mEFnmGma5a%2B56g7uK2V5%2BRsCIRACIRACIRACIRACIRACIRACIdAnAr9Kxzh4%2F5PpCeeedXUdjXzhZNI333j3u%2B%2B%2BF47BH2P%2FvU%2Fo9epbr7z8evlPuITsE7w17rn7cf4YfdIxqA0cJ5ZbcovmcatffPGVbJnSZVx68c3iWQgIcoH2%2BHnknj3eNHgJ36jrUSg6hnNXX%2Fw548QD9z814VgLSub58k81Tm6dc5bVxx9zAb4Qr%2FZ4s6xTUs2er75VTkf96suveW44R9V93X7rgzQHMSmzzrjya73aiSsx4567Hds2P0ZZWIs%2FhngbNyjdB%2Bxvvfnunbc%2FvMn6e4058lwzTrP8qz3eaN5FyiEQAiEQAiEQAiEQAiEQAiEQAiEQAm0JVB3j8stubfdqM8%2FnJV1vomNstdkBJaeE9u%2B%2F97FEnXJcyFzR9YLrORisvtIOJXyjjPZi955SZyjfc9djfdIxBI%2FoLmZksvEXff75%2FzunVZfOx5439WRLXX7pzSU%2FxszTrNBMImFqiTfLLM2%2FRcdonrtKRdlmy4OHHnj6PXc9VgZRXx3CIpPnaT%2Bfwao7dwh5QcW8iDrZaN09ll5ss56vvlmGlY5jxWW3HnHITnff%2Bdi%2F%2Fv3v1VfanudGcc%2F4qcF%2Ftt7iQAlLW%2FJ8NnUMmT1E32j86Sefr7%2F27sssvjnZpAxOCHIcreibu346trVU5m8IhEAIhEAIhEAIhEAIhEAIhEAIhEC7BIqOwZ3gwP1OfvqpF556snv5T%2BwDzwdhFE0d46233ptrltUcJHrMkWeTFDgbOMx0mEFm2GKT%2FYzzzjsfLjjPeiMOPcsu2x%2FxQvee%2FA26Xni9RBPrrrGr80adV9InHYODBBnhiEPP4M7B6eKxR5%2BT7fOSi26cYqLFJ59g0WeefunHH380BSFileW3efSRZ3r3fv%2Fuux5dapFNuFX06vl2y3211TE0ECfivBJnubo7X2%2B56X6LcVDIOWdd9frrvZ0Vsv%2FeXcYbY76zz7zCUjmNDDXQdFtsvJ%2FzWcwljsZdSMfx3HOv6CsNiFNWV11hO%2BqK%2Fw47%2BLSJx11YNE27OgYXDke4SpHBrwMxzhgbr7vXYANNvdF6e4l54f5x%2FbV3dZp2RYOX02mb9%2FL1V98cfeTZB%2B9%2FCj7N%2BpRDIARCIARCIARCIARCIARCIARC4G9LgP4gtGGIgaYlZRAo2OPlv1GGm23D9fZkd2%2B%2B8b7DDz7TddfcVRA5cVWAxtADTzfxOAuNN%2Fp8Qww0zTKLb1ZjIu69%2B7HZZlyZCCD5wyTjLezQVdLBZRffxKXhrjsfcSDIemvuVvNj0DomGXfhOWZarThsfPzRZ5tssM%2Bwg81gARQGk9IHLr7whv%2F8%2B7%2BJI17v9TZHCJqJq6x%2Bq%2BX7cehBpzqGteW3ozyYlCjRrdvL9RI3jP336jLoQFNJwaELf5Ljjz3PcSfDDDqjNVitG1ly4Y2f%2F0mp6P58D6lKBx9oajfIRYQLitwdnY8%2B1yAG7P78q7PPtIqrsmdIH8qvg3vGiEPNcvFF%2F01%2F%2BsTj3eUXXWSBDYo%2Fxrfffm9GuPBcYekt8RTGssQimww18PS6G9xdGJxe4Yeoqy2Fl17sOfao89B27r%2F38ZZL%2BRoCIRACIRACIRACIRACIRACIRACf08CFIarr7r98ENO5w7hb%2F2PRHDFZbfKenHdtXe5xPWi8nGW6NGHnykN5nZbHXLGqZeJCqmXFLhhnHj8hdtsfuCWm%2Bx%2F1GFnvvD8%2F3WUieKow8%2B88vLbhHWU9u%2B991GX4y4487TLv%2Fzi%2F04DYeZfevFNO257GF%2BIg%2FiHPPlCc%2BTPP%2Fvqoguuc3Xzjfbdd4%2Fjb7vlwSIsNNsof%2F7Zlyd36dr5mPM4ZjQvvfXWu8cedc5JXS4ssgkPkIcf7HbgfieKMdl%2B6%2F%2FeyIcffFLbv9P7g1NPvmS7rQ%2FZfKP99tv7BO4fzbm6d%2B958P4ncRGR2lSszaVdbzrikNO7Pf2i7lxWjj3q3PPOuebbb78ro732Wm8wN91wnxOOvxBPlR%2B8%2F%2FFZZ1yx3VYHm3rfPY%2B%2F9eb7a5xOXYCCo1vOOPVSMHOgSRNLyiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiHQLwn85z%2F%2F%2BeKLLz755JPvv%2F%2B%2B7bz%2F%2BMc%2FXPr3v%2F%2Fd9lJ%2FUmPZVvjtt9%2B2rOebb775%2BOOP%2FW2p%2Fz1fcbjqqqv233%2F%2FHj16%2FJ5x0rd%2FIPDjjz%2FaOR999JFN3lzPd9999%2Fnnn%2F%2Fwww%2FNSo%2BJvaS%2BpXGzjS5G8zFys75Z1qblqhoPoJGbn%2BbD%2BK9%2F%2Fauss6Vjy7AtCy5XjWk97V6qw7Z7tTl4yiEQAiEQAiEQAiEQAiEQAiHQXxFgHO2yyy7TTTfdQw891HZh%2B%2B233zTTTHPHHXe0vdSf1Dz44IPTTz%2F9mmuuSbVoLun888%2BfeuqpL7300mbl7ywzZjfaaKOBBx74zjvv%2FJ1Dpfv%2FkIA9f9ttt%2B26666b%2FvTZaaedrrvuuiod%2BHG32mqr008%2FvWng%2B%2BnPO%2B%2B8bbbZplu3bm1XThPQy3NUBjTyfffd11T%2F%2FvnPf77%2B%2BuuXXXaZB%2BrVV19tjvDMM89s1%2Bbz6KOPljbPPfcc3cywm2yyiWHtdiup3c1bht1rr71eeumlWq%2FwzjvvHH300VtssYW%2BFnb%2F%2Ffc3O2pch91tt90M21xtc5yUQyAEQiAEQiAEQiAEQiAEQqB%2FI8CmW2uttdq1zRk%2BLKCRRx65f9YxrG3IIYccaKCBDj300Cbb448%2FXuVpp53WrPydZUAYhsMOO%2Bzdd9%2F9O4dK9%2F8VAQ4V55577gorrLDeeusde%2Byx9skGG2yw%2FPLLn3HGGcXXgqax4oorrrrqqvfcc09dpJ%2B%2Bc%2BfO6h955JFaWQs333zzSiutROPq0qXLMcccQ1VbY401Hn74YQ0oGLfffvsBBxxgwOWWW86kL7zwQu2oQAAx7CqrrKJB%2Fdx7770u9ezZc%2FPNN1d55JFHHnfccWuvvbZH9amnnnKJgmETHnTQQa5avOnoIerL57PPPttjjz3U77vvvjrqtfrqqxMrytU333xzyy23LMO6%2FTLs448%2F%2FnPv%2FG8IhEAIhEAIhEAIhEAIhEAI9NcE6BjrrrvuEEMMcdddd7VdKF%2F6t956iy3WvMTx%2Fuuvv27W1DJLsOVS8y2w6I%2B2ASClr16GreM0C9bQp0uaMQOHG244ksU444xT32KrP%2BGEEwYZZBDGqXJZQ3MltaZZyTb86quvmi%2BmWxascdExHnjgAcNaM3oKbT8uNd%2FmNxu4lw5up9ky5T%2BDAHmBaMAH47XXXvOL%2B7l79erlKyngySefNOP111%2FPxqdLbL%2F99rwayhr89DQKHZt7rFz69NNPdd9www0JFJoZk7a28sorEzSU7QTeFqutthpJgWvE%2Buuv%2F%2BKLLzbv69prr9X41ltvNY6PEBIfz5GhzjnnHHrLFVdc4QG0TsKFBVAelO3MHXfc0TrLsLSIZ599tg7rWbb%2BU045RTONbVd3p6XFaMMtxLBclcqwXDUMe9RRR%2FVpM9dhUwiBEAiBEAiBEAiBEAiBEAiB%2FoFAxzoGo2nZZZd94oknylLffvttJlunTp3EoXjbW94al0tffvmlt8azzjqrS0suuWT1WGCmLbHEEqeeeurWW289wwwzaLD77rt7X1zvnQe%2B99R6GZZp5mVxvcTi8%2F5ar5lnntkb5HfffbdeqoWiY%2FAnIWVYEiGiXGrqGNa%2F%2BOKLn3zyyWxDV%2F31In6ppZbyCls0AXd9b6u9tp533nmFqCyyyCI33HCDV%2BFmNLVVbbzxxmVVOqocaqihrFN3a55nnnkYm00DUMDCoosuOu20084%2B%2B%2Bze4JcEHRIgEEC8dtdxjjnmYNg2pQxr2GeffdikYgTqfaXwZxDwS5EXmO3VOaHMwgfDDineO359v4WP7WTPlB%2FXT98nHcNWsUMEcVS577333qNXCCEhI%2FhxyQjaGMeeJDi06BjCVYgML7%2F8csv92iF2BXnkjTfeKJc8NTvssMO2224r6wWVzLDytJjUQ2qEqmMQLigYasSklI5UkYMPPnidddbRnrTCi8NjZUnlqs1pWKE0LZFZ5Wr%2BhkAIhEAIhEAIhEAIhEAIhED%2FRoB51YE%2FBgONV4OXxZbt3TQbn1zAup9vvvkGH3zw0Ucfndnukve83jW7JJnGAgssoMuYY45ZTMWTTjpJvc8oo4yi16ijjqosBoS1pSOHfC15g8w%2F%2F%2Fw0BJeIAB988IFLrDCjDTbYYLQCM7pE7mgKINr40DF0X%2FCnDzWDYFLqmzqG9evOAKw6hhAAixQ4wNJcaKGFXPWVZDHTTDMpc%2FCYaKKJRhxxRKOOO%2B64apilJX8Cc89Xn8knn5zuoRcOZ599dpn0yiuvHGmkkUYYYYSFF1544okn1kwWApamN%2Bxl5J%2B6%2FldvKW%2FGSy9v4WeccUaLJ%2BmUmvz9kwhATUQSrNGiidGp%2FIgCQMxLx%2BAgIRKE8kbgKnljOtAxSFU8OgxYtrQRnn%2F%2BeQ4YdmBVNlQSEOhaLTqGSvKIOBSOQxZAExO3UpQTYgXVyxpsnkLDXrUqUTB0klLjr0mJck0dw6REuaJa1GYGp8xwJjHIzjvvTI57%2F%2F33y1Ubm6sGwaQpIdaOKYRACIRACIRACIRACIRACIRA%2F0agYx2DF8EwwwxT7DviAzNcVkAvcL0OdnIHkYGlr9y1a1cWPfOQzsCwYs7rxbZipnnHrReXjO7du2vJTCNlzD333Mw0jeecc06GP8vRJcPy5NeYFmFVXhlLfMEHnl3GVCyXLrzwwhaAdIxBBx2UuSfAn%2BYwwQQTlBfTTR3D%2BmkdrMKqY1iqFdINmHWLLbaYiazTLNw5JBawBg4Vjz32mGV4Uc6DYrTRRitpDegY7tR0joHwmtvKxxhjDI19Jb9MMcUU9A0dTdS7d2%2BajJu1HrfGEcUajjjiiFdeeYUJWW1et6OxpAfEFotpubt8%2FWMJ0OIIbnaC7denkcWV8JwRzcHhgY6hcRHW7Kh240paxrGFPCk2%2F9NPP928ZBu31TFoCJQuLk%2FLLLOMLksvvbSIjzPPPNPGI19Q3qgudak8NA455BDPRVNw8Ii16Bi2kwQg4kqqu5TNZmo6BmnR8opTR%2FW%2B8OiRRwzbkoC0ufiUQyAEQiAEQiAEQiAEQiAEQqD%2FIcBi6sAfo%2BgYtAI2Ox%2BD8ccfv7qjM8FEiLCz2IbsO9kvr7nmGmWxJ0x%2BSgVJwYtj5z7QGbwOLrfMD4Ebw5RTTsmW93aY54MkhEVe0EBH76Z52hM96AM8McgIEnQY1uA8H6ynqQDoUnQMxqlBGGskCN4OLD4RAQSHkh%2BjYx2D2uC%2BakyH5AZFqSgLNizRg7NEeS9fgFQLURuuHQQK%2BgxNg7BD4qBglDWLLLAet0PHEBozySSTFIu4jJy%2F%2FZ4ABYCLEYcEYVB9mp2OUdNHkNQoDPYSFeLEE0%2B0z8llNqcaXykbjsXx49ahPE02PMchG57CUOsV2tUxPFYXX3yxfSKjBRnB3rPZ%2BHKQwugY9hL57tfqGOYioVBgPJsip%2BzGErriQbNLrVY9hSQ6RvPXSTkEQiAEQiAEQiAEQiAEQuAvRKBvdAxGFqtHoAQJgmd%2B8%2B6oCmpmm202lj4rvn7Y76JO%2BB5QElR6QVx6eR3MhWOqqaaiY1x99dV6HXjggc0B6QbGNCMfCQJIHVDZmEw8pl%2BzfdUxVLL4iBLGZLj9Kh1jvPHGqy%2BjiR50DO%2BsyyzMT6almhImQ8doOa%2BEiwUdQxoQhq0V1gUrWIkaaUMsjI4x6aSTtoQzNG8k5X5AgCC22WabcbFoG6BUZy86xiWXXKKGEMfkp63R3ERI0THkWiFYyQDDxYgnDzcGY5a%2B9i1PJI4QtnRT3ChX29UxasdS8FekFW%2BQs846yxNHcPBpq2NQyWr7tv4YLnGxMEI5BoWgYdmcPciVFBhCor3tAFkeRGWQ4o8hcqpKlHXwFEIgBEIgBEIgBEIgBEIgBEKgPyTQlzqGt8OTTTaZBBctOoY7UsNIF6bBKULUibSZPgrSCTLbxWuw6AX%2Bl3tv6hhsf%2FqAZm2xeDFNuJBIs4xW%2FhqTHtInHaP4aYgFcFCss0ukbTR4X%2FpjtNUxvAcvq%2FpFHeOwww7jKOJepFukWpiXm0pdsDVLIYJedIy2v3K%2Fr6EJ%2BGU32WQTXkPN2flp8ItwcgcZjUzBH6PoGNrwvaGeMfypH8VTwiBydZYP%2BYsOUIa66aabxG7sueeezfwVdZZ2dQx9PRGewdqM1EBzECpiz%2FAbEcdUBQdxTxJZ8P%2BhAdb27eoYrnpMeBBxCxGKRYQRLUXAsTCxSzYnOa4usiQU5abSwqROkUIIhEAIhEAIhEAIhEAIhEAI9FcEqo7RbpLJEkZR4kpY6PQBLhZl%2FewyeQDY7MQKcf3SXLQkBCjN%2BEW01THElTCjuOhLiel9cZEgtBfcwcZkQnbr1k2aTcOapeKqzWqNQvXHKFfZoQ6koCf40DGkGihteHdUaUJNua%2BSH4MLx6%2FSMSg2EoSWNZhUQkWDuxcZFUzK0iyXmn%2BjYzRp%2FA%2FLrH7BIGSKkp%2B2ruTyyy8XP3LBBReoafpj%2BOoBESoiWKl4NdAEaq9mgQbCbYNEUPWB5lXldnUMGUEdACRmqjYWUSLLqKeGEFEPGSlXP%2FzwQ7IGcYP0Udv3SceoDRT4kAgqobNZgw8HIV9LvhdXbc6WAJZm35RDIARCIARCIARCIARCIARCoH8jUHQMUoNIfyc8UhJ8XnvtNb7rLCnnGjDbeUdY9lFHHcVOZ0bxmWcN3XjjjS6JNPGauCTzZOiVV7qUDQbXXnvtZQQZBtrVMXjjy1GgOwGEM4MBfeW9YAqWpjF5xXPJYNDxhGesPfzww9IOmLQFYIuO4Sr%2FEGemNHUMWQLk25Rp0615%2B8xnQzZOi%2F8NOob7Ei1SzozwapsFKo%2BH41RManAZMKTa8F7e7ZiIXewN%2FksvvSSKoQN%2FDGKIxAWO%2Fkyez5Yf98%2F4SiJjxROy5Fa1zXyY%2Bb6KEPFLmbFFx1Bjr7L0BYzwlGhXxzCmLcFTQsHvbseWj%2F1fb8GWaJvn05NCuDM47w6bnKQgTInMUp44USrKniCjGYpQ5itprino9UnH0Mau8yB7ZPhaUNtoJmUxQleMIwzKsBxCPH2eNUe1GqquNoUQCIEQCIEQCIEQCIEQCIEQ6G8J0DG8R2b1s%2BuZ5JJalI9DOhhBfNGZ7eXcVS%2BaJd7UUnYLmgAnBEeplqNMWEzcKlxixc8111zcG5T5PzC%2BStaI4hcBglfJGkw44YQlxp9JRceQX8KAU089tV6yZ5RkmF5Ml6NLRZfIv2F5AkYY%2By0kLUAvVmTTuBML4IQR9QQW7ekDNBZfzeuEUwkulE1azl115KvGPXr0KCOXQ1rpFeUr85N9qr0X7mrEzij7uFNDGURQST1FhRZkneV2XNXMXyaqSARnyDpOpeZSKIOXvzQQ96gxgaVZn%2FKfQYC1bhfRl2wJoSLcIRR8VVm2ELt%2BqaWWuuiii5qzk8s0Y%2B%2FT05r1ykV%2FkzGDPCL1hG3vU1Jb3H333bWxjXT00UcLPKmOEC5xHzKvYekMAlIIGtxCHEpiTFc5YBADyXeG5emhGb2lJYuF2%2BFfQWMh1tW5FESj7LPPPqbj1GTTeiLMVRrYb7yGHJJiWMoh9w9yZd3%2FzUFSDoEQCIEQCIEQCIEQCIEQCIH%2BkAAdg3nFVmJAMd%2FqxztcJx14S8t8e%2FLJJ8vKuVsIJKFmzDTTTISLpt3NVJcpgiPE9NNPL%2F9hly5dHGiiV3m7XZQQX0kKQvVlzmSjlTE5RRBSOnXqRN9wqWnpM8023HBDEgdnBstoN%2FLFW3Uvl03X1DGU1bDU6CRlll69etE6ZpllFqN5A06mYLo6EdUi9957b7O42dLSzQoiEEpQvjI%2FSyRCeZ1NltFR%2BgLEaBTuVMqO5ots98vedGn22WdnHprXOOWQCKJQzXVQBi9%2Fy%2BGb%2FAF4dDTrU%2F6TCLDoOcDwfCBi%2BNj%2FvlYzn1IhZ0tTgrAMXhZ%2BaAoDVaplVTQHaVhcEmalY%2FlI9anQFD1sJIltaQ68npojqPd0aE%2F68HCZhc5QG3jiuCRxgiK5CJhqO7u9x23j8MMPLzutduTaYd%2Ba7tJLL20b6qKGm4dhSRluv%2B2wdZwUQiAEQiAEQiAEQiAEQiAEQqB%2FI8B8Ywox%2FDv%2BNJfN7uaR3qyp5ZK3sGnX12FLmzKdBtVsLPXc%2B4vuUYeqBZf6NJ02Zfy2A9ZLdRwF49TYjdKx3n5dT8uC9ap8Srm25FvCCG2OX8susXzr17az1Esp%2FA8J2Ax2V8sCbIDyi7fU%2B9pufflxXWr7MVRzkI5HtjmbcSjNjvZS23XWBmXYui1LfVlVbdNuoeNh2%2B2SyhAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgb8JgZajEH7xrh3c0HGbX2zQ7N6n2Wt9LTR7pRwCHRNw1kzLeSJt29tazd1Vvrb9Wzq2rW%2F2bQ5eWtaadjv2qW%2FtlUIIhEAIhEAIhEAIhEAIhEAIhMCzzz67xRZbXH311S0obrrpps0333yzzTbbdNNNd9lll2OOOebll19uaeOrYxwvuOCCrbbaatVVV9XyrLPO%2BvLLL5vNHCt51VVXbbPNNqutttomm2xy6qmnfvrpp6UBo1Jfg%2Ffs2bPZRfmzzz7bb7%2F9dtppp7feeqt5qVevXttvv%2F1xxx33448%2FNutTDoGOCbz66qvnn3%2F%2BEUcc0blz52uvvfaLL75ot%2F0999zTpUuXRx55pF594YUXTj755FManzPOOOONN97QwKnBV1xxxUknnVQvnnjiiddcc03L5qSc3HzzzYb1rNVhH3rooWZHU5x99tnvv%2F9%2BbZBCCIRACIRACIRACIRACIRACIRAWwLe%2F%2B62224DDTTQ3HPPTTpoNjj88MPVDzLIICOOOOLQQw%2BtPMIIIxx44IGEi9rsww8%2FXGONNVwaZphhJp100pFGGkl5mWWWefvtt0sbksWGG26ocsghh5xkkklGGWUU5UUXXfS1117TgLlH3FBz%2F%2F331zFL4b333pt88sld2nrrrckd9erjjz8%2BxBBDzDvvvD%2F88EOtTCEEOiZANFh33XVXXnllgttGG2207LLLHnzwwZ988klLL1rHxhtvvMQSS1x00UX1EtHDll5yySUX%2F%2Bmz2GKLLb%2F88k888YQGtjdVrV5yfaGFFtp1111bNudTTz219tprL7XUUtSMOizpY%2Bmll659PRSUwFdeeaU2SCEEQiAEQiAEQiAEQiAEQiAEQqAtgd69e0833XTkgmGHHfbOO%2B9sNjj66KPV77XXXmyr7t27e5c9%2FfTTq%2FFGu3jmUyG4UqhZa621nnvuuc8%2F%2F7xHjx6sRTU777yzNj7777%2B%2Fryw4phydhDcFBw81PEB0F2mi%2FaCDDvrggw82p1b2YrpMR0K5%2Fvrr69Unn3ySrrLIIou0mIq1QQoh0ELAxttxxx3tUnIZNwxb69hjj11hhRVuu%2B22ZkvOFfb2iiuu6NIll1xSL3EZojBcdtllD%2F%2F8efTRR4vo98477%2FBZ8hQYuVy0kz0sdn7tzj1pzz33NOxKK6106623lnoNjjzySEsikpSOlBbayNdff107phACIRACIRACIRACIRACIRACIdCWwMUXXzz44IPPMMMMnBy8WW6msCg6xumnn157iSvhIzHuuOOy1FT6O%2BaYY5JBPvjgg9rm9ddfH2%2B88SaaaKJ3331XecIJJ%2BSG0QwbKY4WOqpkzf2ijkH0mH322RmMZYq2Ogb95Nxzz6WcMCeJLS1RLXVhDzzwwFFHHVXHqfUpDPAE7EN74%2Fjjj6%2BOPYQIwsKZZ57ZvHeyBu%2BgddZZx6WqY9iiIj7sUvu52biU%2BW%2B4JDyk7aVSw99JUNUqq6zCH6OpYwi22nfffWkgfQpv6dOAqQ%2BBEAiBEAiBEAiBEAiBEAiBvzMBL6C9ERbrwcbnEk9w4C9RgRQdgxFXaxQOO%2BwwwoIcF8p875WZY80G3CS23XZbcR8vvfTSddddp8EOO%2BzQzF5IKuF4P%2Becc1Ik1HegY0wzzTQjjzzyHHPMYRBdisbSomMIYOGcr8Hwww8%2F3HDDKTBC25qcfD9Yka56t95cbcp%2FBwJ%2Bfe4TTVeHW265RWzIpZdeWm%2F%2FzTff5Cm0xx57kMKEn1Qdg%2BDASWO99da7995777vvPnkzmqrd008%2FLa6KQiLcSWINmTRa3ITU6HvIIYdIqdHUMahtpBVeSdwwjKx7cfCo60khBEIgBEIgBEIgBEIgBEIgBEKgLQGxHoQChhhDT35CZr5Mg7VZuzqGEA%2F%2BG5QKzbg36CLMv3ZpKZQx%2Bc%2B31Nev5u1Ax5hyyimnmmoqHvvTTjut%2FBslt4A1l7gSfb1e50NiDaIGvBkX%2FyKZhq98M1ytsygQTHTn3t8UapoNUh7gCdgDop9oEXyQJMHYbrvt%2BGmUuyZWSGPrQXjmmWfuvvtugoM25RLBQWiVSBO6h78SZchVS3woV2kXdLNydbnlltPRODWN7VdffUXlo2PYdeJHNKtxJWJb7NXS0ciGtZ5mFtAB%2FufIDYZACIRACIRACIRACIRACITAbyBwwAEHyE1x4YUX6vviiy%2BOPfbYMhx%2B8803Zah2dYy77rpLwgpO8qzCkvvCi%2BY%2BTe1FNlWBZdenBr%2BoY0w22WScKxwJIU3oPPPM4511t27dio7BPYN2IciFa4fQkjKFzI2zzDKLqJboFX1i%2Fret5ykhSy21QZJP4oNTRSoKcoTQj7KT77jjjqaOYWvJCCo1qAAr%2FkWS32rJj6IEKBHZCHG8OK688squXbtKImpwHh0lP4YpDFW8PkSXNHUM0VVcjHiACIkicXiUXOWh0Tb1aF1kCiEQAiEQAiEQAiEQAiEQAiHwNyfgrfGss85Ku3j%2B%2Bee%2F%2FfZbEoEADRKBrIOFTLs6xu233z7UUEM5X1Ub3vJkimYCjRaksilqYJyW%2Bvq1b3QMkSOamdFQhJHiQyLPJ1ORyamS90UdkLrCV2TggQfmq18rUwgBBAhftrpktnQG%2Fhjrr7%2B%2BqBD14kS4Rjjht2gIGlTxwVXbTH2NVDKIoCoyiFOJXeXI4VzgKv2Jpdpggw3sQE%2BT9C%2FKlJNy1bnGlAqPj14%2BxnHWTw1RcQbQQQcdRCGpnh6lWf6GQAiEQAiEQAiEQAiEQAiEQAhUArIa8qwQJDLXXHMtuOCCCyywQDk1lZMGNUCzdnUMqRGpBPvtt58GUme0lSnYfXICEENK%2Bk0NSuM6r8GZe%2By1jz%2F%2BWGBIx3El%2FDEkLtDXSSjCTGQH3WeffaTCcEilidiSxt97773r4Aq77767SgkQmpUph0CTAD8KSTA6d%2B4sRcw555zDicLpwxwqfHhf0BPEkkiRUeWLZl85Qgkd%2FCialaVM1rA%2FHTTsEfD4GNbmF6JiWPVmJFbw3KiBJ80ROHsIMOGb0axMOQRCIARCIARCIARCIARCIARCoBAgAnhrLKjE2aa8Mjp16iQcQ4E%2Fxswzz%2FzRRx9pVnSMZvoLb5adoCrEoxxYKcbEKSess2ZuQ%2FKFQI%2BJJ57Y%2B%2BjHHnvMca4yiEoUUMkbZKGFFhpnnHFkA6Bp9KWOobv4l8EGG4zwQqYQ%2F%2BIWqCW%2Byj%2Fg7XYZnzAic6kVOsKyzpjC35yAICPbmPdO5cCJgj8GkUF8h5gOygPfCdFSDitZffXVnbLqo8aJPFw46HXyZtS%2BUn3SMexGGojDWGkUNYNo0TFKAg1BIoY1SxnWSSjls%2BWWW77xxhseDcMKjKrDlgQaN9xwQ61JIQRCIARCIARCIARCIARCIARCoBKQ4VASCeeZeuNMTKAJ%2BCt8g5HF3aL4zMtrQTHo0qULccAl8R3C%2BdUwx4rhxnmeRkFbYNMZweAG8XabPCJEhas8yYIHvgGbR14y%2FagfPEBkUNSr73UMAzIJLcCHjmFVHP4djDLqqKNWb3zv2WUuXXjhha2t3mwpmO61115zIy31%2BTrAE5AAVg5PyWC5AJWbLck8KQmUB48Anx%2FKRvnQJcgULvlKqZCPxR6WFkPslb7ay16rxgh2r1S3GtdQLKIHVY2CIWakd%2B%2FezWGFX%2BnliB%2F1dD8ZOXhrCFEpG5LQR07xZAmbGuB%2FjtxgCIRACIRACIRACIRACIRACPwGAgwoakDLkanGEY4xyCCDeInMvCr%2BGCI7pEZccsklRXPoQjdgFdYZWXNiPQgXTDABHXQGssYYY4zBTCttmHgTTDCBjow46RCNLL2Gk17L4SPFfcJVB7%2FWMUvBgQ6TTjopsaXElZRK5004HFZ7cSXFCYQqwiXDFGYXGiDtJ5GE637LaCaSA8GlMm%2FL1XwdsAkQH4SQ0A0cWGPDOJSH3wUBrellUQnIjyG%2Bo567SouTlUWNTBeXX365w3eU7eQilHH7oZDQLs466ywCiPyf9rkIkTpaLcjzaQE1P4bQEttVYwqJYQVzuXrooYearnZJIQRCIARCIARCIARCIARCIARCoBDg2MDvYooppnj88cdbmIgoWWyxxeaff36uC1JhCA8Zf%2Fzx5QL1V7yJvJ1eNLd0IUFQFUYbbTTRHFwj5Nl48MEHm23MIsPh6KOPrgFnCek4qB%2BlAbVkhx12IJW0XYmJuFUYzfvr5mjnnXeeVTFCi47hnbh33NNNN50AFp8ZZ5yRBaqy2UWZjsH2pIFUQ7KlQb4O2ASEO%2FGIkC1W2AgRg8sECYL7UNu7djArv6NmngpbkUORI0v0JcRJb1sPbLXTZJS1h0UzETScV%2BKMknZ9fvg4mb3qe%2BY1yGGHHSZ6xbAiUExBu2u7ntSEQAiEQAiEQAiEQAiEQAiEQAiws1hMrDPWfVsaXjRLGuC9sEAMzcrH6%2BO24kDtaxxeE95uM83abcZg5KWvAXmkOanGRrYSDvx1tFLQjKjiQIcWq1C9ShElzYnEucgd6tPB62xTCCto9mqZMV8HeAI2m5QUtiIPjT7drPgR%2B6TtRrLr9BWEUpOx1BEIg1JeSAjjkamVLQVb1LAlOKVe8lx41gxr8FqZQgiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAj8tQg4EMT5I4516OBIiL%2FWHWW1IRACIRACIRACIRACIRACIRACIRAC%2F0MCDo686667zjrrrLN%2F%2Bpzz06eUVXbt2tXRq795ec6gXGGFFTp16uQ0yb4fxDmqF1100bXXXtuifjjI1cIefPBBJ1T2%2FWhpGQJNAjbPs88%2Be%2Butt95222233377Y4899sorr%2Fzwww%2FNNs4Xvu%2B%2B%2B15%2B%2BeXmTvv8888fffTR66%2B%2F%2Frrrrnv44Yft0tpF96effvqRRx5xQqvRbrnlFoOX8U1Ryv7ecccdNL3aS0tXHShcaxQcJWwWLZvjNxsoW9Vzzz3Xp1nuvPNOfd2CQcrUCm7T7dQTjT2YDzzwwL333ts8UtY%2FBU899ZQu9eBXNXpZ5JVXXmmQ%2BhS%2F%2FfbbBWAZv3mPanr16tVcMCVTAyO3PaO22UzZA%2B6mXnjhhSb2ljbNr%2B4C9ptuuskv8tBDD3VArNkr5RAIgRAIgRAIgRAIgRAIgb86gR9%2F%2FHGdddYZqA%2BfkUYaqUePHr%2F5Hhkas88%2B%2BxhjjMEa6vtBXnrppZFHHnnIIYe8%2FPLLm70YLJa51VZbNStTDoFfRYCNfPzxxy%2B77LIUthVXXHG55ZZT2HfffZ9%2F%2Fvk6Dhtf%2Fcknn1wNasb1TjvttMwyy6y88sqrrLKKwjbbbMM2L12%2B%2FPLLXXfddb311uvduzcJbqmlljJm%2BZiiFJZffvlVV12VAFK6eDQOOOAA47RsclLD3nvvraWnoK6nbeHEE0%2FU9%2BdJ%2FnsjpWzZa6yxRvfu3Z944onVVlvNpKW%2B3O%2F%2B%2B%2B9ftAgm%2F7bbbrvRRhsRGerg%2Fik47LDDtH%2FyySdVkjjOOOMMN6vGegy1%2BuqrX3zxxUQbymcZsAxeZ%2FfVAiiQdUyFZ555xiBHHHFEi1jUbFPKHnDoTNr2Utua1157za9mVSuttJLxraf5i7Rtn5oQCIEQCIEQCIEQCIEQCIEBhoCXpF7Lnn%2F%2B%2BeyvM888c%2FLJJx9qqKFYWDwxLrzwwiuuuMJr6N98syyjueaaa%2Byxx%2F5VOobGY401Fsli2mmnfeutt%2Brs3tWq3G677WpNCiHwawmQJogATO8TTjiB64JX%2BUxs6sSGG25YpQwv99WcdtppRcegTrD62csnnXQSB4Bu3bqdcsopbOett96aZ4IF0DH23HNPI2hJ9%2BOTUFwUdtttt9KruEaY7r333isLJlOsv%2F76zHAdv%2Frqq3oXdIz99ttvzTXX7OCRsSpLLbPwi9hll13oDFZbZiEyfPrpp48%2F%2FjjZYffdd9dA%2FVVXXVWs%2Fj322MNqOVntuOOOm222WdMbxNN65JFHGqroMzfccANKO%2B%2B889133%2F3iiy8aZPvtt1fjMXSbbtBH5VFHHaXy0EMPLTVWRWGot6PA%2B4W0cvTRR%2F%2BijmFkQPhcNbu3W3aD2GpsdnfKO4X6QbfxizQ9Xtrtm8oQCIEQCIEQCIEQCIEQCIEBiYB0FkssscSYY47ZtKGYDF4iq%2FFe9eqrry5GiogPlYyjG2%2B8sa3DBhvtmmuu8XZVrznmmKNFx3jjjTeYSBpUs7GFoV5Fx6Ba7LDDDjztS4O2OgYrjCXF6b340pdm2nsfzd78%2BOOPRQe4SqgpHvU9e%2FZkt5ra6%2FWWSZlmN998sxt0mzi0XPWVlce%2Ba%2FGZb9ssNf05ASIABYPpLQykLtVPzwrmrvD111%2BrbOoY2pP4uBmwr4usUXqdfvrpSy65JLnPV0JE1THqmAq0BToGQ7tZWcpEQlLJuuuuyxuq%2BD%2BU%2Br7RMVpGo64QH8gFzXqBJJSWU089tVa6NVKGSjv8iy%2B%2B6EDH8OzY7QcffDD9gYJRRzCFNVM2OJPUSsIFNwzPVK1pKfwZOoZ%2FW0zapUsX66zTiYOjbLR4g9SrKYRACIRACIRACIRACIRACAyQBL799ttFF11UGEjTJqJUDD744KOPPjo%2FDcKCN9F8JJiByuUjBuTcc88tJh4FgHEhGqVc0mu44YabcMIJizDi6nnnnUfWKFeHHnroQw45pO1bWo1pKaOMMoqVmJR8UWi36Bi%2BTj311P%2B3iIEGmnHGGYvByKhkYA4zzDDTTDNNvcrq4WQy2mijlZoRRhjBmuuPeMkll1hkuTTEEEOIIGjmDSjNxBoMNthg888%2FPxuwdkzhL0fARi06hh%2B0Lt4m5JVByuBrobLqGMp2Ah8GakOLXkfREnhCADFguzqGeg3oGDWWpE5HYSPQEQSocLQCakPNHfFrdQwd3U51oqhTFB3DApqiHCnGg3D%2F%2Fff3pY4BSFOEcZvSg%2FgHwT8UdaIiKVALa01L4Q%2FXMfxY%2Ft3gbcINozmX344fS%2BfOnZviRrNByiEQAiEQAiEQAiEQAiEQAgMegXZ1DHYKMWGQQQaR6WKLLbbwdZNNNmHyc0rnss5Fnzgw3njjFSuPiUR%2FIF%2FwM2faeIs96KCDTjTRREXH8OqWxDHppJPSEEgHs846q5H5ZrSQ1NggFBVxLlQFkSnvvPOONk0dQ0pA4xBJmJ8c2lmFVuhtLDPQe2epA6xwpplmYrj5FLnDUGuttRZ%2FDK%2Fdhx9%2B%2BOmnn74My2glxbgFEo01zzPPPPoKtGlZlRfTCy%2B8sLfYzZfRLW3ytf8n0K6OYdl%2Beja%2B7aHc1DFkkNhyyy3FlRAf%2BnR3zbiS2qYDHcNjQt%2Fg5kEZIJJ4rGpqzT9Wx2j6Y9AuZN4gTTD%2Ff1HHcBckC3Klx5zbFR%2BqqrTUGyyFfq9jiHQTXOZHaQkh4YjFecxvgXzLIvM1BEIgBEIgBEIgBEIgBEJgQCXQJx2DFuGFb7HfmQn77LPPXnvtVcM9GGIcFbylhcUrZiJAdXVgLnGTGGeccagc3qJ6W8pNwoEjBSD%2F9mGHHVaWgJb3p0XHWHzxxWUjFPBuQNPpQjZRLvkx2GIsLM75ZSj24AwzzEAw4SviLmQLJE1wjy9XGWJUDqIKI7HUeL3uprwoZ%2FJIz8gzhBhSLlnquOOOSxJpq1foXu%2B6NM7fvxyBPukYdhcdo%2BhXTR1DBgzCHRedkiiGe4a9J8LI7vJX2kwD%2Fip%2FDLtdsgjPQomrkueTpiEwqpD8A3UMHgtSZ5AjSA2XXXaZ59QjwJPBxu6b%2FBhW4vniLiLWTPyLmBQ6pIer5Rfv9zqGaDLKj2QdzeQeLavK1xAIgRAIgRAIgRAIgRAIgb8JgT7pGAMPPDCDqAmB%2BXbMMcd4Sb355ps7VpUmwByTNIPHAjFBGorSWM3cc88tkER7L08FenDVcLLDgT99jElAmG%2B%2B%2BbxFbQ5e%2FTHYjMXvQhiIKAC2npXUPJ8c5iW%2B4FzhBBMnLxjZ1CZyF%2BJKyqRlWM0oLSSR8pXhaeUkEdkzGKfcNozP0aKsSmpEwsvEE0%2Fs5MrmqlIeMAh0rGNccMEFbrOpY0icsummm1Ydg1C28cYbUzxkivDXAR%2F24a%2FSMfg22K6kuaKqiU%2BhpB1%2B%2BOHl6x%2BoY%2FA%2BopBYJLcKf5Wl8Sy72hPXQX6Mkuez%2FNxSzXDqIBrQQIh7npHqOlIaRMcYMJ6L3EUIhEAIhEAIhEAIhEAI%2FEUJdKBjsOPKTTEDHWLC0qcM0A2mnHJKAoKQDW75rDmaBlmgxGto781vOa%2BEslH8HPhF0C7Kh2%2FGqKOOyu%2BixTKqOgZ3DoMwLY2%2F0EILCfavOgZzz5KsQWjJZJNNVo5ZmWSSSaqOIVPoK6%2B8UtbswAUt64GtDE9HNNIxuPcXdcWwPy9qaKsS1cKNpBxFUUbI3wGGQJ90DB4LjP2SJbKpY3jpb%2Bf4lF3Kk0EyWMeP8qmgD%2FwGHUN3ggBVTV7Kc845R2QWbwdeSeWg1T9QxxBCQi2555576HWSdlot34zyO%2FJ06ksdo7T3aBM3OGJRM6QVbXol9Xsdo8SVcMmo%2F86URXJ0oc%2F4R8NPPMBs19xICIRACIRACIRACIRACIRAxwT6RsdgO7DxKQaOd2Td0AQcfSgRaPXHcKke6sEoq%2F4YXkNzcphuuulEczguxHtef0kWGgs5aS6sRcfg1OGFONmhHGIiFYbGogBMyh5k%2FZnFSuacc07xIL9Wx2Clyp4x%2Fvjjs%2FXKqizMAggvhm2uKuUBg0C7OgbPH2lgxVCIdXKbTR3DNnCpbSJN%2B0Tghjwwv8ofw0SSVPDlWHvttYWWmNHf4jghXYap7fZfPHe1%2BUN0nOez5sewnzmByClR1DlqDG8oj5XsH3U06oTboX64tTfffJPzBsnF3dUGfFGE2HCIIoPUyn6vY%2Fjnwjr9IjVwrCxGQlHqEGmoKbPUdaYQAiEQAiEQAiEQAiEQAiEwQBLoGx2D7SAKg7FTCfCLEFfidbYaIgPBwdvqctW70ZlnnrnkxzC4QHsJMZrnRDAbm5ZU6dWiY6jkWcHxw8g%2BRcdgoCk7%2B7J08a582mmnJUf8Kh2DdsHkkRTUqsT%2Bl6H8lQCByVa%2F1kLe81YUf91C1THqMSLs4ksvvZS7AqcFu9StVR2j%2FOJyv1DMhH6UU1nLvXsQmNLSTfwqHUNODNoFM1y4ik0rysP%2B9xRsuOGGHCQ8LxZTdIwanNUx6o51jHpeiUU6Kog3hbS3bsosZApnsLrTOj6JQ5iYtDNWRXUksPB5KHEopY0gL%2Bv0AJZUIaWyL3WMY489tk%2BPT62XyBfnml2nLqwUmoqKY2K0NCbZp151yq1nufqcNNu3DJWvIRACIRACIRACIRACIRACAwyBvtEx%2BGPIqCmagwXHB8P72SGHHJKOUbQLyoCjQJxhyvnc0Q%2FSaZbwE9IESs4oESEiBoRJJWfFQQcdNOKIIzLfWFVNhm11DFdZNxwwqo7B5DGyhBvONJGb0XGoLolzEb3iLuTH6Ju4Ei73Rmb4CCrhy8EOsn72nVVx9W%2FJ80khkT7Uy%2FRqOjXXnPJfhQCrWShHCQkhvtmKe%2B65p4gSvgo1EKlFxyAvUB6IAAIrnJTqKFLKBn%2BGZZddllDgxrkDGYSNT52oHEzkqqwUVTBhWfMWMHXN6lka0yI4NXHSkAJXcISULxSG448%2FnrpSPh4ux%2FqYpQ5eC32pY2hvbXQJuTg8I74%2B8sgj%2FEmsWZiYO%2BLgJElvkRGsk74n7MUtqzT1E0884cZlttHALTQlgr7RMTifCMyRNfTnG7rUHRGCeKc4pllsTjkLxkPth6CL%2BoeitlTmsvXaa6%2BBr9L9WjwhxZNoMY4r8sz6vfymoPnHpCT%2FdEdWzmesgkohBEIgBEIgBEIgBEIgBEJggCTQNzqGG5cKQwYJuoEPBUBCDPkl%2BN67xMBhmJAyylVHsjpolaxRdAw22gknnCCfRrnq72KLLVYuNXm2q2Nw7y%2FRJVIOakxk2G233WTbKENN8NPHvFJhuPSrdAyWESVEpo66qjnmmOPJJ59sLkmZ8EKuWWCBBVi1LZfy9S9EoOgY5AW6gb8%2BHCRsWpEU9S5adAz1Iim04YBBhSgfvQhfJUVtX567KhmLxCwtTg5lUruLGd65c2dD0TGsrS7PChnsMtO29VzSt%2B91DI0d3EMocCOUQx1pF3QMNe7IX3dE1ak%2BJwpS04h5%2BfmOV6R7uOWW%2Fd83OgbxpHk75Y4MTr6gb7hanE%2FoGC7h4G%2F9WBg4jz32GAiyjNZoL4IqEUOzsjwFUhKxsfB0p1SmM844ozp7lPr8DYEQCIEQCIEQCIEQCIEQGMAI0Bm8x%2FQSU%2Fh8vTXGlzNJ5Y5oWgSyUni7yurhJ%2B89rwaC0%2BtbWmXuE127dmVWEARoC6yzOqBK71iZGOrbfcXMUHKJ5dLip%2FHee%2B81V2K17E1vjbmFcIm3ErEhCl4luwu%2BFnVSDvw6ivovt%2BCvxmqahiFLyotpgSreOzdvvy6bAWU6N97kUK%2Bm8Fch4Ofzft%2B2lLjSX7vC9mj5TdnXvAWEUTTr7Ssd7UwOPHaCnVY3vK1oY9j2JSyloNCXF4EpirOBSqKHr2S62rFCIxp069bNtuSioEFZnhWWj68uVRO%2B9lIwi62rWVFU6iXCi14W0LwFj5uWhqpPlnvnLuKOaAWUnGbjMhS5gJeIBm4ckLYr91Ty1mg6otQ1lIKnCcz%2Fu5Of%2F8fahG4Zzb8G0GGrsRiWlhsvzT2n7h1ei2kOzjNKXw8yLcVNNR2o%2FBuCZzOPR7NjyiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAv2egDMfHVXgb7%2BfOjOGQP9MwIkhngvnd7Q9OuQPWXYZvO2xI20H19LnT1pG2%2BlSEwIhEAIhEAIhEAIhEAIhEAJ9T4BR43xGxxp%2B%2Fvnnfd%2Fr97S87LLLlllmGQcmtgxSDlK0kpaP0xVdammcryHwGwgwzB0V6rhPR3O27e5kUpeaJ3i2bdNujf2pYz3qt902fVPpgFFHCR977LHNQ4FLR4%2BnKRxO2lZbIDi88cYbLnU8BXnkqquuOuyww5yg2ralYR2y7DhUBSfJXnrppUceeaQaLcvUf8gz6IRZd%2BGYVBOZpe0yUhMCIRACIRACIRACIRACIRACv0iANbHJJpuMMsoo99xzzy82%2FkMaHHrooQMNNNC5557bMhrbba655hpuuOGGHHLIIRqfGWaY4RdttJah8jUE2iXASD%2F11FPXXHPNhx56qG2Drl27rrrqqm0VtrYtW2ruvffe1VZbzZY2vkuffvpp7969v%2F3225Zmv%2FhVl1122WW99dZ7%2B%2B23Wxp7PNdYYw0qR0u9r7169dpwww333Xffb775pu3VWkPHOOaYY9xg9%2B7da2UtfPbZZ6becsstrZww4iF1RxRODU4%2F%2FXTl%2B%2B%2B%2FX%2FmHH34gbnzwwQfa1L59U%2FDvzC233LL11luvvvrq1rDWWmsdcMABPXv27Ju%2BaRMCIRACIRACIRACIRACIRACTQLsi3XWWWewwQa76667mvV%2FXtl7XjrGBRdc0DIF42i66aYbfPDB2WvsqS1%2B%2Bmy%2B%2Beb777%2F%2FJ5980tI4X0PgNxCgM3Tp0mWFFVZ44IEH2na%2F4447tttuu6effrrtpY5rOBhsv%2F32N998c2l24YUXbrrppt26deu4V9urdIw99thj4403Jia0XPV4Lr%2F88kSYlnpfqXx77723S0SGtldrDR3j%2BOOPJyC88MILtbIWTE3l8KwRNLT0kPpnoegY1113HSxF%2FTDXTjvtdNRRR%2F0qlYbTF1eQFVdccf311z%2FzzDOvvvpqbiG%2Bbrvttm%2B%2B%2BWZdQwohEAIhEAIhEAIhEAIhEAIh0DcE6Bjrrrsu94cOdAx2jZew3hG3NZTUqOerz1RR5pmvcZ2XRzo7he1T3lOX%2Bg50jKmnnnqSSSZp%2BzK6Dmi1TDwNeODXSq%2BG%2BaszrMrVjz76SMFKeMJbldk5sdf23llbEs2kuaQ6lELp26xJeYAh4Ec%2F4YQTWNDt6hg2cLHi3a%2ByHWX%2F2Dl2y8cff1w3jDY2vC1XsdjzKnVRsMFOPPHElVdemQODvkYozXQnxxmqbYCGGvW8OIy555579knHIL%2BcdtppddJaMIWQlhZnDDUWacy6bGsrOoZALbfmajO4RjOz66XgRpo6huV5kHVXLyqE78dee%2B3Fe8rdGUcYTr1HS9LYSjxEdXkKpRdNsrqCaHD22Wcvt9xy5513Xl2hlpbUsrAypok0M5fHualqukE1rtbp6g9XGmtQL9WC9nr5h8JN1craUaW7c7X4nLi7cpvNdbp3t9nsXsdJIQRCIARCIARCIARCIARC4M8mwKDoWMd48sknl1hiiZFGGmnEEUecZ555vLOuS%2BKcv9BCC4088siuMkl8ZpttthdffFEDBpF3u%2BOOO644kVFHHZXlyHoqHX9Rx%2BjTK9q7775b4Im5RhhhhE6dOl1yySXFgGIDLrLIIjPOOONiiy022mij8ePwKnz22We3HvNqb4VzzjnnjTfeyPN%2FyimnHH744ccYY4yNNtqobRYClstuu%2B028cQTP%2Fjgg%2FU2UxhgCDBFO9AxrrnmGpvnsccec7833HCDMueNHXfc0QPCeCcC2NuUBE4FQj%2B22morDhjF1H3kkUc01sXG40q09tprC8TQa7%2F99it2tPwVIjVKR54bVJRq%2B%2Btiyxlwgw022GeffWxLvhzt%2BmP0Sccg0%2B2www6WyhK3cooKiUCwWFn20UcfXdJcMLq1EdNhJVbrqokuvvjiIgIQEA488EDBKRQbLYuOUTJpXHTRRW7WvwMCTKxTYAinDquVQ4NiI1SEO0rZIQY56KCDdt55Z0tq7hmPKmcSiXGalcQNlHh6kBzVm%2FeUU06h4ViYv2eccUaVIC6%2F%2FHJU8YfOVYi09Fu4nfpb3HnnnQXp9ddf7%2B4szMiFwMknn0yHKVNDxL3E7bjkXvixyMZTLgl7obT4ff0KrrpBoKAjWaDhNsu%2FbBoTUoDiOUNyKX3zNwRCIARCIARCIARCIARCoF8S6FjHeOqppyaaaCKxHswi%2FyefAjDeeOMVx%2FsePXpMM800IkSoHEwGtr%2Fy2GOP%2Fdxzz7GDWH%2B%2Bzj333IwCeoLyoosuWqyJX9Qx2vXHICwYXOoMNg53dHqFWBgWFlZsjbKSQQYZhDsHv%2FdHH31UY5NOMcUUbB%2F6hjJFhduJZbCzJptsMjVsrmpOFubMHFbSMMMMU2ME%2BuVvkbn%2BbAId6xi2E63gvvvuswxGujK3CloEm9rmX2mlldjvoi1OOumk4447TpINekVxMJAfQ2PW%2BiuvvGJ7b7bZZqussop9qCMj%2FZ133rFj1ZAUmMnMZ33LQyRwg9Ft5IMPPlhgiKdMWfdfpWMYxEqkm7B7Gd1mkUeXhS68xeKXXXZZS%2BKcQHKh4dATSBmHHHIIKYA4Y9lXXHGF%2B%2FVseorJBcS9Fh1DS6siWl577bU8MSzeLZgOKDXGrzFiNBnShMEto%2F6URjv88MPVU0JqpQLPDf%2B8%2BChwBdHLPxTEgfPPP3%2FXXXdVPuKII4qTCfnROi3bA0tLIb%2FQJ8tvQaNwvwYnO%2FgXybB%2BBUuyYPeOth%2FOUCXoxq8vpMXXbbbZhh%2BIfKqaKRedp1wykX8x%2FHD%2BBdOSVOKfiDKmLVHW%2F8QTT%2BhoeS1uJ827SzkEQiAEQiAEQiAEQiAEQuDPI9CBjsEAYR2w%2FeuLVIkQaQVc3%2F1%2FexHupAC2DzPE8hgR00477VhjjcWy47O99NJLM6bKG1VT0Dq4czz%2B%2BONadqBjTD%2F99NQGSgKpgR3hw6mDTWcKVgzhwpvZgkLaQz4VM888M7PLdHQM43sVq6XpnnnmmTHHHJOHRnHtYA0xSayWOcmgMwJLhJcIZ5JmdEAZmT%2B5BALl1Xapyd8BhkDHOobtXeJB3K89z6Tl2FB2gi3B6pe55eGHHy40WLU0AS4cvrLodeTboGyKkhjTblf2pBiE2Wtzlo7quTR07tzZ88VmNwgzuVjEFAl2NzXj1%2BoY%2FAfY%2BAakAfLukL%2BiKAD%2B7r777pwWOIRYCavcTUlVUdxIPKpuiqcBFw4OVJw6lNvqGNQAd1dux8Isj0RAn3F3hvVPhClKiArlxPg33XRTudPyl6bBe4HSUj2ymldLGUCIKBLFN8NopA%2BTFrcomoNh%2FTpl2Z5uo7nlIowUwjCWef0uHnZrLj%2BcFXrqtfcPlGefdkEqqe4ixTmnKFd%2BSh2pE%2BVedHSn%2Fn3zVV8j0HAKVb8aIYUHSNsbSU0IhEAIhEAIhEAIhEAIhEA%2FINCBjsFm4c8gNoThI6bDx4tXJ5ssueSSnMAJC%2FwWalJENhTrjHrAH8OYLCMmlRQB7EGvueedd96hhx66nATRgY5BeaA20CvqcSXcPJiQXND5gSy88MI1EJ4NxeIjqngVzvKyTn2r9zhLh8rhja1VFYZMLV4lXrmWr6SPmWaaiQzSjLXvB7Qzxf%2BWgG3TQVxJi47BVrW7yoIpcsxhn7ph7G0NdNGgqWP46m0%2BDwFamTIruBxBwuz1aPgIKuFx4aW%2F54sgQGSoqgWT%2Fzfkx6B%2BFB2jiHgUCQ4GJrJ4UWB8G8gvHiL3XvNjlJsiC5BTPLYvv%2FwyQe8XdQy9%2BJaw7qmLRQCkFTD83azoDIsnEXCXaokLox5wyuLHYpYyb8tfDykXF4PwzaiXyCb%2BhSFHWDYdA%2Bp6xIw4Ms%2B%2B1RbRQ5fbbrtNgyuvvFKZjqHcDH8rbjZqKB66%2BOeCokIhoYVSSygkIs50pGM0RRiShR%2BOt4z2wHIX8e8Jfxv%2FBKkXmQJFXW0KIRACIRACIRACIRACIRAC%2FZJABzqGsHciBmGh5UOUcNTjggsuSD2obzb9X32e80XHsH6RHVJSNDuKSflFHYNHhxG8ueagTiHxYR8xHJh%2BxA22A0ukwmFZGJ%2FZUnQMx7NWG7PoGAy06lbBmqONCI0v3Rl60TEqyb9P4dfqGDX5ra1FxPB2viZtoF30jY4hhYLIBa4F%2FA2YyT56%2BTCQ5VtgjBu26m%2B2epECqrJRfxor0ZddX2tqoeoYZbfTTzgymcLzSBygBjDAmzqGcu1LIrA2jg2%2FTccwDn3GrVE42fjECq4gVTwss%2FgXRhCKaJSaRqPOruAXIXR4lnmGNAUQqTkoPEQS%2F7AU%2F4eampUI2aJj3Hrrre63qWNQNuos5WrxnPHvlTQgFlzg4KPc1DFKWV9Aio5BNvFV9owyBXp4irUpziF1lhRCIARCIARCIARCIARCIAT6GYEOdAxu57JMTDjhhKync37%2BiFWnBrDOJJ0YZ5xxCBplqcwNFhMVQi%2FCQknI6W0vLYKB4%2F%2F588coxkUH%2FhjOK5l00klLuHqTAFWEDMKuaZpI3mXTMfiK9L2OIZy%2FDBsdo4n371Pu9zoGo1uwBiOdlc0WlnfFR4EMwmGpvNmv2sjv1DE8HTa2HBf0BGKgZ5MyIPMGQa%2BpY9R8lX73s846i0VPNvzNOga3BKoC5UQOCpIIr62W7YS5rJtEmJZLfLp08e%2BJgn8T5Nzo2bNn7csFi5OJSBP%2FQP1OHYM0QYKQhZVgwgGDcGFSLhlW7vYtrG90DD8WVxMqk3uhfsjsWpeaQgiEQAiEQAiEQAiEQAiEQD8mUHWM%2BrqzLsCLSMKCyI5mWv7iT%2B5dpP9XP%2FDAA7PISns10uvRMVhJPMAl5OQ%2BUYeSmm%2BooYbqGx1Dok6R6bVjKXiLKgPG5JNPXt6NqmSSmK4EtkTHaMGVr30iUHWMdu3QtnElv8cfg4JnGTVDRTn7oyzM7uVZ5NHjbyDoowoLQjNkt%2FBk9ckf48wzz2x7a9UfwzMofQc7nT5Qm5EImjqGspCTugyZK4ge5A4BF30fV8K%2Fwi2UQUwq7QYFg4xZc2bW2UtBBIdVWQm1s17yr4SkFjwxVHLn0KAZDMKJQk1JOVLiSuo%2FUL%2Foj0GaaPpjUB4MRQtFVe4daS6suSxDZhJr6Bsdg0ZElTWO2%2BRCQ3upN5JCCIRACIRACIRACIRACIRAPyZQdIxBBx3UEQyMO3aQDxOjuJp7zcrngaUj052UF6wDR68ec8wx7EEBHTJOLLDAAuw1bzblBJBpk4cGo0xYhzQas8wyiwGZb9Iejj766H2TH4Ns0q6OwYjwJtRKmFo0DcIF53BfvUpmT5FZRLj0TVxJ3%2FhjMF29tG1KN%2F34F8l0fx6BqmPISMlxqHzEO3g7zxeC1cwel%2FjCAuR18RL%2Ft%2BkY7G4GL48IG9XzxRODsWzH8jcgA5pUthaeAHZ1CXngMsF3QmOeTuIvOjh3VTJPq%2F2%2FdXfvrizgpalj8KwgjEiTKxkFs11CGA%2BvMYs%2FBsFBKkvTWQlJ0CNsnUQJSkLf5PmERS%2FJPTyGnKz8g4CnSrkmuChQD6S5cFNtfz6BM1Jk4CnlKc1EKl3r5ItCEyg5NkuwBn3APx0QuUoSqalBf62O4R7dFDmUOOMX5AxmZHh9%2BMaQMmgaHnBTi%2B7pSx3DTck3YsFuk39aM8Ct7f2mJgRCIARCIARCIARCIARC4E8lwM5i%2BNAEfPhXlIK%2FTiZlH7E4vMD1leeDo04VKBUMNPaLF838ydXIOzHCCCMo%2BMjGybYyJkvHV0ktnD%2BioLu%2FbDf3cuihhypz7W65L69Z%2BX4Yv%2FnmurZxlcWhowCTkUceWWG22WZj0GlARZlggglIGfUlKWlFM3ZTzY9R1iMIpQyoy1RTTUU2qV1KvRfijlkxeDmMss6ewoBBwL71Vt1hOvYGEax82PJcICgJ%2FDE4%2BTBX3SxNQ7PqIWCfsHmFbNQcLHwMNChHjirrSBsplCiBRiaJkAEFelBIuAQwrj1oJnJJQa4YtjCbnSpoy5EahFHoYmFOABHF0ALcSjRjRP%2Ffon%2F6H43VM9g9pDwr7HZzOZLDYkxBB3BrCnoRZ9w7rdIgjHGVorQ0sx4paMxFamDj0ygoFbQIDylpwr8ALlEntCweLB4QPiS%2BmtEjbEwNMCFKaO%2FGW5ZdvxqKq4nZrcrU7tQd1INajENjtDCfgshoVJ0yvn83oC6KhwFJELQIqyVTlPGF6mhQjlUizrhfH0gNBZFC%2BU0BJ%2ByUn77QdsmS6CTGITcttdRSNYWOn0YuViP4l6fM4h9DNRb22GOPlZr8DYEQCIEQCIEQCIEQCIEQ%2BJ8QYLM4zNTrSx%2FHEJSP1BNMHv%2B%2F3ZIYC4w1VoMAdk7g3pzWdTKanAXg%2FAIhJGyNWWeddfzxx%2FdqWwNWAB94b5ZZRtrw3DYym4thwqTyRrh5NkEZ0Bth5h4zs5qKdaJSYGrJ0sGcZMU4eaH63qv3ovnkk082aWnpEid2VlJ5O2xSlo532UX30EYX7SXrq11KRy35nLDU6uClPn8HDAIsWc4DNiSZgmpRPr6yYe12zgDKBA03yyVAmRtSuXH7hIVLwSPflRouDRqUbayLMvWs2N30BDKFJ0hih3KIJ4cH2957fAkftGye3OH5sjlte7uRqe7wUIuxP1uAyxdhtc1l%2B2oofhHSa3iE5dukH%2BrlORLtZUA7XCxGuV%2BxJOXeCXQSgTL56SeWZ9gykWdZBgligvVoaSValieRgmGumoSTBkjBcCNGLn2JG7xN%2FBPRogq23IJ1erjcpr40H4JnwVWaKVNUSAquWpgF16sW7E6LqKKxGyRH%2BCfFvKUvnhoUQYaOQangA6aNe%2BQn5metQ0Fk2cjggxKvM0j9NG7ZPw5WVf99M7jfRXbQqpZAxJFGrleOKC23lq8hEAIhEAIhEAIhEAIhEAJ%2FCQLe2zqboJ5XwioRPCLqpGNb5i9xa1lkCIRA3xOgAPCykJSDGtD3vf6klkXHqAFBf%2BAsdCr%2BG%2BTZqor8gYNnqBAIgRAIgRAIgRAIgRAIgX5AwOkDYkbGGGMMzhj87WX4HGSQQcS%2F5%2F%2Fk9wP4mSIE%2BgcC%2FFV4PvC5koOipg%2F93y6sxJU083z%2B%2FvUQaqg0vMtEoxR%2Fs98%2FZkYIgRAIgRAIgRAIgRAIgRDo9wR4aPNCn2%2B%2B%2BWTV8HE8K2%2Fzmo%2Bi368nM4ZACPRjAsJPZLSQSlRMSj0EpB%2BvoWU6ITaWVBObtFz9bV%2BlY5XQg4gheKd%2F8Dn5bXeRXiEQAiEQAiEQAiEQAiEQAoWAKHJx63IFJGY8WyIE%2Fm4EpA2RoKNmwuwfbl9CEsltalKLP2RJMnJIwSGS7g8ZLYOEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQH9FQFpO5wnyDy%2BHNrasTcx7y8mkLQ3%2Bt1%2B%2F%2B%2B47y3MUZrufdu%2Fof7vgzP6%2FJWC3OwLVhi8f5T9qPU74tRv%2FqNEyTgiEQAiEQAiEQAiEQAiEQAiEQJ8IMPb33Xdf56U%2B9thjbds4WXWuuea6%2F%2F77217qH2pOOOGEmWaaaeafPrPNNtscc8wxyyyzlK8SkD711FP9wyKzhv6HAB3jkksu2XXXXXfbbbdddtlljz32OOCAAy644IL33nvv9yySiGGQ3XffvVevXr9nnPQNgRAIgRAIgRAIgRAIgRAIgRD4RQJ0jDXXXHOggQZqm%2BGf0eeowcEGG%2By66677xXH%2BJw2OOeaYqaaaauqpp%2FZ36KGHdhdjjz32NNNMo4as8cQTT%2FxPVpVJ%2B1sCtvTxxx%2B%2F7LLLrr%2F%2B%2Bttuu%2B0222xj8%2Fu60047vfPOO7952R6iI488crXVVutPzh79zTeSjiEQAiEQAiEQAiEQAiEQAiHQ%2FxNggjlPcIghhrjrrrvarlby%2F5deeqnlNFXBJnq1bayGndjSuNnMa%2Bs%2BHc5owD5d6qCXoJJPP%2F3UUSmffPLJeuutN%2Bigg15%2F%2FfWff%2F65GvV9OiHRIuuqOl5wB%2FdSR0jhL0TAz82HZ8UVV3SSpkMofJxtweloueWWu%2FTSS%2Fv%2BRmyMlt1l1xmq7R7WsrnfmlP06ZKR%2B7TxjN%2BnS82RUw6BEAiBEAiBEAiBEAiBEAiBAZhAxzrGmWee6c31M888Uwh8%2FPHHBx988MILLzzvvPNusMEGzcANB7CeccYZiy66qEtrrbVW9YW45ZZbvPK%2B%2BOKLRa%2FMP%2F%2F8iyyyyOGHH97MufH000%2BbQi%2FDHnTQQR9%2B%2BGGl%2Feabb26%2F%2FfZ6LbTQQnvttRdpol5qW9hiiy24jjzwwAPlkrMgrXDnnXeuB8K%2B9dZbG220kVACB8V6F%2B8V%2FEknnWQ9xt94441feOGFOiZT8eyzz1588cWtisjz6KOP1ku1YDFG2Gefff7YEx7r%2BCn8GQSqjvHQQw%2FV8R9%2B%2BOFVVlnlxBNPVPPiiy%2FyrLjzzjvLVbrB1Vdffeyxx9o8pcae12Dvvffef%2F%2F9haiUX5%2FUdu2111JIOHWY4oYbbuD1IRrLmFp6ZO6%2B%2B%2B6mxPH8889zJbKlbXj6Yb0kwwbfJ6EuLh1xxBE2XlVLTERpMakBCS%2FNR6%2FeSAohEAIhEAIhEAIhEAIhEAIh8Hcg0LGOwfAXrEGLgILCsMIKK%2Fg60UQTzTDDDAoTTDBBSZ3B%2Ftpxxx3VjD%2F%2B%2BBJWlDZFymDK%2Beoz7LDD6lWiPxiG5SX1PffcYzRXXZpwwgkVTMG5wnSvvPKK2BA100033cQTT6xAD%2FECvd0fhbm3%2Beab0zHuu%2B%2B%2B0oBUQqPQ69577y01F110ka8iZd54441JJplEeZBBBhGEIhRF2Sw9e%2FbUUu5H6oeascYaq9zLOOOM0zR7y2hMUfeib1IiFCB%2Fib9Vx3jkkUfKgmkI5557rl1X%2FDHsn6WXXpoiV64SKA455BD%2BG927d1dDWFhnnXVWX311GTY23HBDASnHHXccBU8zYsXKK6%2F88ssvm0LlUkst5SsfIUKc7rZuzT9jQ5ZBJOhQWGmllegeNrAnkXq2%2FPLLb7LJJi6tuuqqAlVKtJcpOnfuzGnEJqeeGVDHegt%2FCfJZZAiEQAiEQAiEQAiEQAiEQAj8UQQ61jE4OQwzzDC333676YgPrHs1kiJ6O8z6G3jggZdYYgkjXHXVVYMPPjhjjWu9kyDOOussgSrcIVhnp512ml4cG7z11stQI444IhcIEStEiQUWWIC%2BQWFwybBsQ41Zc6zLzTbbTJzI6aef7hIJxWimu%2Fzyy9u98bY6hmbcLYzGD0SZpck8JHRYgImmnHJKy3ALlkHW4BCi5YEHHqiliAP3wrBVT5%2BRv5Fe4dZaTqNgWnoFf9NNN8XPv91fpP%2BsJDIQ1kgHO%2BywA98GH%2F45a6yxxp577lkcgfjzuHrOOeeU9ds2vC804KehzEeCiEGR8Ou%2F%2Ffbb%2BpIdunXrZliXuCER34pUQnPgccGLw%2B698sorKQ%2BnnHKKS9yEtt56a2KaXgZ59dVX5eiw1Tly2G90DwlItbHZKIRWst122ymTR6yBVELi83zRFUkoNrYR%2Bk%2FOWVUIhEAIhEAIhEAIhEAIhEAI%2FHkE%2BkbH4GbPWhdnwTOBpVYWw7zaaqutRGQwwRhZ5A5GPVlAHMfrr78%2B99xzc7T44IMPCBHkCPEppZcDUl2SlpOxxmFjhBFG8NKZClGuCmDxIlsXSTm4OlA%2FWIIGNCzbjbzABqyNm0za1TFYfxbsBJOSu2DyySefc845xYNYMH%2BMTp061fAWQSXjjjuu6bTccsstS2pTMovGFiCqhZ8Jk7M5Y8p%2FRQJVx%2BAsQSUoHyLDoYceWqKWOtAxaGvEChEo9jl1wu1zxbEt33333XKp6hiCSghfxYVDM3FMLgmnooR4lEx36qmnGsHHDqSZqDEvHUMQE4HFbvRUGpMXEOcNDkIeBwOSOLgMqfcw8ht58MEHo6H9FTdh1hwCIRACIRACIRACIRACIfA7CfSNjiGE34vgSSed1PGsJeijTKqvN8JqZp99djEaRImRRx55pJFG4urAvWG00Ubr0aMHUYIswPOhdGG7LbjggkXHuOaaa%2FSSDaDeAjmC1eYjn8CQP32M5mPY4YYbzphEj3Ztt3Z1DC0JLPw9WHxsT%2B4cxTfDm%2FSiY9TUGW6BiKHSgr1h17Lei9l9JcU8%2FvjjdZ0p%2FEUJFB2Dsw0fCboBwY0uJ2TDj86JyC7qQMdwyzwxuAyRHfylS9hXVAX1HoSmPwYdg3BRM67QMQgU2tuQEsXoLirECOWjpdmlxTDIeeedZ21cPiiE1lNDlvhgSL7hEjWDlMd%2FSeqYv%2BhPkGWHQAiEQAiEQAiEQAiEQAiEwO8k0Jc6hgyfzHzxINX2r%2FN6kT3zzDMPNdRQXlXLp1GsM34aAvkpBuJK6BjVUb%2BtjiH%2FQB2qFkR%2FkA4mm2yyn629DY1sTEEBVI7arBba1TFclfSA%2BiFqQGIB6krJ5tGujuHWOJBw4ZAegeOH9%2FXNe2E8VkeUOmkKfzkCdIxyXkkz4Qn%2FHIEeUr%2Fa2%2Bo5adTtWuNKqihh83Tt2lU2GJKC4BHyBb%2Bdtv4YfdIxLrzwQnKE9BrEE2k0fBR8ai7ZJ5980gptdcugwkmdYc2Fs8gssV0uUUKMT%2Burl%2F5yP0QWHAIhEAIhEAIhEAIhEAIhEAK%2FmUDVMWo%2BzOZQJT8GZ3hRJPwoWPpeLpcGbLfzzz%2F%2F5JNPlm5CRs1RRx31ueeea%2FYtZQ36pGOwyzhLyE1RzTHBJgxDIoaQE5fYawzJtmO2remTjuG19bTTTsszhH8FZ5IiwhQdQ7wJUaUM5cU3zURaUUkS2KcSYjhFpe0sqfmrE7DT2uoYfnT7XDwRtxx7koAgQ0u5U%2B0dDkJPkB9DcJOdWTa5ze9B4EpE7yJ9aNY3%2FhieNX4XvC%2FaPeNVzIgoFaFYpuaAQXPjxUFA%2B%2Bijj8hrpi6RLyKzNKNj7L777pb0V%2F9Fsv4QCIEQCIEQCIEQCIEQCIEQ%2BLUEio7BA0EKTQZd%2BTCmWE8uFR2jHJrgjEi%2BDWy3kl2Q7sHDQUQJNYAjvUscGErUCbnAgaQSJHKkl96wrY4hzaasAprpPvroo4tbsWyOFjIZGkeCRGMuueSSIku40FuGq84HkerTO%2Bh2b7BPOgYDsxykYljrKX3pGFNMMYWEGIII1Lgdq9XAYZfaS%2BWhzLefqOIqU9EtWxglpzk1gUWUAbnD1M36lPtnAn7fomOIMyIL%2BFDhBD3RLggRdhqZgmphP9icvnLDEOJBNKAkaElY4LkhGsU9kjJKagtn7hjW7tKs5PnsU1yJPSNwSTJPYxJGDGIK3bt06eJxk2B2scUWU6ZUuGQ6KUA9gB5JeXQl1KWulEePhGIZMmkUXa5%2FBp61hUAIhEAIhEAIhEAIhEAIhMAfToAlxQOB5c6hQgYMwSM%2BzjmV3JK95iQFKSxYWObl2zDjjDNqKVumnJ8SR8jt6cwOl1hhZAeXOD%2BIy3CYqTIHeGZXOTSk5vkkUJQUoMQEHaUpIFZwlmDBzTXXXHrNOuusDj1xiX035phj0kBkrmDEkTu4SbT7IltjYgIVRXe9fG1%2BiCRya7i7muDC1GWFo4wyihtxOzq65fKqXapPATJqaB3LLLOM81iV11577eq8UQZn4SKglzQLzelS7p8JEByIDH5WikTxwaAq2LFkAUKZlQsSIWc5DURUFDVDM8EjRC0%2Ftz0mu4WoEI0PO%2BwwwUqcMahk9C6XZAp1Uiq5wxSiP5Rrnk%2FePrQR8VNkPerHZZddxtHIvGpMQUIR0uLhIqrsvffepnPoqmQaPDGUnQCrC03DRHrxwTC1p9IyPAsu9c%2B0s7YQCIEQCIEQCIEQCIEQCIEQ%2BDMI0DE4Gzj%2FlFxAYagftht7X%2Fw%2BS5%2FjQZna22TWH9Oe1kFbuPnmm%2BuSuHAwsmaaaaYJJ5yQl4Uxy2kgXiWTRIT5l5beNTtKkixQvB1U3njjjTQQYR20BYPzrq9jlmQF9AQCi9AVLeullgJDkv%2F%2Fwgsv3DYeRPYDR5%2BYop5OUvwxxhhjDHaoezE4w7akzijD8iqRwUDgiXsRbMIRhbjRMiN%2FEu%2Ffpd0oLigtV%2FO1%2FyRAZLjiiisoFRQDf2kR%2B%2B23H62guFiUNdvkRAlhJtwhjj76aPuKQ04Jp%2BIydP311ztulZJAfOA4VLYrRwvn82pGjjAFpUK5Zumky9lCgrDoGKbQmIOTBRjEFJJjFE3PJQ%2BFOKztt9%2BeVELNoFRU9cwKKTAm1csCPFYtDkL9J%2FCsKgRCIARCIARCIARCIARCIAT%2BcALMLtZZux9ve1lefCpYXs15hVpwwCAdNCtLmdlF0GhaWHQSI%2FhbGpTp1DS7K8sj2lYrKF0IBX2ari7AsO0uVQPJBKTa4K5fGzMb6TAECqKK9%2B99Gpzu4V5kKqgdWwr4%2BLRU5mv%2FTKDsk%2BZub9nbZfE2JO%2BIkn1CWfvmD%2B2rPWnn1Dutw2qsbLc3u5QR7E%2BXahdfWwaplzxEHofmQ1QvmVQvg9eaFEIgBEIgBEIgBEIgBEIgBEIgBAYMAmzGZ555xuGVzngdZ5xxvGSv91V0jE6dOvVJOaktUwiBEAiBEAiBEAiBEAiBEAiBEAiBEAiBfkOAZ77UFgMPPDBf%2Fear8LfeekukiTAW79z7zUoySwiEQAiEQAiEQAiEQAiEQAiEQAiEQAh0TOCRRx4RTuLQWI79zZYCRq6%2B%2BmopO%2BKf38SScgiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAj0ewLObnj22WefeOKJDg4H6feryowhEAIhEAIhEAIhEAIhEAIhEAIhEAL9GwHnTl500UW77bbbrrvuKjHmvvvu27lz53vuuafd8yj%2FpMU74HKJJZZwFqoMnH%2FSFBk2BBCQKeW%2B%2B%2B47%2B%2ByzX3jhhSYQBwFfe%2B21Xbt2dappsz7lEAiBEAiBEPj%2F2LvreO2qqnv40g0iXQKilAqIIuVDd3d3d3c30t0N0p3S3dLdIK3YWI%2B%2F%2BLzfl%2Fl51rvf6xwONyF3OPYfF2vv1WOvfXPmWGPOFQSCQBAIAkEgCASBIQ2Bf%2F3rX6uuuqpzPVwjjTTSiCOOKOF3q622%2Bstf%2FvL1jBaPseCCC04%2B%2BeThMb4ewP9je3FsDZpuscUWQ9x1T6v561%2F%2Fus0226yxxhpvvvnmfyw4mXgQCAJBIAgEgSAQBIJAEAgCQWCoQACPsdZaayEujj%2F%2B%2BMcff%2Fyxxx67%2BOKLZ555Zk8uuuiir2cKeIyFFlpoiimmeO%2B9976eHtPLfyYCeAzrfPnll19hhRXOP%2F%2F8dpANHoMYab311nvrrbf%2BM5HJrINAEAgCQSAIBIEgEASCQBAIAkMLAsVjjDzyyPfcc08b8znnnEOVsfvuu3vywQcfnHfeeddccw3h%2FcEHH3zfffd5%2BNFHH5177rkHHHDAoYceeuONNwpwUXWffvrps88%2Bm2j%2FjjvuUPiwww6TYDy2llEW3FhU%2FPnPf857pQzJ4jHoMe6%2F%2F%2F4zzjhD7kknnfTOO%2B%2B0Wi0hgMbll19%2B%2B%2B23%2F%2B%2F%2F%2Fb%2FbwySCwCAi0OUx1llnnSeffLIq9uUxlLSMnc%2BL1rv55pvjbzKICKdYEAgCQSAIBIEgEASCQBAIAkHg341A4zEefPDB1tdRRx2FxzjooIM8QVyMNtpobuvad999X3jhhXnmmcftKKOMMtxww0nssssuIgwofOSRR7qdZJJJECO8VKRHHXXUSy65pFp%2B4403lllmGQ%2Fl%2BiX5EI4DB%2FLnP%2F9ZfAwPJ5hgAs%2BHH354v%2FPOO%2B%2B7777bhlSJhx9%2BWNZ0003329%2F%2Bticrt0HgMxFoPAa3qRVXXHGPPfb44x%2F%2FqFYPj4Elu%2BKKK1ZeeeXllltutdVWW3LJJbfbbruXXnrpM9tPgSAQBIJAEAgCQSAIBIEgEASCQBD4dyNQPMYII4yw%2BeabH%2FPJteeee0488cS4gmeeeUbvDzzwwHjjjYeyIMWnvqCF2GCDDZAJ4oKW7mKWWWZBdNx7770KH3fccbImmmiiffbZh4vKgQceKGuJJZb4%2B9%2F%2FrqONN95Y7vrrr8%2BB5ZZbbvnJT36Cu7jppptYkUIWyJprrrmuvfZaypCFF17Y7VlnndUzfY4nm266KcFG0SY9ubkNAgMjgMewRPmVWHUUQVi1Sy%2B9VBU6n65fCbpslVVW2XLLLa3qV155hfRIFfKkIj0G7iK5QSAIBIEgEASCQBAIAkEgCASBIPBvRQC9sPbaayMNeq7ddtutfDfwGGONNZb4FXWrPK8TDIOEgXEMOeSQQ9TlTuJW8AFpPEONmcPInHPOOemkk%2F7mN7%2BxnT3hhBPOMcccWIvKve222%2FiSUH0ohrggxqD0qCw%2BLNopx5Z6kt8g8OURKB6DysKqFtJzww03tPhffvlltFjxGCLNOqnn8MMPJ8ZAxFWPTcVRZN2XH0ZaCAJBIAgEgSAQBIJAEAgCQSAIBIEvjEDjMew%2Bn3LKKSeffDLfEHE%2B6SgEQtQsi2%2FsscdeffXVW1BEDx1eSZY%2F%2B%2ByzzzrrrJNNNhnOQUXPS4%2BhnRqPE08WWWQRBMWHH35IgMHHZIsttqgsv0QayI23337bNjee5Nvf%2FnaLiaGwNnfddddWOIkg8OURaDxGhXm54YYbcBqYNEFgnGAizmetRmeX%2BBz%2B8Ic%2FtB6FeVFSaJf2JIkgEASCQBAIAkEgCASBIBAEgkAQGCwI4DGcV8K%2Fo7vXzLMDj7H44osLXiFuBh7D2aylx2AJnnnmmaOPPvq3vvWtBRZYYNlll%2F3BD36AcyjuongMUTprLgJfEFqQYdBjkFjopV%2BJRcX5dF5JO3c1PMZgWQzDfKeNx6jVzp2E6xOC4vTTT9922205TOExhF7hu7TDDjt0zx32FSDuTjvttGEeokwwCASBIBAEgkAQCAJBIAgEgSAwhCPQeAw7zm2oTp%2Bceuqpxa9ARDz00ENdHoOyYtppp51xxhmp7lmFqmAwPpPHUAs3MsYYYwiO0Xr5%2BOOP6Tqef%2F55G9917mp4jAZOEv8OBHp4DF1QBFmTK620koAY4rfgMSxLPibSyLc2BofyoOwuu%2Byy9iSJIBAEgkAQCAJBIAgEgSAQBIJAEBgsCDQeQwDPGgD%2FEYoLkT%2BFCBAroEeP8dprr%2FETmX%2F%2B%2BWu3WmABm9d4jFNPPVX1T9Nj0O2%2F%2F%2F77U0011TTTTKOF6uiiiy5Sce%2B998aWDDqPgfRgaQ4WrNLp0I5AXx7DjIT6pLUQxhZ3gcFTxmLGWlx%2F%2FfU1X%2BvTwTokSU899dTQjkDGHwSCQBAIAkEgCASBIBAEgkAQGNoRwGOsueaa%2BARHqa7zyYVScPKpWBbXXHON2YmPQUeB0yi%2FEvSFM1KVZ%2Fftt99%2BlXZ77LHHKuxX%2BsQTTyxYGIALLrig407qBNXDDjtMy0J9Kub8Vp4p448%2FPl0HXoKLijgbTY9x8803a8dxrj3wCgQ6wwwzcAToav57yuQ2CHwaAjgKa88xJdRBrYxVajUuvfTSG220ER7D81dffRWngbhA6FFiOHxHLo5ORJdWK4kgEASCQBAIAkEgCASBIBAEgkAQGCwI4DE222wzfMI444wz5ifXuOOO%2B7Of%2FezKK6%2BswJ7OoKSjcLJD8RgG6dRUET6dxDrccMNNOeWUTDzVHcPKSBRAQJr1V3PBNqA7pp9%2BeueleiIcgZNbhcvAUdB7zDTTTLfeeqvnDEnnWrql6q%2BKnFy0gyep2%2FZrQ9xgkCc5AbNhksSgI1BaCwFhqIy6tZ599llfwdZbb91W4JNPPmlJczaxgJUXxrYb9rNbN%2BkgEASCQBAIAkEgCASBIBAEgkAQ%2BDoRQFZQQdA5vPjJJeE8SuRGGwMuQo4y3fNKROa8%2F%2F77uaLwFsFOKKCWAkIKiHchUmJV55by%2BuuvO9fyn%2F%2F8Z2uQqaiiAyM00lOMl0o9kWUk2A%2BGZ6sooQs75rxUug%2BTDgKDiIDlZIm%2B8cYbPXoe68pKtrS6C9XCtpgfeeQRK7ZnHQ5idykWBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAwRCHglApHvjrC1bkqQ9TAMpjBhYBzSf761786Lqd7ZM%2B%2FYzBOMbb2HJtiEX6u9g3M8AwyR6h8LtxSOAgEgSAQBIJAEAgCQSAIBIFhBgHWkFNTb7311nfffXeYmVRNxNSeeOKJq6666upPrmuuueaee%2B5xkmZjLf7%2B97%2Bvs846884772uvvTaMzT3T%2BUwEsAEPPvhgz6vHKpx99tnbbbeddfKZLQxQAEHhwFZnE7see%2ByxV155BV3WLf%2F73%2F%2F%2BgAMOOPDAA3ued8v0m3YeseGdc845%2FRIgv%2Fvd75xo7AzZfuvmYRAIAkEgCASBIBAEgkAQCAJBYNhAYPfdd%2F%2FGN75xySWXDBvT6c5i%2B%2B23N7Wea%2Fnll2cMKobHmGuuucYbb7y67VZMephH4KabblpuueX22muvv%2F3tb22yyIEjjzxyxRVXfPzxx9vDL5B44403NtxwQ%2B3Xtcwyy2y88cYXX3xx6%2Bujjz7afPPNt9hiC4TG52r%2FySefNDyD7JfHuOKKKxZddNFTTjklao3PhWoKB4EgEASCQBAIAkEgCASBIDB0IbDHHnuw9C%2B99NKha9iDMtodd9zR1FZdddUTTjjh%2BOOPt%2F0955xzejLffPP95je%2F%2Bec%2F%2FznPPPNMPPHEFCmD0lrKDDMI%2FOMf%2F9h%2F%2F%2F3RC2uttVZXeoEcOProo1dZZRV0wZeZbPEY66%2B%2F%2Fi9%2B8QuKoBNPPHGTTTZZaqmlTj31VKtOy3iMrbbaauutt%2F7DH%2F7wuTp66qmnDM8g%2B62FkTvssMMeffTRfnPzMAgEgSAQBIJAEAgCQSAIBIEgMGwg0JfH4GNCnX7XXXe9%2BuqrPXN85513eGfcfffdLLWW9cEHHzAGm0KeCz%2B5vrotyIDEM888c8cdd1Dytw1oW8Y6eumll%2Bgifv3rX%2BvuoYceEjSgNSvB3rQzfvvttxPnK9bNYnI%2B99xz2pRVtmE3t9I77LAD1uKyyy5rWQILrLDCCvXQqPryGIZtJPfee%2B%2Fbb7%2FdalWCs8DDDz%2BsR3MR36Cb%2B%2FHHH%2FMjuPPOO19%2B%2BeXu824adK%2B%2F%2Fnq%2F2%2BjdYkl%2FDQhYruuuuy6Cy2I477zzmnqh8RioAAuAX5IX%2Bt%2F%2F%2Fd81JAvGirXa29u3XN9666228tvIfR1IjJ133rktWr4etB%2B68%2B0oVjzGNttsozUfi45Uac1WO7%2F97W%2BffvppxIWV00ZYPMZRRx3FhcT6d6up1q%2FlbYQ9H1HLTSIIBIEgEASCQBAIAkEgCASBIDBsINDlMdhxrP7JJ5%2Bcpe8aa6yxGF%2BMIzMVVsK28qSTTlpZ4447LnE7nkHW3nvvPeqoo9p3LkCYZj%2F96U%2Bnm266srBwFKuvvnrV8jvzzDOXKaew%2Feixxx6bowdRRBWgile%2B2kFxuG0V7Z43XoWaYtNNNx155JErd%2Bmll0YRVK3ub%2FEYF154YffhSSedpNbhhx%2FOPu3yGAImgMKUq82JJproggsuaLQDtmTuueeurBFGGMH2umlWs7IE2ais0Ucffc899yzEup2ySX%2F2s59NMskkLNPu86QHCwIXXXQRSuHyyy%2B3QpAJllMNo3gMjhs8klZbbTUrU%2FrnP%2F850kABXIT3brVjtKr8DTfcoMyVV17ZM4viMXbaaacupeDVr7zyylrzKWlQv8KzWC260IgsUS%2BKkUOYXH%2F99eutt57nxmkk559%2FfmUhLnxNhmH9y%2BK3Io11rAHceOONWhsmtVU9COc2CASBIBAEgkAQCAJBIAgEgf9kBLo8Bjn9BBNMMNlkkx1zzDEnn3wyzoF5ftppp8GHKGK00UabYYYZ8ADHHnvslFNOOeKII3L5l8UWY9o3a46BP%2Buss37nO99hrCE6WFsaYZQxHnfddddRRhllttlm%2B%2FDDD1UkrZeFA9lggw30UkQBwb8sdRdaaKHhhx9%2Bo402wiew%2BKSXXXZZbAP%2BgTuAivbTbaYLRCDN4iOKULF7FY%2FRE%2FoDM6M8hX%2FTYyBM7Hfr13OMxLnnnovlGH%2F88b%2F1rW8V7YA%2FAYXp22HXI0ZFyc0228wGOtYFJrKEGTnzzDMF3JDFh6VtoNd4qFBYo7PPPvsAgo3uyJP%2B9yFAPoGmwKFZYxawlUOBU93hMax8y8x6E%2FATmeCN8wex5mVZ2Faj1d54DAUsBlEpekbbL4%2BhXwteWAz8nvUgXKe6FTcD86BlDiMVl4Owx6iM8LrrrkOVSLj1UC8W5BprrOGbMgxZQmEgQHw%2BRfF5gtnoWfA9Y8ttEAgCQSAIBIEgEASCQBAIAkFgaEegy2Nw52eGF5NgXtwrxhlnHBu%2BjLgjjjhCFnKj5sti%2BuY3v8kWc2uHGqfR5TF%2B%2FOMfTzPNNHz%2FmX4E%2FAo0zfzaa6%2BN9HCOg4pbbrmltOAV1SYjTpuMOxvWZCGyEBFV0a92RhpppAceeEBdvIFd6arld80116TrkNWeVKLiY%2Byzzz4kE7%2F61a%2B4fhx66KECe5J%2F8A1pPEb5DiiGHmkqC1SG%2BaJQNFVz96SatclOcEKwwSOgss4444zK4ozw3e9%2Bl%2FSCjVxP2q8Yj83%2BbQ%2BT%2BPoR4L5EtHDWWWfpWmgUC%2BmQQw4p55HSYyANbr755hoYwm3bbbflJMJfg6jmy%2FAYfEyQXdY%2F7sunQY9hSbfYLD4fK9%2FROvpFX4jl0s5S4c2EuMC8ycJjoDtwcW0tkRupiCSUGx6j3lp%2Bg0AQCAJBIAgEgSAQBIJAEBi2EejyGCwmTMK3v%2F1tzIO9ZoYbF4ny8XfLj4MMg4V1yy23sNMZ5pVla7hfHsO%2Bc8kSEAU2jhlf9BgoDgSFk16ham%2BaI4aIE4WwXWwkwMILL6zZXXbZZbjhhtNRA58FRynBf4QaBMNgG5qfy3HHHeeXX4knZZm28hLFY8ii5XBpUHrCCScs2oFWpPmV1DiRGKxCNA57c5FFFlHYsNm2%2BuJHgwxpjQsSoiRLk0VMYWJe2BiD4Wsz44wzEnKIXdAKJzHkIIAio5ah5Hn22WeNyu2%2B%2B%2B5LfVF6huIxEAXcN9qYKYVWWmklb%2F%2FL8xi%2BtcZj0GagMlpsDZoQ%2FEnFcrEacWWCydBp0P9YVHgMq8uQiscgGmmCH7E%2BTEd4T4PnVxI9RntxSQSBIBAEgkAQCAJBIAgEgSAwrCLQ5THIHthNQlsw4V1YhYMOOqj8NWxYU9e30BnTTjutQxPKZ38AHgNoNB6kC1pj3eMQEBf4kC6PUWkl8Rjf%2B973EAh65LVBfUFB0Rf2cgxBShQvoWUNjjHGGIbXU7h4jAUXXJCug3SE4oKzQNsBx5Y0HkNFZiN3GK1hcgyYwEO6Dpjg8GLir7zySk%2F7nFw0rlgbiQQ3GXqPLunRUyu3gxEBYTm5chDwcAJCGiAKrA16hoop0XgMgTfbILlpoBEwV1%2BSx7BaEBfUTcJx8Cup80pa2Nsuj%2FHee%2B8hErm3cCGhAKlAGegXQyoew6dnqDVCM1LA2qYvCo%2FR3loSQSAIBIEgEASCQBAIAkEgCAzDCHR5jNrkZXDRSJAl%2FPCHP2SkozIYTZVFD09yrwoeQ1btESMW6DFanE9m2k9%2B8pPyK2FkTTXVVNNPP31JOKg7OPurODCPQekhggEFRQtcAH8PGZLIB4IQLdhGd6gEHwGXBLcRyv%2Be19RvfIxWpvEY5CLSiy%2B%2BONEF81Y4R7cYDLoRfjS4HRYlBoYrSqtL1U9tYqZYF6FBRYwkZanBGAkruO2ztypJDAkIXHPNNUgJAhtCGgkXrQUhBP2PZW%2BR9z13lc5HYZRa8Rhdnw5%2BHDiQQYyPQQFCOOGzwv6RM%2FXLY1hIvjUKEEMi%2BKmlyN9KcJVP02NYveUao2J4jCFhjWUMQSAIBIEgEASCQBAIAkEgCPy7ESgeowTtQgHgH%2Bz5Vqf4AeoClppdY%2BE00RrtxBBRB2UtueSSTD%2FhBegQKpSEitz%2FERe0HAxDsSxQHPxH2izEBBiYx%2BBXQvvBeFTM4Q6tIuU8nYOQHWw93TEDWxbagbq%2B3bZE8Rjswfakm2g8BqEFg3GKKaYQ5LPiJCjmaEtESsUDKQUIpUqrLmCC8J4sU7QMuqNxOAqgU%2FoyKq1iEoMRATQa7RAxhhNz3nzzTT5KLm8fU4fZKPEPHgO5UfFbDJU0yAfCGUR55AM1BZGPhV2zsBQH4DG6567iQPbbbz9OH%2BUqhQT7NB6DuxMxBleXdoqKgSFbujwGKo%2F6osaAS8GzOevEbeJjFCb5DQJBIAgEgSAQBIJAEAgCQWDYRqCrxxDkAXvANGPoCRFgk9rtFltsIYwAK0zaNjH%2FC04TFPJunSwJHCEKpeebbz4qi1%2F%2B8pesKreoDJoEm8V8NJw3Sucgl7cI9oPDSFlzFR%2Bjx68Ej2HDugJmEkiIUEEIgdYQn3OWWWYhuS%2B9B5Jht912s1WNUVliiSV0QQjR86YGkccwSPoKziOcU5i0pUXRnVmUr4qWzYKwBNsDFsFCTWGxxRZjFzN4hUKVy5A0TuEWEUGymr9ADUlJZIhzJVoc0Z6h5vZrQMBxPBQRXDDKH6r1WD4d4q4gB%2Fxa%2F%2Bgp6wqhx5sD%2BeCNI7gwDOrSZmDGUFiicXJRQXr0q8fA%2B3nd3FVoJHgzCWmL9EOSWAn6%2FTQewwIjq0BZ%2BIhwaJa3uDS%2BGkNqPAb1hQgeNBvWG3GUjjypgB7hMdo7TSIIBIEgEASCQBAIAkEgCASBYRgBbACDvU5Q5TbC195tuxzMUfIMBAILrj2XcCpHhYxgs3ezxL1EMtBj2L%2FmlEGtweqvigJZTDrppNKsMJBuuummnrRgnjbHp5566gUWWKBsPUZZC8ehioNcG%2BOBPZhpppmqTb%2FaF%2F6ineDQXhZ5idwmFGnPK0GPgbtwvmpFzGBCYiRam4KdSmuWXam80KBKtlyMSkXAkHv66adXMI3Kxaiwc6tW6%2FGdd95B7FBukLi0h0l8nQhYit6UQ1S5lvT061ASXAFCQIQWITSRdVRDfnEUCAQUBKarqiDxyDmQDLLwG1gRv6Vl6rZpJSMxFHMpIA4tfQUurgk58BhIPAxh47vQJvqqI1NfeOEFuWoZg4v4x%2FdFSWLF%2Bhh16mAdv7K0T6ohBA2m0QCQHmrV2SXd8SQdBIJAEAgCQSAIBIEgEASCQBAYZhDgFWLPlyHGgqtJ2Xe2PW3jGP9AaEEP3yYrQoX9XyeNcvFgDHZDQCBAbD0ffPDB7H0GPnvKpby6LCwxErlp2OkWRIIhxs7Sqa7dqsXGry5YeZq97bbbmnMHe5CNZiR8Oig02kgk2J5U%2FbKcV4Ic0Fo3V9oTz%2FEz7fzKngI230kvuhOxX0%2BAYXbGgKIxNlA0AT%2B6gymqR3A187PadDqJLFoOnIkx93TklhGNe7npppuYon1z8%2BRrQMCisjItxZ53p2ukEz2Dd829yKvkuySuy3333WfxoM6af4eSFpWFYc0gHKxe3kxqtW%2BnzcJpI163LLomPWq824hiloFoKlqg8ahafJGUb4uHboe0yZeiHVn4E5daBq9Ng3zppZcMwydAGdJIMy5d%2BJBPW%2FBteEkEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJB4N%2BNQN%2FDRLo9DpzbLdnSX6BKq5tEEAgCQSAIBIEgEASCQBAIAkEgCASBIDCUIuDERidCOtK07%2FXxxx9%2FVZP61a9%2Btfnmm19xxRX98g%2FOl9xyyy0dK6k7h5w6O7IObB2gd%2BeibrbZZn4HKDMYs4y%2F4eng2na25mAcUrruIvDXv%2F7VC3KeaXs1Vqazgx3m2%2Ff63e9%2B59jcbnXv17moGqmHctVyVKsWusW6aWV8UP1%2BUw6EVb17irG01nTRzvxtTXniuVyHFLeHPQmT6rejKqY7ddvRxj11cxsEgkAQCAJBIAgEgSAQBIJAEBjCEWAW4RAmmWSS8ccff9zO5cmNN944wOARIB999BGTalAMIgzGN77xjZ122qnfBn%2F%2B85%2FLPffcc%2BVeddVVE0000YEHHthvyfbw2GOPVeWoo45qT77mBLOXecv8bIZwdwAPPfTQ9773PROB6AQTTDDVVFNttdVWTz31VLfMAOl%2F%2FvOfgNV%2Bv7TPABWT9ZkI4BNuueWWLbbYYo011lhzzTW32Wabhx9%2BGM4%2BhOOPP96T9f7nWnfddTfYYAN3%2B%2B%2B%2F%2F1%2F%2B8pfWspVvrfpq3nzzTQ%2FfeOMNy3WdddbR4Prrr3%2F66af%2F%2Bc9%2FboUlfCaXXnrp9ttvr7Xrrruum1XpO%2B64Y5NNNrnvvvuq8AknnKDk6quvvvbaa%2B%2B8885PPvlkq%2FLaa6%2Fttddenutro402Ouuss7qMn6m9%2Buqr%2BjIpc2y1WuLFF1885phj9LXddtsZdnueRBAIAkEgCASBIBAEgkAQCAJBYChCgPnGdsMJzDnnnKusssrKn1wrrbTSaqut9sADDwwwEQaUivPMM8%2BgGERXX321Lnbfffd%2BGzzyyCPlXnDBBXLZX1NMMcWJJ57Yb8n2UAFVjjvuuPbka07AbYcddvjhD3%2FYNTPbGO65557RRhttrLHGWmGFFSCK0zBaTBGWppUZIIHx%2BMlPfsLY%2FF%2F%2F638NUCxZXwABrMWqq66KnTjjjDNOOumkIi5eeukl7MTll1%2BOkTjkf65DDz102223XWqppQ4%2F%2FHCvu%2FWFYkIFHHbYYd4OygKxsMwyy6iE3Nhxxx2XXnrpM888U2tVHtdhnXi41lpr7bnnnkVWtKYk0IA61WDpdjSrsC%2FlvPPOk15xxRU33nhj7ISSeC1M4PLLL3%2FEEUfoa%2Butt1522WWxFkV2mdc%2B%2B%2BxjsS255JKeX3%2F99d1epG%2B%2B%2BWbsx3LLLUfIhACkRekpkNsgEASCQBAIAkEgCASBIBAEgsBQgQADjYU18sgj89FgEPVcbQrsNe4nf%2F%2F73%2BsJC46EfrbZZiM5ePbZZ92qWFksOAaXPehmynlePMbee%2B8tLddVheu3y2MYD5EDQUK3wO9%2F%2F3tWXteoLx6DHVoNKtDKG4lGqjDlv2F3JRO67hZutQZuh7GpnabVLx8BtA92gmXqtk2%2FGrz33nvxGIxQXcsyjPPPPx%2BPMc000zz%2F%2FPPdTtFBWm47%2BAob%2Bf333z%2FGGGOorqLGW3mwKNzVBrSsStTcex7mtiFgYRD%2FoOkaR0d0xLQ%2F%2B%2ByzQWfFdi8v4rTTTlMYRdBakEAIYEJQVdLaQVUhOmrFvvfee5QeSAlch1zfy0EHHaR9zEZ9EXrpNiX9%2Buuv03LoSPqZZ55BRBxwwAG1HgwA2YIkueSSS%2BTeeeedmrLma21jSDbddFPSi3Jm8UUYKkpk1113xXXccMMN3Y5eeOEFKg4KE8vVmjTNviPplk86CASBIBAEgkAQCAJBIAgEgSAwxCLQeAzi9n4HKYwACf13vvOdSSeddMYZZ%2BTQwWQ755xzpptuulFGGWXEEUeUZZtYOywjkS4WXHDByT%2B55p133rvuuqvaxGMMP%2FzwiyyyCFNL5tRTT20fnCFWuV0eQws%2F%2FvGPCeYr65FHHmHOf%2Fvb355sssnmmGOOa665pux61txwww1He2%2BLmX7DGKQNVS2%2B%2F0zLRRddlJ7Bc91p8Morr9TUwgsvXL1Tm9Qed%2BuF6SdLRxLk956bpi1v7dgWx9gYgOnT%2FLMir732WtMfZ5xxjGHKKafccMMNu%2FJ%2BdYvHYJN2nW5I%2BvEee%2ByxR3WKjrCJP%2B2003LhwW%2FYdmf8sjGhrc0RRhhh7LHH1uPFF1%2BsvE4vvPDCH%2F3oR94CdQcNQF82QxkeEHQ1Zlpd5LcHASwWeNn%2BjcuyZjiDeCmNo2tVCI3INvbdd9%2Fuy7UqPNFIcXEXXXSRBYNhqFo%2BASvTIn%2F88cc9efTRR60BTzxHHbSWuwkiEEv36aef9tD3wqOkS0FgS3AX2Ay5fn0LFUbGrQZ9jOriKNw%2B99xzKBEdFTPTbcTCMAa0W7E3nzYSjeQKAkEgCASBIBAEgkAQCAJBIAgM%2BQgMzGPYF8YJsL4XWGABhjb1BcudKJ35Nv%2F884855pjM7dlnn90uMHqBlH300UdnfbP%2BmFey2P5PPPEEEPAYJB%2FaYYbLnXnmmaXRGrWV3OUxtCzr4IMPVoshycwfaaSRGIZ2k9EmPDVKMM8u076SSAC7zDPMMIM0%2B5SNxkT9%2Fve%2F73bUUUctmT0KxVCFqsAAsBNnmmkmudos%2B5Rxh8Egn0CJkPTL%2BulPf4pSQEHwKXDr%2Bq%2F%2F%2Bi8zMgDFMC233noruma88caDxqyzziqIQQv5WG%2B88Ri1TV8PWZpiZcw999z69VzEDC3jWOzgIx%2BkzZF1TLUyyyyzuP3mN7%2BJeClXlKOPPhoO3%2F3ud72FyrXt3iVJdKFNtqohYXuqx%2Fz2IIC1sAB4bTTWgkzCi0BYdcNsVi0iDYu%2FcRT1kAcK%2FVLpN5AGp5xyioVUi7wKWMCYh1JrKIblwF%2B5sB9kGz0xUnxfvE5k1frxS2%2FTXUv8R7RQ8WxVtwitojYpdKIV3kNbefsG0OUxaJkQL%2BgaTAh9iO4MzMPWThJBIAgEgSAQBIJAEAgCQSAIBIGhCAE8BvOZ8YuXYOngBFyE7pdddplZOGcEG0DZXia%2F8JWMaywE64%2Fd97Of%2FQxTwa9EI%2FZ8mfP0D82AIjZgjLPgtFN6DAqNV155xS3PETvLiAjCBrddHoOEnsaD%2BN%2FzX%2FziF1poAgYRA9wScsg6%2BeSTpZdYYgl70G6NgY2PvsADGJiwFRgPMRXRGox9dqvC2IlSgDgPBZVh5AYjl48A%2BoVgQzuu%2FfbbT2E6f5MSZwD9Qo%2FB7GW0ljPLLrvsgrThY0LUgVsg1FdSblWv3355jPfff5%2Fu4gc%2F%2BAETUsBG%2FI%2F2IamKYQMHy2FvXWvaJPZgojJpAct2pjkhxqjJqoJFmXDCCbvmc%2FVrat5RM9K7Q0oaAgC0vDFFzdUI64X%2BQiWVvqKhhMjitUHSU1Rbe04e471bb55YBiK0eE38QVoBASvQCLfffrsXZ%2BVIe8uoD0yC78iX5ZtqhaVRE31jWVQBzSov5oZB6gtbqB2LoVXnrIRpsdjaE4m%2BPEZxL744IzEpRKIPxzrvmVq3kaSDQBAIAkEgCASBIBAEgkAQCAJDLAKsZjwGy51i4Vvf%2BlY7sUQsQWNmVk888cTkCpwaqCNYf8Jait6gFmaD6c2%2BLmoCY4CdsN%2Ft1ma0GALYA%2FTIbrvtph22lS7EOWw4IBnIJFAfnhB4yK04n10eAyXi%2BVxzzUX%2FIPIA6kDXZbzTY8iiq68GZXEAMXjRC%2FmVcMeYfvrpK0aBAtpUuKJzuGUSsumoKWyOM%2FGwBygOLZsaeb%2BZom64imiTPMPcS7evIuUGekSWNCsVbngMvgNue65%2BeQxb7eQlHFLMBdVgqE7PNAazM1M8EilLhWJg3poLfYihatk2OiSFZ2TYGqRfm%2FgNsZ6uczsAAugjzMCg8BiYNxRET1xWSwuzwXmnaD1vB033aTyGj4XyAXWApsMvodfwFcQbTX2hOjkHVuGdd97pO2bLQ5RRLEfJLRBu%2Bv1iPIZPBveirkmhRCw%2FHKOBNcqxb%2B95EgSCQBAIAkEgCASBIBAEgkAQGGIRKB6DNIKfvv1Ze%2F0uggFWvDHLteMs5iSrmUyC%2Ba9YaQ8UKB7j5Zdfrtmx8jAVDHCF28X9QW7xGJiNhgPznwqCEefJp%2FEYGrR9XE3RJzhoshz8VSlphIMyq0GDWXzxxVETjcdAFzTzsHxVbGdXYZNiThaPQfnAVaSNtiUUgIYNdERNU%2FILTWAYeAwIaIRhiMfoUfVXF%2F3yGMYjMEipWRTDSOBeWo8SAoFSU8gCDhjZsNgStwzYbrGWHozHtdQ0h7pfghyiI9FFevQYxBIM%2FDYdnAMVBOLIcmoPJbwXhEA7jxgRIV4KHqOiW1RJpBkChDcKrsOCt8IrbItcndapqaWrwbOJCGrx11vudmQAon1qx4HFFc7CekM%2BECNV8JYqTI%2Fhk0TBdev21WOgxQwSnVK0mMKWtIl40rfrblNJB4EgEASCQBAIAkEgCASBIBAEhkAEyh7nPdETB6A7VKYTk3m%2B%2BeYj2GBEO96RQcTloctjsLaQGGQDLCaWFOueqB49wgtDU8VjNA8RTwgPyB4EK5D%2BNB6jxsC0t4Eu0maFp6i4l8VjNEN%2BUHgMhmE12OUxEA74GS4bRiv%2BgIsQxcV%2BxKLQY3yFPMbdd9%2BNEcK3GADbFqFBgME4hTx%2BxnNij355jHJ1Qa0wPLuDbCEfa175%2FUwESBEEGLEmWwwKIpnNN998%2B%2B2378ZNpZARlYIfU9ddqFgLNIhGqiO5DiLBJHS5LLXwD15NRQRVvpEhvhHMmw%2BkmDF8iHgmmLSeYVvMViMODf%2FWeAZ1xUjhA9WNsEGoo4WKKdoa6ctjqKJT4qXGY5iCgeHH4oLUcEsiCASBIBAEgkAQCAJBIAgEgaEFgcZjVGTCnmHT1fNuKONaFgkBnQNXC6EebC7jMRzwUZvL1AsiXopQQctRjfAcIVcoz5HiMYrTqFxbySiR0kgwz6W7fiXsOEYi5xHih2YGaoTzi9AQjLuKj%2FEleQzTISbhV4KiKU%2BBGlsF5zRBcT4%2Fk8d47LHHqlb3t%2FQYtrybIcw4pd8wzdNPP13Jcpkpp5uqyN8By9HlMWy%2BV3XGctFH3S68uO5t0oOCgLfgbB3CnsZFcCyywrFJDU9lqCa8LE4o3TbJaaxGS64EEpVlkXPQ4K9Rt4gCTRHSaNa7s0qxHO3z4RuCkROgg5tJpcUX7epANGLV4TqQGF56tyNZpfTgsVV9WaWoOa1V1Jd66Lcvj%2BELMmVcTevL4b8WJ6eYxpO06kkEgSAQBIJAEAgCQSAIBIEgEASGcASKx6BJYHMJvEAhX5c9XJEPcQss6MUWW0wkB5YXm12EB5ddbLfcIig0HNjKPsJjzDPPPDwjKBnk2qFGOKjb4mPQZohZwX5Xkj7fOR30FbUZ3S%2BPATf6DS2IBWEk7DukCh6Dicda%2FEp4DBvZZlH0Av8Xw8ZmsBPFL2UzshMH4DGMgZhEiA8BQk2%2Fxx7EY%2FCaccqJmcJTYAT2rLlAjNuOqTmBQl0nttC6IIVsrDvJBZgVHwPBAklnxIDdkRbIlqmmmkpgT30REkBDHFS5zeGlrTEuBniSRiW150kUArgFJ4BYQg7sIMDgvoGXcNuNtEkbI3gm9ULPO0VZED%2F0aGDwFcKkoCMc42uJWs%2F4AdxdqTu8fSoaS0tsGS%2Fulltu0TLywdJSEU9lGN1XYzX64hAjVj7Fji%2FFu3ZZYL5Tn6fGtYYG0ZdQom5pdXo0FX15jOL9qEQcZ2zKlgeeUC9O3un2nnQQCAJBIAgEgSAQBIJAEAgCQWCoQIB9RKzOxOYSIlJEuzh9MKn4VpSZz6x2aCkaQTFuIHaKmYQcTFR0ggktATONleQWX%2BEoEBwFO90tU0tJFIS0y3MKhwq4IWQiww1KrHJZDExpYTalnQ4pLWSoCJxuKUCc9KFl4SkqOCEz03MjKZDtobPrUQc2zY3ZsSlTTjlli0tQbEyL82nKHEYULncAbAAlidYwCSqaoATPAmNz7KmJ1%2BEUOsIScMBht5qR23KH4RuCnWBs1kjqlwsJakibYASphGZZwezZKsBEJfX3XBwMkVQlwOWqA1xwHXPMMYeHsurAF7DoyBMjhIaETltr1SZ7FjEiq86a6Y4n6YYAcQJJhgUg%2FIXwFKgqVACioAogsrhHYRt6nDUq2IUViwdoTUmgCIp58Jk4vxVrQd3RpE1WmqNR8SRyHYmCSaCLQEeo6CweJekiuq0hN3wvhiSL%2Fwv5hwtdJnwHnkRfPjG5NBj6woxptq8cCNnFR6kWUmvcrEX8UNeUNWj6hx56KIqsFUgiCASBIBAEgkAQCAJBIAgEgSAwtCDAOGJqORnBnjLjqHtV%2FEDGjkNIWUyMI0YQCb0qNTuBCh2%2BwCayvWuLmURB8AqBDZlLZBj2rzmSiDVh79iJCWxAHdHMMxIxJ6eeemqLUYA0IHovKQJuQckmnsdFHHzwwUy2JZdc0vMm0ScCcVym3xoJE17EDLvYDFLN0vbb9W7cgjYVbnvuxk%2F%2FULvkVR37Ie6EXszF8yIu8BhUH9iP5tjClrTVzm4twT8uQkesVP12oytok4LC3KEKT12bwl133dVwq07JP1Rn20LDeDTrFbQZ2fdnw2q8GaRmASU4KI%2FG0Xu1036NShRWIyxPn%2FY8iR4ECGDQCKJDoMtwPo3EUEyWVUpT1CNysIDxTmr1NOXWOhHhhKYIrWfBkN90y6Ay5AptgS3BQtSrsTJ9SpaoT6Nb2Dqk0KCo8cX5QOpypomKBqak8kQdPjd9yS1KpNuCtKGq0g2jUQWsN2fCmjLa0LcQEqMHt9wGgSAQBIJAEAgCQSAIBIEgMIwhQIHAIut3Uszz0ie03B6DvScX3dHj%2BN8qflpClZ42P63kl3n%2BxXrpO%2F3PNYaB0eg7a08%2BL3qfazz%2FOYUh6eqZb89abbloKLRDjwCm5Up4KZ%2F2gcjVbLcvhBhqqx2%2B021nUNID9zVwC4ZhyQ1cJrlBIAgEgSAQBIJAEAgCQSAIBIEgEASCwFCNwFfLHX21rQ3VwGbwQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASGJQScpOCchXZ92nkNX2zKmv1iFb9Mra%2B8U0c8FD7dIye%2BzAhTd0hAwMmqDgvudyRO9HB67wDnegjU6bjV7nqwQv72t7992ufjvNSeg1y7%2FbZvsD30RGu6aE%2B6Cf3K%2FbRgoTrqOc611VVFxe6wW1YSQSAIBIEgEASCQBAIAkEgCASBoQUB9tchhxyy8MILL7DAAvPNN98iiyyyyiqrHH300b%2F5zW%2B%2B5BQuv%2FzyBRdc8KabbvqS7QxK9WY%2FOstyoYUWOv300wel1iCWOeuss%2BBjLvPPP%2F8yyyyzxRZb3H%2F%2F%2FYNYtxVrI2xPkhhcCLz44otHHXXUzjvvvOuuux5%2F%2FPHvvPNOGwkz37rdZ599dthhh3333ffKK6%2Fsl3%2F45S9%2F6RjWN998U8U%2F%2F%2FnPl1xyyZ577rnTTjvtv%2F%2F%2Bd955Z5cAef3113WxyyeXTl966aXWV0s8%2BeSTKj700EOe%2BB5vueWWAw88cMcdd9xjjz2s5A8%2F%2FLCVRFBcddVVhid3v%2F32u%2B6665RvuY888shhhx1mXno79thjX3755ZZlFtdcc40qKu61117nn3%2F%2BH%2F%2F4x5abRBAIAkEgCASBIBAEgkAQCAJBYChCgB202mqrfeMb3xhnnHGmmGKKySefXNo1%2B%2Byzv%2Frqq19mIugR7Rx33HEDN8JM22abbZiBH3%2F88cAl%2B839xS9%2Bsc466zz66KOVe%2FHFF%2Bt02223%2FbTd6n4bGfjh7rvvrs3RRx8dPuOPP770yCOPzBgcxAFfccUVa6211hegPgYeVXK%2FGAKvvfbaZptttuyyy26%2F%2FfZbb7310ksvzer%2F4IMPtEaeccIJJ3iy4YYbemhdLbXUUmeeeWaXK1DsT3%2F6E5YDB2IBkEyceOKJim200UYIBJ%2FSSiut1Li7t956a6uttsJ96cia1Okmm2zyyiuvdEduoeIc1lxzTYyH9IUXXqi8BWMAG2%2B88ZJLLmml%2Ffa3v1UFPXLBBRfIXX%2F99eUqI41pKYrsrrvuqt4xFT4oszDN6ssgTzrpJINcd911VVRdsxgPmpPuSJIOAkEgCASBIBAEgkAQCAJBIAgMFQiw0RhEI444IgPqvffee%2Ffdd3ECxWww9Lo7y6bzuUQFNnzvu%2B8%2BRt%2FAODAhJ5poollnnbXfje%2BB68pllyEWbr755ippP%2F3BBx8su69b93ONvFtRmiGpC3YffGzB2xCfd955PTnooIMGhS2xt64we7On2dx%2B%2FQh4XyeffDI%2BgeiCFU9KQWyDDcA1Gcxjjz22%2FPLLUzv4Cigf8HjIh7XXXruH0COcoFm6%2FvrrVXn66ad9LBZhVdEC9kOtUjugHfR13nnn6egvf%2FkL2cZyyy1HYtFdNtb%2Fpptuevjhh3v4xhtvqL7ddtuRbRgAig8ZqIXqC9GBiJBrEcp9%2FvnnSYOQFVa79nEXci1%2B35HbM844A5VxyimnGOSvfvUrA7aMq%2BKvf%2F1rSg98C%2F3G1%2F8K0mMQCAJBIAgEgSAQBIJAEAgCQeBLIlA8BoGB%2FdzWFBNpggkmmHPOOZv4%2FPbbb7dlbBt3vfXW60oLbPWeffbZbCJGE6X9Oeecw7B6%2BOGHNUV7v%2FnmmzOsqlmGni1sNqOLwsFWtec2u1dccUVSBzoH9iBjn4H285%2F%2FnLF25JFHrrzyytq0S%2F773%2F%2BeoccGtKes%2Fccff1xdg9T%2BDDPMgCXgFEPRgTOR5eHVV19dnfo1cvbdEkssseqqqzIkW3AAz9mPLEQtG5Jh2LPWe6vYEsVjnHvuue2JwU833XTolxdeeKEe6tqu%2BgorrAAiW%2FM1a1v%2FrMuZZprJCFEf9utr398YLrroIrMzHQVaI619CVNmbHLw%2BWL0TreppBsCXpOlZQk1tynvCFPB4cJLQbtZon5beWyAhdFd8Jg9K9MrLm8UBAie4cYbb6wq6DLOI0iDZ599VlrJNdZYowkwVPER4bW6y4wXiZXAH0oLt912m9ZoitoAfJWW%2FWmnneaJknIvu%2ByyllufHpqCC4nPR3eNIfFE1zgZ80JRquh7bBVxcZ5wS2lPkggCQSAIBIEgEASCQBAIAkEgCAwtCDQe44477mhjtvPLh2K22Wb7wx%2F%2BwByzqzvqJ9c000zDJJ9wwgkFAVCYTUelMNxww3k41VRTjTbaaJVmN8ktHYL9bum3335bfAnFNDvJJJNIIEmYkFT3Y4wxhlquscYai8LBtvUcc8yhQF2LLbaYaAZMObeTTjrp1FNPLfGd73wHicHe%2FPa3v01J4onRqWX%2F%2BtJLL3WLMdCpkdv7RpIMP%2Fzw3%2F3ud0cZZRSJvffeu9wE0A5KInA8n3LKKQ1ArpmqpW73Kh6jJtKeIxmUr4c4h1KwYDaMTbOGyjJFquh3pJFG8kQvM888M2D1vttuu3nCkQdoEjPOOOMzzzzTWq4EVYwsA7N73pOV2y%2BMADINBYGsaC4VxAxUDRQUNAxeDdaoaZCwDRakN4uUaD3iIrSAX6piFhgSD5PQCtBgWK5FfaDpUFt333135VJrKMx1pduFyBiET7%2F73e%2BUIfjBgBlka01QC62hHTzxWZGL1KdXBbBwniAo8I3IQ7qRtnr1hU7h1aUvq06zjbpRFynXw8%2B0HpMIAkEgCASBIBAEgkAQCAJBIAgM4QgUj8HWZg1Ju1jltA2MaL78tnefeuopxAVbmynEXCLI%2F%2BY3v0newBJkvtFR4BYEBGAP3nrrrdNPPz3rHplg1mQVGiGqlyZ1kKY9UIwlyPHf7cEHH0yfwC6beOKJf%2FCDH2jto48%2BYk4Kp6kRXvy2uZmQCAfDo514%2F%2F338SoVdoOiwwAYbkIZoDLwDzQSBm94WjZ%2BnSIHtIwKMDwj5w6APMEnlEq%2FhvSjH%2F2oBq8ixgPZ0tcRpl8eg1GpI4yEjpiuRmhb39RUZzwav1vxE5iQYiMgSVi%2BVP3kK1xgDBg%2FQ4bBej3iiCO0Q0Ni8JpqFz6HGACSqrSHSXxJBLwCgiIvtCkirHbLcssttywmQfuWipWMQEBiUErwQ2mF5VotuAjfgrSvAymhDO%2BSNjCvDPNA7eOJ9Wnd4j0IJ4TWpAPxTXVZEf4j1okYL41%2FaO1I%2BDoQLKoXl1XipVJuVDEKDTxGsRzdigbsMzHOFjemm4s%2F1CbyxNy7z5MOAkEgCASBIBAEgkAQCAJBIAgMFQgwn1lSTGkWPZ8IBEV5aiAWHKNgCtgGuYIZstoUFoDCPi%2F6Ar%2FhoSxC%2BjZTFiIjnfuGJ10eg%2B%2BGknKLJWBJsRwrZAR2gqxCWFEtq4XH%2BK%2F%2F%2Bi%2FciFgB1axhEMDbpEZcoEFYiCQcxlCb2igLmgoGXRWmutdR8RjVKQKhsvxiMBS2%2F85sxGNgGyqAgCyN%2F%2FSnP8XDkI608pXol8dgPOqIk4IyLFNmL2PTbr5db3vx44033s9%2B9rOyfw899FAdlauLMSNecCks5cLT9PWLJsKB9PSb268cAQtPAAqanEZNFI9hSTQeA%2FXE%2B4Orkc%2BBR1JXKqNWnRVSDIC3ecwxx%2BAxumWKx%2BAhYvB8OtAgvId4XWlQAsflYZsX9yLeHxRH7UlL%2BBzKl8rKKZbj1FNPJaIoCqWKWfY4kwru0SoaFVLOc59nXxIME2gK2un6kbW6SQSBIBAEgkAQCAJBIAgEgSAQBIZ8BBqPwfTm9EF6wTz%2F8Y9%2FzHHD4OWyuz3hMcEJgrbBL92CJzfccAP3DSZ54xCUZyJRJvTlMbTGq0KtySabrCIAiHpR4DD%2F9cuWL4qjeAx8Qtef4t5777XvzFnjW9%2F61thjj60dwS7KRqOIQE20AAXFY2AeNM44VfKee%2B6pjvwyUaeddlqhKhikDEyUi4AelUv%2F4NjZQecxzFHjAjxWdSETDcnsaoQEGPPMM08daGJbH4%2FBtFSSFelYWLdKFp44HIiBtLunX23m9ytHADlGjzEwj4GMeu6559AFeAMkg6NJuHvUSEhoBMVt8SswBs5UHUCPQbyBcNMdEYUGeVohEDBgxUtYDEQRTndtpEqbr%2B%2BO%2BgL1of0WIKWCdfToMfAVPSFkfZicWQiWqJtag5XAjWAdfYCWvcH35OY2CASBIBAEgkAQCAJBIAgEgSAwVCDAYqrzSuwjM3xsWDPzhW4oYTyuQC6Dfe6552YxMaxcKAW2G9UBQb7AFF2H%2FU%2FjMUAheAVlwlxzzcWK16BEUSX98hiiaFYgUBVtbY855phoFs4XwnGQQOiUefiZPAYNv466QRr1RWcikgb1RfEYBP%2F1mj4vj1HRP5iZqjMtDQ9o%2FGXMEb2DbKEq6ctjQBi8iBdsBnOy4UkkIDBjjSS%2F%2Fz4EiF7wcrivEv%2FoyBshDbKS0WjohWIY2gAsD68JM1BPOEmRavAWqVuFReDkviEYRavCScSXYtV5%2B1bCBhts0OJdUBNZw%2FyMSvshwIvvCNfR6laCUAdVol9rCa3Xcrkv%2BfS6OgoD86SReErKNR4z6tKA1QLmsA4xodPoy5y0XpIIAkEgCASBIBAEgkAQCAJBIAgM4QgUj8GybvYR5QCFAA08Q4ylxibCBvCbMBEWVjP0JCgNZDGv2hyd%2B9CjxxAWwM6voBCsLQnkA4NdeE8VK7hE8Rj8SjAJ2ik9RuMxiPBFGFC4glooYEud5oGx1uUxmjHY1WMIxaliiUNqhMIFjDvuuCI3Gsnn5TGackNTDFJuOIiLMmDr7NfWkbAGk08%2BOaKmy2OU%2BB9%2FwnNHgJHyROjBswaZ338fAiQQKAtLGn1RvaDLOI%2Fg35j5uDvUQff4GOsKn1Bvli%2BJaBXCctbCq%2BrWG9aCl1Dd%2Bih4jmAnuBpZJJbujjvu2DgTFSmFdGfNe%2FVCo4hT0defqNgJvlFG24WiBtNVX%2Fi4fAhNoWF58xFDlfTlxCx4EV3wZnxVLMJus0kHgSAQBIJAEAgCQSAIBIEgEASGLgQaj1ECDINn5iy%2B%2BOIjjDBC6ecZcbwkxKUkqJDL%2FrIrzdwjd%2BdRggBhmLPcZQn1IE4muQVph9uKj8HUwkUIFoFSaPvaEm4ZXIrZreZXghZgWmrctjglQ%2BMxmH60CtqsKAHGxhDjD9L8ShiGmJPS6rMiu%2FExkCeUG7xFyi8Aq1CUCH8B%2FdqV1s6g6zFIL7RgeOJ1UIPU%2BKGnKTvsbvE5BgAWLYOl6THgAMDKVaCidmByyrxVnj1rSF3rWJsMT3oVjjCquM31lSAAVTwDDYO1ocFaMJiKOmEHV2YxI7hKruB1ezU8Qbg1KcxBCUHR9aLy0JE0FgP6roQTFUITT0Jx4f3iIug3nnjiiRo8dxWLmS%2BJwugLUg3hNaz5yvVrPA43sbaRLeK0uJVblzR2QmvYv5JzWNWaIi8pvQf6hRyI10yFtala5qui32uvvZazibq%2BstZs5bbekwgCQSAIBIEgEASCQBAIAkEgCAwVCLDE68zQikxYY5YWS%2FP73%2F8%2B7gJ1wPhip4tFyYxaeOGFpQV%2FENCSuVfRM5RkGyIfZDHhi8eoAKElY7CJrEHRQRlT7D5Hr7qtcxZKgIGpQDiIuum0CKeKOL3U0RI1GGdEapP4AbsicIcuXOzNUjto3624FkbCsiMmcVtKDzvsJfwQudTI6zhXhEztxeMllBSFoHqhBkG2OFi2ryC%2FdB0TTDABsgUIarkYjDbcq67ZCXBhRhoX6KMKzDrrrGVvolaQQg5gtVfOzlXLTJUhQWmjkqjpVIN%2BhVEl29Ajdqg9TOLLI4AdchQOroCPkiWKphBHpZyYLAzSGjE5BZdwSg6vEGmiI68GF6cwHqzYvDaMoqEUUxEp4UQSrIj1jytQhssV6sN71xdKxJrRXWmHhK6V1XOeiDVvbKRQ2tHvAZ9cFCCoMMvSp8qNRV9Cdhge9kxJPCHKAr1G%2BFGBSXk8uaougsVCcvnGiTF8DmbRmjXg%2BhbadJIIAkEgCASBIBAEgkAQCAJBIAgM%2BQgwjlj9s802m2NJ22gRFGylmWaayWGsHlLUK4MN4NDhJBH7yJw7qjA2g6xCyEqBQMXQ%2BOEPf0gdUTEtUQSzzDJLc0hh3HG1UAwhgI7AP7SDG2wW60sWCwufwJRjc7WoAkxFGgZUiYp%2BWZcLLLAAOX2pLNiVgjEKm4n9YOthYHRaYSuMUGviKKolfgUmgR3XaApHRRhGcSlKslXNixHalzdwtqY2TQ2JIYFOEQOhG2GAlEIZUzBCjAroFv3kwloUejgWmhPMRoUEYa5yLsCZwBP5wwRulEih6pcSQwwNlm8djdGeJ%2FHlEXj44YetZ6%2FAShOEk0yitYnQOPLII%2FEVyAf8hkNtytL3OpT3lpEGrXAlfALWm2AsqlhgaKu2NhQmc7JivUfVUQ10IJY9qYYBeI5%2F6Lb2yiuveG79CAKDpqhLmzvttJMBKInlI93BcohaY3g%2BsaK%2FDBLdoRaFxv%2FU%2B3%2F%2FSxkiAi3vJ133NCvL3Htome5gkg4CQSAIBIEgEASCQBAIAkEgCAyZCNg4Zgqxp3r8GogZPOy60vPWZ0yx2roTYQOyoZhXDCIt2G6mTKgjQvAPWmg2nVoKoBFY8d2H1ZoWcCO0GUw%2F5INEYzmqgN6rgFtmoJbLp8OtBPOTbwudfA27XDaqol%2BtGXnP1nPP8Fq%2FGmkVK1HdacSlVk9uuzUkIzQRT6rxBqm5mDjipTsp3IVR9cRAaK1VI6bTfZL0V4UAYL0Oa6b7RlrjuCN%2BH15oe0JEgfRoHiLteUvQ3qhi6bYnLaEv5Jju2rJ%2F6aWX8B6NQ2slDcbH2O%2FVHWff4Vm0PlW999T1xCJ09Txvt31XextMEkEgCASBIBAEgkAQCAJBIAgEgWEPAewBfQJnCjE8KeRtJYs4wTuj%2FCmGvflmRv%2BZCOD6EAIYp6%2FK6kdz4SLQC%2F%2BZeGbWQSAIBIEgEASCQBAIAkEgCASBwYWALWCe%2B8I4VEQIv6JDPPjgg4NrPOk3CASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCAyPgoATxOUUVEHIwwRwGxiq5QSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAKfFwHnOTr%2F8dBDDz3k%2F38ddthhzz%2F%2F%2FOdtrco7CPWyyy476aST%2Bj2GchDbdFDpMcccY1AHH3zwcccdd8EFFwjEMYh1UywIDICAA1Vvv%2F32c88916JyRnDPWbqWvS%2FizDPPvPzyy5966ikH8vZt6plnnrn00kvfffddWQrU7dlnn33ttddat93yYuHq4rxPrjvuuMPRvd3cSr%2F99tv6coRx3TqY%2BLrrrtPaxRdf%2FPDDD%2Fcca%2BIruOKKK8466yyDfOWVV7qtORP5lltuMS%2BRau66666er%2B%2Fll19u83r22WcdwtKtm3QQCAJBIAgEgSAQBIJAEAgCQWBoQQDnsOqqq7YDR1pi5JFHvvrqq7%2FYLH7729%2FOPPPMo446ajPNvkA7zj0Zc8wx23gkxh133J122snxl4PS2muvvYZIufvuu2OvDQpc%2FzllPvroowMOOGCppZay7FdeeWWJ448%2F%2FuOPP4YARuKaa65ZZZVVlltuuTXWWGPZZZddccUVMQw9x63iPfbdd9%2FNNttMU1ZXVVl%2B%2BeVXX311rW288cZPPvlk4fnHP%2F4REbf00kvrSLNLLrmkir6OHrTPOeccuTgTz5Eea6%2B99jLLLKO1FVZYwRhOO%2B20v%2F3tb1UFE2JghidXmTXXXPOhhx6qrJdeemnrrbc2gNVWW01rEqZZfRnkTTfdpErNy6%2FxXH%2F99fk0el5EboNAEAgCQSAIBIEgEASCQBAYKhDAY7CbRhhhBBbWDTfcYCO4LmaObeIvNgV2n63kE044odlfX6AdO9FOdJ1tttkYkgbDHpx11lmxGezEng30fhu34a7wDjvs0G9uHv5nIsByp6PAABx%2B%2BOH0CWQJlj27Hj8AEE8wAwgKYgaiCNqGDTbYYJ111nnxxRe7cBFs4ARoOTxUTJn111%2F%2F3nvvfeONNy655BLUx957711L1LpFROy%2F%2F%2F4IPbUcUqxrA%2BgSCMQh22%2B%2F%2Fe677%2F7Pf%2F4T7bDFFlv4Hn2DWkblbbPNNhrUuL4%2B%2FPDDrbbaynisbbn4E5zGzjvvTHfxj3%2F8Y7%2F99lMSJYLBMy9j0JfPUMVXX3113XXX3WijjaqiX2N2W3qS7tSSDgJBIAgEgSAQBIJAEAgCQSAIDPkI4DHWWmst6ouylboD5nJy2223UWW8%2F%2F779ZzNRTnPOiOKeOKJJ8raYk9RPlx00UVk7VWMEv6RRx6hhWDN4TTuu%2B%2B%2BX%2F7yl7%2F61a8YWUgJNqNGfve731VhBdhrGmGmdXvHY4w11lh2lpvRx8pbeOGFUS509a0kg%2B4Xv%2FjFiSeeeOGFF0p7zqa788472XF4DFvhBtnGz%2BJze%2FLJJ6vy1ltvtUa6Cfam4fWr%2F%2B8WS3poRICXx6677rreeus17w8uIYQZaA3rEMNAMmF5t6lZsVgOYob2xGrkcoJqKHID9YepQF9UAV8TPgEZUh4fHKM03lRJGAYECD8plEVr0Dq3yG%2B88UZPfDIGcMYZZ7RcXWtfj574Qqk%2BeJRUrpHwt9I%2B1uL1118nscDJNCeUp59%2BWrMoFCITX7EhcfVqzZ5%2B%2BulID99de5JEEAgCQSAIBIEgEASCQBAIAkFgaEGg8RgE7T1jxmPsueee2IBNN91UMblHHXWUWxYTM58uQnqqqabyW9fss89O3K7Y73%2F%2F%2B7nnnnuCCSaw4YvKmGeeeRQYffTR%2Fc4yyyx07xK8%2BKs7JMN3v%2FvdSSaZxK5xdwDFY7C28BLt%2Ba233jr88MPb%2FjY2DxEaU045ZfXud9pppyWzp%2Fafbrrp2sMRRxyxLFNW5GKLLdaeTz311LUL3xqXYAayW5XpUiXdAkkP1QhYbJtssgkNQzmSmAv2jACDbgfHhXyzLIsNq2kSXVgPBAxt1tY29w2MQXERWAVL1FptBbSAbSh3D5IkH0tzM0FuWPw4k%2FqaVLGMjz32WN%2FXO%2B%2B84xbjgTlBrbTWLHgDQLt5gmaRRkq0XOyfvjwxLzExEIYtmkeJRqiY8DNYR0QHErJVPOWUUwz70UcfbU%2BSCAJBIAgEgSAQBIJAEAgCQSAIDC0IFI%2FB2D%2F11FOZUfZ2XTZzS3NONbHQQgvJvfnmm6nuJ598csRFxdvcfPPN2fvTTz89W8%2BuMRG7WzvdqABhAeaff36F33vvPTwGEYWs73%2F%2F%2B1tuuaXohTavRxllFJvFZc1p2S37jsHVBa1fHsM2%2Bve%2B972f%2FvSniBS8xKSTTjr22GPbv37ssceYlsbJrCMLIf9gmepU16T1ahmGLhQwBtvQRx99tGgbiJeeYAXGwK5cYoklmu3ZHVLSQzsCRDhIMOxcI8escLyEVYGg6JmdtbHddtvh64pkqNz7778fNWGBuUUaEAK5rdAWVcDyxjZQBLnFZtBLkAYRI1lRBx10kGXfZc98ZRtuuCE5U%2FFy1UL75ZZ14IEHknAUs%2BHbETEDWdEK%2BHYs%2BL5xbEg1ECyEHAq0wj43FI2vmNyIc8ouu%2BwyiKFmWgtJBIEgEASCQBAIAkEgCASBIBAEhgQEWDdE8kx%2BNv5oo40mOGddDKgaHsNtookmoqMgZuDTUaJ3FpxNZLdNco89EL9isskmw3LY2u7yGAsssMDEE0%2FMlKsG6SVEAcVylPaePaX3psxvmPTLY%2BhFxIxpppnmgw8%2B0JGoAldeeWVVsd384x%2F%2FeIYZZiiHESEOhhtuODvvlct4xJagWVr7%2FAtMWQyE9qQl2qZ2e5LEsIEA7osJj1hoPAb6QgwKUSmao1PNVAFeIdg5C6x5NmEbjjzySCsfQacY1ksZPEZXQdFVTSjAiYmrCEoBuSFBodGlLLiloNfap9EF2SIkw1ALwViMn0SPM4jVq0Bf7dADDzygWdPsukcJd4N7NFohQH3yjz%2F%2BeLe7pINAEAgCQSAIBIEgEASCQBAIAkMLAo3HYNrgLnj314UHaFM44ogjUA0ue9mEDZ4z0FhzeIBuVI0dd9xRGVvAPTwGToPniCiI1SCrkIWFZKCEp9yYY445uIH0jSnaL4%2FBC%2BAnP%2FkJEUiFvDB4ZqM9bqKReeedVzwNHdlx1hETTxciKFan5RGDP7G7bYvcb0UNZWZWgfz%2BJyDAvULEy8%2FkMSxvKiMkBtaiFnyBQ9iDChNcopguxUSoGIDHwBX4ZCg6eIvwN0EjYFFaVApyi3322adcWvqC7zvCWmDq8H6V2zeoRb88BuGHQZJ59JzKiugTHMZIyFHoOohDuixH3wHkSRAIAkEgCASBIBAEgkAQCAJBYMhEABUgzudII43UNz5GG7CQnhNOOKFYoC1UYOMxRCZsxRAgeAyqdcEHunoMaQoKW%2BGtJG087YdDExhiyBAb4hpsuZXol8dgmvEl%2BdnPfoYqYQZWjA5PkCFzzjnnOOOMw%2BukXx6D14mxITpoS8zFRTrCR6YbU7FnALkd9hBARFh1e%2ByxR2MnyDAcAsK1pDlZ4NnIJHAISIYetyPxKOgcfA6FDDYDD7bSSiu1J54LeEsj4WsSQEOYTYxZ81ESGhSPgZqwepWk4tBaHSnSAzWHFGeR8HZBvLQsTAj%2BofudUkPxK8FOtDK%2BMgQjpq4CdLTn3YTPk%2FOUQXa5ym6BpINAEAgCQSAIBIEgEASCQBAIAkMyAsVj4Ci6wQy7A2aOFV1A3oCRqFNFGo%2FR1WNsu%2B22yog62FeP0cNj2AheZJFFxh9%2FfA2OOeaYXUOsdd0vj8HrHx2B91BMLfSL3fAKX2Cciy66qOid%2FfIYJSlhmSrM8cQlwTuAIKT1mMQwjwBtAxcSUS%2Fae68IFbvtthtarKbPBQkPQCZRziMNE%2BY%2FJcNOO%2B3UYoTKItvAJHS5hdNOOw0HgtnAgaAUtOOQlGpE6Ji99tqLIKTUR%2FQVaI0Kjdt6kRBtQxmCip4sLAr%2FFBxLK0yMpK9GR1jSBEjkST3fMiKFd4w13yoK34HH6JdCaWWSCAJBIAgEgSAQBIJAEAgCQSAIDJkINB6jWUM942QriZghOIZtaxwCTb5taHvWbDRHhzQKgmn2gx%2F8oPgKNAWCosX5lO7hMXQh0qbWXD%2F60Y96TlytARSPYbO7xRMQqcPpJDQVZTYKNaC6dqq8rfaZZprpO9%2F5TvEYDnpAqnB1qVyBGd0yOZtRycxEudSpE1Wmfs2ubdZ3nyc9DCCASeA8RQXRInMi4ogcnN%2FhvZsgTxDcAiFE99SSmrjgt%2FgNn0MXB3oki4oqo2JooEcsOZIP9Ag%2FDqzCRhttJF1VLDksiuNRJMg%2FfFBkQobUbZAAg%2FtJv%2FErjM3ngJEr8ZJVesABB7QzXgX68G2i9VrImtasMYuJQSjSnpRopG%2FJViCJIBAEgkAQCAJBIAgEgSAQBILAEItA8RgIgaWXXtresa3quogrMAn8OASjIJywpctQcsAHGgFFYDp1XonQFvgEu9IiVGhEC%2BxB1tx8883HccOONmtLGr3Q9StR3akoCqhiK7xfcPTuSJEpppiC6adZWgt0Ct0ISXyZnPbNOacowNP%2FsMMOE%2FtCa%2FQYdZyK6gJ7CqYhgoEzKO2hE%2BorsPjiiwufaNPcoSfjjTdeC1ZQY2AhMgZNeQBZfr%2BjzcOhBQHMAw0DSY9lfOONNzqGFa1Rrh84BLeECoceeqgsHJ2LCoK8gcQI10Em0bOMuaVQaGhQ3AmUiHXoO7LkLCTMxoUXXuiWG4u%2BLFdyILc0RbJwcUiJ%2BpQadPgNkWmV8VE4LLgG4NdgdGQNc93Culi9PLOOP%2F54Q9UjLk5UUqE8BPTwsVx11VWtLicv%2FJ7QNNQd1j%2FPFGSgrxUh4%2FttBEsbQBJBIAgEgSAQBIJAEAgCQSAIBIEhHwE8hu1jhIA4FfQV7XLLXCK6QB3YAq7tZvaRGBQOM3VciD1rtMAEE0ygioSzS9hlpayw14x2EHKzeAzpGWecsevpDxbGF2MKNVEnVPYF6pFHHsFR4CK0r3ElF1xwQaL6IjGUt5FtP9rI9e5CPjhUBfVRtiHDk8C%2BxsacVN5gbHNzRanyomoQ9vfshru1Ha8vlmPfIeXJMICAV%2BxwHEY9EsDldZMllMIBxeG5iBaYDcKGuvAGSA9iDBTBwQcf3LNgAIKRQ%2FopxukDoXHIIYc0fRHmwSEj1jnNhr4sSOe00mmQGOEfMAmtZAGLcBMXVO%2FG8D%2F9r%2Byzau4nPiK0SesL3VFOVc7x8T3qSEW9tLrGU9%2FXo48%2BavxVsWbUonYMA%2B80UwgCQSAIBIEgEASCQBAIAkHgPwoBFhxHDGaOwx97LlvPnlCzt4AASA8GHYaB%2FUUej%2Btg7wsFcPnllzvqsbljKPbcc88xlDzRfqWbQ0fB63buuecWsZPMo1%2FAmXu6NjCXLmwra7ZvSaafcydtdmtHBABjw7FUMS049MGWdLMW2Y%2FGrzw3k0%2FbjFZdmXYuZ98e82QYQMByooggTugGwaAjEoqz74U9wOAx%2Fy2zfufOkeqxxx7DGFiNPUQHAlBf5BPUGnQRxcJpEOGARmukXDXrQxMTo%2B8AfKHt85HwORiJMKHti8MK%2Blr7VvSkfV%2BYPd40Bum3fdH9TicPg0AQCAJBIAgEgSAQBIJAEAgCwx4CrDNSDTxGhar4XBNEEdDP26dW3eb156qbwkFgsCCAi8CqNTLhS46Bk4jW2oGqX7K1VA8CQSAIBIEgEASCQBAIAkEgCASBz0SAyoI3CgeNT%2FMKGaAFWgg%2BIOqKaNE3muIAFZMVBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBL4AAPbx4m0sssUQ792HQGyGw507Cc58Uf9BrpWQQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEAS%2BJAIV%2F%2FNzNaJKTwyBz1U9hYNAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAn0REGTGgSOOGvkCoqa%2BreVJEAgCQSAIBIEgEASCQBAIAkHgPwQBxpQDHO%2B%2B%2B25niPSdch1P6cTGvllD1BMnZjpx9ZprrrnrrrsSO3SIejVD2mB%2B85vfONu0nUY6GIfnGNY99tjjnHPOcRzwYBxGug4CQSAIBIEgEASCQBAIAkEgCAxdCPzrX%2F9ae%2B21RxtttNtvv71n5LaJ11lnneGGG%2B7SSy%2FtyRqibm%2B55ZYf%2FehHDkCp61vf%2BtZhhx1WR2SyEBmtiBob3585ZpTOyy%2B%2FLHLpxx9%2F%2FJmFU2AoReDiiy9eYYUVbrrppsE%2B%2FhdeeMFIDjroIN%2FgYB9MBhAEgkAQCAJBIAgEgSAQBIJAEBhaEGBDrbXWWiONNFJfHsMUTjvttEUWWeTJJ58cYqeDdphkkknGGGOM7bbb7oILLjjiiCPqUNf999%2FfmP%2F2t78xFaeZZhrCks%2BcAoW%2FU1SmnXZaBuZnFk6BoRSBiy66aJlllrnxxhsH%2B%2FhffPHFVVdd9dBDDw2PMdjfRQYQBIJAEAgCQSAIBIEgEASCwFCEQPEYI488cl8egx7jT3%2F607vvvvuPf%2FyjzYjO4dlnn3388cfff%2F%2F99rASmsIAOE31vffea1l%2F%2FvOf33nnHQeUSOAcnn766b5qhw8%2B%2BECDmu2b9bvf%2FQ6LQlDxab4tBx54IBnG0Ucf3XpkHk444YSTTTaZBl955ZX%2F%2Bq%2F%2FGnvsse%2B%2F%2F36uBC0QAScazbq0XxV1%2Fdprr%2F30pz8dd9xxb731VoVpOfggmEs7XYWoAxqqtHbUKjQ8bwPoSXz00Uf9%2Buz0FMvt14MAHmPZZZft8hjIrtdff50Up%2B9rqlUhq%2BuH4qGSTeHjDGK5VkXXPcStWm%2B99VZfjuLDDz%2BkEbLmn3%2F%2B%2BfAYX89LTy9BIAgEgSAQBIJAEAgCQSAIDEsIDMBjmCaRA7XDHXfcUVOWmH322UccccThhx9%2B4oknJn5o1hyjbOmll6brkMWzQxZ5g1qnn376%2BOOPT%2FLB9WOEEUZAmMw777xN8IAhQUGgHdTSrMZxCNUX85A%2Fy%2Fe%2B9z21ZM0000z33ntvZXV%2Fd9hhBzzGWWed1R6iHfbaa6%2FVV1992223HXXUUbXMNWaUUUbhI8PSNN9TTjll8skn16xr6qmnVhcvQctB1KGkyyDXWGMN%2FIONe8PGb1Tj9913HzS23nprY%2FNELI7ZZput0BhrrLH2228%2FFm4bRiXQIHPPPffMM8%2F89ttv92TldrAg0MNjPPHEExb5yp9cHKx%2B8Ytf1JK2JO65554tt9yysjbYYIMrr7yylvRll11medx22201fgzb3nvvvdVWWxUNYkkrue6666q4yiqrHHDAAXi8Kmllat%2B3IMvvjjvuuNJKK%2F385z%2Fvy3UMFmTSaRAIAkEgCASBIBAEgkAQCAJBYKhAYGAeY5NNNkEF3Hzzzeby6KOPfvvb3x5zzDHZfYcccsiMM87I5D%2FuuONk8dpAQSAxtthiC3YZzw5Z5513nqyTTjqpwlbMN998ai200EJuNatfpiK6A8%2Fwwx%2F%2BkLoe7TD66KMjFqgvVLzqqqvc4jFYgttvv71h8Ph49dVXZXWv66%2B%2FXtZEE0105plndnUgyvzyl7%2FcaKONZCEx0BpnnHEG%2FuH88883gOqR%2BTnBBBN885vffOCBBx566KHNNtsMOWMWrEv0Cx5j%2Fvnn55NC1FE9ioYqkMiGG27o1sPvfve7qI%2BddtrJvGaYYQbNHn%2F88U2qUVUMCW%2BDDAmPUYAM9t8uj0FFY8VaGxbPJZdcgosg1bj22msNkpoC8YWOsIxVsZA4KBV3ceGFFyrWImwQGu28886IDjyGty%2F%2BxvLLL4%2BjuOKKK4488sjlllsOwaWMNlWRpaTYnqeeeioqQ254jMG%2BJDKAIBAEgkAQCAJBIAgEgSAQBIYuBAbmMTbddFNkQmkk7E3jHOgWaoI0FUiAWWedlY3GFkNcoCwqi2x%2ByimnnGeeeWxtEz%2FIomEo5xR2PWZglllmYfQx7aWxAU3wwLjDBuA0%2BLPgECaddFJNVZtUE7LwHj3w2iJHI6Ay5GIhCPUZpM0RxgAWW2yx8cYbr%2BJjmCxWZMkll6QeqXZOPvlkFbXgVq7CGim5CHHFAgssgEjp8hiICyatwvbcVUTpVDtOS%2BG9wtStLft6WL%2BcDlAi3SdJD0YEujwGgQ1iAcFV47HYCC0q8CYSjBoHKVFZDz%2F8MHbL8sNUaAH%2FoEBlWf%2B77LILdsuLJr1Yf%2F310W4O0KlcC8yqsDyqGMnHc889V1n0RdpMfIxCI79BIAgEgSAQBIJAEAgCQSAIBIFBRGBQeAzb0MJizDHHHIQWTSSvIsbg7LPPRk0svPDCDHxsALuPlYdz4AaChcAeoCZ4Xpx77rk1HiJ8%2BoTppptOfIA777wT%2F7D55pu3odofP%2BGEE0TqoP3gqYEkUdH2t2ZZl9pZb731nCrSyleCacnFY80115xqqqlwCy4eLkU%2B6G7RRRfFYzjjUmElafvFLqAwQbAcddRR7ErlHX8pF%2FuhMB5DyAu3A%2FMYLFORNAhUnI1iImxYzIxLFzWq%2FA6ZCHR5DKFUiCI23nhj2gmRW5APCDSXl%2FirX%2F0KyWBxOswX8%2BBh5ZqUBdkvj2FdUexgLeh8cBTcUvAkJ554osJYLwIPS1Q4Fx9OIWOJJj7GkLlIMqogEASCQBAIAkEgCASBIBAEhmQEBoXHQCyw0MWUQEH84Q9%2F6JmOJz%2F%2B8Y8%2F4Q%2F%2Bvx%2FKDTa%2BDW4nnuAfWvwKxAIHE3wIHkP4CxUQFD0NunWUqiAV%2F19zn6S0wwWgaS361kKDkItoX3HGI%2B4FF9HlMVRBaGBdFNAaJ5HqZc8995Q16DwGO1eoDRyLOX4ytG%2FgbQ4%2F%2FPA67LXvwPJkyEGgy2N4iXw9SClIL5ZaaqltttkGoVELDN9VYS48R4sJw3LDDTcUBTEAj4H0sPCceuPXxf0EreFWWAyRbBEjNEuN6cp5JUPOqshIgkAQCAJBIAgEgSAQBIJAEBiKEBhEHsNm9Pe%2F%2F31BMLouEnwoGH22oQknRJkgob%2F66qvFtXAJMsBCRHF4%2BGk8hjMjZBWHUIiJX6FBJiTmBBPCtYQtWQ1qmZEokEXbzlZF%2B2QVuqjYjNWIg1TwKoKLklU4iqLLY6jLO4afy6677ioKh5Mj6EkQEZ%2BmxzCAblAOO%2BzlV1JxPnWnBWwMlYiwHtqx295XLlKjyu8QgkCXx6ghWcAC2Ir0wosK86BAe4noO9KdY445RlALvIRFqIoC0hU0xi1qznJChmhHtBYtHHzwwZQYVouLMEOa5xS9B%2FWFptriwfJFjzGErIoMIwgEgSAQBIJAEAgCQSAIBIGhCIHGY%2FR7GkjFx%2BBXYufatvI444zDn6Jmhzqwv7zEEkvwKyGYb2E0KhfDwL6Tpqvvy2PwK8E2kOuLsLHgggs2FsKZrSJwio5IhE%2F%2BgTbpngDSN1QmUgWFwjmlQoNW12gQ0UTxKnbAtYzHwGmUO4zy2hS7o%2BIuKk%2B%2F0cNjOJGkgnKou%2FjiixuGffNqWZwQEg5uCEzR%2Ffff32El%2FF8qi5XqpFdnvLaW63l%2BhzQEisdAshkYDkpAToutBolt4Ge02267Wb1CcYp6UWFV5Dqid7XVVsO5%2BRBoNpAV1113XdXiciIgRsXHsIB9FHiMyqpfZIiEz0QgWYFiuCDVc%2BvfgSaJj9HFKukgEASCQBAIAkEgCASBIBAEgsBnIoDHYLsRPzDfLr%2F8cpadS%2BALO8s0GCwvBAUvD%2B2Q01MykCg42oNrvyAAGADhBVh2JPeO%2BeAtohbTj9BCJE9qfCxEv3oMPAYfEIQDPxFt7r777hoUSrE8PtiJ2iTy174zI9ARDEyxLBAFWJHujEj0PVfMgSCGzTBELDg8AnMimgdrVBe2zhEdrFfshCc8CBxfwtLUrACPFVKj9BigYIQq7DnugtqkDnXVIJrC%2Fvucc86pL9yOMdQ5LJwO9PjWW28ZGKBU74nzCQGMh%2FaL1ekOPunBgkDxGJao3i1Xb1DoV6%2BbtAY1gaxDLHiJQrJgsQSwtfZwbmqJCGqxWXJEQYqVpEdFh9S4dQQPlszrxnVU7FAfwptvvmmdoDjwGzQeDsGx%2FBw0rNZTTz217777Wpw5r2SwLIN0GgSCQBAIAkEgCASBIBAEgsDQiwDj3UYz87zn4ijhkFPWGYqjjmbgo%2BGQSreYB6yF8riC2stGOzDHaBU8rIgTDu%2FgskG3wMrzkAVXEDHn55prLuxB7XQ7GYSqQQHMg2Y17vTVClCA6GBjyvKwuiOlwCf0QK1BPIMySo4wwggakeACI%2FJnlRS2whPXIossQmJhLkQgbqsKqYY0PQnGQ%2Fk6MKUKa5n56dhWt1puVRxIoSRKhK%2BBh3qsKZtU67S69gtDLUCm4o6250kMLgQocIiIMBgG4BUT%2F%2BAWVl55ZZSaBLEN6YUspAQvIVwcyYQPRJbFX0IdkhtBXSxOtJULa4GOsCowISoq4xQbFfmMqKuiSLAlycCHkH84Lkd3qA%2B5imnKNzi40Ei%2FQSAIBIEgEASCQBAIAkEgCASBoQ4BFASNPTlB93JoyHnnnVdxAwSgqMM%2BTI3BxcfE%2FjXq4Pzzz1egO98HH3zQkSU09oIAlDEo94knntBy4x%2FsdFN9aJwav%2BoyGDVld3u%2F%2FfYTFsN4WpvMzMsuu4xag8JfUM0yBltuS9QWufgGurYbrjUa%2FpZri1xd9iMDtgxGR1HQY%2By0005K2hYXiZQCBMWhikCdZCeyjLCUFQqjaLRsY93cyUt4l1SIA1Xs4O%2Bzzz5kG3L7ZSo0IqwHrUiRM21USQwuBFBnSAwqixqA92Ldet0oLG%2BzHccj18oR2oKDiRXiGxGZto0Zi4UQ85xsA9ll3VoVLcorzxESJtydhafx7qu32omXVLQqOENplrqpheNo7ScRBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIPAFEHA0qhNXHT3Z73mRTiN1QOqf%2F%2FznL9ByqgSBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQWAQEXj%2F%2Ffcvu%2ByyZ599duDy77zzzvzzz7%2FUUkv1S1bceOONM8444%2BGHHz5wI8kNAoMdgZdffhkd9%2Fvf%2F%2F6FF14YlMH86U9%2F%2BuMf%2F9gt6QnirvtkiE3%2F5je%2F%2BT%2F%2F5%2F8MCcP761%2F%2Fev3117%2F99tsY0X%2FfeN57771GtP7rX%2F%2Fyr1a7bZ16fSjZdvt5E179u%2B%2B%2B22qZl6vdDkrio48%2B6juqVtGwf%2F3rX%2FdbwKuU1Up%2BsQTCeYCK%2F%2Ff%2F%2Fl%2Bz8ztAmb5ZEIB83%2BeeWIH9Pv%2F3PYSS996W%2Fd%2F%2B9rd%2B%2F5%2FVBvDPf%2F7zscceu%2Fvuu%2B%2F5n0v66aefbq8A5nJuuOGGBx98sN%2Fp1Ct76qmnXnvttb7Q%2BdfmxRdffPLJJy281qnhKXzHHXf4X%2Bfjjz%2Fes4SsMQO45ZZbbr311ueff%2F6%2F%2F%2Fu%2FW8WvIWEwhvTLX%2F7yzjvvfOONN%2FrOqO8YegYMkG4ZS%2B6BBx4A4H333ffBBx90s75MGob%2BGf8yLfwn1LX%2BuwtvmJyy%2F6f4n4t%2Fgnw1Fm379k1Wludf8xc0TIKcSQWBIBAEhkwEjj322G984xsrrbTSP%2F7xjwFG%2BNZbb00xxRQzzDBDv%2F9PvPTSSzWyyy67DNBCsoLAYEcAX7f66qvvvPPOO%2B644yqrrMJSGHhI%2Fuq2qh955JFW7M0339xjjz38tiefKzEoRsGgN3jllVdeffXVDKV%2Bm%2FX323777dfMsUFv9t9RkhWDBfXvDCPx39G%2BNhl9Xk3jSe66665VV10Vudr9Jwtt6%2B3ff%2F%2F9X2wMoD7ggAOOO%2B64qs4EPv744zFjg9ia12SQhx56aBl6%2BDEWd09dY1txxRWPPvroHvZMX6effvree%2B%2FdU%2F5z3T7xxBMGXFUY7y%2B99FJ35Vgq55133hegoy%2B%2F%2FHJvFiw9BqwP56ijjvpcI%2FyShaF0yimnQKktA7eM6AGaRWptu%2B22Fme7FllkkWoBIL4v%2F2Isu%2ByyK6ywwjLLLLPRRhvBrdsa02nfffeVu9hii5155pndLCYkZDbeeOMlllhinXXWQVxUrv%2FVnnPOOSuvvPJyyy23%2FPLLa3y33XZD8VVufbb6klUFYDgwFdPt9Eum%2FYu3zz77VO8Gttpqq1111VVdw7Bv%2B4gvCHQHbPX%2B5S9%2FqZLWwCabbCK3ANxggw2%2Bqn8Bbrvttosvvrh6QTGhm7qLue84PRl4Iv1WGaof%2Bgqs%2F0cffdQsPvzwQx%2BCD%2BQzZzSE%2FC%2FjM8fZCqAZl1xyyU033dTnaaWhMloWCtH%2F6P1%2FYWD%2BtpVPIggEgSAQBIYiBPyFT2WBgphooolsGA0wcn9lTT311D%2F84Q%2B7RkEr76%2B1kUYayR9j7UkSQWAIRMDfuttvv%2F0222yDvmNH%2BEt44EGeffbZiy66aLNc%2FBnMWGPvXHTRRc1QGriFnlx%2FePub%2F6GHHhqYNuyp1e8t82GttdZiIiEr%2BrZmqOxlFsRn%2Fm3fb%2BNf%2BUP7zv7ORCwMogzm8w6A3bT22mujp9of4XpkhW299dbdzbhzzz3XH7rw%2F7ztV3k8GMC33HLLkmRcd911jNzXX399EFvztzQQVKkV5Z%2FNYlqYGK0FhM8aa6yx00479Vgc6lqx66677nPPPdcKf66Ef7q32morq0IthAxkNIjWaIi98sor%2FuavAp%2BrZTw27sU67NqJ3gig9t9%2F%2F8%2FV1Jcs7L1gDCD8zDPPaIrhxhgf%2BHWjkjbbbLP111%2FfxH%2F%2ByXXQQQeZkQ%2FHawLR5ptvjvAkUTjjjDPQNV4N9GqcHq633nrmvtdee1144YU6bZ9bMSpLL720b%2FCEE0646aabWi12FoLCWmXRszFZ%2Fdakf1i8CGvVP01qHXHEERgABpru5F577bWt5S8J0QDVvb5TTz1Vd7gsAyPJYBj6rAb4Zk3T%2BA34yCOPVMWAzQvm9sd1hJOxBqznK664Aodmwfskt9hiiy%2BvyvDWvOgLLrhAL%2F448V3oBTs08OyAbLRe0xf713uAxofMrNtvv90Chrzh%2BX%2BZVYeya3xav2P2r9kOO%2ByAvutXetRvlcH%2BkMLNvzMYm2uuucbgX3311SbU8QlbGxtuuGF7MthHmwEEgSAQBILAV4WA%2F82NM844o48%2B%2BnDDDecvuL7N%2Brv95ptv9ncg0WwPj2GzySaIXIy3PydGHHHE4jFsbyns70l%2FLdh8bH%2Bi2wm1Q8pyJFXt6cj%2FOj13%2BR9QN8vfdZrShb%2F3WjvdAtIff%2Fyx%2F0337F32lMltEGgI%2BHum1pJV1zW7WoFu4rTTTmO3Nh5Dlj%2FFF198cXv6Xeu4W2WANMuO8bjwwgv7y%2BrLr1h%2FvB1zzDFrrrlm25Tsdu3zZIAwGb4G86fb7wBp%2FzJ8YRtcs8xw%2F0SA3T87ff8Ul8uQ8V6aVa4K2YyrOySmqG3ugQ3bbvmeNB6M3cpMqyVx8MEH63TQeQzWE3mPrUMGl3GylxmAWugxGYjf%2Bk7Q4tluu%2B2sxrIQewbm1ou%2B9957WYjq9is4YY2yuBnICuuCgal3ll1bIf4lZ2b2%2Bz%2BCvt31PCFK8S989yGstH%2FIIYd0H%2F6708xqKOkXEaEvCpaFFlqoq6fqOwAjxwfCoSfLPw6%2BL0Y927yyrD2vDETlhkkj4X95OBP%2FG%2B2p61YtaNsI7vk%2Fl%2FfOlNYIDqRq%2BZB9p14HosNylYUKaAIM%2BwvIJS%2FlC%2FyD03dU9QTtaZFYUT0FfKGGgdVpm9dYNcZvv%2F%2FCVF3%2Fy8bF%2Be5aa%2BbFdsaEAND%2FuH1uLOjWEZzBMrBCphUeIOGfAp8h7kgZkhgyDy%2F9kksuGaCKBelfS%2Bth11137REODVBrqM4qur62qE488UQsHL6uvdx%2Bp%2BZfSNS966uSzfTby1f%2B0IdfH4iPyKoogqt68bfooEvmvvKBpcEgEASCQBD4NyHgzwx%2FL%2BEfbMGIbjHXXHP5g6r15f8L%2FuIdddRRqTVc0003HbrjRz%2F6Uekx7Ib4a6SyxhhjjOmnn17a32yq%2Bz%2FI8MMPP%2B200yrvYf09aTvpe9%2F7XpWfcMIJTzrppNoQ8b%2Beww47bLzxxqusb37zm%2F7PW%2F8%2F8jeV3cMRRhihsozQllYbXktoSgFS2PbXeMtKIgh8SQTYQT08hj1KAnKO3l%2BgZX88M3noMbp%2F2H%2BBdrpVGI89zvWVi8dgcX8mj8EMIYbvCnG7jX%2FlaexNXwt9UHph%2FfnSvQv7bgw9Wv2mw6%2Fq%2Fl1ipPTwGH1bNtkvw2OwSXUN1fpn0A7%2B5%2BIxjIfDgnEWccH0Q0qwyPqOs%2B8TIQj23HNP3BQKom%2BuJ8gZlt3%2F0959x92SVHXbJ0uUnKMEAckZJWfhQbIEJaOIBJEgGQFJkhEUJahEFZAcBCRIRoICKqAgDwiiBMkgKvq%2BX2a91qfffZ9z5swwMwxw7T%2Fuu3d3ddWqq6q7a%2F16VW3vW2llfM%2B9zjWZgoM5Oob0ug2JeDux5bvRMfaaxG1kz4F1DM8gTtYRe%2BvWBzj%2Box78%2Fu%2F%2FPtnwwDoGSYonTlnSHFy8JRe4Wj3LPJ4WInYKX5F4gj3e9a53eQh6C%2Bxa06DTJYaDp6eKk0coHvY7urLVk51Oi6PAT2KShVajHnjkEfmdaL6YZHPUHo2up60c9qI%2BrHvcxHj9RFr8t%2Bdy93ROBqxwBV1CfU1sWfZs09sWw0PN8%2Fp%2BZUXWU3HCi0ygo60tgNK7o5pgYudOPtuvytIr9lfipLS0iE4%2BOoZ24b26lA78zh1w7%2BsJiaq%2FLe4wbWuObXUO07lHbGI3wB2Rdm%2F%2BuuuKx0CJ4HOo6oT1WJxlQPXdKO26vYtirz1HwR6XMLlG3N13UxbJd%2Bly300%2BnRuBCEQgAkceAU9k6sTlLnc5Y4D73ve%2BxAeR2Ku4kSPOfvazixel5JMRyAUXvehFDboM8MTp%2BXrFK17RQNHbjZOf%2FOS%2BLh3DNv2B6GEA5s2g8Z4EZzjDGWgUxk4W2SBxeNGjLPoGIUW2PDvBt5bgON7xjmd07Zn70Ic%2BVD4Gil6yeKqe5CQnOcc5zrE3wJVOwranPOUpy%2FI2InCoBLgSOpUOuXe0xsXQYyeHvToGMc28EmFCqwive4wnD%2F4Fn%2BH9%2FlwSh8R7cGdW5vvb4CiJbSCncD2WQ7RNfDA6hnf3XHLiANd%2Bn2%2FwZWhYaPS7zyJWcUbUjNk7t2UlmA0XNaePy8Yl3zl0qF%2Bdy09nrRk9PHG3mvFZcJhzD6BjaE0R75NsfzoGp0no%2B44fpFAzR7bv02279XGFpgoH1jEIDpyCnY5Bi6CELJ6KQE%2F4%2FV4CvO9t3IgS1dq5O8Eb60T90OwG%2FqNbt7kME0yuXj6TZkfHWCeujf3pGEoE0HWhnzN4pd%2B7wbtU5dl%2FMDqGd%2FeEoAlv2OYmmGHvrX6b4ADbJmJQuobwwegYogQ9ZbynxtZfcelqMdAOgfftVWWEzTER7%2BGJyQCcueSK0y3NcTAFw%2BoiIypKKTePPwEb5ERHbcxr8WW568WVTof0gJOPQAVdRVlT6EpGInB5%2Brv2HGBj6Q8HSOOQcAhxFzr8qtqkZyEJyIN4iRJanPHWhNnfLUvKHYOJMAymGCwblOLKcifRhWBR9D47%2FErv7irKZV2za7%2FRwjpxq2OsBAezAfs07t7EngWYrFvK3gQguMToNntzcB9%2B7nOfuxNvJr3LwV3a30P1i13vbm56zt5y9%2B7RVchNbiY77YKz9UzWPWerY%2BzN5AB79qmNT3oa1Hve854DnEvnIWTtXXvKHcxOPXknc48hddc33DEOkK1D2s7zl2C1k8OcJX%2B3eoFA5NNnP%2FvZO1m5ubnSd3a6n%2Buoq7fPUR3VfUA33kkMNcHfA3rFSu0k6GsEIhCBCByVBAQQrukkVHphFYboMxDymKBRmHKy7vxGOKc%2B9akvdKELOeSh7NBlLnMZN%2Fwx2KtSwgUxxFejO%2FqD4co8TGUoiv7EJz7xeoXtgXWCE5xAIKjBgCnVEq%2FX0x7BpzjFKbwCcy73yvYa%2B3mLTcowktlB5BnEhdk7rthJ1tcILALGijM7gCJBo9sO5o2prGDgjfaMZg%2BsY3jzqK%2FKyqjb8Gm8m1XKoW6IhdgZ93qH7j2p0eyBz9XhjaVdICJDuAw8r70Dy4PRMQz2EODEebPJO9s7gOdgupDNgyAq7gyYl4XCCbzDlY%2Bwrp0B4UozG0by3ECDTHeA7SHluqsw2GdvKYLElrCDsCKkmZGkrzysmeKxPx2DD86ph2iUh%2F3pGLTTvXHptB3TCrbNMTqGN%2F5MVYUD6Bg0Yb1CZd0Pt5XlenB4xxgc3DM58roQB2pLj3vrbqxR1ojdKXQM5251FTnbv3yfwWjn3Jl1bLfW9WpynzoG341ONRbuU8fgVPJkzaXyoQ%2BQ%2FlZzbOtle17Eu1HP%2FoPRMegMFtWkQm%2BvQa%2F4KQC63MqfkWtyx%2Bz0WFnzMnR%2BzbcSj44xbuPB6BjcIq2ssbiogLug%2BPJ7I5RwJulLOW%2FzPXFMD5HYRyPS510putDQ9oh0UTikImQBV6ttNxbvDpadnq2a2MPRWZSxfcpT5FYJrOezHrXr9J0NgoOoCT3ELctNbI66w5gaszeoHm06z96HJulgIkxWc9DitL4gokNVKadEfUl6GLexncQHFeQbggDLoU4qcZGa14D29oqYpU4MWqagfeoYKCG%2FQ2a%2Brhqto4YW28gKCWbxnDXmWSnXhh4uOkX3QHvttKHWrmL3SX1gVRxhfdhOF44brNjXJY9vz51tDj4ty12U3rW3Xfam18S0AqdsnyBw6fC60wpA2qeO4cLZnye%2BQ8mdeYeG%2B5KRm7rMDXCvYfbw9NXaZbitiG0Xggo6NDG6c66LV5c45O5yTRsex%2Ft7jBoTqo4cfHZuqrJy%2F%2FcswIQe6IYvvGryn7%2FuBsDuTOd0D2GSDr%2FTZ7SFItYjY3JAxpVlv8fu6oTbItqOQAQiEIGjkoBhiWeHCR0TIuvrFa5whTOd6UzGkMwQ%2B33GM57xkpe85HqJ4KWh2IwLXvCCnl8UdeLDGq9KT7te63yOjmEQMtVx4oUvfGEFkSwMtHwMF80fIYN49BAunHi2s51NuLjxoREyJ8XHE1kypZBTnGLAb1TgqToyy1EJqrJ%2B8Ajwm4ymDKqNA426t44h%2F8V%2BsbjzKnyfOoaBotGX0Y5Rk1EN14a7KkNq3v5Y6dheY22H5dwi3gG%2FYzt0pIoYKRkB7owS9f%2FtO2u2GbNRIYQ38A6cwsH0NnC9hmOGHHjH3hRv89%2BaJwHHissg5MAAUizuNshESgNyA0I0Ruiw1s329Nnm%2BRrYOxdJf5fquDelPSwxgkXMYHUlYDPXGHCOAAcBw%2B3o133JnWTbQOtEydwZKDnzetTIXC32ziuhRWidZdv%2BdAyKhBbkkC4xR2PBa6fZbRpryj1IHQNJWDBBj4O89TXoGKi6K6op70bbMU8Xsr28D2Xx2pzrs85lwz51DPfe%2FUXpe3cpc72U9yHPfeoYmhX8KXqvjuF%2BC4IGclfnlOkJOj8gS%2FpYzWHDc0FFeO7cPV8PRsfwuNFq%2BuHCPvnAovfODBoV93TYyhqs4q%2B5BCTmUcrBhbP6%2BWHVMXjZLBf%2BoUcxw%2FtZ2DXT9hpUIgJqR66czuAhZa0MrcZjGh9fXahMGlcncSHoAK6%2Bab51uot02SlgRtf1mNNA7kJeJaxD6uWjkwhd0DqH6vjzarmWzJOVHktGUC47UdIBdq7ryXyff79LHUOLq7KreMdhdyfR2w0YyJjY0nyG2D5tsHN6haxWsBwybhp6BcIzJtmnjkGI0P326jY65E6PpRoxRt9eNmhQ3NDTl7a3oJXAhv3EK2m2k7ZwdiLyKo72mrhhQxcVokPgJVgxXgITYbZ36clcDyQO8LJdX%2FsMT9raMNtzd3Iv3ca96LGi3ZgnYm360l4dQ1t4ZEzwzzZbVVOpNd5zSA5eG7m4tqKNt1HqqBG3z6NtPrYVYZKULj23nTnqRPdDdXTJMHLk2ZEmGOniMj6UrduLozvCwuRAMHcbl4N%2B7hqfgKtVtKeYS28UPJGWev46ZEOXcB%2BW%2F%2FYpZvw5Kr022l56eh0bNIR%2BuDJxaSjdTUktnLXk5ZWgjQhEIAIROCoJcMROdapTCXggUxtNGZKd%2F%2FznF54xvhhxw9ErX%2FnKHu5jlSGldT5Hx%2FCOb1bVWAa%2F4AUv2NExDAbmqPGh%2BSAUCQEbPmavOFdkBdnEeE%2F%2BJPGznOUsEvic85znNE6YQj0NPU0spmE%2Fwy5%2B8YsTOraPm1V6GxE4MIH1nloyvc5g2HhyhAijsm30vgQuBxIfh9f26BhbR8Bgz4jUkMkAz9Cav%2BCogZARDldi645tTTLANvDe7uHpGNYadW9fjbnKZEuU8IOYKzHjXQgGumtYyC1aR71SpIeM1ODaWW91D1XHMEgzVBNs4CXm%2FEjBzip5amo0SNI0cQwlw%2F6tqWOAehnrGtM6anjJKdt7hWIyrp9T6DlA8cfXjUVd3E8EwPgdGX%2BJM1u1h4CjsfZJVTJeoVGlATPDjMCh89p3kZkN9rgXkTsEPNgzOoa7304yPqO2AGTFNkjgFsTFNnxdAc%2FCttlz4HgMHoEamTfHUZ139FsfnDen3WXiQ9UxwOalemlueL99Tal0ZutpY7avsoXXWTrwMl5zc%2Fe2%2FXMdsmHsrVL6hqb0da%2BOgaFOqxQ10kaM0ffWrXuyUu7KU1AEA5gKNbdixxvVTJ4mfCuHnLI%2FHUOGGmv1E50WEKhXKfqGLi2f8d8xl8AlthK4EFSKSmOPkAOevmt2HVUXtRiPTAc%2B1PUx1omzoS9xS1Vz9QSmEouUqO1WXAQjeak4rPVeJBtFTnNoela5P6xqisTg6poLsHr%2BFOer9%2BOubu24bVkFUdUUKlxkZbJj6voKBe%2FVUxgZa3IqWk9WC44b9%2FDgfa65KXkir2uQ2a4vNd0xexW9NnRFNwoGi0Tap8Hy5GZi6Jrdq7w5un0Rz9F2z1lNoBRw3HX1BL3a1706hhYft5QmueyXUn9zfW19UjsF8CCmq9heHw8Ctmn6pZ%2FMIffSdQfT3Oq4FUBg16w6sA15rpf127u0dsFQ5iqlatuGVgRdXfd2b%2BTR6%2Faz6MeyarvhilutSf3Qymu5m0lGtnV5CuCZKuzVMbQOrYAlO5qzryzf2qzFdSH2bEMytKyKuGPsPCyUrsSFnbriceAqGKuY7Z7vcsZhuqX77Rza3l50YxcCRO5aCt3e9jWu3kUf84j0mGD%2Fuu7kQw%2Fx%2BHCW%2BirFtjvw5L%2F%2BelSpiwRrjw2yhuKkV9lt9zM2MAZYAwNYCJvuoq5KcRqeCKtq29zajkAEIhCBo4yAgR99gKrwHfngkA%2BtwH93bw8d7y9Of%2FrTC5lYD03PnRWPIW7QiSZiL2sNtvenYxhnWvOTQOH1hAhwHoSPhyYHUEHz3PQE8eAwdrUWKDOI8x6XHnD%2BGsl4rHsukz5MbOHXrELbiMDBEDDI4fDyhiaxwYmhi9c39vNqbet4RlAGxgYq4jGMKg14DIaln1BS7gnXzEtbPrtxuBGaAfD2nZEhHHfM%2BGoNd7eGuXaMgvT27U6DNCMiHocilDsfwyqZKH3bz71JNHI2OjUUJOWx0BjP3%2FnwCFhl%2BDdpvGsjTRiVsYRiQF5wEW3LXdvGe5wCng5fzPhfpbwnXUdtsJAXQ1FxdXu5LCUmW39TGk6fewUHWS2MLWWI3k5N%2BR1rJMwYbjJxYDXHtsS92wQW5e7MKVjJ3CK0nXJZZaBrHIuDlvVZDapoY1f%2BhTfpTpw31BoLasn%2BF%2FzrtS%2BxSGfge05PcMi5dsqf6yE94PzZUUvmZT0gXIntiHps0x%2FshG6CLrQyT0ozuXPyxdRIj5pXh9Ib%2FHP5MdEf3AZXP3SKlHaqi6L91ZpO3zpB2OotGk5nXli2GxwKCXhYdo6OMRxWGlYZlrNQL3WvVh0ONS%2FPfn%2BVuzqbDS9VWQ6IzuYvXUWNkBmbJeBVeYJwGeQvQzy3CsMUqrF00eW%2FuB5VEyhNsPIRacNXov%2BwgYvHPDnrBtw0H17kMHQN%2BmDi%2FS8HbU5HUi2WjuH3KXbCA1bdbeirGPJKFkB7CAiuoyVeMQwiEhkvbJ3Lfr2IYRPQOPs1hJR0CU3vWtDVV7YeZK5H7wtcMhIIgFn3CheFjkR7WVnpP1IirL8tUKvovRtuFy7AZZ4auVd4Aa3fbqWAvSfu7CFk6d571%2FlU0wObwWCNpVEEWG5Tugq02urqigNTr9jrqrugtvcfdyfVdzFKPxepruhacJsauU9buyW6sa8qGEvoKhLoAEvOddR4Y0eas9NFRJ%2Fxcp8%2FPt3GXzcENxDl6rorWxvuusvrd2sVDOCGSV%2FSOalGTnEhOJ2FOp7SXXRz4axrZ%2B7SitM9mC08Q3vNbYrHravo8C4EFx3FFe1t6dttMJFZewQeyJMlikOJDYp2G3S7npvS6Bj62zqFDiCeyj1hO13OLchtYZts0rvzu5wJF%2Bu%2BpEZSakH9c%2BVpQ6Or0dwVfaUa6c9z%2FaqmU1hlXEfi07v0Eze3uaMuRDbcXkTfOer24qN2bi9zLzLalKEcaOZuKezfcnDjclTTY%2BtEPLURmFsyuoqj6r5uFHCpIHVxbhceDSvGQwdgsDAwd12ZaB3J6DwQuaYUNHfULYG2IxCBCETgKCPAibjUpS4lvsIzwjiQpOBjLOeZZQVOaoPn0aUvfWmxEOvxbYRD2Zh4DAkscEHVXyMxz2KxFoZ%2FquBZQw9ZIwfBe0aSAj%2B2CrbcjHyMGD1wL3CBC3i2Tt3ZI1rDSMBz0H5D2TUoGuHFs%2FIoo1RBPxgE9O1ttzH%2B4dXqeN67GQTyRAxcDX5sjENnw4CHXqH6TjRokcAh4ysbDtk2BuZUGs36yIeH4ujq88NN1zUuco0YL62XdFukRpJym8zl6aNoH1eWcelKyRVihvx9xs5Jtv6qi8%2BcboTJp3NxUSHUzpum%2FekYhmrkQZeYUybz7Ss2xnMiDA6N8XyMDNmpmlu3iMhJRpg03B%2F2yEck%2FPY9mlq4qHmaBthCFHjB0IG2dW1WTfdusAEiRZBHOGvuPD7cUkh5TKjKSsW9muS3ypmREM1f9iykDJsGNVhdzLfktynnxNnjr8%2BknP1O92Zwq2NsmaiCOx6Zixc56CYHkGXi3GlBjoy73Hd6zyH9x5hZ5g5JvMyebTt9ZqdaOGurY%2FDR5OmohtBnhg83lr9j%2BM2t42Fp4nEb6Riy8rZ9h7Pe5YqgR%2FF9tOPQW1WW%2BfajLZaRivZZXyeZmhIlFKGDuXa4ADvFaXo90yNDxzApQxdl1SpucpusxhJF2PDRCedjDxQKQhg6O7%2FD6H8pyUEtRscgVHIMt4%2BeMcajRwJXlssTIs2xVAutqVH4O96PS0xw4MLwoLdrKUwmurQKLldal6Ae6IRIugBdXPJZMfl6LwuhlkxjSbYiBFwvdE4V4Y7JmUncT4nlvL%2BLVzK%2BpwvQx8beZNxt%2FdChMfUg%2F6oyHdUNZOkAehHO2zvDZCV%2FAGH0VY1c1wzeusaTjA6jsVyqywCeowy3e%2BaQMYm7BClDr6CDeWuvHbWyv%2BujVwA%2BKpk7gG35r5xtuGtxePWKrRBBauCNbpNNSnKBHPQr2Spiupy%2FKrKaZs7Si6hYbl9aXP56lzRO9Ne9WiZqZFu5c%2FrkuTKcDbdoHXUKkmCKdpZLDyJfHZWDq3XNyZ3S6TOeIyD7qi6uF2YQBySbO95OFRRhj4tdeh3J19me3Pw15GMMiXjtcUsRt7OGc2u%2FYFomTSuoxaJkjx67ktnQGdz0XOyaTzcQl6U6kqmgv1NHlNw95gbCwpXbFtQcnbJg8Vk5yAcl91VpYN%2BG9OiHAvlccdJroHmwSr9DRrZy89nWhRnq6AmiRvQW9oPsngDRjv1KVzQDlCKNy1a7LKFyS6PtCEQgAhE4UgkYnAif4H%2FtjHOMLUkQ7uFKNxa1Te7w0KRgGw366odFPLA87zwgCBdcOcMJrsFZz3pWR43inLijY9hjOC0xDYS6buDtzRqFxOPYg4D04URPFlI8B9N40leDIqVc5SpXEfVBk3eK1wF0lfkpkx0yXkOY%2FyLnnf19jcAQ4A5sh7teB%2FNDjUm80zFo1JN9jEwMWubjq%2F0jSuh4xjn2OGQY9p2kh3x8dcr6SOMF7o67xF%2FjsPDEuduuoL3N4eWU8Zhs%2F7%2BC%2F%2FefnVzRbXpeg6uSzUr831T%2Fv%2F%2F%2Fa9d34pYpCSMSMmlN1t7mtrYNX40GOblG6a7BbWLenIGo8Z5hm49tpbsw17k26AmKm%2BG3AZ7EuLm3bNPY5kRYtc%2Fp6uVjTEsPWe%2BOdxLvfHVPmCr761zF%2BRySzXf%2BKM6H5dwuiNCexNJsQYEj8TjU7lfSDzvJthCH4T53bnNTEf1nxvxeOLpP7txFjYRlu8UiZ3vkvJrJV3mujzGzsfT%2BipZs7JSAjrF8TKxsa24mSeCvjzQ7fKjT8wqboKdL0JB3IHvJyAAet7suH3aK2zFmDJi%2FqxY2lm3rkBbxCFAEdZrHt7c4Te9Epi47bezNRxo2aCwPBR4unoqQzLVGH%2FDg8JfZ9vjMobHBX8qDurBB5AaXaq%2BDxpvmKc8LaDcHBvDjqEBin7wv9tU1q1l5fEpnA99ZYpqGj26PJJfZNeIJqOfP8hp8cJY4nVdFWJAt%2BzlHbgu6MWGEtZ5lrPK0UoQrWp4iN4g5bGYAX5VbpCz5yNkbaumnUH93pD%2FXuBflHqYCPHYa9FC%2FqqaLxbWwI4CoMqVF07hY1N3NAXkCy44jLH%2BGAci1Z7BhA4OF8VDPtgYLwucIOx1DFwWVRnCF6IV9dkK3ZZfAtlfY9lltamO6xNzMvc1X4g4ThnkLr2MsrU%2BNvPp3p93LxD3ZI2BvP3cf49Vu02PiImKMVpuPs1RZV9QEtAK6nP7JPE3Mzr15Ti0kWB%2BnS6YsCq1njR4rN8lsaJdt6S5MEoFRlrpwn3csYZWsJv%2F566s79gho2pFvPlGv2zz1rrlIZ6cghxn1bdPY5qcTB1R5m78qKHRHtHe5SYnnltLY5m6vS6uC9nUpqbLP%2FhApaCGyMVWTg4c4LVG95ii1be8TxBWhIuQ7QqJTlL5TinPlv4PLHs0nJPie97ynZ9Oy34aPxFpEDze4XfqVmzDJ102VSa7fHWh9jUAEIhCBI5WAgcfIBQSKnYLcnC3v6UdJPMc9DowHqArzEVBBRqBF2O8sYzPJ%2FvfgMcz4sG3E6BBh3PaE5U%2F%2BxnVGTcc%2F%2FvFX%2Botd7GIz99BLLk%2BKtd8Gl2eeUF5gWbJjHTLfxNjV66cdmz1PpfFA2RmS7STr6w8tAQM5fXJbfYMu7raBnPGJEREv3vtBe3jcBrdedbk0ZnxCajM4t8dHKKk0s739a%2BRs5Ll3RO2doPdl85ZHMOrWANv8ICca11H55KZomSvdX%2BLJvH1bpxjBikciPI6d29J3tuXJ%2FeHUG4Ia%2BiplZXKADd6Qcfj2JaaX%2BxwQVrnS%2BSbyVNA2gdy4Y4aL4vxFbRFeQODX7J0wYlRpEGvsakBomobmUEdgD2DPOuRm5V2w%2B5VBPlbrIzcOET3BQJ0Xqb5OYR4D2MlL3QEljTky0ihXXVDyYb%2FEQ96J0%2FRGp3ay0P7JbTLUJWz42OBUutvwDnShraowZmtrHhbXFRY%2BL6HVWQyY4g7JY%2FePZFxaHWyVslJs68JmvRGTxceGBhq8C44NuDQfT5NbzYmYWAJOJTO259qGTtcykp8OLM0UPcbgsCwBZBCtPTaYt2MzI8cN5ElphZ3ifNW3vQrn1FAAeASCt12G8nGi%2FDEf%2BP5qCyH9fFIPHT61gviwfOFZpILr4SGiCDuda2MMs62Jx3dTC%2F74Xht4pnyceaVLB2DPcnk8jzinU4T7hv3Dcxwof3k3HmdzhXpIeUraM%2Fs1xHQzJboA9QReko88ebic0Gk71fEyfQ45URH3uMc9JgZAuXxM6ac1V6ESuy1sK%2BJCU7QHH%2Fdtu%2F9gtrHyYoIs6caykx5ttVOjMdttQdPsvY3oh9xbL74ZPNrXXoP1fJnrNtpajaambonadO%2FDWnvBxVWcXkHVcftCTJvOFTp9w9%2BJk3GD2uquUwvMAbnb3e5mY%2Fa43t2BVWGnmqQtAQmM0UMUoddNKTqPnHcuMdemRnFNud1pKXYyTJ662chlgnkAkYMe5RJzaHvhTLfc%2FnVUp3VDcOErCw0iD1z27%2FWLKYGKFmagFXRmcoG7H8tVU79liazG%2FmHl79wlZKuBdjQZHJityxGCFhPXkb6kW649s6FoVabWuvTYpoJzB0DJXX2bWEdS1ug5sqKKuMTskZLoJyXg1CRyFrxq6tLeuW9s%2BaxtJG0PE1cc%2FY0NLoS9d92tMbalhNdn2xC2tc7gUpGpEQvdq53CpLHfc9ZQkyzJfulntpeORCRhv947A1SBba6RfUpkO8b0NQIRiEAEjkACHiiGgp4Oex9bxhLGBp4vM6alP7hv8xQM54xvbXv22TnGuJm7zzvqyTIj5JmEQgzx9JmH1zLbI9VgwKPN%2BE3RMxSZo2OPMZWPJ9QagTjqHY3nCCdrosr3jqakYaon0ZqZskpsIwJDQC81POZx6PAGjYZDXADdxkhMzzEgFAs0KXXpg%2FSvD4atEY4xubeBBuQG%2Bfxxg1VXhyGWEAjjbQN746XJagZmDHOlcKzmbdocMlQW2GzkaVR2MOVOGgPLvXXZ%2B%2BrWHkKKd%2BgG51unhi7B%2BBmw7a9QF7Ih386Yf5%2BJMQecyzPD%2Fn2mOcBOnpp333gabxNV3G0Mzt1D3DoOcNZBHnKTkRKr7Z3HHkUcuGpaai9hJxoAczHWffIgzfhukuFAqTDM5hZpOANvkg7aarRtU7d3e%2FgmLJ%2Fi2O%2BerPV57geurPQ6w2EC7nY9YsLeqjmkw3OZtew2zVyh0h%2Bqn7I3z33u0QqrsiuBEmdhkxVmoO6gjY%2FjObXazi3CzeGQNTneZmM%2BfFVdceFSCwoed5g3tHN9SaNRZMtjIlNsLXGIT%2Br%2B46LWq9eJCOvhClLo%2Fxb4%2F%2F3fcUgJJu5d3NsR8VbtDmYDdoXuZLhO1EkcHQffLWtr9krjKGnCQEIXOlSDdU4p%2BYn%2BupBXJjsb%2BqpOO71i4ZVmOoO%2BsdrFTttqwbyVCYaa1St1IVLLZm1NynP%2FN3Rx75Weta5Q4THuqFLO6bArd2W1d8ONS%2F93r9j7JmVv4iN2jyeXx4egncmWAWPJ9n4FnWeHBNsLBwSto8rb4ZaWHbFoi05%2FJpUApU%2FqV64Lgz0CI%2BXZU2ANrpS7zzveGKY4BbnPExgPkGwSf2%2F%2FMm9Gv6zdWjIDA3%2B3PW2bYG1LQO4jsO9o%2BytBGxGIQAQiEIEIROC7J8DtNUgjGvAujT1EDQnFN0LzztGg18SlebVqVO8Vv%2FAM7vl3X6hxuBklXpwJVufmcBUN%2B73cFBlr%2FOyr0r1Zm8EkN4c9M7rmUXrdzwzyHT2QguENoFefArzH4%2F5ubOMRcXwMZfkL6uu1LDmFJV4WG8FuczYWZdK8sN7u327z0bzAgne78%2Ftrm%2FdqVC8y3EtbvWK9DOXsaykdRtsd1hpREuS5VaIOaw5HXnrdkq%2Bnz%2BtaQrUFS7sueEnzRvIA5Rqumw7A%2B%2BN6HyDZd3NIn%2FTWm23eMut78zb%2Fu8lwf%2BdqZRECFio8TLLM%2FnL7nuznM2o%2Bbbc3JuEosIe7KrBKY%2B24gUdG0bqBq0nshFuicJeJLJqCOPIuW%2F3ZfUzQkVuZW6vLll6xLAHKIfMd3H655G50c9v3Jl3MwNxRpaHi6hJeymx1gJXJ93CDx01eYJub7f7MIGKwXxQEwdwN2VyqrYwg%2FgE6Mgg9beKgPPUExW3fChGXXN3I6FHK8tcTSjKXobdaU640Qry0O4ljf5Z8X%2BwHRwSaICLPVhVEZoviIKtA7fRw935tK8Me5Lkli0AEIhCBCEQgAgdPQPy%2FsRk1wPDMx4DNx8DPAHgN%2BbifHDrh1sa9B5%2Fz%2FlIaGgkukr9BpmE%2F8cQY21jaX2qGIbdI4OVGCdVgnlkSXANjRVHKtJSZu81OZtNhSBD7K%2Bvg93uryH2bIf2MV20rYu8ypBxemo%2BA%2FwNk7pWW0eDh8PQPkOdRfEgAA9cG9mkgY%2F4xwAZPR2fQTIfVJFh4TKZ7HNYTj5r0ehdnx1WgK37nMrjOdThBhzqSn6vD3PydKVpHoM1cVNNM9H8vgllF1jsCM99m5bWyKJTt%2B%2Bjt0e%2BLba63G4JIs3X7OirNJiC4ox6q9nWEmETjdSVau4CG5ja4487TId1O3SGnM7ubmWexUy5ZwwWuR83t132P%2BmHmxerz3q3bY0Kruxn9duf07%2B1X92HKobuQ58j%2BLBGQI8xV7Vw4NB%2BzyVZ4j1M42nRaV64EQOGgmmtV25Und94zaBABTjanh2yDDQQIQX31q1%2F9MIUFrvyPPhsCWqz5rC95ZeBGrekPR4wNmdo9xJ3k6FOvLIlABCIQgQhE4AeSgEGg2RzeYJq7ZKIHX4ybuRPeYDRoxOvQzv4jBIiBpXhmsc3%2BGgJtQ4Inf%2FvtFCMxse78FNO1uHVG3f4egVqBQbtZLbzFQxSd7yxzisk%2BvSHzTXZ%2BXG8vCm9Ij7wX9HuLOzL2iCcxXKfweMUG%2BxSBkv38RLHrh7VQjoNAjp3VDA5rJkdqei68CU3eRZqYILReZQ%2B1uLk6ADkyro5VOr%2BAo8rBFMV0OJyLlU8bP0gE3Lr1OuE6pj%2FsrZfL1uXGfxdRoH9y%2FPemcS7VxawWObjY997uSM3WPVgy5t4cjuZ7XJ6i%2BNx5xBptQ1bGbBc4RRofTxOLQe1TwXOuO57oFwqPOVCI7VRZEU4kEx3dQlZ27DyYrx61HrjuM%2B42R0FM0cGYVJoIRCACEYhABCLwg0TARF3TPWbi8xFbL%2B%2FjvOPzZo3DuN5L7hRhYO9d1c7Ona9Eoe%2F313M7NTpCvopwsJrcEZLVkZQJr8TcmaPn5Jcjqcpl%2B4NKwMRAPvj%2Blvv4Qa31Ya0XPp4mByNaHtacSx%2BBCEQgAhGIQAQiEIGjDwFvME1LP7A9ZsR8v0%2BXPnAFD99R74WPwBCaw2dDZ0UgAhGIQAQiEIEIRCACEYhABH6oCAjA3juNeoeAH3Q48Dr%2FO%2Bl%2FSL4KzC5c%2BYekratmBCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQicAACFp%2F36%2FZWk1u%2FArBN%2FPGPf%2Fwtb3nLYVqby6p0b3rTmz7xiU9s8zmabO%2BzjlvbDpzAsnvWXbfkuCXLtme1HYEIRCACEYhABCIQgQhEIAIRiIDf2HrmM5%2Fpd8bvete7%2Fsrmc%2Fe7393vUR5RfF7ykpcc%2B9jHvt%2F97rfPDB%2F3uMc56qcwHbWMNmP8LNo%2BU66dfiLQKX4Mfe05%2BmyQIMC0LiK2W6v84qef8sTYz5lt9%2B9sf%2FnLX77CFa5wgQtc4Oip0uxY29cIRCACEYhABCIQgQhEIAIRiMBRScBvrN%2FkJjc5xjGOccxjHvP4xz%2F%2Bj%2Fzv56QnPenLXvayA1jiR7ue9rSn%2FeZv%2FubBLO5Hx1DEfe97331m%2BNjHPtbR5zznOY7%2B4R%2F%2BoW06wIGDFn77t39bMr9puM8Mj4KduPklgoc85CF7f6%2Fwgx%2F8IHpnOctZdg4JSvnxH%2F%2FxE57whISOA1hIx7jsZS%2F7Ez%2FxE%2BkYB6DUoQhEIAIRiEAEIhCBCEQgAhH44STAH7%2F5zW9%2BnOMcR4TD%2B9%2F%2F%2Fr8%2B5CMSw%2F8vfOELB2DixMtc5jKnO93pdrz1fZ4yOsb973%2F%2FfR7d6hgK9QOOh5rn6BhPfvKT95nhUbPz53%2F%2B52kpe6NWkLnFLW5xrGMdi9CxteTFL37xcY973Gtf%2B9rf%2Bta3tvt3tukYl7vc5c53vvOlY%2ByQ6WsEIhCBCEQgAhGIQAQiEIEIRGB0jOMd73j7m%2BwgLuId73iHuAsqhPgHv64IGuf98Y9%2FvJCDk53sZA984ANf97rXffvb37afD06FeNCDHvTrv%2F7r5ol89atfHcJ0DPEed7nLXV796lc%2F%2BMEPFsbw2te%2B1jyLObrVMT760Y%2Ba50JRmUNc%2Fle%2B8pW%2F8Ru%2FoZQ%2F%2BIM%2F%2BLd%2F%2B7fZPzrGwx72MDNQZCjBa17zmrFBoMgfH%2FJ597vf%2FZjHPIbZv%2Fu7v%2Fv5z3%2FeLI8%2F%2FdM%2FZZvEzFDxycpfp7z85S93iGGveMUrZj6Iv4qmRfjlxGc84xkPeMADzH9hnvQf%2BtCHnvjEJ17iEpcwt%2BWe97yncy1qsXKz8cIXvtCh2972tmtqCdvueMc70j2e%2FvSnT0qFUjYYjxVoX%2FnKV2b%2F0jH82KVspRGp8rWvfW2O4v%2BUpzzl7W9%2Fu6%2BMeepTn%2FrWt76VnVMvDWG%2Fn8jUUgx%2BwhOe8LGPfWxOnL%2BWImE5Jg5NXbZHbWuU17%2F%2B9b%2F%2F%2B7%2B%2F2m4nQV8jEIEIRCACEYhABCIQgQhEIALfQwJLx7DS5l4zeLUUgxOd6EQc8Pl77nOfmxM9yoOd87npTW%2FKYed3X%2Bta17LnBCc4AWHExs%2F93M9NUAcdQyjCSU5yEt69ow6JAJHJSBlbHYP64ehDH%2FpQxnzzm9%2F8xV%2F8RQKIxOa82H%2B1q11thBTRIwIeTnGKU8h2DtkggDhLiec617kkPvGJT%2BxEyWxf%2BcpX%2Ftmf%2FVlZTekSc%2Bdn6or0BAdpTKmZxDQT1aEzXOMa17D%2FtKc9LbMdtX2xi12MAYQF2%2Btz1atedcfrt2bpRS5ykTOd6UxjLasICCaVnPOc5xxhQaG3vvWt5cCSYfUzP%2FMzn%2FrUp6RcOoavX%2FziF6klpz71qT%2F96U875PPSl77UWZYQsf385z%2Ff9o%2F%2B6I8iMBDU7na3u51SVGQMZgbV5ZBT%2Fx8ro2o%2Bp0xTokQDmUPrL6VIHaV529vetna2EYEIRCACEYhABCIQgQhEIAIROJoQmEkQ%2FPQJafizQz6vetWrrLfJwo985CNnOMMZrNXwxje%2BkUtOXuDhEis%2B%2FOEPC4Q4%2F%2FnPT0mgPPCUxRtY%2FsJR61h60W%2FPda5zHV8dlQ8dg6RgEoowAL9aIvzg7Gc%2F%2B8lPfvL3vve9jm51DIekFP5hv6gJ2yZiWHFChre5zW1keK973cshMQlECQEhAg8YaVFN7vyVrnSlr3%2F961%2F60pcudKELSXmHO9xBXMe73vWuq1%2F96r4qzoIegiuYdKpTnYpHP%2BKAGAxHuf%2Bq%2FJ73vOfyl788YUFgAynjete7nkNXucpVhCj87d%2F%2BLVnGV9ZaEsTPr9BGcBPswbwVd8G2%2BYh5kNhyH%2FNVob6KSAGKejNHSSsf%2BMAH5EyucZTBmkNgxswroWOoC3vOetazrh9zES4i5UB40YtexABqicxBEJshPMZR8osQGtkOMaEabDBL5bznPa%2FWJMIQVaAARNXIJmPh%2FBUBIvhE%2Fgez7Mn2xLYjEIEIRCACEYhABCIQgQhEIAJHAYHRMTi%2FO59xfq1I6bU%2BHWAmdHByTY64%2Fe1vTy7gj9MN1mqWHHliBT1h%2FGJfn%2FWsZ4kKENugFg7Jn8SxajQTQ2gC9uxPx5iwh7vd7W7KkownfoMb3ICWIo6CjiHDMdIh0y74%2B6c5zWn4%2BwwgvNBJlu%2FvV0Ik%2FuVf%2FuUpnYxAihHkQH%2F4l3%2F5F4npHjbmKMWGjmG2iHgMUoxka1lO8QwCHlR%2FUt7qVrcSTWEhkfm681eog6OkD9DQELKCJIFIMgKF2IxznOMcIljmLHEX1vYU%2BEECwvYgdQzTZOg897jHPSYTTXnNa17TOqJritA73%2FlOMTDW8UCMOgECtWrZKSBEFAfpZu35wd7Q7tZd0V6zDgz4E5Dzg13raheBCEQgAhGIQAQiEIEIROAHjMDoGGIbiAyWbiBEzGfcW%2F719a9%2Fff6v6AU%2Fa8JxXgs12PDzoGc%2B85nFVywmzuIdmwrhZ0MdcuJEDsxsiO3vrpq2wK8Xh%2BBc605ISbWwvY3HkLMQAocufvGLS0lhWAXtrPPJGNKE4BAhFqNjnOc851lzMayWIZOZqyIHVRYIccpTnpJLS6MgTRAryCBXvOIVKTPKkvjGN76xWAg6BqFmzcsQ5OCHSERu8H9lYn1USsVSOZZtszFhFWc84xnVQvwDGqZ4WKbDURNz5EPZmGk1k%2F7e9743IOJAvvGNbxy8jsEAoR2TA8FEY53%2B9Kenz8wezYGJyrL2zne%2Bs1YWQiOMZGrKNjU98K%2FSTD4%2FGH%2BttWIpEk1Ml0Pg0Y9%2B9OhjPxi1qxYRiEAEIhCBCEQgAhGIQAR%2BSAiMPy4CYb3E36m4lRwEJ1zwghfk8%2FqIXjClQpq9OoblLs3XML%2FDbIUb3ehGP%2FmTP8lx5p5LPDrG9ndXzSixSoOQBprA%2FnQMJ5rr4bc%2FTKyY0q2PMatTjo6xfneVMUIRSBNLxyC8zHITMrFWp9NXKIIqM09i7%2BXVhXrAZunnQzkRniEIRAgKHYP%2BYIKGTHwOk44hvUk0yrU86fOe9zwbJrAcks3%2FY5aKuptFso0HMJVGK5gqclh1jKUO8dNNhDF5x1yVKchKp3QMmoxDULPBJBQKz9SUpkG0MZFoEv%2FA%2F9XuZiqJ5KHUWV%2FlDW94w1ZH%2BoGvfhWMQAQiEIEIRCACEYhABCLwg0Fg6Rjcur01Igu8733vM9NBdIHAA6%2BzzUQgUIh5sAjnNh5DGssycKKXe%2BgHRDjmv%2FZrvybb0TH8gsYqwu%2BVWNvhrne9qz370zEsifk3f%2FM3LPzsZz9LcBAIwROfcAh%2BqO3vXscgp9Ax6BWWgxB84iMMgypCTPCxNMd3o2PQE0wVEZ0iBmA7P8WvvVBR4BJBsYCYroKt2SjK3RuP8ZnPfGZSkh1UfKJchMeIxzgYHQNDS4MCTlTRlFNTG2pK4lg2tBGBCEQgAhGIQAQiEIEIRCACETiaE1g6hnU7d0wVLeDHVXnNaxkKv8rxUz%2F1U1xy%2Bgb%2Fl3tubsIESAjbuPCFL%2Bxdv1Us5CNi31nWx1jxGGIzrG7Bg3bUuaIR5GzGh69718d41KMeZT%2FXXhohCrZ9xGYo%2BjKXuQybZ32M71LHoCew59KXvjRVYck4XHvBHiQURh5Ax7DkxS1veUsygpiHMW%2FvXzmY06EKPiJAKD%2BThlRCC%2FJzKqtQYpGYEwESn%2Fvc5xgwOoYFHJwy82XWD9EOq8OkY7BBU%2FpxE00gNmP9tIoVUC0QahmQHcvpG4SjbazIToK%2BRiACEYhABCIQgQhEIAIRiEAEvlcEaALWgeRoEyhudrObWQRjfaxHYUoF%2F5q37oc2iAbzgx18Yf41pcKMDydaJsKvcvCOLaTpq5UHrNgwv%2FTh66ztaZ1PwQC%2BCuEwv0MRtq0XMatrPuYxj%2FHVapwgUDa424985CNtW7pBiIIFH0giFBXTVSSb2RlPfvKTbfvt1OHG9xfe4Nc6Zl6JeRN%2Be3TNK9mZ1qHKFBWJZw0Qv9bKNqUwm0owpZgSQoWgIfiBjzWvxLoWxAc%2FAsLH9xHhwIYb3vCGdA%2B%2B%2Fz5bcJYqVSOBENsEL3jBC0wtsfjGgx%2F84Ic%2F%2FOF%2BApUNfvpEGrEutBqK0Cc%2F%2BUlfH%2FawhymFsoGbtUes5uHrRLm88IUvpBTd5z73mZwZPAuTrnklQmgsxMFCqgvFZhrF3Bww6UuWRbXW6HZ5E%2FkgSffYRqFM5v2NQAQiEIEIRCACEYhABCIQgQgcHQhw6v0EiekPFlLg2m8%2F3H8W%2BpEOMyPGfbaOxHWve93l%2BQrh8LsbVA7LS1I2RGIQE%2FxABufaBBPuMD%2FdX7MnrEsgcoMHbekJP7EhN1EQ4gGGgEki0nPtfX3xi18s5ZOe9CTbvG9Lj%2FrlEaeQAkRNkFPml1P8%2Bgab%2FXjo5MD7tmympS3m90p4%2FWSZ9XslIjokplFMYlU2OYVQMD81ohS5kT5UhOVCPkglqkMWoPAQW%2FzI7Jwo9II8Qr6YdRX8GojlTM2dEVwhHGXS7PzF5FKXupRFRfxq7fYQFciqHUOPggEUmWimmZBEyBHWHZ1fMzGjRCwHqgiwTb2wmrU%2BLEiCFRlkcmawRTAEdSzhRcCJeqmsKksjQyKMn1uVFYHokpe85Cx1sjVM6ZqMvrEiQLZH245ABCIQgQhEIAIRiEAEIhCBCHxvCXDJLQ3B3fb2f%2FuxR2DA2MapN6eD2262BQd8azAv20t%2FP2c5rj1PnNdvjzzpAyIiBEjYEK0hQ38JDpxrPrL1NFY%2BQgW42DPfgR8tpT3rqFkYTpHniq9wSALJHJpkrGKJBAxQHAVjtufolL6khqmyyqrXKkVllSJCg%2BWzUzLhIgxbMy%2BsXOGs7ZwL2wzbi2VlK2xDJmwbPmv%2FbCiUlmKNjhFnZufURRVG1rDTht9MIfvYOTbM755MvWZbsjGYhctgFfRVjbal2yMrDbqSTbnrL6psZvna00YEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRGAvgf%2F5n%2F%2FZu7M9EYhABCIQgQhEIAIRiEAEIhCBCETg6EPgX%2F%2F1X3%2F913%2F9EY94xFe%2B8pUdq7797W8%2F61nP%2BrVf%2BzVpdg4dDb%2F%2B9V%2F%2F9V3veteXv%2Fzlh9u2f%2F%2F3f3%2Fc4x734Ac%2F%2BMtf%2FvIBMnn7299%2Bl7vc5R3veMcB0nQoAhGIQAQiEIEIRCACEYhABCIQgSODwEc%2F%2BtHTnOY05zrXuT73uc%2Ft5P%2F1r3%2F9Kle5yjGOcYy%2F%2FMu%2F3Dl0dPsqmOSe97wnUy93uct99atfPUjzPv7xjz%2FoQQ963vOe91%2F%2F9V9OIdf82I%2F92ElPetK%2F%2F%2Fu%2FP0AOj3zkIxX08Ic%2FXJqvfe1rT37ykx%2FzmMd84QtfOMApHYpABCIQgQhEIAIRiEAEIhCBCETgCCHwsY997KxnPesFLnCBz3%2F%2B83sz%2FKu%2F%2BqtXvvKVAhX2Hjpa7fn0pz997nOfm7xAhfiLv%2FiLg7Ttfe9733GPe9xb3OIWk%2F6%2F%2F%2Fu%2F3%2FSmN73%2B9a%2F%2Fz%2F%2F8zwPk8JnPfOaP%2FuiPlCgNHeOSl7wkFej7ImTlAJXqUAQiEIEIRCACEYhABCIQgQhE4PuCwAF0DFEKr3vd60wt%2BeIXvzh1%2BeY3v%2FmCF7zgAYd8nv%2F853%2FjG99YdfzWt741h0Q4vPrVrx4pQJjEm9%2F85t%2F5nd%2F50Ic%2B9OxnP%2Ft%2B97vfwx72sG10hwQmaNh5n%2Fvc5wlPeAJjVoY2PvKRjwh1cOjxj3%2B82IntoZ1txhz%2F%2BMc%2Fz3nO4%2B%2B97nWvnbU%2BBJa88IUvZPUDH%2FjAP%2F7jP2Y2yeIVr3jFr%2F7qrx7zmMe8xCUuIf%2B%2F%2B7u%2FI9e8%2BMUvltWXvvSlV73qVb%2F3e7%2B31AkZkjie9KQnMeNv%2FuZvnvGMZ6jRu9%2F9bvNxzna2s5361KdW6ze84Q2yZRhuL3vZy8zWURwmoC1r%2F%2Fmf%2FxmN%2B973vmav%2FNmf%2FdnEgayjbUQgAhGIQAQiEIEIRCACEYhABCJwYAIH0DE44Ne85jWPfexj89llYuLJLW95SzEPP%2FIjP0IusPFzP%2FdzMxuF0HG7293OHvuPc5zjEAfufe97%2F8d%2F%2FIez7nSnO9l%2F2tOe1s4TnOAEtk9%2F%2BtO%2F8Y1vdIjX%2F%2FSnP%2F3EJz6xnfP3HOc4xxxy1EoXZz7zmR06yUlO4u%2F5zne%2B97znPfbv%2FZBQbnazm5kdQzMxEUYmEywxKT%2F72c9e73rXkwOzj3e849lQi0984hM%2F8zM%2FY3t9%2FvAP%2F1AtKCFMFXHxkIc8xCE7JxPTRsgd9Ip%2F%2BId%2FIHo4ROUwtWSdbuP2t789uYNIcve7391XZU1xd7jDHYRtyAfGS1%2F60g6pLBo%2BBI2jf6zLEOhvBCIQgQhEIAIRiEAEIhCBCETg6EDgwDrG9a9%2F%2FR%2F90R%2Bd9SKEHPDBb37zmwtI%2BNu%2F%2FVsbvj7qUY%2FivAuosP1Lv%2FRLXHWTNYgJRINZcnOc%2Bgte8IIveclLxFeIUpCSAOKsf%2FzHfzznOc9pPgj9QZyDNTYdusxlLkM%2Fcejsh3ycRXMgHcjwute97j7XvqBvnPKUpyRlCG949KMfTXj5kz%2F5k2FLKpkSb3Ob2zD7Ax%2F4wI1vfGOlCIf48Ic%2FTIuQ7VWvetW3vvWttAtixUUuchFLZBBnPvjBD57oRCeS56gx1BXZUipk%2BMQnPlEOFJhPfvKTIk9U4QxnOMOLXvQiEodCLZdBoPj5n%2F95OSjxpje9qROf85znqO897nEPJ2L1f%2F%2Fv%2F33b29520Yte1Nc%2F%2F%2FM%2FPzp0g2yIQAQiEIEIRCACEYhABCIQgQh8XxA4GB3DWqBiFc573vPy2f%2Fpn%2F5p6sVt5%2FULwyBBXOxiF%2FvxH%2F%2FxtVIo35yHbtYGr390DNM65izFCZwgVgiieP%2F733%2Fyk5%2BcjDCLZFIh7njHO9761rf2yykmX1AD%2FJ2z5POzP%2FuzFBXrdeylauFNiZ%2F73Oc6JE%2FJiBUT5yAY4%2FyHfD71qU%2FNiTQZi4EQUiaxYA8SxxyyQsjoGKaTMO%2Fa1772Wc5yllEn7n%2F%2F%2Bws1IapIuXQM2wIt1EWsyMxAQemKV7zi6U53OtrL5EnNEIUiAkSchoU4TnjCE1pvZA6RPn76p3%2Fachzztb8RiEAEIhCBCEQgAhGIQAQiEIEIHCqBg9ExpBG9IObhyle%2BMgd%2F8iQ78NbJGkILTnWqUzl62cte1s%2BFXP7yl6cG0DFucIMbiKwYHWNFHYhhONOZzsT359dbtkK4gpQ0EDKFlSsm3ELowi%2F8wi%2FYT3C4whWuIE8f4oA9lq3YqdG%2F%2Fdu%2FXfziF7dIhWAPsRN%2BMvXqV7%2B6uSF%2BhlVKERGkg5vc5CYTVmEPs9ngQxuxUocpHkSGWc1j6RhiM6SkoihRKAU7f%2FInf5KG8y%2F%2F8i%2F2b3UMCoxDFJ6RdyyyYS4MIUUF2QyFVUBlcqUrXUnVVJAY4iitQ0DIzNaRYZ8IRCACEYhABCIQgQhEIAIRiEAEDpLAQeoYZmSc7GQnEz%2Fw7W9%2FeydnC2CagsE9587%2FxE%2F8hL%2FiE0gZd7nLXSgAo2O89rWvnbMICKNjzJIRVtQU6nDhC1%2BYs%2B%2FjdDM4FDGTVqgTk6E8zUy51KUuZd3RndLN7KBUiHmgFVzjGtcgYoziYYKJlNQM0oEYj71mO%2Fqud73rADoGUcKEEUoLocbPoNztbncjfTjrADqGcBGSDhrW2RjL%2FYWCAQQW54rBsOSIBCrLMAuBQmR%2FnwhEIAIRiEAEIhCBCEQgAhGIQAQOhsDSMWZyx%2FYU0RSzPoY0lnSgDwgzWH63wAyag18beec732kBzKtd7WrmlfDWfUwMkWx%2BFoT7z2ffq2NIIOyBkiCxWIX3vve9D33oQ61WYdUI%2BZhgYlkJ8zgcmjzpHjJc0SBj50xFkb%2BIjqV4UFFIBII0nGidjVOc4hRmiDh3TpGDqRxvectbRH0cOB5DCIf5KWbBXOc61zH9ZE0A2aeOMfNWTFoxFUXRFhodsxcK9bWOB4wUFTNxVM3Cocc61rEoG1vmbUcgAhGIQAQiEIEIRCACEYhABCJwAAKjY1hDwlISXPv1EXuwdAxrRHD%2FBWOIXljuvCgFcQU3utGNyBFiIRwSmDEFceEtoTnzJvanY1i%2F4mlPexoJQkzCnMXTt86GXzMxR8MUDIfMLmHDHFWuX27d0THMJREyQfogI9A0TA%2Fxl2RhIglJxA%2BbSi%2F%2BQazIss2Sng5RNlSQsECgEPuh1krZmVdij99gJTWwxCIeJrCMJTs6Bm3nXOc61%2BgYqkD08Ists8ap9AzwI62iWSgbQjVElfjp1cln1jX1W67ztb8RiEAEIhCBCEQgAhGIQAQiEIEIHCoBOoZf6ODaX%2Bta1xJ%2BQJfwueENb%2BgnQsQS%2BMVSYgW5QD4ve9nLiBXWf%2FDDJX6WlPNOBBiH%2FQUveIEcSBB%2BbtUvmFjUwsKbj33sY%2BkDVvukA7zmNa8ZS8wrkczPjxIoRC%2FIxJSQO9%2F5zoqbtTKsqkHiIBrQRpxIcJDPr%2FzKr9AiJBZfsa3Rb%2F3Wb0nzgAc8YLvTNnvsJ4MIfhjbmG1JCpb7VVZmv%2FSlL5XM%2BqV%2BFEWcCbGF0CEihc4goGLWx5CAtaI7ZMWGVcQTnvAEe4gw9qgFg3291a1u9axnPYs2oqZMFQQiT2fNr7uyUMpBYY0Rc16oN0wyVYcctHJuIwIRiEAEIhCBCEQgAhGIQAQiEIEDEzDHQUQBX95Cnbzv9TGzg45x29ve1pQN%2Fr5MaALiE%2FxM6vGOd7zjHve4VAU%2FbzrrTvgrWMIqFtQM80HkZpKI4ARncdgJF2agjBniFoRP8O7nqOgIpVvggu5hDQr6yZQlMcP8qogfNHGIfmJtzHe%2F%2B92TyfwVd0F4oUuY27Ldb1vwg2U2rcApE7Y985nPHLNZbvrJMlvwxiMe8QgVVx2relqsQ9yFVThmPU%2F50CUoM5bp8DMoq4inPvWpamT9z9lDyWGDHMR1CAgh3bz4xS%2B24oe5LWI5xGDc6173krPE4j3udKc7mYNjP0usoSHsZACuzNuIQAQiEIEIRCACEYhABCIQgQhE4AAErALB6zeVY%2Bdjmgk338%2BJUh62szmIG2ZJWD%2FT5JGdbOeQlS7WD7BKwHmX86gWvk5xAh6W%2Fy5zM1AsVSHWgm6wzZMmYEEJh0xsmZ8U2R6VlZxN09h7yInsF00xP4DirLGNHGFjm4kShYW8733vEwHCJLntZCi9PVCss4gSct5WH0AaC1DKnWRW8%2FBTKeatrNCOdfok3mvJStBGBCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEvrcE%2Fud%2F%2FocB8%2FeIskRuk%2BH8PaKyPXz5HH0sOXz2d9ZRQ%2BBb3%2FrWi170ok9%2F%2BtNHTXFHdin%2F%2Fd%2F%2F%2FWd%2F9mcf%2FvCHj%2ByCyj8CEYhABCIQgQhEIAIRiMD%2BCPzHf%2FzHP%2F3TP%2F3f%2FXz%2B5V%2F%2Bheeyv3P3t5%2BP%2F%2BxnP%2FsOd7jD%2B9%2F%2F%2Fv2lORz7mfrbv%2F3bd7%2F73T%2F5yU8ejtPXKWqkyp%2F73OfWnsOx8dKXvlQF3%2FnOd%2B6cu8OTA%2FuVr3zluxFeNMFnPvOZ7yaHHQsP8qsS%2F%2FVf%2F%2FXjH%2F%2F45z%2F%2F%2BX2e8oUvfMHRf%2F7nf%2F72t7%2B9zwTt%2FM%2F%2F%2FM%2Ff%2BI3fONGJTvTWt751aJA1%2FvEf%2F%2FFv%2F%2FZv99n9oP7Qhz7093%2F%2F91%2F72tf2Rw%2F2f%2F%2F3f9%2FnUX1PDnsv2C996UuUh%2F1lq3f9zd%2F8zSc%2B8Yn%2F%2Bq%2F%2F2sn2m9%2F85sc%2B9jEnfvWrX51DavTzP%2F%2Fz5zvf%2Bf7u7%2F5uJ3FfIxCBCEQgAhGIQAQiEIEIHDUE%2BC%2FnPve5f%2FSQz0lPetJTn%2FrUpzrVqearv7e%2B9a15XofDktvf%2FvbHOMYxXv3qVx%2BOc%2Fd3Ckv%2Bz%2F%2F5P6z667%2F%2B6%2F2lOZj9vLZznOMc17zmNQ%2FgLR5qPg960INU8HnPe95OSn79Fa94RQzBPMlJTuLv6U53uqtf%2Feqve93rDocWQUm49KUvfdGLXvRTn%2FrUTkEH%2FxU3VnGc93q4B8iEOvErv%2FIr%2BsP1r3%2F9L37xizspebj8WUdvdrObff3rX985%2BsPwVWsSDbQLFPur73Of%2B9zjHve4973vfUd5ePOb33yFK1zhxCc%2B8Y%2F8yI%2Bc9rSnvc997kPjmnNt3O9%2B98PToROe8ITnOc95nLsViDTBy1%2F%2BctfjJS95SfLRTolkyKc%2F%2Fek%2F9VM%2FJQE1Yx2Vwx%2F8wR%2Bc85znPMEJTiDbn%2FiJn9BjVz%2F8t3%2F7tzvf%2Bc6nPOUpj3e847msrnWta33kIx9Z577pTW%2B6xCUuwdqxhzI5J37gAx9w%2BVz%2B8pffn8C1cmgjAhGIQAQiEIEIRCACEYjAkUHA29g73elOvNGb3%2FzmV7nKVfjmJzvZyX72Z3%2BWl3rTm970cY973NYtOngD5Ml1es1rXnPwpxxqSv44n5oDyJM61MQHSOCFOGXgNre5zf7eax%2Fg3HXooQ996HGOc5w%2F%2BZM%2FWXtmQwDG%2Bc9%2Ffoeuc53rYIjqpS51KVRpGn%2F8x3%2B8k%2FhQv%2FJer33ta1%2FjGtfY5%2Bv7Qz19EnBOL3ShC2mRw9SUXGBOMcuPf%2Fzj723Hd7%2F73ac5zWkc5fz%2BcOoY%2BNzlLncB9r3vfe8%2BG4LacN7znlfbjVz2D%2F%2FwD76SNVC9%2F%2F3v70T09CLnCoSgdfhKo3jgAx%2BopVyDFLDXv%2F71jjrdUSEQEvic6Uxn0oFXiYIlfu7nfo5WNkddIEIm1tE3vOENOp5Tfu3Xfu2ud70ree3kJz%2F5O97xDgnYT0hxFkXi13%2F9129yk5vY%2Fpmf%2BRnihqPCQs5%2B9rMf61jH%2BsVf%2FEUn0jpczq997WsnZ5qGQ24Oq6A2IhCBCEQgAhGIQAQiEIEIfE8I0Ac4LIIHvvGNb%2BwY4FUsV%2Fqzn%2F3s3nf6c0jkwDYufXQMjpj0Dm1f6HOgCAiTjyD5nRNXud72OrR1yvanY0zKfTrpfEBCzTjaEshB%2FopW7gqVnxJVWcqtncsSp%2B89dGAdwxtwp0wOqvCHf%2FiHXmpf%2FOIX5ySipPqgKVEFx6RJifBOlSUzKcDHxrJngEu83TlHVU0b%2BYBsjwRK52h7y3%2B9613vy1%2F%2B8raNVJaRe9t6spLDbW972%2FGOObOT4RyS7YMf%2FOA5RGbZ6hj7ZLXIM8Y0GdWZfLZ%2FNQdjWLjdOdtwOWtCF%2BSA2Lbivjq6bTgQnDI1VZajC7KdCGv9vaXI2aHx4ueoUpw45%2FrLvGW5fNRU3Yk84hacuzVpTufmk7Ne%2FOIXz9dnPOMZiN373veelGKKKHIXvvCF5UloojBc5CIXETYziZ%2F2tKdJLFjCVyEfZzvb2YRAUBfPfOYz7%2BgY8lfKZS5zGRIEY25wgxswZjJhpLAosRaveMUrZs%2BznvUs2dI0fDU5S7akFXOsfNVGmltiy1%2F4%2BpCHPETK3%2Fqt35oTX%2FCCF%2BhChE1g7UFJic7FdhL0NwIRiEAEIhCBCEQgAhGIwPeEAB2DP%2BUN8k6o%2FAc%2F%2BEFe0ukP%2BVz1qlfdLgrxtre9jTfnCKfsJ3%2FyJzlB46ZxwfhEd7zjHcV4OPRjP%2FZjv%2FqrvzpeqtPFq%2F%2FCL%2FwCJ%2BsMh3yudKUrbV9qW1XDa%2BUznvGMTpRSzMM40XzJnXiM973vfWaayENK77Kf%2F%2FznjzyCHm%2Fud3%2F3d73Fdkg4%2FT3ucQ8TNO51r3sxzytsEfi3u93txuPjwVl2QyS%2FAIOznOUsFJjx7GTCd374wx%2FOhRTw75A36QL4p2kOrGOc61znWjqG9HgyT%2F5WFRDYz54b3vCGZA0zet7ylrdIILzBPJepCDutDDkYuec805%2F%2B6Z%2FmYk%2B5QFEkpi201F%2F91V%2FNfn%2B9Z7%2Fa1a7m1bwPpGYxqNov%2FdIvcX69PTdBwBt2nqyUQgVU334m%2FfiP%2FziPe6tFTIZbHYPDu51xwHvlgPNzfZaOoXEf8YhHqPiw4hTPMiYqIsaAk%2F6whz3sspe9rOZghgCDNamHDCUwQBFO9FeQw6qs1vzTP%2F1ToNRIF9KprnzlK9%2FqVreajsRCLS60ZhrOUQIO4zn%2BiKGkjrx%2BrCDly5vlxPtmwFnPelZNuUQDFoo0kMYh6QXqTMADene7292UznIxNmO5Ouo20vPihSgc85jH1FEpDGuGyNBjobJ0qrXC56Mf%2FWi4%2FuiP%2FmgSqP4FL3hBeheYVs%2FQu0gHc8hfKgdR4pa3vCUCsnrjG99ItlKuyxCKbTyGbdcgecGaG0RItZ5eLROnuCS175oAot3VnVDpUpKnhTus8bIKdaGxkJ2OXve615WbxTHmKBvMiGHk2uO6oJ%2BY6rJObyMCEYhABCIQgQhEIAIRiMBRT2DpGNt39FaT4HfP21jTT2zwVWeVPz41J1GovKB03p9D4uEn%2BNzSCt%2Fxco9xDA4vd5IPaPvJT36ySgnS4ED5KjF3kn9kmxc%2FzilpQv728MhoHac4xSnoId5lO3FHx%2BDr0QGk5OkrgjPLkmc%2B85nDzRvtYx%2F72DxNL5HNfZDMx0QPTqtqCra3igWPz9dHPvKRDhErqC7cf9v0AQKC19lTC56sjctd7nIO0R%2FGAT%2BwjsF5HJ96jKFjyISFllt80pOeJB8fLvAFLnCBv%2FiLv2CP0u250Y1uxP3nzquy5RGcK3KAB827H3f4Pe95D3eeh2u6Ct%2FZKZqGcyolZ1ZbOFEdb3GLW6i4fLSFdSY5s1BMpI3lSXEmPjiXs%2F%2FLv%2FzLzrL92Mc%2BFoqxdv5SCaZNVZypT3jCE9ZRE2S0NS1I62Crdlx%2BMoJ8VNM0hGlT8x2GlXZ0yMcUGxXEga7ye7%2F3ezKUQK0dInE4kWxlW7kydPTP%2F%2FzPdRKl6wySqbijMp%2FoCzqVPeBwxokGDimRR09rog%2F4yuUnBVDebGtxTMgphJ05SqMYl19Ig1JYRVjDRGKevpADR2984xv76qNQWUGqj73sZS8b%2BQ5Sh1RZVtN7FyKd0wwOaoBeNDtf9apXzZXCeOoEUcVVMPQA3AZ7SE9ugoiksDK0QWtymezoGCuBvqE5tjqGjkFs0aWnFaTUJ%2BlpILBWiAUhghyxcnBh6j%2BiNaRnmGt2qXYjarH%2FL%2F%2FyLye9xOCTB3e6zcqtjQhEIAIRiEAEIhCBCEQgAkcBgb06Bi%2BMq8ulWu%2BRTZHgV5pQz8vjP3J8LDA4URCiCPjLfDdOKJ%2FUWf5OKL44DV4PV5RDNC%2BCeVjzayZcdTEbnD6un3y8bXcit1pKVfaqmuNGr%2FD2nDETjyE%2BxFHaBS%2BSOzwp3%2F72t4tnEIAhJVfxYhe7mAgEb%2BH5WU4UIS9bDr6vTufxcW%2BdaB0Ajjyt4KMf%2FajiuIrjuvJVuZb8biVOeADXj74hzwmBOLCO4c21nNXdRz6kEqD4wjxEYg6zUZrfj1CiKkO6MIrQ4FPTf5TrdBszSwVwsg9nXBDCdIbHP%2F7xThRNMR63ItZijGQQpRAE1B1nXjwFRlkIi4fhwPpKF5LPu971LksxwKWsyXb%2BgqM4J%2FKpvcSnM8x0DEIBCYXj%2F5znPMd%2BnrV8hF5QEsTGDCuTRAROcISniekD7BQMMw71S17yEl9JMYzRFiqI%2BRwSnEDbIU1Yflal6GNazUqVjJGYCKP6BCjBD2Jm6F160QSKqJoaOarDzCGRFRPkQyUwLwYNetdEJiiU5VaoGMKECGFIOKg4XDQrhYoDUSIDdOnHPOYxM5%2BCgMNyCRxSayVaHlN7OWvHnaeN6PBmkSykzCChyFln05HMM9Ks29CmldJKGkI1RK1Mn1z7ITpMOgbRjOo1UslkovrUJ1eTDYqfurjQVv4myLBZPJLqMFV3Gk1SAhcU7Pa4yiY97GQci8Co18qhjQhEIAIRiEAEIhCBCEQgAkcxgb06hhB9fiVvyM9tmPvgnS8HjS%2FPDRdhzpH0elf4%2BtjJexWBz2fncJkdwCdy1hyaV%2BR8W94fd4m%2FSVJYtZOYm8kD5cbKUzT%2B%2BJuTwIQUR7lUnFmuKO9YCL34BDEPvO95NT8pySaTktTA0d6%2BDbeH2XznpWOYgsEbpc84xWSBZQzXUj4iAagx%2FFweOnfYy3Tet9f0FAD2S3wAHYODzF0FTV18ONSK4CnLxIlPfOITfRVLMCXCKBjDpIbtxATakTRssHN0DJaYQSArzWH%2FtAW3ms4gCIEkQjlBY0UFwGIJC0EU1ACKimS0gglyQI9OIiuihxPF3gi5keEcHav8HR1jlAFNoEYziUBKopPIHFjkw7OWAzd%2FLyvnijaRlXiMHRdYYIPWcaIPnUf3kO0rX%2FnKF77wheorW0bqezzurcCie5A4Rg4imjFJ8IkTBQkw5gEPeABov%2FM7v6PJINXZZD7VIXZJvJijSjojZVBLOObgSKzdJx%2BuvXzMhQEENN2GIDD5kGVUiryjF%2Bk8jpKA9qlFPPWpT9VVtithQiTUQc56puo7StCg3U3O66%2BriW2SUW%2FWztk4rDqGoCm9gri0pIbRMWamye%2F%2F%2Fu8rZWvh6Bj6nuK0tSaj8NiwX5iHxEC5A4wxLkDzksTqbFcU2TG4rxGIQAQiEIEIRCACEYhABI5sAnt1DI7bTAnhxWw%2FgtW5b%2FxN3ujykrbmzTqf63cu5tddOWijY3gZPc6gU7iEoz%2FwKHmv%2FH1ixdan5oRy%2BrhdS8fgoDGMTkKX4KevckciEDFCPxEDP%2BsZzlHeLi9sR8fw3pmnr14yX5lsN0grM2Fh1Z0bK55EmgPrGAwWy4GSD29X%2BAqtYHIeI5%2FylKfMVxg5jAIn1G4V%2FahHPUqJAiHoEqNjmBEgYMDb%2F2XJ2rBmAjtxE2GypbFyU%2FToGODbqdbqy5ueHFiIsJ0r%2FWyMjjH6g2kUJkEQTOTv50GxFRoh25kZIRbCKZrPL18sq2xQAEbzoWOYhzIrgUjpVb4Ym9ExfBWrQ6zYnihCRk%2BQvzf%2BIiJWZ5gVL%2BkYhIgJa9meNdvaRWejY4gfWEu5oi2sYmYnKVHkydIxKCcO7c3HJCMdW%2BnQMcZZPi6Q6bpEDHxGx5if%2F5gE6y%2Fz9AHrrqw9dAkFEV4oV3x%2FGpSgINERSwaUkmESMAbkvS1yWHUMcR1kNLEfS8%2BhY5hkJGaJAdZpYc926sroGERFlugqflRlkaFc6XtEJHlOjWhKRBL629b%2BVdk2IhCBCEQgAhGIQAQiEIEIHDUE9uoY9hAreENC603N4IP72DC1YQ6tZR63FpImDlXHMJlCMmdtdQweLtnEGgUTxj95Ko4%2FJXhgq2NwLflWJqqMbz4p5026%2BQ6kBo4zeWRZ5T3yXh1Dhl7fcxvN6Vgp14Y4Af61swSZ8MEpIeZTsORQdQzv2U0K4K5%2BJ9rgG99QysrTBoBKXDqGQAIe4kgEK5n4EGnEiqx4DDoGAzjRMlfNbVuIkFE7zSSoYLn8KysbOzrGHOKQsoH0QQBRFqVlx05faU0kC%2B4tj17mFrSkOfCCxYRwhIW4cNVNW9BYWIlCoSyREYgq7JnlLA6sY7BWQAjNgQ2wEASoOmQK4QraVxSEShG1VhOLAZh4jKVjiBOw3sh3OuUh3RIWkot1IfapY6xWXjqGyA3zR4RqKHSLVG4kOLWmVBw%2BHWPiMbTU0FZTESxkH2RW05B3NOggslOaWY%2BFhKLbrGRr47DqGObXmJ8iYgquyWSaiZQhKzOnXCOCWFb%2B4oV077WHPX62FRaXnv4vNobktSYfTTwG5UpPWDm0EYEIRCACEYhABCIQgQhE4CgmsFfH4K3MVP3tT1WOX2nmgp%2BKMAeEMzh2co6sWSEu3Ya3ukIXDhCPsVfH4P%2FKk5%2FuPa9X6pMnlcMMFP4y%2F5RnPetjcMy9WbZcoakBLFwphVvwSeUzLrCX3csf5D%2FuzCvhwnurzpvjxd%2FznvdcqMkU5krYz3fj1hFk1iHrgcj%2FYHQMyo95FuvE7QYfeatjiDFQZUsleME9yVRTGIAq8%2BuXjuEoJuprJczlS0o%2FwTDK4mb6rEyQpEJYhtFrfeErnHHrnY5SYYFHIsOaDSGyxYly5uRu7Vw6xtR3fglU02Ayv6xBgaFy0DE4vFZ95BRzzFcOFliw58A6huiOmddDKVonWnKBrGEJi2li03NWpax8Yi7DzCsREKItJnhgnTvNffA6BqQ46BgmX6xMbIyMpp8fqo4hrIW%2BtD13tplH1VkRQbKymIbwEl13Jda1hLvMGhS6IvFqInNWp10pZ%2BOw6hiTnjC4ft9kZldRVBRnQV1KoIrbnvxnBdql9mxLn0NEqtEeHZr1MfYXjrU9t%2B0IRCACEYhABCIQgQhEIAJHHoG9OgZn1vKM%2FG7viOkVPCzevZh8AfM8Gv6sQ8Ie%2BNFmQIyzQ6DgOx8OHUPOqjZrCFgZkkDBi%2Fdem5vptS%2Fnndu71vmU0goGUzr3Vkr%2BFzdQ2ICU%2FFCBIjxuYSS8YKERsyjozjqf7OS800P4el5Gq52VFa2bwUHmmPPTSTEi5y1iwBjRC%2BY78M0PUsfYkQVWq%2B3oGAhbDVJF7n73uzMVRss4EDEEpZgWQY6YeSVyY61WkNIvg7DHbA4LZdBqTBLRFsPND5hy%2F70in5kp4%2BZzOYV8IIMDUBZ4lAn9gb8PFEde9IIoi50JAkvH8FKe8XxhYRhOFCPBHbZnq2PMKhOm0lBFKDNMokWgd6g6Br0IbY3FSKqCaT5M1Ry6okphokR8hBb4WPFDm6qIWgDCHvqM%2BRpanM4GLAMIVrM%2Bxt55JctDl37mlSAAsm3AdRXc8Bd%2BYFFT8gLgB9AxmKerEyv8XCl0OudqYhs4UAloMjLxVWI%2FHKMutAvdVdv5vRKTa4SXAEtJMOdFVmIndDZXkzTzmTk7k%2FNh1TGcpVIK1Tf0CiE9LitfxSA5pKYuK7TpWkoBnCxppVyWT3G0F0E7%2BpgL3JXlMlnqogR6hfYlAKrapO9vBCIQgQhEIAIRiEAEIhCBo54AH0roOwdw%2B0aYyzw%2FRultsrf2PD4uqlUFmMdjFdXAM%2BJ4mnRggyM5iyKOx8RZm1pYyNG53EPOEQ9ISi%2BFxwPyd9zz8Xn5p95cS6AUL99tiG1485vfLB%2Fn8r55XrM6Iq%2Ff0hASmIYwa2lKyaGeEsVvjElsc4pkPn46U3E8NS6YJQoFEkhsJUO%2Bud9uYKFlG21YnYBbCoL0zsKEMTbU3d%2F5RdQHPehBti0yMMWtv5gIrmAPP3Tt3G6Ya%2BBEms%2FaKaUIEDuZMRUR5TI%2Fn8GzHh1DK0jP52W2lOwRGsHUUWAc4mNahtQhYgtv1AZ%2Ff17Ec9u1qT1akLPvKz3HV462KnOf1WuEqWWSDTqG2S6S8WR9xY1I4qtzNYQ9VsX0lQggJoSLPXkSnYYVz9fR%2BZmbwThrfjpRD8FZp5IP%2F5pwJKU91ouwwRifiVIgKwkHslOec9S2Esk78rEuqMrao6Fng6rD9YYCAbMn1voYo7CtdT4R0BXRnh8EgdrP18rHWcNfhAyVhgRh3grlZAVR6HiSwTIxDDL01dwQEy5W0AjDfCgtjBHoMg1nD6tEkkivu2o76o1%2BJcoFWIeEnTgkvsVRldVSPhrXRJ5D8vvOH5C1KVD0qLVzbWgOvVpH2ioqSp9C8SGbKAI9gsac5fKcDq8bUIQcpXto9zmq1i4oO30ssiF%2BY5Vlw3K%2BrNUE251tRyACEYhABCIQgQhEIAIROIoJeOVtdjzvbBzVVTpvmp9r1ga3TnjG1qPhh1rS0OKEpipwbcZr45p5ySvlWiBRMmHzMiEdePPuDTs1YJxBf6kiXuxaKmFK5GaygfNrbQRBFyOMOORcL9%2B9lKcVTEr%2BrJfLDNtJOUfNTZAtm73WH9nB72%2ByjZGqKb5ieXxe4rNWsIeZEX6XZPQNmXjLL0LALA8VtHyoN%2B%2ByMllGJv6qBW93ylp%2FsfrN3%2FxNlVWLtXO7QcYx3WOCT9Z%2BBQ1hGo6lKjjjc8j%2BrY5hp1f%2FvHKuNGvve9%2F7eo2%2BMnHIL7ESiEQRiMfYTmxhp0kxMvdDtNJz8AUnmHgiYEB0h1iU5b2u3Oyh0niVv%2Fibn6LKa1lL2hRo1gidWRhMtZaCorHinltBRTVpINoXNyeu9iVVaQ5ppptpDrEK%2Bg97xEL4gLz0KD6%2BPqCy5itZlJLvTwChEoydfG1NqfXVWt3HQ8fBRBXdcibdSEkHYwxpa84SfoC2WRLLoyf4OIUNlkB5%2BMMfrlApdQ%2F9jag1vdoeFwgxxwIsg4sZugeqGkJ4w2S%2B%2FjpEkLH%2BxtqjRezUXU3HuNvd7raEHfuVzkIV99Mw6%2BPrio6Qieq4LqBbZq%2BcbWgOK3OK65gIkHWIzTJnpKpNeNI6ZENrKk43sNKsZlo93yFhLbqipiF3bIVNh%2FRtyhi1Z5%2BWbPNvOwIRiEAEIhCBCEQgAhGIwPeWwCgP%2B7ThAIf2mf5gdh58nntTclq50qsU%2FqPXyhy6tWfvxt5MJg3VYm%2FiI2nPjg28Y%2BtnCvDYie5g0k7KZY9DPuvrdmPvKXvli236w7G9v6IPNau9tjmFhEI%2BWo65t%2F%2BiI4hRa89k69zDXe7WsMOXz%2F7OIiyYrSPaRBzFthTbRzj2nfz3ft2fkZPysNojzIZEs40p2ltieyIQgQhEIAIRiEAEIhCBCETg4Anwc61dQLgQZi9KxBKXpjlYBWJNEDj4rL5XKbmWYicEIViRYztF4ntlz%2FekXJEtGtFUGgIU%2BcJEGHMuZr2O74k9h7VQP65q8oUwiQlZOaynHw3T04tMrnEpmeEi9OhoaGEmRSACEYhABCIQgQhEIAIR%2BD4lICre%2BoSWa%2BAI%2B3gtviZEfF%2FUiMNokgvLLZLwohe96PvC5iPcSJ6ytWQtZHFIGx7DQpSmwBzhpRx5GRKjyGhWulhTWo68so6anM21MW%2FIXKc19emoKbdSIhCBCEQgAhGIQAQiEIEI%2FJAQoGb4cQ0LO6ylEr6PKm7Rhte97nU5jNbTEAMglmbvGhRH%2F9bk%2BIurUYWjv6kHY6H5KeJh9rnQ6MGcXpoIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAt8lgf8XwAklVQplbmRzdHJlYW0KZW5kb2JqCjExIDAgb2JqCjw8IC9UeXBlIC9YT2JqZWN0IC9TdWJ0eXBlIC9JbWFnZSAvV2lkdGggMTQzMiAvSGVpZ2h0IDk5OCAvQ29sb3JTcGFjZSAvRGV2aWNlR3JheQovSW50ZXJwb2xhdGUgdHJ1ZSAvQml0c1BlckNvbXBvbmVudCA4IC9MZW5ndGggNjI1NSAvRmlsdGVyIC9GbGF0ZURlY29kZSA%2BPgpzdHJlYW0KeAHt0DEBAAAAwqD%2BqWcJT4hAYcCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMDAa2AtxAdXCmVuZHN0cmVhbQplbmRvYmoKOSAwIG9iago8PCAvVHlwZSAvRXh0R1N0YXRlIC9BQVBMOkFBIGZhbHNlID4%2BCmVuZG9iagoxMCAwIG9iago8PCAvVHlwZSAvRXh0R1N0YXRlIC9BQVBMOkFBIHRydWUgPj4KZW5kb2JqCjEyIDAgb2JqCjw8IC9OIDMgL0FsdGVybmF0ZSAvRGV2aWNlUkdCIC9MZW5ndGggMjYxMiAvRmlsdGVyIC9GbGF0ZURlY29kZSA%2BPgpzdHJlYW0KeAGdlndUU9kWh8%2B9N73QEiIgJfQaegkg0jtIFQRRiUmAUAKGhCZ2RAVGFBEpVmRUwAFHhyJjRRQLg4Ji1wnyEFDGwVFEReXdjGsJ7601896a%2FcdZ39nnt9fZZ%2B9917oAUPyCBMJ0WAGANKFYFO7rwVwSE8vE9wIYEAEOWAHA4WZmBEf4RALU%2FL09mZmoSMaz9u4ugGS72yy%2FUCZz1v9%2FkSI3QyQGAApF1TY8fiYX5QKUU7PFGTL%2FBMr0lSkyhjEyFqEJoqwi48SvbPan5iu7yZiXJuShGlnOGbw0noy7UN6aJeGjjAShXJgl4GejfAdlvVRJmgDl9yjT0%2FicTAAwFJlfzOcmoWyJMkUUGe6J8gIACJTEObxyDov5OWieAHimZ%2BSKBIlJYqYR15hp5ejIZvrxs1P5YjErlMNN4Yh4TM%2F0tAyOMBeAr2%2BWRQElWW2ZaJHtrRzt7VnW5mj5v9nfHn5T%2FT3IevtV8Sbsz55BjJ5Z32zsrC%2B9FgD2JFqbHbO%2BlVUAtG0GQOXhrE%2FvIADyBQC03pzzHoZsXpLE4gwnC4vs7GxzAZ9rLivoN%2Fufgm%2FKv4Y595nL7vtWO6YXP4EjSRUzZUXlpqemS0TMzAwOl89k%2FfcQ%2F%2BPAOWnNycMsnJ%2FAF%2FGF6FVR6JQJhIlou4U8gViQLmQKhH%2FV4X8YNicHGX6daxRodV8AfYU5ULhJB8hvPQBDIwMkbj96An3rWxAxCsi%2BvGitka9zjzJ6%2Fuf6Hwtcim7hTEEiU%2Bb2DI9kciWiLBmj34RswQISkAd0oAo0gS4wAixgDRyAM3AD3iAAhIBIEAOWAy5IAmlABLJBPtgACkEx2AF2g2pwANSBetAEToI2cAZcBFfADXALDIBHQAqGwUswAd6BaQiC8BAVokGqkBakD5lC1hAbWgh5Q0FQOBQDxUOJkBCSQPnQJqgYKoOqoUNQPfQjdBq6CF2D%2BqAH0CA0Bv0BfYQRmALTYQ3YALaA2bA7HAhHwsvgRHgVnAcXwNvhSrgWPg63whfhG%2FAALIVfwpMIQMgIA9FGWAgb8URCkFgkAREha5EipAKpRZqQDqQbuY1IkXHkAwaHoWGYGBbGGeOHWYzhYlZh1mJKMNWYY5hWTBfmNmYQM4H5gqVi1bGmWCesP3YJNhGbjS3EVmCPYFuwl7ED2GHsOxwOx8AZ4hxwfrgYXDJuNa4Etw%2FXjLuA68MN4SbxeLwq3hTvgg%2FBc%2FBifCG%2BCn8cfx7fjx%2FGvyeQCVoEa4IPIZYgJGwkVBAaCOcI%2FYQRwjRRgahPdCKGEHnEXGIpsY7YQbxJHCZOkxRJhiQXUiQpmbSBVElqIl0mPSa9IZPJOmRHchhZQF5PriSfIF8lD5I%2FUJQoJhRPShxFQtlOOUq5QHlAeUOlUg2obtRYqpi6nVpPvUR9Sn0vR5Mzl%2FOX48mtk6uRa5Xrl3slT5TXl3eXXy6fJ18hf0r%2Bpvy4AlHBQMFTgaOwVqFG4bTCPYVJRZqilWKIYppiiWKD4jXFUSW8koGStxJPqUDpsNIlpSEaQtOledK4tE20Otpl2jAdRzek%2B9OT6cX0H%2Bi99AllJWVb5SjlHOUa5bPKUgbCMGD4M1IZpYyTjLuMj%2FM05rnP48%2FbNq9pXv%2B8KZX5Km4qfJUilWaVAZWPqkxVb9UU1Z2qbapP1DBqJmphatlq%2B9Uuq43Pp893ns%2BdXzT%2F5PyH6rC6iXq4%2Bmr1w%2Bo96pMamhq%2BGhkaVRqXNMY1GZpumsma5ZrnNMe0aFoLtQRa5VrntV4wlZnuzFRmJbOLOaGtru2nLdE%2BpN2rPa1jqLNYZ6NOs84TXZIuWzdBt1y3U3dCT0svWC9fr1HvoT5Rn62fpL9Hv1t%2FysDQINpgi0GbwaihiqG%2FYZ5ho%2BFjI6qRq9Eqo1qjO8Y4Y7ZxivE%2B41smsImdSZJJjclNU9jU3lRgus%2B0zwxr5mgmNKs1u8eisNxZWaxG1qA5wzzIfKN5m%2FkrCz2LWIudFt0WXyztLFMt6ywfWSlZBVhttOqw%2BsPaxJprXWN9x4Zq42Ozzqbd5rWtqS3fdr%2FtfTuaXbDdFrtOu8%2F2DvYi%2Byb7MQc9h3iHvQ732HR2KLuEfdUR6%2BjhuM7xjOMHJ3snsdNJp9%2BdWc4pzg3OowsMF%2FAX1C0YctFx4bgccpEuZC6MX3hwodRV25XjWuv6zE3Xjed2xG3E3dg92f24%2BysPSw%2BRR4vHlKeT5xrPC16Il69XkVevt5L3Yu9q76c%2BOj6JPo0%2BE752vqt9L%2Fhh%2FQL9dvrd89fw5%2FrX%2B08EOASsCegKpARGBFYHPgsyCRIFdQTDwQHBu4IfL9JfJFzUFgJC%2FEN2hTwJNQxdFfpzGC4sNKwm7Hm4VXh%2BeHcELWJFREPEu0iPyNLIR4uNFksWd0bJR8VF1UdNRXtFl0VLl1gsWbPkRoxajCCmPRYfGxV7JHZyqffS3UuH4%2BziCuPuLjNclrPs2nK15anLz66QX8FZcSoeGx8d3xD%2FiRPCqeVMrvRfuXflBNeTu4f7kufGK%2BeN8V34ZfyRBJeEsoTRRJfEXYljSa5JFUnjAk9BteB1sl%2FygeSplJCUoykzqdGpzWmEtPi000IlYYqwK10zPSe9L8M0ozBDuspp1e5VE6JA0ZFMKHNZZruYjv5M9UiMJJslg1kLs2qy3mdHZZ%2FKUcwR5vTkmuRuyx3J88n7fjVmNXd1Z752%2Fob8wTXuaw6thdauXNu5Tnddwbrh9b7rj20gbUjZ8MtGy41lG99uit7UUaBRsL5gaLPv5sZCuUJR4b0tzlsObMVsFWzt3WazrWrblyJe0fViy%2BKK4k8l3JLr31l9V%2FndzPaE7b2l9qX7d%2BB2CHfc3em681iZYlle2dCu4F2t5czyovK3u1fsvlZhW3FgD2mPZI%2B0MqiyvUqvakfVp%2Bqk6oEaj5rmvep7t%2B2d2sfb17%2FfbX%2FTAY0DxQc%2BHhQcvH%2FI91BrrUFtxWHc4azDz%2Bui6rq%2FZ39ff0TtSPGRz0eFR6XHwo911TvU1zeoN5Q2wo2SxrHjccdv%2FeD1Q3sTq%2BlQM6O5%2BAQ4ITnx4sf4H%2B%2BeDDzZeYp9qukn%2FZ%2F2ttBailqh1tzWibakNml7THvf6YDTnR3OHS0%2Fm%2F989Iz2mZqzymdLz5HOFZybOZ93fvJCxoXxi4kXhzpXdD66tOTSna6wrt7LgZevXvG5cqnbvfv8VZerZ645XTt9nX297Yb9jdYeu56WX%2Bx%2Baem172296XCz%2FZbjrY6%2BBX3n%2Bl37L972un3ljv%2BdGwOLBvruLr57%2F17cPel93v3RB6kPXj%2FMejj9aP1j7OOiJwpPKp6qP6391fjXZqm99Oyg12DPs4hnj4a4Qy%2F%2FlfmvT8MFz6nPK0a0RupHrUfPjPmM3Xqx9MXwy4yX0%2BOFvyn%2BtveV0auffnf7vWdiycTwa9HrmT9K3qi%2BOfrW9m3nZOjk03dp76anit6rvj%2F2gf2h%2B2P0x5Hp7E%2F4T5WfjT93fAn88ngmbWbm3%2FeE8%2FsKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqClsgL0lDQ0Jhc2VkIDEyIDAgUiBdCmVuZG9iagoyIDAgb2JqCjw8IC9UeXBlIC9QYWdlcyAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvQ291bnQgMSAvS2lkcyBbIDEgMCBSIF0gPj4KZW5kb2JqCjEzIDAgb2JqCjw8IC9UeXBlIC9DYXRhbG9nIC9QYWdlcyAyIDAgUiAvVmVyc2lvbiAvMS40ID4%2BCmVuZG9iago3IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UcnVlVHlwZSAvQmFzZUZvbnQgL0FBQUFBQytDYWxpYnJpIC9Gb250RGVzY3JpcHRvcgoxNCAwIFIgL1RvVW5pY29kZSAxNSAwIFIgL0ZpcnN0Q2hhciAzMyAvTGFzdENoYXIgNzYgL1dpZHRocyBbIDU0MyA0OTggNTI1CjUyNSAzOTEgMzM1IDIyNiA0NzkgNjYyIDU2NyA0MjMgMzQ5IDIyOSAzMDUgNTI3IDc5OSA1MTcgNzE1IDU3OSAyNTIgNTI1IDUyNQoyNjggMzg2IDI1MiA1MjUgNTI1IDQ3MSA0NTIgNTMzIDM5NSA1MjUgNDIwIDYxNSAyMjkgNDMzIDQ4OCA2NDYgMzA2IDUwNyA1MDcKNTA3IDUwNyA1MDcgXSA%2BPgplbmRvYmoKMTUgMCBvYmoKPDwgL0xlbmd0aCA0ODUgL0ZpbHRlciAvRmxhdGVEZWNvZGUgPj4Kc3RyZWFtCngBXZPNitswFEb3fgotp4shiqUkM2AMw5SBLPpD0z6AY8nB0NjGcRZ5%2B57vZjqFLs7i%2BEpX97Ot1ev%2B837oF7f6Po%2FtIS%2Bu64c058t4ndvsjvnUD8W6dKlvl3ezZ%2B25mYoVmw%2B3y5LP%2B6EbXVUVzq1%2BsOWyzDf38JLGY%2F6kZ9%2FmlOd%2BOLmHX68He3K4TtPvfM7D4nxR1y7ljnZfmulrc85uZVsf94l6v9we2fVvxc%2FblB0TsWN9H6kdU75MTZvnZjjlovK%2Brt7e6iIP6b9SDPcdx%2B59abmuK%2BH9pqyLqixR8H67kQYUvN%2BtpREF1KobdGsaVN2h4H3ppU8o0Mr2PqPgfexUbVDg3K30iAKLrVWLAgfZVAkFqs9anFFAbW%2BHAqrOgfACTVLCCQ7SVIFwgs47KeEEU2nIQDiB6qBAQEGrJylZBXutFVmD5Q2NqmQVxLcxyBosL6NSJaugVZSSVaAKGMgqUJuKrMHy7hQwkFUwlV5OIKtgDJ0bySrYq4PIYbC4lZJVoDqXBgaLrUpWnquVAkayChbr%2B0ayCtQ6kzXev69ebCSr8J73iZJVoHqTkawCtVZkjZaXT0OVrIIq75nf9O%2F%2FqD9WN%2BvjJrTXeeYS2PWz%2B6H%2Fvh%2Fyxw2dxkkNjD%2Fx2fi2CmVuZHN0cmVhbQplbmRvYmoKMTQgMCBvYmoKPDwgL1R5cGUgL0ZvbnREZXNjcmlwdG9yIC9Gb250TmFtZSAvQUFBQUFDK0NhbGlicmkgL0ZsYWdzIDQgL0ZvbnRCQm94IFstNTAzIC0zMTMgMTI0MCAxMDI2XQovSXRhbGljQW5nbGUgMCAvQXNjZW50IDk1MiAvRGVzY2VudCAtMjY5IC9DYXBIZWlnaHQgNjMyIC9TdGVtViAwIC9YSGVpZ2h0CjQ2NCAvQXZnV2lkdGggNTIxIC9NYXhXaWR0aCAxMzI4IC9Gb250RmlsZTIgMTYgMCBSID4%2BCmVuZG9iagoxNiAwIG9iago8PCAvTGVuZ3RoMSAyOTA0NCAvTGVuZ3RoIDE2MDgxIC9GaWx0ZXIgL0ZsYXRlRGVjb2RlID4%2BCnN0cmVhbQp4AdV9d3hcxd313Hu396It0kraXa20KqsuWcVFWlvFKm6yLVuyLVuy3Fn3Bi7YdBA4QGgxgQAJGBITvFo3gSlO4oSQxIQklFBCIG8SWpxAQkKV9J2Z2ZFlCG%2F%2B%2BJ7vefLJOjpn5s7M3vubmd%2BUOwvbtmxfSYxkP1FIaf%2F6vk2E%2FdS%2BAdrUv2NbgAVJuJEQ9eOrNq1ez8OFIHNkdeySVTw88WFCqg6tWdm3gofJ5%2BCqNYjgYakSnL1m%2FbaLebiWFnAstrE%2FeX3iHISXrO%2B7OPn55DWEAxv61q%2Fk6TfPp%2BFNW1Ymr0tdKO490ou%2Ff5Vkyqo%2Fz2O84hwP%2F%2FEthCXkKsJVNZgQmdhICbmGEEeVPIHF0Ouaior79HcPL7NO%2FidJ1bHoU%2B%2Ft%2BQUVL9w%2BsOqzT4f36%2F%2Biq0JQjxL4D%2FJpvzX8CiGGez%2F79NN79X9hn5S8yKhoUK9MnSf%2FVP4JqSF%2B%2Bekk%2F47UyK%2BQTvll8Evg3yb5RfALCD8P%2Fg341%2BBfgZ8CPwl%2BAvw46SQq%2BVVSCcwHlDG1AqH7gecBNbkIJUnEiPwSSZF%2FSBqBFcA24FZAjbRP4tr9KFEiAfnKY3qv1BYYkq8Q4nIhLhNivxD7hLhUiL1C7BFitxC7hLhEiIuF2CnEDiG2C7FNiK1CbBZikxAbhdggxHohYkJcJMQ6IdYKsUaI1UKsEmKlECuE6BdiuRB9QvQKsUyIpUL0CLFEiMVCLBKiW4guIRYKsUCITiHmCzFPiLlCdAgxR4jZQswSYqYQM4RoF6JNiFYhWoSYLkSzEE1CNArRIMQ0IaYKERWiXog6IaYIMVmISUJMFKJWiBohqoWoEmKCEJVCVAhRLkSZEKVClAhRLESREIVCRIQoECJfiDwhcoUIC5EjRLYQISGyhAgKERDCL0SmEBlCpAvhEyJNiFQhvEJ4hHAL4RIiRQinEA4h7ELYhLAKYRHCLIRJCKMQBiH0QuiE0AqhEUIthEoIRQhZCEkIkhTSqBAjQgwL8bkQnwnxqRCfCPGxEB8J8S8h%2FinEh0L8Q4i%2FC%2FGBEO8L8Tch%2FirEOSH%2BIsR7QrwrxDtCvC3EW0L8WYg%2FCfFHIf5HiD8I8aYQbwjxeyFeF%2BJ3QrwmxKtCvCLEy0L8VoiXhHhRiBeEeF6I3wjxayF%2BJcRzQvxSiGeFOCvEL4T4uRA%2FE%2BIZIX4qxNNC%2FESIHwtxRogfCfFDIX4gxGkhnhLiSSGeEOJxIU4J8ZgQjwoxJMRJIU4IcVyIY0IcFSIhxKAQcSGOCPGIEN8X4mEhDgvxPSG%2BK8RDQjwoxCEhHhDifiG%2BI8S3hbhPiHuFuEeIbwlxtxB3CfFNIe4U4qAQ3xDiDiFuF%2BI2IW4V4hYhvi7EzULcJMSNQnxNiANC3CDE9UIMCHGdENcKcY0QVwtxlRBXCnGFEJcLcZkQ%2B4XYJ8SlQuwVYo8Qu4XYJcQlQlwsxE4hdgixXYhtQmwVYosQm4XYJMRGITYIsV6ImBAXCbFOiLVCrBFitRCrhFgpxAoh%2BoVYLkSfEL1CLBNiqRA9QiwRYrEQi4ToFqJLiIVCLBCiU4j5QswTYq4Qc4SYLcQsIWYI0S5EmxCtQrQIMV2IZiGahGgUouEonS0PyVcmMuv8mDMnMl2gy3noskTmRIT289A%2BTpcmMk2I3MtDezjt5rSL0yWJjKlIcnEiowG0k9MOTtv5tW08tJXTFh65OZExDRk2cdrIaQNPsp5TjNNFifQmpFzHaS2nNZxWc1qVSG9EkpU8tIJTP6flnPo49XJaxmkpz9fDQ0s4Lea0iFM3py5OCzkt4NTJaT6neZzmcurgNIfTbE6zOM3kNINTO6e2hK8Vz9DKqSXha0NoOqfmhK8doaaEbwaokVMDp2n82lSeL8qpnuer4zSF02SechKniTx7LacaTtWcqjhN4IVVcqrgpZRzKuNUygsr4VTM8xVxKuQU4VTAKZ9THqdcXnSYUw4vM5tTiFMWLzrIKcDz%2BTllcsrglM7JxyktkTYLxkrl5E2kzUbIw8nNI12cUnikk5ODk51fs3Gy8kgLJzMnE79m5GTgpOfXdJy0nDSJ1Dn4dHUitQOk4qTwSJmHJE6EkTTKaYQlkYZ56HNOn3H6lF%2F7hIc%2B5vQRp39x%2BmfCO98%2FJH2Y8M4D%2FYOH%2Fs7pA07v82t%2F46G%2FcjrH6S%2F82nuc3uWR73B6m9NbnP7Mk%2FyJh%2F7IQ%2F%2FDQ3%2Fg9CanN%2Fi133N6nUf%2BjtNrnF7l9ApP8jIP%2FZbTSwnPQjzKiwnPAtALnJ7nkb%2Fh9GtOv%2BL0HE%2FyS07P8siznH7B6eecfsaTPMPppzzyaU4%2F4fRjTmc4%2FYin%2FCEP%2FYDTaU5P8WtPcnqCRz7O6RSnxzg9ymmIpzzJQyc4Hed0jNPRhLseD51IuBeDBjnFOR3h9Ain73N6mNNhTt9LuOH1pe%2FyUh7i9CC%2FdojTA5zu5%2FQdTt%2FmdB%2Bnezndwwv7Fi%2Flbk538Wvf5HQnp4OcvsEz3MFDt3O6jdOt%2FNotvJSvc7qZX7uJ042cvsbpAKcbeMrreWiA03WcruV0DaerE64%2BPPtVCddy0JWcrki4ViF0OafLEq5OhPYnXBhspH0JVxXoUk57efY9PN9uTrsSrhVIcgnPfjGnnZx2cNrOaRunrbzoLTz7Zk6bEq5%2BlLKRF7aBp1zPKcbpIk7rOK3l%2BdZwWs3vbBXPvpLTCp6yn9NyTn2cejkt47SUP3QPv7MlnBbzh17Ei%2B7mH9TFaSG%2F3QX8gzp5KfM5zeM0l1NHIiWKB5uTSKFmnZ1IoR12ViLlCtDMREoRaAZP0s6pLZGCiYTUykMtnKbzyOZEyqW41pRIuQbUmEjZB2pIpOwHTUs4mkFTOUU51XOqSzgwL5Cm8NDkhL0boUmcJibstB%2FVcqpJ2KcjVJ2wd4GqEvZFoAn8WiWnioS9EJHlPGVZwk4frDRhpw6phFMxz17EP6GQU4QXVsApnxeWxymXU5hTTsJOrZTNKcTLzOJlBnlhAV6Kn1Mmz5fBKZ2Tj1Map9SErQdlehO2pSBPwrYM5Obk4pTCycnJwTPYeQYbj7RysnAyczLxlEae0sAj9Zx0nLScNDylmqdU8UiFk8xJ4kSio9blfooRa79%2F2LrC%2Fzn0Z8CnwCeI%2BxhxHwH%2FAv4JfIj4fwB%2Fx7UPEH4f%2BBvwV%2BAc4v8CvIdr7yL8DvA28BbwZ8tq%2F58sa%2Fx%2FBP4H%2BAPwJuLeAP8eeB34HcKvgV8FXgFeBn5rvsj%2FkrnM%2FyL4BXPM%2F7w57P8N8GvoX5kj%2FueAXwLP4vpZxP3CvN7%2Fc%2BifQT8D%2FVPzOv%2FT5rX%2Bn5jX%2BH9sXu0%2Fg7w%2FQnk%2FBH4AREdP4%2B9TwJPAE6bN%2FsdNW%2FynTFv9j5m2%2BR8FhoCTiD8BHMe1Y7h2FHEJYBCIA0eMl%2FgfMe7yf9%2B4x%2F%2Bwca%2F%2FsPFS%2F%2FeA7wIPAQ8Ch4AHjEX%2B%2B8HfAb6NPPeB7zVe5L8H%2BlvQdwN3QX8TZd2Jsg6irG8g7g7gduA24FbgFuDryHczyrvJMMt%2Fo2G2%2F2uG1f4Dhgf8Nxge9F%2Bl5PivVGr8V0g1%2Fss793dednh%2F577OvZ2XHt7badwrGff69rbv3b338N5X90YdGsOezl2duw%2Fv6rykc2fnxYd3dj4mX01WyVdFJ3fuOLy9U7U9Zfu27cqH26XD26XG7VLpdrw42W7bHtiumLZ1buncenhLJ9kyZ8v%2BLfEtqknxLW9skckWyTA0evroFl9mMzi6Z4vZ1ry5c2PnpsMbOzesWt%2B5Dje4tmZ155rDqztX1azoXHl4RWd%2FzfLOvprezmU1PZ1LD%2Fd0LqlZ1Ln48KLO7pquzoVIv6Bmfmfn4fmd82o6Ouce7uicXTOrcxbiZ9a0d8443N7ZVtPS2Xq4pXN6TXNnEx6epNvSA%2BmKjd7ArHTcCfFJ00p9Ud8bvvd9KuKL%2B077FIc1zZ8m51tTpYbZqdLG1H2pN6YqVu8vvXLUm1%2FYbPX80vN7z988KmfUk1%2FcTNw2d8CtuOizuWfOp8921F3fyLlsAntWvzsUbra6JKvL75Kb%2FuaSriaKFJAkItlAig55jkkuf7PyBKLwsoxI0k1kfqR9SEfmtsd1cxbHpWvjOfPo32jHorjm2jjpXLS4a1CSvtY9KMkN8%2BMp7R2LePiqAwdIxrT2eMa8roRy770Z07rb4%2FupjkaZHqWaIEl3ZOnW7VsjXdEpxP6G%2FX274nrK9kubbLVKVuuoVY5acfNWi98i0z%2BjFiVqKatutpr9Zpn%2BGTUr7qgZMdSUuaY585utRr9R7qw3zjbKUWN9Q3PUWFTa%2FKXnPEqfk39yZNvSrRHIbRH2i1C3tJ0G8YMr%2BN26DWH6D4QwoVe%2B%2BocnQ7plW%2FHDiuHFf3WW%2Fw%2BuSP8f3ON%2F%2BS0OEnSRrqmj8pV4l3kFcDlwGbAf2AdcCuwF9gC7gV3AJcDFwE5gB7Ad2AZsBTYDm4CNwAZgPRADLgLWAWuBNcBqYBWwElgB9APLgT6gF1gGLAV6gCXAYmAR0A10AQuBBUAnMB%2BYB8wFOoA5wGxgFjATmAG0A21AK9ACTAeagSagEWgApgFTgShQD9QBU4DJwCRgIlAL1ADVQBUwAagEKoByoAwoBUqAYqAIKAQiQAGQD%2BQBuUAYyAGygRCQBQSBAOAHMoEMIB3wAWlAKuAFPIAbcAEpgBNwAHbABlgBC2AGTIARMAB6QAdoAQ2gBlRTR%2FFXAWRAAghZISFOGgGGgc%2BBz4BPgU%2BAj4GPgH8B%2FwQ%2BBP4B%2FB34AHgf%2BBvwV%2BAc8BfgPeBd4B3gbeAt4M%2FAn4A%2FAv8D%2FAF4E3gD%2BD3wOvA74DXgVeAV4GXgt8BLwIvAC8DzwG%2BAXwO%2FAp4Dfgk8C5wFfgH8HPgZ8AzwU%2BBp4CfAj4EzwI%2BAHwI%2FAE4DTwFPAk8AjwOngMeAR4Eh4CRwAjgOHAOOAglgEIgDR4BHgO8DDwOHge8B3wUeAh4EDgEPAPcD3wG%2BDdwH3AvcA3wLuBu4C%2FgmcCdwEPgGcAdwO3AbcCtwC%2FB14GbgJuBG4GvAAeAG4HpgALgOuBa4BrgauIqsmLpfuhLqCuBy4DJgP7APuBTYC%2BwBdgO7gEuAi4GdwA5gO7AN2ApsATYDm4CNwAZgPRADLgLWAWuBNcBqYBWwElgB9APLgT6gF1gGLAV6gCXAYmAR0A10AQuBBUAnMB%2BYB8wF5gCzgVnADKAdaANagRZgOtAMNAGNQANZ8V%2Fupv%2Fbb6%2F7v%2F0G%2F8vvj9Bp2djEjN6sd9lSHHjSfouQkVvGH4Aic8g6spXsx7%2BryQFyC3mKvEqWkyugDpJ7ySHyXRInPyDPkJcuyPV%2FGRi5RL2emJSTREOchIx%2BOnpu5BAwpLaMi7kFIacqcD5m1Db61y%2FE%2FXXkllHbyJDGQQwsr1n%2BNUr7hzQ8%2BimGXA0xj1bRsHwNtJV90gfab40cGXnwggeYQzrIIrKYLCE9OIXWh%2BdfQdaQtbDMRSRG1pMNLLQB11ZDr0JoGVLBvTB9PtVGsolsJFvINrKd7MC%2FTdBbkyF6bTMLbyc78e9icgnZRXaTPWRv8u9OFrMHV3ax2Itx5VKyDzVzGbmcKcE85gpyJbkKtXYNuZZchxr76tB1Y6kGyPXkBtTz18iN5Kv0gQuu3ERuIjeTr6M93EpuI7eTb6BdfJPc9YXYO1j8neRb5B60GZrjNsTcw9Tt5A7yOPkJOU4eIUfICWbLftiWW0TYZRWz9CbYYA%2Be%2BYpxd8ytuXPMWpfCGvS5B5LPfTHsd%2Fm4HDuSdqTWuwIpqXUGkvVAS9mbjBGWuAlPxvX556Q2os9w4wXPKXL8p1j6xNROd8FewjLUZrcj7s4vxY5PMV7fTu5GD7wPf6lVqfo2NFf3MD0%2B%2Fltjae9l175D7icPoC4eJFQJ5jGHEPcgeQh9%2B3vkMHkY%2F87r8YpffYR8n9VcnAySBDlKjqEmT5CTZIjF%2F2%2FXjsB3fDHP0WRZibFSHiWPkVNoIU%2BS0%2FA0P8Q%2FEfME4p5Kxp5hqXj4h%2BRH5AxLRa%2F%2BEG3raXion5Gfk1%2BQX5IfI%2FQs%2B%2FtThJ4jvya%2FIS9JZqhfkXfwd5g8p%2F4jsZCpWP4%2Fhtq4iyzFv%2F%2BHP%2Bo04iL3jn48unP0Y6WFrJLmYwL5MGrpGLkBOxMbzn%2B05CcG1R9ICjk2%2Bi9lCThv%2BBX1mpFvj%2F4tuujqq7Zt3bJ508YN62MXrVu7ZvWqlSuWL1vas2Txou6uzvnz5nbMmT1r5oz2ttaW6c1NjQ3Tpkbr66ZMnjSxtqa6akJJcVFhXjgnO5Tl96bYbVaz0aDXaTVqlYL5eWFTqLk3EA%2F3xlXhUEtLEQ2H%2BhDRNy6iNx5AVPOFaeIBmq8Ply5IGUXKVV9IGeUpo2MpJVtgMplcVBhoCgXiZxtDgSFpUUcX9IHGUHcgfo7pmUyrwixgRiAYRI5Ak3dNYyAu9Qaa4s071gw09TYWFUqDRkNDqGGloaiQDBqMkEaoeF5o06CUVycxIec1TRyUic5MPzau5DT1rYjP6ehqavQFg90sjjSwsuKahriWlRVYG8c9k%2BsDg4WnB24YspHlvRHTitCKviVdcaUPmQaUpoGBa%2BL2SDw%2F1BjP3%2FVHLwy4Ml4YamyKR0K4sfa5Yx8gxdU5tlBg4J8ENx869xfc9biYvmSMJsf2T0Iv0kccM1Nc6hOa4N5wh3i%2BYJDey%2FVDUbIcgfj%2Bji4eDpDlvgSJlkS643IvvXJaXHF10iv7xZWx7L0hWLYp1NSb%2FN2xxhvfvzxQVIiaZb85cVUOrgfiSrh3ef8ayn0rB0KNeELYkszvikcbIaJ9SWM2DZaWIH1fLx5iLTVDR1e8JLQpnhKaxq2NCBSS07R2XhfLwmOb4ikNcdLbn8wVL2lCXjSRpgFaMfQGaVmhjq5HScXoG4OVAd%2FRClJJuul9xN0NqJRw00DXilVxf69vBdrnqkCXLxiPdsN83aGuld20lkK2eP4b%2BDj8oAJZLjzbF1KLxHjsuDZHF%2BiSfUo3rS1EBJrxJzRtMi7Y4hoepDU6bXKgS%2FIRkQyfkkxB1QXlIKDkNLQgMxhZG1p8QTRu9vO%2F3JKPPwBuI64buycVbkJ9%2Fp7453zlrfHU9IbyA00rG8fd4AWFIsBuMFnav79PmdoiaQzcgo5WZwt9hqJCGTqAy7q4jOdkUbQWvYE4mRPoCq0MdYfQhqJzumjlUFuz%2Bm2fF6Lbq6y2k61k%2FgUhfr2GX4uTYPv8LhGgO0%2Fx5girV1qtLDydhceCLV%2B43Couw%2B%2BQOQMDKwaJkkObsm9QYkLdcH13fHakOxRfHgkF6X0WFQ7qiCk4v7cBvbcZnjPU3BcK2ALNA31Do%2FuXDwxGowObmnrXTES%2FGAi1rhgIzeuajMpljmCvbxe9Fwdpl9rnT0NRMpk2GJKu7RiMStfOW9T1qI2QwLXzuxIy9pp7p3UPZuNa16MBQqIsVqaxNJImCdAALWkuAjqW3vdolJD97KqKRbBw%2F5BEWBxPhDiJ9A%2FJPM7G0g2G2QdF8d2J%2FiEVvxIVJagQp%2BNx%2B3nqvGRqHa7Y6JXHCAYSbP7hnvkP3wmMGtRRXVQfNclmGSalVZJAzGNIq5fIUZNklnyDKBNPgGi8kh7UR32PspJ41GPSfqSkcftRejKZTGiycQXhI%2FmDd4KST9C5qOuoiaB89hcpptEfuBDvGrQxDDRNgRW0%2Fe3pXjPQ2029B3GjreJXikuhOhKXQ3W4Y40pbgitnBY3hqbR%2BHoaX8%2FjNTReG5oWl9wSKnsITnegNwRHjD7Vhdcd3Wj%2BNtq95ZzA0Ojo%2FK7gWd%2B57iD6%2FBJgUVdcH8FAp85pQ7rpFL2Inh7f399H74N0wpdR19Pa343OLgpEkta4HiXokyUgRTPLQ%2FsbMvWjraFBsvz7EYjv7453R%2BiHdq2ldxQI2OKkJTQxrgnzMtVh%2BkEl3QOOUDntuUgaN%2BRcQ0mPeyPzuniMD0F8GEYU%2BkRaE%2B68P4RL%2Fb0BWB1tZB76Mh8sDLQdImYlfL4qvJLB4EteJPSxlByj2RDXF6NA%2FFJtLEaB%2BNV2wyj04VnommQCfLYtbsQdhceZMpkB1sGlVnov%2BL0GN0%2BT%2FoAW0zFE5oYuhu%2BnN80%2BSovLcXNOax9GN57fiJhQjciMsnQ5NIqWcYbHaumTm2B3uISh0QdDl1AXJ36KCkN09KPtj%2FgeRUcl3QNfjIgvjhQV6r4Ya2bRAwM687%2FPwO2lM48xLQUP0k%2BHNTBtcKy9BZroABtqG5RnIQVYYjzQFsKgJudQYKKjoPsEAyu6aSrc8hzmy0JflQhFjCWiwzQrfMA2ic5KaAjXWQgB%2FA7EV18YXDMWbMblZkwGc4oB9htGxVC%2Fv84Xj6Fl4jJLQmskMBCwhSaG6B88qoLeAPSinsa6BZo%2FWh3tNPv7A13L0dhhnubegeYBfEigvw%2FZaBtMflJ8Q%2BSCItEvJPRDGIRaIb5%2FTqC3O9CLqanU0RUM%2BtAbwYFVffFoqI8OBXPw%2BfidgyEJ1DdAmzjpxof64loMTKv6VoaCGHAQ183syuoHn867DfENDIQG4swRNCMxig%2Bj27VSwu%2BmSKhvJZ1C4%2FMCfStZ3mbcLrMOvT9fUwh9eSXultodz4Vvf5Hl9E%2F%2FQAil9fRGYAn7gGMgUDsAF9yD0UMV7l%2FQi6GKjkgBVtV9PoRg11Ya6kZBPKE%2BhybkXYDezfrIYI8253wM7YvxjRGeWMdKxZ3N7YrPEZlYf6KpNkfisqcGF3GncWkuPBvsT%2F0UjKfOaYV5o2h6Ppo7EJcxvPLqYflbaVa4Bl5hPBti2CDCuhgGSTHaiHFoiQ82%2Fcp4orIQgu16orqPhFSNpE%2F1F%2FKw8jZ5WFaBe8jD6gJgBulXZZGHVV3AAMlSXiBLVJXkoLKcLAL3Kp%2BRHnkzyVHOkAk0Hq8GrgIOalaQgzSsqmHpqO6Vf4Z8QdIhP0KCCN%2Bq3E2y1ENkgrKT5Cv5pFvOJqfwYuQOxUMkvNRqwv21AI8AW4CVwFw4D%2FZCGmzCXtVycJAU4hufduIjfpJBMnHdjO82mrCPlY71pIOkEg8xIJWM9CkkjWSTMMnBdyexa0YC%2BNphiHiJFl7YTbLwnltHckkByScRkody6c9T5CkpJqvky5RUJab8RLVZ9an6as10zY%2B1d%2BhadR%2Fqv2NYbvjIpDXdbQ6ZN1jWWk3Ww7ZFdpP9dsdep9WJU0qufe5M943uP3jMnimetd4i70c4gHqVbx4%2BjYxsVX6NHTkFd1BLZpJZ5I74VZGuxzEez8UNTZSOH3c1NuqKtE9KDXiAAPbbdXgV3xC1qmTzybS0%2BtDJCZoDir11SCo6Vq89gDdJ9cOvDz9bMvz6OUdtyTmp5Hdvvv6m7YNn7bUlFW8%2B%2F2YZThakpJlPxpB1QuhkbIKiORBT7PU0f1Qfq4%2FK2gMxFOKtj6Q9G3m2JPJsBMVESsu6JXvQzpBikbXaFE0oq1iekBuuqqgor5MnVIZDWRaZxVVWVdcpFeWZsoKUPKZOpmFJ%2BfXni5TZwxr50lD9ggp1Zpo1xaxRy%2BleR9HkHNu8xTmTizO0ilajqHXavOppWe2xpqxXtPYMlzvDodM5MtyuDLt2%2BFW15dO%2Fqy2fNahin92qaCYtqc9WvmHQySqNZijTm1owKdi6wOq0qYxOm92t0zrsprzGJcNXu9JpGekuFy9reCbMGRr9VHWpOgX1HiavUbs%2FSrJH3z5mskkzQkNJER4aff%2BYETFGIXBm5P1oGo3KsdG%2FZvbXxP5G86QcernQKM3MDoVzPjQZTd6sjJDBLLlVJmKymeQjoadCvwwpIVPI5MiY6%2BhUd5L6%2BnpHbW1JSU%2BP3VNrh7RX2M6V2yvKSqVID99Cx0EDXzQTRZpyPoyNL3N8OV5R0FgxEZSCystxuzWsxnKVoGJRQlnhcFW1xKvJow0pQdV2nWTL8ftznHrVxuE%2Fr1MMzlB6Ro5V0kkJlTk1NzNQkGZR7ZZ%2BL%2F1wittnUSlak16aNPKM3qxXqS0%2BtyphtOgURWc1HhjejR7YN%2Fq%2ByqTORJtm7floOpkUgUWP2qSZ4PePWhn%2F5aiZ8V8xwaXxbx%2BF2SJPyhXom16pBH02LBUmnPNUp6QCMoGUSsWD%2BgVo4M%2Bfo5BK3mS2sb14Bs16MOjFkbujsaAzPCQVHos5501QDUkFR2MT9KVDUnEihpxo1WciFNQkKRbehitZ69S4kq2VtmNXSiZaLG%2B1KpOs1qVEl%2B1uvfTnN86cd%2Fuv9tWsW9Ts06kVlc6os5TP3jx7wYEV1RP6b1o8c2tHpVVr0CgnbV6HJSU%2F1zf%2F%2Fg%2Fuvu%2FzI0tcgQKfxZnmSEl36nNLcpuu%2FsGe3U%2FsmxouCWvsmQQtEd83V90IP%2BCAD%2FsGbYnRjPqg5PTCXk4bjOVMgaWcDpjJ6YWNnKfkcnimNG7RtKRFGSMd%2BF%2FUomBm0bRTsh0e0SuZEpYO35AUHlTPJ%2FXn6scs%2BDw3ZFlpj2%2FQAjOajsUsHWqaMhFDUpitnrkAaqJgVniCvbKqIogera0slkMhOzWV6sYFD7x%2FaOSvnvx8j5Tz0Nt3dxyv3Pi9q48M7vnellr5zoc%2Be2CuP1d1ea5%2F4XfePrj2%2BJVtn9vr9v8ALQVPruzBkxeSR%2BhzD6blJtsJmLUTxngqMHsqdh02yB2S7VG93hlwBvBwaUOSLmreH5ZOh6XnwlI4rEnFcyTMHbmgQQ1%2FXriyns1b8NglrLvZ%2BGOX09YTZgUYY2hxbgW5zTT7sZi5Q0MLSMRQAjMDisBmYrIBXWgN1oKCdmqYcVLZozKYdcO3UMPIq3RmnVqNPyMaKaFD31HpoWfJks5sUE13%2BBw6biSdw5fi8Nl1I%2Bv0tnSnI82mHSnT2X107Ht49FOpCz7LRfqovU7We2Z7jngUkrQamFmNMawGZlZj12E18hjagmH09EmXNNNgm8ucj1QSOd8AjrJIeBLeR7gHt%2FNOIrukLl1KMNWblaLTu4Ke1GCKLk1n0qrVWpNO9YpQyVptx12m8bt8lLh4U8WZNHZ7jHF7YHZ7YNZUXajUY0RvnesakiLJapNKzorb8x21ztXQS2MVIu6SN0veZV2sDpR2GFc%2FfMaTr0vJ8tJblZ6j7qo9xefUw8yPiNv97D69PZ1bFqOBnUwhh6hlj%2BZarSlJqzJGN2SM2wa%2FT3sYC8OqKbQtZmYaiovLaZct9yJtuRcJy21IVU67bDlNYiOZNXMNxdZcVWpWR2onbZZw%2FZ7aeqkk2Qfhr0WzLIH%2F90UtX8jAfDxyiAenI2wu2ntuyO122cWQS3soHXM9UqbiqQjTLpu0jepSsyvNXJ2WGwq5RtYEpqbLsqxz%2Br1ev0NXmDY3I9efYZcmZlSVl3kltEunP9UdcOimp2D4NGaU58pv1O6d1HJ72%2Bf%2F0JppvZu1qu%2FlZRk8%2Bf7hn1b29%2FaUzD48W34SowOatkkLz9Y%2Fek71tjqIuVYuuZt5trQUaqMU6tZSqFtLoW4thdoIZqyI6gOkFO%2FyFJKZND6YtRkwGzDAbMBg15Er8xQGDANJlfIT1nkh2m7UGCTGu7eesfYzaEW%2Fzj8Ws85T05Rwb2xUOO%2Fe6KwmaSrm3caNBqq32255%2Fdavv3B9Y9utr9964%2FMHmo7nLv7Gpk3fWJYfXnTHls13Ls2Tb7%2F788FlCw%2F9696Dnx5ZtuCBf3x3wxPXz5p%2Fw6nVW05fP3P%2BjY8zXz%2F6qfI0PF46Zpn3UIsMZmuSjwpmj8oYpgGz7sGu41E1tBF57BnUgBnUgBk2k1makRHAtYwhuTxB7DlDkuGoRmPC4xmPujpMdHaRnAryJiaaF%2FV6Gpr6eAzJXTT9sRjLgCY2NuujDSp0QbOCf1ONc%2FzK09Gd37%2F4Fr0zmEr7WEGa5CqYuXb9jPzjkxb2FN7zzVmrm7OVW%2Fru2jB5pFj0ONpktJ76JZcsnL2u0jL8Sd70fjoGYjZmhF2qSCN5grWUTFuxvVqHZ6umz1rNnrWaPns1bS3VaC0n86MI5tfbqeGgGCMtYxgQzAwIZv7FDgMm0ottGC1ObIpK0ahnCp77eLDDk5yHUVP1nKsVc4vy55OuEQMGDJYojtKsx2PIGKQ5T8SSWWm3ZEarFR2T9kulWPmS9dyeTIWOD1r0TKfbLVWGc8NhMYoaNSnZmWnBFKNqp6uobv6krcKuGFWdZVPT2rfOyg1NW1IbqCzKS9lm0Y0MN85Jra%2B4%2BaHG%2Fml%2BuGIduh1cTlnlwvrQ8Mtj9n4k169WzDULNjZMXT17YoolMnlW2cj%2FZGcoV81Y69FqRmYEJ82BB8xCDayB184mVzP7Z2RT4%2BdlS2mUw2lSnkcKm6XCVKnQK6XCpMesmP8yQZuhV8RQEXXQqFRvqjec45%2FrVTv4WOOorbc7JDrZpXO3slLS0yP19PRgmus7OZYMxkQ6akg6Vy1WUf9WVcX9WQWbtmI%2Bq5VPqiypuRnuoNdu0ioj3TrJkZeVHnToVdJWSVqr6GBKf7ZZ0WXSuamkUmO%2Bpkqw2SuG28%2BeUtXTeDp7pb1yyeg5pV75GanAxtS%2F2NMHrNP800qmKUa9p9IEF1VpQ4OqpC2v0kafu3JI%2BihqIbm5ViKZCG2hZGJyXAC%2FTee2jJGBMrPVxCFZF02xe35MKm2V8qTTlRKplCori6cWDEm%2BqPW5LCkrS5XxbnHblNdMM1WkhLox1iLtbO6ytEfM2c5ElvbUlvC%2BXI6mubTHFzUbPVKl58cxWl4WK9AdI1lYeKDM4ox3Y8VtpimvxWi53hI6pUvOZWjRkR7WbOkiAYPFBL5YYI6wYgKd5I0t7upUtBpcWj5NdleUV1Ur9bZ0X5rfMunmjulbO4rqtj20do%2B7bFbtlL7WMpMOI4HWN23Bqsq%2Ba%2BeH7z%2FQuGKav3vO1I1TvCYT3I5pUX1zTvOqqTM2teU0V86Z4MsIZehsqdbUjLRQhrOw89L5ZzxF9fnN86Y1oo4Ooo5eUG%2FG%2BnwKOUHr6Hh9vWQIViU7Ofh9anUw6%2Bw0zKxeNSR9HPW5InSgiQSQIkJrMUI9SYTWW2RINkT1xGWomhBUqbFcUJ8It%2FmabTNqIQfVM%2BloQr2oB34hOU6ft3yP7yTPF6YZsYjmWdU0L8aXmWzeSJ2Dh7oGKTlC54r1xvmR2c7XY2LY0drdMG%2BdrLxQ0X9TT6S1uTkX00IXBmKN1hnwpmJUzmtvaclbfv3CvEdclQuigbpoU27jnoa6rupU6a3tp65stocn5m%2BAE1CpMD9T1zD3gD%2FDf8qvCdlmXRHf3nT5iimOgmnlIwfnLZzcv5vOLxfBxgHlGSy3fkotPJiOmeNp6lrBb1Dr0pnkMZiPsIk4LrAJOqwIZuPy%2BQn66Ls0Aybqxqi5xCJZUt%2FyRw3mFn%2F2kCQfc7Yp75XRrxnozS1lhUOSZlAPQw8%2FH6GLuwjaeXLUPgM%2FQafoUZM%2F9a0YL8BJSzgZc7aVKe%2FFaCHHaSF6WgoWeszkyEZXev9%2BqcdWfqEsLPrOL%2FSUgKzWpk5u7yrpu33lhKmbD3ZHOhonePUa2WG25k7unLhzXzDaM7l2QX3EpDVolW%2FbU%2B3m1JwMR3T30e1XPbVrki0ty2txeh25%2FmBe8OQjC6%2FoimRHQjpnBlpuL6x6F85yhbEifpx5F3%2F9JMnoq6U%2BpdaAdllrgylraWuspY2z9hQO%2FBJSwm1eQls0roPZ%2FIAxMrF4pC6hDdjgDDYba3N9Kgv6vjrhbYODUh21zMQWHhova76YZ%2FKpkJhtwnPAcRhERi%2FNeSzmbbPQvFgF0sx0OsCa79ick06SxvuIcrdnbH6JxdP4JXS1cpfWnp5Cd1%2BmH1zcf8PCvPLlNy%2BbfUVUm%2BKnbVh%2FqGFvYz1aLFrw1OCUaHNuqmiwO2cumHnF4PJtp66c3tQgG8V8c7gJbXX5nmjj5SvRdhvKYN0eWPcgfHcEL7jfZdYtKKmqr9pYpThpb3cGYFWnM1hIJ%2BOF1LqF1OyFzIujzXxyvDFyf0SmGxPHqTeoVCWbOpi1aBZGNjB34ypq72Cw8On9qptU8mmV9JxKUqnSS14Lt3nf7bVsssgW%2FbvprDn3JD04W3cy45f%2FLsKbNvW7GAJRAVmqwqdjO1gZ4ZLX4EEs3ndjxGLDN10US7r%2B3RjKopsXdOnJ%2FDZbgUrYhQuOa8FwKeO3NGRXbhWrC61yMDd1OJHZvKkjuqK1xKQ1ahRZ0RqrFmyObnxwy8TJm%2B%2FtX3dbb9Eh5ZKdU5bUZWFFkBtsv3hBsSvNpbWkOsxOq8mY6nXW7Rrate3Ry5oat36zy3n5rcUzVlZTj5GDM4RXqy8mk8m11PYJt426CuYifEmPTJl5Ygg2HwMzF409hk8SpQXYM3su6rDZsaVmOFc1PS18rrQlMMPWQpdH58rpRkXkTMUHdMZwJlJBd3ui9irDuRhSlobPxZJp2RSs%2FIKpPGuJLjZiwVbj5rEY5sToxtZFKvlqzA80Wldmvi%2BnMmB5RmfUqx3WZ3TwtN6AU7fPZlPBc%2B4LtaxvC03LNmHeYHV6LGq9Ue%2Bt6Ji4XGtPc2YHPn%2BPTjHovpDiCmQ70%2BzanqXXLMg3W01OrN0VMmHkFuU65aekDvu8yyQ3a6kuR9F02uun69A4pwdsTmnG9Ir6odGP6foSzPo7%2BI0T9FK9djZk1Gx1SDNm%2B1TWUqVCq6WtFc4BNj0dNUMUVWh9Pm1FkYrWQ7QSDZd00Y%2FoCtiQrasgJ2oE51hLtUpN2yumeW%2B7XL01yjuTWwoC016uaVv8cmA2Wz9hDnaOTg7OvciHvkjFWVoBHkze6PTNjhHNdjaC34j4Q2sml5VranslZnK55r0do4VPVt6J0eJrpr0cq2kLLH45ho9I7qfUoyB0BNtPxkZI1BTWsmygDOdq4KTdnuScWWwnV2OSgn1m%2BpdWn9sTLKcT6bFJSZ3sxLQ614KJNh9Dr3NaLwull%2Ffsn1Xd73N4pla917BpbnHlRYc2rz%2B4vNAWLAuUlZTn%2BLMrl1w2I3%2B6X7LZ7SMjK3tKp5d4Vi4uaynxzFvW8U4g36u%2Fckf7yjqfsi3kz15YMuvieYUZbkdxZqhYNsjBKd2T6jZ1luVEuyuDdTUVqakzCqf0hnN6ps3cNb9IrwuOfLBkdaCmNa97lb%2B6ZXjpxHpZl1qUn%2Bea2pBRWkd70kGsDO%2FF%2FKacr5SP1VdKBc5kTwHzLgTBuhCNoMOrk05uPJlGOogYqV8zUg9nZM7NSK8ZSBSXSGZBKtYumpNFbdnNqTPYoEDnNJjSJLd%2F%2BJSGjQhHC1KLaGLMZsaS0%2B5FfQ%2BbKGJVSAcBO5sbarTjFojJbQY7n6%2B7lHt1Dj5Z8Ra3ltbtaUQwFT1KK%2BYw029qXbR7RjBV9BzZOnNpY3ZX5%2FD1Imb8xKW9dcqq6%2FrofP0q7IN1qEuwDxYkD9KedLI%2BNDu0MaS4qcFgBjCzEws7WZh1E4RZn2LxsJT7FN5f4U0Rt%2BaXN6SSZseu08cnDP4o%2BhG%2BLF13LNXWymz44rlIclBNjqnMow%2Bm0kTHYzwVTPeTsS1DbrekmZz0RQpty2jEUt0XbeMsnDQxQjFmHeVKutMGV6SVSicW5NcCvN1IdWg3LtLBLYE9wY1sT5C1ELgBZgk6c6M77GDelAie6ZjB1sweJPkUdDw6yqJw1xfWdfKev3yfY7d3vqr4Xamfw6g8R8pkns7nsOHD2f512GY0STNyvfTvprlS87g2ze6QtW14MsawOJhNJVlbz8x0o3ozM8sNtMkbaJM30CZvYE3egBHl5JyoXZo5pw4zUfbg42akbJcAYdYEGCN77il8s6qc2DB7bG%2FD1FITNU9tq2suqmktmjHWVbADMH5TqTa5M4AXa8ktAtpz2PdBfYPttPMci7W3TWWlWWIXFse6Ei0Pg%2FmFJqZLrQu605cikpXgSq6H%2BZrBpX6OdzOnLqWwsbh2axMduDxBp9Zd2FBcu22s12kc6R53hk0748bWmu7GUltRR%2Fv07IU7Wv1jVSiHar%2FQ%2F74co1yJ4VFR9Ebdzs7ZaSVT88oaC5zomDOED0Otl5MhVutWXuu06pPujFXBuJpNerExr5ZsAXTJlmmks2Lu1egIxp0c829ouieTjo15KkNRW0FqdquoLjp2jXm2SHLPK1lDvkHu3IxwbmN5aJ0g03%2BqjwvN%2F9XubczQd8z8D%2B7tAmPCiL3Uu9FV2OuwIt01fYbZMb0%2BX8pzSPl2ugMTNklhnRTWSgWKlC9LbCcUhgKzdg1mSzXwhTuldNqaWWKQDOO2YOkMedwW7GP4uiLB%2BwErmbkJ1YntUilhbcPuoJxcCNOVWbLFiyUaGr748bEdVgk7rG10h1UeWwFTw4rF71ftsCqvT9z6%2FS0bH9hQVbv14a3g6kd8detmt65tDPrq181uWdcYkP604dGr26ddemwLuA28p%2FXy5bWVyy6f2XZ5X23l0sup9Q6O3Kq8AOvRfYJBaj26TxCsoi9u6cgAZm6BhtmsFII1OvgODKUuvkXANgu8dKOH7xb82z2CVtvsr9wj%2BN%2B3CJDzP20RfHlUdX31FsHXl%2BY1To1miyEE7S%2FF5XNo82fM7ChaPkC3CCrYFkFzbuOuhrru6jTpnR2PXzHdllUZGqkTOwOqd9Cn8RrXqL%2BkoC7fNePKI9ubLlsx2ZnfUDZyJ45nrtgD6%2FbCunclrXuSe3aY12%2BMUE8coT6ZG4y54whdxxbgCDJriBXJBgpm%2FhzM1rWM4SEq2DrWldNqnBLxq2zFdB2b1lZD17G2mWrMSv%2F9OpZugNFlrMiXRjMei6W12WjWYzGWFx38%2FDJ2rCXa2Q7j2J6%2FRzRN15fXsXo6kfGnaPPbWlpzqUnL%2B29eltfcNL2AnitISbdrv7SWHTkmLCudza8NWcV61p4zKX%2B9MPXIP%2FmClm%2FGYEHLvaj8IGxcwXdij22aIIWtyWYLZq0VzJsvFbRdW2nzdZAoJjuEDoCEdm2ShladE9VH2sJWV6DVRTcD2FAmldCdFbYiZeYbjLCEhtj5lDAZG6XGdV06xf430z3eMDXyg7JGr9N5MrJdqaUTJobGtUY27ORMnVibYQ5mZ5hUiqQsd2fa9Xq9LqV4RvVwXEz3zvvDK6oac62KzmDQW3ywScfoOflZ2KRVsrFWZyppr2%2Bf3b6v%2FUi7emrSBGDWtVkYAwb49FHYg4XhIRmjoU0dkl6L%2BrPLs8tNPtpsfbTZ%2Buhg46MjlY8ONr7H8D1ruMKoAQFiiiLeRJdYYZRXbzpikk3Fv6s2vGefY%2B%2B1b7Ir1fZqu3vyq1N96vw299t80xDWO2dnJzts52zMcUbGvVAoSW7G8D2AnOri38XshvdixG6zB%2Bw4psFKzJ%2F8aoyVqXa%2FLXYTUWyEFUv3A8Y5VpVovfxMTrEmGf7i4QaN%2FGzF0stnlS5sKnUbVBqj1hipX1BT0Fjuy43O6eyI5ubP3T03u2VivkurKApONOizqlpLCqL5rrzo3M550VzJ0hRDe%2FKkpmT7nXg77Qv4HKGqnHBlnj8rUrdg8oS%2B1kKTw2UzWd02e6pN6051O0Ol6bkT8gJZBZPxH7%2BXSHD0b%2FJ61ffJRHIdrc1j%2BcQeKkq6B8aoFTCrTTBzE4xRDUW0oZs85qJzoZYM8zlPSxm6%2BaCW7dOeO0sH%2Fgq%2BzVV%2B9gzbOkTR52JI64l6zOdinhYtzZCIIQdbhKbZzoqBX8XXNV%2FcH5Bd43cR2JqS7irI63W2QH6xp3lFNONSq4O%2B2d8rFjZv0a1vh%2FWt6ume7PQUnVqvVi3OyLJZ9JocvMWRLXyD4EXxqvRFvoUwYuhZpjfo1RYvbHQr3TVUHh%2BbS%2FkxgzLm0vaaS9trLn0rk8vcbC5ttTip8MkJ3vP9yf4AZhYEf8yGOiroGoAmEBHv8wjpk6jeWdSaa1SntmLaqj6%2FdUidgNg5HGvA3OfqkxksNMf4DUOaZ%2FzcVuwXjm0U2tnOVVX1WAR2Ch0ZLk%2BGXTPzdjZp0qbwzRdPSUtp3e4m7Bhi5ejQj01Vd3bOmrz6uuVylpiNDn84e1lDTlenvF3E0JaGt1rKblixUDLTlvYojn1hlKdLD7%2BO%2Fs3xS5lcZEpscQjzsPMJYPGC35n0uY4k433i%2B9FqJKjGfMwu5dqkPLWUlYeIKVlSdpYUpBIneLKDUoDFBqTsgJRrlXYEpSDd7NLbXS3BADwJQm9H9XA8QbpLSUN04wf8ftSEMoJ5rUFjWquRu23UAt0Ei5BID5tzReibs54IfYOWPCdG36RFfMdJULKp2QcZ8UFjZTCHXh%2BB20i6dK3Ex79c6fx7b4%2FTU%2B3kEw1ltyQr8shZlTktLzMzL9WiGnlWpaanAjwZIRwVG1Epn8nYd%2FZ5Mu1a5R6V3mDSfv5d%2BlpNpbMYlIUmh17BWlXGH%2F1wmskk%2F1mPrTNZZ6T1MmH0U%2FWVqJcmqYzXy3Q41ykwAl5U4J1ujVRNOadYCgelcEAK%2B6VwphTOkHLTpTyVlK9IEydJkyZKk4qkyYWSLYADLfiPKLElPmW8aEBEACXYMD6yaMpRvJWcaaXR1qmtLB01e71ttm2jbZ9NZYs63C22itac1ok3FUqF9Foh9fk2p7tldeHOQrkJsZ4ZelodL1Cb95yprz8Lm%2FOaOf9Sk7%2FW5LNiViXRjKmtVpvfRj9KZeKfE2UfNKdQUtiHOPAh4cKqQllGU1Xxj0GNvYDq6okso5%2BUdhav%2FWi3wtbvWNUpuVp2po%2BfAxFDwLhaHCfVV6rUIx8pZk9epr8g1aQ8IctHFHNafqY%2FF6GRT9QqLBw96VkOnfKyjP%2FNh96BPoeTIfJLsvSijJfRaV6cwVTu0aZYz9ezfECvH956vtatKVq9EZWuxfGbNL0elW7GOIKNi2GvCMk6A1pAPnpmO1pACbmft4AymNqOVl9CPVsx9WmTiiW8TX7%2FBGSlV%2FIkvRftpizKLelpTynAZULzTCZSTUiqMkrGALqUkdaz0VhWmt8aMtozWu3JbS%2Fqm%2BhbaLb9zt5AoyLpL%2B1EvqhxfHJUAJsEUV%2FmThGnKM8fojz%2FatrJOg19My0pDTpnrj8z5DKqfvuSyujKwllKu6SXvCMf6SRnbiAjlGJQnX1OZbD7fRk5Dlk%2F8kmhxWlSY1NeK60c%2BSZIUZucFumk9KDFaVYpGoN2ZFCaDVJUxhTryFJYrxtLnZfw3waIkF5uPRss4aZv3cPspEQJ0lTqG%2FWyPseOldjR1BYrRggs49qxu4tN9XIs5M7Cj%2BCREzmpNAn2q1qsapoIy7Z2OjZiO71ceHF6xIq2rmqJKrrxyo66sbMMEpXySxqdRTf8ostHe790YGSfzUnPYMkqI97Q07iR7dIhHHvTNDt9dm16MMvidqfa5HXBHBx202osbnvA4vWk2YZv19p8WGmcGv1IOqDcxlbB5fQZBwmOCO0%2BacgMYZ1vxeuBs%2FVn6WBPB%2FkTNC6KSC%2B6Sf3ZpIMTO8Z0tB5bTyUP%2BEgH9Kl5%2FkAeWqU3L%2BDPS9V%2FMawEAoU%2Bo9FXGMgqolw0nBfkEfgCEPxZWhFsfAfucgN5A2fQ8%2Fk94rTO6RNoeRq9Aj%2BBG4z8APd3VB9FkN4cM%2Bi4fb8NJXWTiynWTy8pbgKod5RG3lIM6iexp%2BdhpdrUpKQExQxCeEtKUIaHu25xjlf7kMqckuFKDTpUGrlHZXZmuvB%2BTaX%2BwGzVqbRmp1mz22zVoxummGn5TdIxuVieglPyAVr%2BMaI1nsOBA8ydzuJTjqmM52L0nACfXeI9E9%2BlZNVc7LCPLHXgR%2Fo2KlMtfZKb6Q%2BHMzX2NNRZC%2BYsT7PzXhHJSEuOpn5h2yJHbFtgOnc6mmOVZ%2FYWSeM2JOjuXgqd36TQY00pXqpOyUV4Gxrg08AAujw9QQxmq0gwm%2BSA36Z2x0iL737gPLuBHiWLEoUu9KN65CgxzDbIhI2%2FCGHDEP%2BdKnoTBioMBF%2BJxgspA46R0XNR4hjZ%2BfMqcBC2N6mPwAsPPsGkU3eMwPwHex80OztdRgv4T6fLMNccO2usUp4uWR%2B%2FbNeDqyKlsfj%2B3eC4xReZPLO0c90Ud%2BbUlS01nVPQUuWB2%2F412Lfwux%2Fde%2BtHjB%2Fuu3NHZ3XqnBsej9388%2F0TsxuWbrmK1u8jeOF0j9pDiiUTq4Xs7EwpO0PKTpdCPik7TcpOlcJeKeyR8tmmkoP6y1JqCzOtkFKJUOOTfDofwRUwMzlj1A%2BYmRzMppP59GCaJdNLM3mN9K8RU6Q3aC2Bnz%2BKMsGnaVHj4k%2FTqQ7CqBzkuBffL3A6hqT6o6G5%2BdjT0%2FLzn%2BX1w%2BhC3OqRs%2FQFIHsRGPkxsz3hk59kBRx1RkO0hOMxFKGhZYiDonBkyY6HVxdBugfPHBi8joa%2FRarOSW6l4jgzvrtwj8Zg1g4v0ZqMGg3O6EqWT%2BnrPkVj1EsFKpPD68BMVPOuzqJXN9IFkNaWhmO6dr3y29sMKnOmx%2B61mTRPKSq80sXb1s9u1KNvSPivmhCcAgji%2Fd%2FPWJ2Y86ukSKaUn0FnNlFqfA81flRy06MpbubF3dSYbjTnExU5%2BEdqkzVS%2Bxj%2BO3FGmBQmNNKJjRG2NtpragOBWrTC4hMVbk3xPBt2p%2FKEHTHsYTVKzz7TaeSbkbN0ZcoaMm3FhB3E8p3kRRTTMvD6h5eiocWcNyVKwPqTFnR%2BbOAWpRPHLxwY0vDXr%2FRbI9R7wAB6q354gsVl1SoGq%2BmzhWtrHekT5lSy40IY%2BFQ4b%2B%2Bd1H3RpKUHeord06%2FeeFauwPcK1G0OHJ7X2jLdKZkej1kyLPn6xcsjkZkTs7LysnSOTBeWmhZXdsg7YcmuprrdNx7Z8qLewU5Mr4RHuh3nCuvI59zqudVSbhXbQFWY1U9wo1cnLQvGNz4wjFbTs9J5qJU81EZeFPbNs8wu31i%2Br1wp%2F%2FdHMB%2FDSVSCHoMCqV%2BhpwmwHQN1ku4jOp1enEQqjJoKJ34YyMJJL3VhB07bG5OOBm9Be7BLQF8oSLYXaQXh50zP888zyauI1pHvGAoqZCXZY1kTP6RnvIwKK01NixtzOyiQbRCgxPOjodgTYF9w4FXFhiJxpt9FDxUk394ptzfvH4xNjs2vsuJ7OtgW1BoKpq9tadjUUZzbsWfBlK5wutefIU%2FRWQ3qFMdIRqi1dOOhjbXSvWu%2BvXGiPdVrMdnTHHacY8dxrkDj6ra6ZfV%2BU1qObA0G9KjN7LyR29TyhL4Bumc7F7V0CH2jlEwjb7F6cuYXSwVqKZ%2FN9Qtwpt8gNdI%2BEqDV1SiV6eCHyvj5111lUm1Za9naMiVSJpUNyYU4zWWxBPCfEqLeHm6I1ccbx2h9TKIuC1nB7%2FNDitsnSVWTmietmqRkY0UxJEeilpIc7Jn9PRDQVn1YMA9G1Q1q%2BZli%2BjqcHk2Cw6HvwiPsEB4C5ewkBzoR60XYjLQGon%2BPoYCCqg9jBfO0tAxsOCRPG9MX3rxK6H7DBds21ePO3omTtmPDRJVyKKW0Y%2Fd3N0U6pham6OGOdMa8KXMr%2Bq7vKpQn3Nobu6U7t3zd%2FVs69i6J5tqPZE3rrZ%2B6ZFJ6as2iae03yI%2FNf%2Fie69dMMtocDn%2BaO82itjqs7ZceWuIvnbTqhnkLvrmjOX%2Fm%2BoH7mvcfiZWWzF4xYdLyxhw67OJHwjdT8G1z%2FGgIvjU4lf40RBr6YmuXb1n7fwCxnfOHCmVuZHN0cmVhbQplbmRvYmoKMTcgMCBvYmoKPDwgL1RpdGxlIChNaWNyb3NvZnQgV29yZCAtIGR0TGljZW5jZS5kb2N4KSAvUHJvZHVjZXIgKG1hY09TIFZlcnNpb24gMTEuNi42IFwoQnVpbGQgMjBHNjI0XCkgUXVhcnR6IFBERkNvbnRleHQpCi9DcmVhdG9yIChXb3JkKSAvQ3JlYXRpb25EYXRlIChEOjIwMjIwOTEyMTI1MDE3WjAwJzAwJykgL01vZERhdGUgKEQ6MjAyMjA5MTIxMjUwMTdaMDAnMDAnKQo%2BPgplbmRvYmoKeHJlZgowIDE4CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDgyMiAwMDAwMCBuIAowMDAwMTg2ODg0IDAwMDAwIG4gCjAwMDAwMDAwMjIgMDAwMDAgbiAKMDAwMDAwMDkyNiAwMDAwMCBuIAowMDAwMTg2ODQ4IDAwMDAwIG4gCjAwMDAwMDAwMDAgMDAwMDAgbiAKMDAwMDE4NzAzMSAwMDAwMCBuIAowMDAwMDAxMTEzIDAwMDAwIG4gCjAwMDAxODQwMjkgMDAwMDAgbiAKMDAwMDE4NDA4MiAwMDAwMCBuIAowMDAwMTc3NTgzIDAwMDAwIG4gCjAwMDAxODQxMzUgMDAwMDAgbiAKMDAwMDE4Njk2NyAwMDAwMCBuIAowMDAwMTg3OTIzIDAwMDAwIG4gCjAwMDAxODczNjUgMDAwMDAgbiAKMDAwMDE4ODE1OSAwMDAwMCBuIAowMDAwMjA0MzMwIDAwMDAwIG4gCnRyYWlsZXIKPDwgL1NpemUgMTggL1Jvb3QgMTMgMCBSIC9JbmZvIDE3IDAgUiAvSUQgWyA8ODQzYTlhMWJkNTcyMDczZjg5MDYwZTJmOGZiNDgxNmY%2BCjw4NDNhOWExYmQ1NzIwNzNmODkwNjBlMmY4ZmI0ODE2Zj4gXSA%2BPgpzdGFydHhyZWYKMjA0NTUyCiUlRU9GCg%3D%3D\"\r\n }\r\n ]\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}GenerateOrderSSL", + "host": [ + "{{baseURL}}GenerateOrderSSL" + ] + }, + "description": "This API facilitates to generate new order for all emSign - SSL / TLS - OV Wildcard certificates.\n\n**Prerequisites**\n\neMudhra provides following values for generating SSL OV Orders. It is highly recommended to keep these values configurable, as they change for sandbox environment and Live environment.\n\n- Generate SSL Order Base URL Endpoint\n \n- Product Codes\n \n\n| **Region** | **URL** |\n| --- | --- |\n| US | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n| Global | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n\n**API Endpoint**\n\n| Header | Header |\n| --- | --- |\n| URL | http(s)://(url host port and path)/GenerateOrderSSL |\n| Protocol | HTTP/ HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n\"meta\": {\n \"ver\":\"\",\n \"ts\":\"\",\n \"txn\":\"\",\n \"accountNumber\":\"\",\n \"authKey\":\"\" \n },\n\"orderDetails\": {\n \"productCode\": \"\",\n \"accountingModel\": \"\",\n \"saveAndHold\": \"\",\n \"requestNumber\": \"\",\n \"emailNotifications\": \"\",\n \"groupNumber \": \"\",\n \"requestorInformation\": {\n \"requestorName\": \"\",\n \"requestorIsdCode\": \"\",\n \"requestorMobileNumber\": \"\",\n \"requestorEmail\": \"\",\n \"requestorDesignation\": \"\"\n },\n \"delegationInformation\": {\n \"contactName\": \"\",\n \"email\": \"\"\n },\n \"organizationDetails\": {\n \"preVetting\": \"\",\n \"organizationNumber\": \"\",\n \"prevettingToken\": \"\",\n \"representativeNumber\": \"\"\n },\n \"certificateInformation\": {\n \"organizationName\":\"\",\n \"organizationUnit\":\"\",\n \"streetAddress1\":\"\",\n \"streetAddress2\":\"\",\n \"locality\":\"\",\n \"state\":\"\",\n \"countryCode\":\"\",\n \"postalCode\":\"\",\n \"domainName\":\"\",\n \"additionalDomains\": [\n \"\",\n \"\",\n ],\n \"autoSecureWWW\": \"\"\n },\n \"agreementDetails\": {\n \"acceptAgreement\": \"\", \n \"signerName\": \"\", \n \"signerPlace\": \"\",\n \"signerIP\": \"\"\n },\n \"subscriptionDetails\": {\n \"validity\": \"\", \n \"autoRenew\": \"\", \n \"renewCriteria\": \"\"\n },\n \"csr\":\"\",\n \"numberOfDocuments\":\"\",\n \"documentsAttached\": [\n {\n \"id\":\"\",\n \"fileName\":\"\",\n \"description\":\"\",\n \"base64Value\":\"\"\n },\n {\n \"id\":\"\",\n \"fileName\":\"\",\n \"description\":\"\",\n \"base64Value\":\"\"\n }\n ],\n \"additionalInformation\": {\n \"tags\":[\n \"(Tag Name:Tag Value)\",\n \"(Tag Name:Tag Value)\"\n ],\n \"customFields\":[\n {\n \"fieldID\": \"\",\n \"fieldValue\": \"\"\n },\n {\n \"fieldID\": \"\",\n \"fieldValue\": \"\"\n }\n ],\n \"remarks\": \"\",\n \"recipientEmail\": \"\",\n \"technicalPointOfContact\": {\n \"pocName\":\"\",\n \"pocEmail\":\"\",\n \"pocIsdCode\":\"\",\n \"pocMobileNumber\":\"\",\n \"pocDesignation\":\"\"\n }\n }\n}\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order.
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | 1234567890 (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | 6288275133 (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | 952a95057654aa1c26a0f287b6368579f5f3ec99f213543df5e0f7eb7f798b3d (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| productCode | 841 (mandatory)
Unique Product Code of the respective product. Product Code values are provided by eMudhra. |\n| accountingModel | 1 (optional)
Accounting Model of the Account.
The allowed value for this field is: 1 - Cash Model. The default value is '1' (in case if it is not set). |\n| saveAndHold | 0 (mandatory)
Should be set to any of the numeric values.
1 - Only Request ID will be generated and request will be pending for payment.
0 - Both Request ID & Order ID will be generated. |\n| requestNumber | 8785645678 (mandatory)
Request Number of the existing request which was saved as a draft.
This field is 'mandatory' only for the requests (Request ID is available & Order ID is yet to generate). |\n| emailNotifications | 1 (mandatory)
Can contain one of the numeric values as under. Default is '1'.
0 - Pre-vetting Organization (Organization & Subscriber Agreement info.) re-use consent notification will be sent to the applicant on order initiation, if \"preVetting\" value is set to '1'.
1 - All Order Notifications will be sent to the applicant on order initiation. |\n| groupNumber | 3589463147 (optional)
Group Number of the group. This is auto generated by the system. Groups can be your cost centers, departments, etc. within your Business.
It acts as a feature / tool for managing account user access to organizations, domains and finances. If group number is not given in the request, then system will consider the default group number and amount will be deducted from the account balance. If group is having its own balance, then amount will be deducted from the group balance else the amount will be deducted from the account balance. |\n| requestorInformation | (mandatory)
Specified below. |\n| delegationInformation | (mandatory)
Specified below. |\n| organizationDetails | (optional)
Specified below. |\n| certificateInformation | (mandatory)
Specified below. |\n| agreementDetails | (conditional mandatory)
Signer details of the respective order to complete the Subscriber Agreement automatically. This is mandatory, if \"preVetting\" value is set to '0'. Specified Below. |\n| subscriptionDetails | (conditional mandatory)
Subscription Details of the order. |\n| csr | Optional |\n| numberOfDocuments | (optional)
Number of Documents attached. |\n| documentsAttached | (optional)
Uploading documents during order creation is recommended for sending us any additional / supporting documents so that they are automatically associated with your certificate order. This additional documentation would help emSign to speed up the certificate validation process. (E.g., incorporation letter, registration document, etc.). Specified below. |\n| additionalInformation | (optional)
Specified below. |\n\n**Requestor Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestorName | John Doe (mandatory)
Name of the requestor / Org. Representative. |\n| requestorIsdCode | +1 (mandatory)
ISD Code of the requestor's / Org. Representative's mobile number. |\n| requestorMobileNumber | 8978945116 (mandatory)
Mobile number of the requestor / Org. Representative. |\n| requestorEmail | [johndoe@example.com](https://johndoe@example.com) (mandatory)
Email of the requestor / Org. Representative. |\n| requestorDesignation | PM (optional)
Designation of the requestor / Org. Representative. |\n\n**Delegation Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| contactName | John Doe (mandatory)
Contact Name of the delegated person. |\n| email | [johndoe@example.com](https://johndoe@example.com) (mandatory)
Email ID of the delegated person. |\n\n**Organization Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| preVetting | 1 (mandatory)
This can be set to any of the numeric values as under. Default is '0'.
0 - No (New Organization)
1 - Yes (Pre-verified Domain of pre-vetted Organization). You are allowed to use Pre-verified EV Organizations in OV Orders as well. |\n| organizationNumber | 2943849 (conditional mandatory)
Organization Number (Org. ID) of the existing organization associated to the respective emSign account of the certificate requester.
This is mandatory, if \"preVetting\" value is set to '1'. |\n| prevettingToken | 0947CBA3C2DF42B0B303590541C8E5B1 (optional)
Please specify the unique pre-vetted token value associated to the respective organization. Pre-vetting Organization re-use consent email notification will not be sent on order initiation, if the organization re-use token is submitted with your certificate order.
To get the prevetting token, please contact your Account administrator. |\n| representativeNumber | 21023 (optional)
Organization Representative Number of the existing Organization representative. It will consider default representative details of the respective organization (in case if it is not set). |\n\n**Certificate Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| organizationName | emudhra (mandatory)
Organization Name of Organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| organizationUnit | Bangalore Branch (optional)
Organization Unit of the respective Organization. This field is not required, if \"preVetting\" value is set to '1'. |\n| streetAddress1 | KIADB (mandatory)
Street Address 1 of an organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| streetAddress2 | Near Airport (optional)
Street Address 2 of Organization. This field is not required, if \"preVetting\" value is set to '1'. |\n| locality | 3rd Cross (mandatory)
Locality of the respective Organization. This field is not required, if \"preVetting\" value is set to '1'. |\n| state | Karnataka (mandatory)
State of an organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| countryCode | IN (mandatory)
Country Code of Organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| postalCode | 560064 (mandatory)
Postal code of the Organization. This field is not required, if \"preVetting\" value is set to '1'. |\n| domainName | emudhra.com (mandatory)
Domain Name of the website / server. If the pre-verified domain under the pre-vetted organization (if \"preVetting\" value is set to '1') is provided then fresh DCV is not required. |\n| additionalDomainNames | support.emudhra.com (mandatory)
Additional domain names of the website / server. If any new Sub domain of pre-verified base domain is provided under the pre-vetted organization (if \"preVetting\" value is set to '1') then fresh DCV is not required. This field is required only for SSL multi-domain / UCC products as specified below.
SSL / TLS - OV UCC
SSL / TLS - OV Wild card UCC |\n| autoSecureWWW | 1 (mandatory)
This is set to '1' by default.
1 - Enabled (Secure 'www' variant of website)
0 - Disabled (Doesn't secure 'www' variant of website)
If user has chosen to secure website with both www and without www variants, then File-based (HTTP / HTTPs URL) DCV method is not allowed to verify DCV.
This is not applicable for emSign - SSL / TLS - OV Wildcard products. |\n\n**Subscriber Agreement Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| acceptAgreement | 1 (mandatory)
This can be set to any of the numeric values as under.
1 - Accept Subscriber Agreement
0 - Reject Subscriber Agreement |\n| signerName | Sipra (conditional mandatory)
Name of the Signer.
It is mandatory, if 'acceptAgreement' is 1. |\n| signerPlace | GOA (conditional mandatory)
Place of the Signer.
It is mandatory, if 'acceptAgreement' is 1. |\n| signerIP | 10.24.108.199.182 (conditional mandatory)
IP Address of the Signer.
It is mandatory, if 'acceptAgreement' is 1. |\n\n**Subscription Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| validity | 1 (optional)
Validity of the Subscription (1 / 2 / 3 Years).
Default value is '1' Year.
emSign is providing subscription validity upto 3 years where maximum Lifetime of 390 days per certificate is available with free renewals. |\n| autoRenew | 1 (conditional mandatory)
Auto-renew certificates until coverage.
Default value is '1'.
1: Allow Renewal of Certificate
0: Decline Renewal of Certificate |\n| renewCriteria | 30 (conditional mandatory)
Time duration to for renew certificate before expiry.
Default value is '30' Days.
30: Automatically reissue before 30 days of certificate expiry.
60: Automatically reissue before 60 days of certificate expiry. |\n\n**Document Attached**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| id | (mandatory)
ID of the document. |\n| fileName | ID Proof (mandatory)
File Name of the document. |\n| description | Driving License (mandatory)
Description of the document. |\n| base64Value | (mandatory)
Base 64 encoded value of the document. |\n\n**Additional Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| tags | (optional)
Reporting tags are labels with different values, which can be associated with your certificate orders in CERTInext. These tags can then be used to filter reports, giving you a categorized insight into your business.
Tag Name and Tag Value must be colon separated values.
e.g.: Department:Technology
Multiple tag names and tag values can be associated with the order.
e.g.: Department:Technology,
Department:Legal,
Branch Location:India
Specified below. |\n| customFields | (conditional mandatory)
CERTInext allows you to use custom fields in your certificate order form. The Custom Field can be used to simplify record-keeping and makes order management efficient.
To enable Custom Fields feature for your CERTInext account, please contact your Account Manager.
Specified below. |\n| remarks | (optional)
Additional Information related to the order. |\n| recipientEmail | (optional)
Email recipients can be provided for receiving notifications related to Order Confirmation, Revocation and Renewal of certificates. |\n| technicalPointOfContact | (optional)
Technical Point of Contact will be notified for emails which are technical in nature such as Order Confirmation with order status tracking link, CSR & Certificate Download notification based on the email notifications configuration set by your account administrator.
Specified below. |\n\n**Tags**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| Tag Name | Dept (mandatory)
Reporting Tag Name. |\n| Tag Value | Admin (mandatory)
Reporting Tag Value. |\n\n**Custom Fields**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| fieldID | 456 (mandatory)
Specify the Custom Field ID. This is a unique field identifier of a custom field.
This is mandatory, if Custom Fields are mandated by your account administrator.
This information will be available within CERTInext online portal. |\n| fieldValue | Dept (mandatory)
Specify the Custom Field value(s). You have to provide the right input based on the custom field type set by your account administrator.
NOTE: The allowed date format for date picker based Custom Field is: YYYY-MM-DD
This is mandatory, if Custom Fields are mandated by your account administrator.
The detailed Custom Field information will be available within CERTInext online portal. |\n\n**Technical Point of Contact**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| pocName | John Doe (mandatory)
Name of the Technical Point of Contact. |\n| pocEmail | [johndoe@example.com](https://johndoe@example.com) (mandatory)
Email ID of the Technical Point of Contact. |\n| pocIsdCode | +1 (optional)
ISD Code of the Technical Point of Contact's Mobile Number. |\n| pocMobileNumber | 9676462551 (optional)
Mobile number of the Technical Point of Contact. |\n| pocDesignation | Senior Developer (optional)
Designation of the Technical Point of Contact. |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n },\n \"orderDetails\": { \n \"requestNumber\":\"\",\n \"orderNumber\": \"\",\n \"trackingUrl\": \"\"\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order.
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | 1234567890 (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | 1 (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestNumber | 6725625162 (conditional mandatory)
Unique Request ID of the respective request. This is 'Mandatory', if 'saveAndHold' is set to '1'. |\n| orderNumber | (conditional mandatory)
Unique Order ID of the respective order.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |\n| trackingUrl | (mandatory)
Order status tracking URL of the respective order.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |" + }, + "response": [ + { + "name": "SSL/TLS - OV Wildcard", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n\"meta\": {\r\n \"ver\":\"1.0\",\r\n \"ts\":\"{{ts}}\",\r\n \"txn\":\"{{txn}}\",\r\n \"accountNumber\":\"7793288417\",\r\n \"authKey\":\"{{hash}}\" \r\n },\r\n\"orderDetails\": {\r\n \"productCode\":\"847\",\r\n \"accountingModel\":\"1\",\r\n \"saveAndHold\":\"0\",\r\n \"emailNotifications\":\"1\",\r\n \"requestorInformation\": {\r\n \"requestorName\": \"John Green\",\r\n \"requestorIsdCode\": \"1\",\r\n \"requestorMobileNumber\": \"7094940185\",\r\n \"requestorEmail\": \"john.green@example.com\",\r\n \"requestorDesignation\": \"Manager\"\r\n },\r\n \"subscriptionDetails\": { // Applicable only for SSL DV / OV\r\n \"validity\": \"3\", //1/2/3 (Default Value: 1)\r\n \"autoRenew\": \"\", //1/0 (Default-1)\r\n \"renewCriteria\": \"\" //30/60 (Default 30)\r\n },\r\n \r\n \"delegationInformation\": {\r\n \"contactName\": \"\",\r\n \"email\": \"\"\r\n },\r\n \"organizationDetails\": {\r\n \"preVetting\": \"1\",\r\n \"organizationNumber\": \"5919142\",\r\n \"prevettingToken\": \"\",\r\n \"representativeNumber\": \"5655892193\"\r\n },\r\n \"certificateInformation\": {\r\n \"organizationName\":\"eMudhra Inc.\",\r\n \"organizationUnit\":\"Technology\",\r\n \"streetAddress1\":\"1712 South East Bay Blvd\",\r\n \"streetAddress2\":\" Suite 360\",\r\n \"locality\":\"Provo\",\r\n \"state\":\"Utah\",\r\n \"countryCode\":\"US\",\r\n \"postalCode\":\"84606 \",\r\n \"domainName\":\"*.emudhra.com\",\r\n \"additionalDomains\": [\r\n \"\",\r\n \"\"\r\n ]\r\n },\r\n \"technicalPointOfContact\":{\r\n \"pocFirstName\":\"Jenne\",\r\n \"pocLastName\":\"Flex\",\r\n \"pocEmail\":\"jenne.flex@example.com\",\r\n \"pocIsdCode\":\"+1\",\r\n \"pocMobileNumber\":\"7094940185\",\r\n \"pocDesignation\":\"Production Engineer\"\r\n },\r\n \"csr\":\"\",\r\n \"numberOfDocuments\":\"\",\r\n \"documentsAttached\":[\r\n {\r\n \"id\":\"\",\r\n \"fileName\":\"abc.pdf\",\r\n \"description\":\"DTC Licence\",\r\n \"base64Value\":\"JVBERi0xLjMKJcTl8uXrp%2FOg0MTGCjMgMCBvYmoKPDwgL0ZpbHRlciAvRmxhdGVEZWNvZGUgL0xlbmd0aCA3MjggPj4Kc3RyZWFtCngBpVZLcxMxDL77V4gCqQOt4%2Fd6edMHpYUDndkZDiwHJpNSmKTQFv4%2FsrNSNgnTadrpwapWkvX49DmXcAqXMNq%2FNjC%2BBl3%2Brseo0sr6%2Bf9ZSMrVOkKonapi7WE8g70GwtweDxsrpbV24KsomhmMmsaCgeYMvoB8sDXEcB7kwyHsFuERCfTlcVEYkINtklq5PRRonpU3mQ3J4QkF5RBP873oP3cXcqfLY7BbTANIVQQLkjXk0y5u5XhsvTMUpY5RcXcgdY6MYdiUMqYPpruaPWznsdWFotw4gus8yNMP4Ss0J3DYlJHdOB%2BxOh9jvVFVMnk%2BsDSfnHLzcx5VK23qqKuEk1cuJeuCw0KrEFO0Ce6FCxPWcBFoYINBJJGBUHXlJ5oQCzZ3DHtNHaSzphitfEbicxJorGz0Isd3QvJAXhYFSLZoO9yA5KtfUTIMlwWUXtMk39CdNEG6m07CBtfKQu%2BiDH0skjeFbQac8Sq2WjkvigDPiQv5lnLixFeT4az5Ikpzj4qeK4QctIt12S8fcZPYrdc%2F1vF4D%2Fog1iLzxx1QZaroVUIkexMIzaKwTR%2FNoyMkte9Iav9BNSp7qK6s8hXSW6yNqm3txQy8QzFTHulguqyzGmlwCn3XTnUOZ0ykApOwOYk7E6oLfrlEJNTD0nSkHR4aM%2BQajTCfMHbf4QzELYlkjeh9SMomH2AtL3m0IJJNq%2FWVU6G2Jkddo4n3eTFxFY6786Q7P6zoP67o%2BfloF%2BDkbjBub70QgumAg3D7eUtpJZl3aIlwLszeYtP2GG1rZZzvN728rqKP902j8ptta4cQE4s3%2B15Rralg1%2BiqXh5kP2b%2ByYETLz8pYsz7GSLMIOAbtaybsk4451TKdrhznWtPdQ6f4SJ%2FMHlnnUN6yPvpY1I%2BgbNBxQRXk2wlRp8mV%2BPJ7z9%2Fv03h6gemglZlYZ2NiLO16PiWjo5nBg5%2B4cN7%2Bg8KcLz%2FCmVuZHN0cmVhbQplbmRvYmoKMSAwIG9iago8PCAvVHlwZSAvUGFnZSAvUGFyZW50IDIgMCBSIC9SZXNvdXJjZXMgNCAwIFIgL0NvbnRlbnRzIDMgMCBSIC9NZWRpYUJveCBbMCAwIDYxMiA3OTJdCj4%2BCmVuZG9iago0IDAgb2JqCjw8IC9Qcm9jU2V0IFsgL1BERiAvVGV4dCAvSW1hZ2VCIC9JbWFnZUMgL0ltYWdlSSBdIC9Db2xvclNwYWNlIDw8IC9DczEgNSAwIFIKPj4gL0V4dEdTdGF0ZSA8PCAvR3MxIDkgMCBSIC9HczIgMTAgMCBSID4%2BIC9Gb250IDw8IC9UVDIgNyAwIFIgPj4gL1hPYmplY3QKPDwgL0ltMSA4IDAgUiA%2BPiA%2BPgplbmRvYmoKOCAwIG9iago8PCAvVHlwZSAvWE9iamVjdCAvU3VidHlwZSAvSW1hZ2UgL1dpZHRoIDE0MzIgL0hlaWdodCA5OTggL0ludGVycG9sYXRlIHRydWUKL0NvbG9yU3BhY2UgNSAwIFIgL1NNYXNrIDExIDAgUiAvQml0c1BlckNvbXBvbmVudCA4IC9MZW5ndGggMTc2MjcwIC9GaWx0ZXIKL0ZsYXRlRGVjb2RlID4%2BCnN0cmVhbQp4AeydB3gV1bqGufcc7ymeI3ZUFDuKgFIVRFApCoiKDaWEGnoX6b333nvvTXrvECCQACGB0EISQgIhkN4TuO%2FOwjn77EBIIAIJX555NrNnVvnXu9Zs5v%2FmX2vO%2B108r00EREAEREAEREAEREAEREAEREAEHjIC0TExN7L%2B3%2FXrN4JDQn18A%2BR6i4AIiIAIiIAIiIAIiIAIiIAIiIAIZEsCPn4Xr4WGX0cCyBZ%2FiYlJAYGXJWVky7GqRomACIiACIiACIiACIiACIiACDzKBFAwfPwCQq6FZRsRwygx8fEJkjIe5YGttouACIiACIiACIiACIiACIiACGQzAoQrsAVcvBwZFZ0tojAcG5GUlIQ%2B4%2BcfmNJSrfAgAiIgAiIgAiIgAiIgAiIgAiIgAiKQJQn4%2Bgf6B1y6FHw1IjI6KSnZ0f%2FPXt%2Fj4uNDQyMuBgX7XQjKZkrUfWsO83S0iYAIiIAIiIAIiIAIiIAIiIAIiMADI0CkQlJyNptIkrb6kpycTJsfGPAsrgOkzVZnRUAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAERCC7EuCNxNpEQAREQAREQAREQAREQAREQARE4KEmkF19crUrHQRYPSYxKYlFYqNj46KiYyKiorWJgAiIgAiIgAiIgAiIgAiIgAiIwMNMgNfRRsXExMbFJSQkJiVn87e6pMOzf1SSsABuXHxCZHRMeGRUWGQUn9pEQAREQAREQAREQAREQAREQAREIAsRCIuIRG%2BJjolFz3hUnPlHsp3EYBCAQV9nocEpU0VABERABERABERABERABERABEQgDQJRMbFMN3gkvfxs3ujk5OtIVWl0vU6JgAiIgAiIgAiIgAiIgAiIgAiIQBYlwLyDbO7VP2LNS0pKYhpRFh2NMlsEREAEREAEREAEREAEREAEREAE7kggNjaOaQiPmLufPZuLiKG5JHcc8EogAiIgAiIgAiIgAiIgAiIgAiKQ1QlIysgGugbTScySnll9NMp%2BERABERABERABERABERABERABEbgjAZaFzAa%2B%2FCPbBCJqtCbGHQe5EoiACIiACIiACIiACIiACIiACKRBgIfj9z%2FIPyIqKjLqLl%2BvmZCY%2BMjqAFm94bxRN42hqFMiIAIiIAIiIAIiIAIiIAIiIAIikDYB3gYSEBgUHHKVnbRTZuLZqOioE36hm9yuREbfjZSB8JK5C2Vcvnx5%2Bsw54ydOOXX6zJ8kFGDwITf3fS4HXPYfTM9mmeF%2FIeC454n4%2BxKFEhcX53XCe%2BGiJSNGjxsweNjUabN27dl39eo1y5jk5OT9B12trxndAUIWmlGSkJQcl6K6hEVExcTFJz5i02HQNmPjE2i4%2FYVP90XHxrGl7kcOkvh2iijpOXs%2Ff2RuZ4l9cx6GfcgwzBx48hWe4MoqrcBO%2B%2B0%2Bg4UVDA2r2xHjuBmi99m29Fd3nwEyurgkzcCjagDCJ%2F3WKqUIiIAIiIAIiIAIPEAC3MZ4eHp17tJt%2BIhRgUGX75uXER8XM3bdxbK9T4WGR0VEZljKCIuMikvItNeX4JsPHzmmWo3av9Sq265D5%2Bjo6Iy65%2BlJz6tjGzVv9WN1p2o161Srcevt55p1rM0q0%2Buk97oNmw4ddkNksA7%2BGTuenid69RtY3alejdr1a9V1dqrXsGadBtjTrGXbFb%2BvZmVOKp2%2FcHH3Xv3uuvb4hAT67i4GvBmZoeER3G9HRcfcRQkZykIt18LCx46fOG%2FBIiqNjY%2FftmNX%2FwGDzvqcv2%2FXSIYMzvTEEAi5Frry99U7d%2B0Ji4ikfPwdDvr6X0COO3jo8Hk%2Ff77ae9%2Fbd%2ByaO29B4KXLHCc9PpHlFpEMdDNmzT7gesg%2BS6abbQrEALrv8pUQY%2FmfVEumFAuNk6dOz5k3n09DhgGG8d6nzxw4eMj96LGgy8EcMUjvsUbKSUNousfCGS2Xgq9cuXrNbFw1NOc%2B9DVmA8flwMElS5djAPViQOp%2BJw3HFy5esnrtOs5mCs97JOaQ3VhuAWQMYCddxuaQMo2vAE9nF5PysPuR6TNnnTpzlip4lrFo8dL9B1wfQjJptFenREAEREAEREAEHk0CeBlHjnm0bvOrU%2B26NWvVxk27GHQpQ3dNd8eN6STB1yIr9Dn1YiOPrUdC4uLu5uWb3IZlVkhGQkJC15598Nlr12%2FUqFmroKCgu%2FbT08iIjtHq1%2FaIA3Wdm9Rr2PSWW50Gja3NKgrf38396PqNmw%2B7HfnzojL27nNp2LRF9dr1h48au3ff%2FoCAi4RhHPM4PnfBoiYt2qBmEK%2BycNFSJI4eve9ex7i7lTG4tT54yK1Hn%2F70zsixE876%2BP7Zo5TRhRf8UYlS1WvUuhoalnT9%2BpChI3K%2F%2FKrLAVfchLsb9lkrFz8OCGgNGzVZsGixUST8Ay5OmjyVn4smTZuztWrddvzESRcuBnKWDoJSt%2B49a9eph8oBIrywo8cIIzpp3CKOrF6z7pfqNUeOGgPbPxsFw2PK1Olt2rbbtXvvnz1U7rEtRLwsWrKUX%2BBD7u6QxFrXQ279Bw5q0bJ14ybNmjZr8Vv7jqhJ4RE2b%2F1e6gL7mXM%2B9A4D%2Bx6LSm0GZo8aM7bdbx1%2BbdfebF279Zg5ew4%2BMo1KnT4Tj9AWBtuAgYPbd%2BzEIFyybHmXLt1Pnz3n0O98PXvufN16DeB5ITDwPgzCDLURSkc9jgOwfYdOBiB29urdd9mKlT6%2BfulkCApkRjRGS0tMwwaAjJswkUty%2Bcrf4xOTkNG4qOmyh41MGk3QKREQAREQAREQgUeTALcr3GS2bNWGjbvlbj16Nm%2FRirtBHmll%2Bl2uA%2BHY2Og1B4NfbOzxZL1jbWf6MrXkLkIyKDPxHlbJCA0N27Z958ZNWzZt3rp23YbW7TqiMKBjNGjcfPHS5Zu2bOUU24WAAEtPuMcdo2MQ9TFj1pxr10JvubXr0IVACCNl2FeH0sKTWaSMQ4fd%2F4xlTj29Tjg3acG2Y%2BduolPsq2YfTWP46HHEaSD1sPXs098hQTq%2FUnJ4xn0x7rdd3Y%2BgpUydMXvL9p1EjHTt0YeH1Pdyv03e2w1yTpkNd6%2FMp2VxzHGOmFEyYuToN956Z%2F%2FBQ7jkJoHDqDZfKRaDb1e4Qxb7cm6ZxSRwOOXwlTJTH0mjIodT5L2lwTSTwVC%2FQUNELdwc%2F4sXe%2FbqU6%2B%2B88BBQ9at38hD7YGDh3C2d59%2Bp8%2FYfEbK4fHuhk2bzQw1PKm2v%2F7G7wnlGAsvBASuWbfeUjYczEj9lQJpfhpNMwlSZ8QYHHZcQpxWei0y5tayiT18h0LSrjrts2nYnPoURbGNGj22Q8fOyEQx8fH79h9o1rwlCsbkqdM2bd2KxNGpc1c4T5s%2B8xoP7NN9%2BRgj7dvFrIGZs%2BbUa%2BCMvOTgF5vEaRSeujSHtsAcGRyzp04n6GYu1vbrP7CBcyOMR1IgsWWJQ0brODupa7E%2Fyz557YsyZ8lFLAGyD6oagw1PHIBEs2CSfXaScSHza7%2FXZX9YpC2%2ByNpuWaxVeBoGkyYNm%2B94igSWDfQIYjXXV4%2BevQEIxTFjx3fs1IUjjA1%2B%2FB26jIypzUYTW7FyVS2nOkgZ5rqzyjfG2NfIPtoFF7LfhQAKhxjKJHFB9oRNLquQtHfSAJV2OWmfTbtSnRUBERABERABEXgECXCf43Hcc%2BjwEW7uR7hfmj1nLjMXRowc5R8Q4HAHmLlwWN4Tz6n%2B%2BHNPNziWq5FHvrZeJ%2FzCmLic0VqYnhATe5fzLAjkmDRl%2Bk%2FVa%2BObp2z1UTCsQAhCDsxxNIduvfoSqJtOPz3tZEbH%2BKm604JFS26Xsn2nbrfUMUhvkzLcj67bsJnb3fiEzHxjC6%2By7dlnQA2n%2BswguJ1hy1b8bnSee9ExWJ01o71MegYqi3UMHDqC%2B2R8Mf%2BAwBZtftu2czc37RktjRtmSiNa28fXH72Ou3f7oU75fCUkibPs4%2FWk1jFcD7vj1OOS%2B%2FoHUJp9dr5SIGHh5877EmPvULiDqWSkCjwINlMvTqp9MDzZycLcDTaOm68c4dEzKanLKpB9jrBZR6wdY1JKRRdNqJVVDmnMWeQanlNDAzL2xZJr9px5RF%2FwOJhTs%2BbMRRYgCh0DKISNGuctWIjThMNlSiO7teGV81h50OChlGPaxQ5n%2BTTmGZsdPi3L4QN%2F4Fy6zBI6%2F6VmmJaSAMGEaBCy2PcCX7Ft4%2BYtPFzGiSYkw4TNWyWzY5QNnF%2BYYBLZ%2BTQJzFe6j6oxgFOWwbaMKZagoXEWDvb1moy0lIaT3T6jOYXZnHIokwIxg0ACpvVhNiUTxoAasH3nLgOZT56wIwehpxFbwlfMoCj7oWKOGPuNkdRu4JCMfdM6WyfOnou2cOSoh9Uo%2B%2FYabdA6ZVVECTQW1KY0vrLDV9NMUzgHMZLRwnBi31ZIRBQ%2BNQrMxElTMNseggMfYzMJ0L5AZL7aBkbEf4a0qZSzpsvsjeQUk5UaNW6K%2BBwdF8dARdNIrWNQLFWQ0RjMVzby8pUy0x4JNMohI%2BaRkezBV65aeU2ZfJKYU1xTdK6pxTplbKB3%2FPwD%2BK0wBnAWRPyw16lbn2uKXxiSsVEvcz2IieresxdFccSUQy4znBxoUMiy5Su5TpmRZCEyxsAcembQWuWYRpkEDjqGOchwJRd1WXZaDbF2KI2zZkgYDZO81llO8RUUqSlZDDlr1UJGisJOPq1yqILEHLSK1Y4IiIAIiIAIiMCjSYDbg9Bw240oLiG3N9xNte%2FQkSdo3HIEXgrmILcckdG2%2B6hM4WNbry4WMSCaHTZWxjhyJjRva8%2FnGx0jJOOZBseGrAhITuSc7SzJzIbcccfa8Ueu37h%2BO9c7jeMEcvQbOAS9wtIubrmD547LTOBEGkWl%2F5SlY7DExO1ypaFjkAWzjx7zWL9xCw%2B%2BM3GCyXFPr1%2Bc6qEV3HKeDstiEKACCqNj3Mu8ktiMz8hgrNLBxGDMnDMfAvGJiQSLdOjcbcPmrRy%2F4wixT8B4ZqiPHjOudJnP3%2F%2BgSJGiH9Z3bsTMB4Y9yfhk2PPos1jxEgXfL1zl629%2FX7Xm87IV7OMx8r7z3uKly3AzSVCocLEaNWsfO%2B6Fe0h27OTGG2%2F040%2FKcLZEyU9%2B%2Fa0DroFxPO3NYJ8sZKxdp36hwkVJXMupLpE%2F1MWcC4Qak4AVUb7%2B5jvsZKtY6SsiGdBtyNitR6%2FPPi9vPVLnID5vyY9LozA41EV7MYkw%2B0qVv%2F6gUNHCRYr%2F8NPPhFiYZCk%2FAhFDh40o8fEnBQoWYgZN334DroRc4zgGkBcXhhitXr36QgYthXD3Tp264kwZXKRhBwdkxKjRRGik%2FGLEEDzAQ3ncYWbZE4zBY3E8SuIiCC2g2ONeJ5h4snnrNlrBg2DW5OnQqTMKKhv7nOrTtz%2Flk5La12%2FYhEff5td2RNdPmjKV8qmODaSoEzQKjcU2i%2BG39pjN5WDMxio2Wk0wAMbv3rsPH5AnzvZkKISVbBFYkFkogUpZ1oMaTatxl1hmgSqwn0988PMpopZpL5LCmHHjycVZDAamkUQMLuLzu3TtjnLCKGICDq5ZRMqvKM7d0mUraKPtVKfOxCuYZpoyCVBxbtiYcZV0%2FcbO3Xtw%2FPHE7Q2mi5lxQHUUwnHSU9SWbdtNGpqDF9%2BlW3dW3wUCG%2BuQjB034bcOHaEHww0bNwMEgDSZ0DvkHZBiIWobJXAKz7dTF1t7OU5vkp0yaRFmo1Ax9WDN2vXUCC6UihPep3jW37N3H9oCH0zCuyQxWYyOwZAwMLGEfXIRxkMtXHq0kXKoiNqxEH3M9Bp5ceqpCAMolr5zPezWp19%2FhrSVgDb27t2PvPQ4g9Z%2B2gh88P3paGyjRbfTMWz2XL4yeMiwKVOmUyPwScyYHDJsOB3KxiQOqHKcjSZg8%2FIVv5uRgMHTZswECIWwER0BWC5JetnkhS39YoolgefJk8NHjjLjhOajD5tiOUUhrCHDMDAAYWLceYyxdAzzI2DMoEyiaJAmVq1eS0s5yBEWA%2BndN4VGu%2FaDhw7jaiI7BvMDYrqYqrms3I4c5VrjP%2Fqp02ZQI%2FRoDvEql4NtE4soZ%2Bv2HV2792CBLEq21zGwkyFKvZadXBQoNqZnTVvMJ0e4heCnEiCUz68E04usCBzK4ZcK5pxp2%2B43Yrf27HMxVdOhZNm2Y%2BeMmbOxlnHSq09fDmIwQ4WB171HL%2B9TtqAamgZwRg6XPG1MbYO9PdoXAREQAREQARHIxgS4teBh4qAhQ5lFwjMsbtVSdIxOM3jSGho2eOjwxk2acy80d%2F4C7q%2FunUNYRJTrqWtzdlyavvVSvyX%2Bneb4EYlRse%2Bp5xt6IGKwsVOwnVfdsedaTTvfa6H%2F%2BPWBM7dd2uB2Jegqdyx3kDK4pUk9CeJ2EoH9cQQBXsZB0MUt5QvrIJ57q187PCgdAyMd%2FpAUYmJjWSVjw6YtzHPPrAkmc%2BcvQp3Yf%2BCgPSKzj7Kxeu16VqVo3LwVMzvYmHsCutQp03ME4zM0ohir5%2F0uzJq3gIVGUXgmTJ42duLk8ZOmNm7emvVMfl%2B7PiQ0NP23tdwhDxg05Kmnn%2F%2F2ux%2BGDh%2FZuWv31994%2B8uKX%2BFZUAh3yL%2B26%2FDc8y9%2B%2Fe13%2FQYMQqPAu3%2Fz7Xfq1W%2BIR8%2B8kpGjx76S53UOfvFl5b79Bzo3bPJS7jyVv%2FrG57wfziymDhk6%2FIUXX%2BZIvwED69ZvcPOsrx%2B34vat5quPry%2FaAgkaNGzMSgjoFagiL770ChoFj2Kxc%2FPW7WgmKBj4xWxILu%2B8m3%2Ft%2Bg0s08FChRiJMoDrgdkkxv9i4Y5de%2FYaN8eqi1O4qM88m%2BvjUmWQGvr0G0CBH370cYq7Z5saM2TYCAz%2B4cdqeKDVazhRLM4C2SmWs%2Fi5CBGz587Dq2Kw8aR4wsTJHLfKNynxYnBXocdXrMI7w39nWgdOByIGfhOaA%2F4%2BrcaratSo6cpVq3lojm%2FFxH9OseH%2Bk5E4ASQFHkDjtqB12J5B9%2Bi1YOFidCdO4RyhsdAiHE%2B8fn67OIsPOGXadH7E8IP4TTO2URGuE946vi2%2FYLQIPsbXxkLO4vThltK0yVOmYSS%2BMwUyU4bCaTg1oiQQ28%2BqIKhSnMIqswAy%2BgygGjZugsc9f%2BEi%2FERq2blnD2Wy0S7sxMVmZg0lUwjDAANozrz5C5kdMG78RE7xWadOfSaSGI%2BMjHRr48bN8NToPjrXyakuvh4TTOw5s88o5Yea9GCsV88ZrxaDOU75qB9UbUI4SIaRpvk42mgCrLyxZet2CFA7mg9dADTgE%2FwAMfxT8qJL8IoiJBpg0l56kGLpC%2FYpCueUYYCkQ6fg2OJy0il0HJ1LXnxhEmOYg44BTEoAL448HHCKsYS5SKa7KQpWXFnYgM3oABDD4SX8gLGKAkMC%2BHOWkhl%2BTHmgXhQAlj1hFiQdRI9TBRuTRPDlWRPDdFMaOgZiDvZQF2Wy4W7TNBo4YdJkRgJNw4ZVa9bSHOolmITWQQAyw4aP5BRVmw5FMsI8TEJMgAPzWRo0aIRJICUvhnHN8n8oIg8OPgIUKgTKA%2F1Fp6OHUBQ2UCyiH%2Fvs0ArOptYxbP0bF4fOQHUoPySjfIaKkQfpFEMDyQ6FDZu3btsBPQbtxMlTuPZRiuh3%2BoXhN3rsOFZZQehgH%2BnAdBkSH4nBa69jQAY7bY1yboQAsm7DxmEjRhJ2RTAPpxyGJZVCgJ5ltDOoaB0yL6OO3wSq4DfEJj21a89tBhvXHdUhcvIrh7ZDSk6huACfS4aLmnge5KDYhATmslE7WhCW8AtDgS1btkF%2BTG2Agz36KgIiIAIiIAIikI0JcCfAvSJ3y9y8LVi4iEeZ3EZyOzd9xkzuGTZu2rxw0WLuhXBbeO5277cNxIYu2XP5jZae%2F65zlNALtmedjz3X0BaJYW3MLuEgG2efqn%2FsiTpHWTQj8EokbscdO8K8RCM9TrR9GvSBgUOGm9Ue7GeUWAqG2TE6Rnh4hH3eu97PaDzG1u07U2%2B8NYOD%2BLlIGcc8PO%2FaGPuMQ0eMAkLgbVY3jbH9xdr%2F3XUoSEaHUzwz%2BufM%2F%2FGXWnQE0214mwzS0y%2B16mFtjdq2lTqOeBznbvmOg4QEuAB84rmjFeDOoEvw%2BHvQ4GFIE%2Fv2H%2BT9qkycf%2B21t2rWqsMduPEQETpw7es3aGR0jFFjxj351LNVv%2FvR%2F8JFEnADP3jIcFQCRA8iQ3bv2ffqa2%2F%2B8OPPPMfEKaCl%2FQcMfvOtd5YuX%2BkgL%2BCYjJsw6dnnXkBYIBlng4KvEJKR64XcPXr1wRLu2yt8Uem9%2FO8zj4CzbKyZwNdy5b%2FAU2MrWuwjIit41h%2BZ8pYBJr98W%2FV7PCzTRosGFs6eNx8%2FAteAxvK3aMkyhAuc5eTrN3bvdcnz6ht16zvj6XCWXHjoNGHPvv3USAN5No3TwWICOB141rgqOO%2FWk2KrFiq16mVtBBwTXCqygxF5AW2HLFhCS3HA8Rl%2FX72GfbLwyWa6D%2B8e1wx%2FjYzEfuAD8jiebiIvafBe8QT37HXBEtqCg8OTXDPbhfQ4QTiDvMiYlFjFkTlz55PG%2Fcgx0qMh4C6xmjFnjalGncDr5wgbs07wbQcOHMKvH8mwEL8Ph9SUhjOIa09HgGj%2BgkVURHACVWAYPhoZeYiMt05injXj2LJPD9JeNAS8RSBwBA8XZ5YdRh214MhjIV%2Bxh%2BajaeC%2BBV0KxnjcUgxAh0k9qo3xVIQb6%2BzcmEVTMYMspETCwsgdu3ZjFZEb7M%2Bbt5B9mo8igRmMbbJTO%2FXSp8QgcZaMJKY6fu2RaCiZNJhNG5GA%2BIqF%2BI%2BUhlNJXRxByqhbtwFGkpIjZMe%2F5qA5a3QMxBa%2BUjgbs3uozuaAR0bTEWhEjG2qJgF9ipSEpMZXE4VCsQzFqFjbHBZCJuhBnG4KwQzYoo%2BR2FwOjEzOspIDeUFNwAyjjlqwkK9p6xi40oxJk5LyKYfZKFRKRcyuQvWiLprgddIb85h0yT6VwtnoD7j8UCVsgIwMUZtwEWcTPZD40AcYwPFJSUgWeP1cLESOMRgYYAQUcR1RDgIIQPjKMMB4aCDmMD7R1uISE2%2BpY1A4wgiiGYIn6fktYjhxUduyJCRSJoIAZaJHkZLqECu4WNC6DRwuIq5KRgtdhuVkRyxCZqHH6QW6m9q5B6AcKx4DFFxcjElAmTLpApqDLEN2yoGG2djnCPIFA4ahThPYiPfAHsYG1XHhYAxqDij4ClVAMVzNKarmJ%2BLEKVsUDRvRUww2xDSKpYPoR5CikpEd2kSsYbBVtXZEQAREQAREQAQeQQLcJAwdNpxnH1fDbLO%2Fo7hRDL5i5pVwU8x9C%2Fc%2F3EjXqlWHJ4yZcufA60i2Hg0p2uEEC2JY2sUtd55v5MEbTJhmYtbQuGPvcDeYmGh7GWhG%2FwgzOHHSG%2Fdw8tQZDtqF%2FVf85cbNWvHmTZ5%2B%2Bvicz2gtDukzqmPgX6feXA647ty9d9OWbRs3b%2FU%2Bddqhirv72nfAYFodGhZ2d9nTn4vxdsc%2BtU%2FA7e6U6bOQL%2Bw7xdrnuKvbkZi4dOkYplhupxOTk%2FGUj3udxJsbPmI0OgbjnCYQofF8rpdYSZVKScyNPbfi%2BQu8X6duA%2B7V8QHRMf71xFOLlyxjnwRcGqdOny1UqGjdes4JSUlEp5N95ao1XD6c5TrCA%2BI6wqm3v%2FM3p5wbNclf4APMMECoi9t14jF69OqN3uB62J1gjFZtfuXe3uSlxuYtWpOAU5jXvWfvvO%2Fmp3DqxafjOJ4RhVC4w0ZjKRBpBfff0%2BvE7Lnz0THGjJ1Ae3Fncr34MkvOnvA%2BTVF4MQibFIWra%2FwjJprhyuGF8RUfGWGTB6%2BmdQ61WF8tHQPLcaBwUqiFfRLQUnsdw2QxDgseLn4KMRikZAMF3jEHffz8eGKOS4WfyyN4vGCY0xA8oxkzZptuIj1uI%2B7P%2Bo2b8EbBjoOGN9q374Cw8Ei44TpRGr48ebGB4Jl27TsQlkCfkhgzsIE%2BOnPWh7NoFMg1iDYGJglIhsOIu4pMhJ%2BOUoGMgE2kp%2FvwnZFfSEB6EzdC1AE10nY8Smo3JTA2sJAAD1JiHsc5Gx5lqxoPEWtxqCmfbfyESWg4OOxmYFhgrR2Op6FjYAbEcPnbt%2B%2B0ecs2bMTzZcyb5tB3aD74mOifcOMggxlXEf%2FR%2FM5jLfbgMiMTISYwjwCnHq8ZF5VTpOdnkOxM%2BiA7RxhXACFWgVMYBh%2B6iY6jWD5pFI4qUs9Jb9v7ZGk1uXDJidUBHVOQ6HSKAgUTbUjJCDSt5pNRx9ijBGzmMoQeag%2FCEdiBzxHaSL0AxHLcf4rCf8cMjqRHxyAXfYR6T6yIbSRE2UYCRmIeVWMAQ92mWa1bz%2FAD6Xl%2FfyOeMMxw1dExjHRgLgfqJaiGI8gUDEtjMN2KF092upVrkLZTKat3MhoJTzLFMvDQH6jIqIW30zHgzLwhIkPoTaZaAJm2mxIwjdECZDQHKsIewki4QHjlDTWa4Y0Eh44EcxLTOjRDLkxgYvYtdQyaDxzGADcJxJPQfEYC%2F8uaxlpD0dqhB2kaSwaZnoW%2FeQjCcdM1QEYKwwCyGBTYBhy6lRgP8ho76TtSslYMzaQvuFJoMpcDG9NM%2BL%2BeBFal2hEBERABERABEXgECXAzMGjwEJuOEcqdflQE9652OgZHuMfAr%2BE1rIRtc6uTKYh43uV25tqXfU89Xf%2B2UgbxGG%2B28Jy1LYh1Mu44o8RYddc6huV6s7zGqLET0lgoAymDGAAW%2FGzdrkNg4D29jzWjOoZlpP0OD%2FC5WyYYw%2FaQPeluNBz70sw%2B8zWIdvA6cTL1qcw9ktGXruIU8I6S2%2BkY9EuGdAzuz3l5K7pEiZKl8r1X8LXX3yIg4Y038yI%2BJF%2B%2F3rxl67fz5uPG29xXcyPNzXypTz61Xx%2Fj1VffsJxcriMCmZhjQuwE45BJ%2F0gE6Awmu%2B3KSnnaTjmpr6AvK33F%2FA5cRXNnbq64l17Kg44B8K3bdqCuMI3FuvrwIEaOHoPIwLNjTEVVIHCC59ck5kk0bbml50t78Sx4dPtJ6c8QZEx7iTAZO34iGVu1%2FvXFF19BMKHVbOwQPUIVTJmhCYgwOPv4EexgBo4bDj6PpI1zZN8ijlgHM6pjUBFxETiA5i0J0OCICYHAWbPfcHnwamkRwx6%2FD1fZwOEIj8g5S%2FQ7OkZKFx%2BmQJxxfEbswWvDI%2BYBtImTITvFsqyBfb%2BwT0ZaQYQ%2FDjW%2FflaLMIlTbAwGnCwqsreK%2FYYNmxC3RgfhYjNZgAQ4qtRO1fiMpmTmwkCS42ydu3bD0cOL5BS14PNSo1n6gLrQGXBCbS%2Bb%2BO95JYaMsSQNHYMEpGT84KgCgVgL6sUVxTHkOEba6xh8RaqiOpiQ0fQpVjENh2fx%2FO%2FA8EbHICTDZKdreKxPsSa4hZRMDkJPIL6FvqCEAYMGW3yoGoWHLkPVoZkkZnFRpicwGukOzrI1a9bS6Bh45ZCEMMnMhYOcQnYW67BEA3LZk6ci7ERfwio8d0rzOO5lmp8eHQOD6S%2BayUiwH8wYQCGQYfCgjThUSvdROM3hcmCfKBczTgjJMDOhEMY5wq8Bk1NoqSmBUcESJYgMVEqLyJi6WEJ0qPSWOgb2oCSgF8GZTqRqh%2BxgofloMlyqFJJaxyD%2BgU6kBFvKJs0Zb4zPNHQMILBREVIG3U16tCwuDa4g00EOxOhiOpofIgwjMcYYhYr28hPH5UzVlMMtB2ILchB8oGR0DNQ2GmiNPWZsoVog0dBSsjM8KI2M%2FI5Zyexr174IiIAIiIAIiMAjRYA7hNQ6Bqvp4RahYYCCewyjY%2FBMinuJzIITFxt9JiC85sizuVKW93SIx3i%2B4bEPO59YdyiYZOkUMYxh9%2B7Ls%2F4DMxSsp%2Fy33EHNcKrXyGW%2F67349feuYzDB48BBV9b59DxxIjk5c0QMWsRiaz%2FXrLN02cq0WxccfGX4yDE8WUs7WRpnYzMSO0H%2FZqKOwR04btcXFSu%2F9XY%2BZrXjOPCUHxfDisfg%2BenLr7zGg1F8AXMV8OS3%2BIclWY0Tn46nydyrIwJs2rLFRAJQYMDFwNKffv79Dz9xyTBdhXiMHbv24FiZkcmNNymt2%2B%2FIPxbOJWOzFq3ezVcAP5fEXI8kW7t%2BIxqC0THwO9AcmHViFUXtTDlh4gk2c0levRb2xZeVMIzHx6xZitJCIWymXsvX4EjtuvWxCn%2BNaxnbJk%2BdbtMxxtniMfBlKJBADuIfiP9nw6dgIQW8WpqD44BXwm8C1fGVmel85YE7KKyKqI59vBj8L%2BPqZkjHoFgeLmMbvYALZsymyTj1%2BHrMO0CgOOh6mI2Hy3zatJrYO%2BgYlMnKAGgLzOPAC2NjGQ18bdwowjboWSIiWrVqiyiEM2UawieVkpGW4nPh1xNmb5HHKkOAp8M49ViL8MK7j41hNtsOu%2BFNUwgl4LUR5LN0%2BQrWPcCVo2rj%2BlEaC7ngyBOEgLDAKfxK1lrEHoQpvuIzIlxQEY%2F10YvMYh2mNw1k8AIZXAwno2NAyRhJvZiEX4%2B6xVljCSYhuBGFQi8TccFEQo5TnYOOwdNw2kuBFGKqAwvokEFsOkZKPEZqHYP0VAQZRx0jJV6FIBAUCRrOJ0CMSXwlZgCHFAGK4c3%2FL0zSoVOMjkFfYz%2BTMjDD2G%2FkJq44bCYNTSDchYlCN7Efsg0JCqF8zMA7xmCWTyEvW3p0DNARHoDrjYRijQQIoEhwikophEqBbA1CxCUqRVKgRUbH4JIx3Ox1DEOG4zSZuAuWfUDQQHlDyuAyZ9IEYxvRwxpClEkDiVCh3lvqGIwKhCkiHFgNgxIYnMgRhDkxc8SiwQ7ikpFKjI5B%2BdiAMRwHO7NpiDrjxw2GkEw7HgOkYMQehCxaTYAWQ4JxQlCEdfmTwIxMrgt%2BOTGJDrX1rIcnq69gLY2iBIrCBn6m%2BJEh3IsrkeazhC%2Bzb4yOgchmGFIaiVnnhHVQzAVFRhO0w9hwWKrXjFV9ioAIiIAIiIAIPGoEuAP5Lx0jKpq7QVwJ7n%2B4c4AG9xXcIhKPkbk6BiXHxsYcPXftlaYeLzB%2FxG59DPafrHdszNqLN5IzNu%2BABztJScRN3%2F0fC2UMHT6KxRZuKV9wMGUphvrVatbhHRk48ndfEzMakpJa%2Fdqe967e3ftKCFNhhTQmPjBBICn5nlrt0AraxUqevJnl0qVLDqfsvxIaUbVajZWr19ofzNB%2BXAZlMe7bMyseA3dg5%2B69eV59HX0Am4l5JsKZR7ovp8wrYZ85F%2Fj1%2BPiEYXMJwBcv5pU8b1jrfNrmlfz7SVbDMBHyJEP%2FIaKjdZt2ZOc2HsUAB5aSyc6nywHX9h07s54JlxW36DgFXGhcfXg9%2BPusj8Ea%2Fjx35gjO0Tfffm%2FWx8AuNAReL8Lin8EhtlgIvCrEgypfVyVkAjec0jhImD1hGBMmTiGIYu78BYAyv2OUxiwAPqnR1%2B8CL0P58aefOYvNtJpJ7jmffMbEYzADAoOnTJvBcRLQHMSEQ25HzBqPeP24mSgbOCOUhuV4izzh3%2BdygFX4zLqm5Dpx8hSyAA4%2BeUmWWsdg7QVoYDOb%2FbwSzMNbgT%2BOtm09jXiby8NGMlwwqmaWPeVTF82HGpoPXjNF4eHeLh4DLBSFv9y3%2FwBcJzjgZEEYL4%2BnuvQskgTqgVlegGe%2BpKdA3FjkC%2FxrdlhqABeMaRGcwhKqRqXhkT2WcwS3mnLoAljxFfMwidkQiAzwoddAx3GbwSx5OmUaz6bBSLGkwau1TrEkAk2gRVSB08cwICNtxxi0Gp5HEz1CyaQ3QCiQ6QwYhivKQUOACBbGGCVgBmMPMQQdgxJosjGJXJzCWwcIeg5G8tXEexAEwll6lv61YJr2YgCrPOPd0wTQ3TIeIw0dA52HXBSF5YwHNsqhIkQVHFjkFNBRNa1AY%2BEI3i5W4V%2FT4wxppA%2FS8wlqWsQneU00wuDBwzhFYvLSTGjTg9RCe%2FHK6VzOmhqNjnHGx4eKSGltJKZka30MvH4CbAgVsEUmJNjKJDuTU1jqAW%2BdsAHIABzI1Ei9pKfrGbSkTEPH4Cx9jc0MCXKRHdeePqUtlI98Srt4P441hPhZYN4N0Gip0TGYf8RZyuEIG2dZT7VFi9YoEnxlcR4iVVgrg%2BYYkhw863MeJQQCmGp0DCJDDCuYI8jwHwdlcoTJpKiRDIm04zG4nBlFsKUKcmEe0VnUy3Cic6kIjYtP9hlL9B29yQ8mifmd4S0zKKtGx8BaeopLgFOgQLSh0%2BksfgNZUAiBAuGCcmgslsMNZYkhB2oaZS5brj5uTpBijL5KYm0iIAIiIAIiIAKPLAHupux1DDhwhFsIbqjY4Ss3FX%2BSjsELVVcfDGZVTwcRw6Zj1D%2F222xfKs9QMAb3crd8VegdnWvecuLmfoTb%2BJGjx6FUGBGDHWvfOsKrMZh3gDcadJtlMO9Yl5XgXnQMXkbLzTMihsdxz3sPQbFMsnYWLVnO3BkkndDQW6%2BSwb03c086desZdg%2FLaEAgQ9cdt9CZpWOwjAa33KxKUe2XGtxjc3eNT8dLQIh8wFuhohPep5lFQpgEEdR4rzw0JNQBT99%2BnU%2FEB6Zg4K2TACC86wQdw2THcWaOCaoCk%2F1PnjrD08YKX1Qk2GPXnn3cwJP%2Bx59%2BQULBReLmH3%2Fq5%2Bq1KLxylW%2BaNmvJW1NLlCyNJQRdYAkXYOcu3ZE1kEFwXti6dO1B4vYdOpurlQQcRMcoVLgYL3g1XjCnKJngom%2Bqfo9ji1%2FAFU2LSpUqs237TtwWYt0%2F%2FuTTp59%2BHh0DUQM3kDe9UsiixcvwqXHVazrVISKFyBAMRhshAAP%2FiEvM%2FCbgCuGY42jwaJXGohgQmYMQUa%2BBM3M6qI5k9joG77jkMS6OLf41Py%2FYZukY2I%2BXN3nqNDwgPHR8dvwjNorF5cHjRv1AMWAWAwbgEqIJ4FQSpYA3lIaOATo6BX9%2F7boN2IPlZsMtIqgAy7GZEngETGlE6TPdg2GAWovbNXb8BNMvtIiHy%2FQ%2B0Tj4lSzjibfF7yEZccYhQDAPD7UpClcRpxgfE%2Fvx%2BxAfePLOnC%2F0E3jyYg68V5agJCXHSUZp%2BIO4h2DhwTS5GAwcR%2FsykAHIDvoSTcDZRC6AGxZiMC6keYaOf0eBvM6DjJjBogSE6OCWQhIRBgIITfituIpkpDokAnxAJtcAlrM46cR7MPKhyhEjJZGA0AJQ00HUS3sphIrIfhc6hpHRaIu1AZZYCzqU108gBdDRXDJANvNK6Ck4EKiA2TBkZgFNY5YECfD9yUs5uMn16zckMAB6jFsWlqS9SEDMOiHKBYMZgQwqUvJfGDoGQxchgpkm5vIhDXEynLXXMSiZEYWqgJtMSnCZaJwRI0eT0iLD%2FxFcO5iNYQwbvG8z1QV%2F%2FJbxGIwTBg%2BroeKA01PQIBmJFy5cAlKKorMYivQaHBiBBjh20juMEMIeqIhBzhF%2B8FnikvTUi7hhUNBAG42Ul%2FPaaJz3Y3hAgClCnAImtXOW2AkkKRrC5BfaSI%2FTv7SRHdClPa8EO9EnGerEDmEJw4ChiBlcGuzzNIIVd3v27INGR0rKpGdpBT9xtJdLj2GPAfzGggLdhn0iSfwCAvhBMJcea6fwC0PwBl2M5UTjcL14ep3EZl7Ew9igK5FQCGeiUi4Beo%2BUhKDwy2BuUWiXNhEQAREQAREQgUeQAHcCDjoGELh%2Ftu4QuIv4k3QMFvzsv%2BSCeVkJIRnmHSXmBazs8zLWwCsR6XlHidVrUTExLHBhOePp3yEMo9%2BgoSy8ULOus5EscNIbNm3Je0XZMUf4fLDvK7Gag4jB6xh4bSI3e3f3nlmrqNvtxMcnjBk38acaTl269z7s5s67L0xKNJOAgIvTZ82tVbchMRv3uLIoopPlslmdmMYO9%2FaTp81EYDESk8Pnz7XqZGh9DEY499toC7xuFf%2B9SNEPeVsH8yxwi7j%2FZ2P11EJFinEk77vvsW4GN%2FzERfxSvSZ%2BFk8zeSUxrzdt1qIlr15FzcDlR8TgzSPGgeKWHm%2BofIWKuV54CTGEU%2Fnyv48vQ3s5xfSBJ3I%2BzQtHAi7a5phzBJ8df61c%2BS%2FLlvuCF6Ow1uJbb72Lh0uTSYBf3Kx5K17M%2BuZbec2yFTh9%2BESGHg3BCa1e0%2Bmvj%2F2ja%2FeelGYYsoOv8be%2FP86ioJSDYbPmzDOm5stf8INCRVgbJFeu3LjwyTdukJjoi08%2FK8uyHtCASd538g8ZOoKieISOt44x9o4DbhQKBi45%2FiZuOBs7uMB4LggF5teDOR0cwUEjMUdwSXijKK8ZxUPBckLiyYJrA2pGMnj5ygNxs7VE8kiJTMBs4PD8l7P47%2FikODuIHiFXQ7EZj5hQMWQWCsFUKiL0HcefJTqp0Qgg%2BHfG6bOw4OfSj8zaMLmIuGDuCZ4a1uJn4cmah%2FvkwvsjegE%2FFHcM95MExLSb1nEW1QWvjbwYi%2F3oGAcOHuI4G5M7YOLcqDGtwGYsZxFFWs2Gv0xptAItAo%2BMZHjHNBM7KY1QELIbU6mIrkcOIjG1k553UdI6TMKxNb3PJ%2B98IQH2G3S4eLAiOogyyc5sIGM5%2FjLJGBJmpU1qwSVk1OEtYgaOLTRYqxaHkSMYTC7KhBJVYAlOK%2FUSvmKGAfAJV0AGoR8pijRIMZQDPcrhiE0CatoitY5B0xiueN8QM2SwgaAU2oWrTrHkvRwSwjqujDqq45UWOMWMNAYqtbBhCbMSaCPZIYljTsQI8h2DHGvpDhxtyqEizKYijKQttN1s7Ldp0w5ZD3UC%2BAhH1EhKNio1Kc1IQO9C4eEsG73DJUCl1Eg3QQYViKEOZFx4tC9LPCG6gB9njqB0YQZqCbElNJa%2BoxWUz7DEx6chFIuDb6QJTjai3BatoIolZCQSCctpnTGbHQrBBi4xAJKGBlIIXcwFhbDDbwK9ZtEwrJDO6AjMxmAmxdDqYcNG8pWU1EVf00ZaZHqfgYRyQjJ6EK2GLMiwlEN1BHLQuRjIeOY4Qx0lExGDs0ht1X6ujt4LClKyg52kpFiaRvm8roXrAh2DEcIit7xeh8YaFHQcKKgOpZcs9DXHoUAVvKuaaW6mpUiX8ESABQu1cI3Yrvp5C6jdXCn6FAEREAEREAEReAQJcDuUWsew58Btw5%2BkYxCP8cOwM7y1BCnjhYYeNUed7TrP753WnrxuFVkjT7PjHj6hrKtnb0za%2B8xTuJ1jnvZxdIwBg4dZy3uyPsbIMeMDg4JCQq4u4KW0f0RoGB3j2rXQtEtL51krHoMFVG%2BXpUPn7paQYqUhMnndhk226SSZtLCnVbL9TlRU9MzZ6BXOP9eq27Frj5FjxrH%2BJ2%2BnRd75sbpTxy49mJ5vn%2F7u9lkig9cDpN2t1lmG4n7Xw207dG7SvHXTlm3sN94j03fQEO6T039ny8jnJnnLNmZqT0FhMFEZCxcv5ZGiKQQvw%2F2oB24gbgJOSvDVq2DfvnM3uTiFI8mbB3GF9roc4Jnp%2BImT8Bwx1fgX7GAtRfF2G7wYHkEad4%2BzbLgePFrl4bj5igvGJYY8QpQIRyCJQ4dIwtqe3PxTFPaQBa%2BZB%2Btsq9esxx%2FBBnsy2M%2BN%2FemztsUkzXHexMoDX1qEC2a1aP%2FBQ1OnzaAzcRz8AwIZezizmEoWfARSogmwXgQLGxL1TRVsuEK4eyyegG1WjexwigZChkfe5OKRN14%2FB61kPL0lSgHLTTOZDsOjcDQcTOX1HPh3yDWGNs9b2efpNg%2Bmb2679nDE5KVMnsMSyMEDXCBA3ng3FEtGjlsN5AgpebZLw0kDYWQNe5vZBwVpCFfA2puGhUfQO%2BgMeIj4pLje1GhysYOfRc9SNQ1kYRCyk4uzJi9CCgSQaDADvcU%2BIy4wASFMR0LwMWSsMnESt27bgduI3MHTZ%2FgD38QDAMcqxNRCQ3DMkdcI0We1DbAwYEyXmQR8AhYyNIHexAwIYLaxkOwsnoBnjQLDo3OwW%2BXTFhxYbGAhFMQB0lvtpUU8yge1KYQqiJnBs8bJxXnnqy3vufN0FgWaNAxggGOqQQRSvpKLxA4b6TlO1yA3UQtjAGvpEfoFA7AEdHQ9bcR45iYQZ4LTTcSFsZzyOUuUCCE6lEDQC9IKp1geg7e%2BoopQDmlMpZDHSIAw4G2fKTsYRiuwAWecsBPsITGfVMdXMxJIw%2BgyNXKWHTMSIANMOJtW88nPDoWjfVnlsM8RzOYIlrAcClc0%2FcsA44qggfbF0nz4I18gZyExccqUfDHw0k2bUyyn34HP4ASLqcg0kPL5NYAGEVZYTouAYzWfHWZzcGVRuBk2lIDAwthjXQ6GCtcOJlECKRkM8KFD2cdIIqysa4Qj%2FEaRhfEMTyNOGmh%2B%2FgEMIQonDYbBEDtpKe2lcK5ELg3zy0wCKsJC5CauGsQNTKW9tIiWItdQOzR4pyqXG%2FxNQxhXXMemy6iRQmgC6Rk%2FtxxdBos%2BRUAEREAEREAEsj0BbjwGD7FNOGUHpyn1xqNn%2FBHeu8pMW4dl8%2B8FDgsDnL4QXqzTiZx1j37U%2BeS8HZdCw6NY1fPAyWs1Rp59odExXmUyd%2Ftl3mySzlqw%2F66DE9Ax%2Bg4cYnQMxIrmrX%2B13kWSkJDQq%2B8AIyZwilUjMlfHoFJUAiSCW26459bcFksoYP0K3lV41421yknPDrHNo8dNaNn2N7SdH1A0nJv07NP%2F9zXrMuutrKxnks7%2BNcmQB7it5f429YZjwhjIUGn2Yx5HkjtqIpwtF4OiOMgVweNCPrl%2FNvumCu69ScxBczYuwRbC4WAARZksPCmmKMs2U685Qi5u6QnYqF7DyfWwO74S9%2Bc%2F%2FVzdrDJKLSaXVbtljFWa2aE0PD574znOV6q2P%2FiHtbZGUSZNsKogPfsOBnPJ4yIRpYBLThaHSimZLDSHojDAvihTGgctJuyQwErGVwo0tpl9vjpsVl5TPolNduu4fSHGNlJSCJ98JTGbg818NWksLBTCM3RsM%2BZZhZuMplKraofSTC4rjf1ZK4vVZOusxYFTJOM4OzyM5hE2fp85YiW2mZeyqAg7pkzTOiuByW4s4dOeAKfIReHGBj4d8poCOW612hzh0xToUAsprSOkAbWV0VZR%2FH%2FGACnT%2BP%2FCWEUVJKMcy2Z2bKvItmjJHBYkEaQAfGHWoiROAGeclKZ2K7spga9sOOAEABC2wUEHIx3GldUKduwTUwhfzcapW7LCBodclvFWpQ5HUltrpWTHFHi7Yu0tT22SKSft8jGGjFZeK7E5YurloLHE6lCOsM9Zy1SwkMV82h%2B3lZ%2ByoE1qY0jGRjmkSX2W0ky9lGl0DBQPEnOcXNZZMrJvGcZXYxsHTZn6FAEREAEREAEReDQJcIPBGnoEmvKAmAeOPLVx2HiYSGAzAa48K%2BF%2BI7Mo4Tdscb%2FydgvP9rP9eHEJX7mho3CEi2vhkTO3Br3%2Fm1eb6b7cF6ezRu5q0uOY3zINExxmzJprm1dSpwGf7Tp2uRZ6M%2BiCmAdiM6rVqGNO9RkwODY29paFZPQg8RgIJugYvzjVY7XPW25UinbAbBe2jJafielDrl71v3AB8YRXcsTGxmViyRQVm8HbUW5ib72leuybzpHzYJNxAaLJsMg%2FE0aYvVK4SHEW8GRmB5HzGGbu8x%2BghVzyRHETDI93mYmX%2FwNs0UNYNWOAR%2F%2BMAYLwkekeeKc%2FWEQ80Gf9B%2BaMMOqYC4PGzowDwoHSJoPnS8AAk1MIXNFAfbA9mNHa6S8UDPqOwCf6MaPZlV4EREAEREAERODRJMBtA7GjrKhWt54zc05Tb7Vq1WbKLbHxBHNm4g02jxZ3Hb%2B65uAVZgnzEMwePmt7Imsc9wmdvf1SSCgTgR3Dku0Tm30i%2Fe%2FxnR28%2FoNAeh49E0zrfuRofPxNVQSJw9v7FAcJvnUhSD4kJLO8eAwePGxE1x69u%2FXqm54ts%2Bp92MohsCQTx1XqsfHwH6H5bCwBynxwFgpgegiB2dzbPyRYmFyAhmm%2FOMbDjzRrWUhHE2%2BPK2cmZWQt4%2F8MaxF2mLDDkpgsn8JEpKPHjjMRKe3LgbNMdjBzJdJO%2BWcYrDLvhQDdTd8xucaam3MvpSmvCIiACIiACIjAo0MAKYMJxUc9PJkbnno7cNCVeSV4MdxsZC4TBAreLogEcctiETcc9I1bJjMHeab%2FsLnnsif9BBJSVoFIo38fhVMETjPLg2kdfD5UIdMoKvZB3Y9CX9z%2FNuJ6AxnU97%2Fqh7NGM%2Bqsz%2FQYyf9QXD6Z%2Fv9UeqpWmnskoL67R4DKLgIiIAIiIAKPLAHuIrhjxHu65caph%2FkJF2rI3b1uNf2OtlL%2B2QTi4jXZ%2BdaC3iP7o6SGi4AIiIAIiIAIiIAIiIAIiEC2JMDahKwV%2BWd72Sr%2FPhBgjclsOUTVKBEQAREQAREQAREQAREQAREQAREwBBAxEhOT7oOLrSruD4F4Xn9wm3lGGvMiIAIiIAIiIAIiIAIiIAIiIAIikKUJMJ1EkRj3R164n7XwGhd6NkuPTBkvAiIgAiIgAiIgAiIgAiIgAiIgAg4EYmLjtCbG%2FZQX7mdd9CzLZWi9Pocxr68iIAIiIAIiIAIiIAIiIAIiIAJZkUB0TCyP7O%2BnW626HggB1IyExMSY2NjIaNtrSRmrYRGR2kRABERABERABERABERABERABETgoSaQsloCbixP53m5apIUjAeiKTzQShE0mEDEQijxCYnaREAEREAEREAEREAEREAEREAEROBhJsATeaIvkpP1RpIHKiWochEQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQARG4E4GLgYHaREAEREAEREAEREAEREAEREAEREAERCBLELh0%2BbI2ERABERABERABERABERABERABERABEcgSBO4Ur6HzIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACD55A8vXrl6Lj1vsFT%2FcO2H7xanh8YkLS9RPXIheeCxp23G%2F%2BmSDf8JiE5OQHb6gsEAEREAEREAEREAEREAERyLIErqf8OZh%2Fy4MOafRVBERABOwJJCZf942ImX0q4Metx4qudq2%2Fy2uNb7BfROwKn6Cftx17fbnL91uP7bp4LSohyT6X9kVABERABERABERABERABEQg%2FQSio2J%2B%2BKbFsIHT7bMkJyd3bDesacNekZHR9se1LwL2BK4HByfs3h2%2FbUfith0he3etP7N%2F4%2Bl0b6dctp85eCn8in2B2s%2FSBOKTkk%2BFRo339C%2B7wa34GteP1x4uusb1263Hpp8IGOFx%2FstNR%2F5vyd7ym45suRASmZCYpVsq40VABERABERABERABERABB4ggfCwyFxPlnSu08XehqSk5ErlnT8q%2FFPotXD749oXAYtAkrd3eMUqIa%2B8ceXFVyOez7Ox8Ns5epfK0f3j9G%2BP9fik2DinoxdPWmXe407o9IXR%2Bw9RyNXQ6N5D19VsOqvhrwsatrNt9dvMGzd%2Ff%2FjkRf6VnIPrdD55zKdJx8XOKWcb%2FbawTss5DTos2bX3VPi0%2Bf5VG0Vu3H6PljyC2RExPEMiBh3xKYV8sdq1tcvJAW5n6%2B7yZL%2FUusPfbDlSaLVrjsV7y26UjvEIjg41WQREQAREQAREQAREQAQykwA6xsvPlW5cv7t9oegYX1dsXKr4Lw46xtWrYQnx%2F%2FUgleknJmN8fAJnrUKSkpOvXAlNuNVTV5LFxsZZKbWTRQlEte8YkitPyGt5Q15%2FJyJP3k0lCuYYUPb%2F%2Bnyezu1vKSlzdC9Zb0mPzCJwafJCn8Llro6anBQd4xsU3qzTolcKd325ULfXi%2Ffk07n7yuAWvb1yvOqbq8zere5vl%2Br7arEerxbt8cL7XUv%2FNH7puFXnq9Y%2F%2FW6Z6BVrEmLirIGdWbZl73LikpL2BF7tfPBUybWHCMPoefjs4CM%2Bg46cG3bUp63LyfdXueZZtj%2FnMhfpGNl7GKh1IiACIiACIiACIiACInB%2FCBgdo0mD%2F9IxWKnPXsdA1li2ZNNnpZzefLlcgbxfdek44trVm3EandoP%2B%2BHrFj06jyr4TpW3XilPriNuXnNmrixSoOobuct%2BXKzaiuVbrIbs33ekypeN3ni5bIG8lbtSiII9LDRZcCe8Ru0ruV9HxLDXMVAnMrT9T8%2FSZac2zazW9x%2B%2FbUTnWeer1r1Q8ZcYF1eK3bTL%2B%2FPvR%2Bcp0v2ND3s17b36Stv%2BJ3Lk9Xu1vMv2owU%2BH%2Fh6sZ5vl%2B7ffeCqM31G%2B75TOqh1txvhoW7elxq0nR8dE59ZVmX7ciITEzf4Bzffe4LQiwob3fu7nevvdvaX7R7sN9x9YqD7uW4Hz3y0zu2vS%2FYZHWNrxueVXLgQ5Ol5xkH%2FRGs65X2eLdsTVgNFQAREQAREQAREQAREQATsCRgdo75T56ioGGsLD4%2BqXMHZFo8RGkHipYvXP%2FH3IpXLN5w7a1WXDsNz%2Fr1IrWrtTKxF3Zod%2F5YjPyknjF0wsN%2Fk3M%2BVfjtP%2BQ%2FyfT1s8IxJExay885rX5w%2FH0Ah7m5eb%2BYu%2B3np2vNmr%2B7WadST%2FyjatkX%2F5OSb4Rz2JmWV%2FYSEhJiY2CzdhHtBHV6rTqboGOWmNbsXM%2Bzztu%2B9Mleh7k4tZh3sPPJC0QqXO%2Fe%2FERl5OTyuU79VrxTp5txtpaVj7Nt29PWSfSo4TV0%2FZXVAFafzJSon7Nwbd%2F3G2Fl73yvdt3D5QbFx%2FxV3ZF%2BL9v9D4PqN4Nh4VvKst9OzyCrXKluO9nY7i3BRdcvRPMv351i87%2B2VB5x2eA454tPV9XS5De5PLdlXYt3hWd4BwRmUiZo37f3m6%2BWRMv5T9Y0b%2FAQVLfw9W1KiVg21B6N9ERABERABERABERABEcjmBNAxXnvp85ee%2BaRowe%2BKFKxq2wpULZT%2F2%2Bdzlij7iVNYWATPQD8q%2FOPHxX62wicG9Z30xN8Kb9uyHzT1nDoxLeW4x03%2Fol3rQf967IPZM1YaavPmrM7596JrV%2B%2Fga91anZA4%2FHwvmlMtm%2FYlo5%2Ffza%2FmYNb6HD5ketnStc%2Be8ctaZmeWtf%2BtY7yz8aP8OXqVZJ5IxrbOxUpPapBZJrXtsfy5fJ2eL9D5vQrDxvScc%2FKr2uc%2BrBi9eeuN5OQ1m48Pmrrrcut%2BxGP45il%2FcNfxbj0Xn%2Bo5zC9f6aA23ZPDwl09L1apNem5fB1zv9%2B1ULmBsXEJmWVVdi2HV5NciIxdeCaIV5MUW%2B364zaPAUfO9XM%2F99n6w88vcyH64n%2BW7OPzleX7q24%2BgpTR6%2FDZipuOFlrlWm%2BXJ29lDYmNJ%2B4rnXAaOXd%2F%2BaUyx4%2Bftk%2BPjlEwfxU26Rj2WLQvAiIgAiIgAiIgAiIgAtmeADpGnhc%2BLVOyxpgRs0cNn8U2evjsEUNmfPDuN5%2BWrMn7Sk6eOPvCUx%2BPGDLTQuF5%2FAxHRg6bxRHiMYoV%2FM5aRmP8mHkIINZj0%2B1bD%2FzrsUKL5q%2BLjYkt9eEvxT%2F4YcrExWzTJi9tWK8bp%2Fa7HLWKzXI7bZv3f%2FbfH3kcO5XlLM8Ug%2B11jKsvv%2BlbsdK6swfXe%2B295bbhxK2Pr%2FPavd8n08bAlv3nRs5xGbfg4Ji5%2B4fNO3D4dDArdoZMnJMYco0mE2AR9OuAEznePpu7bFxUTILXmWtjpkcfPMKpK7HJU1e4DZ%2B5l7xj5x%2BYttwtMSk5Uyhl40J8wqPHefp%2FscG92OpDtXYc540kXV3PFFlz6Iml%2B%2F7CFJIUHYPPx5bse27Z%2Fs%2FWu%2FdzY7mM81W32N7HivSx6GzQ5XRHZTRu2OOV3J%2Bm1jHeL%2FA1m72OwSQ4AsC8vM5ER8faww8NDQ8Pj7T1dfC1Y8e8r127uZhPUlISUqSX5xliq%2BzTs8%2BaP97ePufO%2Bicq3sMBjb6KgAiIgAiIgAiIgAiIwAMlYOaVNHXu6WDFN5WaMFuEt7Lu2X0Yb33e7FVWggv%2Bl%2FK%2B%2BkWPLqM5go5RJP%2B3IVdCzdmbOsbxm%2BEZxGwgVixesD748tWCeavkyfXph4V%2BLPb%2B92zEeLC%2Fb6%2B7VWyW22nXaiB6znGP%2F3pGbLWCyfuXL4WcO3fB3j%2FCM4qNsa1xyhQe%2FCPjWFlZ2EE4Ou8TEJOSBvg4ZRwkJMbkMinj4xJiomPtF6Jk6VR8MbrSvij2mfNywT8Ix439uLh4NvsEly5duXDhEu%2FYtQ5Sr7211vFb7tjrGCG5X79a9aeohMSo8MjbbBG3OR4ZnXnv9o3ZsitsxKTwMdPDx04PGzM9ZNTUq%2BNmhoyeHjJiCtvV0dPOl651MkfBUzk%2FvjJw3NWRU6%2BOnckWMnIqZ0NHTSOXLe%2FoaRFT59%2BQ63rLXrc7OOf0xXIb3EqsOdR494nhx3yb7zv5wWrXf%2F6hYBCMYTakDLanl7p8vO5wh%2F2nRx31rb7N46M1h5y2H9%2Fgn9637hodw8vrrF39N7g6HHSMAweOflm%2B%2Fou5SuV6rmT%2B974aPWqONearfNX480%2BdWrXom%2BflT3M9W%2FLtN7%2BYMnnx3r1uZUrVyPXcx2xlPql55MgJq%2FzNm%2FeW%2FLDa88%2BW5NTnZZz27XWzTmlHBERABERABERABERABETgwRIwOsbt3lcSGRl15rTvi09%2FPLDvZMvOo%2B4nn3vio7Gj5nIkPTrGovnrcdvRLr6r0jwyIjoiIooNfxkXg4ehVrFZbicNHQNxg%2BVPcz9b%2BsWnSxXO%2F%2B3iheuN7DB04LQPP%2Fihd49x771VCQ3k%2FXe%2FXr50k2k4z3zHjZ6X%2F%2B3KHC%2F03jcdfh2a781KBLRwtuZP7X74poX18pdOvw39qNCPrHzIKWQK57rdXslVhopYZ5VwGqQSUyCBIt9WbpL72U%2BYv9PEuSfRNRhsVvMgl9Mvv730TCm2Cp%2FWdT3oQRYyVqvaunyZOmGp9BBToMOng44R8u2P12JjrmX8j8fkDiXf9deLtbp65Xj7ZI73b7d55yjs%2FT9F2W6XIOV4gdNPlbmeIiXdtSWPQsZN%2FiGdXc%2F0OHx23HG%2FJntO5F%2Fl%2BpfFN8MwLBHD2kHKQOIotuZQu%2F2npnj5dzxwepC7z6Hgm0ERd8SFjsG8EsK3kP74yTJbSEhogfe%2BsuIxuOjefKPCW29UGDNq7qKF6yp96fyvfxQaOdwWNsbfZ2Wc%2Fv5YgeJFf5g4ceHECQuZjYLWwZob9et2WbZsU5fOI5%2F8d9Hvq7YwOt7BA8defrH0R8V%2Fmjd31aSJCykzf76vfM5dMEXpUwREQAREQAREQAREQARE4MESSFvHCAuNYDXLzz%2BpzYqdvilLW6A8dGw39Kl%2FFnPZZwvIr5OOeIwFc9eSEsUDr9kKwMDpmDltueV0P1gId1f77XSMS0FX8r1ZMf9blZYsXL900XoiT5hrQ%2BA6tfTuPvZvOQoUe%2F%2B7CWPms97pB%2Fm%2BQeW4fDmEU6t%2F38YCqqgKK5ZumjB2Pm91efbfH65fu4tTlcs7f%2F6JU%2Fwfb7xtWLcr74Lx8w2kLyp8VpdomamTl6xdvf3byk2f%2FEcRkwUX7%2FNPalEvU4RWLt9S9atm1NuoXg8WJKBPv%2F6ycd7Xvpg9Y8WKZZvRl%2FK9UTEoMBghpdNvw1o06YPolB4gD6GOEVijm1eOvCdzfHC7zTtHEZuOkQMdo9Dt0hCwcTrnp9Ix7jgGLkfHH78asS%2FoGut2lljnxrtI7FULFvnkiO3zj8AM29nFeyttPrrF%2F4pLUOjJa5HhfwzpO9bVpHHPp3IWK170R%2BSITz%2BpZbYypWo%2B90yJwh9UJTADnbBp417%2F%2FmfhNau3m9K4rEqXqoH%2BYBS%2Fz8rUypP7U0%2FPm9FTCBR%2F%2Bd9831Rp%2Bp%2BAjcqNcr%2FwCUFKlFbHqdMrL396ytvHFLV0yYbH%2F1lo3Nh5d7RTCURABERABERABERABERABO4DAXQMgit4X4l9XdzJVyxbn%2BUszMIXmzfszfVUSfzxgX0n1avViZU8mzfpbQIMalRrV%2BDtyleCb84rGTNyNs64NdViy6Z9j%2BV4b85M25wUojjee6vyW3nK9%2Bg6plunkbzHBBee%2BSb29Wat%2FdvpGOd9LnRsN%2BSAi03n4Q%2BtgGVRFy%2FcwH7vbmORHXbvPJRy5saYkXOey1nC7ZAXX7%2F%2FugUBFUwqMaemTlr89OPFN6zbzdcqXzQqV6aOpWMQPENKf79AZpdA8vflW00W1i2hK%2Fv0GM%2FXNb9v%2F%2BdfCtJf5lTgxWCkFec6Xfm6dNFG7Fm1cps5tXf3YSqaPmWZ%2BZr%2BTwcd4%2Bq3P%2FH%2BzciM%2F0VHR6e%2F0rRThnQZ5fvel%2F4FvzWbb4Fvz%2BWrci7f1z7vf%2BdT8Ltz%2Bb85%2FXQZ7%2F8p7P1Y8bN5v7I%2F5fPeN1Yu%2FwLfBJSudT32v%2BbgpF3vo3z2UnTc1JP%2BxdYeMjoGqsUTS13e%2B%2F3gZxvckSxKr3d%2FY%2BWBfyx1MWqGee%2FqlgshEQmsVpKBP3SMZ5%2F%2BqGb1di2a92n%2Bx9asaa%2FcL5Yu9P63%2FBaxInHJj34uU7pmlN00pbFj5%2F7fX%2FNv2byPmjhFSmvaFDNKHvtL%2Fi6dR1hG8EqUJ58oxkLEFMWskw%2BL%2Fbhli8vGDXs2bdo7Z84q5qqgk1iJtSMCIiACIiACIiACIiACIvAACbAIw8%2FftTaLdlpmMPuga8eRrZr2s5wCIrprV29fsli1Lz6rO2n8Qush5pCBU5s06BGRsoAe2Vev3Pb91819z998CwnvWv2qQkPzZhPOep%2F0adW0T%2BmPqrPyxq%2BtBmb1OO3b6Ri0lEUn1q3Z2avbmGYNe%2F1UtdXTjxebMc0mFPTsOgYJ4vKlm%2BrNogVrn%2FlX8b273WJj44sV%2FB6BwqhDpFy3dhdBL2noGNabX3bvOjSgz8QWjfogRiGSdPx1KNlZlzX3M594HPVmn7%2FY2NjiH3zfoHYX9jv8OoSX3vL%2B3MH9pwwZMKVz%2B2HoGK2b909JmIEPex0j%2FLW8e%2FPmL1wiw3%2FFihWrX79%2BBmpNM2mAT%2BDx%2FSe8XE96uXofc%2FH0PuydFBqWFBYWtfdA9K59N8LCApv0OJHjnTMvfJbo58%2FXxEvBkVt3xxw8fPVisPteT09bxpN8eruffmRfp5sm4FucvJxKx8i9fL%2FTjuNTvAJ4H%2BtYD7%2BvNx19btl%2BBx0jMoM6BvNKWNfi9GlfBws%2BKPgN80o4SHjSO299We2nNtYVxMHfV2xBx1gwfw376BgkZqKcKYHAsH%2F8X0F7HaNli75P5Szu7x%2BIDsmaGKyMwUa8BxtfX8hVqq5TR5NXnyIgAiIgAiIgAiIgAiIgAlmIQKas208IeJLd2pJZqPkOpt5Ox4iKimYSTa4nS%2Fz4bcvuXUY3bdjLpmNMX052dIw3Xy5HdIQpCh2DySO4VLxMoeA7Vdq3HWJVsWbVjjvpGIEkZrVVSqhYrkGXDiO6dhhBPEandsM4TuDH6y997nPuZnRHdDTrk3xndIzqP7Z96h%2FFkJI%2B%2FbhmmZI1P%2Fu4VpkSNfr3nmhVnc4dex0j4vV3N7%2Fyxt9y5nwyg3%2BPP%2F54uXLl0lnjHZP1GLY%2B%2F2cDi1Yc%2Bt5nA76pO%2BWAV9D1qKjANj38P%2F4qaMHKAyeDgtrY3rvq83K5816%2BZ%2FxsalL4klXnSlQ%2B3bb3oGFrC301qmC5QUUrDiv38%2FjYdE95uKNV2TuBo46xeB8BGF1dTx8LiYhOSN4fFOq8ywtl4951jDTeVwLh4OBrRQt%2FX7liQ%2FuparNnrXzsf99bs3oHCdKpY%2Fj5BQYGBrN0xhfl6%2FHepSNHTrIdPXrSw%2BO0j4%2FWx8jeY1mtEwEREAEREAEREAEREIHsT8DoGMzmcGjq%2BjU7%2F%2F1%2FhaZOXGyOex4%2FTZjEjGm31TGIx2Cli5JFf6r%2BQ1vr7SHr1%2B22dIyvKzYua1sf4%2BYCnrxchqCOgAuXeUfJS0%2BX6tB2iMnFa01Y1ZPVS6h34rgFaBoue9yMDfHx8fbxGCzcyttSyIUwZf%2F82qEhaX8Nr1n7Su7XQ15%2Fhw0dY0ueNx9%2F5plnM%2FiXM2fOChUqpF1R%2Bs926PP7C%2Fk7v168R4f%2Bq4NCY%2BJcDvqU%2BDqwipP7yp3VW8517rbiStv%2B6Bh%2Br5bfs%2FnIh5WGzl58MPH6jQSvk%2F6Va%2Fp%2B9v3ivrM%2BqzEpd%2BHuxb8cEhuXsYkP6Tcym6VMrWO89fuBPofPnA2zTRfyCIlosvvEyysO%2FKk6BiOZqXA%2F%2FdDqhedLHfvjPciM7Zo1fmPiiXkNdDp1DF%2FfAK4js7DGpUu2hWvMH%2BFP1kK7fxzTvyIgAiIgAiIgAiIgAiIgAiKQxQigY%2BR6suSxI97MQcCNMhttmD1j5eN%2FfX%2FenNXsR0fH9us9IeffCrOoKV9vGY9hlsto0aQ3kRVbNruQjDerElZhWx9jrW19jFq%2F%2FMa6Iqe9z7Pvd%2F7ipyVqvvtGRYI6Dh30ePaJD1k7lBAXvLb5s9cQ%2BMG0EZK57HUnu3OdLuYVrrw8l5feNqzXjVNMeGFeSfdOo8zkoKDAK0wOOpVSeFDQlYsXL5MmPX%2FhdRtceem1e9QxnnjiiUqVKqWnuvSkadV1aYnKwzbuOX09IiK4y0CfvJ9cHDh64owdH3wx9KUPujbtvdrSMVy2H81Xpn%2Bewt1qN5996jzuavLVibP9i1Xwatr1t07zi387Kib2pmqUnnof5TToGFPs18dYvA8do%2FfhM2dSdAyiMu6DjpGU8pLcTRv35Px30bKf19m54yDaRbt2gx7%2F%2BwetW%2FY3Kl86dQwTdDF71u%2BP%2F71QlcqNuHAoasigqbwtZdbMFY9yR6vtIiACIiACIiACIiACIiAC2YBA62b9%2FpLj3bKlnHgbyLeVmrDxYpFlSzZe8L%2FEEqa5nytd7fs2rM%2FJe1T%2FliN%2F35TlN5n9wUtbLgbc1ArmzVn1%2BF%2Fe37n9IDS8T5zjRasEVNSs1o5FRQiZQCRBc%2BDU3NmrnvxnUdZZRdDg9SKP%2F%2FWDvK9%2BwYIArG3y1RcNCdvAAGI2eL%2FqP%2F63QLXvWpGFZ8dtmvdnRdYKn9WjwPferPTvxwo3rt%2BD6Iv4hAQWJyHXl2UbMNOEAl95vgxaCrJGpfLOpUvUuHYtXS9CjV%2BxIuTVt6%2B8kOfKi69GvPjaxudy%2F%2B8%2F%2F8k8kQz9EY8xbdq0zBoMu%2FafuxgSFe%2Fi6vNh5cDKNQ8t2VqzzYJXi%2FfMW7L3mx%2F1ctAxCnw%2BMG%2FJPq8V61G47MBpC%2Fcno2X4nA%2Bs3cK3zLdbRi%2BOT0jKLKuydzlXY%2BMXnw38atORt1cceGflAT4%2FXnt44JFz51J0jONXI9u6eBdbfSjvyptnf9nmsS8wNCZFeUg%2Fmfp1O7POp4fHKfssDPK8b33BZnQMTk2atOj1V8s9%2FeSHzz1T8pknP6zj1NGKqfiw%2BI%2FvvP2ltT4GAkWOHG%2B3%2F%2B0%2FM7lYSvTvjxU8d86fcoh96td3ArEcTz9ZnFUyWDfj55%2FaWGvw2tugfREQAREQAREQAREQAREQARHIQgTmzvodicDpl%2FZ8mu3n79uYt4cccT%2FBop0sW8HrXTau383%2BhLELaNrSRRt4sal5Cwxf9%2B1xa1C788kT50yrPY6dat6od%2BVyzq2a9R3UbzKzUXjhC6cQH2ZNX8FrVat80ZBXkIwbM%2B%2FXVgOuBF%2FjlL9fEKJEpfINbFWv2MrqrF07jSQ8g1O832T86HnkYpmOyRMWFcr3TbOGPc0sksTExBlTl%2F3yfWssRMpwO2x7YQrxJAP6Ture%2BWacBkfu8Icksnx5ZOt2kW3axbb57XirtnUbNmyUkT9nZ%2BdZs2Yxp%2BYOFaX%2FdFhYSN8Rp18rea7ToDETNr%2F72aBc%2BTu%2FVrTHq0V7EI%2FRoNvKK817e%2BbI4%2Ft86b1bjrxVss8rRbqjY7xSuFuu%2FJ1%2Bbjj92KlLsL46aU5AuWrXo2PTX%2B2jnDIqMeng5bCeh8412O3FVn%2BXV2sX73lnLl6MsgE8Fx4zztOv%2Bd6T9VPOkmDQUR9CNRKS0I0y8Oft7cMbRhzeCMxgPnjgGJsZ1aY4JkytXbNj6ZKNboc9reWIOeXm5ul68Jg12HgpyY7tB86etakW5u%2F0qfO7drkyqeSPAzdOnDi7evX2hfPXuuxztz9uJdCOCIiACIiACIiACIiACIiACGQzAvbu1R2bxntUd%2B1wtZKZxTdYAcM6kqEdHlWjn1iCiesBj%2BefKDFkYKZFPmTImPuWmLeTRG7cFe%2FhHRmfvOvAuX2u5%2FYfPm82l0M%2B3j5XEk77Rm3bH7vPPexa5AF33%2F2HfawE2%2Fee8j57M04mIeDi9QwGDNy3NqoiERABERABERABERABERABERABEXgYCCxeuO65Jz6sU6PDwL6T69bs9MTfCrdu3s96fJxRC4nWYB5KsYLf9e05rlunkfnfqpz31Qpn7lYVyWjtSi8CIiACIiACIiACIiACIiACIiACIpC9CRC4Pnn8orKlnYoWqFqq%2BM8D%2BkyMiIi66yYTCnL4kCevf%2BV1q6yAUf2nXw%2B5Hr%2Fr0pRRBERABERABERABERABERABERABERABFITSEpOJpTCesVq6gQZPRIaGhEeFpnRXEovAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAlmCwPd122oTgexHoErNFj0HT8gS16CMFAEREAEREAEREAEREAEREAERSD%2BBlwqW0yYC2Y%2FAM3lLV2vwW%2FovBKUUAREQAREQAREQAREQAREQARHIEgReK1JRmwhkPwIvFihbo3GnLHENykgREAEREAEREAEREAEREAEREIH0E8h%2BDqxaJAIQkI6R%2Fh8BpRQBERABERABERABERABERCBLERAPq8IZEsC0jGy0K%2BQTBUBERABERABERABERABERCB9BN44bmPtYlA9iPw1BPFfvy%2BVfovBKUUAREQAREQAREQAREQAREQARHIEgSez1lCmwhkPwI5%2F17kh29aZIlrUEaKgAiIgAiIgAiIgAiIgAiIgAikn8DzT5bQJgLZj0DOf0jHSP%2FPwEOa8vr16xEREWFhYYmJiQ%2BpiTJLBERABERABERABERABETgvhPIfg6sWiQCEHhodYyEhARPT68j7kcjIyIdLnfcdh%2Bf8%2B5uR2JiYhxOPTxfAwIuHjp02NfXz94kLD939tzhQ25XroTYH7%2FH%2Fbi4%2BKVLlk%2BaNDUwMOgei1J2ERABERABERABERABERCBbENAPq8IZEsCD62OER0dPWni1CGDhvv7X3D4GYmJiZ02dWbvXv28T55yOPXwfN29a0%2FfPgMmjJ9kL1kkX7%2B%2BbOmKPr36HzvmkYmmxsXFzZo5Z8jg4RcuBGRisSpKBERABERABERABERABEQgSxPIlj6sGiUCD7OOMWXy9GFDR6b2zZOvJ7u6Hlq7dn1YaNhD%2B6uyZ%2Ff%2Fs3cecHZU9du%2FqaSH3oUg0hGQpqKiIAoqRaSD0qUq4J%2BqFCEhvffee%2B%2B9b3ovm832ku3l3r29l3m%2Fcydc900gJoiSbJ75XC9zZ86cOed7Juvn98yvrO%2FcqVvHz7vOn78wFe6BjjFr5pyOHbrs3Zv%2BDY4cHWPsmPGwwgnkG%2BxWXYmACIiACIiACIiACIiACJzQBGTwikC9JHBC6hjxuN3uqKioCIfDqb8q1dXV%2BzMyMzIyKyurCN9IHWfH6XJlZWVnZOyvqqz%2B4lSCHkpLSn0%2BX1VVFQEs2dk5nv8%2FgAXxobi4OD19X052rsftqdthPB4nguNLr0o1s3SMLp274yaR8r44RMdwuz14mzidTusqumUwaBHMi7CasrJy5kLii7y8%2FH3p%2Bxgtl9PSGjAzcn4h46R0jLzc%2FAMHzDEXFhbRQ2ow7NAnQS6cKigopL11ivtwl4qKyurqGugxmP%2BfXN0OtC8CIiACIiACIiACIiACInCCEaiXNqwmJQInoo6BGT5%2B3ET0AWx2%2Fo4gOKxfv6F7t14dP%2B%2FCh%2BOrV62xrHhUiz179vTvNyh1au3adUnviMTs2XPxlxiTdGMwz3boMmzYSHQD6w%2BTs9Y5Y8asTh278uEs4SE5ObnWqWAwuHTJ8q5denD88w6dBw8elpuTd%2FifM0vHwEeiU8duOJbU1tbS5hAdY9OmzfSwfPlK63LmNWa06VaBRONw1DLs3r36Dxw4xBoDksiSxctWrljFBJO37kIDsm1wraljjJ3QtXMPxpl0AunCJTh%2BeL0HU4vYaxxTJk%2FjKqur8eMnWtEu3Khv3wHdu%2FXu2aMPcTq0icVih89FR0RABERABERABERABERABE5EAjJ4RaBeEqgHOsbWrdsxzwcPGrpx46bNm7aQVQODnYP8ncHLAqu%2Fb58B69I2bNm8jVO03LFjF6fmzJ6HdtG9a8%2B5s%2BcRpTJu3AQkBWJAkD6QBaZOmdGhfecpk6bu3Llr2bIVdNKv30BUjng8sWL5KlqOGzth%2B%2FadyznVpcfAAf9fEgzr7xs6BsOYO3f%2BtGkzSZSxcMEi3C3ovG5cyZF1jAH9ByNKjBo5dtPGLdy0d6%2B%2BdIiaMXPmnB3bd5LYk5%2FIFyT5ZGM8%2FOzfd%2BDq1WmbNm5GOeGmixcv5Y4%2Bn3%2F8%2BEmcnTVrDnlTZ86czf7UKdPRc%2FDEYF4wGTRo6NSp09et28AgT8S%2FzxqzCIiACIiACIiACIiACIjA4QTqpQ2rSYnACa1jEAeBd8SokWPwYUhVBiGkAtlh0sQpxIxMmzoDw5%2BICetfdH5%2BIX4UEydMxvCfO2c%2BcsTaNWnWKbJw4NGBaECoBXEcNBs9epzff7AeCjICssbmzVurKqv69O4%2FfNjIVBWVpUuX08%2BWLVsP%2BaOBjsHxFStWVZRXokjQITEdtJk1a24qP8aRdYz%2B%2FQYOGDCY%2BBer58WLltLh7FlzLJcJomCQZfr1GUADnE%2FGjpnALVIzJTilT2%2BuH0IKkd279qBU4H%2BCpkFXyBcQYLK0qaqqRuTBrwNB45Dx66cIiIAIiIAIiIAIiIAIiMCJTkAGrwjUSwIntI6B%2BECyCGIikDJworD%2ByCBEkEWTRBmcIs4CiWP58hUbNmzks3zZSn4OGTyMsBF0DNwS0tMzrKuIs0CgoAxKKBjCqQNnBhqn%2Fmo57A6KpXK73btNTWDE8FHr123YsJ4%2BN1l%2BEYsWLkk1tnYsHQNfDn5u27qde6F%2B1NY658wx%2FUCsPJ9H1jH69R1IqAtqjNVhsgBK57S1662f1JylRknvXv2YpqVjMLVUnk%2BOTBg%2FCT0nP79g4YLFpgPG1BkbN2wiAGf9%2Bo1cyCzQVdAxmDX0%2FMdxBVtrvvoWAREQAREQAREQAREQARE4VgL10obVpETgxNUxCAkh9SWpOLHWJ06cgpvBIf%2BoCQNBCiA0gw9mOx92cFoYOnSEvcZOxAfW%2Fd69po8EW01NjaVj4OCxZk0ang%2FY%2B9aput%2BbNm2hE3w8Un3SIR8CUg6JyKirY%2BD%2BgdxBn0SXENCRvK9Zr%2BTf6hgM1ftFjgtcR%2FAJQc2wxkNd2sN1DJQW6yyDmTF9NoNEzzEjWZIBKZ07wqEb3xDDHwM%2FDTQQZj1yxGif3193mtoXAREQAREQAREQAREQARGoBwRk8IpAvSRw4uoYGOOY7ZUVlfhjjB41LuWPQfQEdUDYTB2j30CCJoi2KCo8YH6KDhCKUl5WjrBg5sf4Mh2DfjZuJP1mp5UrV6f%2BcOHjQVURXCC2bTM9K9AiDhQVW32Sa5Q%2BcedINbZ26uoYHCkvqyC6BAGE0fJd1x%2BD2BPrEkZFeAhuFVaeT0SYY9UxUv4Y0UgUbadr157kILUiaEgQylCtMRcnx0xkCjci%2FEQ6xiFrp58iIAIiIAIiIAIiIAIiUD8I1EsbVpMSgeNfx6DC6SF%2FQ5AarHolJcUlpLAgXqNXz76pZtU1dsJJKL1BEAflP5A7cnP%2FVU8E6QOJgw5nf6WOEc7JzunSqduE8ZOtoic03rZ1Ow4MOGNYyTdGjRqH24Y1KrJVFBUVHV7m4xAdg8Zbt2y1fDlSOgbd4mKxcOEiqytCSIYPG%2FW1dQzcQnK%2BqJxChVlkkP79BzkcDkJgcAVZMP%2FgXbgXPh5lZWXsSMewyOtbBERABERABERABERABOolARm8IlAvCRzPOgZGPbY5gsPq1Wsppcpn1crV2P5ul9tK%2FmDVXbXsdIIs9u%2FPzM7KmThhCmY7eSRwzNi2bUfHz7uSLXP79h04Y9BPl849ZkyfhRJi5cc4LK5kFAIFfhfoJPhdLFiwCOGCTtAEkBdwY0DZoFwI2TMmT5rKvXKycynzQcTKrp1mDZS62%2BE6BjedPGkajVM6Rn5BIfIInXOL9L37qGzCKTSZr%2BGPYdUrGTpkBHk89u3LGD9uEoOcP88sv0LIzOBBwwgnQcooLCjauyed%2FB6Ek%2BCvUqm4krprpn0REAEREAEREAEREAERqF8E6qUNq0mJwPGsY1A8FBUCwx9JwfpgmyNukJ2SQqJIHJaOgfJAgdGDCSs%2BNxuT2TIQMP0lCK8grQQuGRykH%2FQNTHjrqtmz5vJzzx4zTwUbxj45M3HtQMTgJ0oCvhypq9AW9uzZiybAKZfLRVzJ5x0Y0sGxzZu7IJXFwuwruSXTcnaimskXB8z%2FktCjX98BXEhv%2FIxEoozcmiDfzKhrFzPwxNIxqCQyZMjwVM9rVq9t%2F1mnVIEV8mOMHjWWgVl5PseMGY8GQpCIBarj550nTJhcW1tr3R01ZuiQ4czXvBfVZrv1IvsHGUW4EeVcYaL8GBYofYuACIiACIiACIiACIhAfSIgg1cE6iWB41bHwMrOycnFX4KyGqkPP0n4gHBBGY596Rle78FaHsngjgPU41iXtqGgoLBulAfiAyEnmzdvwRkDRwWr%2FAcHUTNIUkHsifVnij7378%2FkjtEv8oUSsbI%2FIwvdYOvWbagcdf%2BahUJhIjjWrl1P%2BQ%2BCSqLRWN2z1j6JO%2FB8sMI3Ume5L24hyfseVBhw8OCma9akUdSV7BaEwGRk7EejwHlj%2F%2F6s7OycVGwLpVFRXfi2emOcVmMUG7J65uXmZ2ZmVVfX4NeB4sG3Jcikbk00DRg5tXXLNjqxNJmAOcf9DIDeUi21IwIiIAIiIAIiIAIiIAIiUD8I1EsbVpMSgeNWx6gffzc0CxEQAREQAREQAREQAREQARH4tgjI4BWBeklAOsa39SdF9xUBERABERABERABERABERCB%2FyqBemnDalIiIB3jv%2Fp3Q52LgAiIgAiIgAiIgAiIgAiIwLdFQAavCNRLAsetjkHOh5KSkrFjx07UJgLfKgEewnXr1imFyLf1f766rwiIgAiIgAiIgAiIwNcmUC9tWE1KBI5bHWPTpk2ffPLJqFGjRmsTgW%2BVAA9h7969%2B%2Fbt%2B7X%2F70MXioAIiIAIiIAIiIAIiMC3QkAGrwjUSwLHp47hdrs7d%2B6cmZn5rfxj101F4BAClLPp3r377t27DzmunyIgAiIgAiIgAiIgAiJwPBOolzasJiUCx6eOYbfb33nnHafzYFHU4%2Fkvg8Z2khDAJWP16tUnyWQ1TREQAREQAREQAREQgfpBQAavCNRLAsenjuFwON577z3UjPrx10OzqAcE0DHWrl1bDyaiKYiACIiACIiACIiACJw8BOqlDatJiYB0jJPnj5hm%2Bp8QkI7xn9DTtSIgAiIgAiIgAiIgAt8KARm8IlAvCUjH%2BFb%2BnuimJxwB6Rgn3JJpwCIgAiIgAiIgAiIgAvXShtWkREA6hv64icDREJCOcTSU1EYEREAEREAEREAEROC4IiCDVwTqJQHpGMfV3xkN5rglIB3juF0aDUwEREAEREAEREAEROCrCNRLG1aTEgHpGF%2F1T17HRaAuAekYdWloXwREQAREQAREQARE4IQgIINXBOolAekYJ8TfHw3yWycgHeNbXwINQAREQAREQAREQARE4FgJ1EsbVpMSAekYx%2FqnQO1PTgLSMU7OddesRUAEREAEREAEROCEJiCDVwTqJQHpGCf03yUN%2Fn9GQDrG%2Fwy1biQCIiACIiACIiACIvBNEaiXNqwmJQLSMb6pPxHqp34TkI5Rv9dXsxMBERABERABERCBekng1OY36iMC9Y9As4bX3v%2BbV463f7MOh%2BO9996z2%2B3H28A0npOWgHSMk3bpNXEREAEREAEREAEROHEJ3Hv3S%2FqIQP0jcPedz334fq%2Fj7R%2BmdIzjbUU0HukYegZEQAREQAREQAREQAREQAREQAS%2BioB0jK8io%2BPfFgHpGN8Wed1XBERABERABERABERABERABI5%2FAtIxjv81OtlGKB3jZFtxzVcEREAEREAEREAEREAEREAEjp7A19YxYrFYeXn51i3bVqxYtXnzloKCQp%2FPl7pveXnF%2Fv1Z%2BfkFsVg8dZCdA0XF%2BzMyKysq6x780n2Xy5WZmbV61Zotm7dWV1Uf0iaRSBQUFHKLmuqauqeKig5wVVZWtvVhv6CgqG4Dut21c9fKFas3bdpS8WXDcDqdXJWXlx8OR%2BpeyOys8WzevLWm5kvSiXi9vj179q5cuXrD%2Bo3FxSWMsO7l2j96AtIxjp6VWoqACIiACIiACIiACIiACIjAyUbg6%2BkY1dU106fN7NypW8fPu3Ro3%2BnzDp07dew6aNDQjRs3R6NRGE6bNsM6uHv3nrpIhw4Z8c9POixcsLjuwcP3t23d0ad3PzqnE767d%2Bu5edOWVDMkguXLV3L3Du07L1%2B2ou7xwYOG0t66kGvbf9ZxyODhqQbFxcX9%2Bg5Mddu1Sw9EidRZdpBfBg0cyoV9%2Bwyw2x2pU%2BgSQwYPS13Ys0eftWvXpc6ygzIzeNC%2FGjC21avWSsioi%2Bjo96VjHD0rtRQBERABERABERABERABERCBk43A19AxSkpKB%2FQfjFGPdoEsMG7sxFEjx6AJIGj06N7b8p2YPn0mZzHnBw4c4na5U1SHDxv12acdFy1ckjpy%2BA5eDZ06dkNMQC5AhejSuTv98J2bm0djdJIli5fROUf4XrF8ZaqHSCQycMAQGg8aMGT8uIljx0wYPWrcnNnzrQZer3fggMFc0rVrjxHDR%2FXq2ZeWfHAasRqUlpZxuTXs%2Fv0GpXQMvz8wfPgoxtOta89Ro8b27tWPNkx%2F%2B%2Fad1oXcd8Tw0Rzp0qX78GEj%2B%2FTuT7efd%2BiyLz0jNTbtHD0B6RhHz0otRUAEREAEREAEREAEREAEROBkI3CsOkYoFBo7doJl1K9YsbKmpgYrPhAIHCg6MHnS1BXLV1nxFJaOgbjRsUOXBfMXpYIsjkbHIDZk8ODhKAN4Qbjd7p07d3fr1hORZOXK1azO6tVr2UcoQFU4RMeoddTiR4G%2BsWP7zng8zsDCBIdEDoaH4ONhaRTr128gSCQnOxfVBfFh5ow5dFtbW9uvz0DmxZjpvK6OkZGRyRG63bRpczAYLD5QjNzBhfif8JNrM%2FbtPyiqrFiFWpL0%2BhhAgwkTJhF9QwNtx0RAOsYx4VJjERABERABERABERABERABETipCByrjrF3bzrOEhjpRHYcAgrpgM06aOkYSA1dOvfAxieRhXX8aHQMWno8HqfTZV2CEIELBArDksVLOZKbm9%2B3T%2F%2BZM2YPHTqCYdT1xzhwoBgXju7deu3dk44ugYhh9WB9z5gxizCTYUNHWuIDB2fPnoskgm8JLdFnZs2ai0AxZ%2FbcQ3QMlBPuzilGZXW1edNWJBEUj8zkvBYvWkrPSB8e98EGa9eso%2BeePXq7vzhSdyTaPzIB6RhH5qOzIiACIiACIiACIiACIiACInAyEzhWHWPG9FkY9f37DeTCI3BDx6DZ6FFjichAbSB3BJ4VtD9KHSPVM44cZKJACaG3XTt3W8fJ1elyu0eOHMPBujoGfhEoJ8gLeGXwGTRwyLy5C2jLVQgskyZMQW3AaSTV%2BZo1aWTYIAykuspMForvBkoFvhwMuK4%2FBnk7US1QSAg8sa7FJYOfNOMUR2bPmkvPI0aMZt%2Fa9u3LIK4ERaWkuOSLY%2Frv0RKQjnG0pNROBERABERABERABERABERABE4%2BAseqY0yaaKoB5MQ4csQEOgYOCTTO2JeJtoDgsGD%2BQugeomOEQqYjRGqzcoSmFoHjixYtRZfgcvwoKAiSOuX3%2Bwk8OUTH2LZtB2EseFMgIJg3TaYJHTN6HI3piiQeDAkdJtXJpo2bLYGioKAwdXDb1u2H6BhVVdV02OnzrqNHj0tP34ezx8QJUyxpZdWqNVzINOmZcJtUJ1RLYRiMfP%2F%2BzNRB7RwlAekYRwlKzURABERABERABERABERABETgJCRwrDoG%2FgzY7DhaoAwcAZelY0wYNwlpYvGiJQgOGPXULeXCVJ5PlJBhQ0cQM4JHBB%2ByVaxLW5%2Fqk6Ko48dP4kI%2BVCGhkkjqFDskuDhcx8DlIy1tPTVTcJwoLCyaOmU6agOj3bFjJ%2Ffi1uxPr6NjbNiwCcmChJ9k5Eh1friOwSn0ChQPnDf4tj5MB6WCsrOctZiMHTs%2B1UlWZpalY2RlZqcOaucoCUjHOEpQaiYCIiACIiACIiACIiACIiACJyGBY9Ux5s1biO2fDLIoPQIuS8egaAg6BprDsCEjEAFGjBhNvAZiglWvBG2BAiIIAvhO8MHwX7M6zeqTTBeErhCawcE5s%2BdZsSF1b%2FelOkbdBuwH%2FMF%2BZiemK0jCSEyZNA1PEnwnUs1WrVzDYA4psfqlOkY0Gtu5Y9eE8ZModDJp4lQkEdSM7l17WbVO5s6eT8%2B4mqR6JosI98WLo6ysPHVQO0dJQDrGUYJSMxEQAREQAREQAREQAREQARE4CQkcq45RkF%2BAttDx867Tp81IlQKxuNXWOq3SqPxM6RhWss3s7BzLgcEKx7B0DHJfUNqjoKAw9bFyaJCHkygSUy3p2Xf37r1W54d8UyHF8sdYuWJV3VN1FQ%2FyXfTvn5RNFpllXq2snmTq8Hq81iXTppnBL6TRqDuR7QSnJPNjMJ26Paf2ubU1PFJ%2FWLNbunS5pYc47AdzhixbthIdA08P9JbUhdo5SgLSMY4SlJqJgAiIgAiIgAiIgAiIgAiIwElI4Fh1DCx3wj2w9HFImDJ5WkFBIT1UVVYhOAwePKx3r77FxaafxiE6BpLFwgWLuQo1AwPf0jG%2BivbWrdvonMbz5y%2Bkagn9s9ntdkqaconfH7A7HCUlJUMGDzfbzFvIWTQHblGQX0jxEUJL8ILgw%2BVILigMxJVwIbk3%2BUnPy5auqKysJGtoMnFHF2swJAKtdTrpas3qtbTBSSMvr4Cf5NbgWrOa6oFi7kLeTqJIuC%2FuJTt27LKmgFcGXVmDIRyGnBgoGEyTCimpAi5WS30fDQHpGEdDSW1EQAREQAREQAREQAREQARE4OQkgKn%2B3nvvoRIc%2FfRrauzDh43ETsdyx7%2BC2BAKoSZN%2B04IBcgFdHWIjsERfC1QHpAI%2Fq2OsXTJMlQCeiZpBtVOBwwYzKdf34EICIgVVEFln%2FgUpANUESJcGADNKiuriED59J8dGEmvHn04bt0rmSDUFECCwRA5PznLIOmZ%2FmlAs7JkFRK73UHLAf0HIcVwqluXHtyFDzVVuZa6JFyF5wahIvTAZ%2FbseSQp5RQbYgWSDgfpkHQfjIodvvPy8q0G%2Bj4mAtIxjgmXGouACIiACIiACIiACIiACIjASUXga%2BgY8KHu6YIFi1ADLA8HzHZUhUmTpmZl5Vj0ZiTrlZAfw4q8sA5mZmZj3eMgsSgZ6PFVnJcuMcM0aImeQP%2FWB5UAGQTFYOHCxSghHORsqg1xLjhgIGXMnDGbkTAeqwEDqJuhArmG1BadO5peGTRApsB3whoGRUlINMpxq1t6pgE3mjZ1Bg2mTp3OvnUVzfDoqBuKQgPiWSZOmMwltOGDP4blBPJVc9TxIxCQjnEEODolAiIgAiIgAiIgAiIgAiIgAic5ga%2BnY1jQ3G5PcXFJenoG9T6cTmfdSqw%2Br6%2FWUevxePCgSBFmHwGE41awRur4ITtEjtSam%2FOQj5U9g2u%2F9KxVs5VbMBICPaiNgt%2FIIWoDN6LMCrEhlE%2FNzyuwAlWsuzN4YlgOuSM3soq98k3qUa4iAQj9HzJg6yf3ok4K7ii0qZum40sb6%2BARCEjHOAIcnRIBERABERABERABERABERCBk5zAf6JjnOToNP3%2FEgHpGP8lsOpWBERABERABERABERABERABOoBAekY9WAR69kUpGPUswXVdERABERABERABERABERABETgGyQgHeMbhKmuvhEC0jG%2BEYzqRAREQAREQAREQAREQAREQATqJQHpGPVyWU%2FoSUnHOKGXT4MXAREQAREQAREQAREQAREQgf8qAekY%2F1W86vxrEJCO8TWg6RIREAEREAEREAEREAEREAEROEkISMc4SRb6BJqmdIwTaLE0VBEQAREQAREQAREQAREQARH4HxOQjvE%2FBq7b%2FVsC0jH%2BLSI1EAEREAEREAEREAEREAEREIGTloB0jJN26Y%2FbiUvHOG6XRgMTAREQAREQAREQAREQAREQgW%2BdgHSMb30JNIBDCEjHOASIfoqACIiACIiACIiACIiACIiACKQISMdIodDOcUJAOsZxshAahgiIgAiIgAiIgAiIgAiIgAgchwSkYxyHi3KSD0k6xkn%2BAGj6IiACIiACIiACIiACIiACInAEAtIxjgBHp74VAtIxvhXsuqkIiIAIiIAIiIAIiIAIiIAInBAEpGOcEMt0Ug1SOsZJtdyarAiIgAiIgAiIgAiIgAiIgAgcEwHpGMeES43%2FBwSkY%2FwPIOsWIiACIiACIiACIiACIiACInCCEpCOcYIuXD0etnSMery4mpoIiIAIiIAIiIAIiIAIiIAI%2FIcE7Hb722%2B%2F7Xa7%2F8N%2BdLkIfFME0DFWrVr1TfWmfkRABERABERABERABERABERABOoTgXA4PGHChEmTJnm9Xo82EfhWCfAQ7tmzp3379shr9elfmeYiAiIgAiIgAiIgAiIgAiIgAiLwDRIoKCgYMmTIO%2B%2B88742EfhWCfAQdu7cefny5d%2Fg462uREAEREAEREAEREAEREAEREAE6h%2BBRCJRVlZWoU0EvlUCPIT4g9S%2Ff1%2BakQiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAj8VwkkDIPPv9%2F%2BTaMjnT7aW%2FybQRy8hdVb3e9%2FXXewyb8OfO29RCIRi8US8fjX7sFIJOqSTQ34YIfm729uuF9%2FlN%2FAlczzi6las%2FzyPo9htl80tbr74teRn9R%2Ftfry23%2FJURbZXIO6H1oduSPrwTCfjfqyfF8CRodEQAREQAREQAREQAREQARE4L9DAEsqEolgUsXjcXZCoZC1bxlZfFtnaVZ3MyIxIxyPeEM5Wfmr1m3dm1PsCBm%2BhBFJGKbVHjcSkUQ8GDbiMSMeNWJxIxI3AlHDEzOChhHhk4iHo7FwKB4NxGN%2BI%2BE34r5EzGPEvEbMZ0QDRiRoRLicexrhhOE3DI9heA0jEEvEEkY0EacD81wibt4iHDYCwUQoZEQ5zR25PcfjCe7LxQHD8CeMYNQIBhIBTzwR9CcCTiPoMCJ2I%2BpM9uyKhP2hcCJKs7DhDxsRc8yJaCQWCSYSUVMsSKBGmJu1COxEk5v10zprEbNO8R0IBDL2ps%2BbNit7T0bYF%2BBsKGZOKWTEmR5jjRixcCLsD%2FujsVAkGgyHmVw0moj6o6FQAkbMK2S4%2FSY0CMUNn2HUGEaFYVSaF0aNSMTw%2Bg17rREIhKMhtxGlAXMFsDliGEcijMGkxIIkt9RorcFbpzjI2DifOhsOh2nAWWvjLD9pwE926NZqzD4tU7NmvOYnnlxta5GTIzFXg9kwJj7m0n%2FxCRsGnxBXJcJGuNbweAxvyGAu3ljQzxIk6I0ViRmxSJxF51cwEfeFQwhD5m2iXBQ0whEjFOPpYsF8NKQVz0MoavgihodT4UAiWGtEaoxotdkgHo3Fwv5AnPWN8xhFjHDAfNjiYYYYi%2FhjEZ%2FBWMxhhRMJ7hY0T8WSNwoGDB6wYMjw%2BA2X2%2FB5jLCXa8MhT23A5TKilUaYdak1DDePm2H4wuYAzcmy3kFmGI8EguEQi2P%2BcystLZ0%2Ff%2F7KlSurq6tZo7q0rVXQtwiIgAiIgAiIgAiIgAiIgAiIwBEIWCYqDbBJLSuV72AwaFmvlpHLEUvi4Jvjppkfjuzfm%2F7Cn1%2F5xT339h81odQVQMdAq0BIMK1oXjVHTRM0GvBF%2FL44UgMaRxDFIh6we8vzivZu27Fj65bCwpzc3H2VlYWRiCsWc0VRF6Ie09CPmYa%2BKURgFyeNXYx0zMMwikg0hlEdRmIIBaNY%2BtyFD%2FYsdr0pm8Sw%2Bc2X%2B8mNYZhSRpCRIVD4jLA%2FkeAaTNCI04hyM5eRcKKexLBI0TIQQ7BtUTwwXc3e4rFwJBpCEAGO1aHFytpPGfiY8%2BCyLHoLF%2B0dDseq5SuGDxi8ee16rxPrFgAxTGhvLOSOoS%2BEg%2FGQL%2BwLRZmHLxoOII2APxgOBNGTzEmjakTMkfhMCSMeMVwJRIxEqZGoMhJIIkYwmPAHDZcn4XKxXLWJcG08ho6BMBCMxlOrxn3ZGDBH2KyfzILN2rfO8s0RGtDSWne%2BWWs2DmJrM8dUJyn5gh2fz8ezkURuqgtoDFHW6AvdgnuYOoo5m%2BTHUjMO0TRi6BwRt%2BHxGl4EqUg8kIgmJalQNB4wSYCfFaUDRhA2JQ165MGImEoBikSYB8DUMZDADuoYrKClY4QjvnigJFJbFHPlR5w1Ub8pxiDBwScUjvMgIFPEw4lYOB4NxqLBOCKTKZ1EYvEgzzdXB33upKgSMpWnYNBVUpK3dVvFvoxAyQHDW4uOEYv6%2FfEgj6zDiFcZcYQmVpp%2FCIgrqCwBV7iisHLPll15Gdnu2lr%2BDfn9vqqqqv37948fP37WrFkIGia9L7bUimhHBERABERABERABERABERABETgcAKWxco3p7BVsVstc4qfmLSYqGzscNw6yw727N69ezMy9lU6ykuqC0dMGXn%2BFd%2BxNbM99MIza3Zs98SiYWz%2BGEZhMBbCpMZeDUVjvnDUH0lgW8ZcXm96ekb3Tt1fePLZJx954vGnnnzmzy88%2Ftwf%2F%2Fz26xPnTynzVnhjnmiCt9d4QWClIikkDfq44feEqqtqKypqiotLCwuLvF5vOBLBgsY5wPS7wHHCHCpyBm4KCd55I3rgvGH5J%2FBWHNPUPGk6bPDmPhyJR9AKPPGwJxHBEcTLq%2FJohMmi3ZiSSCQa9QdiNE4gZHAB%2BouJyNoAYhHjJzsYoRaiFCWrAac8Hk9WZuaW9RuL8wtDuIskbfAQ7hZxNAtEjDAKBh4NiVikvLjIba9JMARezTORRBy7HJZJZ4C46WARMCfGjKoNo8ww7BwIBRIen4Hqgprh8%2BLRgT%2BGMxbzoQnghIIDAxJQch39fn95eTnE%2BMk6Mjzrm%2FGwWZOy5sIUmA5nTRTJHb7RMaxvhBp2rMvZp1trypbQASJ0BsvVAtqmxwR9J7vn%2BBfqBYeTN%2BW%2BfNAmkoITl8bigWjCh9%2BJ1%2BcoKsw9UJjv93jQwWJIGXQXNZwOV0VppcPhjCBccDGEkLJw5jEdKnCuwGci5jZiQUv1YBFxv%2FGbeoUn7M2oLNhVnpfvqXaZ4KP0FjOfl0goiTiEZIaQkWAlEEPM54fu8BPyRYKekN8XDiIZcQQXIH8ktGXXjr6DBowdOzpj29aow266DOFjEw3nl5emFxfuLSrIqa72JmWzUCRRXlYza%2BrcN15989mnnvnzcy8OHTp0bVraipUrFi9ZnJOTs3Pnzj179jidLBqETC6wZUutiHZEQAREQAREQAREQAREQAREQAQOIYDphClqmU6YqBinljHFQbfbzVtjPAo4iFVrmVrsZGRkdOjQoX3HDgvWLNhatLXXlD5nX3MeOsYvH%2F3N3NWLvNGk6RjFm9%2BFYR0Ju6ocB0qqC4rtB4qcpSW%2BmmKfI23Xtj%2F%2F%2BZUfXH3jFZdddfYFFzZs1dLWvEmDM1r%2B5vknlmdsrU6EcD0gqAAVA3UiTgiAL%2BQurlw9Z9Hgrr26fdbxs08%2B7dS504IFC3bu2pWbm8uwMQEZcCgaDibMABXCTxyGUWUGX1ghGOY%2BR%2FDDQJHBsEVQSUozmLFIBqaJyncE%2BSCEX0QAqxTXCj5Y%2B5jRgQghCqa1j2dGXXuTfdMgTzo5AJABAMdiZR2kAcfNLjCcsdaRFMxYmJhlJuNfwE3xvnA7HMsXLuzZufOw%2FgM2r01zVxHpgqcB7aIMCVPd9G4J8Xaf0ZsxI66klEHwQhCZhaCSEH4juAoEowkzIgI3AD%2B6iBlSY6o73JRR7du3b%2BDAgYsXL66trWVI5qiSG%2BgYduqRqDtsrrLkC2QrngGeBEussHQMrqIDhBG73Y5WY%2FUQwS8hQeCGOUjTLMcc52PuIRugLCVYVq%2B5sqbggJCQdK8wFQozJoQ4jqjfCPs81WVrli%2F5%2FLN%2FfvzxR4uWLHHUYuMTDBQpKSydMGp8h4%2FbTxw9viivAM0J6SqcCAViuIIgRHEHU8FwGbijJHvmMpw0gqaO4Qp6M8sK95cVVgc8TDiBwxCaBT4fsYQnziVxJmC5%2BiAXmYoRsJOzcEci6HJQdSUYuUm%2BPBpKy8kYPGfamHmz0tP3RoguiRAwErFXVS9cvHjC1Gnjp05btGJVWY0Df5hwNFFSUt6v36Drrrvx9NPObN68xc233vrm%2F%2F1fn359J06aBFLrmbHWIrUu7Fg8v61vnmHGZsksaWlpCxcunDlz5vTp0%2FletGjRunXrkF84W1lZScuvN0ikGxTIr3etrhIBERABERABERABERABERAB0xRPvgjGRMV6xYzCYnW5XDt27MBs2bhxY0lJCccBxSns1smTJ996660%2F%2F%2Fntg4YPyCvNXLtl%2BZ2%2Fuf3Syy%2F5xyd%2Fz8rJNF9xJ216Ml8Eo8HC0sJ5S%2BdPmjN1yvxZ0xcvWLtze5nHXe31rUvbMHrIyH9%2B%2FOlv73%2Bgxeln2Jo0tp3S6JZ7fjVp6aLqkGkdETtA2Irp3e8LRGpdWdt3tH%2FvvZ%2FeeOPll7T73uWX%2Fuint73y%2BmtdunadO3euDzcD693%2BwVQEZuyJZXVi7CNf4LrAh30P%2FZluGUnjGvua%2Fs2PGfmCTcx30uXfjQTjD%2FndPo8%2FRNRK1I2mkbQtsTrZrAeGAykRAHocx5zfvXv30qVLN23aBCWIfdHAostsrJuZ7%2FqTQRK8fCdewb92%2Bcpf%2FvT2c9ue9p3Tz3r9uRezd6fHgnihMCoMWjI1IBmYdjeODqgTSARMjYQe2NQAMkNOAjiQ4GQS9IZ8zgSRD6aCYaoH6BhJ5QUVwlqyV199devWrYgPlhbBRKwVZ4ch8o1BnZziwfQXKBhZWVnkcCD8YfTo0cuWLSssLOTZYO54YuTl5a1evZqep06dysTpNhAKumNhj5HAc8Ts0JQokklRmGfSTYJcEiSRIJyHvCfJGTJYYi%2BYbTBuppAgfMafy0J%2F8Pfrrrzq6iuu%2BvSz9lk5efTh9vg3rtv85KNPXXbJZc8%2B9fSGtHUBP14nBB1Z%2FSBkmdoFCgY6BikyuJ0ZWGTe3Yxs8oYD1T6XJxQk5Qc5W8zUF2bYUKw2HHZFcGph7eFMbAlqiJl0BSeNiJnPI4GfBtkx8NWpCfrskUCtEa6I%2BkrC7opIsCYS8gQDcVw%2BuCYYLS0smTlt5piRY0aPGD1zxuzc7PxAAFAJrz%2BwZNnyB%2F7wUMs2bW2NGjVp1uyXd%2F967Phxu3btsrxZrCcKXBb85INmPWL%2F02%2FuywNcXFyMqxUKIarXe%2B%2B99%2FTTT%2F%2FqV7%2F6%2Fve%2Ff8EFF5x99tl8X3vttb%2F%2B9a85ztl%2B%2FfrRMj09navQPI9y5EyT9tOmTdu2bdv%2FdIa6mQiIgAiIgAiIgAiIgAiIQP0igA2CfYrpjT3FPhYWuQeXLFnSu3fvUaNGbd%2B%2BnZenHMfm4kXtyJEjf%2FCDHzz04B%2BWzZkfrnZ6CsoWj506Y8iY3C3pUWcw5kUTIJIk4Y3Ey1yeNdt39R05pkv%2Fwd37D%2B09cOS0mUty8yrJN%2BB3%2Bt01juryylkzZn7%2F%2B9e3atX21LPOffqFV3fuyTKlFIxarxlcYubTwJmBDAy11YsWzPjnp%2B%2B9%2Bc7rb%2F397Q49u3bp1eOzDu0nTZxIHAK2KjY7ljvWMwa5pU%2BYlq1p%2FR9MJIk2YprNydf%2FppRBz%2BYHaSAYqyXlp5lWNE4qhXjQH%2FEVlh9YlrZ6xYZ1hRUVHtxUyKWA4JF0YzBN9OTGz9QOZIqKij7%2B%2BGOMvt%2F%2B9regw64HGt%2BmD0YsEiA2AYUn6SiB3wLShOldkUiUHSjp273npRdc3NLWpLmt0d0%2Fu2PZ3IURbzARxEZnSmgy5NMgoMHUJYiw8ZD1Iek84MNMJ6LE7U14Cb7Aj8SzO3vfvpICL54zZOnwY%2BWbS8kbc9IvIET88Ic%2FfO2113i3zvt0jGjEDSxKGrAxC%2BsbS5bwE9rX1NTwMPDdvXv3yy%2B%2F%2FDvf%2Bc6FF1541VVXtW%2FfPj8%2Fn1OIGG%2B%2B%2BeYNN9zQrl27733ve4888sj69et9wQCOEe6k64UZVQLbEEoLHhGmooHUgoLhMiJeM7YHFxfylbAmfhYALcaMnCGzhTdUsG13n087%2FekPj33wf%2B8tX7rS7nCZASLhaFlZVc%2BefZ977sU%2BffqRToUUE0HyiJiRSqaHDUuNdgErS8dAlLCeBjMhbBzpglwiZB8Jm%2BPh0XJGqoqrNu%2FeuzUnq7qWwBBTCEoEvfEgQouVLoMnBbEjRLrRYMTrj3hKqos37FyfW5bnjuEBg86GXkQoSgJfGEaNahL2hqsKy2sOVNpLKmvLqsO%2BIFFByEQV1VX9hw669KorGjZramvYwGaz%2Ffbee9emrePBgD%2Ff%2FFvjb4n1IKW%2B%2F5d%2FXaxhsKBk6nj%2F%2Ffd%2F97vfXXnlleeff%2F6ZZ57Ztm3bFi1aNG7cuEEDc%2BR8s88RjnOWNldfffW9996LpoGrBj3wwNPbEQbPBBFFu3Xrdttttw0ePPgILXVKBERABERABERABERABERABI5AwLKesLnYaIZJi1WOrFFQUIBLBg7k2LwcsTb0jWHDhmGtP%2F7Io5uXrDUcMaM6Gi90hTIrE2WU1TArf%2FAmm1ScmGf2WKLI6dtXVJ6eX5qVU5aVcaAou9JPezfqhBlXwXvxmoqyCePGvvW3%2F%2Fv7Pz6ZN3dJTYXHLBaBHYnngfkdT%2FgwM3lhHnA4S%2FJK9mUe2JdRkpNVVjRv6aKOnTuNGT3a5%2FFi8pspITChcJfgmw9TsT7WETSD5AFcMlzYnsgCHCdwxe6uzsgu2rHLVVQYdFbjTRAIu9dtW%2FfUi09f%2F8Nbrv%2FRrW988PesohJiNriCDVZYaoCCkvWTI%2BxjweGG8eijj2LrXXzxxbyqRhBIEkUrQVEgPgRPELOSiumVgdqC1MKAEobH4ZwyduJ1V1zTsnGzlo2aPnTv7zev3YCOYWosSa%2BCZIgNfgKmZQ4zDH7LxmaffJdm6pGAv7a2ate%2BHW988H%2FvtP94yYqVO9K2Z2xOLy0oYVSsmuVW0aNHj0GDBiFE%2FOIXv%2Fj973%2BP0YrTSHJO5qRoxrzQKObNm4fWYWVsyM7O%2Futf%2F9qyZctmzZo1atSoadOmf%2FrTn5BBEDpwg7n55puxbRs2bNikSZOLLrqInjOzMsnlyoDxUDg4XNwVCOKgCkwyd2qAuBGDch3mUauEjBnrY%2FhJY5HUMUxto3hX5oju%2FTr9%2FbNt6zZ7XKgypMBAhUjkF5e%2B8tc3r7z%2Bhhdfe23b7l1%2BAkpYiqSCkcwqagaq4I9BtRv8MXCisEKBrAkic5CIJIHTR1LMyt6U%2FsbLb%2F7orl%2F99P57hw4b6ikpNqiN4kdf8ePWYnZDhAtDSVBExltWWbBh65qRYwc%2F9Pj97Tt%2FvD93TyThD8e9obiZ%2FMWLxsKdrMfJUsp46lgXxBfz31F4f07m2x%2B%2B3%2Bqs0xqc0qhh08b4Y9zzm98uWLjIy0P7BXaen9QTZT0z1rD%2F298MgGeDVeZf9GOPPXbdddchTSBTsKxHv9Geq1C0nnrqKbJ%2FkLyUPs0n%2FLCNg2VlZTyEqKCnnnpq3759D2uiAyIgAiIgAiIgAiIgAiIgAiLw7wlYxhTfGFPWhknLDnElWOKoFrwvttrwZp9THJ84ceL111%2F%2F8EMPz5s1r6qoinKppmZRGzXc5GjEVDSLbpIFwhsniIBX5OaLbdOqwdbDGcIsfsobePI58Ga%2B0qyEGa91Oku279myNyujstbpDyXTVWC3W%2FqD%2BSqdQhbBWMQTitSG4rVBw4fnhCseStuysU%2FfvpPGTwy4fearf9PUTyoY7GBLmq%2F6k2KIpYdwMJn0gDCT2qQUYMY9uINbFi%2F%2F8OXX%2Fvzgg28%2B89TSOdP83pr0rJ3P%2FeWFVuecappyDW23%2FPyOeUtXebBczdCV5DySOg%2BIsNeQAgDCcfZXrFiBJwYXoWMMHDiA7AEWt0Aw4PK6yOdpChMMkU7oBjEjKbygceRl5XXv0uOPT%2F7pwQf%2B0P7TDum7001HCyYUIvUoqUIiyDQOv7%2FG5fOEktJPMlzDTEZpJkM1821UO6tmLZl9y50%2Fuejqy%2B6774Gn7nvsj7977LUXXp40aRKaA8NAlSKrCSN86aWXUB7OPffcXr164UBirTUNEDHY8NMgoGD48OFWGg1WH7njlVdeue%2B%2B%2B%2B6%2B%2B%2B5nnnlmwoQJBAXgkzN79my0LCaLxME7ejrs0qVLQWFBmLwVBOmYAkPSH8NUWsykqabXQjKiAzGG4ZgRG6bPDXoDMSisEB8zgIZFLMkuGDdo5IAufUrzS3hq8FsJRGJ2n39x2tof3vVLW%2BOGP7zrjtnLFrsChNeYLjJEqJjBNGYMiRkahJSRrJNqimE8BaayhdMNihAj4ZFwxT1FjnH9R3%2FnwkttjRvZWjd%2F5oXn96%2FfnOARCuNHZCbZwHEjGKHsboxqMjll%2BT2H9nnyz0%2B%2F8f5bTz77VK9%2BPfPys83QE9K%2FRFiXL4KVrAeP%2Fvlw40Ak6nJHAl4WsNxROW%2Flor%2B897e77vvtr%2B%2F93R%2BffqZzx87r160P%2BJkyMzY3yPNtSRnJdWDU%2F4uNZ2PMmDHPPvss%2F5xRq8wH%2Fj%2FYWrduTT%2F0Rp%2F0fPgE8MQYMGAAfkE8M7hzIJ4c3kZHREAEREAEREAEREAEREAERODfErBsbb5pyTfmOSYV8QUEsPOGvWPHjiNHjuQ1PSIG0QQYWVjEOB4QIP%2F5559v2LI1p7jU5fIFK52xkppESaVRVmVUVgUrS8I%2BezCOB77pyo8da9bHJBdlMGQEAoaPF9%2BIGOVGpMCIFhmJcqSFQNTujbnJEol1R4lR3P8xP03pAAvRHFYoGnGHw45QpCaQcHuNoCsezC7IXbxw0ZoVq%2FDhN%2B1VXoCbVmvyg8sCIkbSOcRMJ0Gn2LSIKMkMmUgZeGXgG%2BGutA%2Fr0uO7bU7lDXTbhg0%2BfOcvubl7ps2ZdOGVF9maNyT1aJvzz3%2Fs2RfWbtzhD5Ab1BwIlEDEDjTIHbFy5UpcVtgHC5kxnnzySV5q%2F%2FGPf1y1aiW%2BEDQDJjqG2%2BOkDkog4HXW2v1uMkNisBM7EyMAhDHDtaCweNrMWa%2B%2F%2BRZ2bo9evffsTcfOJVlJAGcKI1QTdG3Zt2f5%2Bo3ZheVWTVtsdezlBC4J5Jag3GegdsriGdf99CZbU1uLZi1OtTVraWvUunHzF55%2FnoQYDI9hoEchXPTp0%2Bf2229%2F6KGHCARAabFOmYCTITOZmZkzZszAH4N0B9bzgO1Jy549e5IEg4QGlgsHAhcBR1dccQUKximnnIIzxnPPPUdcidfjiZJ1gjqwjMpUlpKCiykw4DqBlJFUMxA0zJwW5CalXirahRmfYUai4IBiDsKoLq9Zuzxt%2FvT59rIay5HFH4kVVVWNmDntyttusTWx%2FeBXt09eOKfWxwiTOoYZoYJ0AQfuxz3YQ8RAKDEdV%2Fjg2IJzBD5C5mPgjjtyK%2Ft16tPm1LNsTRrZWp3y8BNP7FixPuHCAcbsgBUmKMcVoSYvJW4T2w%2Fkvt25%2Fe%2BeemzAmJELVy7PzMouO1BasD%2FnQEZuyBeiDSVWHQnDx4U8e6aPCZ4fiYjdWbQ%2FY%2B%2BurVWO8qARckQ9%2B4qyZy2aP2z0qH%2F8%2FR9%2Fefm1%2Fr37Zmbst4RBFDAeHmhbagYrxQ4%2F%2F6sbN%2BW57dSp049%2B9CNW8D9QLw69tHnz5vTJHwcrGC01C54ZhIsf%2F%2FjHOPZwDS4cI0aMSJ3VjgiIgAiIgAiIgAiIgAiIgAgcKwHsNzbrXTDfxJI88cQTVmj8NddcQ6kCvA7okzZ8E2ZC7seMffvIOFBRlLt93uyMseNLhoyo6TMg2HdQZOgwd%2F8%2BoUljoovmRJfNjy5ZEF2yKLZkYXTR%2FOj82ZF5UyPzJkUXT4ktnBiZPy68YHxo6dTAhsWJ4sy4u5KgBN7Wk1ECC9GqH4EHR1LJMJ0PDDw%2FzJO8KydRhJn5obqkvOpAaSIUj0Wo%2FYERya6ZTNOUNXgtTukOyngGkvkZzHf2UacRp2gpCT9dJI6gHKfdMWX4iJ%2FfcP3l559zw2XtPvno7bSNy3sP7dX23FOxlxu0anbtrbf2HzrCQ7YKU8QwoSY5meoEHvJz5szBewH5AsnC8sf48MMPP%2Fnkk1WrVmHvg9FqzAyIL3A4qpctWTR0QP%2FZU6eW5RXE%2FeHaSntOdn5JWSVpFpyh4Jb0PS%2F89fVLr77ynvt%2BN3nKpFp7dbKKKTwC1Y6KJWtXjpk2bdOuDLQeMzVDSpyJkgEiVhv1rNqz%2FqFnH7%2Fmput%2BdNMt111w2dVnt7v%2Bsqs%2F%2BvBD0jBaGhQiARsrS0gIGTsZP7Ngww3g4DgTCYaNZlVRUcFx6yA%2F8cHA5OQglzMpjO41a9bccccdp59%2BetMmTZAyfvazn%2FE84MJRUV6euy%2BzKr847gnibQIxekFVMNGxKgga6BuBSDK1ZzK7J6E2lr5hqhBmoA7xI75gtLramV9Q7CV0JByt9fn35uUOnzzxlfffOffKy5qfc%2FrDzz%2B9dstGP%2FlM2DD5cbZAZDIFDNObAuULlxe6Y%2F0t%2FwiULU6augaHQOcKL563%2BJf3%2FOaSH3y%2F3S3X%2F%2BPT9gX7D5h6BzoYmVSRlXiQGF3ycrs%2FnLZ119xFK6m4E%2FTH4sFE9o6cqcMmzxk901PtoQ0JV51xw0P1VyKNEO18ZP4gUUbhtAnjunfvtDdzN%2FoIsh1ldLyhwM7du17580vXXHr54394ePmSZVZuTHgiMbFGfIOX5UBcsp40c4Lf9MYi8u%2BX%2FC141yAmsHyHKhH%2F8W%2FLPwdpiyzB3Is7opghYvCcWLcj9urcc8%2FF7eebnpz6EwEREAEREAEREAEREAEROIkIYLdibmBDYVWxEV%2BAx8V5552HrUppEnwzSPmIOWpZsjTGbI%2BZ%2FhWuffMmfXb7zQOvuWLxVddsu%2Fh7hee1s198WXW7S0svbnfgokuKv%2Fu9ssuuqLjsyorLrij53iUFl16Ydfk5Gdedk33D%2BeVXfbf44ovTv%2Fe9rTf%2BYM1dd%2FjGjDSK8gyz4EbAE4s4jASCg%2Bk4Yb5Fp6wlL%2B6T7%2B4jyWycZJvA4AxFEj6MYrMmhdeHh0YMgcJpFl014xYSpqMDRqWPXAfk6vAYvmrDV4Vvg5mIEscMMywD%2BaM4L3vU4P7PPfHQ88881rtP57lLZn7S7Z8tz2qNjmFr2uiO3%2FxmybKVlF3BzYNEn9bGY4HJuWHDBhIVjh07loQAcGPjBfSQIUPQfMzcnskNUEQMQC3g9%2BzZue3l5569ot0lj973wIp5izzVzu2btg0fPnr%2B0mWOcMiViFaFfbPXLH3jw3deefPVKdMm1FSVmGEXJGoIusPO6gMlBTszMw5U2T3kxLTiZSw0IaJPzLQOZQHH9GVzJ86YMnf6rCHte%2FZ497MxA4dRbtXyuMBAZkSMh8Vl7czlS770T83Ietat9bUa05L2yCDk%2BmAjlsRqg6BBdADpPYlPsd6to3QhdCCMbN%2B6bcqo8evmLgu7%2FCgSDNBuxKrMhBWmGGWWXCE1iRNjP2F4Mf6TFUtQO5Llbfgv1jsfbzRW4nDsLympDgediVi%2Bs7rfhNFX33bLOd%2B9%2BMwLz7vn%2FnsnTp5U66g1E6aSa4RngIotJPtEzTC9XAggMSN3LB3L0iLM8B36RdrgE6I4a6yq2r56w%2Fp%2Bo4cNnjR2R0Y2MUOmzwjPEYoIHaQUkaRjR5j0ny5yayR1DY%2Bxf132yG6jx%2Fee6C5jAobbiJMPhOfN5Xbv3bFj85o1Jbk5u7dufuOvr%2F3h0QfXbEpLCnDc34xFcdY6Fs%2Bb369Tt%2BnjJ1WUlkGYxwb9h5Cf5cuXoy2wXvwEO%2BvCKb4t5t%2FUN%2BuLpEBc2M9%2F%2FnMCSazsnUfWLay8KHhZoGqy4bxxNNIHPdP4rrvuIsEsk0KyILEn%2BVWse3GW6ieKK%2FmmllX9iIAIiIAIiIAIiIAIiMDJSQADB6MJAwqzFzMW1QJj5x%2FJjdx9vJTnuGXzWt9JSrzZLV7W96OHWzd%2BzWab2bhxUcu2oUYtI7YGUZstYrN5bLZam81raxiwneK3NXbbGjgb2apb2Era2Mpa2bxNGscanlrT7IxdzU%2FbculVkd79jYz9RnlpwlkTiUcwgfHYJw1lMgaELJ7JnI7EI%2FAW3iwWEY34gwk%2FP5PGKYpFLIG%2FSKURrzQidiMcSJj2qhH2GyGCOJyJhCNgVNcaVR7DzSlfhFoXnhgZHUnPUV40avjAO%2B647bLLL776%2Bstfeuulf3T6%2BOxLzm%2FS4pTmbVu%2F%2FNrrBQVFMaSScCIWNqUe6%2FFAqVi8eDEpJsiKmXqrjqWPlGH5P%2BChATHag9TMGBEOVhQXjR069B9v%2Fm1U%2F4HFmXmBWu%2BalWu6dOs5avLkYg%2FlUmJOI5pbW9KpX5frb77mkYd%2Bu2XlIsNdY%2FhdCZfD8LmiIU9twOUmPMRMWZkMkfAh2cTwAaASR7KeabjSV1vjcnjtTldBpSO3zFlh1n7l%2FrzrZ03ZwXC2NAps5NRcDn%2FgWWIa0J7LmQ4PABuzs5a%2BsLCwa9eulLRI2bOXXnopYSYEDpQUlyyYMWfFjAVBt1mEoyIRLjOipUYc7SgZOHFQCkg4o0ZN0EylgmLAdPCwwT3m4EoauaUlE%2BbPGTFnWpajCmGqIhZcsXfLPzq3f%2FXNv7777jsLZs9xVlYnFYcEMTXhQNDtcvsdtaanB5lP8aVAtkgmfEW6sFwwqFhiRhshJYTieE2YAT2JqCfoqagqqbZX%2BsP49pjnaYbmRG0YqJoyCxPgg9%2BL5Rtk7bgTOSt3Dv2419iOgzzFdkYfDLpCARcZa%2FOy973wzFM%2FvPGGzz%2F7ZMHCuS%2B99tJ9Dz%2BwfMNqHECo68odzWo1hKvU2HN27N6yJi03K9vj9bA0ZCC5%2F%2F77b0xu99xzDylNiOqq86%2Fs8PX5mkfok0VEefvJT36CLnEE%2BcLSLshiccYZZxA9REaLX%2F7yl7%2F5zW%2BowfrjH%2F%2BYsjXnnHMOMgi6hCVkfVVXtLnllltQRMkCiqyRaiYd42suoS4TAREQAREQAREQAREQARE4jIBlunIYa5dMfRQyyM3N5T07Vq1lCLPDZprnCAvxSOH%2BTe3fePAnLWyP2WzDG9r2NG5c26Bh2GYzGjVNNGgUsDVw2Rq4bQ39tqZRW%2FO4rVm0YWN%2FY5u7ic3TtEGoQeNwg5bupmcVt70g%2FaKroh17GDvTjdIyo6bGINYhYXpNIE0kfSdMM5OX7Gb%2BC2Iq8M0geASjlZgTYgsw7Ml%2BGUvYE5EKI%2Bw0i2gGE1Gf4XYSAGPU1hiOCsNTYgQLjSifiri9tCJ9Z%2F72jd5yfnqDPvv6jave%2Fcfbv3%2F8wfsee7Dn4H7LN6z9%2BPP2Tz77zGt%2F%2Bcuc2XMCZOPAxOW1OsELX7wfx9Ik0SXpBfCcB5pFDKufBBQY%2FsRu7N69m1wTZvVSpINIOBLwhTyuyqLCgoz9NUUlcW8Qf4x1aRt6DRg4fvbM8pDXZSRqEv59pVnvffS3ti0aXnvBmdN7dY4WZBkOu1FRlXBip7vDMS8WMbY1ngPmxPkEE16HK6%2BoYF9JTrGvys%2FcuZ%2Fbbzo8MCjyW5JhIxBgyRg5q2bJGuywmqm5HPYUmAcslYMLWf2NGzeS%2BwK5hq7ogVIm48aNoxgrOoZlxt50002oOjBB7%2BrcocNfX35lw6aNdmKOEoFyI1xl6hhm2hMz6QO5KvyJuDMUqHQzeFwnOI5CgGBVFYtQm4SVzcrP6z9uRMcRA3ZUFpQZwUojWBaq3V%2BYnZmenrt9V4AcLMgTfIgsCsdKyytWbtywcf2GqIc8IshNpnNOSsdAoDBvaukYZsxIAjmK%2FwYTgZAfpD7zE3LGIjVGohaXCjP3rM9huOwGwB1uw%2B42ql1GpdOocBiVDqPGaVRW58yc0ffVl4a8%2B5Y7c7cRqDXdfDxV0aoDK2dN%2BsGV38VUf%2FCh%2B8ZPm%2Fh5z66vvfO3tds3B8jtgn8I%2FJPuIp6q6lnjJjzz2OMvPvc8j9DKlStffvllq7wp%2Fi1IB4R74OqD4nTkBTIX6Rg3QjwIEULEOHJCDMaAgw0ZVKjBiscRwVNETq1evTotLY14IiKSODJy5MhPPvmEPDAsfatWrY7g10EtG3y6DpE7pGMc49KpuQiIgAiIgAiIgAiIgAiIwFcSMAWKZIAJdi6b1c4yezme2g4awvFoTuamj95%2B%2BEetG%2FzBZuvVwLapgc3ZqFG8ceNokyahho2ctgbltoZVtlPcDdv4GrYJNGgVatTC35jjNofNhr7hbNCs8pQzC9peuPuCK8J%2F72Cs32KUVBhOLNxgDAOccIzkK%2FGkzUq4AOIF7%2B%2FN8hxRAgowTjFTTTM4HnT67bXO6ojfbhB2gqjhipfkR9ZvSCxZYSxbYSxeZCyfa6ydFd88J7xxUdnsqav79FjQq0vemqXh6gM0dnqrt2XsWLx%2B1dKNafuLC2sDgZyCgvUbNu7euaumrCJKXQmz3AaJF0wpAAgYmNjsCxYsoJIpsQAcARQHsROpSYofC%2FUaRowYgc2I%2BV9dVRWPRSnRmQgTJJKMeiAbZTjur3VnZebMWbJ4xbaN9njIQcBL3J22Y%2B2Lzz56ekPbNa2b93vhj%2B6li4yMTGPHPiMrx6itjEVdxMkQEYOlbqo33oSnzD532sy%2FvPGXNz95b%2FbqRZVOO%2FZv0OVLuAi3MduFgiGUB8ZmbQwVacIa8Fc%2BBMkTrD7N2HAYQLggbwY7lpMJMTWbN2%2F%2B6KOPiBrAKKaUCXpOQUEBx7dv3%2FbkU0%2FccON1oyaMqiR%2FhOkNE3YbMT5W8AVKgsvu3Ldr37aN2z1uP4EmzoTpeEOm13Iz4CPmtzvWr0%2FrNqhXp%2BG9d1Xnlxv%2BGsPnT3pamDJEWXU4p9g4YDdcUdO%2FIhLfsntPjxHDx06cGKz1mnErfJI6BkuCP4ap5cAK2SepaKBFUZWE3LOkio3SscdjOF1GbUHcsdOo2WaUbkrkrDF2LUtsWBBbOjM8Y0Jwwsjg%2BBHeEYNq%2Bnar6N25clD3kiFdZ73%2B5Ls%2FvvzT392SNb5XdNMSI2%2BvYS%2BO5uya1bfTDZec16SB7Y5f%2FWzoxJHz1i4fO3vmvqIiUquYuhPDQMsIhgv2Zbz%2F17%2B0btrk3LPPxt1pypQpuCsgHeAggY6Bh8MjjzyCuME6HnmBjvUsjwFPLA4VKS%2BalHdEagdFgoqozz%2F%2FPKVRV61axZqy4l96I4ZH2Roe79GjR7%2F55ptU8qWOaqqff7sjHeNLqeqgCIiACIiACIiACIiACIjA0RPAPKfxF8aume0Tqyd1kB3LB4M2liGPeUtj0ipGyguX9Prs6VNbvWWzTWnUOLdZ61CL0zzNWpW3aR247Wbvfb%2Bt%2FP0DNQ8%2FVnv%2F45k3%2F3znNTcW336n55EHqx%2F5TeVDd9sfe6D2qcfLnnwi%2B7FHs155JThpopGZES%2FK9x4ojJPwMOlDgFUajPMOnVwWRBIgB2Cjcu%2BYP0oEQNI49Rll%2B4umjZ08dOjwDbu2VQYdEV6pVxzwLl504IN%2F1jz9uv9Pr4Qffyb45JO1zzxa8eoT%2Ba8%2Bt%2B6ppybed9%2FEZ%2F5YOGNyrDg3EbCTfTJgRB2JSInHlV9ZVW53Bqn0SX4HHDDCkTg5GiOBOGU1eJUfiVgxGngmUO6BQh5Y9JbJCTQyD%2FDCGvkCHYNvkoogaxBtgfxhll0hQwUv2UnmQOADekyIypyevLLSAkeF03Qj8Qfinoy9G9999tF2DW0%2Fatyg%2Fa03p7%2F7vr9L78Cn3YPDxiSy042II2L4iZMJ8Ho%2FSOxDrCrvQPfPO11z7dU%2Fu%2FsXI6eOK61M3ov%2BCdkwczsc9LtgsVi4lJSRWtyvekJoyRJbigfzYrOeAY5g2FKthqQHlEFhgjhmYMwyR9LAwiQjY98HH7376JP3L5w3OVhbYbrGIOAEcY9h9ULhGNVko1mFeaPGjRs6ZFh5WSWPnTeWqEzEio14XsCzZuO6kf37vPvGq48%2Bcv8rrz2%2FeXtakPq6cR%2FeNRFnTda69XMGDBn5z47Tug%2FYsXhVoNpFlpS0Ldva9%2B07fORoPzoGYgUPCuk6k%2FErBKqYjzX%2F4zg%2F8N8xg3co1%2BqNknbFXRlK2xycOi84dkhoWIdA%2F%2Fc9nd9wfPCC442nHC88XPX474rvvj33pzfl%2F%2BTmjJuuWXvpecsvOWfV1Rctv67dmAtbdjrF1u%2B8Uzb%2B%2Fvbivz4X6tfdWLvU2LVhY99OP734nGY22x133jZh7vQ8e1VeVY0jSCZTM%2F%2BoOTAWPRAqzs3u2aXDbbfedPevf4W3w969e5ECUISIzUHEQNAgtGTy5MloYuY%2FsW9oY%2Fm2bNny1FNPfZWIgbBAseBHH30URwu8iXjIraflCPe3GvBIsPqExqB%2BEG9ylMVbpWMcAaxOiYAIiIAIiIAIiIAIiIAIHA0BDFXLKrFMJ8totaxX9tkw1a02mLEEGrAFSFIRChsl1ZUDxgw754rpttNyml9c2%2FaS2sanZzZtlXHVZbHhfY19W42izPienZkjR497%2Ba%2F9Hnl87ttvl8%2BbZhzYbVTsMSr3GlX7jPI9RjFvw7Pj%2FtKS0v2z50zsN6j3us0balxOYiHISBEwUyGaER34Q2AI4hhBiU2qpmKYYqKGqwOTh068%2BpIrLjj3vLfff3vzzvWhqmIjNyswbFT6L%2B7PPu%2B6sjOvqGn1HXvb88pOPzP%2F3LN3X9xu1aVXzrr2hjWPPBZesdTw4MHhpiAINnBJwLtq%2B47JcxetTtviwpXAzAzJy32iEPDwp%2FInqTsRAMwNSpj51dXVBN1QlhQ4ILKsfmx5LHpCMHBOYB97kONJFYFclpGQxxshSoU6oWaBUP4bQa%2FxmzEwhMowLW%2FUXjy9%2FT%2FuPKXJ%2FTZbl%2FO%2FM%2Bvam1deefOGdjfsfODR6JqlRrAiYrhJluGhMSEZpsoT2b15S58%2BvUaMG5FVlOMLeElpYdYICSdCDDli6g8pe5MhkdaDb4ZkreZXPRtcYq56Ut2ipSVb8ZN9IkfwIiDpQd%2B%2BfQmcYbKWsMMpGrjczo1bVy9YMrEyZ7tRUWwUlhiZhcb%2BIqOsGv0gFnQFEv49pdkjpowdMXJEZVm5VTeVIjK5Ee%2BCPVvu%2Bf0957RtdU6LZmee0uT6C86d3LNr6EC%2BWaXXbi9el%2FbWgw9ef%2BZZF7Vo%2Bb3Tz3ry%2Fj9sWrfR5fGn5%2BSNmzZr1sx5AaJpcCBCMTqob5nJLczKNQgIwYThMZN44qtjN7xe6qniBpK%2FP%2BO1dzdf9%2FM937s8v91ZBy5oXXhOy8LTmxef2qzytBb201o6WreoaXmKs02L6manHGhkK2vRuKpt85I2TbOb2fY0tO1rZss9p03Gd7%2BTeetN7r%2B8bEwbXzVu6PO3XnvpGa1feemZ7VkZtfG4N1mF1aTIs2Sl%2F8Axx%2B%2Fen7Fj2rQJU6dOLiws5OEhZwVJPt96660zzzwTTwbUDGI6KHHLSn3VAh3TcdaFf7B4TZBa80s9JQgzufrqqz%2F%2B%2BGOkOW5qrfvR34KnhWeABwMvDkSYs84665AoksNvKh3j6PGqpQiIgAiIgAiIgAiIgAiIwJcSsExd7Bc29lOma%2Bo4liyn%2BMbyIpKCpBAVlZUJqkwW1%2FgHT15y%2BU%2FWt%2FhuXuMLixqeVtKgxd7Wp2%2B75brIgimGu5RKp7s2rrn%2F1%2Fdcc%2BnVl1%2Fw3cu%2Fc%2FFrr7xQWJVL6oGA4Ygazojh8htOMlkUuQ%2B81%2FWjS2666txLv%2FOze%2B6av2SJ1%2BfH7yBOKQrSU2D3Y0%2BjHxjRADpGMqAEo7Uqt7Lbh53PaHFq04aNf3n3naPGDXbk7DHys%2BPDRuXdeOeBUy52NT43aGsbt7UJN2rmatw0v2nLbS1OX3X2xdvvuicyf4HhrDEoFRLzu2OhXKdjzqq0Hv2Hjx41pZBCnL644QvH3e5EGBuYN%2Fi%2BUNRPelGLCSjY0DSwQ1PKBqfAa4GyOLMPzFg8FgwFIngP4N0RRGKIJkjnEeO%2FkWCcKiyxEHEmRDwE7Ya7omjyuJ433%2FJxizZjzr54%2FhntVre%2BaGurdjtuuyu%2BdJ4RLIUYlrjbCFBpJeHBg8NP1EdNdWWNqyYQ491%2FJBowc4XUOjxbtuwsKy1HTbFkFgSHnTt34i6CAwD7DOxLn4TUsLkqqcCY02Wm%2FOSbI3v27HnjjTeuvfbaDh06EFmQmi8d0hJ3FZevrNadGS9NN1avjg%2BeGO82yugx3pi%2B1CirIMoklPAUBMu35%2B1K37sj5Ma1xFRjcJcoCnkmpy392T0%2FbdOyyVlNGl3cqNFPTj19wit%2FDa%2FeZBRXG%2Bm5m%2FoNvOM7F51us7VubGvcxHbZ1Vf2Hz68oLSiqsaVvb%2BwMKuQMrp1RQzKofKQmJFRpo6RrMAbiVKzpsSw1xqVCaPayNiZ%2FdCL%2B864ruiUs1y2JuFGjSNNmgabNPY2auRq2MjVqKGzcUNH04b2Jg2qGzawN2robXWKr2Wz2oYNCInyNmrsb9KkpkHDkubN97Vus%2F3Si8ufeSIxevDqz%2F%2Fe%2B93XF8%2Bd5iC4x8oPysOLiGF9zNgWkmT4PSF7tZN%2FQBUWVZ4fSsDgCPH973%2BfZJi4ZDzwwANr164lQc2Rl%2BkIK1j3FAVQRowYQRpR4lYOlxSIZyHHBXc%2FcOAAI6l74THt85hzI%2F44UK%2B5TZs2h9%2Bo7hHpGMfEVo1FQAREQAREQAREQAREQAS%2BlABGqGV0m%2Fa5WS3UfL2easlZ9vnOysqihGL%2F%2Fv0RNFAWjOKSqoFDZl930%2FI252S3PLes1ZmVzdvsOf3UtBsvD88fZ3hK%2FeX5IwYOuODcSxo3aNvYhnXT9OYf3j592eISjz0cJ%2FIBJwtyd8bdQf%2Fi1avuefDBhi1aYu%2Bce%2F6FfXr1Ky8qTaY7oBXRJGgBBHfwZp0Yj0hNOMrLbkI98nfmf%2Fy3j05reRrm2F333TV9waSws9gozomPGJF3%2FU%2BLG5%2FjaXhGyNbcaNTSaHpKpHEjb%2BNWtS3PzT6j3f6bbotOmGxUlBleB9IIkRoV4WhmSfWSlVumT16wO21vqCqQcCEUuBMxT9Qg34QnGPVBxuLAjsUESlj3%2FLRw8c1mahdJNwYMVSrGMkF%2FJFBeU0kW0JIDxS57LYkdUGboIhiPBGKBIM4eEcQJMkmWRJYu2fviK1MvumJh24u2tblkf8t2uU0vyr7lF4m5Uw1vQdRMXeqtitYWFeUV7NmXu2tvUWaWvbIiQA0WM3NIPEEcjD%2B8du3m555%2FZfqMWQ6HWcQT%2B5RgAaIVqCpCCAA%2BJAwvtbiH7zA1GlgTQfQgTwK1ZXnhTlYQflKi5dNPP8WFALcTWtI5r%2BOTJjlePdjqSAglidwt3k87Ff%2Fs4cKrfltx1f1Vj7xpLF5lOMrjhqvGsFcFK4JOquAGDFfQcJoqjicR2VddMGzGiFffevqZe%2B9882e3d%2F7JXbN%2B%2B8fslz6s7jC4utvwac%2B%2FfnPbU9s0tjVp1cDWwnb6VRd%2F0Kd7TlllxBePVgTjdrxQTE8M7HCyhjICcm6gJJj%2BGIyIrKduCrOGvYavzKj1GFVGotJI3%2Bl%2F%2FA3PWT%2F2Nm0Xtp0RsZ0WaHh6bZNTy5q3KWhzavaZp%2B8559Rt57TZfu6puy44Y%2B8FZ%2Bw767Ss09oWtmpd3qKto%2BUZvtbn%2Blud5WnW2tmm7Z62rbZecan%2Fo%2Fdiqxc6923xVBWTw8SJDEYCWhKikMKFJBOWlIFXDs4LhlktOMruF5x5hPiXRbqVF1544cUXX0RVwNeFSBCoHr46x3SEW5B49t577%2F3SAiV4YlAOlSghfEKOqduvaswscCyhykld1eLwfekYXwVQx0VABERABERABERABERABI6SQNL4Nt%2BnWzt8s4%2F9a1m7lrXFT3rLy8ujBCdlNwvQMQyjYPmifg%2F86p02LXo2bLimefPs008raN1642ktVt58eXjBBMNbbs%2Ff36Vj5zNOb2eznWazYd2cdtMP7x43f1mpJ4A4Yb4rT34iocTGTXvuvf%2BxBg0oztj4%2FHMu6tutb2VhuWmEmskbKVMSTfiTORoTIWxBdyQeIOFEGDcOR9%2FOfU5tdWrzli3%2B%2FNZLO7K3JuKuRHmuMX5M8c23VzU919ewbcjWyGjYxGjaKN7QFmvQJN74tOoW5x24%2FIb4yHGUR0m47eTO9ONq4vKNmrrgpdf%2B%2FsTDf37xoRc%2Fff3vmxatDDuoZOENGQ6%2F4Qgn%2FBDCvsdytxBhfrKxDyg2QKU2i57VwB8K7tmf%2FknHz55%2B7tkPPvhg2ZKlYVxZzOqgTI48HKFozJcIOg2fPVFdZqxcVfvGexuvuGVp0zOX2ppvaNA819a2%2FNY7DeJxPAWhRFWWr3D6mrlvvfWX5x557JmHH33msUc%2F%2B%2FAfW7ds8vkpvmpm3nA7veMmTr3y2hveff8D5AuGxDCILMAfg7f8%2BGOQt9Ma7ZEfD%2BaFnwBG7nPPPcdL9ldeeQUNhGgaDmIak8DBVGkoWsqWdNXgF0kwzOIkRmFi98rS3%2F8xv811JQ0ur7JdUXLpndHOfY2svYloDYVAvAnSmpKmImjYfYad6iohj8%2BbUZ6flrF%2B1rIJ47v9c8zTT8%2F69R%2BWXX932qV3rrnut3Nvvvf9dt%2B%2FtlGTUxrjkGGztbWd8cPLu04bV%2BJB%2FKHYScIUClAMkllhETGoflJt6RjMkEeInKhoUeGQz%2FBXG%2B4gJ2NlRvou44UPE2f%2B1N%2FwMm%2FD75Q1OCOrQZt9jc%2FMufDKitt%2F6XvqKffrLzn%2B9qrzk3c93T71df3U%2B9mHgY8%2BjH%2FwUeJPL0du%2FqXnzCu8rc4PNmoRPaVZSetWW88%2BM%2BvBexPL5hlOaub4%2FPEYoU%2FmI4peRXQIriH4hJhJS%2FhXxMw9pGilFi8PCZCtDZLIXKRboXQvi0ViVaI8EIiOvEb%2F9iwCxYABA9q14x%2Fgl2w333wzuVzwo%2Fi3%2FRxNA7QX%2FjLceuutuJR8yc3qHJKOcTQ81UYEREAEREAEREAEREAEROAIBCxLqm4DjF%2BMLDYOWvuWxcqrfCpsYs%2BWlpYFve6x3Tvc2rrJLTYb9Uo%2Bb2Qb1azJlCaNJrduufjWG8PzZxnO6nBl2YTho84%2Bp52Nl%2Bi21u3OueZvr3%2B0e09eACXA9LKPma4MZL%2BIU1%2FU26vHgJ%2Ff%2Fusbf3DbYw%2F9aemi1R4nJSsROnBcwEbFDg2a1UioOxoNcxxXDgzXRCiyYuGCe3555y9%2B%2FpOxE8b8P%2Fa%2BO7yJK%2B%2F6jrpsudu40YupCaETaiAECCkESCUJIST0FkJoIaGGXkINofeO6R1sg6kuuOHeuyzL6r3e74yU5c3ut%2B%2FuJnn%2F25nHjxhJI2nm3Ds8z%2B%2Fc8ztHpVFQu4kqa%2BjFizV9hyrF4UZGZiEMZRg3n3HyGBvhWYlYyQ%2BubdKB%2FnqE1iioUQulh0pvScope%2BeTiUQQwuMFSok0TCBb890iRWEeulhsVG1wKxt0inplPYo%2B1O9ADOcNBwzA4oUI1TyA8mKInd%2BqU%2Fzjdjdo1Mdjz7Z%2F5WVUcp07dNq74xeLx8wB122iLlhdsJ0zuDSzju0aiX%2Bon77ocdteJ8T%2BGwk5QEgG4dX1HuC%2BFkv1lQ6Hpqih8vDFM7Bk7NG5c%2Bd2bbu%2F3GniF%2BMeJdy3GEwwPwVU1bWKTVu3topp%2B%2BWECaiFvaeC08O5eCiH32QAoDJwtnjRu%2FPizF9cBd5CawMkHH1efRU%2FNXjQ4D179mACoLjGhk%2BxFIZn8HDZaADyGJmgUQYsQanryY2CgW9W%2BrRR8RsbmDBloxjzh5%2FRa1eoVq6lZhMuGpaf4If0YDMMboOpoKj8WOzFg8cPJN6NLb58ImXhN1d69n4Y1T49KOZKcMwySfhQwmtNSFSArEXH5u3f6D524ZR7eakqFwxZ3Q4L65zCToa%2F6TEgyWBDe50uCwQquEpQGRYX7FysbMyqwQmewyF3IzV13kpFs76JwuizvMDtjGgVw1%2FB993SLObga6%2FHTZqiP3GSPk2iJcVUXk1huqIso%2FVltKqI3oujy9bYeg%2Bvk0UqhMJ6HsmTiR%2BER9zqO0B96hRVoH3GZIMEBsACaUTBgscA2cL2tiAuFvMBdIpO79aCv%2FIOByaSdwiAvHdG4RGk06ZNm%2F46wwBiBJ4V%2F9R%2Bs2XLlmgOAmHyYsS9c%2BBPPOIb4BWzYsUKtMb8W3MM3AUcj%2FEnQOY%2BwiHAIcAhwCHAIcAhwCHAIcAhwCHwewT%2BRSHjrXNxAKot1O8orOCKgPVirPPGx90e9%2BHbITwSTUhHQt7mk7EMmULIKl%2B%2FC7362y%2FdoCqkbtozHz4ZNuzt1jGdX27bbd7X3z2%2Fn2HXsFYG0FgY2TpWZ3GbWK8Iq7WuSn73Rvyh%2FScfJKYqNei2oPhDDcqyA6zBp5X1xrDqUQejE4F9w4nOFGNtdeG1i6fOnzpaUVSChA4KPw2tjsbdqxr0tpwfZCASB2HcPGJlGAuPZycCJ5Fo%2BIGKsBi6%2BQCtVMAQ02p3KLW2pMzyr75ZEtW2e6OotjGRMX3bvrJl8bKazHTqNBjt8rSCxyfOHNm5cyeCMqFG8Hp4AhCWhfmbfMXbdgG1A1CCISooDu9bSo369KVLb4x4q1XLVmNHv3%2F3%2FBUn%2BinQ6OB06WDuCTLGQxZRmG%2BotTT%2BqWnakjstu%2BzyD%2FiBIb%2BCx2BITe8e7itnqLYWHI7Z4apSqO7dS9y3Z%2Ff2LRsO79%2F1JCHeqFBTqxvGq5AelFRX%2FbTup9ZtW06dNiU9PR01snf48IjCGWeFV3Bi3jP3vvJilLGDDXjjYGw4BovsJ06c2LN798kTJwry8u1WNgQFnwL3xNJQTgcYDFAIdjfbJoReIacT7EKp7frptK59KqTRKoGPifB0EomudRu6ei0tKcH1mrzKCR1OV0PtDQ6TIT2nat%2BhSwd37s67e52mJ5p2rYp%2Fqflzf%2F96%2F4i4gMhv%2BBJwZW14gndf7b%2Fyh4Wnzh%2FJKEzVOdSwFKl3O%2BF2AeICZBCmBBpJLC5YZbDUgMFk1ttBD7E6DTawFwE0LtiSgAdTU1c9Lcm0LF3xpGOvJQKfdwjp6ZnDLQlp5yfr1LzpR2%2B9%2B%2FjKXZtc79IiV8ZktcFb1WByq9zOWqoroQ%2Fj6cxF8uhWxRJeOo9c5THHJH7Hur8mv3TLoda7bVZEyLrtyIE1ejxZEdICBsPDY0DRA%2FaLIj%2FX6IR65m%2FMEnZwygAcwGJ0oHVBE9DAgQPBGv3%2BPv2j%2BzDtRG5OeHj476QQv%2B2C2Zg%2BfTpmspdC%2BaPf%2FPvj8Q3FxcUgMWAW%2Bp%2BQGF4eIyIiArTY77%2BH2%2BcQ4BDgEOAQ4BDgEOAQ4BDgEOAQ4BD4P0EA5ZV38d1TGrLRFSiOUGeBzZgyZbK%2Fn0QgII0Ylsd4j2E%2BJWQyIeulgXe7DnDuOUFTc2h2oe3egzvr1x9ZuOD89z%2BUHsaL6TQ9i%2Bbn0pJsWoG8kkKqrKS6epdW5TKZjVqDol6rMztMDmqGvQDsMFhFhtsJDQZr8IlMCoR7wFEBzSZ4z2h3Nlis9QZttVFbD0EAezT0EWYLTU6rHPqeXBhsYnxcfL5byDMQRkMYHWFMjFgh8a%2BKaOne8AutqHQ5jEhUNTtprdr%2BMKNky8Ez38xdumreiou7j1SmZDhVDS6nIbssbfKCSW1fjomMimzbtu2kSZPgAwBCAIUnHsFmYAfMBjImbt%2B%2BDS9NbNhH%2FwXKUmwILymtkR89dWbn1h0J124ZKuuojr0ExKGg4cDrWcpyB2hC0Ojpncf6Lxdcb9xxm79sCZ85QkgBn1H2f5VePUc1dZAxOHCVDmoyWRrqFSpljVZda9Vr2XhaE4XNh9burtNpT58%2F%2BWrf7stXLEVfCU7AWyCjWH5xwtj3PkURigNwFTh%2F7OMA7%2BYdbhyDt%2FQ6PagZrVpjRZsDy1%2F8xmNgYugNBhiJeiQPrPCBZadcOqc8p%2ByXjbHNWydJAuukEgefmPlE4yujH35C7z8C8YH2F7auN1mpSem2yWF7ojbROqVFW6N0KWrpkzjdnAlpTYNqJSKnb1BWSMQakXQEIYMDw7bOW1yUnqrXK2wOuF%2BACoC2AT0qbBoJOC0X2jnsbpyPyQimy%2BG2I9qGwtJUa7MZzDYLNEDguMxo4VGxzS9lz1XLlsa2f%2BkDAb8pYa1b0LAiZkjTJk3eHzvu%2BMlLilqTqs6a%2BjQ3JSWnAjOMWmuoxkYRJltBnydBklEf0yFdRC4QZi0hPxCyu8tr6oRUTy4JunvAUyEDR0sdemCIgWajV0GpgOQw6Q1O2HkYHXB99TirAHCAjCgZL6UA5EGIwU91%2F%2F79cCD5K3cxZuCGDRsCAgL%2BgcdA%2BipsP8HI4Xf%2Fyvezc9bJRtgsX768Y8eOQqHwH37of3sKPUZkZCScdv7ir3Mf5xDgEOAQ4BDgEOAQ4BDgEOAQ4BDgEPj%2FEUAVhgoX7o6gL1D1YEP1ildQyI8aNQoCcSJiohnyNiEbiP9%2BEnSc539THPEgoEldr9fNw9%2FXvzGy8vWhKQNevd2r0%2BM%2BL5e81U8%2BcmDt4B7yof3Khg3PHjYm4%2BOvatdupumZaCyhNhSiNhg3QomPP1S6bGmMYtViM5gMFkRxUCu6L2xIFUVpbnHYLNoKed6zrPvZOU912joX%2BjRsWOy3o56nWc9Lh40s4%2FtriNjM8MwMoyZExWc0Er7KV1QaJMtt1dyxeQOtzLW4lMhYRcELC1GVyVVRpysrqlHkV1vLlVQL%2BYe1tq5iy8lfo7u3IUIikUqwkI11Z5iEwCkCcAENIIPaEzXj6dOn4UWAt9CLsW3btsTERGgzUKXivKwud71aW1ddZ4ZGBb0GaHOwuY1Wu8JuaXAid5W9UtY3FVGb8UnaT2ef841cxjBzCKvHSCOkvMvL7vMnqBpFNKQUrLGGZ4NQxbPSjxoejI%2BV5TfgLQm6Jys%2Fc8nyRffux0MWgnPzUhPez3iJC7zi5TG8b2HfS2Lg8cWOd%2BjB0kAQ4z0YlA0yS%2FCU%2FRQ6e0BoQPiA91gGg%2B0BYrt9HAZL%2BsMrU79cIJPuFgmzA2R2qcDCYxQMo%2BzQybHnAK1Hdc8KIxBlS21KalO47UDQDdoK4g5q0rnPnzUPfUMXGsbGgjC8UrHspjBwa2D0vtGfFsbfd1p0ZhhqoEkDxA0cUnBxYDDQWmJmdS02q9NiA9XjBI9hN1sVet2T%2FNyLd%2B5evXk37Vmmuq7ebdIjaZe6Gmh5tmHNqgtdug%2BRCUWwc5ASCY8EMrzhXfud23bEXG5w1jifXkme9unMaV%2FPjnv0QOXSW2Ad6qx2q%2FNo4g06e25Vy9ZZfoKbPLKVkM3%2BIXfHTrOnFFK1i%2BrtbqS9WPAP%2BBQzUmXBtLBDDM4A9iHQNcFxxQUeg20nwQB5eULcWQAS%2Bzk5OQ8fPoRSorS0FAd4R%2B3PPaJnBBPy%2F3fdRJ7Ijz%2F%2ByFr1%2FoUNUwCnBwNYWL60a9fuPycxXugxOB7jL8DPfZRDgEOAQ4BDgEOAQ4BDgEOAQ4BD4H9FAJU4chOuXr3qTYFEUYxFZKwdJycnfTfvu1btY%2FgSfleeeDkJesJvlyJo9YAfnuQb%2FlDomy72qRb6GXj%2BaolPmS8%2FW0yKZaQ6gKhkxCQlBgFR8nxLeE2fhXSs%2FmAifZBE4ahparDb0RNi02JR3%2B2CEyhIDFTqCBKFYsDqxhq8tchSl1VVVFFWKa%2Bounzl3JzFU0d%2BPHTMh2%2F%2BuGR%2BZmYWlBouK6JZbbSwsHzkh5W%2BoRqhD2QYckLKCFOHAE1fgTyAn99Imt6%2Bie3nFbQyVe%2BuVlGUxqgyWVUGGlNYbgBlJ4waYaVgdZRUlc%2Fbsda3dQThE5FYLBKJ4JqIZW4AAtSAhncD1ZOdnQ0jzWvXrqEp4MKFC6B6UKWi1kOzA6JWPUwFKlk8YUkM%2FIrZ4VS62CYHA1bkcZgZZhE6mvDU%2BOms635RMMdYScgpQrIJKXm5vfvEQdb3A5wIG3TiMZB0Yx9kDzxDrNRgx3fi3BHjotBrM3LSbt29WlZeDKkFTs%2Fb%2F4LCE5Uyez5%2F2zy8FNtmggvxvouD8RTjC89JsFXe4cbheB2P%2BB7seAtYfLGXIcHXWe02B9tggssAwaDV37qybXCf9xkyX0Du%2B4lNIpGNYeNKywKClBOnuhKTqMWMlpBae71BV%2B7WV1Eb6nqWx7AjwkOlsP28zdC8PRX42QhjBPvEiCrE4U9bdc9esMyWV%2Bi2G7Vo9mCvGrkfoBbQRuQZL1BRdjAhLGvEdiI5XJoG1aFTJz%2BcPLHfsOGjPxy7Y%2Fuu3IwsqxpJu2qQJ7Q4w7pxw9VefV4PFPN9CSMhCEJpyvCXjfm6IS6H1rpMuapnl5%2BsmPHjyjk%2FZiQ%2BdICQwFjpK%2Bizu871K9SDBueHh2UG8pMCmNgQ2ekuPXOWbnLn1FAtpg3rTGt3oAPKWk9ttdSN5hBwXibMMFAZuEy7ES0vsKsFmAAZj8Af8OJeO3fu3MSJE0eMGDFu3DiEwvxFPQa%2BGQKhHj16YNK%2BkEZAjPHyyy%2Fjjsbw%2Fa%2B3%2Fb97A0ONM4fuaPLkyS1atPinia4vfvGf7oAPBNf3736He59DgEOAQ4BDgEOAQ4BDgEOAQ4BDgEPgDyNQUVFx%2BPDh9evXFxQU4MOofdALD5uI2bNnvz%2FqvS6tmrfgk8%2FEvufFzfPFMReEgWt4zHIxs0RI1sI3gCGVjMAglmjFArWYp5USrYCYBYT6wnKTWIhfA79pfkiH%2Bg8n0KQUqqqh5lqrE9kJZpSaOrfDhkQTVHisVIIN9oB1YpVacTvz8cHzp86ePX%2FpwqXPJ4wNbOIDA1GelDRpEbF1xw65QouVflbdUF5a8eFnNSHRWl%2F%2FeoZXSEgmVA2QBAh5SiEp8xUWNo90rFxMCx%2FbXZVGqoaGw%2BKyItgV0gqTxmCrVFKlmY0ENTnr1Zotp4%2BFdIohAsZbkcXExAAT6DGAhtcNw0sCgNlAHYqVboCzZMmShIQEGGWglgQ3YMUSPXgGNBiAKLHAsxRMBL7bWapX5ylqSurlFdVV1aX5tuoyeuee44tv0qLbX%2FDzjfUVpEiFcrGoqstLrsN7qbwCpf4%2F8BhuyBLsFoo%2BHFTxbpqSW7Bl96%2FfzJu14Ptvd%2B7chmV9%2FDxKTohGvAzGi0fsgMfAu2AnQFygl8HLS%2BAp3EE3b94Mi0h8EIOOI71v4WB8BE%2BxYQcfQaENrsZsYRkPtmsFjSdalebMiXUd2rxPyEKG3BeJDTyhnfAsAqZSKirs3sv8825aV2%2Bwa7IU%2BY%2BfxdWUZVB4VoBjQaOFydiQmVYz%2B9ta%2F3B8xMkQp4Bn5wt0wuCCZp2KJsxwPH4Gxwkb4l3QPoSeCEgkNHY2ZhXyHdaeA2zAb2IVt91RUlj44RfjhCFBhC9o1qrtiuWr8rKe23QaaoUphwI9Ta59ex8PHDIvIOgdkfhNnngMTzpWFHxxyqKK2Lir2w%2Bt%2BWHl4rkL530%2Bcf%2FcJfVnrtI7D93Hz9p%2B3mqbOV03cGBNeGSpr09OkOhplPTJoB5Fy37UXr9NYWLKtrc47SYLDE8R6FtHXXlGQ2JxRXJ%2BlQ5RrKCdWL7M6jRhrrE%2BsV5gQWIATwzWp59%2BGhgYiGkGeUN0dLSXK%2FvDN%2B3vPgArG9aptU8ffC3YDPSYIE9k1apV4Kl%2Bd9Qf3sVQY%2F5DejRkyJDevXu%2F%2Buqrffv27d%2B%2F%2F4D%2FZcNbOACngQ0HYxs0aNCZM2f%2B8A9zH%2BAQ4BDgEOAQ4BDgEOAQ4BDgEOAQ4BD4dwigkoqNjUU4IwofVFsoeBFW0qxZMx6PL2WYDmLeBwGiNX4%2BSX5R2fzA9TzytoD0E5PuEvKGkKwRkCQBkQsYHZ9n5zEWQsyE2D1%2FNgIew1dNIguCW9eO%2BpjevGrLfmTWFFioWkENdfBURFEOQgLr1%2FCP0CFYE79traquvvX0wb4Tx7bv3L1m7four3ZBrwc6Ang%2BROIv%2BXrq1GdZBR4hvotWlFR%2BNr6yUWOlb4CCx8snJJUhJQyjYdglfgXh14Y1cs%2BdQzPvu50VLqp0UZ3NZTC7zPnVxRfOxx7fvjv%2B2HllbjnW1iEESczO%2F3DGjPZdu7Vs1QpNJePHj4fu4smTJ5cvX4Y2HnaFjx8%2FhvEp9Bjff%2F99t27dQHSgXsPrtbW1KFThggDJgxURH%2BAxoKUAlcFafrgVBt311McHLp87df1y7JXzN6%2BeqXmaQGMvuD6fUdLq5aSI8NQI%2F5JAmVIsrurysuvQHloLHgMiFZavgMgDpT8LEMtjoOEGGg%2BqbDCs3Ly1VacOIl%2BRWCJo3brFxk0bvefgbX7x8i1gITCULPPgaRrKyMg4cuQIDCHRI4DKGuYn%2B%2FfvR9W5ceNGuKC8OBKf9ZIe%2BDh2YEGJmYBrvHnzJoplvMiqIMAtKOt1B%2FbsbNViOmE2CSQpkkAjkTgJz%2B3DV4l5xY2iTJ9Now%2BTDA1Vt9MTdhzb%2FigtHlSMy6RXV1SkJd6%2F%2BPPme8PelvuF4yMUlJcP4xLwzERYGhCRPXCE7dQVqgQLgRYaKDLAMnkaaaCcgUEGTEqQYot%2BFc%2BUthmMDxLu9ezXjwj4hMdv2brdtq07FVUQtIDuMVCnktaX0NhzuUPePu%2Ff5Iq4yUVB0%2FOClkf9291%2B%2F%2Btfv5jYo0VTsZ9IKBO2lEhmdeyeNXaG4%2F3Ztk7DNWEd9P5NzJJQE1%2FSIBSXhAXk9WirXPEdzU2hDTVUrXOZHBa9RafT69ngFnctpYlFpVsPnd29L1ZerGOlI%2FAkBeuCcXOythjADRvwBCEGjQS0EyAxIG%2BAYSaEE3%2BRbQASGDUQDgcPHpw6depHH30EaxcMGcYLr%2F%2B7W%2F9fvY%2BPK5XKuLg4zH%2Boj8BIgMGDBunixYt4%2FIfN%2B%2BLZs2dxGHqvvNulS5dKSkr%2B1W9w73EIcAhwCHAIcAhwCHAIcAhwCHAIcAj8OwRQm6BofbF5D8dqOwQY6NlH34S32kL1inTF4JCQFo3CJvbodPD17mciQ%2FP8w8r5fud9eD%2BFkzlNyKQmZGGM8GS7wLSYqKKWTUuaNi2LjK6IjFa1aa3r1LYmpomqcwddlx41HXqmdemd%2FslHVdvXPTq0OT%2Fvnomq1GyIhZU1lESlBT0G6j6vFaYV0Z42nclYUFxy7uzFVavXduzakfAIEaDwIz7gMaZMe5SSqTdZrDYTLc5XTp1R07hVrY9vHcOUEZJLSBVhDAKxhe%2BjYHwg1XBPnUWT4qmtwqP617rd4DEMRy6f6NWvV9OQsF6tO145eNqtsWOVX2NzPS%2Bv3Xfk%2BJo1a1HyIwQEigWUhK1atYL5APIgsCqNugyV3YMHD6BkgHwFhyGJEnYHLF0A7gHdMVDxg3gAPWNDjCycS53lDfWn791ef2DXxr07d%2Bzdcer47oK4S%2FT4cdfHE8sbx2SFBGcH%2BRQL%2BJWEVLzUyX10P1VUszwGMlwc1GKFRgItHmhT%2Ba2vxKF3JidnjfzkM75UIhDz4FwikYhGjx6NzgIMHAYXjyiZ%2F2GUUSkjfDMiIqJp06ZoliktLcUra9asadOmzfz589Ea4%2BFh2I9jPODxiR1MDCgxQF%2B8%2FfbbrVu1%2Bvqrr9B5BKEG3oSdCJQwps3rLrdps0%2Fof9M3qtwv0sL4OghxixmDmJHLAnSv9Hdt2GEryr9w%2BcyqbatvpyRYHQZdTcXDSxeWzJrx5YD%2B%2B1rFKEMauxmhi8c4%2BQyCZoyEqfUNKHqpl%2F2n7TSnmJrMrJGIBZIMKFuQagPbUzuybcBjwK7DDrjdLotGe%2BfGze59XiViIRFL2rZ%2Fec%2FuAw1yZMKi8wQWGQpaX0zPx2YPGHqLH5zNCy0SRGczjR9Kmh9v2mFqREQboMdjbT9bEfIZX3ImsHWFb0cTv4VT3JhKwq18XzWfqRPzqwIDHO%2BPoqcOO9MTyxKupV69XJ2T7zCaAROELEpKwRckZOYuX7Vt9owfY4%2Ffeno3vbZE4UAMD1ifv3mS4I4DyPCyQKUP%2B03wGBBOiMVimGGWlZX9u7v237%2BPIQOLBXoKhAZ%2BBXe0dxD%2F%2FSf%2F5RGYS5DusGocz4ap%2Fq8372EvHnEwvuFf%2FgL3JocAhwCHAIcAhwCHAIcAhwCHAIcAh8C%2FQYCtVT21FeoL7HiPxg6qFRQd3uV77CCI89dff124aNHq7xcm7dhQ%2F9O8jK4d68OaGHiBFXxpho8owY8fFx1QMrx%2FyTvDSj98X%2FvtXPmCRRVzF9bP%2B8G%2BcqNz607jji32g7%2B6j%2BzTb1qdu2z%2Bg5%2Fmn1n6zY4Vs5NTb6JVwEjhE4F1fQ%2BPgdofkgz0lZhdqFXdCMDUG%2FKf5%2B7auWfJ0uU9%2B%2FWQ%2BIuFPnypTNi%2Bfas169bfuPPgbvy9J0kP6xPuGOfO03Z4pcbXX8HnwR%2BjgnXJ4OnEfjpxaKUktDSklfPr2fR%2BHDVWsa6PTi3K28q64m9%2FWuAT4iPikTCJ7Ke5i2uzy8EToDpHraxs0GIhG%2FUgFA4HDhyAyYC3zQT1ZlRUFCIbUHWiYIThJw7wdpR4SQC2Y8LocFpRr6JihS0CzDIQWurWOu1FWmVWbXluTWlhZUFdbb5NUUjj7ri%2FmlUb2bI4IKDCT1onFKr4wrpuXeGP4a5DaAY%2BDzIDDT4YLpgw4HsNbvhjwM7T6Eh8kDx01AdEKGAEWNMnAQGyd999F4vjGEEcjUcvoYF9nJh3wzkvXbpUJpOBkAGhge6houIiZGi2btV6%2Frx5hQWFmAaYFuyPQTbAunqyShB8DxiPnTt2fDd37oF9%2B6srKuCg4bBYXaAX0lKs82Y9a9Y8RRJR6dvSIG3s8PAYLnQSIbVEIKwPbqIfP50mJt06cmz9pg0XHiXoLAZ9VeXZnTvGvjX0teaRPwUHlQWEmgnPRIhDwNiFxMQjcqm4KKyF6ePp9NYjqmctTXEmBjtCSMzIsoEbrNuOthT2AmGyAYoHJhSlBYXvfzpWEhYsDQ4Z8sbb167cMmkNrG%2Bsy4gRdzeU0iuXng964zKPn8bw66QBCmFIhU%2FkVWnAIoQIE%2FIKITGEDCBkNmGuCMJq%2BE3NvAgLE2LgSWtEvDwZKQrk1Upk2sZta94ddXX8R593bvtepw6Xdv1qb2gAZJi2sPesctkTU7NmT1%2FULLpDmzZduvR%2Bbe2uXwvUCoTPWpxs9M%2BLEcGdhYybMWPGBAUF%2Bfj4YESwoavr39y03NscAhwCHAIcAhwCHAIcAhwCHAIcAhwC%2F60IoFD1khgorFDhomjFK78H48XrWFGtq6srLCqqLshz5aVZ921O7BzzTBZYTKR1giCVMKyEF1gdHkO%2FnP2o3xunh47QnTrrTEm3ZeU7M4tpRjnNr3RW17gUVbS%2B6Nbe1VPeffXdvu2H92k%2FZ8anqSnxMI%2BAAMKGVXPKpnOi6GSbJ7Bsi3YMVO%2FwmDBbdIqG3Mzc5KTUi1djv1%2F53eiP3vxi3JjlSxYtXPj9Rx%2BPHzxk6Nsj39o8acLzMR%2FIW7crE0ugx1AzBD6TSp6wUuKf6RP6QBr%2BuFE7%2FVff0vgEt15OHRCA6NxWfXZu6rjZXzK%2BPIZP%2FCTiyZ99mXE%2Flc2YsKId4X%2FQQAsAem28PAZaAMBjQMwAGQa6MLyIeZHEPssIoe7HVemsJXmlGc%2ByyiurLBarAyaZbpfGYcuuq0ouzs2uLq6qL7Pb66lDQZMe0CnfKMKaVPv4NkjEWpFAyxPWdensPrSXwj0D8GAxH8igu4T9drPLZXTDoxRnaHDmFpRNnD1XFhrC4xM%2Bn8hk0mnTpiHBE2OHFgY8%2Fp6e8p4k1tPv3LkzY8YMeHpgB5cGHmbLz1s6dui49MclZSWluAoILTAl2OPx5yU1qBvfWFNdXVRYWFdT64A0AvMFaSMI6Lh9w%2F7JqLLQsFpBmFXQxMk0chCpnTBOwjgYYmR49eKAmgFvqjbtebT7%2BM6fd1yBC4fbVZ2XM%2BezjztEh7YW8ecIBc99%2FCwioZlH3ALGJWHMYqIU8ap9Gxn7vkePXnIrdQj2QCxLPbVr3JDuQJbh6d6xYeKC4AG0kL%2FANtVw%2BsL5CbNnfTLhq5%2B37qosrQHXweazoq%2BEqqm6AjxGyqv9TqLniBCdAKEqEp3I%2F7lf0Fk%2F36U%2Bgo98yAgxGS8kP4kkl0RBebwwBT9M6RNS4yPL9hM8DmMyGonqJAEaUfT90CbfBsm6gPrg8zZP%2BbomPQXJO%2FgZPXXXuxyZWQULpy7yFwSxyiFfv%2FE%2FLkisK1VQh4l1fGHTfzAu3rsP4IN0mjVrFtiMjz%2F%2BeMqUKSDNfn8PcvscAhwCHAIcAhwCHAIcAhwCHAIcAhwCHAJeBFCiYoPmHA4PkFvABAPuB6jKsYGy8NawWDv2Lut7P4IXWbJBpyo7uHt%2B08gpfP58wvvVJ%2FC0b8henvRykw6KGQvWdu05uWePnEuXqVLHZn9onbTBTrXIykQFblY0lH6z8Cu%2FYAZFd6Cv8MORw5PuJ6CfH1aW8MNE6wR6SrRWa6VaVWfQwU4AyZ7sojsbsYlqFMwGFBv28uq8hIQrD%2B5evXLu1Jh3Rwf6R%2FL4ErFUODgq7GS7DgXhjcslUpWQp%2BdjTZ%2BvEIivCYWLRcJpAvEi%2F9A7n32tv5fgNsOOEfGYFqozVJcVfbd0njAAphvEx0c8%2F9u5Rc8L8Fvwo2BLZZAqHjID1w41Apov0Fjx2muv4XHevHlwxUQdysLiyZ5gOQbPZjFb8p7n%2FLrll9lTvxn%2FxYQZs785cOhwfn5B5vPnW%2Fft%2BXLu7E%2Bmfv3l7KmLli%2BIj4%2B11OfRpEQ6ZXZ9cESdQGIQiUwCoZbwqzt0dO7aRstLqNnuQoX8G5WB80KfidEJn0%2B4XJrdGp311OXrX06ZMmToa4MH9R33%2Bafnz8firHAiKJZxSv8w4fE6KmiMO9pk0CyDqhlPwVNdv3b9qy8nnDt1WqVsYD8COw5YfeKK2F22OYZ9BBbsH7vn7ZdhuSa9iZ487hrQS%2BMr0%2FADzCTISmRO4uMiAogrnBgFkUAu9U9u3Sn%2Bk0lZB2Nvnrpy61FKeY084caVPi%2FFSBkSzpCJhDwWiMxiqV0A6oO1hLUJGQ0PVEZgQ5ve9o2%2F0spal8WkozCgcOnZoBkrm8lrsXgoFlBfuEw8sK4TNUrFw4z0uMdP8gvKHbAQwdmy8xZpNBqqraZxcVmDhlxmSB5hTPAUJQIjz6ckMORJkyZH2zefHhMywJ%2B8LiZjxILJhL%2BeiO63aFfV%2FzXVkEHVA3pmdWv1rFlouV8jhU%2FTixL%2FLwjpTUg%2FPrNg8KvpsSeoBUYYoOPctVpNbnLW2Q37Jr899q0Bw94bNWbfqePFetwPDhiBescFbAbYJDR9oHWrsLDw%2BPHjsLBAm9LRo0dxJ%2F7DkHFPOQQ4BDgEOAQ4BDgEOAQ4BDgEOAQ4BDgEvGvBKMBRycK1Dy58oC%2BuX7%2BOeurUqVMI3YDtA4pC78Kxpzpkl%2FXZJgWL1apQXti2s2NIkC%2BPBPLJAD%2Bfj3xk0OTPjm598utpH%2FXu1a9vjzOnT1q1JngbunUuNswUUR3sruVuYeqAie%2BTAB6RMJDRj37z3SdXEyiSKOAsAP8Doyvpee6vZ04v3LFpyb6tR%2B5eTK%2FJr3eo8Q7rkgl1BGgO%2BGa6oNA3WVR1V0%2Bd7Nu1N59IBXwxT8hrR8iORhG5oVE1Mn%2B9RGhkiI0nqBQKVgtYx4MghrQXCuaOGJF695bFaXbZbVRnp0oLHDLOXDg9cNiAl7p2HPh6%2F7PnzhgNRshC4GeAP%2BSnsA4RHk4AtScKz8TERCygw%2FOzzNNRAkzwLiBCigdoHy%2BnoVapD%2Bzd1yKimRCOkzwRsjN69ulz6vTZW3fuTpg%2BPaZ71xYvd2zXrfNrwwfs279RW%2FGMJt2nk2c2BEU0MEK7UODgCw2EXxnT1rplI8tjWNCfwtbinr4ftKdYbfDFsJmpBxCcap3BnPr8%2BYVLZ87HnszMSAM1AQYDkxyjjBPD6XknPJ5iB0%2Bx4TyxeXfwiLcgtLgXF19bWYVWERv%2BcF3oJQH3ZEPrhgUpqywngiPxbfhJVjfD0kogoKjW4Ny2xd400gF5A19qIj5WIqWCICoLtgsEdgHP4COq8ve7FBTyU7P2h6csiD9%2B9cKFu6dOnd%2BwZnmzqBCYnYTzyZeE3GF4DTyRyUNiuAXExWcdYrVEUh3UwvDN9zQr222DiQoiTZ0ICLG6TTa72Wa14DxtdiTDsMEw1A6zDLvFade7nCZWT%2BLhX3CebCorZpiaGuU0Jbl09IfJEoESJAZD3AhV4fuomjZXDR9R%2Be2Miwsmjh%2FerX%2FHyFdjGg1vE%2Fldr1eezptj2fMLPbjfvXObffFC5chRVc1fLpI1OSvxnU7I656%2FKS0j7m9dQ3V1Wnn5teuXtmzZsnXhsr0zFu%2Bds%2BTUhm1Pb9xugPUrhgJ2KVCOeGYLfFRgsrp%2B%2FTrEf2A64R7E3YeOEmh%2B4EPC%2FQfFIcAhwCHAIcAhwCHAIcAhwCHAIcAhwCHwTxFAtYuqfPXq1bt27cKiMJaDt23bhsLqxo0bMH58IX0Hm4E1YrYEQxuB1VqVU7h5wdII%2FwAiJkRC2vtJh4l9BhIyPqLZngmTRvXu0fvVrqeOHbE1wPQCNhcgMdgCDhyE3m2%2BW5jx2qRxxN8XcRKygEbzZ36f9zDbrYIPBhvBUVoin%2Frtgsh27cSRIeKIgGY926%2Fet7lYWcJmVMB7AEvubM6mlRXvo9dCVR93NvaN3oNExJcQ9FSQLj7iozHti6KaV0hlWoHAzDB2IqzgCX7kM6EMG2HRVCT4oGev0wcP1aoa7GYHVTmoBuflLiovPnHx9L7j%2B89fiy0pL2Y9IZwuk9VlhR7jt%2FV8B1bPgQNKeRAX2AenAUBYYgedIfX1yC4BF4Qok%2BTkZISYYDt%2B%2BGjfbr2bRTZpHN2keZs2oz74IDb2QnWN%2FOmz9F8OHFqzZcv2Pb8eOXkot%2FCpw1ROk%2B7RKTM1oVEaRmhjiANSAcKvahNj3byBlpWwuSRouWFJCIgLcFYmG9u%2BYEdah13v0BvtKqtdZdA3qORaTZ1Br8G5eSkLPHp32A96%2FDFw%2Ft5X2Dc8Gy4Bx7PUhtNlt9hcdhABVq1ag3RVvGYwGVPT067dupmc9kyt06L7iJU3gEkxmHT1KrNKy3I99SrTkh%2FkYmhIiJHHN4klRqHMGRpNX36F9nhFGeJbJRaW%2BvgeEYhGQr3gF7xu1sITh88d3XNw3cJv24cFBBPSkpCpDHnAiGsFftU%2BElvLUNok0MXH8BEjEdZKwxUjP3Gdv0rVWqvD0kBtWrfZ5sLcAg%2FjNJkMeqPOZMP8tLBOsQ4HAkwMbpeZ7Zn6PY%2BB9FMtNdXRtNSyDz5J95UZhchG4VsJXyP0s%2FcbTH%2FeRnMynNXZTxMv7Ny9bs3mZQd3%2F5xy5YK9II9WV9OqalpaRtMz6ZmLxlHj8sLb3vAP%2FlnEn8yQrwhZFhb4ZPF3jpy0uHPHu3bpJPOTRUplbUS%2Bb3XscvaXX7W11RR5wlBiQFTk2XA3wW2mXbt2%2Fv7%2B6E6aOXPm2rVrMXkwbRCIA20MO9LcxiHAIcAhwCHAIcAhwCHAIcAhwCHAIcAh8PcIoIpFJZ6amgruYv%2F%2B%2FSjG0YwAs0psWNBHzY4DUHWhzoXMALUVXsFT1LCK%2FJJtc3%2BIkspAHTAi0sPf92Ox37uEzGnc%2Bt73yxa%2F9964EW88jo11K7QwonSbjRa7zuY2uKkBgRtVBu2UH1f6RTSX%2BIQ2b%2F7S0b2xhlqLV4yhrzNfuXS3z4AhRAAVA0MQHhEinbVoZk5xhtWh9bQUePQYyNBkPRtN1KAtfpoyZ8KMlhFtfaUBgSFBIzvExL8%2BtDqmY5nUV8NaH%2FBshF9OeCt4pAlDZIS05glGt%2B%2ByeMqsq9dvW%2FVokPGkoiDG02mvM2pr4MFhVJvtJsSKsJ6cUKA43CZPMgPICuDAXr5Hz%2FCCDcAr6AtAR8DAgQORY9K%2BffvXX3993759FaCBiktvX7q%2BefWGmTNnL1390%2B2EeHmdAg6VBqNFqdLW1jfUKuoVKrnZoXQ76%2BjTeDppujosWsMXWWF0SRgrEVS3irFtWk%2FLil%2FwGJ6WDpwUeA2zy2kzKLU5z%2FLiEh5fS0h89jxLr1chsgOjioHzDrWHqPifffAVGEcMOt71vuUdYlwdrohtGrE7oPXAI9tP4mFsMp9nTZ0xvdMrnafMmJ6Tl4sC3G611dfUJt6JO773wI0z50vTc0ypWQ3TppbwePWENKATRMav8ffRt42hn31EF8ws694220%2F6nMffRZh3COkiEsx8%2F8OEW3F6uTzr4rmRzaJgrTmIkFWEl0eC5b6N67t1dk54z%2FFaNyOCSwjPxUjr%2BYF5bbpqf1xPS%2BXIXfXkwKC7Cc4p2MVpwgHEZnXB%2BRMkBtJMrOCboMeA5SYLA0YM9BerxwCPoaMmBc1IL%2FtkXJp%2FoJZA9yI18PzLxcGKAUPp7v20poratUpzQ7lGUdSgqFKqLGiZsThdesxfCzXZKCRGhRXulT9XdhmY3Khpgn%2FAUYYcJuRSaHjupElFB%2FaunzWtbZsWApnERyYJ9ZX0eqXDvv076jU1uG1gS2p1OSFrwRljdnz33XdSqRStTBKJZNiwYXgKFtHbaYID%2Fv5O5Z5xCHAIcAhwCHAIcAhwCHAIcAhwCHAIcAiwZSyqWlRM8EnIysp6%2Fvw5nnpxwVsodfEWdvCIyhctJ4i0QJHF1vKoeavk139a30vmH0hIBJ98Ehy4wq%2FRPOLzS8fu2j2HHq%2FfeH79an1WJlUZqMHqthn0VG8Go0F1VodebTTfe5qzYu0vX038bs2aXYW5tYgmYZ0qtC6z1vEoMXX4myNlfoEyf38%2Bnx8SErB82aLy0mykirAyDNTf7B9KWL0bXgRGva2uIfFy3E%2BL10%2F6evrkmdOOLFus%2FXGZacCQKv9AjVhoYtjwi3JCNjGkOyFtCB6Zz5p0%2BP6Try6euWjToKnECcsEVJjwI4B7gokiAsQO%2Fw32JVh1ONw2o6UwvyAtLQ3dN0AATA4gAibYAbfjpQWgxPjss8%2F8%2FPy8OSZInfjyyy%2BfPnliMZp0ClVtWWVObl5%2BRZnWZGK7NzwuDqissYMqG%2BWtAx4i9lr66DadPEPVqImGEcEe0034DiKEYal90waIVKjFxQpRvD6fIFlYXsOi16nuXr311WcTB7w2rMeAwV9OmpQQf8tsVHtKd3YkwbFgHL3cC55iNMFXYPO%2BgqvA%2BXufeqkq2JCCwXBbWScT72XKFXVXr1%2Fr078fLq13376xFy5gtpQWFa9duer1vv17tn9pcNfeY99879QPy%2FM%2B%2BaTcV4JOjToBqfQh2YFCRb8edNNKeul43defZDaJTiO8fYR8QMirAmbuyBHZ9%2B9Ro1Z38%2Bq6rh0m8sgihlzgS8oEjSsbtbfPnEZP77J9PRaJLU4idvNkenFQUXhr3Rff0OR8qjZSG%2BsVYnHa1AatskFhMhusTrMVRiFoLbFZdMr6WkWd0mxCJxPLY%2BAPcwaeImwEDgRBSvo8q3Lc15kR0fVimZJI6iQhBYFNSoe86zhwhNYr7C4LEMQf4lPhtonAHPTugMCwePBnv61ebfhlf1r%2FN55GNCsOjswV%2BmaJZCnBkanD3z708UcfdO8ye%2BbU0V%2BNe%2BfjUZNmfLX1143pBcl6pwaTCQHCZjiveoYA0wmTRCBAeDDLY%2FTv3%2F%2Bbb75BXwne9N537PhxG4cAhwCHAIcAhwCHAIcAhwCHAIcAhwCHwN8jgCLXU1TZUZWDrEAB5a1qvTwGalsQFxC6l5WVoZF%2F%2Fvz5CLbAUzZXpKa2ZtPmxeFhnwjIeIbs8gu459%2F4sqRRYte%2BzsPHbQ8SjRnJVNXALl7Dw8DtaKB2hdskNzVUK%2BU6gw3pFuUVDanP8nPzqjQ6VNqsN6LZAmMDWlvVcPTgiRmTp3%2F07qixo0avmD8%2FOf6ORaWg8LSE3yS7sI4%2FT4amGZ6Namq2WZSmsrzaZ%2Bk5yTmZyuxn9Fys%2Be33Sv3864V8A4PuDEZJ%2BHE84QqGN42QmUS4uWWPqwtWFCc9c7HMigv8BbpeNGzLgRtcicVhQa4pG7lqtiLttL6q9vKFizt37ty%2BfTvaRmBfgNrfiw92ACDEGFeuXBk%2BfLhYLAbxgrIUj5%2BP%2BzwpKclpgxbF7jBZ7C4nUjZMKGHZNhW2NQY5I2gJAZUBYsNGTU67nCbfc8%2Beq4xoquKJnYRPiRA8Rl2bdo5NG2lpMbpqoCZAVe6hPlCuIw8WlhWGjCep82YuGD5iVP%2BhI%2BbMn%2F%2F8%2BTO7zeDRY%2BCL2Q1j%2BnseAyfsPW28iLfwFBsGHcGyMHotKigohjNmYZG2QcUmxbpcao3mTkJ839cQQ0p69nn1zLmzEOqkJqWMeusdVOAi2KISEsITT%2Bo3%2BFzfAamBftVSvlLM1IhJSoi4asxweu0MzU91Ht5dNXR4rk%2FoFUawnJCJAmbt4L7lcMXMz3YdPvi4T49zfqI4mSDDPzRP2CynXT%2FH4f20JNW5bqUpqoVVEqjhSerFvkV%2BjTQDRtEjV93lCriegsuSqxqepqU%2BfPxApa63ua0YM7vdVJiVvnbFssnTpx49f06hQV8MO2HAS73gMahFRXNzqyfNSGrVItPfJ4UhGUKfx7LQB9371m%2FcQmsqbS4T4ngV1K3wsBlGp8eDxA1OC%2BhDAGIzFOcdmDxhdqPQbSJJhjRQ7h9RGRieFhL5eMiwDW8O6904askPP5y5dSP%2ByYP0589qakptcJRFCww4LNibgENCS47Viok0d%2B5coZC1lkXQ6tSpU2HviQBWvPX39yj3jEOAQ4BDgEOAQ4BDgEOAQ4BDgEOAQ4BD4H8QQKGKMhaFrbe2fcFjeF8HiQEdAur3DRs2jB07tkOHDps2bYIwA1aKtLLMunndzeaRJ%2FyE54S8TLG%2FRhxZKokoe7mna89%2BWlxA9QpkjlKTxW1zqmzW%2BOL8rbFnlm7dumTthoMHj2en5Zl0VqvFrTfZ9VYXSkWoHFBrou63mh0ahTo3Of3ehWupt%2BIb8oupWkeR74n6zuqAj6MFRIMFLRVGaje6DUhNdWCZHcQDTDhgsEGNDfTWTf07I3N9fWpEjEEE20mRhSet4ful8f3iBIFXBBF3YvoXrd5qKihElis%2BC9cNmIxi5R08htGDB6tGMFmowYzfVdbIExPuwXrx3Llzd%2B%2Fera6uBufjJQfAEqCoRy8ACtIuXbpERUXhsW%2FfvqNGjTpw4ACOhNGE02QFa2O22xqsxgadzgH6AaIAmB%2Fgz8r2yOBLWOsPt5JmPXXPW1Qb0bSeEbuIkBIRy2O0buvYCD3G73gMT10OHsPuhn7EadWZMpOfX7525%2FTla0lpaXa7Ed0T6IdBOxDKbmwsl%2FG7DSOLsUYDEU7PG2iCN7Hz4MGD4ydOHDty9OiBQ%2BdPnUlLTlEq6sFvOFzOovLSOfO%2F6zNwwJx532U8zzKbTcUFhWuWrRjW77W%2BHbu89lK3Ia%2F0ejem0%2ByA4MN8JkPEbxDzVVJeUlRg2ZRP6bNEqiijWWm6mXNLo9smSQNvyHyPBMr2dm5bvHoxvXCGfr%2B4tl27Sn9flcy3zDc0y69t0TvjnU8eUKuCnj1N%2Bw%2FX%2BEfUSH2qZJJCsb%2BySRfXzGU08RmtUzt0huzi4mPnzh46crBOUYMLBbINDbVnjx7s3C6GEQrGfv1VckaW84UZ6W96DAO1qmhBYe2sb%2BPaNL%2FhL7gEc1Ee7xxPuC80InX6TFArDrtORS1yt03J%2BrWwJqYgQ0BhsDPUbXHq5E9vnBnWo10TQr4mMPSQNAREVfiFJgWGPhg6bP1773aNiv52ztznJRUYZzvUPWh1sYEpQ08K0mlZSYjDxnqqYBZhUo0YMaJz584jR46EMwY6TUASAvP%2FuT%2F%2F%2BB5GE7ctHHrxVX90Q%2FAu2BUEFYHS%2FOO%2F%2FI%2BfwJfk5OTcunXr%2FPnz0JlgHxPvHw%2FinnMIcAhwCHAIcAhwCHAIcAhwCHAIcAj8QQRQj6PwQemEwgqEhrfIxYvYUGehroHS4N133%2F3ggw%2FGjBkzaNCgkydPqlRqtq2jqsy%2BdV1Ky8ikEP88P3%2B1JNTJhFULgivadXHt2UeriqgDVaABK9CwFiioqlm2eVvLTt0kfmG%2BfqEtmrX8acnSssJCm8kEQQDqbQtOAPaRrBGHzWGH1yRaWawOg8UFRQQYDDQRwF8U3RB2Jzoeauu1FdWVaq3cjV4UlIcIIGWPwo%2FB%2B8DlNChp3C3d6NHZAbJKMaMTC8x8MXgMNRErBX51Qc2e%2BTU%2BH9bixJj375w6UFlZCC0Cgi50bjf6CFheAL4EsCO1QaSBNgUbls5xbohngQ8mUEIhhkfvjlfGgNzSd955JyAgAE0lvXv3%2FuGHH06cOAHyB80XOMxps9t0BovekJSWcuTiuTv37xnUqKPZ9hX27288BqJY7OBRclKc8xaVhUXXEYmTiMBjwNmjpkUb27q1tKTot74Sjx7D6ekrsTh0Wp1KV69xWyh8PtUWO%2FgLuC9Qh9ntgubDw2L8bT5gZHFpeMQGg4tHjx6BfoFiBOeJi0LBGx8ff%2FjIkUMHDhzau%2F%2FciVOPEx9WlpejcQZyBiSCPEh6cvbi%2BaRnqSYLvtxlMRirikvvX791Yufe0zv3HVy%2F7b2OXfoTsoCQmzxSJxJopaJnraIrF4IWyHArFLRG6dixr%2BrVwemNIgvCwzPDQ%2B40CS96e6hr6kT30KH1fv4GscAiEeT6%2BKe372Nc8wutqGQFDPfvW8ZOeBYSlhoiyQwVZwX45fhFZb06tHTj9qLb8XlPUuNuJ2zf%2FsvadWuyczO1BpXWrKquKtm%2FY0ur6CiIHEaMGX0r4b4DNASQYGU8%2BAeNJnpqVdPiYuV3Cx%2B0aZ4g5d1D2CshVwk5LBRnf%2Fo5TU%2Bl5gYn2kDMGpfZyNJZDQZa20Br5LS8mOama66d3j1tbLtoGbxWxhJyjxEqpI3yJf5J4VEPx4xe9fH7XVu1Wr5sVWGFAiocG4gykH7oh4LliAGkFvQYeMZ2jmD%2BAPa4uLh9%2B%2FYlJCRgHxMGNyNGDNPNu%2FO30fsD%2F%2BKbMbjw7IXjzR%2FdwFIiU%2FjatWuguf7AT%2F6zQ0FiYHZ9%2B%2B23%2Ffr1A%2F%2FZq1evOXPm4MT%2BTxiSf%2FaD3GscAhwCHAIcAhwCHAIcAhwCHAIcAv8tCKBcwobaFuUPdlBbYWkeDAbyQ1GMY40YC6lbt25FzQtCA0agEGOwcRVo%2F5BX2HZuSG3aKCcgQOkbYhU2cpCgCn5AYZuX3Dt30IpMo6tGTfWgPOR2S3Jq9ozxs%2F0ZfymRBgsC%2FIXCsR%2B88zD%2BmlWnsBvQcWJyWfROs9ENdwYrCh0kcTiMNosKIRQuuxm9Hi67wY2%2BDDY1E8ac5XUNz4vyKuXFNgtKeANLqrhYSgD5JSro%2Fm1a%2BizJ8uX45yF%2BuTxSw2M0hK8jPCPDtwnF1UJJAo%2BH1oa3pMy4z9%2B4kXRNZdOyX8v6P7JCCfaLzBB1wPcTZ4G6k30dxIOXFABKqDFRbwIxHAtCAMh06tQJVTM2FGuQM3gbT36DlPWasJYUFHz%2B1RfBjcPffX908sOnbh2aWNxsHwu%2B12O5YGDdF1T0ebJjzvzisCb1Yj87I3IToY0IKpu2NP%2B0khYVsnITNKHAq8GJkFhjdX3l02cPYmNP3zh%2FNSc9%2F%2BGTtKzCUrh1sE0qLvSfsOKDF384Ve8oe4cYgwiyBUUusj7z8%2FO9QbHojgGnodNq9RqtUauHswdYG1wFvgTVtc5i0pqNOrMJPAbmClJZXRabw2g1K7XakuqU6%2FGf9xncB6m7DHOBEdYKpVqpT84rHRTrltOyclpnpgo7fZZr%2BH5xTts2iuCgeoGwxs%2BvNjRcHx7uCg4x8XhWtP8IyLNAv%2BShI2ncE6qxIFLVnJCY8cX4vSE%2Bu6XkmJTclPHviKWHI5seGTf%2B6LqNx37edXDTnnVLVs%2F9ZvaRYwdvxl3Lyk9XKavvXrnQr1vXsPCw2YsW5haXoQPJ04jkHULwUzpWj1FSql%2BwJL99mwp%2FoZxP5IQpI%2FzCRlHWydPoo%2Fu08BktSbOnP7Y8vEfvJtDLd%2BiJC%2FTQMfrzZvO8b1JGDJgdLIriEx6PwNj2BsNUMLICWah88OCGtT8dXP79W0OH7N1zrKYWcxa%2BHJ6kHk%2BDEjxJIfuB7ghjwwLr4Q%2BhnQDyeMS89953xcXF9%2B7d%2B9P1PubnkiVLGjduHPLHt8jISHBxhw4dgsroL%2F4PmJGRMW3atPDwcDTOoM0Kj6GhoeBC8Tpuor%2F45dzHOQQ4BDgEOAQ4BDgEOAQ4BDgEOAT%2BaxHwVrXekspbd6PEwOo8Clu06kPx3r17d4gNjh8%2FDmEG%2BI2SkhIEs4LiYNeXq8vs29ZlNYsuDW6k8m2kEoaqheGFftGPW3eoWbSg5Nrxh7l3s7TFcre53mEryC5ePm1xM2lEiDAgWCgL8ZF%2B8dmoqxeOZibfq8hPZ6kMh5lajdSKlBA4TkAfwVpU6Nx26Ctgv4l2DzV14hGUgtZFNTaHyqQ32LRO9JWYoNhneQxQEDrqrofRhUNLs9M0X46%2FJxXfJSSDkEqBUCHxVQqESoYpEQoey6TbRfylXVse2jwvP%2Beetq7EZda5HDAvhZOjDUaktMFENWaqt0IHgqRXi90AmoWa0NticNtMbiv4FgvbxOB2KJSKg4cPtY6Beyi79ezR88yp04grxYxC1gnqfdAHZoPp%2BrVrA4YMIgLS%2FqUOh3bvbSitKU8rrM2ssKqsTlwn8lAcRodTTTOTHd8uKIlsrvENsjEiGyM0MqLypi0sS5bAl5LqLSyVYXE5dQZFWfH6lT8M7NO1e8d2fTp2Hta7%2F6DefZcu%2FrEgI91t0sIyFYwQRXsCa9rJSllgDwqyCkQVyBc86vT6xMQHDxIfVFVWGfQGlsJiDUTZv9%2FuBTzHIEAGw37SaXZaEHPL9t%2FgULRpwC%2FCBg9Yz%2BEgVjS6%2BnuPNo0YPUPkv0PglygNqZcGqqQBhQP6qPdup%2FI6ikRdpZvWqhyxp2tGvFknC9ITPjxA1DJ%2FnUisJ8TKJ2qGVPsKbkeHXhw1xpCUbleZNAp94ZXrZz%2F7eE20%2F88ysotHzgnIHYngWru26T8uLr1xPfPS9TtHzu9et33ilxN%2BWLJ48%2FbNV25fUSiqa0qLdm%2FfvmzlilsPEvWQ6Xj8MTxUBhtX63ZhHNW0vFK7ZGVB29ZKKeOS8hxCgUngowgMNfTpZ5o6KW32lw8WTrny8ajjvXtd7tIjufdreb0HVfUeWP1Kt4K2bZKahG4NEPTkkQBCRiOmhAgKpJGVHbo5l%2FxInz7KuHt9w7p1GZlF0HGAQmGpDKDrQKiK2%2BTpXcJ8hlbGe9N5b0DccXiKDdwF%2FGe%2B%2BOILEGKwK%2Flz%2Fy%2BBx1i0aBGyXL1z8j9%2FhLtLz549f%2BMqMW3%2BwoZpcvDgwSZN0HnzdxvSfCAUgfLkL3w391EOAQ4BDgEOAQ4BDgEOAQ4BDgEOgf92BFA9oZhC3eHdoMdAHVRUVDR58mRkKKAIwYrqypUr0bmPIgvuELNnz7567ZpV2UArK12bN%2BU1aZEf1LgkoEmuf7O0oJbn%2FKO%2Bl%2FjOatXy8%2F7dh7874JvFs55lPzOZTVaNIeXW%2FTWLl074YvxHn3y8YP7cud9O%2F%2ByTMe%2B%2F99bUr8fnZqax9TJqYzSJeOp%2F7NngU%2BERSEBboMayv9mgsbFJmhBOsJYF7Bo7pAl2qCbYhgsIFdjXwXvo3Q41LSmomzrjpl%2FQLYaJY0hKoO%2FTYNlTmU%2BKryxRKrsVEHA2vFHWF2P1h36p3bejbMc2V9xdmpZO0zPo81xaUExLK2h1Ha1TUmU9hb9ofRWtLaHVBVReQJUl1FBFLXXUxbah6M2amwl3uvXuxfCEAkb01pC3Hscl2jR61lIBDhsIFYGyw2qLf%2Fh4yIg3kSHbrUvHnRtWr%2F1x0efvvz92zIcb129OSsnMzy8tfP7cXFlKn6U5v19W3rRlg1Tm4AmMEt8amX9x61aOeXPpo4e0tpYq1bRBjWDQmrhbk17vH0oI%2FiIIiSSkMbwaBg1IP3mUKqqoXkU1atAL1GCkRmg99HDRgJjD4rIjWxYdC9XyuuXLf9qxdae8osbTbcFapZrB2LBkBVv3s2hanWiFYF0pqcUKbQQrwWHdQ1kNCbJdrG4dCA5WTWJw11e6z519PuTNmwHBaT6yMrFMI%2FSTB4YVv%2F%2BO9uoxqq2jOgvVYaQMtCzfumZDTXiHOhKSJhLf9xOkiXgVhNSKmDQfwRERb7pY%2BF501Pzx4%2BNv3FHIG0yYiIf33hr95tXIwAcCJkMsyPMVpbdsJp8ykT687yopVObkpD16fDH2wp24%2BCcpqYVlpXpMNoulqrq6uKK8wYBOI48MA4wLSyaA7QI3hVwcI61RyDdsze3aWSsTOKU8nYCnEgrrhVJVUHhOq%2BZ7WwVvjWl0KDjoNBHvJWQ9IUcFoiTf4FLfsCJZcHZgwM3QoLki0duE%2BYYvOS2LftK2t272QnonAaSNVV6bl52TV1SeVVRZoVSBJPKKbjCNNW7E66JbhvULfSHpwQ2Im449Tbcbl7t48WLIKHDflZWV4cU%2FseH%2B%2FfHHH4ODgxmG%2BTsS4V8%2Bwc0%2BcODAU6dOqdWY2H9pwxUhimXChAlez9vf%2FywkGTNnzoQE6C%2F9APdhDgEOAQ4BDgEOAQ4BDgEOAQ4BDoH%2FbgRAXwAAlB7YvIQGxO0oQ8aOHetNUoDzw8KFCyHDgC3k2rVrY2JilixdKs8vQLHvWrc5p0mblKAmySGtboe2ORHcarFvKGItmnvqax4hndvHXDp2zFBTB8kETBkqy4rikhOuPbz1KOXRrFkzQoKD%2BXxeZGT4wQMHtBoNKxjwGHSgywUBGljv956YvK7u8JHDK35aicRPuRoxri64WKAYh7yAdTuAUt8Ixwez0w4DTTPkFNSuoqVFtd8uuhPd5g5Pdpohe4S89SLmFxH%2FjNjnlMDnmEB62i%2BoYMhw9RcTnvYdcKNT58pPx2lnz9HMnqNdsEi%2FfLlh7Trjz5uNWzbrf96k%2B3mDfsMq408LbavnGTcsVGxcoDi6yZF7j1oq3c4GWB4U1pTOW7z49aEjhg4esW7JuvoSOTUgNwMcC9t9A1R1Dlpcr161Yf1bw4dM%2F%2FqzLau%2B7%2FVSSxFSTXikfdfO361Yue%2FQsUsHj8lv36Pxj1xLV1e2jKkTiCwMUy%2BW5AT65bRoahzzHl27yr3vV8ehfdYTh00nDucg9HRQ35EhfkN9xEOlwmES0WsMmdqy8c0ZE7WnDpsuxRovX3ImpdBaOdVqWFrDAYtJh8Fl1bhsWrv9wZPkIUPe%2FPTDz1ISn7rAV8AZ0mbSu6w6HMM2peAFD1UEA1WQH9RoZ%2B07wMuACvB4iMCOw8FmklpYUqOBVmTQ9attnV5pkEotQr6Rz9fzfMobRVXMmGBKu0mtcmrWsX6qTrPbaXTfemB9bUKd38tXRdKtfBIrIGUypjhIeNJPOIGQlwkrcvD39xn94QfnYs%2Frqstpaa7%2B5w1FHV9WigK0EpnKR1ISFlj1ak96%2BjiVl7n0dQZ1nVqtNVlcoFgwY0BweUxHPDmrHg6DvRg0x4DEQJ4JdCX4wzSp15T%2Fuj%2Fr1d4aqdAmIkoJr0YiUPPgB%2BufL5P96kM2isgdviSX%2BJ4lzHxCfiYkRShTi8PrJKHFvoHpoRHHfYK3BESe6tDz2egvahavcd9%2BQKsUVN5Q%2BOjprq3bZs%2BfPx6U34olh65fzFPL65ysSkZDbSqXGaPgcLN5JV76wvvfD249TBUEH8%2BaNQvZJTweD8zhn%2FufCd8Mk5agoKDfEwj%2Fet%2FX13fw4MEIJEKf1J%2F70d9%2FCm0y69evx38UuIp%2F%2BF2Qot9%2F%2Fz1kXb8%2FntvnEOAQ4BDgEOAQ4BDgEOAQ4BDgEOAQ%2BM8RQPXktRb0llHeD6KeAmWBgJIBAwa0b99%2ByJAhZ86cwSptaWnp0qVLoQyfOWtWEaQLlZXOA%2Fvz3no7c%2FDw9Nffie3ab33LjuPDomIICUQWJ0MkUuGg%2Fv2un401VMmphe1xsNtMGqe%2Bwa5PyUr76OOP2TKHYVDafPfdd7m5ufh1L5HiZVSwjw2FVWJiIooskUj03siRD5OfahxYUKeegFhWDsCKMwxOu0ZvsbKZJ3ZqoHY1LcyXL1oW377HHZ%2BIA4Q3g5DPCFlGyDkkUxAmjsfDX0pA4LOgoGs83mnYPPr7Z0RGPItolBYdntEkKrN548zmTTKaRT9rEpUcHZ4aEZYdGlQcGZbWPPJS00YX%2B3fTntxD64upWemwabUWfXJW1oUbdy5euZ2VUQDhg9vuRh%2BGE1ksCAZxO1VOd73VgSSRi%2BdOXT17ZO%2FmFS%2B1CmfrO4Y0bd%2F663nz9hw6evvUefntRPAYzlUbi9t2qBCLdeAxBKRcyisPldW3aqx9pZ2q1yvyfj0rBvfPHzooeXD%2Fu316nnqp%2Fe6mUb9Eh%2B2KCl0i4G2QCG51iskdOujZsCEJH36k2HuAFhVRrYoawGPA78Nuog49skQNhhNnz%2FfrN3jkiFE3z1%2BzG6AUgAMGGnsc4IB0YC48cgyWCEDhj54SakCcix22HC94DBMrgal3O%2FUOrctR6y5MojOn2Ru3MAslbj7PSvhGIq0Ka6ZZtsBd%2FYy6lOwvW6G0QbatjeaU0SUH6tsNjZX6byQklsfUBwgrAn2O%2BflOFIj6MExLP5%2BWzZt07dZt1eo1ldmZVF1DL19U9BqoF4WZhH4GobhKKi2Jbupat4EW5lKT0m7WWOHECprG06%2BBGYHmG48Egn3Fo8fwxI3AJxa6EvhUgMTAn1JXfvB45qsDFIzIzBc2BPpXymQKnszAC8nxC9khISv55JpPwDNR6AHCzGDIMh%2F%2B3ajo8uYdilt3yO7UMbt%2F36dDh6d9MVG%2BeqPr0g2aU0jrVFSlMxaXbV%2B1NjQ4hCcQMFIJz8%2FntVHvXkmMr9YqDQ7kyyB0FyYY4N1Yl5UXPAYmPG5DMIpw1zx8%2BDACfNHP9Vf6SiDqAI%2FxH%2BoxQJsMHToUgcJ%2F2pHj9%2F%2Fh4EIQUPL666%2F%2F01%2BHkwwiWvR6iKq4jUOAQ4BDgEOAQ4BDgEOAQ4BDgEOAQ%2BBPIgCuAJ9ESeUlNFBSYUNJBbNKVBxbtmyJi4tDeYVXYP134MCB%2Fv37I9GgqqSY6pU0%2FYHt5H7H6eP2EyeyVq8%2BMWnKgnfe7tqiaUhYkH94cNteXRavWFZRXI6kStTJWNJHWqodfhdOW1pWxkcffQQeA1ujRo3AYyCTEaeBk8EPeTecBs4KrfQw0uzbty806nAgPHLyeIPZCMNPC2waUBZj6R08BnwHUAWyPREWuFpQ%2BGNUlNUuWxffbWB8ZNtjIhl4jC8J2cGwXhlyQjQigdFHgj6CSkJyPQYa8AJVIy1UxGsQMPVCnoLPr%2BPxFTxeHY8n5zEKAd8oDtT7RlT4RSX7R8S36WRYt5Hm57kNWjQp2J12g8OhtNpVVuy49A5nTUNDYXlpjbrO7Mb5sJ4eFRpd%2FIPES7GnH8ddi7t44pN3Xm%2FeNDyiSfiYL8Zee%2FJYrtbaNCZaB1uPIvvmneltO%2BT5%2BOolIquM7%2FBjHAF8g4RRCXh6X5E5LEAbGlTpJyvylxWFBBU2CisIC8kPCc7397%2FHMKkCRhkarAoJTvKVnWrXoWDTzxQL37oGdqQ8PIaVuoBWrVZ76NjJbl16jXj9rRvnrli1yKpFLwlkGEh%2FwR9rpgoGgBU3wB%2FDhcFD6w6rx2A5AbiQoAY1sO9UanVVNcUWVSHNfGD6%2BCNDWGMD38dEGBMRWIhMG9Xetm2TS1tgNlQixdTSoNLrNVaYttZp6JlE9bBx10Ki9jPkNiEqPr9a6hPfrOWhnn1XDRw88623f5w9Z8%2FOX7PSs5x6rduiog%2Fv6Ya8Y%2FJrbGUCzURUS0SV%2FlGur7%2BhDx65zfBNwWWxfAX%2BcNqYaJgUOE0QGh4egxXueEw%2BMFs8PAbmIHgMlU5%2B%2BkLJG6P0vpE2YagqJApxt5qQVuaQ9nnRbXdFhKwI9bvSqn3OKwNud%2B21s3%2FXQ%2B%2B99uzzjxRff1U3Y3Ltopman1c6Lp1BmCytKqeKWqpVU4PerWqoKy7auX1b65i2AqmUiEWEz2%2FXufPOXbtU9UqH0egyIV4HviXo1mHnNmY4S9V5lBigNTDt8SJkGDdu3IA7zZ%2BWRvznegzcU2g%2FGTZsGG6x%2FxMSA5eQl5f36aef%2FlN3DlAr48aNg4spLhlTids4BDgEOAQ4BDgEOAQ4BDgEOAQ4BDgE%2FjQCKCtAYmDDN6C2elFSQR8OBgP1lLfmwgFgG%2Fbt24ccExvcNbF6ry2hVVlUXkRry10lBYaCnILkR7%2Fs3jZpwTfj581esXvn%2FfR0E1sBe0pKCBVsLtgv6G3WKnntgvkLoqOjAwMDW7Zsia58eId66zjvOjVb3Xk2OI4iqxHLu1Kp9O133om7n6CzoTGANat0Yw3ejKYHt1tl%2Fn%2Fs3Qd8VFXexvEgSFcUpSMggiCKigUIuq7ttbxr29W1rm3XdV17AQs2EFQUlF6l996l9w4JvYckhPTepvf3d%2BbivFlWEFDRkGd2Nkxm7tx77vdO%2FHzOM%2Bf8j9fudAdMaUsXoYa%2FKJSZmfJVnwXR%2F7Og6TWjL6r3fuUK71cpP71aheSK5fOionKioooqRBWfE8Xj9KioFH6l1OQ55Vgyw82DqChXFA%2FKecud4z%2FnnGD5CsEKVXzlLnScUzf1nPqx5eusanhVxuvvhzbEhgopTMphKUlqFmyl%2B8%2FP5Lz85Rs3TJ47c3vcbnvIRSzAhJkDaZljxo%2F%2F6P13vurywawxQ%2BZOHP7N159179F11pKF6U6GCIR9Cl2h%2FYmuvoPWXNk69qKLUs6rll2lfGHFcrbKUfnlo7Lp75eLKihfLpMGE7BUr5ZatVpCpUqJFSumVKmac94FaZUq5VauZK9a1Vbh3EMXXDi1Zcs93%2FYOJR8xk0p%2BGI%2FBkAhamO2wL1%2B97s477vnH0y%2Fs3rw94KTGBf9jOVAzJIMGk3UczTGYGvNDjsEkGdP5ZKwGRUxZIsbNqjE5CXG7ipN3hTYsTb%2Fv%2FswL6hVUON8dVdkTdZ67fF3vlX8IjvguN2nLppjF29aviV29dtOmTQmJ8aHs%2FFBsvLdLn9VXXje6XNRK8MtVzqp20aLGl8V98LFz1bqcmB2Ze%2BMK0nO9NMzNgrS5oZ1bQ692tNW7PL9cjWDFmp7zGhbWapkbfZ939KRQXnY4FjBzRyg7Si0PJvTgWSLHCIc0VPIgzSBD4LNjZpd4QqzSunj5wWf%2Bdbj5tYmXXL6zRav8Bx8O%2Ff310N87Fr3w%2BrLnnhr%2FxJ8TO38UHDrWP2OGbeMix65Vwe1rQ0vmuqaNzJ02zL5yRih5T8ieHXLmhey5IWdByGsP0WKXY9%2Bhg1%2F06tnsqtbnVK0WdU6FGjUufPbJvyXt3R%2FiOlP60%2BE2E47CAQZ%2Fa5Ebf1x8%2FvlQ87fGXxw1dXnp9P6iTz7H4K%2FvwQcfXLJkyWlnJiVbSIMPHjzYqVMnynv%2B92AMniGHnD179i9yrJLH1WMJSEACEpCABCQgAQlIoEwJmDDAy6Ka%2F7%2FuKo%2FpT3GjSoYVKTAMg64HzyDD6pCM02BYeCDo8wdYCyLV40oO%2BHKDjpwAXbmAx%2Bv3JOZlbTx8aH3iod05WRkuj52iCPTw6VjSNw4P%2Fme6hdPjYZjHu%2B%2B%2By1e3H374IXX%2FOC5HtBYA5THHolsUboifIRmscUCxAYbcp%2BdkFXvphYa7eNTHcPq8GYVpuw%2Ft2bnrSFaajaVEQk4vAUtebsbwsfPufXhs06v71r2kc4NaXZrWmnlp3QN1LjpUseKWqKiNUVFxlSpmnn9harXzkipWzqxSPavyedkVq2VXqJpbvmpe%2BWoF51QrPKdaUfnq9grn2yrULChfp6Bqs31VGs%2BKOm%2FiRU33vPi2f31MqNhJjzjAKqzhvjMxEIt6JqdlTp02%2FaueX2%2FYtJ5%2BuM3nznI69yenvv7mG1c2b3pdy6aP3nvbjNFD9u2MOZx8OKu4iHoEDlOFkn%2F8ofgjuf0Hzby2zeJGDWPq1tx2YaWtVcptqRi1rWq5A7WqxdWrubfWhdsvvmBb7Yu316%2B3qdbFa847b3XVaqurVlldudKOi2rur11rR%2FWqO6pUjG3SeGybNjHffBNKSgxRmMFdjK6HxWBMTGGCn5SMrO5dvxjz3RhHDiVJiQDoQVOTlFVYmLNjxmyEIy0uGXVKKZ9hZmLwqvnT4DXqYjA7xUuK47Tlpvsz4oKLZu279Y69lWumla%2FhLl%2FTcW6d7EoNnTff7xs9MnbjvCGj%2Bw7u22dInwFDhg6bOXt21s49oZSc0Jot8c%2B%2FOKd2%2FQ3nVissVyOlap2tt91VPHlKKCM7xNAUmy9EhU7yCJ836MwN7IwJdf8y68obU6rW9lSpb6%2FeKLFSo51N2tkHjQ4VMCTGBGV8PIkHzJ2xOeH28zP8iTMRDWdniKlUylgI8jq3O2RzFGzdOuLZv%2F3rgmrPV6%2F0j0Y113z2TnDFotCGDf6Na%2BLXfb9p0RTbtk2hw4mhzOSQn0VI8wO5iTGjB372%2BJ%2Beu%2B26t5%2B6N27LklCAyp0FQW9BMAAqCYr5I0nLzRk4YmSzK1uXO7dSVFT5ClHl21517YwRY12UjQUy%2FJkxy5eE%2F9Cs%2BAJX61cru7A%2B%2F0b7tG4nn2MwFIqlUX%2BRahW0mZDzvffea9asWYUKFY4pi8GvtWrVovQHk2V%2B5tmdFoneJAEJSEACEpCABCQgAQmcVQJWQEEHii%2BCI10MHrAWJB0iIgu%2BY924cWNycjKnTbeL0hl0RrLzsvM8eXkh1jbIYypHsSfP5mWZUj%2Fd3MJQiF4fCyuG%2B7vECma%2BB11KVrx0uwLuAD1gE0MwAIOOD2Mt2D%2FxBXvmJ0e0cMk0uNEqWkILCU8Yr56RmeHxeVlWw4zL9%2Fu8xfZd6zd%2F1enj15554em%2FPf38q%2F%2FuM2bIrtSDLI4SKirMmTFv9P8%2B%2FFrVC16oWPmxqpWeOL9K%2F6YN4m%2B5peh%2F7k66oe3Ops2OXHNd4U23Zl7b9sjlVyU3uyrz8mtyW7TJu7xNYbM2RZdda2t6re3Sa%2ByNr3Y0utrW6Jr8Bm0yGrWNqXf1hOqNRjW9dte7n%2Fo3bwsVOlnl1QwCoAdLFhG%2B52blzZk%2Bq9eXPTZvXEew4%2FQ4893uvYmHH3roIVaPoGdbq2L5h%2B%2B6dcn82Q4H9UDNLI1CMgJrUkRqVt6kKaueemrPo39Jf%2BSBw%2Ff8MaZNiyVN62689vLD996W%2FvD9yX9%2BIOnBBw786X%2FXtms%2F7PwLukZFfRYVxU%2Fu8xpcknj7Hal335V65x0Jf%2FnziuefPzRhfCg5KWQrIL3geoYX6jA5BsRun3%2Fn9t1HDiWZrj7jLMz8EZrAgBaPk4oY4diJ%2Fr%2FplwfJBkwGYpaE4ZkfxmMEPSzGQvFMVzAroWDCyC0337b1%2FHoHK9ZMr3BRcuV6By9o5nny5eCShYlJ21ZsWb5yxYp1K9etW7dp645deckpTMEIpSS7h4%2FcfdefYi5unFGxTnLdyzPf7OjbsjGUVxDKd4QKvaFcb6go6He4YjavGvTaC%2BNvuXlx46Zba9RNrVL3cMX6m6LqbGjUNqNbH8%2FeuCCjYrxm7gh1SDkHC5LT4kPH4B%2BCGIZiELuF%2F%2FUF%2FYQelGqhSoar6HD8G%2F94rFK5qHPKRZ1ft%2BLXQz7Oyj0YCBSyEktaICfOmVrkLQhQPcTDARh%2BZEtL3del40uNL65Mida6dar1G%2Fx5Wn6CM1RkC%2FI5cLlMRmToEjIyvuzbr16Ty6LKnXtu%2BUrnRpW%2FvGGTbz7%2BjEEmIVt4PIZZVdjkGNZnm787PuE8th7w2IoWT%2Fu%2FMiefY1DekwVeP%2F%2F88x07dkT%2B9E7juIweYVAHkUiTJk3%2Be40SK9O49957V6xYwamdxv71FglIQAISkIAEJCABCUhAAscI0HWybuGulRmhQYhhZQiU91y9ejVzSfjJYgoTJkygOAYlQPv07TN7yZzDBUmFgUK%2BrHf77UwZYWCHwxewM9XDfHsdKmCVSyIL5izQS%2BabcFfA4fI6AkwtMaUXuHEIjstBeUBqQU%2BKXzk0DWDgh%2FUrP63unmkh4YUZNhDg63ef21WQnjmy76DGNWpViSp%2FzrnnRp1b7vo7b5m2akGBIz%2FkcGQtWv7N3Q%2F%2BMSqqfVRU66iollFRr13eZG%2Fn90PjxgXHjCnu09fZt793wCDb173yun6e%2F0l3W9ceru49vZ%2F19HXt6e%2FSM%2FBJz8DHXwc%2B%2FCrYuUeg85fu9z4reP39I6%2B8s%2FP1jls%2B7JIxc04wMYkaC36KS3AydJWdwaDTLOfhLnDErt04Y%2BKkg3t3U9nSGzCnvO%2Fw4Y8%2F%2FvCmG667%2BrLGLevXanJRja4fvn%2FoUJzN483zBgrD5UPMftjhtm0FUyb5p4wPTRoVGDU4u9cX8R%2B%2Fn%2FbV584Rw%2F3jxvvHTfRPnOocPeHIl9%2BOv%2FvBt%2Bte%2BnKN2m%2FXavBp0xarn%2F67a9DQ0KSpoQkT%2FRMmFsya6d6xPZSTZXIMD6MFzKgFCknQ0aY9LsbNsGgGq72YchI0mmTILPdBoMIjgg7T2zQ5BtEMYzIIjjyYm2cICLi6RWZH5AZmBE5GfNrEEXuf%2BNuRm%2B5IvvHmpOvax7f7Y9zt9%2Ft69qcOpz9Y7GAoCHMmOIgvVGB3m2kFAVeoMDu0LSb1gw8WXXnt6hr1trZplz9yWDD7ME315xblHTySdzA9eWfivh27%2B%2FT7umXN81gEp3vdi6c3brS0Zt351Wp9F1VtdL0rFj73auK8JcFcKqKYaIxRPHzYyDFoF0%2BYaUzmo0eEQUZjzs489FODlc8is0vc7tzsgb2%2FuOWWNte1bfmHe9qNnz06vyiTYRUUWiHMsQVZYsdHisMnkvqcDnfxgV1bX%2F7bYxdWiGI14loXVn3tzX9u3x%2FrCjmL%2FTZHkCVrOStDl55XMHTU2CbNWkaVq1i%2BfEVikiuat%2FhuwKCMhEQzDsTjZuAR2RyfautPAJDMzEwGHfGT2NBKM8yfx%2BneOLOTX6%2BEsRNU7iWCoEQGWaXVqpM%2FMn%2B2BIwjRoy4%2F%2F77GXHx39NJTDnbcuVatWo1YMAAgsqT37O2lIAEJCABCUhAAhKQgAQkcDwBei50fOg9sYGVGNDdoG9lBQjUx2CxEoILan7yvW2jRo34DvfCC6kOWPORxx5ZsXZZYXEuc1PMBARvgOKbXpvXSy%2BNrmN4kD3fktN99JmRGHzLTJhBEYkgVRo4GMfioEQWhBhW341n%2BJVD0zmy8g3SjIyMDMp0MHiDG6UI%2FWbfdAK9VPEoTs%2BaNmLsLde2bVKvYd1LLqnboun%2FPvP4jDVL811F9LyLYndOfPuDJ5o0v%2F2CmtE1L2jfsM47Tz%2B6deVid15mwFZAic4gNSSLixi5ESpgDECBGQnAPbcwlFMYyi40VTe5Z1j3vFBGWujw%2FlDS%2FlDWkVBuasieH3QUH9i%2BffWSFYUUruSsCCMKA%2BaBzZ93JCN%2B7%2F687EyCHAYIuAL%2B9NycvXt3z5s%2BpWeXj%2F%2F9t8evbnLJi88%2BPWnixLmLlixatzm5gMk3zImgZinzHYpDuZmhjKRQ0oFQ2uFQdlYoPdM0rMAWyiow7clzhbKdgfishO%2FXzuzef%2Fhbn37%2F9cAto6bkr40JHckM0anPKQhl54YYLMMJOmwhF0U5WSCDqhAmo6CBdp%2B%2F2Olm5QyTVrj8QatiA6MUwp19rhhPhz8NXDuTZbBd%2BAITEoQzAnsomBdgIIaZj%2BR3%2BfKOONYt9Xz3XfDr3sHe3%2FoH9fYOHeAeMTy4bk0oN51wh88C%2B%2BRo6Tm2Jas3Tvt%2BfnzWYTe1WIuz82dNWffi31fec1fMSy8UbVvtChbY3AWJ%2B%2FYvnDJ38aT54%2FuPGTN8dKcPO9WsXuWSclFvtW8x%2FfH7Ft3%2FP0NbXv7PqKgP6l468flXkhauMkupcIBwjmGijHAbPQGmeZhiLJwIJ%2BbymxyGD0%2BQz6DJ1ChS4Qk6HYf37106b%2FbUMaPnTpuWlphoFlQh2DHxTng8B91ua%2FyKK%2BjLKU7YtO3fDz%2FGaqbVo6LqVK3y7tuv79m%2Fm6EV5h7we%2F1mRAtHd3p86zfGPvHUc5dd3qpuvYaNLm366JNPbNiyyebik12UnH74SEoiH3zwuPFXlpiYuGzZsvnz5y9atIhMgL8%2B66Xj%2FcH%2B5POnlGOQM1Brt2rVqtHR0V999RU1TBj7RHrJX%2BIJDsQhcnNzDx06REnSd955p0WLFqzRfLwQo2HDht26deM0Oa8T7FMvSUACEpCABCQgAQlIQAISOEkBOhdWjGDlCdZja1wEQyP4jnj69OmMwWABhc6dO7NGKvU2iTLOP%2B%2B8h%2F%2F80KoVSz1Ou8ks6CUzraSYCormi%2FAfen%2BhAEuheujtmpH9fB3OxBNWRXWaARV8dW4GY3BQq500g1%2BtG09y4%2Bjp6emxsbHMPWElx1WrVsXFxRXbixn4wQwI0yu1O7PjjyydOe%2BLrt1fe7dTt8H95m1em1iUQ47Cq5RvKNi%2BZ%2B3QkcPe6dS%2F09tje3%2B1cd2KLBdLjASyvU4GFNAd584X6GZ5VMaEEK0cbUr4kdUfDucwZlgCRSMCeaFgfngiiD0QdKSnJ3%2FxefeHH3pk6fxlrny%2Blv%2Bhz0v%2Fz%2B33ut12j8NBIRAzq4bv%2BL0up91RkHtgW2yvTz%2B%2Bqknjh%2B%2B%2F%2F6%2BPPNKy9bV3PPCX2cvW5Dq8JqNhAgdTHkgeWGGkKCvkZGaFiRK4mUkrVr7Aahu57v0xBzYt3bR0xtJ136%2FOO5xdlJoXt3V3yoEEr535DSzM4TIrtzAZgqvDKAx68SwEa6aWsKaqGYXBtAa3k8oeXJ3wtaPYCavEknOYRMD83xzVCPAPIxi8TqYZGYXwk1y%2FAj4mJo9iN2Z1mNyUUPzB4O5dob3bQvGxoSO7Q0f2hTKPmKVS3E5G27BDuz%2B4dV%2FCa%2B9%2FcttfHho2e1JaUWqIcqzZScED24Lb14cOxgZcaQUhW7Itc9369SMGj%2Bj1ac%2Bub3fp33vAWx90rFK9So1KUZ2euyfh%2B9GOZVNnvfL84xee%2F%2FXdf0qa%2Bb0JdviMmc%2BT%2BRgStdBkH4maz%2BfwetzEC%2BZ8A04fw1EYzMMSIQyy8DhshY7iArMwrsvjyM7PSUx1ZheZ%2BiQA8AEmu%2BBqMuyEOw%2F4iJhAI%2BjIzOv23odN69avc%2F55LZo0njhubE5OtpvBFYQn%2FqDT7vLhyWUKhGxFrtiYnX37Duz4Xucve%2FVavHJFjq3QGfTuTNg7dvbEWQvn2J12PuogktRt376d8QzffvvtwIEDV6xYQXJoff7NVT%2Bt2%2FFyDHKG4036IM2oVKkS9TlvvfVWCnXyx75161YiRAIN8kNGiZAu8pNEkWo5KSkpzBEaNmzYv%2F71rzZt2jAM40cLYlgjMerWrfvKK6%2BwN873tM5Gb5KABCQgAQlIQAISkIAEJHCsAAECN57lJx0ouhs8sDpZ%2FKTbQobQt2%2FfpUuX8F1t7969X3rppeeff77zBx%2FMnTE9NzXFFE40Mw98IYfHfDNOzUq6fsVEGRTh5KfP1DY0XZgAwzGcfhdLkVJpkRiCo%2FCdb6QIBo%2B5RVpCX4wOlNXFY0R6r%2FCNoe%2FpGWlu0yt38x04a63yNb%2BroDj%2BUPyu%2BLi4vKwsn4sKlHRGTYeWshV2VyA9uyDuUNaB%2FblHEpzFBcxx8QUDLuZU%2BOm605E0Ex44Z074aDfY6gnTuvDd1I6gaojZwB6kXkOISTN0d10Or33L9thnnn%2B%2BVavWQwYMy2ekhOn8mzuW%2FEvftzjkN5UpmHrBdAOviTYKszO2rFnV4%2BOPrmp66V%2Fuu79D%2Bw58GV6vafNuvfsnZObQgeY7%2FaAZKuAMuYtCbpYcLXZ7WPbUYDG%2Fgc4yUYvP5U%2BOT5k5aeaQvkOGDxw2f9bc%2BAOHtsXEvPrqK%2B93fnf7rq0eZkWYeIEpDC4z8IDhB%2FTfjRedejOph04%2Fe%2BK8sTBthdGUlgiPmzBxDoFF0Glz%2BVmfFBW%2FL7c4f%2B%2Fhg1nF%2BezIfFAolmH3Ovx%2BClzazHqsxUFHlpknkpvhTthxeOfSuD0rd8Us3rN5RV7cwVCene05DuVME%2FNs%2FSdMebPbZyt2xOa6CgLESIGikD8%2F5MsmW3KbWiuOPL8zMy937964WdPm9%2Fy8z4TxU77t3%2BeC2jXOr1Xpk64vpR5aWxi%2FYewXnW67tPanTz%2BRtinGfN4oCspZYEMk4%2FE5nG7mseQXFsUnJaVlZ5Jm4G%2FuxHXmhAN5eTnr1q1ZvGRhakqyyWpcgWARFTPCAzBIikwMFV5%2BhpgGACIzEwv5Pf6ALeBftGXdB726%2F%2F3d1z78utu%2BgwdcRQ53EXNQjKnfwRZsGvZlf3ZvRlp2QmJSUmpadnEh2Zot6F25bf1n%2FXsMHD2syE7lVXPjT4ziEocPH6ZKTHx8PLNLGHTEnyEvIX16tx%2FNMQgxGEPVvHnz6tWr%2F%2BjACWIHbqQZ9erVu%2FLKK2%2B%2F%2FXZq8FJct2vXrmQs%2Ffr140%2Fwk08%2BIZT461%2F%2FevPNNzMG4%2BKLLz5BMMJRCDFefPHFDRs2%2FCKLup6eht4lAQlIQAISkIAEJCABCZyVAnSarPEPfDvMt67kCXSvrM4UHRC6V4yFSExMpH%2FEV7GkGdyIDRz0O63VH6hxyNfdZhVUb8jmMV3LItaboDxk%2BLt%2BXqWKgtfh9NkYyu9hqoO7wONnJgtjFtwMuuAQSUlJ9OPoxHHoyHFpDBP214ZvDMngtmvXrqycLJff7Q%2FQX7X7C%2BhB02GnGKSLLnEB0x2CXlMbki46382TTzjddGtND91MRgnPqzCPw312floPCE%2BoWmBmVYS%2F0TfjHsJ369ejT7IpvXqnM2gv9he76Mf73Wu3bHr0qadaXtl64KBhuUzlMD1pc6cfbIIcszxpMC8UoBgEAYHP5ynMz92wauXA3t983PGdpx5%2B%2BPMuXR%2B8%2F8Fy5Ss2at7qq%2F6DD2eZHIOv9sk7Aqaugyvkt%2Ft9DncA4oCT4RxMXDAdX1rqz8%2FN3xqzdd3adZs3b96zb098UsLsBXNv%2BMONdz5019zV83O8%2BU4TITH4gpMK17c03XXOxAyJMSFGuJ2mrTxtEg3GyoRjHHPiJiohw%2FEQAZmJJwGX3b55W8x7XT6auej7zKJ8FoNx2F3xiUmxB%2FduPLQ7Lj%2BZKMPnYyRFjic5bt2Sqb1GdO81%2FuseI3sMHjtw6%2FIVzH8h52DCBSDUfd2Znb2J3rrTXBU3xVSCxbwSCJGQFFBIk4VViGBoHovAxB1KWbBoVWzsztVr1j753BMPPnPf1OXjM2wHk9K3Dhn6eetWDTq%2B9kLirp3hERSMCmGURaC4yJ6VlVNQWGSzO3bv3Ttp6pTvlyw%2Bkp7GqAwUsDCjJUKhlLSUMePH9vym5y4GkIDA8QgxSI8KvPY8lszxGyPiCFY1Me0LksDYiCjM1QwdCtjWZR76%2FuCWmLT4Qj4OTkal8EEKZxfWsA2uIm%2FnQ8A%2B2XnYOBwfsSv3im3rvhrSe8SUccUMlTn%2BjU%2BvGe9yulHGj%2BYYRArPPvssgyjIIVnv2EotTvCTFILJJixoQunOK6644qqrriK4aNy48UUXXUTWcYI3Rl7iKJTdIMQgqzz%2BueoVCUhAAhKQgAQkIAEJSEACpyNAj4nggqHjjCTnxgx9ulF0prjRJ6Ibwlh3XmXX5AzEDqaTFQg47EUFeZluVzFdW4IF%2BsOmdgXdbsZm2FgPgq%2FtGadBvkG33uPx2ewe4oviQMDucua7nDb2S1LBVJGxY8eyXONbb7312WefTZkyJTExkQZY1T45OsflJ4emMYzQSElNSc%2FOsDltXrvNn19EFUlyDJfHQ185N%2BTLp6gmMyS8ZpwFnWefm5DERZtZeYVKHOG1Kjx%2Bl6MgJ9NVXEg3n2kHXjff2HNGfCtv1h%2BN3MMrkPBtvMk7wkt1MPXE6%2FfxZX%2BxL0ApC%2F%2BhpMQe3%2FT6x79fXrJ6TaHDafVlrS6s1Yt1m1ER9IkZ4WEihOL8vO1bNs%2BdPo1SDN%2FPnr11S8zXX%2FX84613PPXs35etWZ%2FvdNsCARvZjlUAxAzNcFMGhK43vWCn181oFhOvhMeQMCWHcRpeH9DkL75CV%2FGareuefvm5Vz9%2Bc83eDVmBgiIz14L8IzLEgv67SSjMHsLda5O3eLzFhUXOYls4x7CqmbAJjTVjUY6uw%2BINFOTlT54%2B9dr2bTt98tHu%2BLh8uy0uIXHOwgVDJoweMGnkgthVqe4cR5CimIX2jMSNm5aMmz9mwtKJ45ZMnrV0TsKWbcGE%2FFAhq3NQGTSUHV7FhuVsqDIaZHQJeVSQwS2M42E%2BEg0OXwLTAFOPI9%2FmTaSqSF5xXlbeqlUrFqyaf6QgwRcqLMhPmDZ12H0P3DZqzJDc7AzmGAU8fh8jbJzuObPm8BGaOnVaVlZ2XHz8wsWL12xYn5aVyWIm5BgmyjCBSjAtI23MuLHdPu%2B2bed2rhqfLSIHmpSWnh2XlJKeXwg0bSD1cFK01hQLNaVfyM4cwQCjfRhjkxt0mWE2Jv%2Fh8gQ9Do%2BXk4gs%2BGp9FPgQ8GkgjWAMCDczosa998CeOQvmrlm%2Fxs0Un%2BPf%2BPBTfYI%2FyeNvcqJX%2FjvHYDrYM888Q6le%2Fpq2bdv29ttvszrqCYZSRLKI03tAwY2WLVtSN4N1jvjrO1Fb9ZoEJCABCUhAAhKQgAQkIIHTFSDKoI%2FDgqoUxCBeoA%2FFM%2BQVdKl4THZB54gkgd3zk%2BcLCguWr1k%2Bdvq4nUl7coN2W8jH%2FAGHGYFB95kqGdSLYHIEtRqYBWIrLMpyeYtc%2FiKXixEKdoo0eAgiPJ6cnJyRI0e2bt2ab3ipE1i5cuUOHTpQUJSBGVauQn0MtqErxBEpKjhnzuxBgwcN%2Bm7w%2FEXzM44cYZkUsyJouPJGUcBjTeKg%2F08nlF4%2BYz34It5BAU6PKzMnp7iomJVaM9OT58%2BdOWRQ34kTRu3cGZNfkGVzFnqDLqffzjISZC50R%2FlmnvkYzh%2FujnCVSzO2gdiAZIZKIOD4yEg8e%2BIOrNyyKcNu4yh85Wz1iOl8mo4r%2F0eLX8yIDrq7zGnwsEYIi1%2F43G6vi1zFt2f33ukzZi1bsSq%2F2MZytIUuF0VS2Umhx51tL7KZ0pS8md4yC3Oy%2BAa9YgbJYMG2BEleNyUtAhAwhsGdVpwxY8WchTFLU1xZhZTVMCNj6D9GQotjPxbsiM7%2Bju3bDx08yOwacxCzngntJMTgMSkIkZQZm5GdlTNg8OCmV7R84vlnN27fVuCw79q%2Fb86iBSOmjR86bfS8TcviClNyAkWMUbEYbf7iIr8j21%2FEzBGzHmke03FM2kJ8kclYCJZkIYoh5nIx8YeIiEvkdAdNiGE%2BNua44YEkHvN6YTDk4qRJdlxej89Z7Mg9uDd20czxX3R595HH7x80atCeuH2MvjAiLndCfMITTzxRqVJF1veMjYl1ulxMHSJ4orJnOJagMiwfO8Zt%2BPMK8petXDF6zJj9h%2BKI2Kh4YpYJDnjjcjN3pScn2RgZYsaEMI%2BHtO7o1cTL5aZiC08wdoldEvzxoSTb4jCFFG0ptpMzmfEuhCDA8zbrbk6HoUGmZgfna8vKTok7lJmSSohz7CX54Xf%2B7phpwtpA%2FOn98Nyp%2FVsyx7Amdzz33HMrVqzgj4gd8ddEeEjFm5tuuomJISeYY3IaIQYJBvvkr5hJKAcOHLCOeGqt19YSkIAEJCABCUhAAhKQgAR%2BSoB%2BkxVQ0P1hwANjIawQg5%2FWA16lS0WUQQ%2FICjcIOnbt2fVaxzdu%2FtPtI%2BZNPOxibQx%2FdsiZE7LTpzZD%2BPm62V4cYrEMvzs%2FP2Puwplbd29xeItCQSseoGICX5IHUtPSBg8efMMNNzBzn%2FHqDGK%2F7bbbJk6cSFEOWk2IQX2MxMREDs2AkD59%2BrRv3%2F7iOrVrXVIv%2BpabJowe7cjKMV1vUxAjkOdxFATcDh8VPG2uQntOelZ2Tm6Rx5Xtcx9MT926Y2d8XHxOVvaUSRNbXdGi5oU16tau9fZbb%2BzZu5sFSOlOM1clXHEjXMsivICmtYwmX7tzZ0SEOZAJabibahKU8Cy225Iy0g6kHWG4QWRdC9NRtBIMOrNEEibd4C3mfMNphhmFQG7ASp50hemAO5z0rcklKFnqKKCOot1%2BJDNj2cb1M5csPpiaanGHYxMSBoYShAdL0I8O%2Bj1eSkFQCIJKHQx88RG8ZBTnZrvogxMo8SsjY0z%2FnbaYUMW6Wb%2BEf%2Bda79ixffyECUuXLqUB7JlcxgybYN9U4nCwP07T5Bjp6Zk9e%2FdtcnmLh598cu3mzaZmpgfg4nxHQUZxdpYzPz%2FozAs6yCqY2hOuMUpP3qxAynomLIZi8gsGMTDHKJwYUNwz20P6wu%2BUT6GaipOPCkcimTHTf9jeSVUT8gszRoIBQDTKhByG3LvvwO63X%2FxHy4tr1q9Rvfp5lWs3qtvp0492Hjxg93qKbMXTp0%2Fr0CGarvd1bdpQppIClZw08YOpf8KnlhNkSV%2FETSjnSk5J2btvX04BE3BMA5kUlB8KZgdcWX5nQRA9RlKYsStkINzDSRQpBM0j4qIxJpQIMtSHAUWMnzGVN0gvTD0Vdu9hSgxjZzw%2Br93tZ9IT4zTIYggxTALmD9ndgQJbwEal1P%2B%2FLD9cnqP%2F0vcnu9u5c%2BfPzzFIFSh28cILLzC5o%2BTe%2BAwy3oOlRnjJqpjBlqeRWpR8C6M7qLzB3JN%2F%2FOMfLLzyc8aTHAOiXyUgAQlIQAISkIAEJCABCRwjYI2v4Cc3Ojj8pNPHNtavPOBXHjO7hJv1K19%2F79y96x8vvXh1h%2BsHThkZ78jNCvkzQ%2B7skJvlSJgNYUYv0MNzMVmiYP3mNQ8%2F%2FfBr776%2BdstaF%2F1dhyPkoCdL%2F93jcDoPHTpEcMG8EuoHfvTRR8wroSYGB6I3R3xBrkJmQp%2BUihx%2F%2B9vfWCfFdJ3KR1WtUf21l17au3EL81ZonTPAvBIPY0JMSYzc%2FJ1rN82bPnP%2Fvv3OYDDVbVuxdcuY8RPGjRgzbcKUjm91uvD8i8qfc27VStVefunVvbv3s4gFfUoXZSl4EP4WnSyi5N36dt1UDTVLe1APgdQk4PN6DhyKm7Vg3ryVS7PcR8dj8C4DR7JCJ5weMv1wZlPY6LCH62%2FQKSbBCIdBbnrXtJuN6S%2F7PAW52fv37l65fCkDTvr073fPgw9ef8sto6ZMc1ijKtgbd4pP0hSOEf6in7EC1kwJv4fdmn2ZWqTELXSXGUFjxqSYba0owxyHG4374Y7qylUrv%2B7Va%2BKUyTYHwQFlQJl3Q47B20yfPcBQBjOaIZCVVzBi7IRrb2zXqfOH%2B%2BMOmZExZj4ODWL3oJgaHIywoXwEd9IZM5CDD5IJl1jDlFkj5BIB%2BvK0iZIQOaFgMT1%2BuvbkFeRCTMcxZT%2BId%2FjAhHv6PE%2BRWNIB2DhXTpY4yBu0OR0xO2JfeerpyypUqVuuwgWVq5SvVOH2P93bd%2BTwzTt3JKemjB8%2F9sYbb%2BDTcc3VV48cMSInO9tM5SB%2FC89RMkEGN9YxcbkZiHIkOaWwqNjFiB0%2F5VAJr8wMIFPGJZxZhauIoMvpc8Hc3E0GxZsN8dGJR%2FypsOZIRkFOrp0ioXziKZXB0CVyEi9TXw7s3bth1ZqYdRsO7dxdmJJuqriYiIbcJnwnCgtfe%2BuyHPOTZpIysTIIf3THvHSSv%2FL2zp0716hRg2CQkRgUlrH%2Bco95u0kjd%2B0aMWLEI488wnrKjIkiizjV4Rlsz7t4L2U0Hn300eHDh%2B%2FZs4c9H3Ms%2FSoBCUhAAhKQgAQkIAEJSOAXFAh38MwP9knXie9t%2BUlvlRtP8piet%2FWz5IOMzMxRo0d%2F8OlHy2PWpfnsWaEAUUYWowI8hdl5WdSgYF%2BkAztiNj%2F9z2cvaFirYcvLHnj8kZEjhtvTs0N2D4UZrcyEw%2FHtM0U%2B6VLt37%2BfcRc8z4Gso9Mk2kCfjkKj9913nwkxKpxTrlrFc6tVevrJJzYvXxlgcYeQnxyDEQisG0oH2pGevWj8lAFf9YrZvIXYJSfo25uWPGXqtBeefPam6zvcevMdD933yF%2F%2F8tS%2FX3x9zqyFOVmFThu91fDwBTOy4%2Fh3kgG3KVbKCitm9ILfu37z%2Bm8H9h09dXyOs4heMF1g%2Brrmulg5BkM0KG3Jl%2F2kGUxYMaua0qvm63oPU0SKPB4bOQ4FPNzOrVs2dHzj5Yfvv%2Fee2275403RV1%2Fd%2BrwLa1a5qNZr7314YF%2BC6cXTnyfE4AE%2FOSV61Tygza6Aj5SARMHKN8gVTLQQnuJCa36IPY52hmkaj7iH20jHds36dX0G9J86a2aRg6DBDMQw7%2BHOG12u%2BLhD23bs3LZ7b%2ByuPTPmfv9mp%2FcXLFpiszvpv5syI%2BQYVPJgaVfGcnC9wut7cKLkJ6aFRBsU2ODX8AfIpBn04hmnEKAeaCCzqCBu9z5Hana4EiynQyjkJdUi2jGfGYav2PjkQBwkNzITPNibh7VCfKn5OctmzOn1wpv%2FvOvB%2B2%2B9s1atiy%2BsV6dVh7Z3PPinf%2F77X717f%2FOHm2%2FiA9Khfft5c%2BfmZueEm0qZVMatmOiJjzKnlp6avnTJskWLlhTkFxL%2BkCsxisJkFKRMDL4IfxQILDgrm5cBSB6m25gowxTWCE%2FxoSgGE2gYxON2H0qMX7F25bY9Ox0%2BBltwTdiRPzUzbfS4MU88%2Bfj%2F3nv3vXff9ciDD3zx8Se7N2z25BWZgRnkNqYQbvjymM%2FKj9wws%2F40fuS1k3uKi%2Fv6668zv4OAYs2aNT8aYlh74u%2BagRNUsWBYFGMz2rZtS%2FRxvEVUzV%2Fff97Yksob7dq1%2B%2Bc%2F%2F%2Fndd99RdZa%2FZdp%2Fcs3UVhKQgAQkIAEJSEACEpCABH6WQLjHGaBExuHDhxkRceTIEUZBWE8yLsJKFawe1tEinG5PakJS3Nbdhbn5joC%2FMBRk0kiqqyh2766Na9YWpWZQ6KA4JXNwvwG1m1wSVa1SVPXK5c%2Bvdvfd965fsMKXR80JE4%2BYrISv98O1AqydW8%2FQ3%2BdJ6%2Bh0teiIUZyQ6gfnVjw3ivHvlSucW6PqKy%2B9tGfTFr%2BDHCNAr9Me9LjMl%2Bp%2BR3LGvO9G9%2F7s8y0bNzrMop5Bin%2Fu3rWn8%2Bsd%2F9DupmefeW7K1OnLlq%2BMjd2em5tPVMPqG05WBaEHTvcrcre6%2FCV%2B8gpfuNvcTr5zp0tsd9hWrFzWq3fPCVPGFzpZncXkGPwkyjDdON5oOuDhzjyZg6njQY5Bf91rKooyJsHvtfH1fdCXnZMxctiAZg1rV%2BC0oqLOjYoqx1Ks5c%2BtWrPW48%2B%2FuHbNpiD9eu5EShzY4XLbnWY%2Bg8vkCQQhHmY6mKVESTBYGNYbKKanHD6o6bgfTTh48eh5RU6HzX2%2BPfv3zV0wf92WjTYPhmYT2m7iDL8%2FMyNj1uzZQ0eOHDp23MDhI%2FsOHjZt1ryU1Awzd4KOPyEVCQZ5DrOI6JITN4SXG%2BU7eFNOghzDY4Yt8CufCoIL%2Bvim6oXDZGT5Htu2fbumTpu6O2arJ5eZR%2F6AiyqxLOHKlQpQepURIGbFXkpUUGSTPYerc9pcntSAK8FTnJGSnrx22%2BoJM7%2Ft%2BkWzZs1Nr7riOdxr1avz0UedX375pTvuuO3djh337d3jsNmIw4wCkYoZnhJkzZyAz39g74Hhw4YP%2F25ERhrlOrhG4UogfNjMYAqPiSLMu8gxfEU%2BJ%2FOHeEzA4g4yt8gM5%2BBkqfhJxuK0O9esXDl4QP%2FFi773uFnjhOE8ZsZRSmbKiLHDH3zkgehbom9of8Mtt97ywQfv7dga6yqmFCplb83QFJNrsavj3CIf%2B%2BO8%2FtNP8%2BfTo0ePp59%2Bet68eYzN%2BOk3cKUKC2NjY8ePH9%2BtWzeGcNx9993M9mratClhCKuWkFcw8YSfPOaZSy%2B9lFfZhvWXP%2F%2F8c961detW1jk6mQNpGwlIQAISkIAEJCABCUhAAj9fINzxNh1buseJiYmU2Zw2bRo%2FiQ6INejF0RWyUgW2tDajP06XMLy%2BKv3TcD0MBsOHQvGpqdOmzhg%2FeHjGnniez9x%2FuPsn3apffHFUtcpRVatElT%2F3ytbXjxk42p5RxHftTE4x%2BwkviWLNH7F%2BtaIMnudmPeZ5CpAOGDDgrrvuuvzKK5pdc9Uf%2F%2Feu0SNH5B9JofQEY%2FTNvBJWEiFLoLeakrF8zKSBX3wVu2mTM%2BRnERPKM1AjcufazbOmTF25ZkV2Ya6Hb%2BnJUZg7wQOvm8VmzdwBvp43HXn6vOFv3un88iW8dacLHvJTR7SYeh8czucqLMpft2bVoH69J40fY7MV8DbGR9DpDVfVCA%2BdCM8FMXNSGAlg6iqYPjXdZWph0NEPj6ogZ%2FBn5qTOnjb%2BqT%2F%2Fqd0Vza9oULdxrYsuOu%2F8clHnVKhy%2FnP%2FenVz7DZr8AMd6%2Fy8rOSUxOy8NKfXxgq21J5gvVj6zuHExMwEKUjPzE1MdWcWmnkcptdtcgzuR3MM%2FiFk4B7OLBhykFeYn5iSlJaT4WL5lfDT9PV5G4MQklOSFyxdPGnWzFHTpr7z8adPPPv3BYuXFxYUm3U7XO6grTjExAFqaNg9ZpkRYgcGIoSniaDAuXGSrqBZBpdqGIkEAhyVcQgFvlCRPS09Zc2WdfOWLty%2Ba4cjn4qkHI0NQhmh4OFQMI61T9kXyQ9rsYYXMDFGbn9cZka%2FpbM7jhvU57uh62YsiFsbO33MxL899UzrG29ocs2Vl19%2F7X0P3T9y5PAlSxYtXLggJmazg9os4U9PuOJH%2BNyoc4GW13%2F4UOKsaTNnTZ%2BVQ3EVKLi%2BxER2F4sI%2B0lmOA0CCapckIxRk8NMLiGSoaqLGRRjEipOEEOiqWLHjHET33%2F1tWljRvvcLJrDsBInaYjLb0%2FKSFy6ftm4GRNGTh47cfa0Lbu2ObwkF5Rt5fNGYEZQZK7JCW78oXE7wQYnfom%2F1i3hG38%2BJ96y5KscETNCS6qALl%2B%2BfNSoUV988QUrmzBO48knn3z88cefeuopal%2FwDM%2BPHj2ayipMCuM%2FGrzr57S2ZBv0WAISkIAEJCABCUhAAhKQwE8K0AGxMgrCClKLffv2jRkzZtgwvrEevmLFCmZ5RDop4X6hyRV4CzcqGfJlOmGFGQAQ7jXTOUs4fGTa5GmTho%2FJjktmaoAv1zZv1rzrO3SoUa%2FuhQ0bNry85RNPPbdu2TpPId19eoWmp0aCQe2LmTNnfv%2F99yxwQLfIep7DcWMDmseTRBkZ6RlLlizpO7B%2Fv%2B%2BGLl69Ijsr03yZTseQkQiMUWAZTHqY9FYzsjfNWzhhyHe7t28nRjBf7LMRXVKWV6Uggpdii7SUr8NJMZjl4fR4XUyMcDntbqedDqxJM47OLwhvSFzDKdO%2F83uKTAXIIJM5bC67y%2BU4sG%2FPpPFjZ0%2Bb7LTTETdvgyFcacHEFJGwgl4vxzfdas6ESTr%2FH2KYYQssRFKYm7F59fKRA%2Fp8%2FPYbr73wj0cfeqh5s8svbX5Fj2%2F6pKSlh2dWMJDDSYix7%2BDu9OxkE18cXRWVeiBuh7PI6Sj2Oh3ZKalH9h%2FKP5IWKKbcpul0W9NN6IcfHY%2FByA3TKTcdchrjYuFbsySKKbMRrv4Qns5AI80qpi44Ct2uPQkJH3Tpen37DsO%2BG5WTnWvea4IdAoHwui1Ue7BT8IEaF0cPhwJnRZkMBjAQHzHbKCUUsHGx7R5%2FesH%2BDZunTJ44c97shNSkAnuxiU%2BIFwgtQsEMRjKE%2FPEhdx55GFeSnCY8d8bkXU7Pii2bW91z6zn1a1SreeGDt98z8psBwwcMWbhg0cTp07r3%2Bebr%2Fn0WLlmUmZnucjNUxelhlRzTDs7UDC85eufEwxmOrbA48VBCQlyCkxwmHFLZM3O3rl67eN68LRs25KalBRxO6qfy4SUEC884Mn7hyT2mqSb6MJ%2FKEPsZ0rvvkw8%2BOLT3tz4GqFjFSgyNx%2Bl3Fnttufb8tPzM%2FUmHEtOTnQEWlzGVbWmCh0vOSJzjxxTmjyv8YTGHOd0bfy%2BnFGKUPA6fAN5OoMF%2FEPjzZKgGQ7NYQoUbj63IkVcZ9WH9hZZ8rx5LQAISkIAEJCABCUhAAhI4AwKR3ID%2BC90TsguWXmXlU%2FosdGciHS4eWDerSaZTRw%2BPrjLD7PmanoINPhZByCOLOLTvgI3iA3SRvf7M9MxJU6b88%2BWXn33hhS969ly9Zm1hXhHbmx6x6U8HKYvxzDPPtGrV6rrrrmOMOuMurM7RD0djeIApK8GT3KgfmJGVdSQ1pYheMN8Cm6UxzaIaRBnhaMKEMnmp6cvmL1i5dGkGuzLdS6Z3mHqPLFZh%2BuhkFNxM59b8ZN9OJ9VGHQf279u8aVNiQryX8hVsZuZ8mHIN%2FDRzDUxaYmo5Wl1%2Burdut4sg5VBcXFJSIvNSrB1yOAIB%2Bqol7%2BZEOVT4Hj606QeHu9ThnjaOfq%2FbUZyTkZ52JOlIYuKGdev69u37dc9eMVu30xk1%2FXFaS8UGt4t2Uuvhh5452QzDVFI3bFi%2FcuWKHdu2ZqSk2lmE1BleaiScV3AgFtagwAPnwW5Y4YQ7e8nMyOSb9JjYmEPx8RT5pJwnIzHo9nLetNDAhi8P%2Fe5NsbHPPP%2F8Zc2bf9atWzKjX2iNkTZ3tgvnAOGPgUmlzHnxkxkutIx8go8G3XtyJBaCcRUUJu7Y886rr1111ZV%2FffzR%2BQu%2Fz8zOMkN6eBcTNajOauKOYK7fVcz0IIpl2hyMnQjvkbqwzgVLl7aOblv5vCqVq1bpcGO7zh3f7dH9C%2Bq4skxJcnrawYT4hMQEcMJtN6344U6eAW%2F4w%2FbDczzDEBw%2BUeYDHH7D0gUL7%2FzjrVddccXNN900ZODA7LR0LDh2%2BPKHT%2ByHCxr%2ByIb3TY5RVDxkwMCnHntscP%2F%2BfIbMNQoPteB4rOeSmZN1MD4uZlvs1BnTlixfymI0pmSombxkriXCZlfHv5nPiaHUTQISkIAEJCABCUhAAhKQgAR%2BYQF6W6b3Fu5z8ZOBE4VFhXyNawIQemsBU3Bj9%2B7dkyZPnjBhAqsxklpQCdDqMrMNc%2BqnT5%2FeuHFjq3zgvffeu3btWsIKs1t6oOFpLPzKTngLz9B6vssmZrFm%2FdPvNj3mHzbmVWp3MIzkxXDVQSIR62zZgO2TkpJYyzIhIcEaCW%2FtkJccTgczaJ55%2BmmClLfefJPHNMw6Om%2FngQk%2F%2FqvjybMci4axN6v7bx3rNH7ShvC7jvZbiZISExMZGEOOdIK90STG%2F%2Ffs2fOWW265%2Buqr77zzzv79%2B3OO7I0es3WOvJ228dgKgjivosIiCj9SBPLWW2%2FlfB%2B4%2FwFWsz14MA6f%2Fz5HiFavXv3ggw9SEqFTp47QscP%2F3uyYRtIAbtaT5hKGb4QwtI3W%2FvnPf3733Xcpp2BdZauFHMhsZS6umzsNZrURMyYivB%2Be4cJ9%2BumnTz%2FzzFNP%2Fe2NN97o3r17jy97kF7wdj4Mq1avWrBgQUZGunUh%2BGldQV4NH%2FxoY6wmHfMz6XBS584fRKpXPvnEkxs3bLA%2BXdYn5JjtI7%2ByzYb1G7799ttZs2aZVOSHG4fmTOfPn8%2FMC0pf0tTJkydzRiY0CZ8OgOw5sh89kIAEJCABCUhAAhKQgAQkIIHfUICeGn20SL%2BeX%2BniMRydSCE9PT0jI4PBHnRg6cpxs7rYrFQyduxYal9wmzNnDvVFeQunEOn08Ss3tucZbuzfijWsbXgmsjEP6EXS3Z47d%2B727dvZjGesG8dibHxqaiopCtvwZLgJJhjhJZ6ns9mvb1%2FemJKSwjNH3xb%2BJ3zYH%2BkLW6dgNaDk9j%2FzsXWO1imfYFdsBubKlSuHDh3ar18%2FusxEQHl5ebyFhqFkNYyfnA43HnCj9x0fH0%2Fxk4EDBzLqY8SIEeyBsTc%2F2rNmey4Zc3m4QMz9sQo58uQJWnW8l6xD08EnJjp48AARUGQ%2FPKDB1huti0Jj2CDSJJ7kUpLt7Nm9m0CDZT25EY5Z7WGaA2fEp4hfrX3y0zrf4zWm5POs1kFWM2TIED5%2BgwYNImrjw2AdmuNGGlnyLdZjXqJVhw8f5oMdaT8vcWg%2BaYxK2rFjB62NiYmheSU34I0lf%2F3vPesZCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAr%2BggM%2FnO3jw4CHdJPCbCvAhzM7O%2FgU%2F2NqVBCQgAQlIQAISkIAEJCABCZx9AnFxcX369Hn%2F%2Ffc%2F1E0Cv6kAH8LPP%2F987ty5Z99fmc5IAhKQgAQkIAEJSEACEpCABH4RAbfbPWrUqNmzZ3t1k8DvQIBUrXv37hkZGb%2FIx1s7kYAEJCABCUhAAhKQgAQkIIGzTCA3N%2Fftt98uKio6y85Lp1N6BRgdtGLFitLbfrVcAhKQgAQkIAEJSEACEpCABH49gby8vHfffZc049c7hPYsgVMS6N279%2BrVq0%2FpLdpYAhKQgAQkIAEJSEACEpCABMqIgHKMMnKhS9FpKscoRRdLTZWABCQgAQlIQAISkIAEJHCGBZRjnGFwHe4nBZRj%2FCSRNpCABCQgAQlIQAISkIAEJFBmBZRjlNlL%2F7s9ceUYv9tLo4ZJQAISkIAEJCABCUhAAhL4zQWUY%2Fzml0ANOEZAOcYxIPpVAhKQgAQkIAEJSEACEpCABCICyjEiFHrwOxFQjvE7uRBqhgQkIAEJSEACEpCABCQggd%2BhgHKM3%2BFFKeNNUo5Rxj8AOn0JSEACEpCABCQgAQlIQAInEFCOcQIcvfSbCCjH%2BE3YdVAJSEACEpCABCQgAQlIQAKlQkA5Rqm4TGWqkcoxytTl1slKQAISkIAEJCABCUhAAhI4JQHlGKfEpY3PgIByjDOArENIQAISkIAEJCABCUhAAhIopQLKMUrphTuLm60c4yy%2BuDo1CUhAAhKQgAQkIAEJSEACP1NAOcbPBNTbf3EB5Ri%2FOKl2KAEJSEACEpCABCQgAQlI4KwRUI5x1lzKs%2BZElGOcNZdSJyIBCUhAAhKQgAQkIAEJSOAXF1CO8YuTaoc%2FU0A5xs8E1NslIAEJSEACEpCABCQgAQmcxQKnlGP4fD673e5wOILB4DEmbrebl1wu1zHP%2F%2BSvfr%2BfHTqdzmP2yTMFBQU8ae3Z6%2FX%2B5K5OaYNAIGAdlwen9MZT3Tg3N%2FfIkSOZmZmcyKm%2Bt%2BT2VoNp86%2Fd4JIH%2FU0eK8f4Tdh1UAlIQAISkIAEJCABCUhAAqVC4JRyjP3793%2F22WdffPHFwYMHS54dmcOwYcO6du06ZcqUks%2BfzONt27Z169Zt4MCBhYWF1vZZWVljxoz5%2FPPPu3fvnpKSMmHChC5duixcuPBk9na8bUhgioqKSiYw8fHxHPebb74hYTjeu37O8yQwO3bs6Nu3Lywffvjhp59%2B%2BuWXX06aNCk1NfX0dgsFIOyEB6e3h9LyLuUYpeVKqZ0SkIAEJCABCUhAAhKQgATOvMAp5RjZ2dlECq%2B99hr98ZJNJd9455133njjjfXr15d8%2FmQeb9iw4a233iIbyc%2FPZ3uihj59%2BrArnnz%2F%2FfcPHz48ZMiQ119%2FfcaMGSezt%2BNtQ2pBCNC%2Ff3%2FSDGubffv2cYiPP%2F44LS3teO867ec9Hs%2FUqVM7depEy99%2B%2B%2B333nvv3Xff5aSg44inF2UkJia%2B%2BeabOPPgtBtWKt6oHKNUXCY1UgISkIAEJCABCUhAAhKQwG8icEo5Bi0cPXo0ffOvvvqquLg40uC5c%2BfyJEMOImMqIi%2F95AOCi%2BTk5PT0dCaYsDGpAl11%2Bv7sMycnh2eseRnMMfnJXZ1ggwMHDrzyyisffPBBpIXM8uC4HO4Xn7FCM1auXAkIOcm3335LtkNwkZSUtGjRIkaA9OvXj%2BErJ2jq8V5icgosZCNkO8fb5ux4XjnG2XEddRYSkIAEJCABCUhAAhKQgAR%2BDYFTzTF2795Nb5rbnj17rPYQCDAHhG77tGnTrBoXjEbYtGkT80EYSjFr1qzIxA2iD%2Fry33%2F%2FPaEEQzgY1EHfnARj%2Fvz5dPx5F%2F193kKOQW998uTJCxYsmDdvHtuzQeRwHJQdLl%2B%2BnETlu%2B%2B%2BYwPmWVjHZazF5s2bx48fz06YmcK0DisbIa%2BYPn26NcBj2bJlMTExlJggHmG3S5YsiQQybLx3714GfgwdOnTcuHFbtmyJBA5MS6GFRCs0mKEd1qmxZeTUSl6ajIwMBl0wdmLQoEGRnVsbcFArnLF%2BpZwIR8GBIzIlhyEiHCiyK04KJRx4dc6cORs3brRkIjkGG3OOvJHzZfgHbYu8t1Q%2FUI5Rqi%2BfGi8BCUhAAhKQgAQkIAEJSOBXFTjVHINinlZqESmFQbJB7NCxY0dCAJpKRkGxC0IDOvLcmExBp95KIcgTGBHBbBHCDeZZvPrqq%2FTiSR7IQCi7YbPZ%2BPXll19mb9wie%2BABO6E7bzmwzSeffMIzvMv6ya62bt1KNDFgwAD2yZMc1xoOQfLAuzhcZLe8yvZkL4QGbEN7rHITBA4EIwQFkd2yEzrU1jgQAg1mvvBMr169aD%2FTQ9iMXxlfYU2HKXmNKOXBS%2Bz50KFDJZ8%2F5jFxB6M1OLvIEXlMphEpB0qAQ3tosHXDBGR%2BWjkGXKNGjYoosRPOi2IjxxylNP6qHKM0XjW1WQISkIAEJCABCUhAAhKQwJkRONUcg1YxtoFeM114a3USEgb69T169OBXhjTQE%2BdX5pgQOOzcuZNCl3TD%2Bcn0DYZbfPTRRyak6NSJNIBqFYxtIMegM042QmLAWAUGQjDYgxCAEQirVq1i%2BAQvEQsw3oBDswdCDCtAYCTG6tWrqS9KBmJlEXT8yRmWLl3KaBCGKLAfjsI%2BiSyIOPiVOhXsn1iDkQwMdeC4tId9sueZM2dyUjRs5MiR7JbD0QZazlgIggVuTKVhDxya9jDyhOOSKvArp3nMlRo7diwCX3%2F9dWQ4xzEb8CtWNIkjchSOxZkOHz6co7PD2bNnswFFMGgtLezZs%2BfixYtJjfi15HgMBqvwdiqI8l6iJNpptS1SAOS%2FD1panlGOUVqulNopAQlIQAISkIAEJCABCUjgzAucRo5BAkA%2BQC9%2B%2B%2Fbt9MdZQYMONbEDjWepEZbnoENNGkC2wAAMetlWnsCvjEAgN6BvzjaRORRWnU%2FCAVrCHhi5wdsJKyLzLwYPHsz%2BrRyDpIKIgM57ZG4FwzA4qOVGOsFwEdIDpnuwW1pIEEFlDF6lp89OOHpkogcNsHIMJrbQ97fiESaMRFY1ZSIJLWcbxpmQz5Bj8JjNrDKbpC7kJ%2ByfPME6euSnlWOQqJyg8gb7ZOfkEmvXrrXeyHHJWKxzh4LsggaXLEPKNBYaQNbBubMxZUvZgKEvTCfBliocjMdgn9b5RhpTGh8oxyiNV01tloAEJCABCUhAAhKQgAQkcGYETiPHoFNPJ508gUECcXFxVufa6t3zqzVmgO42D7gRJtC5ZmMmWVg5Bn1%2Fa8iBdYLH5BgM4bD68mzMBkQTVJmI5BgUxGBXVMD4URzawFQLRjhYEQFtoG0kGGxM5GLlGFZawjORHIPQg2YTAjDggfEhkT2TeJB7cLg1a9aQSJBj8Jigw9qAERpU7GSf1tSVyLt4QODAOTIi5UerZ1hbsk%2FeS0xB8BJ5L0Q0g2YT5kycOJHDMaoEbWsD4gvOi1cJLjgLEhV4I8485lXoKKMR2WEpfaAco5ReODVbAhKQgAQkIAEJSEACEpDAGRA4jRyDVq1YsYJeNl17xl3Qd2aGCP16nj948CDRAdkFQwsYUUBVT27U0uTGAAYWByEZ4NXY2NjIqZ1SjmGNzaCQRWTURGQ%2FdPOtmSDMcGHmi1WCg7ZZpTmsHIOBHJFyFpEcg%2BEcPKZV5BgEGpEdsrKJlWOQDBCncLIlR1%2BcIMcgOSFVwIE6opG9RR5YwQV1SgEki6DMReQlhlKQS9AMGMlq2IDZIpEcIyEhwcoxGOXCYBUyEA5ByMM8mpLOv8YyspEWnpkHyjHOjLOOIgEJSEACEpCABCQgAQlIoDQKnF6OQWeZTMDq%2B9ObJqawzj07O7tLly6kB1TJiGgwsIF1OviV6Q8kAww5sCqCWhucUo5BTQyGMXTu3Dkye4JAg9yAuST05TluZBQEwQW9ftpm5Ri7du3ijTQ48sZIjsHADxCIBXg76QGRhdUwToo9kEgwTCKSY3AU69UT5Bi81KdPH%2FbGmbITK%2BFh8REcaD%2BjOJjGYh2dnVMAxNohQz6YkMK7mK7ClhQLpcFwRXIJinJwOgQdLOFKe6g7Sq5C0ME6L9YeWME2MhnHeqaU%2FlSOUUovnJotAQlIQAISkIAEJCABCUjgDAicXo7BIAEKXdKtJhlgFESkQgUNtmZV0EOncOW6deuoaEGywYANa1lVK8ewsgXr7E4pxyAJIT%2Bhs89IBipyUFyCo9AMxn5Q25MHFO6gYAUjKDioNSjCOhZjGHiJDSjNwWgNkoT%2FrvPJbgku2CG7ZVoHiQFBwYgRI0gYKANijcc4mRyD82JwiNVOdsgbSXXIKDg0DWDlFOqIosGB2D9H4VWOyJQZNuYZsg72wOgLYhCaxNt5lTZDzY3t2TkbMAzDajD1QtkAAZINioKSgfBqqb4pxyjVl0%2BNl4AEJCABCUhAAhKQgAQk8KsKnF6OQZMoLMkip%2F%2F%2B978JNErO8mBoBEUk6HG%2F8sorvMqN7jblKJlAQQphhQOMjoicFH1wtiGXsMZsMI7ipZdeYm4F5TfZhoEHrHXy4osvEixYb2FpUTIKxioQCPBGjkI2QrkJogmWCGEuBs%2BzB3ZIDsAD61gELyQq7JY2815awogI3k57eGwdiFKi%2FGo1m52QKlCewpqHwkmxPsu%2F%2FvWvyFQRRllQJISGsdBJ5FxKPqDUBqMyyFI4IvvkRtsY9UHOY0UN%2FLSyIOtE%2BEnzWPzFGl8BKWM5OAXexVmwExpAy%2FmViIMDka4wQoMN2DMnws0KPUpmSiXbU4oeK8coRRdLTZWABCQgAQlIQAISkIAEJHCGBU47x6Brz1AKAoTIxIdIy%2BmDM3OEoQuMIuAnSYI1t4Iog1ETvIuDRjamGCbDNqiYwZgHniTNINlgMVZmSfAr0zGYNsJRSlbgZPYK21ABg3qhPLAqglrvZVc8T%2FkOWkXJUN5Y8lgkAIyF4FUyBMpfsDHtiVTa5FjUx2CcA9EEP2l2pDYFD8hPOBY1Nq2W84y1f2t0hPXkMT85axrPrsguGCXCiUeaam3JBuyEKSQckeVRmMByzB4olMHwDF5lhAlnzU%2Fio5IjLmgwcQfO7B9DC%2B2YnZS6X5VjlLpLpgZLQAISkIAEJCABCUhAAhI4YwKnnWOcsRbqQGVNQDlGWbviOl8JSEACEpCABCQgAQlIQAInL6Ac4%2BSttOWZEVCOcWacdRQJSEACEpCABCQgAQlIQAKlUUA5Rmm8amd3m5VjnN3XV2cnAQlIQAISkIAEJCABCUjg5wgox%2Fg5enrvryGgHOPXUNU%2BJSABCUhAAhKQgAQkIAEJnB0CyjHOjut4Np2Fcoyz6WrqXCQgAQlIQAISkIAEJCABCfyyAsoxfllP7e3nCyjH%2BPmG2oMEJCABCUhAAhKQgAQkIIGzVUA5xtl6ZUvveSnHKL3XTi2XgAQkIAEJSEACEpCABCTwawsox%2Fi1hbX%2FUxVQjnGqYtpeAhKQgAQkIAEJSEACEpBA2RFQjlF2rnVpOVPlGKXlSqmdEpCABCQgAQlIQAISkIAEzryAcowzb64jnlhAOcaJffSqBCQgAQlIQAISkIAEJCCBsiygHKMsX%2F3f57krx%2Fh9Xhe1SgISkIAEJCABCUhAAhKQwO9BQDnG7%2BEqqA0lBZRjlNTQYwlIQAISkIAEJCABCUhAAhIoKaAco6SGHv8eBJRj%2FB6ugtogAQlIQAISkIAEJCABCUjg9ymgHOP3eV3KcquUY5Tlq69zl4AEJCABCUhAAhKQgAQkcGIB5Rgn9tGrZ15AOcaZN9cRJSABCUhAAhKQgAQkIAEJlBaB3Nzcd955p7CwsLQ0WO086wXIMVauXHnWn6ZOUAISkIAEJCABCUhAAhKQgAROQ8Bms%2FXq1Wvbtm2n8V69RQK%2FuEBRUdFXX321d%2B%2FeX3zP2qEEJCABCUhAAhKQgAQkIAEJnB0CsbGxXbt27devX3%2FdJPCbCvAh%2FPrrr4cOHXp2%2FGXpLCQgAQlIQAISkIAEJCABCUjgVxLIz89fsmTJct0k8JsK8CHUSIxf6W9cu5WABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAESr9AMBTwlcV70F%2F6r53OQAISkIAEJCABCUhAAhKQgAQkULYEgvZU7%2FRrytx96pW%2BpQ%2BXrSuts5WABCQgAQlIQAISkIAEJCABCZR%2BgaDtiHdsrTJ3H32Bb%2B4tpf%2Fq6QwkIAEJSEACEpCABCQgAQlIQAJlS8DkGOPqlrn7mIt8824tW1daZysBCUhAAhKQgAQkIAEJSEACEij9AsoxSv811BlIQAISkIAEJCABCUhAAhKQgATKioByjLJypXWeEpCABCQgAQlIQAISkIAEJCCB0i%2BgHKP0X0OdgQQkIAEJSEACEpCABCQgAQlIoKwIKMcoK1da5ykBCUhAAhKQgAQkIAEJSEACEij9AsoxSv811BlIQAISkIAEJCABCUhAAhKQgATKioByjLJypXWeEpCABCQgAQlIQAISkIAEJCCB0i%2BgHKP0X0OdgQQkIAEJSEACEpCABCQgAQlIoKwIKMcoK1da5ykBCUhAAhKQgAQkIAEJSEACEij9AsoxSv811BlIQAISkIAEJCABCUhAAhKQgATKioByjLJypXWeEpCABCQgAQlIQAISkIAEJCCB0i%2BgHKP0X0OdgQQkIAEJSEACEpCABCQgAQlIoKwIKMcoK1da5ykBCUhAAhKQgAQkIAEJSEACEij9AsoxSv811BlIQAISkIAEJCABCUhAAhKQgATKisBp5Bi%2B8XWDE%2BuEJoXvE%2Bv4x9f1jjujd%2F%2BEuqGJdWjD6R93zEW%2BebeWlWus85SABCQgAQlIQAISkIAEJCABCZwtAqeaYwQn1HGOqb%2Ble7MJr7Qe%2Fe%2FWaz5pkTusYWjSmcsx%2FBPqJPZtvPqTFrTBM%2FZ0j6sc42z5AOs8JCABCUhAAhKQgAQkIAEJSKBMCZxSjkGIQYbwcNsbateIPr9q9HlVomtWj25%2FefvJr7UMTDzdSOEUx3KEptbu8tdWlSvedG2Tto7R9Xyn%2BPajQziUY5SpT7lOVgISkIAEJCABCUhAAhKQgATOFoGTzzFIDDzj6j9%2F6%2FVVKkaTY7Rr3u72K9s2ujj63HM7dHu0dWjy%2F8%2FyYOIJk03%2Be74Je7Ce52fJ%2FIHHvIU7IUPkgRU4RN5ivcqT5Bhd%2F9qqSqUObS41OcaP7vBoWHGCiEM5xtnyAdZ5SEACEpCABCQgAQlIQAISkECZEjj5HIOSFPu%2BaXpFw%2BgLqkV3fujazCGNi0ZcsvqT5m%2F%2B7%2FWpAxpZqUVgAmUr6tpG1c8Z2qBgeAMe84yVKlBPwz%2B%2BHk9mD22QP7xBYEK9yEuuMfXsoxo4R9fnEK4x9YtH1j%2F6lol1fOPrFY1owN74yd5MXY4SOYZ3XD33GHOsvO8asGVkh8oxytRnWCcrAQlIQAISkIAEJCABCUhAAmVH4BRyjEl1tn15WbO6JscY%2BHzr0PTaoUncTdRgDZYgRsgb3vDbp1vff327Fg3a39SiXY%2FHr84Z1pDnPWPrr%2Fm02Tt%2Fuu4PV7RrVrd9dIv2Xf96bdZQ81JwQt2xr1x5b5t2A%2F%2FehpIXj99047%2FuvNE2sj573tfr0o73XXdH63YtG7S%2Fs3W7z%2F56dXyfJhzXGo9xY7Mbt3S77OlbbrzykvY3Nmv%2F0Z%2Fb5Ay9hOoZPx1iME5D4zHKzkdcZyoBCUhAAhKQgAQkIAEJSEACZ5HAyecY%2FvF1ikZecvc1N1avEt2kFkHENQe%2BaRKaWM9aN4RX87%2B75OF2N%2FBqvQvbX9%2B0Xa3zo5n98dwfryPEsI9u8Kc2baOiOjSuFd2yfvSF1aKrVop%2B4fbrKRkamlTvoz9fc0656D9d3444IiqKghvRrtH1F3%2FQokX99lUqRdc8r8Pl9aMvPr9DVLkOL95xfWh63c8ebVW9Crtq3%2BqS9pUrdqhdo0ONqu2rVOzwxePXBCbWU45xFn08dSoSkIAEJCABCUhAAhKQgAQkIIH%2FEDj5HIN8gMhi42eXXde0PWFFtcrRl9WNfv2eNvF9LjVrsE6u%2B%2BUTVxMvXFq73aL3rygc0XjKG1cSaBAyLO3cPDSt1ux3rujyyNV7ezY93K9pjydbk3LUvaA9v4am1P304WuqV6ZkaPsGF0W%2FdOf1szu2TBnQ%2BOYW7XjyxsvazX6n5b6eTRe%2Bd%2Fmj7W9c%2BXHz0EwzHoMDXVi9PeM0Rr7UaumHze%2B%2Bpm31Ku3vbdO%2BYHhDa4bLT6QZGo%2FxH58C%2FSIBCUhAAhKQgAQkIAEJSEACEigdAqeUYxAOUMLicL8mH%2F3l6msatydnIM1gvZIdPZp6xzV46MYb%2BfXaJu36Pde6%2F%2FNX9X7m6kvrtK94boe%2Bz7YKTa3D2qwpAy5d%2FMFlJA8fPNim4UXtiTKWdW4ZmmpyDN5I7dBhL1wVnFgvNKP2wvebXVC9AxssfK9FaJqZvUIdUQpoMIHFqo9RrQrDMKKHv3hVaHodZpr0f741IzduuKx91pCG%2FgknsXKKcozS8fFUKyUgAQlIQAISkIAEJCABCUhAAv8hcKo5hm88lTxNKEGa0f2xq5ncUa1S9JM33ZAxuMkfWrZjjET9mu3rXNChTo0O%2FGxSi3SiAxUzvOMbEG60adK%2BaqWbLqjWod6F0XUuaF%2BnRjQjN6wcgxSideN2hSOo%2F2l2PvzFVqyK0qph%2B8zB5hlrcIVVhcPKMapW6tCqQVtmsjD6IjSlzqTXL69cMZqBIpmDlWP8x%2FXVLxKQgAQkIAEJSEACEpCABCQggbNJ4JRyjHARjEa2UQ1Dk2ub%2B%2FTaH%2F%2BlDUMpKOm5%2F5vL%2Fqd1W0ZoPHHTjdu%2FbEbFzi3dmm37ollM9%2BaZQxrNfLtljWodLjov%2Br0HrlnVpdnUN6%2B6tDYlL%2F4%2Fx6h0bvQDN9zoHluPhVYZejHulZYU0GDMxu6vL%2BVAPGnyDbP6yX%2Bsu%2BocE95%2BSp2JrynHOJs%2BlToXCUhAAhKQgAQkIAEJSEACEpDAjwucfI7BcAj7qPrP%2FfH6xzvcsK5Ls4zBjQ9%2B2%2BSv0TcQONxzzY220Y1ev%2Bdahkm0bNBu2YeXhybX486CIxNfbUUt0J5PXcOIC8ZXZA1pHJpT%2B6snr6pRNbrkeAxyjMei2x7NMSbV2f7lpQQdbPPsLdenDmwcmli%2FYPglw1%2B8cvLrV1j1Magg2ubSto7RyjF%2B%2FLLqWQlIQAISkIAEJCABCUhAAhKQwFkpcPI5BkUqFr7XnGEVJBL1a0bf3JLlQqIvqN7%2B%2FKod%2BjxzFRUwdn3VtHUjZo5EN7o4%2Bi9tr3%2B47fXN60XXqHbTpm7N5nVqTvJQ6%2Fz2D9143RM3XVv7AgZj%2FMe8EnKMR3%2FIMQhMfBPqffSXa2pU7XB%2B1ei2zdo9Fn3d7Ve1JSRp27ydbUyD7o%2B1Uo5xVn4adVISkIAEJCABCUhAAhKQgAQkIIETC5x8jkFljOKRDfo%2Fd2X75tTBiGbJkvOqRDeuHc2qqfZRDXiVeR9ruzS%2F%2B9q2NaubZVUpcFH3wuinbrph%2FzeNbSMb%2FPt%2Frq9zgXm%2BWuUOrDxyRYN2FMpY%2BJ6pj%2FHxX64555wOD7drZ43HoBoGE1g4VrfHrmlR35QSJTnhcDc0bTfo71cFJ9fr8kircyvcRD2NyHiMCa9eXqF8h6ubRKs%2Bxokvt16VgAQkIAEJSEACEpCABCQgAQmUaoGTzzGIF8xyIZPrZg65JObzS2d3vGJJ5xZxvRsHJtQjdrBKcTJmo3BEw83dms58u9WC91rs%2FrqJe0z94IQ6bOAYXX9HjyZzOl7Bq7aRDQ%2F1vmRnj0b53xGA1E0d2HD7l40S%2Blxi7cT6yVuCE%2Bol9Gm8%2BIMWM95qta7rZRmDL6EEqG9CndSBDdh%2BX69LPGPN0iTsIWdo%2FfAzjVxj6pXcyXEfa72SUv2pVeMlIAEJSEACEpCABCQgAQlIoKwKnFKOYcUC4ZKbjL4wd9YuoQhnybiA%2FMF66eir44%2B%2BajKQ8Fv4aTKK8GOe5L2meie7%2BmFdksje2DNPRt4VWbjk6PYTj4YnbG9WLQk3JvLen3igHKOsfuB13hKQgAQkIAEJSEACEpCABCRQqgVOI8f4iYjgP2ON3%2BnGyjFK9adWjZeABCQgAQlIQAISkIAEJCCBsiqgHKOsXnmdtwQkIAEJSEACEpCABCQgAQlIoPQJKMcofddMLZaABCQgAQlIQAISkIAEJCABCZRVAeUYZfXK67wlIAEJSEACEpCABCQgAQlIQAKlT0A5Rum7ZmqxBCQgAQlIQAISkIAEJCABCUigrAooxyirV17nLQEJSEACEpCABCQgAQlIQAISKH0CyjFK3zVTiyUgAQlIQAISkIAEJCABCUhAAmVVQDlGWb3yOm8JSEACEpCABCQgAQlIQAISkEDpE1COUfqumVosAQlIQAISkIAEJCABCUhAAhIoqwLKMcrqldd5S0ACEpCABCQgAQlIQAISkIAESp%2BAcozSd83UYglIQAISkIAEJCABCUhAAhKQQFkVUI5RVq%2B8zlsCEpCABCQgAQlIQAISkIAEJFD6BJRjlL5rphZLQAISkIAEJCABCUhAAhKQgATKqoByjLJ65XXeEpCABCQgAQlIQAISkIAEJCCB0iegHKP0XTO1WAISkIAEJCABCUhAAhKQgAQkUFYFgsVJ3lHnlbn7iMq%2BWe3K6jXXeUtAAhKQgAQkIAEJSEACEpCABEqrQNCd69%2FwRpm7r3s1sKNHab1marcEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAR%2BawGv13tQNwlIQAISkIAEJCABCUhAAhL4GQK%2FdcdOx5dAGRLw%2BXxJuklAAhKQgAQkIAEJSEACEpDAzxAoQ31InaoEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCfwfe3cBbUeRvX8fd3d3d4K7u7u7u7u7h%2BDuwd3d3YIECwka3J2BkfV%2BhvpR%2F37PvbkEy4TwnMW6U6e6rL%2BnOmv203vvCoEQCIEQCIEQCIEQ6JDAd999%2F847H3zw%2Fsf%2F%2Bte%2FOmyYiyEQAiEQAiEQAiEQAiEQAiEQAiEQAr%2BawL%2F%2B9e9HH37mmqvv6PXqW%2B12fv65HpdefNNHH37a7tX%2BsPLrr7%2B9%2FZYHrr36zuuuueu6a%2B686YZ7H3rg6Z6vvvnvf%2F%2B7H6z22WdenneOtdZfZ48vvvi6H0yXKUIgBEIgBEIgBEIgBEIgBEIgBELgb0Xgxx%2F%2FudmG%2B4441MwXXXBD2xv%2F7rt%2FrLvmroMPNM1ZZ1zZ9mr%2FWdO79wfTTr70CEPMPOxgMw49yPTDDDK9v%2BOOPt%2BWmx7wxmu9%2F%2Bw1P%2FlE9wnGWnCJRTb%2B%2FPOvzPXZZ1%2FcecfDD9z35Lfffv9nT53xQyAEQiAEQiAEQiAEQiAEQiAEQmCAJ0DH2HKT%2FUcdbraLL7qx7c3yYTj5hK7LLrHFo4880%2FZq%2F1kjrGOGqZYba%2BS5l19qy%2FXW3NV%2Fa6y8o5qhB55%2BrdV2%2FvTTL%2F%2FUZT%2F15AuTjLfI0otvVnSMF1%2FoOf2Uyyww9zoff%2FzZnzpvBg%2BBEAiBEAiBEAiBEAiBEAiBEAiBvwOBjnUMBH784cevvvqmJSjj%2B%2B9%2FeLfPWSB%2B%2BOHH99776IMPPmn2%2Btc%2F%2F%2FWP738oWSO%2B%2Furbd3p%2F8Pnn7UsKH3%2F02bvvfGjSdvl%2F%2BOGn7777oQW0e1UlHWP6KZedfKLFevR4s7Z5pttL88y25jijznPbrQ%2FWSgWzWMlnn37RrCzl%2F%2FznPx9%2B%2BImVfPPNt%2FWqSnf3%2FT%2F%2B70ZKPUTW4xBbX6uO8dlnX2r59JMvTDbBonN0Wq332%2B%2F7qntj8E8tVRRMHbxtQZd%2F%2FKOPd9q2fWpCIARCIARCIARCIARCIARCIARCYMAm0LGO4eqJnS9ca9WdH3rgqcLhnz%2F%2B64Zr71552W1GHmbW8Uafb7ON9pVeo4norjsfWWvVncYYac4Jxpx%2Fmy0Okl6jXH3qie7rrL7LQfuffNYZVyw097ojDdVp9plWOe2US7799rva%2FZVXXt91p6OmnHgJg%2FNhOO3kS7%2F55v9dfenFXjtvf%2FjE4y482vCzr7z8ttdfe1dTJ6mDFB1jsgkXffbZV2qlwm47Hj3MYDOcctIlpZKCcdbpVyw4z7ojDtWp03QrHHnoGR99%2BElt3%2B2pF7fd4uCJxl1olGFnW3yhjc4566qyzu%2B%2F%2F4dbEGtDGCmNreHM0y5fdcXtxY%2BoKTrGsktu8frrvffevbPxrXaCsRZYYuGN992rS4kuee7ZV7bf%2BpBJxl14pKFnWWT%2B9c849bJ21Qzqyhab7r%2F26ru89eZ7dWEphEAIhEAIhEAIhEAIhEAIhEAIhMDfmcAv6hibb7zfSEPOcv21d6MkKehJx180xohzjjvqvIvMt%2F58c649wpCdyA533%2FWYq5wNBKeMPfLcY486z8Lzrj%2Fv7GsON%2FiMs8%2B0qtSXrt5956MuCWAZfYQ5KRgLzLXO2KPMM%2Bpws7PiC38hGBpLajHHzKsutsCGk0%2B4mLwWe%2B12HC8ODegDs820yohDzWLYhedbX19SyeWX3FL6Nv9WHeO5nyWUcnWHbQ4ddvAZTzvlUl%2FJI9tve5gcGlNNsuRiC27Ef8Nc6625W0nO2e3pF2eedgX6xryzr7Xo%2FBtQIZSPPOxMziR0jOWW2nLMkeZ64P4ny7Duetedjhx20Bku6frfwJyqY7z22tubbrDPNJMtNdbIc40z6rxSdmy28b7cNro%2F%2F%2BrsM6868jD%2FvRGDTzzOQsMPMdP%2B%2B5zghygD1r89XnljwrEXJOlIr1ErUwiBEAiBEAiBEAiBEAiBEAiBEAiBvzOBX9Qxtt78wDFGmPPG6%2B9BiTLA8J98gsVuvfkBIQ%2B8CI4%2F7jxmuAQUwh%2FeeP2d2WZcZZJxFyF6%2FPDDP7%2F66tv99upCythxu8P%2F%2Fe%2F%2F3HvP4xOMucDYo8wt4cbnn335zdffntTlolGGnXX5pbckDmi%2FwzaHkQv23qPzJx9%2FTjDhETHXrKtrzxWElMEtYeRhZzvq8LNM%2BsOP%2F7zislvJCwvOve5HH7UepFJ0jEknWKRbt5fM6z%2FZSh1fYtmTjrfIY48860auuuI2bhJLLbrpyy%2B99u%2F%2F%2FOf113pTJ7heXHX5ba4ecuApboo3Bbnjnz%2F%2B87ZbHrAMck2PHm%2FAxRVk%2FDHmf%2FBnBxU6xh67HkNtuOySm%2FWtcSWffPLFF19%2B%2FdCDT08%2B4aKUmR6vvP7ll18LK6GHDDfYjHvtflwZ%2FM47Hpl0%2FEWQ4W2ie%2FOD8E033Hf1Fbc3nVKaDVIOgRAIgRAIgRAIgRAIgRAIgRAIgb8bgb7VMW64F5nTT710%2BCFn3mev4yulTz75fJ01dpUI9P33P77o%2FOsIEZwQjMl1gYFPJZhuimXm7LSaLJf33fsEJ4q1V9vlH9%2F%2Fo3R%2FoXuvKSdenHOFRBmv9eo987QrzjTNCm%2B%2F9f9iKM487TKuC1defuuz3V6WZWKe2dd8%2F%2F2PxHEYXFTImqvuNN7o8z%2F80P8vqsXIdAxZPUcfYQ55Pjdef69N1t97kfk2GH3EOUYddraDDzjlxx%2F%2BSR%2FYfJP9LPXyy%2F7rzvHPf%2F7L364X3jDS0J322PVY4%2B%2B9%2B3HcQogwxBaXpLTo0vmCnbY97PXX3ta3L3WMkufz5Zd6TTXJEvPMvsann35uKJ8D9j1xuEFn3GfPzmVwNaefcumuOxzZq2f7597%2B1Cl%2FQiAEQiAEQiAEQiAEQiAEQiAEQiAE%2FkugL3WMm37SMfbds8tIQ83C5K%2FseDtQIagZDPx99ug8wpAzC6BYaJ71eEosNM%2B6QidoFzNOs0LPXm8JjuDVQOX4%2Fh%2F%2Fp2O8%2BEKvaSZdav451%2BG68MhD3QRrrLbS9k3fg%2B%2B%2B%2Ff6jjz6TFJSrA4eHicZZSDSKkct%2FlI1xRpvnumvurIspBTrGjFMvN8ows4rm4Ocwzmjzkixk%2BJToo%2BTM%2FOjDT%2BX81EAYi0X%2BtNT1Zp1hZVNstN6eEoCIGeF2opcGW292gCm4UpTBv%2Fv%2BH79Kx3ih%2B6t0jLlnW6P6jTz84NNTTLiYueaYeTVyyrXX3NVultGWm8rXEAiBEAiBEAiBEAiBEAiBEAiBEAgBBH6VjrHzDkdITXnlFf8Nvmj5cGPYabvD2f7kCL4Qow3%2F3%2F8U%2FDfLdCu98sobRcfgHSGKpPRt6Bif33P3Y1JnbLDO7v%2F4WeVojn%2FuWVe1jjz8HGOMONeEYy1w9ZW3N1sql7gSUSeXXXKLLKNPPP786ivvYNnHHHVOadm79wedpltJag6JPkb%2FaZ2W%2Bt%2FyCHNssPbuZXmPP%2FbcFpvsP%2B5o8%2BoodQZB4%2BYb79Pd1d%2BpYxjkySe6UzAEpxh8mEFnmGma5a%2B56g7uK2V5%2BRsCIRACIRACIRACIRACIRACIRACIdAnAr9Kxzh4%2F5PpCeeedXUdjXzhZNI333j3u%2B%2B%2BF47BH2P%2FvU%2Fo9epbr7z8evlPuITsE7w17rn7cf4YfdIxqA0cJ5ZbcovmcatffPGVbJnSZVx68c3iWQgIcoH2%2BHnknj3eNHgJ36jrUSg6hnNXX%2Fw548QD9z814VgLSub58k81Tm6dc5bVxx9zAb4Qr%2FZ4s6xTUs2er75VTkf96suveW44R9V93X7rgzQHMSmzzrjya73aiSsx4567Hds2P0ZZWIs%2FhngbNyjdB%2Bxvvfnunbc%2FvMn6e4058lwzTrP8qz3eaN5FyiEQAiEQAiEQAiEQAiEQAiEQAiEQAm0JVB3j8stubfdqM8%2FnJV1vomNstdkBJaeE9u%2B%2F97FEnXJcyFzR9YLrORisvtIOJXyjjPZi955SZyjfc9djfdIxBI%2FoLmZksvEXff75%2FzunVZfOx5439WRLXX7pzSU%2FxszTrNBMImFqiTfLLM2%2FRcdonrtKRdlmy4OHHnj6PXc9VgZRXx3CIpPnaT%2Bfwao7dwh5QcW8iDrZaN09ll5ss56vvlmGlY5jxWW3HnHITnff%2Bdi%2F%2Fv3v1VfanudGcc%2F4qcF%2Ftt7iQAlLW%2FJ8NnUMmT1E32j86Sefr7%2F27sssvjnZpAxOCHIcreibu346trVU5m8IhEAIhEAIhEAIhEAIhEAIhEAIhEC7BIqOwZ3gwP1OfvqpF556snv5T%2BwDzwdhFE0d46233ptrltUcJHrMkWeTFDgbOMx0mEFm2GKT%2FYzzzjsfLjjPeiMOPcsu2x%2FxQvee%2FA26Xni9RBPrrrGr80adV9InHYODBBnhiEPP4M7B6eKxR5%2BT7fOSi26cYqLFJ59g0WeefunHH380BSFileW3efSRZ3r3fv%2Fuux5dapFNuFX06vl2y3211TE0ECfivBJnubo7X2%2B56X6LcVDIOWdd9frrvZ0Vsv%2FeXcYbY76zz7zCUjmNDDXQdFtsvJ%2FzWcwljsZdSMfx3HOv6CsNiFNWV11hO%2BqK%2Fw47%2BLSJx11YNE27OgYXDke4SpHBrwMxzhgbr7vXYANNvdF6e4l54f5x%2FbV3dZp2RYOX02mb9%2FL1V98cfeTZB%2B9%2FCj7N%2BpRDIARCIARCIARCIARCIARCIARC4G9LgP4gtGGIgaYlZRAo2OPlv1GGm23D9fZkd2%2B%2B8b7DDz7TddfcVRA5cVWAxtADTzfxOAuNN%2Fp8Qww0zTKLb1ZjIu69%2B7HZZlyZCCD5wyTjLezQVdLBZRffxKXhrjsfcSDIemvuVvNj0DomGXfhOWZarThsfPzRZ5tssM%2Bwg81gARQGk9IHLr7whv%2F8%2B7%2BJI17v9TZHCJqJq6x%2Bq%2BX7cehBpzqGteW3ozyYlCjRrdvL9RI3jP336jLoQFNJwaELf5Ljjz3PcSfDDDqjNVitG1ly4Y2f%2F0mp6P58D6lKBx9oajfIRYQLitwdnY8%2B1yAG7P78q7PPtIqrsmdIH8qvg3vGiEPNcvFF%2F01%2F%2BsTj3eUXXWSBDYo%2Fxrfffm9GuPBcYekt8RTGssQimww18PS6G9xdGJxe4Yeoqy2Fl17sOfao89B27r%2F38ZZL%2BRoCIRACIRACIRACIRACIRACIRACf08CFIarr7r98ENO5w7hb%2F2PRHDFZbfKenHdtXe5xPWi8nGW6NGHnykN5nZbHXLGqZeJCqmXFLhhnHj8hdtsfuCWm%2Bx%2F1GFnvvD8%2F3WUieKow8%2B88vLbhHWU9u%2B991GX4y4487TLv%2Fzi%2F04DYeZfevFNO257GF%2BIg%2FiHPPlCc%2BTPP%2Fvqoguuc3Xzjfbdd4%2Fjb7vlwSIsNNsof%2F7Zlyd36dr5mPM4ZjQvvfXWu8cedc5JXS4ssgkPkIcf7HbgfieKMdl%2B6%2F%2FeyIcffFLbv9P7g1NPvmS7rQ%2FZfKP99tv7BO4fzbm6d%2B958P4ncRGR2lSszaVdbzrikNO7Pf2i7lxWjj3q3PPOuebbb78ro732Wm8wN91wnxOOvxBPlR%2B8%2F%2FFZZ1yx3VYHm3rfPY%2B%2F9eb7a5xOXYCCo1vOOPVSMHOgSRNLyiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiHQLwn85z%2F%2F%2BeKLLz755JPvv%2F%2B%2B7bz%2F%2BMc%2FXPr3v%2F%2Fd9lJ%2FUmPZVvjtt9%2B2rOebb775%2BOOP%2FW2p%2Fz1fcbjqqqv233%2F%2FHj16%2FJ5x0rd%2FIPDjjz%2FaOR999JFN3lzPd9999%2Fnnn%2F%2Fwww%2FNSo%2BJvaS%2BpXGzjS5G8zFys75Z1qblqhoPoJGbn%2BbD%2BK9%2F%2Fauss6Vjy7AtCy5XjWk97V6qw7Z7tTl4yiEQAiEQAiEQAiEQAiEQAiHQXxFgHO2yyy7TTTfdQw891HZh%2B%2B233zTTTHPHHXe0vdSf1Dz44IPTTz%2F9mmuuSbVoLun888%2BfeuqpL7300mbl7ywzZjfaaKOBBx74zjvv%2FJ1Dpfv%2FkIA9f9ttt%2B26666b%2FvTZaaedrrvuuiod%2BHG32mqr008%2FvWng%2B%2BnPO%2B%2B8bbbZplu3bm1XThPQy3NUBjTyfffd11T%2F%2FvnPf77%2B%2BuuXXXaZB%2BrVV19tjvDMM89s1%2Bbz6KOPljbPPfcc3cywm2yyiWHtdiup3c1bht1rr71eeumlWq%2FwzjvvHH300VtssYW%2BFnb%2F%2Ffc3O2pch91tt90M21xtc5yUQyAEQiAEQiAEQiAEQiAEQqB%2FI8CmW2uttdq1zRk%2BLKCRRx65f9YxrG3IIYccaKCBDj300Cbb448%2FXuVpp53WrPydZUAYhsMOO%2Bzdd9%2F9O4dK9%2F8VAQ4V55577gorrLDeeusde%2Byx9skGG2yw%2FPLLn3HGGcXXgqax4oorrrrqqvfcc09dpJ%2B%2Bc%2BfO6h955JFaWQs333zzSiutROPq0qXLMcccQ1VbY401Hn74YQ0oGLfffvsBBxxgwOWWW86kL7zwQu2oQAAx7CqrrKJB%2Fdx7770u9ezZc%2FPNN1d55JFHHnfccWuvvbZH9amnnnKJgmETHnTQQa5avOnoIerL57PPPttjjz3U77vvvjrqtfrqqxMrytU333xzyy23LMO6%2FTLs448%2F%2FnPv%2FG8IhEAIhEAIhEAIhEAIhEAI9NcE6BjrrrvuEEMMcdddd7VdKF%2F6t956iy3WvMTx%2Fuuvv27W1DJLsOVS8y2w6I%2B2ASClr16GreM0C9bQp0uaMQOHG244ksU444xT32KrP%2BGEEwYZZBDGqXJZQ3MltaZZyTb86quvmi%2BmWxascdExHnjgAcNaM3oKbT8uNd%2FmNxu4lw5up9ky5T%2BDAHmBaMAH47XXXvOL%2B7l79erlKyngySefNOP111%2FPxqdLbL%2F99rwayhr89DQKHZt7rFz69NNPdd9www0JFJoZk7a28sorEzSU7QTeFqutthpJgWvE%2Buuv%2F%2BKLLzbv69prr9X41ltvNY6PEBIfz5GhzjnnHHrLFVdc4QG0TsKFBVAelO3MHXfc0TrLsLSIZ599tg7rWbb%2BU045RTONbVd3p6XFaMMtxLBclcqwXDUMe9RRR%2FVpM9dhUwiBEAiBEAiBEAiBEAiBEAiB%2FoFAxzoGo2nZZZd94oknylLffvttJlunTp3EoXjbW94al0tffvmlt8azzjqrS0suuWT1WGCmLbHEEqeeeurWW289wwwzaLD77rt7X1zvnQe%2B99R6GZZp5mVxvcTi8%2F5ar5lnntkb5HfffbdeqoWiY%2FAnIWVYEiGiXGrqGNa%2F%2BOKLn3zyyWxDV%2F31In6ppZbyCls0AXd9b6u9tp533nmFqCyyyCI33HCDV%2BFmNLVVbbzxxmVVOqocaqihrFN3a55nnnkYm00DUMDCoosuOu20084%2B%2B%2Bze4JcEHRIgEEC8dtdxjjnmYNg2pQxr2GeffdikYgTqfaXwZxDwS5EXmO3VOaHMwgfDDineO359v4WP7WTPlB%2FXT98nHcNWsUMEcVS577333qNXCCEhI%2FhxyQjaGMeeJDi06BjCVYgML7%2F8csv92iF2BXnkjTfeKJc8NTvssMO2224r6wWVzLDytJjUQ2qEqmMQLigYasSklI5UkYMPPnidddbRnrTCi8NjZUnlqs1pWKE0LZFZ5Wr%2BhkAIhEAIhEAIhEAIhEAIhED%2FRoB51YE%2FBgONV4OXxZbt3TQbn1zAup9vvvkGH3zw0Ucfndnukve83jW7JJnGAgssoMuYY45ZTMWTTjpJvc8oo4yi16ijjqosBoS1pSOHfC15g8w%2F%2F%2Fw0BJeIAB988IFLrDCjDTbYYLQCM7pE7mgKINr40DF0X%2FCnDzWDYFLqmzqG9evOAKw6hhAAixQ4wNJcaKGFXPWVZDHTTDMpc%2FCYaKKJRhxxRKOOO%2B64apilJX8Cc89Xn8knn5zuoRcOZ599dpn0yiuvHGmkkUYYYYSFF1544okn1kwWApamN%2Bxl5J%2B6%2FldvKW%2FGSy9v4WeccUaLJ%2BmUmvz9kwhATUQSrNGiidGp%2FIgCQMxLx%2BAgIRKE8kbgKnljOtAxSFU8OgxYtrQRnn%2F%2BeQ4YdmBVNlQSEOhaLTqGSvKIOBSOQxZAExO3UpQTYgXVyxpsnkLDXrUqUTB0klLjr0mJck0dw6REuaJa1GYGp8xwJjHIzjvvTI57%2F%2F33y1Ubm6sGwaQpIdaOKYRACIRACIRACIRACIRACIRA%2F0agYx2DF8EwwwxT7DviAzNcVkAvcL0OdnIHkYGlr9y1a1cWPfOQzsCwYs7rxbZipnnHrReXjO7du2vJTCNlzD333Mw0jeecc06GP8vRJcPy5NeYFmFVXhlLfMEHnl3GVCyXLrzwwhaAdIxBBx2UuSfAn%2BYwwQQTlBfTTR3D%2BmkdrMKqY1iqFdINmHWLLbaYiazTLNw5JBawBg4Vjz32mGV4Uc6DYrTRRitpDegY7tR0joHwmtvKxxhjDI19Jb9MMcUU9A0dTdS7d2%2BajJu1HrfGEcUajjjiiFdeeYUJWW1et6OxpAfEFotpubt8%2FWMJ0OIIbnaC7denkcWV8JwRzcHhgY6hcRHW7Kh240paxrGFPCk2%2F9NPP928ZBu31TFoCJQuLk%2FLLLOMLksvvbSIjzPPPNPGI19Q3qgudak8NA455BDPRVNw8Ii16Bi2kwQg4kqqu5TNZmo6BmnR8opTR%2FW%2B8OiRRwzbkoC0ufiUQyAEQiAEQiAEQiAEQiAEQqD%2FIcBi6sAfo%2BgYtAI2Ox%2BD8ccfv7qjM8FEiLCz2IbsO9kvr7nmGmWxJ0x%2BSgVJwYtj5z7QGbwOLrfMD4Ebw5RTTsmW93aY54MkhEVe0EBH76Z52hM96AM8McgIEnQY1uA8H6ynqQDoUnQMxqlBGGskCN4OLD4RAQSHkh%2BjYx2D2uC%2BakyH5AZFqSgLNizRg7NEeS9fgFQLURuuHQQK%2BgxNg7BD4qBglDWLLLAet0PHEBozySSTFIu4jJy%2F%2FZ4ABYCLEYcEYVB9mp2OUdNHkNQoDPYSFeLEE0%2B0z8llNqcaXykbjsXx49ahPE02PMchG57CUOsV2tUxPFYXX3yxfSKjBRnB3rPZ%2BHKQwugY9hL57tfqGOYioVBgPJsip%2BzGErriQbNLrVY9hSQ6RvPXSTkEQiAEQiAEQiAEQiAEQuAvRKBvdAxGFqtHoAQJgmd%2B8%2B6oCmpmm202lj4rvn7Y76JO%2BB5QElR6QVx6eR3MhWOqqaaiY1x99dV6HXjggc0B6QbGNCMfCQJIHVDZmEw8pl%2BzfdUxVLL4iBLGZLj9Kh1jvPHGqy%2BjiR50DO%2BsyyzMT6almhImQ8doOa%2BEiwUdQxoQhq0V1gUrWIkaaUMsjI4x6aSTtoQzNG8k5X5AgCC22WabcbFoG6BUZy86xiWXXKKGEMfkp63R3ERI0THkWiFYyQDDxYgnDzcGY5a%2B9i1PJI4QtnRT3ChX29UxasdS8FekFW%2BQs846yxNHcPBpq2NQyWr7tv4YLnGxMEI5BoWgYdmcPciVFBhCor3tAFkeRGWQ4o8hcqpKlHXwFEIgBEIgBEIgBEIgBEIgBEKgPyTQlzqGt8OTTTaZBBctOoY7UsNIF6bBKULUibSZPgrSCTLbxWuw6AX%2Bl3tv6hhsf%2FqAZm2xeDFNuJBIs4xW%2FhqTHtInHaP4aYgFcFCss0ukbTR4X%2FpjtNUxvAcvq%2FpFHeOwww7jKOJepFukWpiXm0pdsDVLIYJedIy2v3K%2Fr6EJ%2BGU32WQTXkPN2flp8ItwcgcZjUzBH6PoGNrwvaGeMfypH8VTwiBydZYP%2BYsOUIa66aabxG7sueeezfwVdZZ2dQx9PRGewdqM1EBzECpiz%2FAbEcdUBQdxTxJZ8P%2BhAdb27eoYrnpMeBBxCxGKRYQRLUXAsTCxSzYnOa4usiQU5abSwqROkUIIhEAIhEAIhEAIhEAIhEAI9FcEqo7RbpLJEkZR4kpY6PQBLhZl%2FewyeQDY7MQKcf3SXLQkBCjN%2BEW01THElTCjuOhLiel9cZEgtBfcwcZkQnbr1k2aTcOapeKqzWqNQvXHKFfZoQ6koCf40DGkGihteHdUaUJNua%2BSH4MLx6%2FSMSg2EoSWNZhUQkWDuxcZFUzK0iyXmn%2BjYzRp%2FA%2FLrH7BIGSKkp%2B2ruTyyy8XP3LBBReoafpj%2BOoBESoiWKl4NdAEaq9mgQbCbYNEUPWB5lXldnUMGUEdACRmqjYWUSLLqKeGEFEPGSlXP%2FzwQ7IGcYP0Udv3SceoDRT4kAgqobNZgw8HIV9LvhdXbc6WAJZm35RDIARCIARCIARCIARCIARCoH8jUHQMUoNIfyc8UhJ8XnvtNb7rLCnnGjDbeUdY9lFHHcVOZ0bxmWcN3XjjjS6JNPGauCTzZOiVV7qUDQbXXnvtZQQZBtrVMXjjy1GgOwGEM4MBfeW9YAqWpjF5xXPJYNDxhGesPfzww9IOmLQFYIuO4Sr%2FEGemNHUMWQLk25Rp0615%2B8xnQzZOi%2F8NOob7Ei1SzozwapsFKo%2BH41RManAZMKTa8F7e7ZiIXewN%2FksvvSSKoQN%2FDGKIxAWO%2Fkyez5Yf98%2F4SiJjxROy5Fa1zXyY%2Bb6KEPFLmbFFx1Bjr7L0BYzwlGhXxzCmLcFTQsHvbseWj%2F1fb8GWaJvn05NCuDM47w6bnKQgTInMUp44USrKniCjGYpQ5itprino9UnH0Mau8yB7ZPhaUNtoJmUxQleMIwzKsBxCPH2eNUe1GqquNoUQCIEQCIEQCIEQCIEQCIEQ6G8J0DG8R2b1s%2BuZ5JJalI9DOhhBfNGZ7eXcVS%2BaJd7UUnYLmgAnBEeplqNMWEzcKlxixc8111zcG5T5PzC%2BStaI4hcBglfJGkw44YQlxp9JRceQX8KAU089tV6yZ5RkmF5Ml6NLRZfIv2F5AkYY%2By0kLUAvVmTTuBML4IQR9QQW7ekDNBZfzeuEUwkulE1azl115KvGPXr0KCOXQ1rpFeUr85N9qr0X7mrEzij7uFNDGURQST1FhRZkneV2XNXMXyaqSARnyDpOpeZSKIOXvzQQ96gxgaVZn%2FKfQYC1bhfRl2wJoSLcIRR8VVm2ELt%2BqaWWuuiii5qzk8s0Y%2B%2FT05r1ykV%2FkzGDPCL1hG3vU1Jb3H333bWxjXT00UcLPKmOEC5xHzKvYekMAlIIGtxCHEpiTFc5YBADyXeG5emhGb2lJYuF2%2BFfQWMh1tW5FESj7LPPPqbj1GTTeiLMVRrYb7yGHJJiWMoh9w9yZd3%2FzUFSDoEQCIEQCIEQCIEQCIEQCIH%2BkAAdg3nFVmJAMd%2FqxztcJx14S8t8e%2FLJJ8vKuVsIJKFmzDTTTISLpt3NVJcpgiPE9NNPL%2F9hly5dHGiiV3m7XZQQX0kKQvVlzmSjlTE5RRBSOnXqRN9wqWnpM8023HBDEgdnBstoN%2FLFW3Uvl03X1DGU1bDU6CRlll69etE6ZpllFqN5A06mYLo6EdUi9957b7O42dLSzQoiEEpQvjI%2FSyRCeZ1NltFR%2BgLEaBTuVMqO5ots98vedGn22WdnHprXOOWQCKJQzXVQBi9%2Fy%2BGb%2FAF4dDTrU%2F6TCLDoOcDwfCBi%2BNj%2FvlYzn1IhZ0tTgrAMXhZ%2BaAoDVaplVTQHaVhcEmalY%2FlI9anQFD1sJIltaQ68npojqPd0aE%2F68HCZhc5QG3jiuCRxgiK5CJhqO7u9x23j8MMPLzutduTaYd%2Ba7tJLL20b6qKGm4dhSRluv%2B2wdZwUQiAEQiAEQiAEQiAEQiAEQqB%2FI8B8Ywox%2FDv%2BNJfN7uaR3qyp5ZK3sGnX12FLmzKdBtVsLPXc%2B4vuUYeqBZf6NJ02Zfy2A9ZLdRwF49TYjdKx3n5dT8uC9ap8Srm25FvCCG2OX8susXzr17az1Esp%2FA8J2Ax2V8sCbIDyi7fU%2B9pufflxXWr7MVRzkI5HtjmbcSjNjvZS23XWBmXYui1LfVlVbdNuoeNh2%2B2SyhAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgb8JgZajEH7xrh3c0HGbX2zQ7N6n2Wt9LTR7pRwCHRNw1kzLeSJt29tazd1Vvrb9Wzq2rW%2F2bQ5eWtaadjv2qW%2FtlUIIhEAIhEAIhEAIhEAIhEAIhMCzzz67xRZbXH311S0obrrpps0333yzzTbbdNNNd9lll2OOOebll19uaeOrYxwvuOCCrbbaatVVV9XyrLPO%2BvLLL5vNHCt51VVXbbPNNqutttomm2xy6qmnfvrpp6UBo1Jfg%2Ffs2bPZRfmzzz7bb7%2F9dtppp7feeqt5qVevXttvv%2F1xxx33448%2FNutTDoGOCbz66qvnn3%2F%2BEUcc0blz52uvvfaLL75ot%2F0999zTpUuXRx55pF594YUXTj755FManzPOOOONN97QwKnBV1xxxUknnVQvnnjiiddcc03L5qSc3HzzzYb1rNVhH3rooWZHU5x99tnvv%2F9%2BbZBCCIRACIRACIRACIRACIRACIRAWwLe%2F%2B62224DDTTQ3HPPTTpoNjj88MPVDzLIICOOOOLQQw%2BtPMIIIxx44IGEi9rsww8%2FXGONNVwaZphhJp100pFGGkl5mWWWefvtt0sbksWGG26ocsghh5xkkklGGWUU5UUXXfS1117TgLlH3FBz%2F%2F331zFL4b333pt88sld2nrrrckd9erjjz8%2BxBBDzDvvvD%2F88EOtTCEEOiZANFh33XVXXnllgttGG2207LLLHnzwwZ988klLL1rHxhtvvMQSS1x00UX1EtHDll5yySUX%2F%2Bmz2GKLLb%2F88k888YQGtjdVrV5yfaGFFtp1111bNudTTz219tprL7XUUtSMOizpY%2Bmll659PRSUwFdeeaU2SCEEQiAEQiAEQiAEQiAEQiAEQqAtgd69e0833XTkgmGHHfbOO%2B9sNjj66KPV77XXXmyr7t27e5c9%2FfTTq%2FFGu3jmUyG4UqhZa621nnvuuc8%2F%2F7xHjx6sRTU777yzNj7777%2B%2Fryw4phydhDcFBw81PEB0F2mi%2FaCDDvrggw82p1b2YrpMR0K5%2Fvrr69Unn3ySrrLIIou0mIq1QQoh0ELAxttxxx3tUnIZNwxb69hjj11hhRVuu%2B22ZkvOFfb2iiuu6NIll1xSL3EZojBcdtllD%2F%2F8efTRR4vo98477%2FBZ8hQYuVy0kz0sdn7tzj1pzz33NOxKK6106623lnoNjjzySEsikpSOlBbayNdff107phACIRACIRACIRACIRACIRACIdCWwMUXXzz44IPPMMMMnBy8WW6msCg6xumnn157iSvhIzHuuOOy1FT6O%2BaYY5JBPvjgg9rm9ddfH2%2B88SaaaKJ3331XecIJJ%2BSG0QwbKY4WOqpkzf2ijkH0mH322RmMZYq2Ogb95Nxzz6WcMCeJLS1RLXVhDzzwwFFHHVXHqfUpDPAE7EN74%2Fjjj6%2BOPYQIwsKZZ57ZvHeyBu%2BgddZZx6WqY9iiIj7sUvu52biU%2BW%2B4JDyk7aVSw99JUNUqq6zCH6OpYwi22nfffWkgfQpv6dOAqQ%2BBEAiBEAiBEAiBEAiBEAiBvzMBL6C9ERbrwcbnEk9w4C9RgRQdgxFXaxQOO%2BwwwoIcF8p875WZY80G3CS23XZbcR8vvfTSddddp8EOO%2BzQzF5IKuF4P%2Becc1Ik1HegY0wzzTQjjzzyHHPMYRBdisbSomMIYOGcr8Hwww8%2F3HDDKTBC25qcfD9Yka56t95cbcp%2FBwJ%2Bfe4TTVeHW265RWzIpZdeWm%2F%2FzTff5Cm0xx57kMKEn1Qdg%2BDASWO99da7995777vvPnkzmqrd008%2FLa6KQiLcSWINmTRa3ITU6HvIIYdIqdHUMahtpBVeSdwwjKx7cfCo60khBEIgBEIgBEIgBEIgBEIgBEKgLQGxHoQChhhDT35CZr5Mg7VZuzqGEA%2F%2BG5QKzbg36CLMv3ZpKZQx%2Bc%2B31Nev5u1Ax5hyyimnmmoqHvvTTjut%2FBslt4A1l7gSfb1e50NiDaIGvBkX%2FyKZhq98M1ytsygQTHTn3t8UapoNUh7gCdgDop9oEXyQJMHYbrvt%2BGmUuyZWSGPrQXjmmWfuvvtugoM25RLBQWiVSBO6h78SZchVS3woV2kXdLNydbnlltPRODWN7VdffUXlo2PYdeJHNKtxJWJb7NXS0ciGtZ5mFtAB%2FufIDYZACIRACIRACIRACIRACITAbyBwwAEHyE1x4YUX6vviiy%2BOPfbYMhx%2B8803Zah2dYy77rpLwgpO8qzCkvvCi%2BY%2BTe1FNlWBZdenBr%2BoY0w22WScKxwJIU3oPPPM4511t27dio7BPYN2IciFa4fQkjKFzI2zzDKLqJboFX1i%2Fret5ykhSy21QZJP4oNTRSoKcoTQj7KT77jjjqaOYWvJCCo1qAAr%2FkWS32rJj6IEKBHZCHG8OK688squXbtKImpwHh0lP4YpDFW8PkSXNHUM0VVcjHiACIkicXiUXOWh0Tb1aF1kCiEQAiEQAiEQAiEQAiEQAiHwNyfgrfGss85Ku3j%2B%2Bee%2F%2FfZbEoEADRKBrIOFTLs6xu233z7UUEM5X1Ub3vJkimYCjRaksilqYJyW%2Bvq1b3QMkSOamdFQhJHiQyLPJ1ORyamS90UdkLrCV2TggQfmq18rUwgBBAhftrpktnQG%2Fhjrr7%2B%2BqBD14kS4Rjjht2gIGlTxwVXbTH2NVDKIoCoyiFOJXeXI4VzgKv2Jpdpggw3sQE%2BT9C%2FKlJNy1bnGlAqPj14%2BxnHWTw1RcQbQQQcdRCGpnh6lWf6GQAiEQAiEQAiEQAiEQAiEQAhUArIa8qwQJDLXXHMtuOCCCyywQDk1lZMGNUCzdnUMqRGpBPvtt58GUme0lSnYfXICEENK%2Bk0NSuM6r8GZe%2By1jz%2F%2BWGBIx3El%2FDEkLtDXSSjCTGQH3WeffaTCcEilidiSxt97773r4Aq77767SgkQmpUph0CTAD8KSTA6d%2B4sRcw555zDicLpwxwqfHhf0BPEkkiRUeWLZl85Qgkd%2FCialaVM1rA%2FHTTsEfD4GNbmF6JiWPVmJFbw3KiBJ80ROHsIMOGb0axMOQRCIARCIARCIARCIARCIARCoBAgAnhrLKjE2aa8Mjp16iQcQ4E%2Fxswzz%2FzRRx9pVnSMZvoLb5adoCrEoxxYKcbEKSess2ZuQ%2FKFQI%2BJJ57Y%2B%2BjHHnvMca4yiEoUUMkbZKGFFhpnnHFkA6Bp9KWOobv4l8EGG4zwQqYQ%2F%2BIWqCW%2Byj%2Fg7XYZnzAic6kVOsKyzpjC35yAICPbmPdO5cCJgj8GkUF8h5gOygPfCdFSDitZffXVnbLqo8aJPFw46HXyZtS%2BUn3SMexGGojDWGkUNYNo0TFKAg1BIoY1SxnWSSjls%2BWWW77xxhseDcMKjKrDlgQaN9xwQ61JIQRCIARCIARCIARCIARCIARCoBKQ4VASCeeZeuNMTKAJ%2BCt8g5HF3aL4zMtrQTHo0qULccAl8R3C%2BdUwx4rhxnmeRkFbYNMZweAG8XabPCJEhas8yYIHvgGbR14y%2FagfPEBkUNSr73UMAzIJLcCHjmFVHP4djDLqqKNWb3zv2WUuXXjhha2t3mwpmO61115zIy31%2BTrAE5AAVg5PyWC5AJWbLck8KQmUB48Anx%2FKRvnQJcgULvlKqZCPxR6WFkPslb7ay16rxgh2r1S3GtdQLKIHVY2CIWakd%2B%2FezWGFX%2BnliB%2F1dD8ZOXhrCFEpG5LQR07xZAmbGuB%2FjtxgCIRACIRACIRACIRACIRACPwGAgwoakDLkanGEY4xyCCDeInMvCr%2BGCI7pEZccsklRXPoQjdgFdYZWXNiPQgXTDABHXQGssYYY4zBTCttmHgTTDCBjow46RCNLL2Gk17L4SPFfcJVB7%2FWMUvBgQ6TTjopsaXElZRK5004HFZ7cSXFCYQqwiXDFGYXGiDtJ5GE637LaCaSA8GlMm%2FL1XwdsAkQH4SQ0A0cWGPDOJSH3wUBrellUQnIjyG%2Bo567SouTlUWNTBeXX365w3eU7eQilHH7oZDQLs466ywCiPyf9rkIkTpaLcjzaQE1P4bQEttVYwqJYQVzuXrooYearnZJIQRCIARCIARCIARCIARCIARCoBDg2MDvYooppnj88cdbmIgoWWyxxeaff36uC1JhCA8Zf%2Fzx5QL1V7yJvJ1eNLd0IUFQFUYbbTTRHFwj5Nl48MEHm23MIsPh6KOPrgFnCek4qB%2BlAbVkhx12IJW0XYmJuFUYzfvr5mjnnXeeVTFCi47hnbh33NNNN50AFp8ZZ5yRBaqy2UWZjsH2pIFUQ7KlQb4O2ASEO%2FGIkC1W2AgRg8sECYL7UNu7djArv6NmngpbkUORI0v0JcRJb1sPbLXTZJS1h0UzETScV%2BKMknZ9fvg4mb3qe%2BY1yGGHHSZ6xbAiUExBu2u7ntSEQAiEQAiEQAiEQAiEQAiEQAiws1hMrDPWfVsaXjRLGuC9sEAMzcrH6%2BO24kDtaxxeE95uM83abcZg5KWvAXmkOanGRrYSDvx1tFLQjKjiQIcWq1C9ShElzYnEucgd6tPB62xTCCto9mqZMV8HeAI2m5QUtiIPjT7drPgR%2B6TtRrLr9BWEUpOx1BEIg1JeSAjjkamVLQVb1LAlOKVe8lx41gxr8FqZQgiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAj8tQg4EMT5I4516OBIiL%2FWHWW1IRACIRACIRACIRACIRACIRACIRAC%2F0MCDo686667zjrrrLN%2F%2Bpzz06eUVXbt2tXRq795ec6gXGGFFTp16uQ0yb4fxDmqF1100bXXXtuifjjI1cIefPBBJ1T2%2FWhpGQJNAjbPs88%2Be%2Butt95222233377Y4899sorr%2Fzwww%2FNNs4Xvu%2B%2B%2B15%2B%2BeXmTvv8888fffTR66%2B%2F%2Frrrrnv44Yft0tpF96effvqRRx5xQqvRbrnlFoOX8U1Ryv7ecccdNL3aS0tXHShcaxQcJWwWLZvjNxsoW9Vzzz3Xp1nuvPNOfd2CQcrUCm7T7dQTjT2YDzzwwL333ts8UtY%2FBU899ZQu9eBXNXpZ5JVXXmmQ%2BhS%2F%2FfbbBWAZv3mPanr16tVcMCVTAyO3PaO22UzZA%2B6mXnjhhSb2ljbNr%2B4C9ptuuskv8tBDD3VArNkr5RAIgRAIgRAIgRAIgRAIgb86gR9%2F%2FHGdddYZqA%2BfkUYaqUePHr%2F5Hhkas88%2B%2BxhjjMEa6vtBXnrppZFHHnnIIYe8%2FPLLm70YLJa51VZbNStTDoFfRYCNfPzxxy%2B77LIUthVXXHG55ZZT2HfffZ9%2F%2Fvk6Dhtf%2Fcknn1wNasb1TjvttMwyy6y88sqrrLKKwjbbbMM2L12%2B%2FPLLXXfddb311uvduzcJbqmlljJm%2BZiiFJZffvlVV12VAFK6eDQOOOAA47RsclLD3nvvraWnoK6nbeHEE0%2FU9%2BdJ%2FnsjpWzZa6yxRvfu3Z944onVVlvNpKW%2B3O%2F%2B%2B%2B9ftAgm%2F7bbbrvRRhsRGerg%2Fik47LDDtH%2FyySdVkjjOOOMMN6vGegy1%2BuqrX3zxxUQbymcZsAxeZ%2FfVAiiQdUyFZ555xiBHHHFEi1jUbFPKHnDoTNr2Utua1157za9mVSuttJLxraf5i7Rtn5oQCIEQCIEQCIEQCIEQCIEBhoCXpF7Lnn%2F%2B%2BeyvM888c%2FLJJx9qqKFYWDwxLrzwwiuuuMJr6N98syyjueaaa%2Byxx%2F5VOobGY401Fsli2mmnfeutt%2Brs3tWq3G677WpNCiHwawmQJogATO8TTjiB64JX%2BUxs6sSGG25YpQwv99WcdtppRcegTrD62csnnXQSB4Bu3bqdcsopbOett96aZ4IF0DH23HNPI2hJ9%2BOTUFwUdtttt9KruEaY7r333isLJlOsv%2F76zHAdv%2Frqq3oXdIz99ttvzTXX7OCRsSpLLbPwi9hll13oDFZbZiEyfPrpp48%2F%2FjjZYffdd9dA%2FVVXXVWs%2Fj322MNqOVntuOOOm222WdMbxNN65JFHGqroMzfccANKO%2B%2B889133%2F3iiy8aZPvtt1fjMXSbbtBH5VFHHaXy0EMPLTVWRWGot6PA%2B4W0cvTRR%2F%2BijmFkQPhcNbu3W3aD2GpsdnfKO4X6QbfxizQ9Xtrtm8oQCIEQCIEQCIEQCIEQCIEBiYB0FkssscSYY47ZtKGYDF4iq%2FFe9eqrry5GiogPlYyjG2%2B8sa3DBhvtmmuu8XZVrznmmKNFx3jjjTeYSBpUs7GFoV5Fx6Ba7LDDDjztS4O2OgYrjCXF6b340pdm2nsfzd78%2BOOPRQe4SqgpHvU9e%2FZkt5ra6%2FWWSZlmN998sxt0mzi0XPWVlce%2Ba%2FGZb9ssNf05ASIABYPpLQykLtVPzwrmrvD111%2BrbOoY2pP4uBmwr4usUXqdfvrpSy65JLnPV0JE1THqmAq0BToGQ7tZWcpEQlLJuuuuyxuq%2BD%2BU%2Br7RMVpGo64QH8gFzXqBJJSWU089tVa6NVKGSjv8iy%2B%2B6EDH8OzY7QcffDD9gYJRRzCFNVM2OJPUSsIFNwzPVK1pKfwZOoZ%2FW0zapUsX66zTiYOjbLR4g9SrKYRACIRACIRACIRACIRACAyQBL799ttFF11UGEjTJqJUDD744KOPPjo%2FDcKCN9F8JJiByuUjBuTcc88tJh4FgHEhGqVc0mu44YabcMIJizDi6nnnnUfWKFeHHnroQw45pO1bWo1pKaOMMoqVmJR8UWi36Bi%2BTj311P%2B3iIEGmnHGGYvByKhkYA4zzDDTTDNNvcrq4WQy2mijlZoRRhjBmuuPeMkll1hkuTTEEEOIIGjmDSjNxBoMNthg888%2FPxuwdkzhL0fARi06hh%2B0Lt4m5JVByuBrobLqGMp2Ah8GakOLXkfREnhCADFguzqGeg3oGDWWpE5HYSPQEQSocLQCakPNHfFrdQwd3U51oqhTFB3DApqiHCnGg3D%2F%2Fff3pY4BSFOEcZvSg%2FgHwT8UdaIiKVALa01L4Q%2FXMfxY%2Ft3gbcINozmX344fS%2BfOnZviRrNByiEQAiEQAiEQAiEQAiEQAgMegXZ1DHYKMWGQQQaR6WKLLbbwdZNNNmHyc0rnss5Fnzgw3njjFSuPiUR%2FIF%2FwM2faeIs96KCDTjTRREXH8OqWxDHppJPSEEgHs846q5H5ZrSQ1NggFBVxLlQFkSnvvPOONk0dQ0pA4xBJmJ8c2lmFVuhtLDPQe2epA6xwpplmYrj5FLnDUGuttRZ%2FDK%2Fdhx9%2B%2BOmnn74My2glxbgFEo01zzPPPPoKtGlZlRfTCy%2B8sLfYzZfRLW3ytf8n0K6OYdl%2Beja%2B7aHc1DFkkNhyyy3FlRAf%2BnR3zbiS2qYDHcNjQt%2Fg5kEZIJJ4rGpqzT9Wx2j6Y9AuZN4gTTD%2Ff1HHcBckC3Klx5zbFR%2BqqrTUGyyFfq9jiHQTXOZHaQkh4YjFecxvgXzLIvM1BEIgBEIgBEIgBEIgBEJgQCXQJx2DFuGFb7HfmQn77LPPXnvtVcM9GGIcFbylhcUrZiJAdXVgLnGTGGeccagc3qJ6W8pNwoEjBSD%2F9mGHHVaWgJb3p0XHWHzxxWUjFPBuQNPpQjZRLvkx2GIsLM75ZSj24AwzzEAw4SviLmQLJE1wjy9XGWJUDqIKI7HUeL3uprwoZ%2FJIz8gzhBhSLlnquOOOSxJpq1foXu%2B6NM7fvxyBPukYdhcdo%2BhXTR1DBgzCHRedkiiGe4a9J8LI7vJX2kwD%2Fip%2FDLtdsgjPQomrkueTpiEwqpD8A3UMHgtSZ5AjSA2XXXaZ59QjwJPBxu6b%2FBhW4vniLiLWTPyLmBQ6pIer5Rfv9zqGaDLKj2QdzeQeLavK1xAIgRAIgRAIgRAIgRAIgb8JgT7pGAMPPDCDqAmB%2BXbMMcd4Sb355ps7VpUmwByTNIPHAjFBGorSWM3cc88tkER7L08FenDVcLLDgT99jElAmG%2B%2B%2BbxFbQ5e%2FTHYjMXvQhiIKAC2npXUPJ8c5iW%2B4FzhBBMnLxjZ1CZyF%2BJKyqRlWM0oLSSR8pXhaeUkEdkzGKfcNozP0aKsSmpEwsvEE0%2Fs5MrmqlIeMAh0rGNccMEFbrOpY0icsummm1Ydg1C28cYbUzxkivDXAR%2F24a%2FSMfg22K6kuaKqiU%2BhpB1%2B%2BOHl6x%2BoY%2FA%2BopBYJLcKf5Wl8Sy72hPXQX6Mkuez%2FNxSzXDqIBrQQIh7npHqOlIaRMcYMJ6L3EUIhEAIhEAIhEAIhEAI%2FEUJdKBjsOPKTTEDHWLC0qcM0A2mnHJKAoKQDW75rDmaBlmgxGto781vOa%2BEslH8HPhF0C7Kh2%2FGqKOOyu%2BixTKqOgZ3DoMwLY2%2F0EILCfavOgZzz5KsQWjJZJNNVo5ZmWSSSaqOIVPoK6%2B8UtbswAUt64GtDE9HNNIxuPcXdcWwPy9qaKsS1cKNpBxFUUbI3wGGQJ90DB4LjP2SJbKpY3jpb%2Bf4lF3Kk0EyWMeP8qmgD%2FwGHUN3ggBVTV7Kc845R2QWbwdeSeWg1T9QxxBCQi2555576HWSdlot34zyO%2FJ06ksdo7T3aBM3OGJRM6QVbXol9Xsdo8SVcMmo%2F86URXJ0oc%2F4R8NPPMBs19xICIRACIRACIRACIRACIRAxwT6RsdgO7DxKQaOd2Td0AQcfSgRaPXHcKke6sEoq%2F4YXkNzcphuuulEczguxHtef0kWGgs5aS6sRcfg1OGFONmhHGIiFYbGogBMyh5k%2FZnFSuacc07xIL9Wx2Clyp4x%2Fvjjs%2FXKqizMAggvhm2uKuUBg0C7OgbPH2lgxVCIdXKbTR3DNnCpbSJN%2B0Tghjwwv8ofw0SSVPDlWHvttYWWmNHf4jghXYap7fZfPHe1%2BUN0nOez5sewnzmByClR1DlqDG8oj5XsH3U06oTboX64tTfffJPzBsnF3dUGfFGE2HCIIoPUyn6vY%2Fjnwjr9IjVwrCxGQlHqEGmoKbPUdaYQAiEQAiEQAiEQAiEQAiEwQBLoGx2D7SAKg7FTCfCLEFfidbYaIgPBwdvqctW70ZlnnrnkxzC4QHsJMZrnRDAbm5ZU6dWiY6jkWcHxw8g%2BRcdgoCk7%2B7J08a582mmnJUf8Kh2DdsHkkRTUqsT%2Bl6H8lQCByVa%2F1kLe81YUf91C1THqMSLs4ksvvZS7AqcFu9StVR2j%2FOJyv1DMhH6UU1nLvXsQmNLSTfwqHUNODNoFM1y4ik0rysP%2B9xRsuOGGHCQ8LxZTdIwanNUx6o51jHpeiUU6Kog3hbS3bsosZApnsLrTOj6JQ5iYtDNWRXUksPB5KHEopY0gL%2Bv0AJZUIaWyL3WMY489tk%2BPT62XyBfnml2nLqwUmoqKY2K0NCbZp151yq1nufqcNNu3DJWvIRACIRACIRACIRACIRACAwyBvtEx%2BGPIqCmagwXHB8P72SGHHJKOUbQLyoCjQJxhyvnc0Q%2FSaZbwE9IESs4oESEiBoRJJWfFQQcdNOKIIzLfWFVNhm11DFdZNxwwqo7B5DGyhBvONJGb0XGoLolzEb3iLuTH6Ju4Ei73Rmb4CCrhy8EOsn72nVVx9W%2FJ80khkT7Uy%2FRqOjXXnPJfhQCrWShHCQkhvtmKe%2B65p4gSvgo1EKlFxyAvUB6IAAIrnJTqKFLKBn%2BGZZddllDgxrkDGYSNT52oHEzkqqwUVTBhWfMWMHXN6lka0yI4NXHSkAJXcISULxSG448%2FnrpSPh4ux%2FqYpQ5eC32pY2hvbXQJuTg8I74%2B8sgj%2FEmsWZiYO%2BLgJElvkRGsk74n7MUtqzT1E0884cZlttHALTQlgr7RMTifCMyRNfTnG7rUHRGCeKc4pllsTjkLxkPth6CL%2BoeitlTmsvXaa6%2BBr9L9WjwhxZNoMY4r8sz6vfymoPnHpCT%2FdEdWzmesgkohBEIgBEIgBEIgBEIgBEJggCTQNzqGG5cKQwYJuoEPBUBCDPkl%2BN67xMBhmJAyylVHsjpolaxRdAw22gknnCCfRrnq72KLLVYuNXm2q2Nw7y%2FRJVIOakxk2G233WTbKENN8NPHvFJhuPSrdAyWESVEpo66qjnmmOPJJ59sLkmZ8EKuWWCBBVi1LZfy9S9EoOgY5AW6gb8%2BHCRsWpEU9S5adAz1Iim04YBBhSgfvQhfJUVtX567KhmLxCwtTg5lUruLGd65c2dD0TGsrS7PChnsMtO29VzSt%2B91DI0d3EMocCOUQx1pF3QMNe7IX3dE1ak%2BJwpS04h5%2BfmOV6R7uOWW%2Fd83OgbxpHk75Y4MTr6gb7hanE%2FoGC7h4G%2F9WBg4jz32GAiyjNZoL4IqEUOzsjwFUhKxsfB0p1SmM844ozp7lPr8DYEQCIEQCIEQCIEQCIEQGMAI0Bm8x%2FQSU%2Fh8vTXGlzNJ5Y5oWgSyUni7yurhJ%2B89rwaC0%2BtbWmXuE127dmVWEARoC6yzOqBK71iZGOrbfcXMUHKJ5dLip%2FHee%2B81V2K17E1vjbmFcIm3ErEhCl4luwu%2BFnVSDvw6ivovt%2BCvxmqahiFLyotpgSreOzdvvy6bAWU6N97kUK%2Bm8Fch4Ofzft%2B2lLjSX7vC9mj5TdnXvAWEUTTr7Ssd7UwOPHaCnVY3vK1oY9j2JSyloNCXF4EpirOBSqKHr2S62rFCIxp069bNtuSioEFZnhWWj68uVRO%2B9lIwi62rWVFU6iXCi14W0LwFj5uWhqpPlnvnLuKOaAWUnGbjMhS5gJeIBm4ckLYr91Ty1mg6otQ1lIKnCcz%2Fu5Of%2F8fahG4Zzb8G0GGrsRiWlhsvzT2n7h1ei2kOzjNKXw8yLcVNNR2o%2FBuCZzOPR7NjyiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAv2egDMfHVXgb7%2BfOjOGQP9MwIkhngvnd7Q9OuQPWXYZvO2xI20H19LnT1pG2%2BlSEwIhEAIhEAIhEAIhEAIhEAJ9T4BR43xGxxp%2B%2Fvnnfd%2Fr97S87LLLlllmGQcmtgxSDlK0kpaP0xVdammcryHwGwgwzB0V6rhPR3O27e5kUpeaJ3i2bdNujf2pYz3qt902fVPpgFFHCR977LHNQ4FLR4%2BnKRxO2lZbIDi88cYbLnU8BXnkqquuOuyww5yg2ralYR2y7DhUBSfJXnrppUceeaQaLcvUf8gz6IRZd%2BGYVBOZpe0yUhMCIRACIRACIRACIRACIRACv0iANbHJJpuMMsoo99xzzy82%2FkMaHHrooQMNNNC5557bMhrbba655hpuuOGGHHLIIRqfGWaY4RdttJah8jUE2iXASD%2F11FPXXHPNhx56qG2Drl27rrrqqm0VtrYtW2ruvffe1VZbzZY2vkuffvpp7969v%2F3225Zmv%2FhVl1122WW99dZ7%2B%2B23Wxp7PNdYYw0qR0u9r7169dpwww333Xffb775pu3VWkPHOOaYY9xg9%2B7da2UtfPbZZ6becsstrZww4iF1RxRODU4%2F%2FXTl%2B%2B%2B%2FX%2FmHH34gbnzwwQfa1L59U%2FDvzC233LL11luvvvrq1rDWWmsdcMABPXv27Ju%2BaRMCIRACIRACIRACIRACIRACTQLsi3XWWWewwQa76667mvV%2FXtl7XjrGBRdc0DIF42i66aYbfPDB2WvsqS1%2B%2Bmy%2B%2Beb777%2F%2FJ5980tI4X0PgNxCgM3Tp0mWFFVZ44IEH2na%2F4447tttuu6effrrtpY5rOBhsv%2F32N998c2l24YUXbrrppt26deu4V9urdIw99thj4403Jia0XPV4Lr%2F88kSYlnpfqXx77723S0SGtldrDR3j%2BOOPJyC88MILtbIWTE3l8KwRNLT0kPpnoegY1113HSxF%2FTDXTjvtdNRRR%2F0qlYbTF1eQFVdccf311z%2FzzDOvvvpqbiG%2Bbrvttm%2B%2B%2BWZdQwohEAIhEAIhEAIhEAIhEAIh0DcE6Bjrrrsu94cOdAx2jZew3hG3NZTUqOerz1RR5pmvcZ2XRzo7he1T3lOX%2Bg50jKmnnnqSSSZp%2BzK6Dmi1TDwNeODXSq%2BG%2BaszrMrVjz76SMFKeMJbldk5sdf23llbEs2kuaQ6lELp26xJeYAh4Ec%2F4YQTWNDt6hg2cLHi3a%2ByHWX%2F2Dl2y8cff1w3jDY2vC1XsdjzKnVRsMFOPPHElVdemQODvkYozXQnxxmqbYCGGvW8OIy555579knHIL%2BcdtppddJaMIWQlhZnDDUWacy6bGsrOoZALbfmajO4RjOz66XgRpo6huV5kHVXLyqE78dee%2B3Fe8rdGUcYTr1HS9LYSjxEdXkKpRdNsrqCaHD22Wcvt9xy5513Xl2hlpbUsrAypok0M5fHualqukE1rtbp6g9XGmtQL9WC9nr5h8JN1craUaW7c7X4nLi7cpvNdbp3t9nsXsdJIQRCIARCIARCIARCIARC4M8mwKDoWMd48sknl1hiiZFGGmnEEUecZ555vLOuS%2BKcv9BCC4088siuMkl8ZpttthdffFEDBpF3u%2BOOO644kVFHHZXlyHoqHX9Rx%2BjTK9q7775b4Im5RhhhhE6dOl1yySXFgGIDLrLIIjPOOONiiy022mij8ePwKnz22We3HvNqb4VzzjnnjTfeyPN%2FyimnHH744ccYY4yNNtqobRYClstuu%2B028cQTP%2Fjgg%2FU2UxhgCDBFO9AxrrnmGpvnsccec7833HCDMueNHXfc0QPCeCcC2NuUBE4FQj%2B22morDhjF1H3kkUc01sXG40q09tprC8TQa7%2F99it2tPwVIjVKR54bVJRq%2B%2Btiyxlwgw022GeffWxLvhzt%2BmP0Sccg0%2B2www6WyhK3cooKiUCwWFn20UcfXdJcMLq1EdNhJVbrqokuvvjiIgIQEA488EDBKRQbLYuOUTJpXHTRRW7WvwMCTKxTYAinDquVQ4NiI1SEO0rZIQY56KCDdt55Z0tq7hmPKmcSiXGalcQNlHh6kBzVm%2FeUU06h4ViYv2eccUaVIC6%2F%2FHJU8YfOVYi09Fu4nfpb3HnnnQXp9ddf7%2B4szMiFwMknn0yHKVNDxL3E7bjkXvixyMZTLgl7obT4ff0KrrpBoKAjWaDhNsu%2FbBoTUoDiOUNyKX3zNwRCIARCIARCIARCIARCoF8S6FjHeOqppyaaaCKxHswi%2FyefAjDeeOMVx%2FsePXpMM800IkSoHEwGtr%2Fy2GOP%2Fdxzz7GDWH%2B%2Bzj333IwCeoLyoosuWqyJX9Qx2vXHICwYXOoMNg53dHqFWBgWFlZsjbKSQQYZhDsHv%2FdHH31UY5NOMcUUbB%2F6hjJFhduJZbCzJptsMjVsrmpOFubMHFbSMMMMU2ME%2BuVvkbn%2BbAId6xi2E63gvvvuswxGujK3CloEm9rmX2mlldjvoi1OOumk4447TpINekVxMJAfQ2PW%2BiuvvGJ7b7bZZqussop9qCMj%2FZ133rFj1ZAUmMnMZ33LQyRwg9Ft5IMPPlhgiKdMWfdfpWMYxEqkm7B7Gd1mkUeXhS68xeKXXXZZS%2BKcQHKh4dATSBmHHHIIKYA4Y9lXXHGF%2B%2FVseorJBcS9Fh1DS6siWl577bU8MSzeLZgOKDXGrzFiNBnShMEto%2F6URjv88MPVU0JqpQLPDf%2B8%2BChwBdHLPxTEgfPPP3%2FXXXdVPuKII4qTCfnROi3bA0tLIb%2FQJ8tvQaNwvwYnO%2FgXybB%2BBUuyYPeOth%2FOUCXoxq8vpMXXbbbZhh%2BIfKqaKRedp1wykX8x%2FHD%2BBdOSVOKfiDKmLVHW%2F8QTT%2BhoeS1uJ827SzkEQiAEQiAEQiAEQiAEQuDPI9CBjsEAYR2w%2FeuLVIkQaQVc3%2F1%2FexHupAC2DzPE8hgR00477VhjjcWy47O99NJLM6bKG1VT0Dq4czz%2B%2BONadqBjTD%2F99NQGSgKpgR3hw6mDTWcKVgzhwpvZgkLaQz4VM888M7PLdHQM43sVq6XpnnnmmTHHHJOHRnHtYA0xSayWOcmgMwJLhJcIZ5JmdEAZmT%2B5BALl1Xapyd8BhkDHOobtXeJB3K89z6Tl2FB2gi3B6pe55eGHHy40WLU0AS4cvrLodeTboGyKkhjTblf2pBiE2Wtzlo7quTR07tzZ88VmNwgzuVjEFAl2NzXj1%2BoY%2FAfY%2BAakAfLukL%2BiKAD%2B7r777pwWOIRYCavcTUlVUdxIPKpuiqcBFw4OVJw6lNvqGNQAd1dux8Isj0RAn3F3hvVPhClKiArlxPg33XRTudPyl6bBe4HSUj2ymldLGUCIKBLFN8NopA%2BTFrcomoNh%2FTpl2Z5uo7nlIowUwjCWef0uHnZrLj%2BcFXrqtfcPlGefdkEqqe4ixTmnKFd%2BSh2pE%2BVedHSn%2Fn3zVV8j0HAKVb8aIYUHSNsbSU0IhEAIhEAIhEAIhEAIhEA%2FINCBjsFm4c8gNoThI6bDx4tXJ5ssueSSnMAJC%2FwWalJENhTrjHrAH8OYLCMmlRQB7EGvueedd96hhx66nATRgY5BeaA20CvqcSXcPJiQXND5gSy88MI1EJ4NxeIjqngVzvKyTn2r9zhLh8rhja1VFYZMLV4lXrmWr6SPmWaaiQzSjLXvB7Qzxf%2BWgG3TQVxJi47BVrW7yoIpcsxhn7ph7G0NdNGgqWP46m0%2BDwFamTIruBxBwuz1aPgIKuFx4aW%2F54sgQGSoqgWT%2Fzfkx6B%2BFB2jiHgUCQ4GJrJ4UWB8G8gvHiL3XvNjlJsiC5BTPLYvv%2FwyQe8XdQy9%2BJaw7qmLRQCkFTD83azoDIsnEXCXaokLox5wyuLHYpYyb8tfDykXF4PwzaiXyCb%2BhSFHWDYdA%2Bp6xIw4Ms%2B%2B1RbRQ5fbbrtNgyuvvFKZjqHcDH8rbjZqKB66%2BOeCokIhoYVSSygkIs50pGM0RRiShR%2BOt4z2wHIX8e8Jfxv%2FBKkXmQJFXW0KIRACIRACIRACIRACIRAC%2FZJABzqGsHciBmGh5UOUcNTjggsuSD2obzb9X32e80XHsH6RHVJSNDuKSflFHYNHhxG8ueagTiHxYR8xHJh%2BxA22A0ukwmFZGJ%2FZUnQMx7NWG7PoGAy06lbBmqONCI0v3Rl60TEqyb9P4dfqGDX5ra1FxPB2viZtoF30jY4hhYLIBa4F%2FA2YyT56%2BTCQ5VtgjBu26m%2B2epECqrJRfxor0ZddX2tqoeoYZbfTTzgymcLzSBygBjDAmzqGcu1LIrA2jg2%2FTccwDn3GrVE42fjECq4gVTwss%2FgXRhCKaJSaRqPOruAXIXR4lnmGNAUQqTkoPEQS%2F7AU%2F4eampUI2aJj3Hrrre63qWNQNuos5WrxnPHvlTQgFlzg4KPc1DFKWV9Aio5BNvFV9owyBXp4irUpziF1lhRCIARCIARCIARCIARCIAT6GYEOdAxu57JMTDjhhKync37%2BiFWnBrDOJJ0YZ5xxCBplqcwNFhMVQi%2FCQknI6W0vLYKB4%2F%2F588coxkUH%2FhjOK5l00klLuHqTAFWEDMKuaZpI3mXTMfiK9L2OIZy%2FDBsdo4n371Pu9zoGo1uwBiOdlc0WlnfFR4EMwmGpvNmv2sjv1DE8HTa2HBf0BGKgZ5MyIPMGQa%2BpY9R8lX73s846i0VPNvzNOga3BKoC5UQOCpIIr62W7YS5rJtEmJZLfLp08e%2BJgn8T5Nzo2bNn7csFi5OJSBP%2FQP1OHYM0QYKQhZVgwgGDcGFSLhlW7vYtrG90DD8WVxMqk3uhfsjsWpeaQgiEQAiEQAiEQAiEQAiEQD8mUHWM%2BrqzLsCLSMKCyI5mWv7iT%2B5dpP9XP%2FDAA7PISns10uvRMVhJPMAl5OQ%2BUYeSmm%2BooYbqGx1Dok6R6bVjKXiLKgPG5JNPXt6NqmSSmK4EtkTHaMGVr30iUHWMdu3QtnElv8cfg4JnGTVDRTn7oyzM7uVZ5NHjbyDoowoLQjNkt%2FBk9ckf48wzz2x7a9UfwzMofQc7nT5Qm5EImjqGspCTugyZK4ge5A4BF30fV8K%2Fwi2UQUwq7QYFg4xZc2bW2UtBBIdVWQm1s17yr4SkFjwxVHLn0KAZDMKJQk1JOVLiSuo%2FUL%2Foj0GaaPpjUB4MRQtFVe4daS6suSxDZhJr6Bsdg0ZElTWO2%2BRCQ3upN5JCCIRACIRACIRACIRACIRAPyZQdIxBBx3UEQyMO3aQDxOjuJp7zcrngaUj052UF6wDR68ec8wx7EEBHTJOLLDAAuw1bzblBJBpk4cGo0xYhzQas8wyiwGZb9Iejj766H2TH4Ns0q6OwYjwJtRKmFo0DcIF53BfvUpmT5FZRLj0TVxJ3%2FhjMF29tG1KN%2F34F8l0fx6BqmPISMlxqHzEO3g7zxeC1cwel%2FjCAuR18RL%2Ft%2BkY7G4GL48IG9XzxRODsWzH8jcgA5pUthaeAHZ1CXngMsF3QmOeTuIvOjh3VTJPq%2F2%2FdXfvrizgpalj8KwgjEiTKxkFs11CGA%2BvMYs%2FBsFBKkvTWQlJ0CNsnUQJSkLf5PmERS%2FJPTyGnKz8g4CnSrkmuChQD6S5cFNtfz6BM1Jk4CnlKc1EKl3r5ItCEyg5NkuwBn3APx0QuUoSqalBf62O4R7dFDmUOOMX5AxmZHh9%2BMaQMmgaHnBTi%2B7pSx3DTck3YsFuk39aM8Ct7f2mJgRCIARCIARCIARCIARC4E8lwM5i%2BNAEfPhXlIK%2FTiZlH7E4vMD1leeDo04VKBUMNPaLF838ydXIOzHCCCMo%2BMjGybYyJkvHV0ktnD%2BioLu%2FbDf3cuihhypz7W65L69Z%2BX4Yv%2FnmurZxlcWhowCTkUceWWG22WZj0GlARZlggglIGfUlKWlFM3ZTzY9R1iMIpQyoy1RTTUU2qV1KvRfijlkxeDmMss6ewoBBwL71Vt1hOvYGEax82PJcICgJ%2FDE4%2BTBX3SxNQ7PqIWCfsHmFbNQcLHwMNChHjirrSBsplCiBRiaJkAEFelBIuAQwrj1oJnJJQa4YtjCbnSpoy5EahFHoYmFOABHF0ALcSjRjRP%2Ffon%2F6H43VM9g9pDwr7HZzOZLDYkxBB3BrCnoRZ9w7rdIgjHGVorQ0sx4paMxFamDj0ygoFbQIDylpwr8ALlEntCweLB4QPiS%2BmtEjbEwNMCFKaO%2FGW5ZdvxqKq4nZrcrU7tQd1INajENjtDCfgshoVJ0yvn83oC6KhwFJELQIqyVTlPGF6mhQjlUizrhfH0gNBZFC%2BU0BJ%2ByUn77QdsmS6CTGITcttdRSNYWOn0YuViP4l6fM4h9DNRb22GOPlZr8DYEQCIEQCIEQCIEQCIEQ%2BJ8QYLM4zNTrSx%2FHEJSP1BNMHv%2B%2F3ZIYC4w1VoMAdk7g3pzWdTKanAXg%2FAIhJGyNWWeddfzxx%2FdqWwNWAB94b5ZZRtrw3DYym4thwqTyRrh5NkEZ0Bth5h4zs5qKdaJSYGrJ0sGcZMU4eaH63qv3ovnkk082aWnpEid2VlJ5O2xSlo532UX30EYX7SXrq11KRy35nLDU6uClPn8HDAIsWc4DNiSZgmpRPr6yYe12zgDKBA03yyVAmRtSuXH7hIVLwSPflRouDRqUbayLMvWs2N30BDKFJ0hih3KIJ4cH2957fAkftGye3OH5sjlte7uRqe7wUIuxP1uAyxdhtc1l%2B2oofhHSa3iE5dukH%2BrlORLtZUA7XCxGuV%2BxJOXeCXQSgTL56SeWZ9gykWdZBgligvVoaSValieRgmGumoSTBkjBcCNGLn2JG7xN%2FBPRogq23IJ1erjcpr40H4JnwVWaKVNUSAquWpgF16sW7E6LqKKxGyRH%2BCfFvKUvnhoUQYaOQangA6aNe%2BQn5metQ0Fk2cjggxKvM0j9NG7ZPw5WVf99M7jfRXbQqpZAxJFGrleOKC23lq8hEAIhEAIhEAIhEAIhEAJ%2FCQLe2zqboJ5XwioRPCLqpGNb5i9xa1lkCIRA3xOgAPCykJSDGtD3vf6klkXHqAFBf%2BAsdCr%2BG%2BTZqor8gYNnqBAIgRAIgRAIgRAIgRAIgX5AwOkDYkbGGGMMzhj87WX4HGSQQcS%2F5%2F%2Fk9wP4mSIE%2BgcC%2FFV4PvC5koOipg%2F93y6sxJU083z%2B%2FvUQaqg0vMtEoxR%2Fs98%2FZkYIgRAIgRAIgRAIgRAIgRDo9wR4aPNCn2%2B%2B%2BWTV8HE8K2%2Fzmo%2Bi368nM4ZACPRjAsJPZLSQSlRMSj0EpB%2BvoWU6ITaWVBObtFz9bV%2BlY5XQg4gheKd%2F8Dn5bXeRXiEQAiEQAiEQAiEQAiEQAoWAKHJx63IFJGY8WyIE%2Fm4EpA2RoKNmwuwfbl9CEsltalKLP2RJMnJIwSGS7g8ZLYOEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQH9FQFpO5wnyDy%2BHNrasTcx7y8mkLQ3%2Bt1%2B%2F%2B%2B47y3MUZrufdu%2Fof7vgzP6%2FJWC3OwLVhi8f5T9qPU74tRv%2FqNEyTgiEQAiEQAiEQAiEQAiEQAiEQJ8IMPb33Xdf56U%2B9thjbds4WXWuuea6%2F%2F77217qH2pOOOGEmWaaaeafPrPNNtscc8wxyyyzlK8SkD711FP9wyKzhv6HAB3jkksu2XXXXXfbbbdddtlljz32OOCAAy644IL33nvv9yySiGGQ3XffvVevXr9nnPQNgRAIgRAIgRAIgRAIgRAIgRD4RQJ0jDXXXHOggQZqm%2BGf0eeowcEGG%2By66677xXH%2BJw2OOeaYqaaaauqpp%2FZ36KGHdhdjjz32NNNMo4as8cQTT%2FxPVpVJ%2B1sCtvTxxx%2B%2F7LLLrr%2F%2B%2Bttuu%2B0222xj8%2Fu60047vfPOO7952R6iI488crXVVutPzh79zTeSjiEQAiEQAiEQAiEQAiEQAiHQ%2FxNggjlPcIghhrjrrrvarlby%2F5deeqnlNFXBJnq1bayGndjSuNnMa%2Bs%2BHc5owD5d6qCXoJJPP%2F3UUSmffPLJeuutN%2Bigg15%2F%2FfWff%2F65GvV9OiHRIuuqOl5wB%2FdSR0jhL0TAz82HZ8UVV3SSpkMofJxtweloueWWu%2FTSS%2Fv%2BRmyMlt1l1xmq7R7WsrnfmlP06ZKR%2B7TxjN%2BnS82RUw6BEAiBEAiBEAiBEAiBEAiBAZhAxzrGmWee6c31M888Uwh8%2FPHHBx988MILLzzvvPNusMEGzcANB7CeccYZiy66qEtrrbVW9YW45ZZbvPK%2B%2BOKLRa%2FMP%2F%2F8iyyyyOGHH97MufH000%2BbQi%2FDHnTQQR9%2B%2BGGl%2Feabb26%2F%2FfZ6LbTQQnvttRdpol5qW9hiiy24jjzwwAPlkrMgrXDnnXeuB8K%2B9dZbG220kVACB8V6F%2B8V%2FEknnWQ9xt94441feOGFOiZT8eyzz1588cWtisjz6KOP1ku1YDFG2Gefff7YEx7r%2BCn8GQSqjvHQQw%2FV8R9%2B%2BOFVVlnlxBNPVPPiiy%2FyrLjzzjvLVbrB1Vdffeyxx9o8pcae12Dvvffef%2F%2F9haiUX5%2FUdu2111JIOHWY4oYbbuD1IRrLmFp6ZO6%2B%2B%2B6mxPH8889zJbKlbXj6Yb0kwwbfJ6EuLh1xxBE2XlVLTERpMakBCS%2FNR6%2FeSAohEAIhEAIhEAIhEAIhEAIh8Hcg0LGOwfAXrEGLgILCsMIKK%2Fg60UQTzTDDDAoTTDBBSZ3B%2Ftpxxx3VjD%2F%2B%2BBJWlDZFymDK%2Beoz7LDD6lWiPxiG5SX1PffcYzRXXZpwwgkVTMG5wnSvvPKK2BA100033cQTT6xAD%2FECvd0fhbm3%2Beab0zHuu%2B%2B%2B0oBUQqPQ69577y01F110ka8iZd54441JJplEeZBBBhGEIhRF2Sw9e%2FbUUu5H6oeascYaq9zLOOOM0zR7y2hMUfeib1IiFCB%2Fib9Vx3jkkUfKgmkI5557rl1X%2FDHsn6WXXpoiV64SKA455BD%2BG927d1dDWFhnnXVWX311GTY23HBDASnHHXccBU8zYsXKK6%2F88ssvm0LlUkst5SsfIUKc7rZuzT9jQ5ZBJOhQWGmllegeNrAnkXq2%2FPLLb7LJJi6tuuqqAlVKtJcpOnfuzGnEJqeeGVDHegt%2FCfJZZAiEQAiEQAiEQAiEQAiEQAj8UQQ61jE4OQwzzDC333676YgPrHs1kiJ6O8z6G3jggZdYYgkjXHXVVYMPPjhjjWu9kyDOOussgSrcIVhnp512ml4cG7z11stQI444IhcIEStEiQUWWIC%2BQWFwybBsQ41Zc6zLzTbbTJzI6aef7hIJxWimu%2Fzyy9u98bY6hmbcLYzGD0SZpck8JHRYgImmnHJKy3ALlkHW4BCi5YEHHqiliAP3wrBVT5%2BRv5Fe4dZaTqNgWnoFf9NNN8XPv91fpP%2BsJDIQ1kgHO%2BywA98GH%2F45a6yxxp577lkcgfjzuHrOOeeU9ds2vC804KehzEeCiEGR8Ou%2F%2Ffbb%2BpIdunXrZliXuCER34pUQnPgccGLw%2B698sorKQ%2BnnHKKS9yEtt56a2KaXgZ59dVX5eiw1Tly2G90DwlItbHZKIRWst122ymTR6yBVELi83zRFUkoNrYR%2Bk%2FOWVUIhEAIhEAIhEAIhEAIhEAI%2FHkE%2BkbH4GbPWhdnwTOBpVYWw7zaaqutRGQwwRhZ5A5GPVlAHMfrr78%2B99xzc7T44IMPCBHkCPEppZcDUl2SlpOxxmFjhBFG8NKZClGuCmDxIlsXSTm4OlA%2FWIIGNCzbjbzABqyNm0za1TFYfxbsBJOSu2DyySefc845xYNYMH%2BMTp061fAWQSXjjjuu6bTccsstS2pTMovGFiCqhZ8Jk7M5Y8p%2FRQJVx%2BAsQSUoHyLDoYceWqKWOtAxaGvEChEo9jl1wu1zxbEt33333XKp6hiCSghfxYVDM3FMLgmnooR4lEx36qmnGsHHDqSZqDEvHUMQE4HFbvRUGpMXEOcNDkIeBwOSOLgMqfcw8ht58MEHo6H9FTdh1hwCIRACIRACIRACIRACIfA7CfSNjiGE34vgSSed1PGsJeijTKqvN8JqZp99djEaRImRRx55pJFG4urAvWG00Ubr0aMHUYIswPOhdGG7LbjggkXHuOaaa%2FSSDaDeAjmC1eYjn8CQP32M5mPY4YYbzphEj3Ztt3Z1DC0JLPw9WHxsT%2B4cxTfDm%2FSiY9TUGW6BiKHSgr1h17Lei9l9JcU8%2FvjjdZ0p%2FEUJFB2Dsw0fCboBwY0uJ2TDj86JyC7qQMdwyzwxuAyRHfylS9hXVAX1HoSmPwYdg3BRM67QMQgU2tuQEsXoLirECOWjpdmlxTDIeeedZ21cPiiE1lNDlvhgSL7hEjWDlMd%2FSeqYv%2BhPkGWHQAiEQAiEQAiEQAiEQAiEwO8k0Jc6hgyfzHzxINX2r%2FN6kT3zzDMPNdRQXlXLp1GsM34aAvkpBuJK6BjVUb%2BtjiH%2FQB2qFkR%2FkA4mm2yyn629DY1sTEEBVI7arBba1TFclfSA%2BiFqQGIB6krJ5tGujuHWOJBw4ZAegeOH9%2FXNe2E8VkeUOmkKfzkCdIxyXkkz4Qn%2FHIEeUr%2Fa2%2Bo5adTtWuNKqihh83Tt2lU2GJKC4BHyBb%2Bdtv4YfdIxLrzwQnKE9BrEE2k0fBR8ai7ZJ5980gptdcugwkmdYc2Fs8gssV0uUUKMT%2Burl%2F5yP0QWHAIhEAIhEAIhEAIhEAIhEAK%2FmUDVMWo%2BzOZQJT8GZ3hRJPwoWPpeLpcGbLfzzz%2F%2F5JNPlm5CRs1RRx31ueeea%2FYtZQ36pGOwyzhLyE1RzTHBJgxDIoaQE5fYawzJtmO2remTjuG19bTTTsszhH8FZ5IiwhQdQ7wJUaUM5cU3zURaUUkS2KcSYjhFpe0sqfmrE7DT2uoYfnT7XDwRtxx7koAgQ0u5U%2B0dDkJPkB9DcJOdWTa5ze9B4EpE7yJ9aNY3%2FhieNX4XvC%2FaPeNVzIgoFaFYpuaAQXPjxUFA%2B%2Bijj8hrpi6RLyKzNKNj7L777pb0V%2F9Fsv4QCIEQCIEQCIEQCIEQCIEQ%2BLUEio7BA0EKTQZd%2BTCmWE8uFR2jHJrgjEi%2BDWy3kl2Q7sHDQUQJNYAjvUscGErUCbnAgaQSJHKkl96wrY4hzaasAprpPvroo4tbsWyOFjIZGkeCRGMuueSSIku40FuGq84HkerTO%2Bh2b7BPOgYDsxykYljrKX3pGFNMMYWEGIII1Lgdq9XAYZfaS%2BWhzLefqOIqU9EtWxglpzk1gUWUAbnD1M36lPtnAn7fomOIMyIL%2BFDhBD3RLggRdhqZgmphP9icvnLDEOJBNKAkaElY4LkhGsU9kjJKagtn7hjW7tKs5PnsU1yJPSNwSTJPYxJGDGIK3bt06eJxk2B2scUWU6ZUuGQ6KUA9gB5JeXQl1KWulEePhGIZMmkUXa5%2FBp61hUAIhEAIhEAIhEAIhEAIhMAfToAlxQOB5c6hQgYMwSM%2BzjmV3JK95iQFKSxYWObl2zDjjDNqKVumnJ8SR8jt6cwOl1hhZAeXOD%2BIy3CYqTIHeGZXOTSk5vkkUJQUoMQEHaUpIFZwlmDBzTXXXHrNOuusDj1xiX035phj0kBkrmDEkTu4SbT7IltjYgIVRXe9fG1%2BiCRya7i7muDC1GWFo4wyihtxOzq65fKqXapPATJqaB3LLLOM81iV11577eq8UQZn4SKglzQLzelS7p8JEByIDH5WikTxwaAq2LFkAUKZlQsSIWc5DURUFDVDM8EjRC0%2Ftz0mu4WoEI0PO%2BwwwUqcMahk9C6XZAp1Uiq5wxSiP5Rrnk%2FePrQR8VNkPerHZZddxtHIvGpMQUIR0uLhIqrsvffepnPoqmQaPDGUnQCrC03DRHrxwTC1p9IyPAsu9c%2B0s7YQCIEQCIEQCIEQCIEQCIEQ%2BDMI0DE4Gzj%2FlFxAYagftht7X%2Fw%2BS5%2FjQZna22TWH9Oe1kFbuPnmm%2BuSuHAwsmaaaaYJJ5yQl4Uxy2kgXiWTRIT5l5beNTtKkixQvB1U3njjjTQQYR20BYPzrq9jlmQF9AQCi9AVLeullgJDkv%2F%2Fwgsv3DYeRPYDR5%2BYop5OUvwxxhhjDHaoezE4w7akzijD8iqRwUDgiXsRbMIRhbjRMiN%2FEu%2Ffpd0oLigtV%2FO1%2FyRAZLjiiisoFRQDf2kR%2B%2B23H62guFiUNdvkRAlhJtwhjj76aPuKQ04Jp%2BIydP311ztulZJAfOA4VLYrRwvn82pGjjAFpUK5Zumky9lCgrDoGKbQmIOTBRjEFJJjFE3PJQ%2BFOKztt9%2BeVELNoFRU9cwKKTAm1csCPFYtDkL9J%2FCsKgRCIARCIARCIARCIARCIAT%2BcALMLtZZux9ve1lefCpYXs15hVpwwCAdNCtLmdlF0GhaWHQSI%2FhbGpTp1DS7K8sj2lYrKF0IBX2ari7AsO0uVQPJBKTa4K5fGzMb6TAECqKK9%2B99Gpzu4V5kKqgdWwr4%2BLRU5mv%2FTKDsk%2BZub9nbZfE2JO%2BIkn1CWfvmD%2B2rPWnn1Dutw2qsbLc3u5QR7E%2BXahdfWwaplzxEHofmQ1QvmVQvg9eaFEIgBEIgBEIgBEIgBEIgBEIgBAYMAmzGZ555xuGVzngdZ5xxvGSv91V0jE6dOvVJOaktUwiBEAiBEAiBEAiBEAiBEAiBEAiBEAiBfkOAZ77UFgMPPDBf%2Fear8LfeekukiTAW79z7zUoySwiEQAiEQAiEQAiEQAiEQAiEQAiEQAh0TOCRRx4RTuLQWI79zZYCRq6%2B%2BmopO%2BKf38SScgiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAj0ewLObnj22WefeOKJDg4H6feryowhEAIhEAIhEAIhEAIhEAIhEAIhEAL9GwHnTl500UW77bbbrrvuKjHmvvvu27lz53vuuafd8yj%2FpMU74HKJJZZwFqoMnH%2FSFBk2BBCQKeW%2B%2B%2B47%2B%2ByzX3jhhSYQBwFfe%2B21Xbt2dappsz7lEAiBEAiBEPj%2F2LvreO2qqnv40g0iXQKilAqIIuVDd3d3d3c30t0N0p3S3dLdIK3YWI%2B%2F%2BLzfl%2Fl51rvf6xwONyF3OPYfF2vv1WOvfXPmWGPOFQSCQBAIAkEgCASBIQ2Bf%2F3rX6uuuqpzPVwjjTTSiCOOKOF3q622%2Bstf%2FvL1jBaPseCCC04%2B%2BeThMb4ewP9je3FsDZpuscUWQ9x1T6v561%2F%2Fus0226yxxhpvvvnmfyw4mXgQCAJBIAgEgSAQBIJAEAgCQWCoQACPsdZaayEujj%2F%2B%2BMcff%2Fyxxx67%2BOKLZ555Zk8uuuiir2cKeIyFFlpoiimmeO%2B9976eHtPLfyYCeAzrfPnll19hhRXOP%2F%2F8dpANHoMYab311nvrrbf%2BM5HJrINAEAgCQSAIBIEgEASCQBAIAkMLAsVjjDzyyPfcc08b8znnnEOVsfvuu3vywQcfnHfeeddccw3h%2FcEHH3zfffd5%2BNFHH5177rkHHHDAoYceeuONNwpwUXWffvrps88%2Bm2j%2FjjvuUPiwww6TYDy2llEW3FhU%2FPnPf857pQzJ4jHoMe6%2F%2F%2F4zzjhD7kknnfTOO%2B%2B0Wi0hgMbll19%2B%2B%2B23%2F%2B%2F%2F%2Fb%2FbwySCwCAi0OUx1llnnSeffLIq9uUxlLSMnc%2BL1rv55pvjbzKICKdYEAgCQSAIBIEgEASCQBAIAkHg341A4zEefPDB1tdRRx2FxzjooIM8QVyMNtpobuvad999X3jhhXnmmcftKKOMMtxww0nssssuIgwofOSRR7qdZJJJECO8VKRHHXXUSy65pFp%2B4403lllmGQ%2Fl%2BiX5EI4DB%2FLnP%2F9ZfAwPJ5hgAs%2BHH354v%2FPOO%2B%2B7777bhlSJhx9%2BWNZ0003329%2F%2Bticrt0HgMxFoPAa3qRVXXHGPPfb44x%2F%2FqFYPj4Elu%2BKKK1ZeeeXllltutdVWW3LJJbfbbruXXnrpM9tPgSAQBIJAEAgCQSAIBIEgEASCQBD4dyNQPMYII4yw%2BeabH%2FPJteeee0488cS4gmeeeUbvDzzwwHjjjYeyIMWnvqCF2GCDDZAJ4oKW7mKWWWZBdNx7770KH3fccbImmmiiffbZh4vKgQceKGuJJZb4%2B9%2F%2FrqONN95Y7vrrr8%2BB5ZZbbvnJT36Cu7jppptYkUIWyJprrrmuvfZaypCFF17Y7VlnndUzfY4nm266KcFG0SY9ubkNAgMjgMewRPmVWHUUQVi1Sy%2B9VBU6n65fCbpslVVW2XLLLa3qV155hfRIFfKkIj0G7iK5QSAIBIEgEASCQBAIAkEgCASBIPBvRQC9sPbaayMNeq7ddtutfDfwGGONNZb4FXWrPK8TDIOEgXEMOeSQQ9TlTuJW8AFpPEONmcPInHPOOemkk%2F7mN7%2BxnT3hhBPOMcccWIvKve222%2FiSUH0ohrggxqD0qCw%2BLNopx5Z6kt8g8OURKB6DysKqFtJzww03tPhffvlltFjxGCLNOqnn8MMPJ8ZAxFWPTcVRZN2XH0ZaCAJBIAgEgSAQBIJAEAgCQSAIBIEvjEDjMew%2Bn3LKKSeffDLfEHE%2B6SgEQtQsi2%2FsscdeffXVW1BEDx1eSZY%2F%2B%2ByzzzrrrJNNNhnOQUXPS4%2BhnRqPE08WWWQRBMWHH35IgMHHZIsttqgsv0QayI23337bNjee5Nvf%2FnaLiaGwNnfddddWOIkg8OURaDxGhXm54YYbcBqYNEFgnGAizmetRmeX%2BBz%2B8Ic%2FtB6FeVFSaJf2JIkgEASCQBAIAkEgCASBIBAEgkAQGCwI4DGcV8K%2Fo7vXzLMDj7H44osLXiFuBh7D2aylx2AJnnnmmaOPPvq3vvWtBRZYYNlll%2F3BD36AcyjuongMUTprLgJfEFqQYdBjkFjopV%2BJRcX5dF5JO3c1PMZgWQzDfKeNx6jVzp2E6xOC4vTTT9922205TOExhF7hu7TDDjt0zx32FSDuTjvttGEeokwwCASBIBAEgkAQCAJBIAgEgSAwhCPQeAw7zm2oTp%2Bceuqpxa9ARDz00ENdHoOyYtppp51xxhmp7lmFqmAwPpPHUAs3MsYYYwiO0Xr5%2BOOP6Tqef%2F55G9917mp4jAZOEv8OBHp4DF1QBFmTK620koAY4rfgMSxLPibSyLc2BofyoOwuu%2Byy9iSJIBAEgkAQCAJBIAgEgSAQBIJAEBgsCDQeQwDPGgD%2FEYoLkT%2BFCBAroEeP8dprr%2FETmX%2F%2B%2BWu3WmABm9d4jFNPPVX1T9Nj0O2%2F%2F%2F77U0011TTTTKOF6uiiiy5Sce%2B998aWDDqPgfRgaQ4WrNLp0I5AXx7DjIT6pLUQxhZ3gcFTxmLGWlx%2F%2FfU1X%2BvTwTokSU899dTQjkDGHwSCQBAIAkEgCASBIBAEgkAQGNoRwGOsueaa%2BARHqa7zyYVScPKpWBbXXHON2YmPQUeB0yi%2FEvSFM1KVZ%2Fftt99%2BlXZ77LHHKuxX%2BsQTTyxYGIALLrig407qBNXDDjtMy0J9Kub8Vp4p448%2FPl0HXoKLijgbTY9x8803a8dxrj3wCgQ6wwwzcAToav57yuQ2CHwaAjgKa88xJdRBrYxVajUuvfTSG220ER7D81dffRWngbhA6FFiOHxHLo5ORJdWK4kgEASCQBAIAkEgCASBIBAEgkAQGCwI4DE222wzfMI444wz5ifXuOOO%2B7Of%2FezKK6%2BswJ7OoKSjcLJD8RgG6dRUET6dxDrccMNNOeWUTDzVHcPKSBRAQJr1V3PBNqA7pp9%2BeueleiIcgZNbhcvAUdB7zDTTTLfeeqvnDEnnWrql6q%2BKnFy0gyep2%2FZrQ9xgkCc5AbNhksSgI1BaCwFhqIy6tZ599llfwdZbb91W4JNPPmlJczaxgJUXxrYb9rNbN%2BkgEASCQBAIAkEgCASBIBAEgkAQ%2BDoRQFZQQdA5vPjJJeE8SuRGGwMuQo4y3fNKROa8%2F%2F77uaLwFsFOKKCWAkIKiHchUmJV55by%2BuuvO9fyn%2F%2F8Z2uQqaiiAyM00lOMl0o9kWUk2A%2BGZ6sooQs75rxUug%2BTDgKDiIDlZIm%2B8cYbPXoe68pKtrS6C9XCtpgfeeQRK7ZnHQ5idykWBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAwRCHglApHvjrC1bkqQ9TAMpjBhYBzSf761786Lqd7ZM%2B%2FYzBOMbb2HJtiEX6u9g3M8AwyR6h8LtxSOAgEgSAQBIJAEAgCQSAIBIFhBgHWkFNTb7311nfffXeYmVRNxNSeeOKJq6666upPrmuuueaee%2B5xkmZjLf7%2B97%2Bvs846884772uvvTaMzT3T%2BUwEsAEPPvhgz6vHKpx99tnbbbeddfKZLQxQAEHhwFZnE7see%2ByxV155BV3WLf%2F73%2F%2F%2BgAMOOPDAA3ued8v0m3YeseGdc845%2FRIgv%2Fvd75xo7AzZfuvmYRAIAkEgCASBIBAEgkAQCAJBYNhAYPfdd%2F%2FGN75xySWXDBvT6c5i%2B%2B23N7Wea%2Fnll2cMKobHmGuuucYbb7y67VZMephH4KabblpuueX22muvv%2F3tb22yyIEjjzxyxRVXfPzxx9vDL5B44403NtxwQ%2B3Xtcwyy2y88cYXX3xx6%2Bujjz7afPPNt9hiC4TG52r%2FySefNDyD7JfHuOKKKxZddNFTTjklao3PhWoKB4EgEASCQBAIAkEgCASBIDB0IbDHHnuw9C%2B99NKha9iDMtodd9zR1FZdddUTTjjh%2BOOPt%2F0955xzejLffPP95je%2F%2Bec%2F%2FznPPPNMPPHEFCmD0lrKDDMI%2FOMf%2F9h%2F%2F%2F3RC2uttVZXeoEcOProo1dZZRV0wZeZbPEY66%2B%2F%2Fi9%2B8QuKoBNPPHGTTTZZaqmlTj31VKtOy3iMrbbaauutt%2F7DH%2F7wuTp66qmnDM8g%2B62FkTvssMMeffTRfnPzMAgEgSAQBIJAEAgCQSAIBIEgMGwg0JfH4GNCnX7XXXe9%2BuqrPXN85513eGfcfffdLLWW9cEHHzAGm0KeCz%2B5vrotyIDEM888c8cdd1Dytw1oW8Y6eumll%2Bgifv3rX%2BvuoYceEjSgNSvB3rQzfvvttxPnK9bNYnI%2B99xz2pRVtmE3t9I77LAD1uKyyy5rWQILrLDCCvXQqPryGIZtJPfee%2B%2Fbb7%2FdalWCs8DDDz%2BsR3MR36Cb%2B%2FHHH%2FMjuPPOO19%2B%2BeXu824adK%2B%2F%2Fnq%2F2%2BjdYkl%2FDQhYruuuuy6Cy2I477zzmnqh8RioAAuAX5IX%2Bt%2F%2F%2Fd81JAvGirXa29u3XN9666228tvIfR1IjJ133rktWr4etB%2B68%2B0oVjzGNttsozUfi45Uac1WO7%2F97W%2BffvppxIWV00ZYPMZRRx3FhcT6d6up1q%2FlbYQ9H1HLTSIIBIEgEASCQBAIAkEgCASBIDBsINDlMdhxrP7JJ5%2Bcpe8aa6yxGF%2BMIzMVVsK28qSTTlpZ4447LnE7nkHW3nvvPeqoo9p3LkCYZj%2F96U%2Bnm266srBwFKuvvnrV8jvzzDOXKaew%2Feixxx6bowdRRBWgile%2B2kFxuG0V7Z43XoWaYtNNNx155JErd%2Bmll0YRVK3ub%2FEYF154YffhSSedpNbhhx%2FOPu3yGAImgMKUq82JJproggsuaLQDtmTuueeurBFGGMH2umlWs7IE2ais0Ucffc899yzEup2ySX%2F2s59NMskkLNPu86QHCwIXXXQRSuHyyy%2B3QpAJllMNo3gMjhs8klZbbTUrU%2FrnP%2F850kABXIT3brVjtKr8DTfcoMyVV17ZM4viMXbaaacupeDVr7zyylrzKWlQv8KzWC260IgsUS%2BKkUOYXH%2F99eutt57nxmkk559%2FfmUhLnxNhmH9y%2BK3Io11rAHceOONWhsmtVU9COc2CASBIBAEgkAQCAJBIAgEgf9kBLo8Bjn9BBNMMNlkkx1zzDEnn3wyzoF5ftppp8GHKGK00UabYYYZ8ADHHnvslFNOOeKII3L5l8UWY9o3a46BP%2Buss37nO99hrCE6WFsaYZQxHnfddddRRhllttlm%2B%2FDDD1UkrZeFA9lggw30UkQBwb8sdRdaaKHhhx9%2Bo402wiew%2BKSXXXZZbAP%2BgTuAivbTbaYLRCDN4iOKULF7FY%2FRE%2FoDM6M8hX%2FTYyBM7Hfr13OMxLnnnovlGH%2F88b%2F1rW8V7YA%2FAYXp22HXI0ZFyc0228wGOtYFJrKEGTnzzDMF3JDFh6VtoNd4qFBYo7PPPvsAgo3uyJP%2B9yFAPoGmwKFZYxawlUOBU93hMax8y8x6E%2FATmeCN8wex5mVZ2Faj1d54DAUsBlEpekbbL4%2BhXwteWAz8nvUgXKe6FTcD86BlDiMVl4Owx6iM8LrrrkOVSLj1UC8W5BprrOGbMgxZQmEgQHw%2BRfF5gtnoWfA9Y8ttEAgCQSAIBIEgEASCQBAIAkFgaEegy2Nw52eGF5NgXtwrxhlnHBu%2BjLgjjjhCFnKj5sti%2BuY3v8kWc2uHGqfR5TF%2B%2FOMfTzPNNHz%2FmX4E%2FAo0zfzaa6%2BN9HCOg4pbbrmltOAV1SYjTpuMOxvWZCGyEBFV0a92RhpppAceeEBdvIFd6arld80116TrkNWeVKLiY%2Byzzz4kE7%2F61a%2B4fhx66KECe5J%2F8A1pPEb5DiiGHmkqC1SG%2BaJQNFVz96SatclOcEKwwSOgss4444zK4ozw3e9%2Bl%2FSCjVxP2q8Yj83%2BbQ%2BT%2BPoR4L5EtHDWWWfpWmgUC%2BmQQw4p55HSYyANbr755hoYwm3bbbflJMJfg6jmy%2FAYfEyQXdY%2F7sunQY9hSbfYLD4fK9%2FROvpFX4jl0s5S4c2EuMC8ycJjoDtwcW0tkRupiCSUGx6j3lp%2Bg0AQCAJBIAgEgSAQBIJAEBi2EejyGCwmTMK3v%2F1tzIO9ZoYbF4ny8XfLj4MMg4V1yy23sNMZ5pVla7hfHsO%2Bc8kSEAU2jhlf9BgoDgSFk16ham%2BaI4aIE4WwXWwkwMILL6zZXXbZZbjhhtNRA58FRynBf4QaBMNgG5qfy3HHHeeXX4knZZm28hLFY8ii5XBpUHrCCScs2oFWpPmV1DiRGKxCNA57c5FFFlHYsNm2%2BuJHgwxpjQsSoiRLk0VMYWJe2BiD4Wsz44wzEnKIXdAKJzHkIIAio5ah5Hn22WeNyu2%2B%2B%2B5LfVF6huIxEAXcN9qYKYVWWmklb%2F%2FL8xi%2BtcZj0GagMlpsDZoQ%2FEnFcrEacWWCydBp0P9YVHgMq8uQiscgGmmCH7E%2BTEd4T4PnVxI9RntxSQSBIBAEgkAQCAJBIAgEgSAwrCLQ5THIHthNQlsw4V1YhYMOOqj8NWxYU9e30BnTTjutQxPKZ38AHgNoNB6kC1pj3eMQEBf4kC6PUWkl8Rjf%2B973EAh65LVBfUFB0Rf2cgxBShQvoWUNjjHGGIbXU7h4jAUXXJCug3SE4oKzQNsBx5Y0HkNFZiN3GK1hcgyYwEO6Dpjg8GLir7zySk%2F7nFw0rlgbiQQ3GXqPLunRUyu3gxEBYTm5chDwcAJCGiAKrA16hoop0XgMgTfbILlpoBEwV1%2BSx7BaEBfUTcJx8Cup80pa2Nsuj%2FHee%2B8hErm3cCGhAKlAGegXQyoew6dnqDVCM1LA2qYvCo%2FR3loSQSAIBIEgEASCQBAIAkEgCAzDCHR5jNrkZXDRSJAl%2FPCHP2SkozIYTZVFD09yrwoeQ1btESMW6DFanE9m2k9%2B8pPyK2FkTTXVVNNPP31JOKg7OPurODCPQekhggEFRQtcAH8PGZLIB4IQLdhGd6gEHwGXBLcRyv%2Be19RvfIxWpvEY5CLSiy%2B%2BONEF81Y4R7cYDLoRfjS4HRYlBoYrSqtL1U9tYqZYF6FBRYwkZanBGAkruO2ztypJDAkIXHPNNUgJAhtCGgkXrQUhBP2PZW%2BR9z13lc5HYZRa8Rhdnw5%2BHDiQQYyPQQFCOOGzwv6RM%2FXLY1hIvjUKEEMi%2BKmlyN9KcJVP02NYveUao2J4jCFhjWUMQSAIBIEgEASCQBAIAkEgCPy7ESgeowTtQgHgH%2Bz5Vqf4AeoClppdY%2BE00RrtxBBRB2UtueSSTD%2FhBegQKpSEitz%2FERe0HAxDsSxQHPxH2izEBBiYx%2BBXQvvBeFTM4Q6tIuU8nYOQHWw93TEDWxbagbq%2B3bZE8Rjswfakm2g8BqEFg3GKKaYQ5LPiJCjmaEtESsUDKQUIpUqrLmCC8J4sU7QMuqNxOAqgU%2FoyKq1iEoMRATQa7RAxhhNz3nzzTT5KLm8fU4fZKPEPHgO5UfFbDJU0yAfCGUR55AM1BZGPhV2zsBQH4DG6567iQPbbbz9OH%2BUqhQT7NB6DuxMxBleXdoqKgSFbujwGKo%2F6osaAS8GzOevEbeJjFCb5DQJBIAgEgSAQBIJAEAgCQWDYRqCrxxDkAXvANGPoCRFgk9rtFltsIYwAK0zaNjH%2FC04TFPJunSwJHCEKpeebbz4qi1%2F%2B8pesKreoDJoEm8V8NJw3Sucgl7cI9oPDSFlzFR%2Bjx68Ej2HDugJmEkiIUEEIgdYQn3OWWWYhuS%2B9B5Jht912s1WNUVliiSV0QQjR86YGkccwSPoKziOcU5i0pUXRnVmUr4qWzYKwBNsDFsFCTWGxxRZjFzN4hUKVy5A0TuEWEUGymr9ADUlJZIhzJVoc0Z6h5vZrQMBxPBQRXDDKH6r1WD4d4q4gB%2Fxa%2F%2Bgp6wqhx5sD%2BeCNI7gwDOrSZmDGUFiicXJRQXr0q8fA%2B3nd3FVoJHgzCWmL9EOSWAn6%2FTQewwIjq0BZ%2BIhwaJa3uDS%2BGkNqPAb1hQgeNBvWG3GUjjypgB7hMdo7TSIIBIEgEASCQBAIAkEgCASBYRgBbACDvU5Q5TbC195tuxzMUfIMBAILrj2XcCpHhYxgs3ezxL1EMtBj2L%2FmlEGtweqvigJZTDrppNKsMJBuuummnrRgnjbHp5566gUWWKBsPUZZC8ehioNcG%2BOBPZhpppmqTb%2FaF%2F6ineDQXhZ5idwmFGnPK0GPgbtwvmpFzGBCYiRam4KdSmuWXam80KBKtlyMSkXAkHv66adXMI3Kxaiwc6tW6%2FGdd95B7FBukLi0h0l8nQhYit6UQ1S5lvT061ASXAFCQIQWITSRdVRDfnEUCAQUBKarqiDxyDmQDLLwG1gRv6Vl6rZpJSMxFHMpIA4tfQUurgk58BhIPAxh47vQJvqqI1NfeOEFuWoZg4v4x%2FdFSWLF%2Bhh16mAdv7K0T6ohBA2m0QCQHmrV2SXd8SQdBIJAEAgCQSAIBIEgEASCQBAYZhDgFWLPlyHGgqtJ2Xe2PW3jGP9AaEEP3yYrQoX9XyeNcvFgDHZDQCBAbD0ffPDB7H0GPnvKpby6LCwxErlp2OkWRIIhxs7Sqa7dqsXGry5YeZq97bbbmnMHe5CNZiR8Oig02kgk2J5U%2FbKcV4Ic0Fo3V9oTz%2FEz7fzKngI230kvuhOxX0%2BAYXbGgKIxNlA0AT%2B6gymqR3A187PadDqJLFoOnIkx93TklhGNe7npppuYon1z8%2BRrQMCisjItxZ53p2ukEz2Dd829yKvkuySuy3333WfxoM6af4eSFpWFYc0gHKxe3kxqtW%2BnzcJpI163LLomPWq824hiloFoKlqg8ahafJGUb4uHboe0yZeiHVn4E5daBq9Ng3zppZcMwydAGdJIMy5d%2BJBPW%2FBteEkEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJB4N%2BNQN%2FDRLo9DpzbLdnSX6BKq5tEEAgCQSAIBIEgEASCQBAIAkEgCASBIDCUIuDERidCOtK07%2FXxxx9%2FVZP61a9%2Btfnmm19xxRX98g%2FOl9xyyy0dK6k7h5w6O7IObB2gd%2BeibrbZZn4HKDMYs4y%2F4eng2na25mAcUrruIvDXv%2F7VC3KeaXs1Vqazgx3m2%2Ff63e9%2B59jcbnXv17moGqmHctVyVKsWusW6aWV8UP1%2BUw6EVb17irG01nTRzvxtTXniuVyHFLeHPQmT6rejKqY7ddvRxj11cxsEgkAQCAJBIAgEgSAQBIJAEBjCEWAW4RAmmWSS8ccff9zO5cmNN944wOARIB999BGTalAMIgzGN77xjZ122qnfBn%2F%2B85%2FLPffcc%2BVeddVVE0000YEHHthvyfbw2GOPVeWoo45qT77mBLOXecv8bIZwdwAPPfTQ9773PROB6AQTTDDVVFNttdVWTz31VLfMAOl%2F%2FvOfgNV%2Bv7TPABWT9ZkI4BNuueWWLbbYYo011lhzzTW32Wabhx9%2BGM4%2BhOOPP96T9f7nWnfddTfYYAN3%2B%2B%2B%2F%2F1%2F%2B8pfWspVvrfpq3nzzTQ%2FfeOMNy3WdddbR4Prrr3%2F66af%2F%2Bc9%2FboUlfCaXXnrp9ttvr7Xrrruum1XpO%2B64Y5NNNrnvvvuq8AknnKDk6quvvvbaa%2B%2B8885PPvlkq%2FLaa6%2Fttddenutro402Ouuss7qMn6m9%2Buqr%2BjIpc2y1WuLFF1885phj9LXddtsZdnueRBAIAkEgCASBIBAEgkAQCAJBYChCgPnGdsMJzDnnnKusssrKn1wrrbTSaqut9sADDwwwEQaUivPMM8%2BgGERXX321Lnbfffd%2BGzzyyCPlXnDBBXLZX1NMMcWJJ57Yb8n2UAFVjjvuuPbka07AbYcddvjhD3%2FYNTPbGO65557RRhttrLHGWmGFFSCK0zBaTBGWppUZIIHx%2BMlPfsLY%2FF%2F%2F638NUCxZXwABrMWqq66KnTjjjDNOOumkIi5eeukl7MTll1%2BOkTjkf65DDz102223XWqppQ4%2F%2FHCvu%2FWFYkIFHHbYYd4OygKxsMwyy6iE3Nhxxx2XXnrpM888U2tVHtdhnXi41lpr7bnnnkVWtKYk0IA61WDpdjSrsC%2FlvPPOk15xxRU33nhj7ISSeC1M4PLLL3%2FEEUfoa%2Butt1522WWxFkV2mdc%2B%2B%2BxjsS255JKeX3%2F99d1epG%2B%2B%2BWbsx3LLLUfIhACkRekpkNsgEASCQBAIAkEgCASBIBAEgsBQgQADjYU18sgj89FgEPVcbQrsNe4nf%2F%2F73%2BsJC46EfrbZZiM5ePbZZ92qWFksOAaXPehmynlePMbee%2B8tLddVheu3y2MYD5EDQUK3wO9%2F%2F3tWXteoLx6DHVoNKtDKG4lGqjDlv2F3JRO67hZutQZuh7GpnabVLx8BtA92gmXqtk2%2FGrz33nvxGIxQXcsyjPPPPx%2BPMc000zz%2F%2FPPdTtFBWm47%2BAob%2Bf333z%2FGGGOorqLGW3mwKNzVBrSsStTcex7mtiFgYRD%2FoOkaR0d0xLQ%2F%2B%2ByzQWfFdi8v4rTTTlMYRdBakEAIYEJQVdLaQVUhOmrFvvfee5QeSAlch1zfy0EHHaR9zEZ9EXrpNiX9%2Buuv03LoSPqZZ55BRBxwwAG1HgwA2YIkueSSS%2BTeeeedmrLma21jSDbddFPSi3Jm8UUYKkpk1113xXXccMMN3Y5eeOEFKg4KE8vVmjTNviPplk86CASBIBAEgkAQCAJBIAgEgSAwxCLQeAzi9n4HKYwACf13vvOdSSeddMYZZ%2BTQwWQ755xzpptuulFGGWXEEUeUZZtYOywjkS4WXHDByT%2B55p133rvuuqvaxGMMP%2FzwiyyyCFNL5tRTT20fnCFWuV0eQws%2F%2FvGPCeYr65FHHmHOf%2Fvb355sssnmmGOOa665pux61txwww1He2%2BLmX7DGKQNVS2%2B%2F0zLRRddlJ7Bc91p8Morr9TUwgsvXL1Tm9Qed%2BuF6SdLRxLk956bpi1v7dgWx9gYgOnT%2FLMir732WtMfZ5xxjGHKKafccMMNu%2FJ%2BdYvHYJN2nW5I%2BvEee%2ByxR3WKjrCJP%2B2003LhwW%2FYdmf8sjGhrc0RRhhh7LHH1uPFF1%2BsvE4vvPDCH%2F3oR94CdQcNQF82QxkeEHQ1Zlpd5LcHASwWeNn%2BjcuyZjiDeCmNo2tVCI3INvbdd9%2Fuy7UqPNFIcXEXXXSRBYNhqFo%2BASvTIn%2F88cc9efTRR60BTzxHHbSWuwkiEEv36aef9tD3wqOkS0FgS3AX2Ay5fn0LFUbGrQZ9jOriKNw%2B99xzKBEdFTPTbcTCMAa0W7E3nzYSjeQKAkEgCASBIBAEgkAQCAJBIAgM%2BQgMzGPYF8YJsL4XWGABhjb1BcudKJ35Nv%2F884855pjM7dlnn90uMHqBlH300UdnfbP%2BmFey2P5PPPEEEPAYJB%2FaYYbLnXnmmaXRGrWV3OUxtCzr4IMPVoshycwfaaSRGIZ2k9EmPDVKMM8u076SSAC7zDPMMIM0%2B5SNxkT9%2Fve%2F73bUUUctmT0KxVCFqsAAsBNnmmkmudos%2B5Rxh8Egn0CJkPTL%2BulPf4pSQEHwKXDr%2Bq%2F%2F%2Bi8zMgDFMC233noruma88caDxqyzziqIQQv5WG%2B88Ri1TV8PWZpiZcw999z69VzEDC3jWOzgIx%2BkzZF1TLUyyyyzuP3mN7%2BJeClXlKOPPhoO3%2F3ud72FyrXt3iVJdKFNtqohYXuqx%2Fz2IIC1sAB4bTTWgkzCi0BYdcNsVi0iDYu%2FcRT1kAcK%2FVLpN5AGp5xyioVUi7wKWMCYh1JrKIblwF%2B5sB9kGz0xUnxfvE5k1frxS2%2FTXUv8R7RQ8WxVtwitojYpdKIV3kNbefsG0OUxaJkQL%2BgaTAh9iO4MzMPWThJBIAgEgSAQBIJAEAgCQSAIBIGhCAE8BvOZ8YuXYOngBFyE7pdddplZOGcEG0DZXia%2F8JWMaywE64%2Fd97Of%2FQxTwa9EI%2FZ8mfP0D82AIjZgjLPgtFN6DAqNV155xS3PETvLiAjCBrddHoOEnsaD%2BN%2FzX%2FziF1poAgYRA9wScsg6%2BeSTpZdYYgl70G6NgY2PvsADGJiwFRgPMRXRGox9dqvC2IlSgDgPBZVh5AYjl48A%2BoVgQzuu%2FfbbT2E6f5MSZwD9Qo%2FB7GW0ljPLLrvsgrThY0LUgVsg1FdSblWv3355jPfff5%2Fu4gc%2F%2BAETUsBG%2FI%2F2IamKYQMHy2FvXWvaJPZgojJpAct2pjkhxqjJqoJFmXDCCbvmc%2FVrat5RM9K7Q0oaAgC0vDFFzdUI64X%2BQiWVvqKhhMjitUHSU1Rbe04e471bb55YBiK0eE38QVoBASvQCLfffrsXZ%2BVIe8uoD0yC78iX5ZtqhaVRE31jWVQBzSov5oZB6gtbqB2LoVXnrIRpsdjaE4m%2BPEZxL744IzEpRKIPxzrvmVq3kaSDQBAIAkEgCASBIBAEgkAQCAJDLAKsZjwGy51i4Vvf%2BlY7sUQsQWNmVk888cTkCpwaqCNYf8Jait6gFmaD6c2%2BLmoCY4CdsN%2Ft1ma0GALYA%2FTIbrvtph22lS7EOWw4IBnIJFAfnhB4yK04n10eAyXi%2BVxzzUX%2FIPIA6kDXZbzTY8iiq68GZXEAMXjRC%2FmVcMeYfvrpK0aBAtpUuKJzuGUSsumoKWyOM%2FGwBygOLZsaeb%2BZom64imiTPMPcS7evIuUGekSWNCsVbngMvgNue65%2BeQxb7eQlHFLMBdVgqE7PNAazM1M8EilLhWJg3poLfYihatk2OiSFZ2TYGqRfm%2FgNsZ6uczsAAugjzMCg8BiYNxRET1xWSwuzwXmnaD1vB033aTyGj4XyAXWApsMvodfwFcQbTX2hOjkHVuGdd97pO2bLQ5RRLEfJLRBu%2Bv1iPIZPBveirkmhRCw%2FHKOBNcqxb%2B95EgSCQBAIAkEgCASBIBAEgkAQGGIRKB6DNIKfvv1Ze%2F0uggFWvDHLteMs5iSrmUyC%2Ba9YaQ8UKB7j5Zdfrtmx8jAVDHCF28X9QW7xGJiNhgPznwqCEefJp%2FEYGrR9XE3RJzhoshz8VSlphIMyq0GDWXzxxVETjcdAFzTzsHxVbGdXYZNiThaPQfnAVaSNtiUUgIYNdERNU%2FILTWAYeAwIaIRhiMfoUfVXF%2F3yGMYjMEipWRTDSOBeWo8SAoFSU8gCDhjZsNgStwzYbrGWHozHtdQ0h7pfghyiI9FFevQYxBIM%2FDYdnAMVBOLIcmoPJbwXhEA7jxgRIV4KHqOiW1RJpBkChDcKrsOCt8IrbItcndapqaWrwbOJCGrx11vudmQAon1qx4HFFc7CekM%2BECNV8JYqTI%2Fhk0TBdev21WOgxQwSnVK0mMKWtIl40rfrblNJB4EgEASCQBAIAkEgCASBIBAEhkAEyh7nPdETB6A7VKYTk3m%2B%2BeYj2GBEO96RQcTloctjsLaQGGQDLCaWFOueqB49wgtDU8VjNA8RTwgPyB4EK5D%2BNB6jxsC0t4Eu0maFp6i4l8VjNEN%2BUHgMhmE12OUxEA74GS4bRiv%2BgIsQxcV%2BxKLQY3yFPMbdd9%2BNEcK3GADbFqFBgME4hTx%2BxnNij355jHJ1Qa0wPLuDbCEfa175%2FUwESBEEGLEmWwwKIpnNN998%2B%2B2378ZNpZARlYIfU9ddqFgLNIhGqiO5DiLBJHS5LLXwD15NRQRVvpEhvhHMmw%2BkmDF8iHgmmLSeYVvMViMODf%2FWeAZ1xUjhA9WNsEGoo4WKKdoa6ctjqKJT4qXGY5iCgeHH4oLUcEsiCASBIBAEgkAQCAJBIAgEgaEFgcZjVGTCnmHT1fNuKONaFgkBnQNXC6EebC7jMRzwUZvL1AsiXopQQctRjfAcIVcoz5HiMYrTqFxbySiR0kgwz6W7fiXsOEYi5xHih2YGaoTzi9AQjLuKj%2FEleQzTISbhV4KiKU%2BBGlsF5zRBcT4%2Fk8d47LHHqlb3t%2FQYtrybIcw4pd8wzdNPP13Jcpkpp5uqyN8By9HlMWy%2BV3XGctFH3S68uO5t0oOCgLfgbB3CnsZFcCyywrFJDU9lqCa8LE4o3TbJaaxGS64EEpVlkXPQ4K9Rt4gCTRHSaNa7s0qxHO3z4RuCkROgg5tJpcUX7epANGLV4TqQGF56tyNZpfTgsVV9WaWoOa1V1Jd66Lcvj%2BELMmVcTevL4b8WJ6eYxpO06kkEgSAQBIJAEAgCQSAIBIEgEASGcASKx6BJYHMJvEAhX5c9XJEPcQss6MUWW0wkB5YXm12EB5ddbLfcIig0HNjKPsJjzDPPPDwjKBnk2qFGOKjb4mPQZohZwX5Xkj7fOR30FbUZ3S%2BPATf6DS2IBWEk7DukCh6Dicda%2FEp4DBvZZlH0Av8Xw8ZmsBPFL2UzshMH4DGMgZhEiA8BQk2%2Fxx7EY%2FCaccqJmcJTYAT2rLlAjNuOqTmBQl0nttC6IIVsrDvJBZgVHwPBAklnxIDdkRbIlqmmmkpgT30REkBDHFS5zeGlrTEuBniSRiW150kUArgFJ4BYQg7sIMDgvoGXcNuNtEkbI3gm9ULPO0VZED%2F0aGDwFcKkoCMc42uJWs%2F4AdxdqTu8fSoaS0tsGS%2Fulltu0TLywdJSEU9lGN1XYzX64hAjVj7Fji%2FFu3ZZYL5Tn6fGtYYG0ZdQom5pdXo0FX15jOL9qEQcZ2zKlgeeUC9O3un2nnQQCAJBIAgEgSAQBIJAEAgCQWCoQIB9RKzOxOYSIlJEuzh9MKn4VpSZz6x2aCkaQTFuIHaKmYQcTFR0ggktATONleQWX%2BEoEBwFO90tU0tJFIS0y3MKhwq4IWQiww1KrHJZDExpYTalnQ4pLWSoCJxuKUCc9KFl4SkqOCEz03MjKZDtobPrUQc2zY3ZsSlTTjlli0tQbEyL82nKHEYULncAbAAlidYwCSqaoATPAmNz7KmJ1%2BEUOsIScMBht5qR23KH4RuCnWBs1kjqlwsJakibYASphGZZwezZKsBEJfX3XBwMkVQlwOWqA1xwHXPMMYeHsurAF7DoyBMjhIaETltr1SZ7FjEiq86a6Y4n6YYAcQJJhgUg%2FIXwFKgqVACioAogsrhHYRt6nDUq2IUViwdoTUmgCIp58Jk4vxVrQd3RpE1WmqNR8SRyHYmCSaCLQEeo6CweJekiuq0hN3wvhiSL%2Fwv5hwtdJnwHnkRfPjG5NBj6woxptq8cCNnFR6kWUmvcrEX8UNeUNWj6hx56KIqsFUgiCASBIBAEgkAQCAJBIAgEgSAwtCDAOGJqORnBnjLjqHtV%2FEDGjkNIWUyMI0YQCb0qNTuBCh2%2BwCayvWuLmURB8AqBDZlLZBj2rzmSiDVh79iJCWxAHdHMMxIxJ6eeemqLUYA0IHovKQJuQckmnsdFHHzwwUy2JZdc0vMm0ScCcVym3xoJE17EDLvYDFLN0vbb9W7cgjYVbnvuxk%2F%2FULvkVR37Ie6EXszF8yIu8BhUH9iP5tjClrTVzm4twT8uQkesVP12oytok4LC3KEKT12bwl133dVwq07JP1Rn20LDeDTrFbQZ2fdnw2q8GaRmASU4KI%2FG0Xu1036NShRWIyxPn%2FY8iR4ECGDQCKJDoMtwPo3EUEyWVUpT1CNysIDxTmr1NOXWOhHhhKYIrWfBkN90y6Ay5AptgS3BQtSrsTJ9SpaoT6Nb2Dqk0KCo8cX5QOpypomKBqak8kQdPjd9yS1KpNuCtKGq0g2jUQWsN2fCmjLa0LcQEqMHt9wGgSAQBIJAEAgCQSAIBIEgMIwhQIHAIut3Uszz0ie03B6DvScX3dHj%2BN8qflpClZ42P63kl3n%2BxXrpO%2F3PNYaB0eg7a08%2BL3qfazz%2FOYUh6eqZb89abbloKLRDjwCm5Up4KZ%2F2gcjVbLcvhBhqqx2%2B021nUNID9zVwC4ZhyQ1cJrlBIAgEgSAQBIJAEAgCQSAIBIEgEASCwFCNwFfLHX21rQ3VwGbwQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASGJQScpOCchXZ92nkNX2zKmv1iFb9Mra%2B8U0c8FD7dIye%2BzAhTd0hAwMmqDgvudyRO9HB67wDnegjU6bjV7nqwQv72t7992ufjvNSeg1y7%2FbZvsD30RGu6aE%2B6Cf3K%2FbRgoTrqOc611VVFxe6wW1YSQSAIBIEgEASCQBAIAkEgCASBoQUB9tchhxyy8MILL7DAAvPNN98iiyyyyiqrHH300b%2F5zW%2B%2B5BQuv%2FzyBRdc8KabbvqS7QxK9WY%2FOstyoYUWOv300wel1iCWOeuss%2BBjLvPPP%2F8yyyyzxRZb3H%2F%2F%2FYNYtxVrI2xPkhhcCLz44otHHXXUzjvvvOuuux5%2F%2FPHvvPNOGwkz37rdZ599dthhh3333ffKK6%2Fsl3%2F45S9%2F6RjWN998U8U%2F%2F%2FnPl1xyyZ577rnTTjvtv%2F%2F%2Bd955Z5cAef3113WxyyeXTl966aXWV0s8%2BeSTKj700EOe%2BB5vueWWAw88cMcdd9xjjz2s5A8%2F%2FLCVRFBcddVVhid3v%2F32u%2B6665RvuY888shhhx1mXno79thjX3755ZZlFtdcc40qKu61117nn3%2F%2BH%2F%2F4x5abRBAIAkEgCASBIBAEgkAQCAJBYChCgB202mqrfeMb3xhnnHGmmGKKySefXNo1%2B%2Byzv%2Frqq19mIugR7Rx33HEDN8JM22abbZiBH3%2F88cAl%2B839xS9%2Bsc466zz66KOVe%2FHFF%2Bt02223%2FbTd6n4bGfjh7rvvrs3RRx8dPuOPP770yCOPzBgcxAFfccUVa6211hegPgYeVXK%2FGAKvvfbaZptttuyyy26%2F%2FfZbb7310ksvzer%2F4IMPtEaeccIJJ3iy4YYbemhdLbXUUmeeeWaXK1DsT3%2F6E5YDB2IBkEyceOKJim200UYIBJ%2FSSiut1Li7t956a6uttsJ96cia1Okmm2zyyiuvdEduoeIc1lxzTYyH9IUXXqi8BWMAG2%2B88ZJLLmml%2Ffa3v1UFPXLBBRfIXX%2F99eUqI41pKYrsrrvuqt4xFT4oszDN6ssgTzrpJINcd911VVRdsxgPmpPuSJIOAkEgCASBIBAEgkAQCAJBIAgMFQiw0RhEI444IgPqvffee%2Ffdd3ECxWww9Lo7y6bzuUQFNnzvu%2B8%2BRt%2FAODAhJ5poollnnbXfje%2BB68pllyEWbr755ippP%2F3BBx8su69b93ONvFtRmiGpC3YffGzB2xCfd955PTnooIMGhS2xt64we7On2dx%2B%2FQh4XyeffDI%2BgeiCFU9KQWyDDcA1Gcxjjz22%2FPLLUzv4Cigf8HjIh7XXXruH0COcoFm6%2FvrrVXn66ad9LBZhVdEC9kOtUjugHfR13nnn6egvf%2FkL2cZyyy1HYtFdNtb%2Fpptuevjhh3v4xhtvqL7ddtuRbRgAig8ZqIXqC9GBiJBrEcp9%2FvnnSYOQFVa79nEXci1%2B35HbM844A5VxyimnGOSvfvUrA7aMq%2BKvf%2F1rSg98C%2F3G1%2F8K0mMQCAJBIAgEgSAQBIJAEAgCQeBLIlA8BoGB%2FdzWFBNpggkmmHPOOZv4%2FPbbb7dlbBt3vfXW60oLbPWeffbZbCJGE6X9Oeecw7B6%2BOGHNUV7v%2FnmmzOsqlmGni1sNqOLwsFWtec2u1dccUVSBzoH9iBjn4H285%2F%2FnLF25JFHrrzyytq0S%2F773%2F%2BeoccGtKes%2Fccff1xdg9T%2BDDPMgCXgFEPRgTOR5eHVV19dnfo1cvbdEkssseqqqzIkW3AAz9mPLEQtG5Jh2LPWe6vYEsVjnHvuue2JwU833XTolxdeeKEe6tqu%2BgorrAAiW%2FM1a1v%2FrMuZZprJCFEf9utr398YLrroIrMzHQVaI619CVNmbHLw%2BWL0TreppBsCXpOlZQk1tynvCFPB4cJLQbtZon5beWyAhdFd8Jg9K9MrLm8UBAie4cYbb6wq6DLOI0iDZ599VlrJNdZYowkwVPER4bW6y4wXiZXAH0oLt912m9ZoitoAfJWW%2FWmnneaJknIvu%2ByyllufHpqCC4nPR3eNIfFE1zgZ80JRquh7bBVxcZ5wS2lPkggCQSAIBIEgEASCQBAIAkEgCAwtCDQe44477mhjtvPLh2K22Wb7wx%2F%2BwByzqzvqJ9c000zDJJ9wwgkFAVCYTUelMNxww3k41VRTjTbaaJVmN8ktHYL9bum3335bfAnFNDvJJJNIIEmYkFT3Y4wxhlquscYai8LBtvUcc8yhQF2LLbaYaAZMObeTTjrp1FNPLfGd73wHicHe%2FPa3v01J4onRqWX%2F%2BtJLL3WLMdCpkdv7RpIMP%2Fzw3%2F3ud0cZZRSJvffeu9wE0A5KInA8n3LKKQ1ArpmqpW73Kh6jJtKeIxmUr4c4h1KwYDaMTbOGyjJFquh3pJFG8kQvM888M2D1vttuu3nCkQdoEjPOOOMzzzzTWq4EVYwsA7N73pOV2y%2BMADINBYGsaC4VxAxUDRQUNAxeDdaoaZCwDRakN4uUaD3iIrSAX6piFhgSD5PQCtBgWK5FfaDpUFt333135VJrKMx1pduFyBiET7%2F73e%2BUIfjBgBlka01QC62hHTzxWZGL1KdXBbBwniAo8I3IQ7qRtnr1hU7h1aUvq06zjbpRFynXw8%2B0HpMIAkEgCASBIBAEgkAQCAJBIAgM4QgUj8HWZg1Ju1jltA2MaL78tnefeuopxAVbmynEXCLI%2F%2BY3v0newBJkvtFR4BYEBGAP3nrrrdNPPz3rHplg1mQVGiGqlyZ1kKY9UIwlyPHf7cEHH0yfwC6beOKJf%2FCDH2jto48%2BYk4Kp6kRXvy2uZmQCAfDo514%2F%2F338SoVdoOiwwAYbkIZoDLwDzQSBm94WjZ%2BnSIHtIwKMDwj5w6APMEnlEq%2FhvSjH%2F2oBq8ixgPZ0tcRpl8eg1GpI4yEjpiuRmhb39RUZzwav1vxE5iQYiMgSVi%2BVP3kK1xgDBg%2FQ4bBej3iiCO0Q0Ni8JpqFz6HGACSqrSHSXxJBLwCgiIvtCkirHbLcssttywmQfuWipWMQEBiUErwQ2mF5VotuAjfgrSvAymhDO%2BSNjCvDPNA7eOJ9Wnd4j0IJ4TWpAPxTXVZEf4j1okYL41%2FaO1I%2BDoQLKoXl1XipVJuVDEKDTxGsRzdigbsMzHOFjemm4s%2F1CbyxNy7z5MOAkEgCASBIBAEgkAQCAJBIAgMFQgwn1lSTGkWPZ8IBEV5aiAWHKNgCtgGuYIZstoUFoDCPi%2F6Ar%2FhoSxC%2BjZTFiIjnfuGJ10eg%2B%2BGknKLJWBJsRwrZAR2gqxCWFEtq4XH%2BK%2F%2F%2Bi%2FciFgB1axhEMDbpEZcoEFYiCQcxlCb2igLmgoGXRWmutdR8RjVKQKhsvxiMBS2%2F85sxGNgGyqAgCyN%2F%2FSnP8XDkI608pXol8dgPOqIk4IyLFNmL2PTbr5db3vx44033s9%2B9rOyfw899FAdlauLMSNecCks5cLT9PWLJsKB9PSb268cAQtPAAqanEZNFI9hSTQeA%2FXE%2B4Orkc%2BBR1JXKqNWnRVSDIC3ecwxx%2BAxumWKx%2BAhYvB8OtAgvId4XWlQAsflYZsX9yLeHxRH7UlL%2BBzKl8rKKZbj1FNPJaIoCqWKWfY4kwru0SoaFVLOc59nXxIME2gK2un6kbW6SQSBIBAEgkAQCAJBIAgEgSAQBIZ8BBqPwfTm9EF6wTz%2F8Y9%2FzHHD4OWyuz3hMcEJgrbBL92CJzfccAP3DSZ54xCUZyJRJvTlMbTGq0KtySabrCIAiHpR4DD%2F9cuWL4qjeAx8Qtef4t5777XvzFnjW9%2F61thjj60dwS7KRqOIQE20AAXFY2AeNM44VfKee%2B6pjvwyUaeddlqhKhikDEyUi4AelUv%2F4NjZQecxzFHjAjxWdSETDcnsaoQEGPPMM08daGJbH4%2FBtFSSFelYWLdKFp44HIiBtLunX23m9ytHADlGjzEwj4GMeu6559AFeAMkg6NJuHvUSEhoBMVt8SswBs5UHUCPQbyBcNMdEYUGeVohEDBgxUtYDEQRTndtpEqbr%2B%2BO%2BgL1of0WIKWCdfToMfAVPSFkfZicWQiWqJtag5XAjWAdfYCWvcH35OY2CASBIBAEgkAQCAJBIAgEgSAwVCDAYqrzSuwjM3xsWDPzhW4oYTyuQC6Dfe6552YxMaxcKAW2G9UBQb7AFF2H%2FU%2FjMUAheAVlwlxzzcWK16BEUSX98hiiaFYgUBVtbY855phoFs4XwnGQQOiUefiZPAYNv466QRr1RWcikgb1RfEYBP%2F1mj4vj1HRP5iZqjMtDQ9o%2FGXMEb2DbKEq6ctjQBi8iBdsBnOy4UkkIDBjjSS%2F%2Fz4EiF7wcrivEv%2FoyBshDbKS0WjohWIY2gAsD68JM1BPOEmRavAWqVuFReDkviEYRavCScSXYtV5%2B1bCBhts0OJdUBNZw%2FyMSvshwIvvCNfR6laCUAdVol9rCa3Xcrkv%2BfS6OgoD86SReErKNR4z6tKA1QLmsA4xodPoy5y0XpIIAkEgCASBIBAEgkAQCAJBIAgM4QgUj8GybvYR5QCFAA08Q4ylxibCBvCbMBEWVjP0JCgNZDGv2hyd%2B9CjxxAWwM6voBCsLQnkA4NdeE8VK7hE8Rj8SjAJ2ik9RuMxiPBFGFC4glooYEud5oGx1uUxmjHY1WMIxaliiUNqhMIFjDvuuCI3Gsnn5TGackNTDFJuOIiLMmDr7NfWkbAGk08%2BOaKmy2OU%2BB9%2FwnNHgJHyROjBswaZ338fAiQQKAtLGn1RvaDLOI%2Fg35j5uDvUQff4GOsKn1Bvli%2BJaBXCctbCq%2BrWG9aCl1Dd%2Bih4jmAnuBpZJJbujjvu2DgTFSmFdGfNe%2FVCo4hT0defqNgJvlFG24WiBtNVX%2Fi4fAhNoWF58xFDlfTlxCx4EV3wZnxVLMJus0kHgSAQBIJAEAgCQSAIBIEgEASGLgQaj1ECDINn5iy%2B%2BOIjjDBC6ecZcbwkxKUkqJDL%2FrIrzdwjd%2BdRggBhmLPcZQn1IE4muQVph9uKj8HUwkUIFoFSaPvaEm4ZXIrZreZXghZgWmrctjglQ%2BMxmH60CtqsKAHGxhDjD9L8ShiGmJPS6rMiu%2FExkCeUG7xFyi8Aq1CUCH8B%2FdqV1s6g6zFIL7RgeOJ1UIPU%2BKGnKTvsbvE5BgAWLYOl6THgAMDKVaCidmByyrxVnj1rSF3rWJsMT3oVjjCquM31lSAAVTwDDYO1ocFaMJiKOmEHV2YxI7hKruB1ezU8Qbg1KcxBCUHR9aLy0JE0FgP6roQTFUITT0Jx4f3iIug3nnjiiRo8dxWLmS%2BJwugLUg3hNaz5yvVrPA43sbaRLeK0uJVblzR2QmvYv5JzWNWaIi8pvQf6hRyI10yFtala5qui32uvvZazibq%2BstZs5bbekwgCQSAIBIEgEASCQBAIAkEgCAwVCLDE68zQikxYY5YWS%2FP73%2F8%2B7gJ1wPhip4tFyYxaeOGFpQV%2FENCSuVfRM5RkGyIfZDHhi8eoAKElY7CJrEHRQRlT7D5Hr7qtcxZKgIGpQDiIuum0CKeKOL3U0RI1GGdEapP4AbsicIcuXOzNUjto3624FkbCsiMmcVtKDzvsJfwQudTI6zhXhEztxeMllBSFoHqhBkG2OFi2ryC%2FdB0TTDABsgUIarkYjDbcq67ZCXBhRhoX6KMKzDrrrGVvolaQQg5gtVfOzlXLTJUhQWmjkqjpVIN%2BhVEl29Ajdqg9TOLLI4AdchQOroCPkiWKphBHpZyYLAzSGjE5BZdwSg6vEGmiI68GF6cwHqzYvDaMoqEUUxEp4UQSrIj1jytQhssV6sN71xdKxJrRXWmHhK6V1XOeiDVvbKRQ2tHvAZ9cFCCoMMvSp8qNRV9Cdhge9kxJPCHKAr1G%2BFGBSXk8uaougsVCcvnGiTF8DmbRmjXg%2BhbadJIIAkEgCASBIBAEgkAQCAJBIAgM%2BQgwjlj9s802m2NJ22gRFGylmWaayWGsHlLUK4MN4NDhJBH7yJw7qjA2g6xCyEqBQMXQ%2BOEPf0gdUTEtUQSzzDJLc0hh3HG1UAwhgI7AP7SDG2wW60sWCwufwJRjc7WoAkxFGgZUiYp%2BWZcLLLAAOX2pLNiVgjEKm4n9YOthYHRaYSuMUGviKKolfgUmgR3XaApHRRhGcSlKslXNixHalzdwtqY2TQ2JIYFOEQOhG2GAlEIZUzBCjAroFv3kwloUejgWmhPMRoUEYa5yLsCZwBP5wwRulEih6pcSQwwNlm8djdGeJ%2FHlEXj44YetZ6%2FAShOEk0yitYnQOPLII%2FEVyAf8hkNtytL3OpT3lpEGrXAlfALWm2AsqlhgaKu2NhQmc7JivUfVUQ10IJY9qYYBeI5%2F6Lb2yiuveG79CAKDpqhLmzvttJMBKInlI93BcohaY3g%2BsaK%2FDBLdoRaFxv%2FU%2B3%2F%2FSxkiAi3vJ133NCvL3Htome5gkg4CQSAIBIEgEASCQBAIAkEgCAyZCNg4Zgqxp3r8GogZPOy60vPWZ0yx2roTYQOyoZhXDCIt2G6mTKgjQvAPWmg2nVoKoBFY8d2H1ZoWcCO0GUw%2F5INEYzmqgN6rgFtmoJbLp8OtBPOTbwudfA27XDaqol%2BtGXnP1nPP8Fq%2FGmkVK1HdacSlVk9uuzUkIzQRT6rxBqm5mDjipTsp3IVR9cRAaK1VI6bTfZL0V4UAYL0Oa6b7RlrjuCN%2BH15oe0JEgfRoHiLteUvQ3qhi6bYnLaEv5Jju2rJ%2F6aWX8B6NQ2slDcbH2O%2FVHWff4Vm0PlW999T1xCJ09Txvt31XextMEkEgCASBIBAEgkAQCAJBIAgEgWEPAewBfQJnCjE8KeRtJYs4wTuj%2FCmGvflmRv%2BZCOD6EAIYp6%2FK6kdz4SLQC%2F%2BZeGbWQSAIBIEgEASCQBAIAkEgCASBwYWALWCe%2B8I4VEQIv6JDPPjgg4NrPOk3CASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCAyPgoATxOUUVEHIwwRwGxiq5QSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAKfFwHnOTr%2F8dBDDz3k%2F38ddthhzz%2F%2F%2FOdtrco7CPWyyy476aST%2Bj2GchDbdFDpMcccY1AHH3zwcccdd8EFFwjEMYh1UywIDICAA1Vvv%2F32c88916JyRnDPWbqWvS%2FizDPPvPzyy5966ikH8vZt6plnnrn00kvfffddWQrU7dlnn33ttddat93yYuHq4rxPrjvuuMPRvd3cSr%2F99tv6coRx3TqY%2BLrrrtPaxRdf%2FPDDD%2Fcca%2BIruOKKK8466yyDfOWVV7qtORP5lltuMS%2BRau66666er%2B%2Fll19u83r22WcdwtKtm3QQCAJBIAgEgSAQBIJAEAgCQWBoQQDnsOqqq7YDR1pi5JFHvvrqq7%2FYLH7729%2FOPPPMo446ajPNvkA7zj0Zc8wx23gkxh133J122snxl4PS2muvvYZIufvuu2OvDQpc%2FzllPvroowMOOGCppZay7FdeeWWJ448%2F%2FuOPP4YARuKaa65ZZZVVlltuuTXWWGPZZZddccUVMQw9x63iPfbdd9%2FNNttMU1ZXVVl%2B%2BeVXX311rW288cZPPvlk4fnHP%2F4REbf00kvrSLNLLrmkir6OHrTPOeccuTgTz5Eea6%2B99jLLLKO1FVZYwRhOO%2B20v%2F3tb1UFE2JghidXmTXXXPOhhx6qrJdeemnrrbc2gNVWW01rEqZZfRnkTTfdpErNy6%2FxXH%2F99fk0el5EboNAEAgCQSAIBIEgEASCQBAYKhDAY7CbRhhhBBbWDTfcYCO4LmaObeIvNgV2n63kE044odlfX6AdO9FOdJ1tttkYkgbDHpx11lmxGezEng30fhu34a7wDjvs0G9uHv5nIsByp6PAABx%2B%2BOH0CWQJlj27Hj8AEE8wAwgKYgaiCNqGDTbYYJ111nnxxRe7cBFs4ARoOTxUTJn111%2F%2F3nvvfeONNy655BLUx957711L1LpFROy%2F%2F%2F4IPbUcUqxrA%2BgSCMQh22%2B%2F%2Fe677%2F7Pf%2F4T7bDFFlv4Hn2DWkblbbPNNhrUuL4%2B%2FPDDrbbaynisbbn4E5zGzjvvTHfxj3%2F8Y7%2F99lMSJYLBMy9j0JfPUMVXX3113XXX3WijjaqiX2N2W3qS7tSSDgJBIAgEgSAQBIJAEAgCQSAIDPkI4DHWWmst6ouylboD5nJy2223UWW8%2F%2F779ZzNRTnPOiOKeOKJJ8raYk9RPlx00UVk7VWMEv6RRx6hhWDN4TTuu%2B%2B%2BX%2F7yl7%2F61a8YWUgJNqNGfve731VhBdhrGmGmdXvHY4w11lh2lpvRx8pbeOGFUS509a0kg%2B4Xv%2FjFiSeeeOGFF0p7zqa788472XF4DFvhBtnGz%2BJze%2FLJJ6vy1ltvtUa6Cfam4fWr%2F%2B8WS3poRICXx6677rreeus17w8uIYQZaA3rEMNAMmF5t6lZsVgOYob2xGrkcoJqKHID9YepQF9UAV8TPgEZUh4fHKM03lRJGAYECD8plEVr0Dq3yG%2B88UZPfDIGcMYZZ7RcXWtfj574Qqk%2BeJRUrpHwt9I%2B1uL1118nscDJNCeUp59%2BWrMoFCITX7EhcfVqzZ5%2B%2BulID99de5JEEAgCQSAIBIEgEASCQBAIAkFgaEGg8RgE7T1jxmPsueee2IBNN91UMblHHXWUWxYTM58uQnqqqabyW9fss89O3K7Y73%2F%2F%2B7nnnnuCCSaw4YvKmGeeeRQYffTR%2Fc4yyyx07xK8%2BKs7JMN3v%2FvdSSaZxK5xdwDFY7C28BLt%2Ba233jr88MPb%2FjY2DxEaU045ZfXud9pppyWzp%2Fafbrrp2sMRRxyxLFNW5GKLLdaeTz311LUL3xqXYAayW5XpUiXdAkkP1QhYbJtssgkNQzmSmAv2jACDbgfHhXyzLIsNq2kSXVgPBAxt1tY29w2MQXERWAVL1FptBbSAbSh3D5IkH0tzM0FuWPw4k%2FqaVLGMjz32WN%2FXO%2B%2B84xbjgTlBrbTWLHgDQLt5gmaRRkq0XOyfvjwxLzExEIYtmkeJRqiY8DNYR0QHErJVPOWUUwz70UcfbU%2BSCAJBIAgEgSAQBIJAEAgCQSAIDC0IFI%2FB2D%2F11FOZUfZ2XTZzS3NONbHQQgvJvfnmm6nuJ598csRFxdvcfPPN2fvTTz89W8%2BuMRG7WzvdqABhAeaff36F33vvPTwGEYWs73%2F%2F%2B1tuuaXohTavRxllFJvFZc1p2S37jsHVBa1fHsM2%2Bve%2B972f%2FvSniBS8xKSTTjr22GPbv37ssceYlsbJrCMLIf9gmepU16T1ahmGLhQwBtvQRx99tGgbiJeeYAXGwK5cYoklmu3ZHVLSQzsCRDhIMOxcI8escLyEVYGg6JmdtbHddtvh64pkqNz7778fNWGBuUUaEAK5rdAWVcDyxjZQBLnFZtBLkAYRI1lRBx10kGXfZc98ZRtuuCE5U%2FFy1UL75ZZ14IEHknAUs%2BHbETEDWdEK%2BHYs%2BL5xbEg1ECyEHAq0wj43FI2vmNyIc8ouu%2BwyiKFmWgtJBIEgEASCQBAIAkEgCASBIBAEhgQEWDdE8kx%2BNv5oo40mOGddDKgaHsNtookmoqMgZuDTUaJ3FpxNZLdNco89EL9isskmw3LY2u7yGAsssMDEE0%2FMlKsG6SVEAcVylPaePaX3psxvmPTLY%2BhFxIxpppnmgw8%2B0JGoAldeeWVVsd384x%2F%2FeIYZZiiHESEOhhtuODvvlct4xJagWVr7%2FAtMWQyE9qQl2qZ2e5LEsIEA7osJj1hoPAb6QgwKUSmao1PNVAFeIdg5C6x5NmEbjjzySCsfQacY1ksZPEZXQdFVTSjAiYmrCEoBuSFBodGlLLiloNfap9EF2SIkw1ALwViMn0SPM4jVq0Bf7dADDzygWdPsukcJd4N7NFohQH3yjz%2F%2BeLe7pINAEAgCQSAIBIEgEASCQBAIAkMLAo3HYNrgLnj314UHaFM44ogjUA0ue9mEDZ4z0FhzeIBuVI0dd9xRGVvAPTwGToPniCiI1SCrkIWFZKCEp9yYY445uIH0jSnaL4%2FBC%2BAnP%2FkJEUiFvDB4ZqM9bqKReeedVzwNHdlx1hETTxciKFan5RGDP7G7bYvcb0UNZWZWgfz%2BJyDAvULEy8%2FkMSxvKiMkBtaiFnyBQ9iDChNcopguxUSoGIDHwBX4ZCg6eIvwN0EjYFFaVApyi3322adcWvqC7zvCWmDq8H6V2zeoRb88BuGHQZJ59JzKiugTHMZIyFHoOohDuixH3wHkSRAIAkEgCASBIBAEgkAQCAJBYMhEABUgzudII43UNz5GG7CQnhNOOKFYoC1UYOMxRCZsxRAgeAyqdcEHunoMaQoKW%2BGtJG087YdDExhiyBAb4hpsuZXol8dgmvEl%2BdnPfoYqYQZWjA5PkCFzzjnnOOOMw%2BukXx6D14mxITpoS8zFRTrCR6YbU7FnALkd9hBARFh1e%2ByxR2MnyDAcAsK1pDlZ4NnIJHAISIYetyPxKOgcfA6FDDYDD7bSSiu1J54LeEsj4WsSQEOYTYxZ81ESGhSPgZqwepWk4tBaHSnSAzWHFGeR8HZBvLQsTAj%2BofudUkPxK8FOtDK%2BMgQjpq4CdLTn3YTPk%2FOUQXa5ym6BpINAEAgCQSAIBIEgEASCQBAIAkMyAsVj4Ci6wQy7A2aOFV1A3oCRqFNFGo%2FR1WNsu%2B22yog62FeP0cNj2AheZJFFxh9%2FfA2OOeaYXUOsdd0vj8HrHx2B91BMLfSL3fAKX2Cciy66qOid%2FfIYJSlhmSrM8cQlwTuAIKT1mMQwjwBtAxcSUS%2Fae68IFbvtthtarKbPBQkPQCZRziMNE%2BY%2FJcNOO%2B3UYoTKItvAJHS5hdNOOw0HgtnAgaAUtOOQlGpE6Ji99tqLIKTUR%2FQVaI0Kjdt6kRBtQxmCip4sLAr%2FFBxLK0yMpK9GR1jSBEjkST3fMiKFd4w13yoK34HH6JdCaWWSCAJBIAgEgSAQBIJAEAgCQSAIDJkINB6jWUM942QriZghOIZtaxwCTb5taHvWbDRHhzQKgmn2gx%2F8oPgKNAWCosX5lO7hMXQh0qbWXD%2F60Y96TlytARSPYbO7xRMQqcPpJDQVZTYKNaC6dqq8rfaZZprpO9%2F5TvEYDnpAqnB1qVyBGd0yOZtRycxEudSpE1Wmfs2ubdZ3nyc9DCCASeA8RQXRInMi4ogcnN%2FhvZsgTxDcAiFE99SSmrjgt%2FgNn0MXB3oki4oqo2JooEcsOZIP9Ag%2FDqzCRhttJF1VLDksiuNRJMg%2FfFBkQobUbZAAg%2FtJv%2FErjM3ngJEr8ZJVesABB7QzXgX68G2i9VrImtasMYuJQSjSnpRopG%2FJViCJIBAEgkAQCAJBIAgEgSAQBILAEItA8RgIgaWXXtresa3quogrMAn8OASjIJywpctQcsAHGgFFYDp1XonQFvgEu9IiVGhEC%2BxB1tx8883HccOONmtLGr3Q9StR3akoCqhiK7xfcPTuSJEpppiC6adZWgt0Ct0ISXyZnPbNOacowNP%2FsMMOE%2FtCa%2FQYdZyK6gJ7CqYhgoEzKO2hE%2BorsPjiiwufaNPcoSfjjTdeC1ZQY2AhMgZNeQBZfr%2BjzcOhBQHMAw0DSY9lfOONNzqGFa1Rrh84BLeECoceeqgsHJ2LCoK8gcQI10Em0bOMuaVQaGhQ3AmUiHXoO7LkLCTMxoUXXuiWG4u%2BLFdyILc0RbJwcUiJ%2BpQadPgNkWmV8VE4LLgG4NdgdGQNc93Culi9PLOOP%2F54Q9UjLk5UUqE8BPTwsVx11VWtLicv%2FJ7QNNQd1j%2FPFGSgrxUh4%2FttBEsbQBJBIAgEgSAQBIJAEAgCQSAIBIEhHwE8hu1jhIA4FfQV7XLLXCK6QB3YAq7tZvaRGBQOM3VciD1rtMAEE0ygioSzS9hlpayw14x2EHKzeAzpGWecsevpDxbGF2MKNVEnVPYF6pFHHsFR4CK0r3ElF1xwQaL6IjGUt5FtP9rI9e5CPjhUBfVRtiHDk8C%2BxsacVN5gbHNzRanyomoQ9vfshru1Ha8vlmPfIeXJMICAV%2BxwHEY9EsDldZMllMIBxeG5iBaYDcKGuvAGSA9iDBTBwQcf3LNgAIKRQ%2FopxukDoXHIIYc0fRHmwSEj1jnNhr4sSOe00mmQGOEfMAmtZAGLcBMXVO%2FG8D%2F9r%2Byzau4nPiK0SesL3VFOVc7x8T3qSEW9tLrGU9%2FXo48%2BavxVsWbUonYMA%2B80UwgCQSAIBIEgEASCQBAIAkHgPwoBFhxHDGaOwx97LlvPnlCzt4AASA8GHYaB%2FUUej%2Btg7wsFcPnllzvqsbljKPbcc88xlDzRfqWbQ0fB63buuecWsZPMo1%2FAmXu6NjCXLmwra7ZvSaafcydtdmtHBABjw7FUMS049MGWdLMW2Y%2FGrzw3k0%2FbjFZdmXYuZ98e82QYQMByooggTugGwaAjEoqz74U9wOAx%2Fy2zfufOkeqxxx7DGFiNPUQHAlBf5BPUGnQRxcJpEOGARmukXDXrQxMTo%2B8AfKHt85HwORiJMKHti8MK%2Blr7VvSkfV%2BYPd40Bum3fdH9TicPg0AQCAJBIAgEgSAQBIJAEAgCwx4CrDNSDTxGhar4XBNEEdDP26dW3eb156qbwkFgsCCAi8CqNTLhS46Bk4jW2oGqX7K1VA8CQSAIBIEgEASCQBAIAkEgCASBz0SAyoI3CgeNT%2FMKGaAFWgg%2BIOqKaNE3muIAFZMVBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBL4AAPbx4m0sssUQ792HQGyGw507Cc58Uf9BrpWQQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEAS%2BJAIV%2F%2FNzNaJKTwyBz1U9hYNAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAn0REGTGgSOOGvkCoqa%2BreVJEAgCQSAIBIEgEASCQBAIAkHgPwQBxpQDHO%2B%2B%2B25niPSdch1P6cTGvllD1BMnZjpx9ZprrrnrrrsSO3SIejVD2mB%2B85vfONu0nUY6GIfnGNY99tjjnHPOcRzwYBxGug4CQSAIBIEgEASCQBAIAkEgCAxdCPzrX%2F9ae%2B21RxtttNtvv71n5LaJ11lnneGGG%2B7SSy%2FtyRqibm%2B55ZYf%2FehHDkCp61vf%2BtZhhx1WR2SyEBmtiBob3585ZpTOyy%2B%2FLHLpxx9%2F%2FJmFU2AoReDiiy9eYYUVbrrppsE%2B%2FhdeeMFIDjroIN%2FgYB9MBhAEgkAQCAJBIAgEgSAQBIJAEBhaEGBDrbXWWiONNFJfHsMUTjvttEUWWeTJJ58cYqeDdphkkknGGGOM7bbb7oILLjjiiCPqUNf999%2FfmP%2F2t78xFaeZZhrCks%2BcAoW%2FU1SmnXZaBuZnFk6BoRSBiy66aJlllrnxxhsH%2B%2FhffPHFVVdd9dBDDw2PMdjfRQYQBIJAEAgCQSAIBIEgEASCwFCEQPEYI488cl8egx7jT3%2F607vvvvuPf%2FyjzYjO4dlnn3388cfff%2F%2F99rASmsIAOE31vffea1l%2F%2FvOf33nnHQeUSOAcnn766b5qhw8%2B%2BECDmu2b9bvf%2FQ6LQlDxab4tBx54IBnG0Ucf3XpkHk444YSTTTaZBl955ZX%2F%2Bq%2F%2FGnvsse%2B%2F%2F36uBC0QAScazbq0XxV1%2Fdprr%2F30pz8dd9xxb731VoVpOfggmEs7XYWoAxqqtHbUKjQ8bwPoSXz00Uf9%2Buz0FMvt14MAHmPZZZft8hjIrtdff50Up%2B9rqlUhq%2BuH4qGSTeHjDGK5VkXXPcStWm%2B99VZfjuLDDz%2BkEbLmn3%2F%2B%2BfAYX89LTy9BIAgEgSAQBIJAEAgCQSAIDEsIDMBjmCaRA7XDHXfcUVOWmH322UccccThhx9%2B4oknJn5o1hyjbOmll6brkMWzQxZ5g1qnn376%2BOOPT%2FLB9WOEEUZAmMw777xN8IAhQUGgHdTSrMZxCNUX85A%2Fy%2Fe%2B9z21ZM0000z33ntvZXV%2Fd9hhBzzGWWed1R6iHfbaa6%2FVV1992223HXXUUbXMNWaUUUbhI8PSNN9TTjll8skn16xr6qmnVhcvQctB1KGkyyDXWGMN%2FIONe8PGb1Tj9913HzS23nprY%2FNELI7ZZput0BhrrLH2228%2FFm4bRiXQIHPPPffMM8%2F89ttv92TldrAg0MNjPPHEExb5yp9cHKx%2B8Ytf1JK2JO65554tt9yysjbYYIMrr7yylvRll11medx22201fgzb3nvvvdVWWxUNYkkrue6666q4yiqrHHDAAXi8Kmllat%2B3IMvvjjvuuNJKK%2F385z%2Fvy3UMFmTSaRAIAkEgCASBIBAEgkAQCAJBYKhAYGAeY5NNNkEF3Hzzzeby6KOPfvvb3x5zzDHZfYcccsiMM87I5D%2FuuONk8dpAQSAxtthiC3YZzw5Z5513nqyTTjqpwlbMN998ai200EJuNatfpiK6A8%2Fwwx%2F%2BkLoe7TD66KMjFqgvVLzqqqvc4jFYgttvv71h8Ph49dVXZXWv66%2B%2FXtZEE0105plndnUgyvzyl7%2FcaKONZCEx0BpnnHEG%2FuH88883gOqR%2BTnBBBN885vffOCBBx566KHNNtsMOWMWrEv0Cx5j%2Fvnn55NC1FE9ioYqkMiGG27o1sPvfve7qI%2BddtrJvGaYYQbNHn%2F88U2qUVUMCW%2BDDAmPUYAM9t8uj0FFY8VaGxbPJZdcgosg1bj22msNkpoC8YWOsIxVsZA4KBV3ceGFFyrWImwQGu28886IDjyGty%2F%2BxvLLL4%2BjuOKKK4488sjlllsOwaWMNlWRpaTYnqeeeioqQ254jMG%2BJDKAIBAEgkAQCAJBIAgEgSAQBIYuBAbmMTbddFNkQmkk7E3jHOgWaoI0FUiAWWedlY3GFkNcoCwqi2x%2ByimnnGeeeWxtEz%2FIomEo5xR2PWZglllmYfQx7aWxAU3wwLjDBuA0%2BLPgECaddFJNVZtUE7LwHj3w2iJHI6Ay5GIhCPUZpM0RxgAWW2yx8cYbr%2BJjmCxWZMkll6QeqXZOPvlkFbXgVq7CGim5CHHFAgssgEjp8hiICyatwvbcVUTpVDtOS%2BG9wtStLft6WL%2BcDlAi3SdJD0YEujwGgQ1iAcFV47HYCC0q8CYSjBoHKVFZDz%2F8MHbL8sNUaAH%2FoEBlWf%2B77LILdsuLJr1Yf%2F310W4O0KlcC8yqsDyqGMnHc889V1n0RdpMfIxCI79BIAgEgSAQBIJAEAgCQSAIBIFBRGBQeAzb0MJizDHHHIQWTSSvIsbg7LPPRk0svPDCDHxsALuPlYdz4AaChcAeoCZ4Xpx77rk1HiJ8%2BoTppptOfIA777wT%2F7D55pu3odofP%2BGEE0TqoP3gqYEkUdH2t2ZZl9pZb731nCrSyleCacnFY80115xqqqlwCy4eLkU%2B6G7RRRfFYzjjUmElafvFLqAwQbAcddRR7ErlHX8pF%2FuhMB5DyAu3A%2FMYLFORNAhUnI1iImxYzIxLFzWq%2FA6ZCHR5DKFUiCI23nhj2gmRW5APCDSXl%2FirX%2F0KyWBxOswX8%2BBh5ZqUBdkvj2FdUexgLeh8cBTcUvAkJ554osJYLwIPS1Q4Fx9OIWOJJj7GkLlIMqogEASCQBAIAkEgCASBIBAEhmQEBoXHQCyw0MWUQEH84Q9%2F6JmOJz%2F%2B8Y8%2F4Q%2F%2Bvx%2FKDTa%2BDW4nnuAfWvwKxAIHE3wIHkP4CxUQFD0NunWUqiAV%2F19zn6S0wwWgaS361kKDkItoX3HGI%2B4FF9HlMVRBaGBdFNAaJ5HqZc8995Q16DwGO1eoDRyLOX4ytG%2FgbQ4%2F%2FPA67LXvwPJkyEGgy2N4iXw9SClIL5ZaaqltttkGoVELDN9VYS48R4sJw3LDDTcUBTEAj4H0sPCceuPXxf0EreFWWAyRbBEjNEuN6cp5JUPOqshIgkAQCAJBIAgEgSAQBIJAEBiKEBhEHsNm9Pe%2F%2F31BMLouEnwoGH22oQknRJkgob%2F66qvFtXAJMsBCRHF4%2BGk8hjMjZBWHUIiJX6FBJiTmBBPCtYQtWQ1qmZEokEXbzlZF%2B2QVuqjYjNWIg1TwKoKLklU4iqLLY6jLO4afy6677ioKh5Mj6EkQEZ%2BmxzCAblAOO%2BzlV1JxPnWnBWwMlYiwHtqx295XLlKjyu8QgkCXx6ghWcAC2Ir0wosK86BAe4noO9KdY445RlALvIRFqIoC0hU0xi1qznJChmhHtBYtHHzwwZQYVouLMEOa5xS9B%2FWFptriwfJFjzGErIoMIwgEgSAQBIJAEAgCQSAIBIGhCIHGY%2FR7GkjFx%2BBXYufatvI444zDn6Jmhzqwv7zEEkvwKyGYb2E0KhfDwL6Tpqvvy2PwK8E2kOuLsLHgggs2FsKZrSJwio5IhE%2F%2BgTbpngDSN1QmUgWFwjmlQoNW12gQ0UTxKnbAtYzHwGmUO4zy2hS7o%2BIuKk%2B%2F0cNjOJGkgnKou%2FjiixuGffNqWZwQEg5uCEzR%2Ffff32El%2FF8qi5XqpFdnvLaW63l%2BhzQEisdAshkYDkpAToutBolt4Ge02267Wb1CcYp6UWFV5Dqid7XVVsO5%2BRBoNpAV1113XdXiciIgRsXHsIB9FHiMyqpfZIiEz0QgWYFiuCDVc%2BvfgSaJj9HFKukgEASCQBAIAkEgCASBIBAEgsBnIoDHYLsRPzDfLr%2F8cpadS%2BALO8s0GCwvBAUvD%2B2Q01MykCg42oNrvyAAGADhBVh2JPeO%2BeAtohbTj9BCJE9qfCxEv3oMPAYfEIQDPxFt7r777hoUSrE8PtiJ2iTy174zI9ARDEyxLBAFWJHujEj0PVfMgSCGzTBELDg8AnMimgdrVBe2zhEdrFfshCc8CBxfwtLUrACPFVKj9BigYIQq7DnugtqkDnXVIJrC%2Fvucc86pL9yOMdQ5LJwO9PjWW28ZGKBU74nzCQGMh%2FaL1ekOPunBgkDxGJao3i1Xb1DoV6%2BbtAY1gaxDLHiJQrJgsQSwtfZwbmqJCGqxWXJEQYqVpEdFh9S4dQQPlszrxnVU7FAfwptvvmmdoDjwGzQeDsGx%2FBw0rNZTTz217777Wpw5r2SwLIN0GgSCQBAIAkEgCASBIBAEgsDQiwDj3UYz87zn4ijhkFPWGYqjjmbgo%2BGQSreYB6yF8riC2stGOzDHaBU8rIgTDu%2FgskG3wMrzkAVXEDHn55prLuxB7XQ7GYSqQQHMg2Y17vTVClCA6GBjyvKwuiOlwCf0QK1BPIMySo4wwggakeACI%2FJnlRS2whPXIossQmJhLkQgbqsKqYY0PQnGQ%2Fk6MKUKa5n56dhWt1puVRxIoSRKhK%2BBh3qsKZtU67S69gtDLUCm4o6250kMLgQocIiIMBgG4BUT%2F%2BAWVl55ZZSaBLEN6YUspAQvIVwcyYQPRJbFX0IdkhtBXSxOtJULa4GOsCowISoq4xQbFfmMqKuiSLAlycCHkH84Lkd3qA%2B5imnKNzi40Ei%2FQSAIBIEgEASCQBAIAkEgCASBoQ4BFASNPTlB93JoyHnnnVdxAwSgqMM%2BTI3BxcfE%2FjXq4Pzzz1egO98HH3zQkSU09oIAlDEo94knntBy4x%2FsdFN9aJwav%2BoyGDVld3u%2F%2FfYTFsN4WpvMzMsuu4xag8JfUM0yBltuS9QWufgGurYbrjUa%2FpZri1xd9iMDtgxGR1HQY%2By0005K2hYXiZQCBMWhikCdZCeyjLCUFQqjaLRsY93cyUt4l1SIA1Xs4O%2Bzzz5kG3L7ZSo0IqwHrUiRM21USQwuBFBnSAwqixqA92Ldet0oLG%2BzHccj18oR2oKDiRXiGxGZto0Zi4UQ85xsA9ll3VoVLcorzxESJtydhafx7qu32omXVLQqOENplrqpheNo7ScRBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIPAFEHA0qhNXHT3Z73mRTiN1QOqf%2F%2FznL9ByqgSBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQWAQEXj%2F%2Ffcvu%2ByyZ599duDy77zzzvzzz7%2FUUkv1S1bceOONM8444%2BGHHz5wI8kNAoMdgZdffhkd9%2Fvf%2F%2F6FF14YlMH86U9%2F%2BuMf%2F9gt6QnirvtkiE3%2F5je%2F%2BT%2F%2F5%2F8MCcP761%2F%2Fev3117%2F99tsY0X%2FfeN57771GtP7rX%2F%2Fyr1a7bZ16fSjZdvt5E179u%2B%2B%2B22qZl6vdDkrio48%2B6juqVtGwf%2F3rX%2FdbwKuU1Up%2BsQTCeYCK%2F%2Ff%2F%2Fl%2Bz8ztAmb5ZEIB83%2BeeWIH9Pv%2F3PYSS996W%2Fd%2F%2B9rd%2B%2F5%2FVBvDPf%2F7zscceu%2Fvuu%2B%2F5n0v66aefbq8A5nJuuOGGBx98sN%2Fp1Ct76qmnXnvttb7Q%2BdfmxRdffPLJJy281qnhKXzHHXf4X%2Bfjjz%2Fes4SsMQO45ZZbbr311ueff%2F6%2F%2F%2Fu%2FW8WvIWEwhvTLX%2F7yzjvvfOONN%2FrOqO8YegYMkG4ZS%2B6BBx4A4H333ffBBx90s75MGob%2BGf8yLfwn1LX%2BuwtvmJyy%2F6f4n4t%2Fgnw1Fm379k1Wludf8xc0TIKcSQWBIBAEhkwEjj322G984xsrrbTSP%2F7xjwFG%2BNZbb00xxRQzzDBDv%2F9PvPTSSzWyyy67DNBCsoLAYEcAX7f66qvvvPPOO%2B644yqrrMJSGHhI%2Fuq2qh955JFW7M0339xjjz38tiefKzEoRsGgN3jllVdeffXVDKV%2Bm%2FX323777dfMsUFv9t9RkhWDBfXvDCPx39G%2BNhl9Xk3jSe66665VV10Vudr9Jwtt6%2B3ff%2F%2F9X2wMoD7ggAOOO%2B64qs4EPv744zFjg9ia12SQhx56aBl6%2BDEWd09dY1txxRWPPvroHvZMX6effvree%2B%2FdU%2F5z3T7xxBMGXFUY7y%2B99FJ35Vgq55133hegoy%2B%2F%2FHJvFiw9BqwP56ijjvpcI%2FyShaF0yimnQKktA7eM6AGaRWptu%2B22Fme7FllkkWoBIL4v%2F2Isu%2ByyK6ywwjLLLLPRRhvBrdsa02nfffeVu9hii5155pndLCYkZDbeeOMlllhinXXWQVxUrv%2FVnnPOOSuvvPJyyy23%2FPLLa3y33XZD8VVufbb6klUFYDgwFdPt9Eum%2FYu3zz77VO8Gttpqq1111VVdw7Bv%2B4gvCHQHbPX%2B5S9%2FqZLWwCabbCK3ANxggw2%2Bqn8Bbrvttosvvrh6QTGhm7qLue84PRl4Iv1WGaof%2Bgqs%2F0cffdQsPvzwQx%2BCD%2BQzZzSE%2FC%2FjM8fZCqAZl1xyyU033dTnaaWhMloWCtH%2F6P1%2FYWD%2BtpVPIggEgSAQBIYiBPyFT2WBgphooolsGA0wcn9lTT311D%2F84Q%2B7RkEr76%2B1kUYayR9j7UkSQWAIRMDfuttvv%2F0222yDvmNH%2BEt44EGeffbZiy66aLNc%2FBnMWGPvXHTRRc1QGriFnlx%2FePub%2F6GHHhqYNuyp1e8t82GttdZiIiEr%2BrZmqOxlFsRn%2Fm3fb%2BNf%2BUP7zv7ORCwMogzm8w6A3bT22mujp9of4XpkhW299dbdzbhzzz3XH7rw%2F7ztV3k8GMC33HLLkmRcd911jNzXX399EFvztzQQVKkV5Z%2FNYlqYGK0FhM8aa6yx00479Vgc6lqx66677nPPPdcKf66Ef7q32morq0IthAxkNIjWaIi98sor%2FuavAp%2BrZTw27sU67NqJ3gig9t9%2F%2F8%2FV1Jcs7L1gDCD8zDPPaIrhxhgf%2BHWjkjbbbLP111%2FfxH%2F%2ByXXQQQeZkQ%2FHawLR5ptvjvAkUTjjjDPQNV4N9GqcHq633nrmvtdee1144YU6bZ9bMSpLL720b%2FCEE0646aabWi12FoLCWmXRszFZ%2Fdakf1i8CGvVP01qHXHEERgABpru5F577bWt5S8J0QDVvb5TTz1Vd7gsAyPJYBj6rAb4Zk3T%2BA34yCOPVMWAzQvm9sd1hJOxBqznK664Aodmwfskt9hiiy%2BvyvDWvOgLLrhAL%2F448V3oBTs08OyAbLRe0xf713uAxofMrNtvv90Chrzh%2BX%2BZVYeya3xav2P2r9kOO%2ByAvutXetRvlcH%2BkMLNvzMYm2uuucbgX3311SbU8QlbGxtuuGF7MthHmwEEgSAQBILAV4WA%2F82NM844o48%2B%2BnDDDecvuL7N%2Brv95ptv9ncg0WwPj2GzySaIXIy3PydGHHHE4jFsbyns70l%2FLdh8bH%2Bi2wm1Q8pyJFXt6cj%2FOj13%2BR9QN8vfdZrShb%2F3WjvdAtIff%2Fyx%2F0337F32lMltEGgI%2BHum1pJV1zW7WoFu4rTTTmO3Nh5Dlj%2FFF198cXv6Xeu4W2WANMuO8bjwwgv7y%2BrLr1h%2FvB1zzDFrrrlm25Tsdu3zZIAwGb4G86fb7wBp%2FzJ8YRtcs8xw%2F0SA3T87ff8Ul8uQ8V6aVa4K2YyrOySmqG3ugQ3bbvmeNB6M3cpMqyVx8MEH63TQeQzWE3mPrUMGl3GylxmAWugxGYjf%2Bk7Q4tluu%2B2sxrIQewbm1ou%2B9957WYjq9is4YY2yuBnICuuCgal3ll1bIf4lZ2b2%2Bz%2BCvt31PCFK8S989yGstH%2FIIYd0H%2F6708xqKOkXEaEvCpaFFlqoq6fqOwAjxwfCoSfLPw6%2BL0Y927yyrD2vDETlhkkj4X95OBP%2FG%2B2p61YtaNsI7vk%2Fl%2FfOlNYIDqRq%2BZB9p14HosNylYUKaAIM%2BwvIJS%2FlC%2FyD03dU9QTtaZFYUT0FfKGGgdVpm9dYNcZvv%2F%2FCVF3%2Fy8bF%2Be5aa%2BbFdsaEAND%2FuH1uLOjWEZzBMrBCphUeIOGfAp8h7kgZkhgyDy%2F9kksuGaCKBelfS%2Bth11137REODVBrqM4qur62qE488UQsHL6uvdx%2Bp%2BZfSNS966uSzfTby1f%2B0IdfH4iPyKoogqt68bfooEvmvvKBpcEgEASCQBD4NyHgzwx%2FL%2BEfbMGIbjHXXHP5g6r15f8L%2FuIdddRRqTVc0003HbrjRz%2F6Uekx7Ib4a6SyxhhjjOmnn17a32yq%2Bz%2FI8MMPP%2B200yrvYf09aTvpe9%2F7XpWfcMIJTzrppNoQ8b%2Beww47bLzxxqusb37zm%2F7PW%2F8%2F8jeV3cMRRhihsozQllYbXktoSgFS2PbXeMtKIgh8SQTYQT08hj1KAnKO3l%2BgZX88M3noMbp%2F2H%2BBdrpVGI89zvWVi8dgcX8mj8EMIYbvCnG7jX%2FlaexNXwt9UHph%2FfnSvQv7bgw9Wv2mw6%2Fq%2Fl1ipPTwGH1bNtkvw2OwSXUN1fpn0A7%2B5%2BIxjIfDgnEWccH0Q0qwyPqOs%2B8TIQj23HNP3BQKom%2BuJ8gZlt3%2F0959x92SVHXbJ0uUnKMEAckZJWfhQbIEJaOIBJEgGQFJkhEUJahEFZAcBCRIRoICKqAgDwiiBMkgKvq%2BX2a91qfffZ9z5swwMwxw7T%2Fuu3d3ddWqq6q7a%2F16VW3vW2llfM%2B9zjWZgoM5Oob0ug2JeDux5bvRMfaaxG1kz4F1DM8gTtYRe%2BvWBzj%2Box78%2Fu%2F%2FPtnwwDoGSYonTlnSHFy8JRe4Wj3LPJ4WInYKX5F4gj3e9a53eQh6C%2Bxa06DTJYaDp6eKk0coHvY7urLVk51Oi6PAT2KShVajHnjkEfmdaL6YZHPUHo2up60c9qI%2BrHvcxHj9RFr8t%2Bdy93ROBqxwBV1CfU1sWfZs09sWw0PN8%2Fp%2BZUXWU3HCi0ygo60tgNK7o5pgYudOPtuvytIr9lfipLS0iE4%2BOoZ24b26lA78zh1w7%2BsJiaq%2FLe4wbWuObXUO07lHbGI3wB2Rdm%2F%2BuuuKx0CJ4HOo6oT1WJxlQPXdKO26vYtirz1HwR6XMLlG3N13UxbJd%2Bly300%2BnRuBCEQgAkceAU9k6sTlLnc5Y4D73ve%2BxAeR2Ku4kSPOfvazixel5JMRyAUXvehFDboM8MTp%2BXrFK17RQNHbjZOf%2FOS%2BLh3DNv2B6GEA5s2g8Z4EZzjDGWgUxk4W2SBxeNGjLPoGIUW2PDvBt5bgON7xjmd07Zn70Ic%2BVD4Gil6yeKqe5CQnOcc5zrE3wJVOwranPOUpy%2FI2InCoBLgSOpUOuXe0xsXQYyeHvToGMc28EmFCqwive4wnD%2F4Fn%2BH9%2FlwSh8R7cGdW5vvb4CiJbSCncD2WQ7RNfDA6hnf3XHLiANd%2Bn2%2FwZWhYaPS7zyJWcUbUjNk7t2UlmA0XNaePy8Yl3zl0qF%2Bdy09nrRk9PHG3mvFZcJhzD6BjaE0R75NsfzoGp0no%2B44fpFAzR7bv02279XGFpgoH1jEIDpyCnY5Bi6CELJ6KQE%2F4%2FV4CvO9t3IgS1dq5O8Eb60T90OwG%2FqNbt7kME0yuXj6TZkfHWCeujf3pGEoE0HWhnzN4pd%2B7wbtU5dl%2FMDqGd%2FeEoAlv2OYmmGHvrX6b4ADbJmJQuobwwegYogQ9ZbynxtZfcelqMdAOgfftVWWEzTER7%2BGJyQCcueSK0y3NcTAFw%2BoiIypKKTePPwEb5ERHbcxr8WW568WVTof0gJOPQAVdRVlT6EpGInB5%2Brv2HGBj6Q8HSOOQcAhxFzr8qtqkZyEJyIN4iRJanPHWhNnfLUvKHYOJMAymGCwblOLKcifRhWBR9D47%2FErv7irKZV2za7%2FRwjpxq2OsBAezAfs07t7EngWYrFvK3gQguMToNntzcB9%2B7nOfuxNvJr3LwV3a30P1i13vbm56zt5y9%2B7RVchNbiY77YKz9UzWPWerY%2BzN5AB79qmNT3oa1Hve854DnEvnIWTtXXvKHcxOPXknc48hddc33DEOkK1D2s7zl2C1k8OcJX%2B3eoFA5NNnP%2FvZO1m5ubnSd3a6n%2Buoq7fPUR3VfUA33kkMNcHfA3rFSu0k6GsEIhCBCByVBAQQrukkVHphFYboMxDymKBRmHKy7vxGOKc%2B9akvdKELOeSh7NBlLnMZN%2Fwx2KtSwgUxxFejO%2FqD4co8TGUoiv7EJz7xeoXtgXWCE5xAIKjBgCnVEq%2FX0x7BpzjFKbwCcy73yvYa%2B3mLTcowktlB5BnEhdk7rthJ1tcILALGijM7gCJBo9sO5o2prGDgjfaMZg%2BsY3jzqK%2FKyqjb8Gm8m1XKoW6IhdgZ93qH7j2p0eyBz9XhjaVdICJDuAw8r70Dy4PRMQz2EODEebPJO9s7gOdgupDNgyAq7gyYl4XCCbzDlY%2Bwrp0B4UozG0by3ECDTHeA7SHluqsw2GdvKYLElrCDsCKkmZGkrzysmeKxPx2DD86ph2iUh%2F3pGLTTvXHptB3TCrbNMTqGN%2F5MVYUD6Bg0Yb1CZd0Pt5XlenB4xxgc3DM58roQB2pLj3vrbqxR1ojdKXQM5251FTnbv3yfwWjn3Jl1bLfW9WpynzoG341ONRbuU8fgVPJkzaXyoQ%2BQ%2FlZzbOtle17Eu1HP%2FoPRMegMFtWkQm%2BvQa%2F4KQC63MqfkWtyx%2Bz0WFnzMnR%2BzbcSj44xbuPB6BjcIq2ssbiogLug%2BPJ7I5RwJulLOW%2FzPXFMD5HYRyPS510putDQ9oh0UTikImQBV6ttNxbvDpadnq2a2MPRWZSxfcpT5FYJrOezHrXr9J0NgoOoCT3ELctNbI66w5gaszeoHm06z96HJulgIkxWc9DitL4gokNVKadEfUl6GLexncQHFeQbggDLoU4qcZGa14D29oqYpU4MWqagfeoYKCG%2FQ2a%2Brhqto4YW28gKCWbxnDXmWSnXhh4uOkX3QHvttKHWrmL3SX1gVRxhfdhOF44brNjXJY9vz51tDj4ty12U3rW3Xfam18S0AqdsnyBw6fC60wpA2qeO4cLZnye%2BQ8mdeYeG%2B5KRm7rMDXCvYfbw9NXaZbitiG0Xggo6NDG6c66LV5c45O5yTRsex%2Ft7jBoTqo4cfHZuqrJy%2F%2FcswIQe6IYvvGryn7%2FuBsDuTOd0D2GSDr%2FTZ7SFItYjY3JAxpVlv8fu6oTbItqOQAQiEIGjkoBhiWeHCR0TIuvrFa5whTOd6UzGkMwQ%2B33GM57xkpe85HqJ4KWh2IwLXvCCnl8UdeLDGq9KT7te63yOjmEQMtVx4oUvfGEFkSwMtHwMF80fIYN49BAunHi2s51NuLjxoREyJ8XHE1kypZBTnGLAb1TgqToyy1EJqrJ%2B8Ajwm4ymDKqNA426t44h%2F8V%2BsbjzKnyfOoaBotGX0Y5Rk1EN14a7KkNq3v5Y6dheY22H5dwi3gG%2FYzt0pIoYKRkB7owS9f%2FtO2u2GbNRIYQ38A6cwsH0NnC9hmOGHHjH3hRv89%2BaJwHHissg5MAAUizuNshESgNyA0I0Ruiw1s329Nnm%2BRrYOxdJf5fquDelPSwxgkXMYHUlYDPXGHCOAAcBw%2B3o133JnWTbQOtEydwZKDnzetTIXC32ziuhRWidZdv%2BdAyKhBbkkC4xR2PBa6fZbRpryj1IHQNJWDBBj4O89TXoGKi6K6op70bbMU8Xsr28D2Xx2pzrs85lwz51DPfe%2FUXpe3cpc72U9yHPfeoYmhX8KXqvjuF%2BC4IGclfnlOkJOj8gS%2FpYzWHDc0FFeO7cPV8PRsfwuNFq%2BuHCPvnAovfODBoV93TYyhqs4q%2B5BCTmUcrBhbP6%2BWHVMXjZLBf%2BoUcxw%2FtZ2DXT9hpUIgJqR66czuAhZa0MrcZjGh9fXahMGlcncSHoAK6%2Bab51uot02SlgRtf1mNNA7kJeJaxD6uWjkwhd0DqH6vjzarmWzJOVHktGUC47UdIBdq7ryXyff79LHUOLq7KreMdhdyfR2w0YyJjY0nyG2D5tsHN6haxWsBwybhp6BcIzJtmnjkGI0P326jY65E6PpRoxRt9eNmhQ3NDTl7a3oJXAhv3EK2m2k7ZwdiLyKo72mrhhQxcVokPgJVgxXgITYbZ36clcDyQO8LJdX%2FsMT9raMNtzd3Iv3ca96LGi3ZgnYm360l4dQ1t4ZEzwzzZbVVOpNd5zSA5eG7m4tqKNt1HqqBG3z6NtPrYVYZKULj23nTnqRPdDdXTJMHLk2ZEmGOniMj6UrduLozvCwuRAMHcbl4N%2B7hqfgKtVtKeYS28UPJGWev46ZEOXcB%2BW%2F%2FYpZvw5Kr022l56eh0bNIR%2BuDJxaSjdTUktnLXk5ZWgjQhEIAIROCoJcMROdapTCXggUxtNGZKd%2F%2FznF54xvhhxw9ErX%2FnKHu5jlSGldT5Hx%2FCOb1bVWAa%2F4AUv2NExDAbmqPGh%2BSAUCQEbPmavOFdkBdnEeE%2F%2BJPGznOUsEvic85znNE6YQj0NPU0spmE%2Fwy5%2B8YsTOraPm1V6GxE4MIH1nloyvc5g2HhyhAijsm30vgQuBxIfh9f26BhbR8Bgz4jUkMkAz9Cav%2BCogZARDldi645tTTLANvDe7uHpGNYadW9fjbnKZEuU8IOYKzHjXQgGumtYyC1aR71SpIeM1ODaWW91D1XHMEgzVBNs4CXm%2FEjBzip5amo0SNI0cQwlw%2F6tqWOAehnrGtM6anjJKdt7hWIyrp9T6DlA8cfXjUVd3E8EwPgdGX%2BJM1u1h4CjsfZJVTJeoVGlATPDjMCh89p3kZkN9rgXkTsEPNgzOoa7304yPqO2AGTFNkjgFsTFNnxdAc%2FCttlz4HgMHoEamTfHUZ139FsfnDen3WXiQ9UxwOalemlueL99Tal0ZutpY7avsoXXWTrwMl5zc%2Fe2%2FXMdsmHsrVL6hqb0da%2BOgaFOqxQ10kaM0ffWrXuyUu7KU1AEA5gKNbdixxvVTJ4mfCuHnLI%2FHUOGGmv1E50WEKhXKfqGLi2f8d8xl8AlthK4EFSKSmOPkAOevmt2HVUXtRiPTAc%2B1PUx1omzoS9xS1Vz9QSmEouUqO1WXAQjeak4rPVeJBtFTnNoela5P6xqisTg6poLsHr%2BFOer9%2BOubu24bVkFUdUUKlxkZbJj6voKBe%2FVUxgZa3IqWk9WC44b9%2FDgfa65KXkir2uQ2a4vNd0xexW9NnRFNwoGi0Tap8Hy5GZi6Jrdq7w5un0Rz9F2z1lNoBRw3HX1BL3a1706hhYft5QmueyXUn9zfW19UjsF8CCmq9heHw8Ctmn6pZ%2FMIffSdQfT3Oq4FUBg16w6sA15rpf127u0dsFQ5iqlatuGVgRdXfd2b%2BTR6%2Faz6MeyarvhilutSf3Qymu5m0lGtnV5CuCZKuzVMbQOrYAlO5qzryzf2qzFdSH2bEMytKyKuGPsPCyUrsSFnbriceAqGKuY7Z7vcsZhuqX77Rza3l50YxcCRO5aCt3e9jWu3kUf84j0mGD%2Fuu7kQw%2Fx%2BHCW%2BirFtjvw5L%2F%2BelSpiwRrjw2yhuKkV9lt9zM2MAZYAwNYCJvuoq5KcRqeCKtq29zajkAEIhCBo4yAgR99gKrwHfngkA%2BtwH93bw8d7y9Of%2FrTC5lYD03PnRWPIW7QiSZiL2sNtvenYxhnWvOTQOH1hAhwHoSPhyYHUEHz3PQE8eAwdrUWKDOI8x6XHnD%2BGsl4rHsukz5MbOHXrELbiMDBEDDI4fDyhiaxwYmhi9c39vNqbet4RlAGxgYq4jGMKg14DIaln1BS7gnXzEtbPrtxuBGaAfD2nZEhHHfM%2BGoNd7eGuXaMgvT27U6DNCMiHocilDsfwyqZKH3bz71JNHI2OjUUJOWx0BjP3%2FnwCFhl%2BDdpvGsjTRiVsYRiQF5wEW3LXdvGe5wCng5fzPhfpbwnXUdtsJAXQ1FxdXu5LCUmW39TGk6fewUHWS2MLWWI3k5N%2BR1rJMwYbjJxYDXHtsS92wQW5e7MKVjJ3CK0nXJZZaBrHIuDlvVZDapoY1f%2BhTfpTpw31BoLasn%2BF%2FzrtS%2BxSGfge05PcMi5dsqf6yE94PzZUUvmZT0gXIntiHps0x%2FshG6CLrQyT0ozuXPyxdRIj5pXh9Ib%2FHP5MdEf3AZXP3SKlHaqi6L91ZpO3zpB2OotGk5nXli2GxwKCXhYdo6OMRxWGlYZlrNQL3WvVh0ONS%2FPfn%2BVuzqbDS9VWQ6IzuYvXUWNkBmbJeBVeYJwGeQvQzy3CsMUqrF00eW%2FuB5VEyhNsPIRacNXov%2BwgYvHPDnrBtw0H17kMHQN%2BmDi%2FS8HbU5HUi2WjuH3KXbCA1bdbeirGPJKFkB7CAiuoyVeMQwiEhkvbJ3Lfr2IYRPQOPs1hJR0CU3vWtDVV7YeZK5H7wtcMhIIgFn3CheFjkR7WVnpP1IirL8tUKvovRtuFy7AZZ4auVd4Aa3fbqWAvSfu7CFk6d571%2FlU0wObwWCNpVEEWG5Tugq02urqigNTr9jrqrugtvcfdyfVdzFKPxepruhacJsauU9buyW6sa8qGEvoKhLoAEvOddR4Y0eas9NFRJ%2Fxcp8%2FPt3GXzcENxDl6rorWxvuusvrd2sVDOCGSV%2FSOalGTnEhOJ2FOp7SXXRz4axrZ%2B7SitM9mC08Q3vNbYrHravo8C4EFx3FFe1t6dttMJFZewQeyJMlikOJDYp2G3S7npvS6Bj62zqFDiCeyj1hO13OLchtYZts0rvzu5wJF%2Bu%2BpEZSakH9c%2BVpQ6Or0dwVfaUa6c9z%2FaqmU1hlXEfi07v0Eze3uaMuRDbcXkTfOer24qN2bi9zLzLalKEcaOZuKezfcnDjclTTY%2BtEPLURmFsyuoqj6r5uFHCpIHVxbhceDSvGQwdgsDAwd12ZaB3J6DwQuaYUNHfULYG2IxCBCETgKCPAibjUpS4lvsIzwjiQpOBjLOeZZQVOaoPn0aUvfWmxEOvxbYRD2Zh4DAkscEHVXyMxz2KxFoZ%2FquBZQw9ZIwfBe0aSAj%2B2CrbcjHyMGD1wL3CBC3i2Tt3ZI1rDSMBz0H5D2TUoGuHFs%2FIoo1RBPxgE9O1ttzH%2B4dXqeN67GQTyRAxcDX5sjENnw4CHXqH6TjRokcAh4ysbDtk2BuZUGs36yIeH4ujq88NN1zUuco0YL62XdFukRpJym8zl6aNoH1eWcelKyRVihvx9xs5Jtv6qi8%2BcboTJp3NxUSHUzpum%2FekYhmrkQZeYUybz7Ss2xnMiDA6N8XyMDNmpmlu3iMhJRpg03B%2F2yEck%2FPY9mlq4qHmaBthCFHjB0IG2dW1WTfdusAEiRZBHOGvuPD7cUkh5TKjKSsW9muS3ypmREM1f9iykDJsGNVhdzLfktynnxNnjr8%2BknP1O92Zwq2NsmaiCOx6Zixc56CYHkGXi3GlBjoy73Hd6zyH9x5hZ5g5JvMyebTt9ZqdaOGurY%2FDR5OmohtBnhg83lr9j%2BM2t42Fp4nEb6Riy8rZ9h7Pe5YqgR%2FF9tOPQW1WW%2BfajLZaRivZZXyeZmhIlFKGDuXa4ADvFaXo90yNDxzApQxdl1SpucpusxhJF2PDRCedjDxQKQhg6O7%2FD6H8pyUEtRscgVHIMt4%2BeMcajRwJXlssTIs2xVAutqVH4O96PS0xw4MLwoLdrKUwmurQKLldal6Ae6IRIugBdXPJZMfl6LwuhlkxjSbYiBFwvdE4V4Y7JmUncT4nlvL%2BLVzK%2BpwvQx8beZNxt%2FdChMfUg%2F6oyHdUNZOkAehHO2zvDZCV%2FAGH0VY1c1wzeusaTjA6jsVyqywCeowy3e%2BaQMYm7BClDr6CDeWuvHbWyv%2BujVwA%2BKpk7gG35r5xtuGtxePWKrRBBauCNbpNNSnKBHPQr2Spiupy%2FKrKaZs7Si6hYbl9aXP56lzRO9Ne9WiZqZFu5c%2FrkuTKcDbdoHXUKkmCKdpZLDyJfHZWDq3XNyZ3S6TOeIyD7qi6uF2YQBySbO95OFRRhj4tdeh3J19me3Pw15GMMiXjtcUsRt7OGc2u%2FYFomTSuoxaJkjx67ktnQGdz0XOyaTzcQl6U6kqmgv1NHlNw95gbCwpXbFtQcnbJg8Vk5yAcl91VpYN%2BG9OiHAvlccdJroHmwSr9DRrZy89nWhRnq6AmiRvQW9oPsngDRjv1KVzQDlCKNy1a7LKFyS6PtCEQgAhE4UgkYnAif4H%2FtjHOMLUkQ7uFKNxa1Te7w0KRgGw366odFPLA87zwgCBdcOcMJrsFZz3pWR43inLijY9hjOC0xDYS6buDtzRqFxOPYg4D04URPFlI8B9N40leDIqVc5SpXEfVBk3eK1wF0lfkpkx0yXkOY%2FyLnnf19jcAQ4A5sh7teB%2FNDjUm80zFo1JN9jEwMWubjq%2F0jSuh4xjn2OGQY9p2kh3x8dcr6SOMF7o67xF%2FjsPDEuduuoL3N4eWU8Zhs%2F7%2BC%2F%2FefnVzRbXpeg6uSzUr831T%2Fv%2F%2F%2Fa9d34pYpCSMSMmlN1t7mtrYNX40GOblG6a7BbWLenIGo8Z5hm49tpbsw17k26AmKm%2BG3AZ7EuLm3bNPY5kRYtc%2Fp6uVjTEsPWe%2BOdxLvfHVPmCr761zF%2BRySzXf%2BKM6H5dwuiNCexNJsQYEj8TjU7lfSDzvJthCH4T53bnNTEf1nxvxeOLpP7txFjYRlu8UiZ3vkvJrJV3mujzGzsfT%2BipZs7JSAjrF8TKxsa24mSeCvjzQ7fKjT8wqboKdL0JB3IHvJyAAet7suH3aK2zFmDJi%2FqxY2lm3rkBbxCFAEdZrHt7c4Te9Epi47bezNRxo2aCwPBR4unoqQzLVGH%2FDg8JfZ9vjMobHBX8qDurBB5AaXaq%2BDxpvmKc8LaDcHBvDjqEBin7wv9tU1q1l5fEpnA99ZYpqGj26PJJfZNeIJqOfP8hp8cJY4nVdFWJAt%2BzlHbgu6MWGEtZ5lrPK0UoQrWp4iN4g5bGYAX5VbpCz5yNkbaumnUH93pD%2FXuBflHqYCPHYa9FC%2FqqaLxbWwI4CoMqVF07hY1N3NAXkCy44jLH%2BGAci1Z7BhA4OF8VDPtgYLwucIOx1DFwWVRnCF6IV9dkK3ZZfAtlfY9lltamO6xNzMvc1X4g4ThnkLr2MsrU%2BNvPp3p93LxD3ZI2BvP3cf49Vu02PiImKMVpuPs1RZV9QEtAK6nP7JPE3Mzr15Ti0kWB%2BnS6YsCq1njR4rN8lsaJdt6S5MEoFRlrpwn3csYZWsJv%2F566s79gho2pFvPlGv2zz1rrlIZ6cghxn1bdPY5qcTB1R5m78qKHRHtHe5SYnnltLY5m6vS6uC9nUpqbLP%2FhApaCGyMVWTg4c4LVG95ii1be8TxBWhIuQ7QqJTlL5TinPlv4PLHs0nJPie97ynZ9Oy34aPxFpEDze4XfqVmzDJ102VSa7fHWh9jUAEIhCBI5WAgcfIBQSKnYLcnC3v6UdJPMc9DowHqArzEVBBRqBF2O8sYzPJ%2FvfgMcz4sG3E6BBh3PaE5U%2F%2BxnVGTcc%2F%2FvFX%2Botd7GIz99BLLk%2BKtd8Gl2eeUF5gWbJjHTLfxNjV66cdmz1PpfFA2RmS7STr6w8tAQM5fXJbfYMu7raBnPGJEREv3vtBe3jcBrdedbk0ZnxCajM4t8dHKKk0s739a%2BRs5Ll3RO2doPdl85ZHMOrWANv8ICca11H55KZomSvdX%2BLJvH1bpxjBikciPI6d29J3tuXJ%2FeHUG4Ia%2BiplZXKADd6Qcfj2JaaX%2BxwQVrnS%2BSbyVNA2gdy4Y4aL4vxFbRFeQODX7J0wYlRpEGvsakBomobmUEdgD2DPOuRm5V2w%2B5VBPlbrIzcOET3BQJ0Xqb5OYR4D2MlL3QEljTky0ihXXVDyYb%2FEQ96J0%2FRGp3ay0P7JbTLUJWz42OBUutvwDnShraowZmtrHhbXFRY%2BL6HVWQyY4g7JY%2FePZFxaHWyVslJs68JmvRGTxceGBhq8C44NuDQfT5NbzYmYWAJOJTO259qGTtcykp8OLM0UPcbgsCwBZBCtPTaYt2MzI8cN5ElphZ3ifNW3vQrn1FAAeASCt12G8nGi%2FDEf%2BP5qCyH9fFIPHT61gviwfOFZpILr4SGiCDuda2MMs62Jx3dTC%2F74Xht4pnyceaVLB2DPcnk8jzinU4T7hv3Dcxwof3k3HmdzhXpIeUraM%2Fs1xHQzJboA9QReko88ebic0Gk71fEyfQ45URH3uMc9JgZAuXxM6ac1V6ESuy1sK%2BJCU7QHH%2Fdtu%2F9gtrHyYoIs6caykx5ttVOjMdttQdPsvY3oh9xbL74ZPNrXXoP1fJnrNtpajaambonadO%2FDWnvBxVWcXkHVcftCTJvOFTp9w9%2BJk3GD2uquUwvMAbnb3e5mY%2Fa43t2BVWGnmqQtAQmM0UMUoddNKTqPnHcuMdemRnFNud1pKXYyTJ662chlgnkAkYMe5RJzaHvhTLfc%2FnVUp3VDcOErCw0iD1z27%2FWLKYGKFmagFXRmcoG7H8tVU79liazG%2FmHl79wlZKuBdjQZHJityxGCFhPXkb6kW649s6FoVabWuvTYpoJzB0DJXX2bWEdS1ug5sqKKuMTskZLoJyXg1CRyFrxq6tLeuW9s%2BaxtJG0PE1cc%2FY0NLoS9d92tMbalhNdn2xC2tc7gUpGpEQvdq53CpLHfc9ZQkyzJfulntpeORCRhv947A1SBba6RfUpkO8b0NQIRiEAEjkACHiiGgp4Oex9bxhLGBp4vM6alP7hv8xQM54xvbXv22TnGuJm7zzvqyTIj5JmEQgzx9JmH1zLbI9VgwKPN%2BE3RMxSZo2OPMZWPJ9QagTjqHY3nCCdrosr3jqakYaon0ZqZskpsIwJDQC81POZx6PAGjYZDXADdxkhMzzEgFAs0KXXpg%2FSvD4atEY4xubeBBuQG%2Bfxxg1VXhyGWEAjjbQN746XJagZmDHOlcKzmbdocMlQW2GzkaVR2MOVOGgPLvXXZ%2B%2BrWHkKKd%2BgG51unhi7B%2BBmw7a9QF7Ih386Yf5%2BJMQecyzPD%2Fn2mOcBOnpp333gabxNV3G0Mzt1D3DoOcNZBHnKTkRKr7Z3HHkUcuGpaai9hJxoAczHWffIgzfhukuFAqTDM5hZpOANvkg7aarRtU7d3e%2FgmLJ%2Fi2O%2BerPV57geurPQ6w2EC7nY9YsLeqjmkw3OZtew2zVyh0h%2Bqn7I3z33u0QqrsiuBEmdhkxVmoO6gjY%2FjObXazi3CzeGQNTneZmM%2BfFVdceFSCwoed5g3tHN9SaNRZMtjIlNsLXGIT%2Br%2B46LWq9eJCOvhClLo%2Fxb4%2F%2F3fcUgJJu5d3NsR8VbtDmYDdoXuZLhO1EkcHQffLWtr9krjKGnCQEIXOlSDdU4p%2BYn%2BupBXJjsb%2BqpOO71i4ZVmOoO%2BsdrFTttqwbyVCYaa1St1IVLLZm1NynP%2FN3Rx75Weta5Q4THuqFLO6bArd2W1d8ONS%2F93r9j7JmVv4iN2jyeXx4egncmWAWPJ9n4FnWeHBNsLBwSto8rb4ZaWHbFoi05%2FJpUApU%2FqV64Lgz0CI%2BXZU2ANrpS7zzveGKY4BbnPExgPkGwSf2%2F%2FMm9Gv6zdWjIDA3%2B3PW2bYG1LQO4jsO9o%2BytBGxGIQAQiEIEIROC7J8DtNUgjGvAujT1EDQnFN0LzztGg18SlebVqVO8Vv%2FAM7vl3X6hxuBklXpwJVufmcBUN%2B73cFBlr%2FOyr0r1Zm8EkN4c9M7rmUXrdzwzyHT2QguENoFefArzH4%2F5ubOMRcXwMZfkL6uu1LDmFJV4WG8FuczYWZdK8sN7u327z0bzAgne78%2Ftrm%2FdqVC8y3EtbvWK9DOXsaykdRtsd1hpREuS5VaIOaw5HXnrdkq%2Bnz%2BtaQrUFS7sueEnzRvIA5Rqumw7A%2B%2BN6HyDZd3NIn%2FTWm23eMut78zb%2Fu8lwf%2BdqZRECFio8TLLM%2FnL7nuznM2o%2Bbbc3JuEosIe7KrBKY%2B24gUdG0bqBq0nshFuicJeJLJqCOPIuW%2F3ZfUzQkVuZW6vLll6xLAHKIfMd3H655G50c9v3Jl3MwNxRpaHi6hJeymx1gJXJ93CDx01eYJub7f7MIGKwXxQEwdwN2VyqrYwg%2FgE6Mgg9beKgPPUExW3fChGXXN3I6FHK8tcTSjKXobdaU640Qry0O4ljf5Z8X%2BwHRwSaICLPVhVEZoviIKtA7fRw935tK8Me5Lkli0AEIhCBCEQgAgdPQPy%2FsRk1wPDMx4DNx8DPAHgN%2BbifHDrh1sa9B5%2Fz%2FlIaGgkukr9BpmE%2F8cQY21jaX2qGIbdI4OVGCdVgnlkSXANjRVHKtJSZu81OZtNhSBD7K%2Bvg93uryH2bIf2MV20rYu8ypBxemo%2BA%2FwNk7pWW0eDh8PQPkOdRfEgAA9cG9mkgY%2F4xwAZPR2fQTIfVJFh4TKZ7HNYTj5r0ehdnx1WgK37nMrjOdThBhzqSn6vD3PydKVpHoM1cVNNM9H8vgllF1jsCM99m5bWyKJTt%2B%2Bjt0e%2BLba63G4JIs3X7OirNJiC4ox6q9nWEmETjdSVau4CG5ja4487TId1O3SGnM7ubmWexUy5ZwwWuR83t132P%2BmHmxerz3q3bY0Kruxn9duf07%2B1X92HKobuQ58j%2BLBGQI8xV7Vw4NB%2BzyVZ4j1M42nRaV64EQOGgmmtV25Und94zaBABTjanh2yDDQQIQX31q1%2F9MIUFrvyPPhsCWqz5rC95ZeBGrekPR4wNmdo9xJ3k6FOvLIlABCIQgQhE4AeSgEGg2RzeYJq7ZKIHX4ybuRPeYDRoxOvQzv4jBIiBpXhmsc3%2BGgJtQ4Inf%2FvtFCMxse78FNO1uHVG3f4egVqBQbtZLbzFQxSd7yxzisk%2BvSHzTXZ%2BXG8vCm9Ij7wX9HuLOzL2iCcxXKfweMUG%2BxSBkv38RLHrh7VQjoNAjp3VDA5rJkdqei68CU3eRZqYILReZQ%2B1uLk6ADkyro5VOr%2BAo8rBFMV0OJyLlU8bP0gE3Lr1OuE6pj%2FsrZfL1uXGfxdRoH9y%2FPemcS7VxawWObjY997uSM3WPVgy5t4cjuZ7XJ6i%2BNx5xBptQ1bGbBc4RRofTxOLQe1TwXOuO57oFwqPOVCI7VRZEU4kEx3dQlZ27DyYrx61HrjuM%2B42R0FM0cGYVJoIRCACEYhABCLwg0TARF3TPWbi8xFbL%2B%2FjvOPzZo3DuN5L7hRhYO9d1c7Ona9Eoe%2F313M7NTpCvopwsJrcEZLVkZQJr8TcmaPn5Jcjqcpl%2B4NKwMRAPvj%2Blvv4Qa31Ya0XPp4mByNaHtacSx%2BBCEQgAhGIQAQiEIGjDwFvME1LP7A9ZsR8v0%2BXPnAFD99R74WPwBCaw2dDZ0UgAhGIQAQiEIEIRCACEYhABH6oCAjA3juNeoeAH3Q48Dr%2FO%2Bl%2FSL4KzC5c%2BYekratmBCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQicAACFp%2F36%2FZWk1u%2FArBN%2FPGPf%2Fwtb3nLYVqby6p0b3rTmz7xiU9s8zmabO%2BzjlvbDpzAsnvWXbfkuCXLtme1HYEIRCACEYhABCIQgQhEIAIRiIDf2HrmM5%2Fpd8bvete7%2Fsrmc%2Fe7393vUR5RfF7ykpcc%2B9jHvt%2F97rfPDB%2F3uMc56qcwHbWMNmP8LNo%2BU66dfiLQKX4Mfe05%2BmyQIMC0LiK2W6v84qef8sTYz5lt9%2B9sf%2FnLX77CFa5wgQtc4Oip0uxY29cIRCACEYhABCIQgQhEIAIRiMBRScBvrN%2FkJjc5xjGOccxjHvP4xz%2F%2Bj%2Fzv56QnPenLXvayA1jiR7ue9rSn%2FeZv%2FubBLO5Hx1DEfe97331m%2BNjHPtbR5zznOY7%2B4R%2F%2BoW06wIGDFn77t39bMr9puM8Mj4KduPklgoc85CF7f6%2Fwgx%2F8IHpnOctZdg4JSvnxH%2F%2FxE57whISOA1hIx7jsZS%2F7Ez%2FxE%2BkYB6DUoQhEIAIRiEAEIhCBCEQgAhH44STAH7%2F5zW9%2BnOMcR4TD%2B9%2F%2F%2Fr8%2B5CMSw%2F8vfOELB2DixMtc5jKnO93pdrz1fZ4yOsb973%2F%2FfR7d6hgK9QOOh5rn6BhPfvKT95nhUbPz53%2F%2B52kpe6NWkLnFLW5xrGMdi9CxteTFL37xcY973Gtf%2B9rf%2Bta3tvt3tukYl7vc5c53vvOlY%2ByQ6WsEIhCBCEQgAhGIQAQiEIEIRGB0jOMd73j7m%2BwgLuId73iHuAsqhPgHv64IGuf98Y9%2FvJCDk53sZA984ANf97rXffvb37afD06FeNCDHvTrv%2F7r5ol89atfHcJ0DPEed7nLXV796lc%2F%2BMEPFsbw2te%2B1jyLObrVMT760Y%2Ba50JRmUNc%2Fle%2B8pW%2F8Ru%2FoZQ%2F%2BIM%2F%2BLd%2F%2B7fZPzrGwx72MDNQZCjBa17zmrFBoMgfH%2FJ597vf%2FZjHPIbZv%2Fu7v%2Fv5z3%2FeLI8%2F%2FdM%2FZZvEzFDxycpfp7z85S93iGGveMUrZj6Iv4qmRfjlxGc84xkPeMADzH9hnvQf%2BtCHnvjEJ17iEpcwt%2BWe97yncy1qsXKz8cIXvtCh2972tmtqCdvueMc70j2e%2FvSnT0qFUjYYjxVoX%2FnKV2b%2F0jH82KVspRGp8rWvfW2O4v%2BUpzzl7W9%2Fu6%2BMeepTn%2FrWt76VnVMvDWG%2Fn8jUUgx%2BwhOe8LGPfWxOnL%2BWImE5Jg5NXbZHbWuU17%2F%2B9b%2F%2F%2B7%2B%2F2m4nQV8jEIEIRCACEYhABCIQgQhEIALfQwJLx7DS5l4zeLUUgxOd6EQc8Pl77nOfmxM9yoOd87npTW%2FKYed3X%2Bta17LnBCc4AWHExs%2F93M9NUAcdQyjCSU5yEt69ow6JAJHJSBlbHYP64ehDH%2FpQxnzzm9%2F8xV%2F8RQKIxOa82H%2B1q11thBTRIwIeTnGKU8h2DtkggDhLiec617kkPvGJT%2BxEyWxf%2BcpX%2Ftmf%2FVlZTekSc%2Bdn6or0BAdpTKmZxDQT1aEzXOMa17D%2FtKc9LbMdtX2xi12MAYQF2%2Btz1atedcfrt2bpRS5ykTOd6UxjLasICCaVnPOc5xxhQaG3vvWt5cCSYfUzP%2FMzn%2FrUp6RcOoavX%2FziF6klpz71qT%2F96U875PPSl77UWZYQsf385z%2Ff9o%2F%2B6I8iMBDU7na3u51SVGQMZgbV5ZBT%2Fx8ro2o%2Bp0xTokQDmUPrL6VIHaV529vetna2EYEIRCACEYhABCIQgQhEIAIROJoQmEkQ%2FPQJafizQz6vetWrrLfJwo985CNnOMMZrNXwxje%2BkUtOXuDhEis%2B%2FOEPC4Q4%2F%2FnPT0mgPPCUxRtY%2FsJR61h60W%2FPda5zHV8dlQ8dg6RgEoowAL9aIvzg7Gc%2F%2B8lPfvL3vve9jm51DIekFP5hv6gJ2yZiWHFChre5zW1keK973cshMQlECQEhAg8YaVFN7vyVrnSlr3%2F961%2F60pcudKELSXmHO9xBXMe73vWuq1%2F96r4qzoIegiuYdKpTnYpHP%2BKAGAxHuf%2Bq%2FJ73vOfyl788YUFgAynjete7nkNXucpVhCj87d%2F%2BLVnGV9ZaEsTPr9BGcBPswbwVd8G2%2BYh5kNhyH%2FNVob6KSAGKejNHSSsf%2BMAH5EyucZTBmkNgxswroWOoC3vOetazrh9zES4i5UB40YtexABqicxBEJshPMZR8osQGtkOMaEabDBL5bznPa%2FWJMIQVaAARNXIJmPh%2FBUBIvhE%2Fgez7Mn2xLYjEIEIRCACEYhABCIQgQhEIAJHAYHRMTi%2FO59xfq1I6bU%2BHWAmdHByTY64%2Fe1vTy7gj9MN1mqWHHliBT1h%2FGJfn%2FWsZ4kKENugFg7Jn8SxajQTQ2gC9uxPx5iwh7vd7W7KkownfoMb3ICWIo6CjiHDMdIh0y74%2B6c5zWn4%2BwwgvNBJlu%2FvV0Ik%2FuVf%2FuUpnYxAihHkQH%2F4l3%2F5F4npHjbmKMWGjmG2iHgMUoxka1lO8QwCHlR%2FUt7qVrcSTWEhkfm681eog6OkD9DQELKCJIFIMgKF2IxznOMcIljmLHEX1vYU%2BEECwvYgdQzTZOg897jHPSYTTXnNa17TOqJritA73%2FlOMTDW8UCMOgECtWrZKSBEFAfpZu35wd7Q7tZd0V6zDgz4E5Dzg13raheBCEQgAhGIQAQiEIEIROAHjMDoGGIbiAyWbiBEzGfcW%2F719a9%2Fff6v6AU%2Fa8JxXgs12PDzoGc%2B85nFVywmzuIdmwrhZ0MdcuJEDsxsiO3vrpq2wK8Xh%2BBc605ISbWwvY3HkLMQAocufvGLS0lhWAXtrPPJGNKE4BAhFqNjnOc851lzMayWIZOZqyIHVRYIccpTnpJLS6MgTRAryCBXvOIVKTPKkvjGN76xWAg6BqFmzcsQ5OCHSERu8H9lYn1USsVSOZZtszFhFWc84xnVQvwDGqZ4WKbDURNz5EPZmGk1k%2F7e9743IOJAvvGNbxy8jsEAoR2TA8FEY53%2B9Kenz8wezYGJyrL2zne%2Bs1YWQiOMZGrKNjU98K%2FSTD4%2FGH%2BttWIpEk1Ml0Pg0Y9%2B9OhjPxi1qxYRiEAEIhCBCEQgAhGIQAR%2BSAiMPy4CYb3E36m4lRwEJ1zwghfk8%2FqIXjClQpq9OoblLs3XML%2FDbIUb3ehGP%2FmTP8lx5p5LPDrG9ndXzSixSoOQBprA%2FnQMJ5rr4bc%2FTKyY0q2PMatTjo6xfneVMUIRSBNLxyC8zHITMrFWp9NXKIIqM09i7%2BXVhXrAZunnQzkRniEIRAgKHYP%2BYIKGTHwOk44hvUk0yrU86fOe9zwbJrAcks3%2FY5aKuptFso0HMJVGK5gqclh1jKUO8dNNhDF5x1yVKchKp3QMmoxDULPBJBQKz9SUpkG0MZFoEv%2FA%2F9XuZiqJ5KHUWV%2FlDW94w1ZH%2BoGvfhWMQAQiEIEIRCACEYhABCLwg0Fg6Rjcur01Igu8733vM9NBdIHAA6%2BzzUQgUIh5sAjnNh5DGssycKKXe%2BgHRDjmv%2FZrvybb0TH8gsYqwu%2BVWNvhrne9qz370zEsifk3f%2FM3LPzsZz9LcBAIwROfcAh%2BqO3vXscgp9Ax6BWWgxB84iMMgypCTPCxNMd3o2PQE0wVEZ0iBmA7P8WvvVBR4BJBsYCYroKt2SjK3RuP8ZnPfGZSkh1UfKJchMeIxzgYHQNDS4MCTlTRlFNTG2pK4lg2tBGBCEQgAhGIQAQiEIEIRCACETiaE1g6hnU7d0wVLeDHVXnNaxkKv8rxUz%2F1U1xy%2Bgb%2Fl3tubsIESAjbuPCFL%2Bxdv1Us5CNi31nWx1jxGGIzrG7Bg3bUuaIR5GzGh69718d41KMeZT%2FXXhohCrZ9xGYo%2BjKXuQybZ32M71LHoCew59KXvjRVYck4XHvBHiQURh5Ax7DkxS1veUsygpiHMW%2FvXzmY06EKPiJAKD%2BThlRCC%2FJzKqtQYpGYEwESn%2Fvc5xgwOoYFHJwy82XWD9EOq8OkY7BBU%2FpxE00gNmP9tIoVUC0QahmQHcvpG4SjbazIToK%2BRiACEYhABCIQgQhEIAIRiEAEvlcEaALWgeRoEyhudrObWQRjfaxHYUoF%2F5q37oc2iAbzgx18Yf41pcKMDydaJsKvcvCOLaTpq5UHrNgwv%2FTh66ztaZ1PwQC%2BCuEwv0MRtq0XMatrPuYxj%2FHVapwgUDa424985CNtW7pBiIIFH0giFBXTVSSb2RlPfvKTbfvt1OHG9xfe4Nc6Zl6JeRN%2Be3TNK9mZ1qHKFBWJZw0Qv9bKNqUwm0owpZgSQoWgIfiBjzWvxLoWxAc%2FAsLH9xHhwIYb3vCGdA%2B%2B%2Fz5bcJYqVSOBENsEL3jBC0wtsfjGgx%2F84Ic%2F%2FOF%2BApUNfvpEGrEutBqK0Cc%2F%2BUlfH%2FawhymFsoGbtUes5uHrRLm88IUvpBTd5z73mZwZPAuTrnklQmgsxMFCqgvFZhrF3Bww6UuWRbXW6HZ5E%2FkgSffYRqFM5v2NQAQiEIEIRCACEYhABCIQgQgcHQhw6v0EiekPFlLg2m8%2F3H8W%2BpEOMyPGfbaOxHWve93l%2BQrh8LsbVA7LS1I2RGIQE%2FxABufaBBPuMD%2FdX7MnrEsgcoMHbekJP7EhN1EQ4gGGgEki0nPtfX3xi18s5ZOe9CTbvG9Lj%2FrlEaeQAkRNkFPml1P8%2Bgab%2FXjo5MD7tmympS3m90p4%2FWSZ9XslIjokplFMYlU2OYVQMD81ohS5kT5UhOVCPkglqkMWoPAQW%2FzI7Jwo9II8Qr6YdRX8GojlTM2dEVwhHGXS7PzF5FKXupRFRfxq7fYQFciqHUOPggEUmWimmZBEyBHWHZ1fMzGjRCwHqgiwTb2wmrU%2BLEiCFRlkcmawRTAEdSzhRcCJeqmsKksjQyKMn1uVFYHokpe85Cx1sjVM6ZqMvrEiQLZH245ABCIQgQhEIAIRiEAEIhCBCHxvCXDJLQ3B3fb2f%2FuxR2DA2MapN6eD2262BQd8azAv20t%2FP2c5rj1PnNdvjzzpAyIiBEjYEK0hQ38JDpxrPrL1NFY%2BQgW42DPfgR8tpT3rqFkYTpHniq9wSALJHJpkrGKJBAxQHAVjtufolL6khqmyyqrXKkVllSJCg%2BWzUzLhIgxbMy%2BsXOGs7ZwL2wzbi2VlK2xDJmwbPmv%2FbCiUlmKNjhFnZufURRVG1rDTht9MIfvYOTbM755MvWZbsjGYhctgFfRVjbal2yMrDbqSTbnrL6psZvna00YEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRGAvgf%2F5n%2F%2FZu7M9EYhABCIQgQhEIAIRiEAEIhCBCETg6EPgX%2F%2F1X3%2F913%2F9EY94xFe%2B8pUdq7797W8%2F61nP%2BrVf%2BzVpdg4dDb%2F%2B9V%2F%2F9V3veteXv%2Fzlh9u2f%2F%2F3f3%2Fc4x734Ac%2F%2BMtf%2FvIBMnn7299%2Bl7vc5R3veMcB0nQoAhGIQAQiEIEIRCACEYhABCIQgSODwEc%2F%2BtHTnOY05zrXuT73uc%2Ft5P%2F1r3%2F9Kle5yjGOcYy%2F%2FMu%2F3Dl0dPsqmOSe97wnUy93uct99atfPUjzPv7xjz%2FoQQ963vOe91%2F%2F9V9OIdf82I%2F92ElPetK%2F%2F%2Fu%2FP0AOj3zkIxX08Ic%2FXJqvfe1rT37ykx%2FzmMd84QtfOMApHYpABCIQgQhEIAIRiEAEIhCBCETgCCHwsY997KxnPesFLnCBz3%2F%2B83sz%2FKu%2F%2BqtXvvKVAhX2Hjpa7fn0pz997nOfm7xAhfiLv%2FiLg7Ttfe9733GPe9xb3OIWk%2F6%2F%2F%2Fu%2F3%2FSmN73%2B9a%2F%2Fz%2F%2F8zwPk8JnPfOaP%2FuiPlCgNHeOSl7wkFej7ImTlAJXqUAQiEIEIRCACEYhABCIQgQhE4PuCwAF0DFEKr3vd60wt%2BeIXvzh1%2BeY3v%2FmCF7zgAYd8nv%2F853%2FjG99YdfzWt741h0Q4vPrVrx4pQJjEm9%2F85t%2F5nd%2F50Ic%2B9OxnP%2Ft%2B97vfwx72sG10hwQmaNh5n%2Fvc5wlPeAJjVoY2PvKRjwh1cOjxj3%2B82IntoZ1txhz%2F%2BMc%2Fz3nO4%2B%2B97nWvnbU%2BBJa88IUvZPUDH%2FjAP%2F7jP2Y2yeIVr3jFr%2F7qrx7zmMe8xCUuIf%2B%2F%2B7u%2FI9e8%2BMUvltWXvvSlV73qVb%2F3e7%2B31AkZkjie9KQnMeNv%2FuZvnvGMZ6jRu9%2F9bvNxzna2s5361KdW6ze84Q2yZRhuL3vZy8zWURwmoC1r%2F%2Fmf%2FxmN%2B973vmav%2FNmf%2FdnEgayjbUQgAhGIQAQiEIEIRCACEYhABCJwYAIH0DE44Ne85jWPfexj89llYuLJLW95SzEPP%2FIjP0IusPFzP%2FdzMxuF0HG7293OHvuPc5zjEAfufe97%2F8d%2F%2FIez7nSnO9l%2F2tOe1s4TnOAEtk9%2F%2BtO%2F8Y1vdIjX%2F%2FSnP%2F3EJz6xnfP3HOc4xxxy1EoXZz7zmR06yUlO4u%2F5zne%2B97znPfbv%2FZBQbnazm5kdQzMxEUYmEywxKT%2F72c9e73rXkwOzj3e849lQi0984hM%2F8zM%2FY3t9%2FvAP%2F1AtKCFMFXHxkIc8xCE7JxPTRsgd9Ip%2F%2BId%2FIHo4ROUwtWSdbuP2t789uYNIcve7391XZU1xd7jDHYRtyAfGS1%2F60g6pLBo%2BBI2jf6zLEOhvBCIQgQhEIAIRiEAEIhCBCETg6EDgwDrG9a9%2F%2FR%2F90R%2Bd9SKEHPDBb37zmwtI%2BNu%2F%2FVsbvj7qUY%2FivAuosP1Lv%2FRLXHWTNYgJRINZcnOc%2Bgte8IIveclLxFeIUpCSAOKsf%2FzHfzznOc9pPgj9QZyDNTYdusxlLkM%2Fcejsh3ycRXMgHcjwute97j7XvqBvnPKUpyRlCG949KMfTXj5kz%2F5k2FLKpkSb3Ob2zD7Ax%2F4wI1vfGOlCIf48Ic%2FTIuQ7VWvetW3vvWttAtixUUuchFLZBBnPvjBD57oRCeS56gx1BXZUipk%2BMQnPlEOFJhPfvKTIk9U4QxnOMOLXvQiEodCLZdBoPj5n%2F95OSjxpje9qROf85znqO897nEPJ2L1f%2F%2Fv%2F33b29520Yte1Nc%2F%2F%2FM%2FPzp0g2yIQAQiEIEIRCACEYhABCIQgQh8XxA4GB3DWqBiFc573vPy2f%2Fpn%2F5p6sVt5%2FULwyBBXOxiF%2FvxH%2F%2FxtVIo35yHbtYGr390DNM65izFCZwgVgiieP%2F733%2Fyk5%2BcjDCLZFIh7njHO9761rf2yykmX1AD%2FJ2z5POzP%2FuzFBXrdeylauFNiZ%2F73Oc6JE%2FJiBUT5yAY4%2FyHfD71qU%2FNiTQZi4EQUiaxYA8SxxyyQsjoGKaTMO%2Fa1772Wc5yllEn7n%2F%2F%2Bws1IapIuXQM2wIt1EWsyMxAQemKV7zi6U53OtrL5EnNEIUiAkSchoU4TnjCE1pvZA6RPn76p3%2Fachzztb8RiEAEIhCBCEQgAhGIQAQiEIEIHCqBg9ExpBG9IObhyle%2BMgd%2F8iQ78NbJGkILTnWqUzl62cte1s%2BFXP7yl6cG0DFucIMbiKwYHWNFHYhhONOZzsT359dbtkK4gpQ0EDKFlSsm3ELowi%2F8wi%2FYT3C4whWuIE8f4oA9lq3YqdG%2F%2Fdu%2FXfziF7dIhWAPsRN%2BMvXqV7%2B6uSF%2BhlVKERGkg5vc5CYTVmEPs9ngQxuxUocpHkSGWc1j6RhiM6SkoihRKAU7f%2FInf5KG8y%2F%2F8i%2F2b3UMCoxDFJ6RdyyyYS4MIUUF2QyFVUBlcqUrXUnVVJAY4iitQ0DIzNaRYZ8IRCACEYhABCIQgQhEIAIRiEAEDpLAQeoYZmSc7GQnEz%2Fw7W9%2FeydnC2CagsE9587%2FxE%2F8hL%2FiE0gZd7nLXSgAo2O89rWvnbMICKNjzJIRVtQU6nDhC1%2BYs%2B%2FjdDM4FDGTVqgTk6E8zUy51KUuZd3RndLN7KBUiHmgFVzjGtcgYoziYYKJlNQM0oEYj71mO%2Fqud73rADoGUcKEEUoLocbPoNztbncjfTjrADqGcBGSDhrW2RjL%2FYWCAQQW54rBsOSIBCrLMAuBQmR%2FnwhEIAIRiEAEIhCBCEQgAhGIQAQOhsDSMWZyx%2FYU0RSzPoY0lnSgDwgzWH63wAyag18beec732kBzKtd7WrmlfDWfUwMkWx%2BFoT7z2ffq2NIIOyBkiCxWIX3vve9D33oQ61WYdUI%2BZhgYlkJ8zgcmjzpHjJc0SBj50xFkb%2BIjqV4UFFIBII0nGidjVOc4hRmiDh3TpGDqRxvectbRH0cOB5DCIf5KWbBXOc61zH9ZE0A2aeOMfNWTFoxFUXRFhodsxcK9bWOB4wUFTNxVM3Cocc61rEoG1vmbUcgAhGIQAQiEIEIRCACEYhABCJwAAKjY1hDwlISXPv1EXuwdAxrRHD%2FBWOIXljuvCgFcQU3utGNyBFiIRwSmDEFceEtoTnzJvanY1i%2F4mlPexoJQkzCnMXTt86GXzMxR8MUDIfMLmHDHFWuX27d0THMJREyQfogI9A0TA%2Fxl2RhIglJxA%2BbSi%2F%2BQazIss2Sng5RNlSQsECgEPuh1krZmVdij99gJTWwxCIeJrCMJTs6Bm3nXOc61%2BgYqkD08Ists8ap9AzwI62iWSgbQjVElfjp1cln1jX1W67ztb8RiEAEIhCBCEQgAhGIQAQiEIEIHCoBOoZf6ODaX%2Bta1xJ%2BQJfwueENb%2BgnQsQS%2BMVSYgW5QD4ve9nLiBXWf%2FDDJX6WlPNOBBiH%2FQUveIEcSBB%2BbtUvmFjUwsKbj33sY%2BkDVvukA7zmNa8ZS8wrkczPjxIoRC%2FIxJSQO9%2F5zoqbtTKsqkHiIBrQRpxIcJDPr%2FzKr9AiJBZfsa3Rb%2F3Wb0nzgAc8YLvTNnvsJ4MIfhjbmG1JCpb7VVZmv%2FSlL5XM%2BqV%2BFEWcCbGF0CEihc4goGLWx5CAtaI7ZMWGVcQTnvAEe4gw9qgFg3291a1u9axnPYs2oqZMFQQiT2fNr7uyUMpBYY0Rc16oN0wyVYcctHJuIwIRiEAEIhCBCEQgAhGIQAQiEIEDEzDHQUQBX95Cnbzv9TGzg45x29ve1pQN%2Fr5MaALiE%2FxM6vGOd7zjHve4VAU%2FbzrrTvgrWMIqFtQM80HkZpKI4ARncdgJF2agjBniFoRP8O7nqOgIpVvggu5hDQr6yZQlMcP8qogfNHGIfmJtzHe%2F%2B92TyfwVd0F4oUuY27Ldb1vwg2U2rcApE7Y985nPHLNZbvrJMlvwxiMe8QgVVx2relqsQ9yFVThmPU%2F50CUoM5bp8DMoq4inPvWpamT9z9lDyWGDHMR1CAgh3bz4xS%2B24oe5LWI5xGDc6173krPE4j3udKc7mYNjP0usoSHsZACuzNuIQAQiEIEIRCACEYhABCIQgQhE4AAErALB6zeVY%2Bdjmgk338%2BJUh62szmIG2ZJWD%2FT5JGdbOeQlS7WD7BKwHmX86gWvk5xAh6W%2Fy5zM1AsVSHWgm6wzZMmYEEJh0xsmZ8U2R6VlZxN09h7yInsF00xP4DirLGNHGFjm4kShYW8733vEwHCJLntZCi9PVCss4gSct5WH0AaC1DKnWRW8%2FBTKeatrNCOdfok3mvJStBGBCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEvrcE%2Fud%2F%2FocB8%2FeIskRuk%2BH8PaKyPXz5HH0sOXz2d9ZRQ%2BBb3%2FrWi170ok9%2F%2BtNHTXFHdin%2F%2Fd%2F%2F%2FWd%2F9mcf%2FvCHj%2ByCyj8CEYhABCIQgQhEIAIRiMD%2BCPzHf%2FzHP%2F3TP%2F3f%2FXz%2B5V%2F%2Bheeyv3P3t5%2BP%2F%2BxnP%2FsOd7jD%2B9%2F%2F%2Fv2lORz7mfrbv%2F3bd7%2F73T%2F5yU8ejtPXKWqkyp%2F73OfWnsOx8dKXvlQF3%2FnOd%2B6cu8OTA%2FuVr3zluxFeNMFnPvOZ7yaHHQsP8qsS%2F%2FVf%2F%2FXjH%2F%2F45z%2F%2F%2BX2e8oUvfMHRf%2F7nf%2F72t7%2B9zwTt%2FM%2F%2F%2FM%2Ff%2BI3fONGJTvTWt751aJA1%2FvEf%2F%2FFv%2F%2FZv99n9oP7Qhz7093%2F%2F91%2F72tf2Rw%2F2f%2F%2F3f9%2FnUX1PDnsv2C996UuUh%2F1lq3f9zd%2F8zSc%2B8Yn%2F%2Bq%2F%2F2sn2m9%2F85sc%2B9jEnfvWrX51DavTzP%2F%2Fz5zvf%2Bf7u7%2F5uJ3FfIxCBCEQgAhGIQAQiEIEIHDUE%2BC%2FnPve5f%2FSQz0lPetJTn%2FrUpzrVqearv7e%2B9a15XofDktvf%2FvbHOMYxXv3qVx%2BOc%2Fd3Ckv%2Bz%2F%2F5P6z667%2F%2B6%2F2lOZj9vLZznOMc17zmNQ%2FgLR5qPg960INU8HnPe95OSn79Fa94RQzBPMlJTuLv6U53uqtf%2Feqve93rDocWQUm49KUvfdGLXvRTn%2FrUTkEH%2FxU3VnGc93q4B8iEOvErv%2FIr%2BsP1r3%2F9L37xizspebj8WUdvdrObff3rX985%2BsPwVWsSDbQLFPur73Of%2B9zjHve4973vfUd5ePOb33yFK1zhxCc%2B8Y%2F8yI%2Bc9rSnvc997kPjmnNt3O9%2B98PToROe8ITnOc95nLsViDTBy1%2F%2BctfjJS95SfLRTolkyKc%2F%2Fek%2F9VM%2FJQE1Yx2Vwx%2F8wR%2Bc85znPMEJTiDbn%2FiJn9BjVz%2F8t3%2F7tzvf%2Bc6nPOUpj3e847msrnWta33kIx9Z577pTW%2B6xCUuwdqxhzI5J37gAx9w%2BVz%2B8pffn8C1cmgjAhGIQAQiEIEIRCACEYjAkUHA29g73elOvNGb3%2FzmV7nKVfjmJzvZyX72Z3%2BWl3rTm970cY973NYtOngD5Ml1es1rXnPwpxxqSv44n5oDyJM61MQHSOCFOGXgNre5zf7eax%2Fg3HXooQ996HGOc5w%2F%2BZM%2FWXtmQwDG%2Bc9%2Ffoeuc53rYIjqpS51KVRpGn%2F8x3%2B8k%2FhQv%2FJer33ta1%2FjGtfY5%2Bv7Qz19EnBOL3ShC2mRw9SUXGBOMcuPf%2Fzj723Hd7%2F73ac5zWkc5fz%2BcOoY%2BNzlLncB9r3vfe8%2BG4LacN7znlfbjVz2D%2F%2FwD76SNVC9%2F%2F3v70T09CLnCoSgdfhKo3jgAx%2BopVyDFLDXv%2F71jjrdUSEQEvic6Uxn0oFXiYIlfu7nfo5WNkddIEIm1tE3vOENOp5Tfu3Xfu2ud70ree3kJz%2F5O97xDgnYT0hxFkXi13%2F9129yk5vY%2Fpmf%2BRnihqPCQs5%2B9rMf61jH%2BsVf%2FEUn0jpczq997WsnZ5qGQ24Oq6A2IhCBCEQgAhGIQAQiEIEIfE8I0Ac4LIIHvvGNb%2BwY4FUsV%2Fqzn%2F3s3nf6c0jkwDYufXQMjpj0Dm1f6HOgCAiTjyD5nRNXud72OrR1yvanY0zKfTrpfEBCzTjaEshB%2FopW7gqVnxJVWcqtncsSp%2B89dGAdwxtwp0wOqvCHf%2FiHXmpf%2FOIX5ySipPqgKVEFx6RJifBOlSUzKcDHxrJngEu83TlHVU0b%2BYBsjwRK52h7y3%2B9613vy1%2F%2B8raNVJaRe9t6spLDbW972%2FGOObOT4RyS7YMf%2FOA5RGbZ6hj7ZLXIM8Y0GdWZfLZ%2FNQdjWLjdOdtwOWtCF%2BSA2Lbivjq6bTgQnDI1VZajC7KdCGv9vaXI2aHx4ueoUpw45%2FrLvGW5fNRU3Yk84hacuzVpTufmk7Ne%2FOIXz9dnPOMZiN373veelGKKKHIXvvCF5UloojBc5CIXETYziZ%2F2tKdJLFjCVyEfZzvb2YRAUBfPfOYz7%2BgY8lfKZS5zGRIEY25wgxswZjJhpLAosRaveMUrZs%2BznvUs2dI0fDU5S7akFXOsfNVGmltiy1%2F4%2BpCHPETK3%2Fqt35oTX%2FCCF%2BhChE1g7UFJic7FdhL0NwIRiEAEIhCBCEQgAhGIwPeEAB2DP%2BUN8k6o%2FAc%2F%2BEFe0ukP%2BVz1qlfdLgrxtre9jTfnCKfsJ3%2FyJzlB46ZxwfhEd7zjHcV4OPRjP%2FZjv%2FqrvzpeqtPFq%2F%2FCL%2FwCJ%2BsMh3yudKUrbV9qW1XDa%2BUznvGMTpRSzMM40XzJnXiM973vfWaayENK77Kf%2F%2FznjzyCHm%2Fud3%2F3d73Fdkg4%2FT3ucQ8TNO51r3sxzytsEfi3u93txuPjwVl2QyS%2FAIOznOUsFJjx7GTCd374wx%2FOhRTw75A36QL4p2kOrGOc61znWjqG9HgyT%2F5WFRDYz54b3vCGZA0zet7ylrdIILzBPJepCDutDDkYuec805%2F%2B6Z%2FmYk%2B5QFEkpi201F%2F91V%2FNfn%2B9Z7%2Fa1a7m1bwPpGYxqNov%2FdIvcX69PTdBwBt2nqyUQgVU334m%2FfiP%2FziPe6tFTIZbHYPDu51xwHvlgPNzfZaOoXEf8YhHqPiw4hTPMiYqIsaAk%2F6whz3sspe9rOZghgCDNamHDCUwQBFO9FeQw6qs1vzTP%2F1ToNRIF9KprnzlK9%2FqVreajsRCLS60ZhrOUQIO4zn%2BiKGkjrx%2BrCDly5vlxPtmwFnPelZNuUQDFoo0kMYh6QXqTMADene7292UznIxNmO5Ouo20vPihSgc85jH1FEpDGuGyNBjobJ0qrXC56Mf%2FWi4%2FuiP%2FmgSqP4FL3hBeheYVs%2FQu0gHc8hfKgdR4pa3vCUCsnrjG99ItlKuyxCKbTyGbdcgecGaG0RItZ5eLROnuCS175oAot3VnVDpUpKnhTus8bIKdaGxkJ2OXve615WbxTHmKBvMiGHk2uO6oJ%2BY6rJObyMCEYhABCIQgQhEIAIRiMBRT2DpGNt39FaT4HfP21jTT2zwVWeVPz41J1GovKB03p9D4uEn%2BNzSCt%2Fxco9xDA4vd5IPaPvJT36ySgnS4ED5KjF3kn9kmxc%2FzilpQv728MhoHac4xSnoId5lO3FHx%2BDr0QGk5OkrgjPLkmc%2B85nDzRvtYx%2F72DxNL5HNfZDMx0QPTqtqCra3igWPz9dHPvKRDhErqC7cf9v0AQKC19lTC56sjctd7nIO0R%2FGAT%2BwjsF5HJ96jKFjyISFllt80pOeJB8fLvAFLnCBv%2FiLv2CP0u250Y1uxP3nzquy5RGcK3KAB827H3f4Pe95D3eeh2u6Ct%2FZKZqGcyolZ1ZbOFEdb3GLW6i4fLSFdSY5s1BMpI3lSXEmPjiXs%2F%2FLv%2FzLzrL92Mc%2BFoqxdv5SCaZNVZypT3jCE9ZRE2S0NS1I62Crdlx%2BMoJ8VNM0hGlT8x2GlXZ0yMcUGxXEga7ye7%2F3ezKUQK0dInE4kWxlW7kydPTP%2F%2FzPdRKl6wySqbijMp%2FoCzqVPeBwxokGDimRR09rog%2F4yuUnBVDebGtxTMgphJ05SqMYl19Ig1JYRVjDRGKevpADR2984xv76qNQWUGqj73sZS8b%2BQ5Sh1RZVtN7FyKd0wwOaoBeNDtf9apXzZXCeOoEUcVVMPQA3AZ7SE9ugoiksDK0QWtymezoGCuBvqE5tjqGjkFs0aWnFaTUJ%2BlpILBWiAUhghyxcnBh6j%2BiNaRnmGt2qXYjarH%2FL%2F%2FyLye9xOCTB3e6zcqtjQhEIAIRiEAEIhCBCEQgAkcBgb06Bi%2BMq8ulWu%2BRTZHgV5pQz8vjP3J8LDA4URCiCPjLfDdOKJ%2FUWf5OKL44DV4PV5RDNC%2BCeVjzayZcdTEbnD6un3y8bXcit1pKVfaqmuNGr%2FD2nDETjyE%2BxFHaBS%2BSOzwp3%2F72t4tnEIAhJVfxYhe7mAgEb%2BH5WU4UIS9bDr6vTufxcW%2BdaB0Ajjyt4KMf%2FajiuIrjuvJVuZb8biVOeADXj74hzwmBOLCO4c21nNXdRz6kEqD4wjxEYg6zUZrfj1CiKkO6MIrQ4FPTf5TrdBszSwVwsg9nXBDCdIbHP%2F7xThRNMR63ItZijGQQpRAE1B1nXjwFRlkIi4fhwPpKF5LPu971LksxwKWsyXb%2BgqM4J%2FKpvcSnM8x0DEIBCYXj%2F5znPMd%2BnrV8hF5QEsTGDCuTRAROcISniekD7BQMMw71S17yEl9JMYzRFiqI%2BRwSnEDbIU1Yflal6GNazUqVjJGYCKP6BCjBD2Jm6F160QSKqJoaOarDzCGRFRPkQyUwLwYNetdEJiiU5VaoGMKECGFIOKg4XDQrhYoDUSIDdOnHPOYxM5%2BCgMNyCRxSayVaHlN7OWvHnaeN6PBmkSykzCChyFln05HMM9Ks29CmldJKGkI1RK1Mn1z7ITpMOgbRjOo1UslkovrUJ1eTDYqfurjQVv4myLBZPJLqMFV3Gk1SAhcU7Pa4yiY97GQci8Co18qhjQhEIAIRiEAEIhCBCEQgAkcxgb06hhB9fiVvyM9tmPvgnS8HjS%2FPDRdhzpH0elf4%2BtjJexWBz2fncJkdwCdy1hyaV%2BR8W94fd4m%2FSVJYtZOYm8kD5cbKUzT%2B%2BJuTwIQUR7lUnFmuKO9YCL34BDEPvO95NT8pySaTktTA0d6%2BDbeH2XznpWOYgsEbpc84xWSBZQzXUj4iAagx%2FFweOnfYy3Tet9f0FAD2S3wAHYODzF0FTV18ONSK4CnLxIlPfOITfRVLMCXCKBjDpIbtxATakTRssHN0DJaYQSArzWH%2FtAW3ms4gCIEkQjlBY0UFwGIJC0EU1ACKimS0gglyQI9OIiuihxPF3gi5keEcHav8HR1jlAFNoEYziUBKopPIHFjkw7OWAzd%2FLyvnijaRlXiMHRdYYIPWcaIPnUf3kO0rX%2FnKF77wheorW0bqezzurcCie5A4Rg4imjFJ8IkTBQkw5gEPeABov%2FM7v6PJINXZZD7VIXZJvJijSjojZVBLOObgSKzdJx%2BuvXzMhQEENN2GIDD5kGVUiryjF%2Bk8jpKA9qlFPPWpT9VVtithQiTUQc56puo7StCg3U3O66%2BriW2SUW%2FWztk4rDqGoCm9gri0pIbRMWamye%2F%2F%2Fu8rZWvh6Bj6nuK0tSaj8NiwX5iHxEC5A4wxLkDzksTqbFcU2TG4rxGIQAQiEIEIRCACEYhABI5sAnt1DI7bTAnhxWw%2FgtW5b%2FxN3ujykrbmzTqf63cu5tddOWijY3gZPc6gU7iEoz%2FwKHmv%2FH1ixdan5oRy%2BrhdS8fgoDGMTkKX4KevckciEDFCPxEDP%2BsZzlHeLi9sR8fw3pmnr14yX5lsN0grM2Fh1Z0bK55EmgPrGAwWy4GSD29X%2BAqtYHIeI5%2FylKfMVxg5jAIn1G4V%2FahHPUqJAiHoEqNjmBEgYMDb%2F2XJ2rBmAjtxE2GypbFyU%2FToGODbqdbqy5ueHFiIsJ0r%2FWyMjjH6g2kUJkEQTOTv50GxFRoh25kZIRbCKZrPL18sq2xQAEbzoWOYhzIrgUjpVb4Ym9ExfBWrQ6zYnihCRk%2BQvzf%2BIiJWZ5gVL%2BkYhIgJa9meNdvaRWejY4gfWEu5oi2sYmYnKVHkydIxKCcO7c3HJCMdW%2BnQMcZZPi6Q6bpEDHxGx5if%2F5gE6y%2Fz9AHrrqw9dAkFEV4oV3x%2FGpSgINERSwaUkmESMAbkvS1yWHUMcR1kNLEfS8%2BhY5hkJGaJAdZpYc926sroGERFlugqflRlkaFc6XtEJHlOjWhKRBL629b%2BVdk2IhCBCEQgAhGIQAQiEIEIHDUE9uoY9hAreENC603N4IP72DC1YQ6tZR63FpImDlXHMJlCMmdtdQweLtnEGgUTxj95Ko4%2FJXhgq2NwLflWJqqMbz4p5026%2BQ6kBo4zeWRZ5T3yXh1Dhl7fcxvN6Vgp14Y4Af61swSZ8MEpIeZTsORQdQzv2U0K4K5%2BJ9rgG99QysrTBoBKXDqGQAIe4kgEK5n4EGnEiqx4DDoGAzjRMlfNbVuIkFE7zSSoYLn8KysbOzrGHOKQsoH0QQBRFqVlx05faU0kC%2B4tj17mFrSkOfCCxYRwhIW4cNVNW9BYWIlCoSyREYgq7JnlLA6sY7BWQAjNgQ2wEASoOmQK4QraVxSEShG1VhOLAZh4jKVjiBOw3sh3OuUh3RIWkot1IfapY6xWXjqGyA3zR4RqKHSLVG4kOLWmVBw%2BHWPiMbTU0FZTESxkH2RW05B3NOggslOaWY%2BFhKLbrGRr47DqGObXmJ8iYgquyWSaiZQhKzOnXCOCWFb%2B4oV077WHPX62FRaXnv4vNobktSYfTTwG5UpPWDm0EYEIRCACEYhABCIQgQhE4CgmsFfH4K3MVP3tT1WOX2nmgp%2BKMAeEMzh2co6sWSEu3Ya3ukIXDhCPsVfH4P%2FKk5%2FuPa9X6pMnlcMMFP4y%2F5RnPetjcMy9WbZcoakBLFwphVvwSeUzLrCX3csf5D%2FuzCvhwnurzpvjxd%2FznvdcqMkU5krYz3fj1hFk1iHrgcj%2FYHQMyo95FuvE7QYfeatjiDFQZUsleME9yVRTGIAq8%2BuXjuEoJuprJczlS0o%2FwTDK4mb6rEyQpEJYhtFrfeErnHHrnY5SYYFHIsOaDSGyxYly5uRu7Vw6xtR3fglU02Ayv6xBgaFy0DE4vFZ95BRzzFcOFliw58A6huiOmddDKVonWnKBrGEJi2li03NWpax8Yi7DzCsREKItJnhgnTvNffA6BqQ46BgmX6xMbIyMpp8fqo4hrIW%2BtD13tplH1VkRQbKymIbwEl13Jda1hLvMGhS6IvFqInNWp10pZ%2BOw6hiTnjC4ft9kZldRVBRnQV1KoIrbnvxnBdql9mxLn0NEqtEeHZr1MfYXjrU9t%2B0IRCACEYhABCIQgQhEIAJHHoG9OgZn1vKM%2FG7viOkVPCzevZh8AfM8Gv6sQ8Ie%2BNFmQIyzQ6DgOx8OHUPOqjZrCFgZkkDBi%2Fdem5vptS%2Fnndu71vmU0goGUzr3Vkr%2BFzdQ2ICU%2FFCBIjxuYSS8YKERsyjozjqf7OS800P4el5Gq52VFa2bwUHmmPPTSTEi5y1iwBjRC%2BY78M0PUsfYkQVWq%2B3oGAhbDVJF7n73uzMVRss4EDEEpZgWQY6YeSVyY61WkNIvg7DHbA4LZdBqTBLRFsPND5hy%2F70in5kp4%2BZzOYV8IIMDUBZ4lAn9gb8PFEde9IIoi50JAkvH8FKe8XxhYRhOFCPBHbZnq2PMKhOm0lBFKDNMokWgd6g6Br0IbY3FSKqCaT5M1Ry6okphokR8hBb4WPFDm6qIWgDCHvqM%2BRpanM4GLAMIVrM%2Bxt55JctDl37mlSAAsm3AdRXc8Bd%2BYFFT8gLgB9AxmKerEyv8XCl0OudqYhs4UAloMjLxVWI%2FHKMutAvdVdv5vRKTa4SXAEtJMOdFVmIndDZXkzTzmTk7k%2FNh1TGcpVIK1Tf0CiE9LitfxSA5pKYuK7TpWkoBnCxppVyWT3G0F0E7%2BpgL3JXlMlnqogR6hfYlAKrapO9vBCIQgQhEIAIRiEAEIhCBo54AH0roOwdw%2B0aYyzw%2FRultsrf2PD4uqlUFmMdjFdXAM%2BJ4mnRggyM5iyKOx8RZm1pYyNG53EPOEQ9ISi%2BFxwPyd9zz8Xn5p95cS6AUL99tiG1485vfLB%2Fn8r55XrM6Iq%2Ff0hASmIYwa2lKyaGeEsVvjElsc4pkPn46U3E8NS6YJQoFEkhsJUO%2Bud9uYKFlG21YnYBbCoL0zsKEMTbU3d%2F5RdQHPehBti0yMMWtv5gIrmAPP3Tt3G6Ya%2BBEms%2FaKaUIEDuZMRUR5TI%2Fn8GzHh1DK0jP52W2lOwRGsHUUWAc4mNahtQhYgtv1AZ%2Ff17Ec9u1qT1akLPvKz3HV462KnOf1WuEqWWSDTqG2S6S8WR9xY1I4qtzNYQ9VsX0lQggJoSLPXkSnYYVz9fR%2BZmbwThrfjpRD8FZp5IP%2F5pwJKU91ouwwRifiVIgKwkHslOec9S2Esk78rEuqMrao6Fng6rD9YYCAbMn1voYo7CtdT4R0BXRnh8EgdrP18rHWcNfhAyVhgRh3grlZAVR6HiSwTIxDDL01dwQEy5W0AjDfCgtjBHoMg1nD6tEkkivu2o76o1%2BJcoFWIeEnTgkvsVRldVSPhrXRJ5D8vvOH5C1KVD0qLVzbWgOvVpH2ioqSp9C8SGbKAI9gsac5fKcDq8bUIQcpXto9zmq1i4oO30ssiF%2BY5Vlw3K%2BrNUE251tRyACEYhABCIQgQhEIAIROIoJeOVtdjzvbBzVVTpvmp9r1ga3TnjG1qPhh1rS0OKEpipwbcZr45p5ySvlWiBRMmHzMiEdePPuDTs1YJxBf6kiXuxaKmFK5GaygfNrbQRBFyOMOORcL9%2B9lKcVTEr%2BrJfLDNtJOUfNTZAtm73WH9nB72%2ByjZGqKb5ieXxe4rNWsIeZEX6XZPQNmXjLL0LALA8VtHyoN%2B%2ByMllGJv6qBW93ylp%2FsfrN3%2FxNlVWLtXO7QcYx3WOCT9Z%2BBQ1hGo6lKjjjc8j%2BrY5hp1f%2FvHKuNGvve9%2F7eo2%2BMnHIL7ESiEQRiMfYTmxhp0kxMvdDtNJz8AUnmHgiYEB0h1iU5b2u3Oyh0niVv%2Fibn6LKa1lL2hRo1gidWRhMtZaCorHinltBRTVpINoXNyeu9iVVaQ5ppptpDrEK%2Bg97xEL4gLz0KD6%2BPqCy5itZlJLvTwChEoydfG1NqfXVWt3HQ8fBRBXdcibdSEkHYwxpa84SfoC2WRLLoyf4OIUNlkB5%2BMMfrlApdQ%2F9jag1vdoeFwgxxwIsg4sZugeqGkJ4w2S%2B%2FjpEkLH%2BxtqjRezUXU3HuNvd7raEHfuVzkIV99Mw6%2BPrio6Qieq4LqBbZq%2BcbWgOK3OK65gIkHWIzTJnpKpNeNI6ZENrKk43sNKsZlo93yFhLbqipiF3bIVNh%2FRtyhi1Z5%2BWbPNvOwIRiEAEIhCBCEQgAhGIwPeWwCgP%2B7ThAIf2mf5gdh58nntTclq50qsU%2FqPXyhy6tWfvxt5MJg3VYm%2FiI2nPjg28Y%2BtnCvDYie5g0k7KZY9DPuvrdmPvKXvli236w7G9v6IPNau9tjmFhEI%2BWo65t%2F%2BiI4hRa89k69zDXe7WsMOXz%2F7OIiyYrSPaRBzFthTbRzj2nfz3ft2fkZPysNojzIZEs40p2ltieyIQgQhEIAIRiEAEIhCBCETg4Anwc61dQLgQZi9KxBKXpjlYBWJNEDj4rL5XKbmWYicEIViRYztF4ntlz%2FekXJEtGtFUGgIU%2BcJEGHMuZr2O74k9h7VQP65q8oUwiQlZOaynHw3T04tMrnEpmeEi9OhoaGEmRSACEYhABCIQgQhEIAIR%2BD4lICre%2BoSWa%2BAI%2B3gtviZEfF%2FUiMNokgvLLZLwohe96PvC5iPcSJ6ytWQtZHFIGx7DQpSmwBzhpRx5GRKjyGhWulhTWo68so6anM21MW%2FIXKc19emoKbdSIhCBCEQgAhGIQAQiEIEI%2FJAQoGb4cQ0LO6ylEr6PKm7Rhte97nU5jNbTEAMglmbvGhRH%2F9bk%2BIurUYWjv6kHY6H5KeJh9rnQ6MGcXpoIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAt8lgf8XwAklVQplbmRzdHJlYW0KZW5kb2JqCjExIDAgb2JqCjw8IC9UeXBlIC9YT2JqZWN0IC9TdWJ0eXBlIC9JbWFnZSAvV2lkdGggMTQzMiAvSGVpZ2h0IDk5OCAvQ29sb3JTcGFjZSAvRGV2aWNlR3JheQovSW50ZXJwb2xhdGUgdHJ1ZSAvQml0c1BlckNvbXBvbmVudCA4IC9MZW5ndGggNjI1NSAvRmlsdGVyIC9GbGF0ZURlY29kZSA%2BPgpzdHJlYW0KeAHt0DEBAAAAwqD%2BqWcJT4hAYcCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMDAa2AtxAdXCmVuZHN0cmVhbQplbmRvYmoKOSAwIG9iago8PCAvVHlwZSAvRXh0R1N0YXRlIC9BQVBMOkFBIGZhbHNlID4%2BCmVuZG9iagoxMCAwIG9iago8PCAvVHlwZSAvRXh0R1N0YXRlIC9BQVBMOkFBIHRydWUgPj4KZW5kb2JqCjEyIDAgb2JqCjw8IC9OIDMgL0FsdGVybmF0ZSAvRGV2aWNlUkdCIC9MZW5ndGggMjYxMiAvRmlsdGVyIC9GbGF0ZURlY29kZSA%2BPgpzdHJlYW0KeAGdlndUU9kWh8%2B9N73QEiIgJfQaegkg0jtIFQRRiUmAUAKGhCZ2RAVGFBEpVmRUwAFHhyJjRRQLg4Ji1wnyEFDGwVFEReXdjGsJ7601896a%2FcdZ39nnt9fZZ%2B9917oAUPyCBMJ0WAGANKFYFO7rwVwSE8vE9wIYEAEOWAHA4WZmBEf4RALU%2FL09mZmoSMaz9u4ugGS72yy%2FUCZz1v9%2FkSI3QyQGAApF1TY8fiYX5QKUU7PFGTL%2FBMr0lSkyhjEyFqEJoqwi48SvbPan5iu7yZiXJuShGlnOGbw0noy7UN6aJeGjjAShXJgl4GejfAdlvVRJmgDl9yjT0%2FicTAAwFJlfzOcmoWyJMkUUGe6J8gIACJTEObxyDov5OWieAHimZ%2BSKBIlJYqYR15hp5ejIZvrxs1P5YjErlMNN4Yh4TM%2F0tAyOMBeAr2%2BWRQElWW2ZaJHtrRzt7VnW5mj5v9nfHn5T%2FT3IevtV8Sbsz55BjJ5Z32zsrC%2B9FgD2JFqbHbO%2BlVUAtG0GQOXhrE%2FvIADyBQC03pzzHoZsXpLE4gwnC4vs7GxzAZ9rLivoN%2Fufgm%2FKv4Y595nL7vtWO6YXP4EjSRUzZUXlpqemS0TMzAwOl89k%2FfcQ%2F%2BPAOWnNycMsnJ%2FAF%2FGF6FVR6JQJhIlou4U8gViQLmQKhH%2FV4X8YNicHGX6daxRodV8AfYU5ULhJB8hvPQBDIwMkbj96An3rWxAxCsi%2BvGitka9zjzJ6%2Fuf6Hwtcim7hTEEiU%2Bb2DI9kciWiLBmj34RswQISkAd0oAo0gS4wAixgDRyAM3AD3iAAhIBIEAOWAy5IAmlABLJBPtgACkEx2AF2g2pwANSBetAEToI2cAZcBFfADXALDIBHQAqGwUswAd6BaQiC8BAVokGqkBakD5lC1hAbWgh5Q0FQOBQDxUOJkBCSQPnQJqgYKoOqoUNQPfQjdBq6CF2D%2BqAH0CA0Bv0BfYQRmALTYQ3YALaA2bA7HAhHwsvgRHgVnAcXwNvhSrgWPg63whfhG%2FAALIVfwpMIQMgIA9FGWAgb8URCkFgkAREha5EipAKpRZqQDqQbuY1IkXHkAwaHoWGYGBbGGeOHWYzhYlZh1mJKMNWYY5hWTBfmNmYQM4H5gqVi1bGmWCesP3YJNhGbjS3EVmCPYFuwl7ED2GHsOxwOx8AZ4hxwfrgYXDJuNa4Etw%2FXjLuA68MN4SbxeLwq3hTvgg%2FBc%2FBifCG%2BCn8cfx7fjx%2FGvyeQCVoEa4IPIZYgJGwkVBAaCOcI%2FYQRwjRRgahPdCKGEHnEXGIpsY7YQbxJHCZOkxRJhiQXUiQpmbSBVElqIl0mPSa9IZPJOmRHchhZQF5PriSfIF8lD5I%2FUJQoJhRPShxFQtlOOUq5QHlAeUOlUg2obtRYqpi6nVpPvUR9Sn0vR5Mzl%2FOX48mtk6uRa5Xrl3slT5TXl3eXXy6fJ18hf0r%2Bpvy4AlHBQMFTgaOwVqFG4bTCPYVJRZqilWKIYppiiWKD4jXFUSW8koGStxJPqUDpsNIlpSEaQtOledK4tE20Otpl2jAdRzek%2B9OT6cX0H%2Bi99AllJWVb5SjlHOUa5bPKUgbCMGD4M1IZpYyTjLuMj%2FM05rnP48%2FbNq9pXv%2B8KZX5Km4qfJUilWaVAZWPqkxVb9UU1Z2qbapP1DBqJmphatlq%2B9Uuq43Pp893ns%2BdXzT%2F5PyH6rC6iXq4%2Bmr1w%2Bo96pMamhq%2BGhkaVRqXNMY1GZpumsma5ZrnNMe0aFoLtQRa5VrntV4wlZnuzFRmJbOLOaGtru2nLdE%2BpN2rPa1jqLNYZ6NOs84TXZIuWzdBt1y3U3dCT0svWC9fr1HvoT5Rn62fpL9Hv1t%2FysDQINpgi0GbwaihiqG%2FYZ5ho%2BFjI6qRq9Eqo1qjO8Y4Y7ZxivE%2B41smsImdSZJJjclNU9jU3lRgus%2B0zwxr5mgmNKs1u8eisNxZWaxG1qA5wzzIfKN5m%2FkrCz2LWIudFt0WXyztLFMt6ywfWSlZBVhttOqw%2BsPaxJprXWN9x4Zq42Ozzqbd5rWtqS3fdr%2FtfTuaXbDdFrtOu8%2F2DvYi%2Byb7MQc9h3iHvQ732HR2KLuEfdUR6%2BjhuM7xjOMHJ3snsdNJp9%2BdWc4pzg3OowsMF%2FAX1C0YctFx4bgccpEuZC6MX3hwodRV25XjWuv6zE3Xjed2xG3E3dg92f24%2BysPSw%2BRR4vHlKeT5xrPC16Il69XkVevt5L3Yu9q76c%2BOj6JPo0%2BE752vqt9L%2Fhh%2FQL9dvrd89fw5%2FrX%2B08EOASsCegKpARGBFYHPgsyCRIFdQTDwQHBu4IfL9JfJFzUFgJC%2FEN2hTwJNQxdFfpzGC4sNKwm7Hm4VXh%2BeHcELWJFREPEu0iPyNLIR4uNFksWd0bJR8VF1UdNRXtFl0VLl1gsWbPkRoxajCCmPRYfGxV7JHZyqffS3UuH4%2BziCuPuLjNclrPs2nK15anLz66QX8FZcSoeGx8d3xD%2FiRPCqeVMrvRfuXflBNeTu4f7kufGK%2BeN8V34ZfyRBJeEsoTRRJfEXYljSa5JFUnjAk9BteB1sl%2FygeSplJCUoykzqdGpzWmEtPi000IlYYqwK10zPSe9L8M0ozBDuspp1e5VE6JA0ZFMKHNZZruYjv5M9UiMJJslg1kLs2qy3mdHZZ%2FKUcwR5vTkmuRuyx3J88n7fjVmNXd1Z752%2Fob8wTXuaw6thdauXNu5Tnddwbrh9b7rj20gbUjZ8MtGy41lG99uit7UUaBRsL5gaLPv5sZCuUJR4b0tzlsObMVsFWzt3WazrWrblyJe0fViy%2BKK4k8l3JLr31l9V%2FndzPaE7b2l9qX7d%2BB2CHfc3em681iZYlle2dCu4F2t5czyovK3u1fsvlZhW3FgD2mPZI%2B0MqiyvUqvakfVp%2Bqk6oEaj5rmvep7t%2B2d2sfb17%2FfbX%2FTAY0DxQc%2BHhQcvH%2FI91BrrUFtxWHc4azDz%2Bui6rq%2FZ39ff0TtSPGRz0eFR6XHwo911TvU1zeoN5Q2wo2SxrHjccdv%2FeD1Q3sTq%2BlQM6O5%2BAQ4ITnx4sf4H%2B%2BeDDzZeYp9qukn%2FZ%2F2ttBailqh1tzWibakNml7THvf6YDTnR3OHS0%2Fm%2F989Iz2mZqzymdLz5HOFZybOZ93fvJCxoXxi4kXhzpXdD66tOTSna6wrt7LgZevXvG5cqnbvfv8VZerZ645XTt9nX297Yb9jdYeu56WX%2Bx%2Baem172296XCz%2FZbjrY6%2BBX3n%2Bl37L972un3ljv%2BdGwOLBvruLr57%2F17cPel93v3RB6kPXj%2FMejj9aP1j7OOiJwpPKp6qP6391fjXZqm99Oyg12DPs4hnj4a4Qy%2F%2FlfmvT8MFz6nPK0a0RupHrUfPjPmM3Xqx9MXwy4yX0%2BOFvyn%2BtveV0auffnf7vWdiycTwa9HrmT9K3qi%2BOfrW9m3nZOjk03dp76anit6rvj%2F2gf2h%2B2P0x5Hp7E%2F4T5WfjT93fAn88ngmbWbm3%2FeE8%2FsKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqClsgL0lDQ0Jhc2VkIDEyIDAgUiBdCmVuZG9iagoyIDAgb2JqCjw8IC9UeXBlIC9QYWdlcyAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvQ291bnQgMSAvS2lkcyBbIDEgMCBSIF0gPj4KZW5kb2JqCjEzIDAgb2JqCjw8IC9UeXBlIC9DYXRhbG9nIC9QYWdlcyAyIDAgUiAvVmVyc2lvbiAvMS40ID4%2BCmVuZG9iago3IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UcnVlVHlwZSAvQmFzZUZvbnQgL0FBQUFBQytDYWxpYnJpIC9Gb250RGVzY3JpcHRvcgoxNCAwIFIgL1RvVW5pY29kZSAxNSAwIFIgL0ZpcnN0Q2hhciAzMyAvTGFzdENoYXIgNzYgL1dpZHRocyBbIDU0MyA0OTggNTI1CjUyNSAzOTEgMzM1IDIyNiA0NzkgNjYyIDU2NyA0MjMgMzQ5IDIyOSAzMDUgNTI3IDc5OSA1MTcgNzE1IDU3OSAyNTIgNTI1IDUyNQoyNjggMzg2IDI1MiA1MjUgNTI1IDQ3MSA0NTIgNTMzIDM5NSA1MjUgNDIwIDYxNSAyMjkgNDMzIDQ4OCA2NDYgMzA2IDUwNyA1MDcKNTA3IDUwNyA1MDcgXSA%2BPgplbmRvYmoKMTUgMCBvYmoKPDwgL0xlbmd0aCA0ODUgL0ZpbHRlciAvRmxhdGVEZWNvZGUgPj4Kc3RyZWFtCngBXZPNitswFEb3fgotp4shiqUkM2AMw5SBLPpD0z6AY8nB0NjGcRZ5%2B57vZjqFLs7i%2BEpX97Ot1ev%2B837oF7f6Po%2FtIS%2Bu64c058t4ndvsjvnUD8W6dKlvl3ezZ%2B25mYoVmw%2B3y5LP%2B6EbXVUVzq1%2BsOWyzDf38JLGY%2F6kZ9%2FmlOd%2BOLmHX68He3K4TtPvfM7D4nxR1y7ljnZfmulrc85uZVsf94l6v9we2fVvxc%2FblB0TsWN9H6kdU75MTZvnZjjlovK%2Brt7e6iIP6b9SDPcdx%2B59abmuK%2BH9pqyLqixR8H67kQYUvN%2BtpREF1KobdGsaVN2h4H3ppU8o0Mr2PqPgfexUbVDg3K30iAKLrVWLAgfZVAkFqs9anFFAbW%2BHAqrOgfACTVLCCQ7SVIFwgs47KeEEU2nIQDiB6qBAQEGrJylZBXutFVmD5Q2NqmQVxLcxyBosL6NSJaugVZSSVaAKGMgqUJuKrMHy7hQwkFUwlV5OIKtgDJ0bySrYq4PIYbC4lZJVoDqXBgaLrUpWnquVAkayChbr%2B0ayCtQ6kzXev69ebCSr8J73iZJVoHqTkawCtVZkjZaXT0OVrIIq75nf9O%2F%2FqD9WN%2BvjJrTXeeYS2PWz%2B6H%2Fvh%2Fyxw2dxkkNjD%2Fx2fi2CmVuZHN0cmVhbQplbmRvYmoKMTQgMCBvYmoKPDwgL1R5cGUgL0ZvbnREZXNjcmlwdG9yIC9Gb250TmFtZSAvQUFBQUFDK0NhbGlicmkgL0ZsYWdzIDQgL0ZvbnRCQm94IFstNTAzIC0zMTMgMTI0MCAxMDI2XQovSXRhbGljQW5nbGUgMCAvQXNjZW50IDk1MiAvRGVzY2VudCAtMjY5IC9DYXBIZWlnaHQgNjMyIC9TdGVtViAwIC9YSGVpZ2h0CjQ2NCAvQXZnV2lkdGggNTIxIC9NYXhXaWR0aCAxMzI4IC9Gb250RmlsZTIgMTYgMCBSID4%2BCmVuZG9iagoxNiAwIG9iago8PCAvTGVuZ3RoMSAyOTA0NCAvTGVuZ3RoIDE2MDgxIC9GaWx0ZXIgL0ZsYXRlRGVjb2RlID4%2BCnN0cmVhbQp4AdV9d3hcxd313Hu396It0kraXa20KqsuWcVFWlvFKm6yLVuyLVuy3Fn3Bi7YdBA4QGgxgQAJGBITvFo3gSlO4oSQxIQklFBCIG8SWpxAQkKV9J2Z2ZFlCG%2F%2B%2BJ7vefLJOjpn5s7M3vubmd%2BUOwvbtmxfSYxkP1FIaf%2F6vk2E%2FdS%2BAdrUv2NbgAVJuJEQ9eOrNq1ez8OFIHNkdeySVTw88WFCqg6tWdm3gofJ5%2BCqNYjgYakSnL1m%2FbaLebiWFnAstrE%2FeX3iHISXrO%2B7OPn55DWEAxv61q%2Fk6TfPp%2BFNW1Ymr0tdKO490ou%2Ff5Vkyqo%2Fz2O84hwP%2F%2FEthCXkKsJVNZgQmdhICbmGEEeVPIHF0Ouaior79HcPL7NO%2FidJ1bHoU%2B%2Ft%2BQUVL9w%2BsOqzT4f36%2F%2Biq0JQjxL4D%2FJpvzX8CiGGez%2F79NN79X9hn5S8yKhoUK9MnSf%2FVP4JqSF%2B%2Bekk%2F47UyK%2BQTvll8Evg3yb5RfALCD8P%2Fg341%2BBfgZ8CPwl%2BAvw46SQq%2BVVSCcwHlDG1AqH7gecBNbkIJUnEiPwSSZF%2FSBqBFcA24FZAjbRP4tr9KFEiAfnKY3qv1BYYkq8Q4nIhLhNivxD7hLhUiL1C7BFitxC7hLhEiIuF2CnEDiG2C7FNiK1CbBZikxAbhdggxHohYkJcJMQ6IdYKsUaI1UKsEmKlECuE6BdiuRB9QvQKsUyIpUL0CLFEiMVCLBKiW4guIRYKsUCITiHmCzFPiLlCdAgxR4jZQswSYqYQM4RoF6JNiFYhWoSYLkSzEE1CNArRIMQ0IaYKERWiXog6IaYIMVmISUJMFKJWiBohqoWoEmKCEJVCVAhRLkSZEKVClAhRLESREIVCRIQoECJfiDwhcoUIC5EjRLYQISGyhAgKERDCL0SmEBlCpAvhEyJNiFQhvEJ4hHAL4RIiRQinEA4h7ELYhLAKYRHCLIRJCKMQBiH0QuiE0AqhEUIthEoIRQhZCEkIkhTSqBAjQgwL8bkQnwnxqRCfCPGxEB8J8S8h%2FinEh0L8Q4i%2FC%2FGBEO8L8Tch%2FirEOSH%2BIsR7QrwrxDtCvC3EW0L8WYg%2FCfFHIf5HiD8I8aYQbwjxeyFeF%2BJ3QrwmxKtCvCLEy0L8VoiXhHhRiBeEeF6I3wjxayF%2BJcRzQvxSiGeFOCvEL4T4uRA%2FE%2BIZIX4qxNNC%2FESIHwtxRogfCfFDIX4gxGkhnhLiSSGeEOJxIU4J8ZgQjwoxJMRJIU4IcVyIY0IcFSIhxKAQcSGOCPGIEN8X4mEhDgvxPSG%2BK8RDQjwoxCEhHhDifiG%2BI8S3hbhPiHuFuEeIbwlxtxB3CfFNIe4U4qAQ3xDiDiFuF%2BI2IW4V4hYhvi7EzULcJMSNQnxNiANC3CDE9UIMCHGdENcKcY0QVwtxlRBXCnGFEJcLcZkQ%2B4XYJ8SlQuwVYo8Qu4XYJcQlQlwsxE4hdgixXYhtQmwVYosQm4XYJMRGITYIsV6ImBAXCbFOiLVCrBFitRCrhFgpxAoh%2BoVYLkSfEL1CLBNiqRA9QiwRYrEQi4ToFqJLiIVCLBCiU4j5QswTYq4Qc4SYLcQsIWYI0S5EmxCtQrQIMV2IZiGahGgUouEonS0PyVcmMuv8mDMnMl2gy3noskTmRIT289A%2BTpcmMk2I3MtDezjt5rSL0yWJjKlIcnEiowG0k9MOTtv5tW08tJXTFh65OZExDRk2cdrIaQNPsp5TjNNFifQmpFzHaS2nNZxWc1qVSG9EkpU8tIJTP6flnPo49XJaxmkpz9fDQ0s4Lea0iFM3py5OCzkt4NTJaT6neZzmcurgNIfTbE6zOM3kNINTO6e2hK8Vz9DKqSXha0NoOqfmhK8doaaEbwaokVMDp2n82lSeL8qpnuer4zSF02SechKniTx7LacaTtWcqjhN4IVVcqrgpZRzKuNUygsr4VTM8xVxKuQU4VTAKZ9THqdcXnSYUw4vM5tTiFMWLzrIKcDz%2BTllcsrglM7JxyktkTYLxkrl5E2kzUbIw8nNI12cUnikk5ODk51fs3Gy8kgLJzMnE79m5GTgpOfXdJy0nDSJ1Dn4dHUitQOk4qTwSJmHJE6EkTTKaYQlkYZ56HNOn3H6lF%2F7hIc%2B5vQRp39x%2BmfCO98%2FJH2Y8M4D%2FYOH%2Fs7pA07v82t%2F46G%2FcjrH6S%2F82nuc3uWR73B6m9NbnP7Mk%2FyJh%2F7IQ%2F%2FDQ3%2Fg9CanN%2Fi133N6nUf%2BjtNrnF7l9ApP8jIP%2FZbTSwnPQjzKiwnPAtALnJ7nkb%2Fh9GtOv%2BL0HE%2FyS07P8siznH7B6eecfsaTPMPppzzyaU4%2F4fRjTmc4%2FYin%2FCEP%2FYDTaU5P8WtPcnqCRz7O6RSnxzg9ymmIpzzJQyc4Hed0jNPRhLseD51IuBeDBjnFOR3h9Ain73N6mNNhTt9LuOH1pe%2FyUh7i9CC%2FdojTA5zu5%2FQdTt%2FmdB%2Bnezndwwv7Fi%2Flbk538Wvf5HQnp4OcvsEz3MFDt3O6jdOt%2FNotvJSvc7qZX7uJ042cvsbpAKcbeMrreWiA03WcruV0DaerE64%2BPPtVCddy0JWcrki4ViF0OafLEq5OhPYnXBhspH0JVxXoUk57efY9PN9uTrsSrhVIcgnPfjGnnZx2cNrOaRunrbzoLTz7Zk6bEq5%2BlLKRF7aBp1zPKcbpIk7rOK3l%2BdZwWs3vbBXPvpLTCp6yn9NyTn2cejkt47SUP3QPv7MlnBbzh17Ei%2B7mH9TFaSG%2F3QX8gzp5KfM5zeM0l1NHIiWKB5uTSKFmnZ1IoR12ViLlCtDMREoRaAZP0s6pLZGCiYTUykMtnKbzyOZEyqW41pRIuQbUmEjZB2pIpOwHTUs4mkFTOUU51XOqSzgwL5Cm8NDkhL0boUmcJibstB%2FVcqpJ2KcjVJ2wd4GqEvZFoAn8WiWnioS9EJHlPGVZwk4frDRhpw6phFMxz17EP6GQU4QXVsApnxeWxymXU5hTTsJOrZTNKcTLzOJlBnlhAV6Kn1Mmz5fBKZ2Tj1Map9SErQdlehO2pSBPwrYM5Obk4pTCycnJwTPYeQYbj7RysnAyczLxlEae0sAj9Zx0nLScNDylmqdU8UiFk8xJ4kSio9blfooRa79%2F2LrC%2Fzn0Z8CnwCeI%2BxhxHwH%2FAv4JfIj4fwB%2Fx7UPEH4f%2BBvwV%2BAc4v8CvIdr7yL8DvA28BbwZ8tq%2F58sa%2Fx%2FBP4H%2BAPwJuLeAP8eeB34HcKvgV8FXgFeBn5rvsj%2FkrnM%2FyL4BXPM%2F7w57P8N8GvoX5kj%2FueAXwLP4vpZxP3CvN7%2Fc%2BifQT8D%2FVPzOv%2FT5rX%2Bn5jX%2BH9sXu0%2Fg7w%2FQnk%2FBH4AREdP4%2B9TwJPAE6bN%2FsdNW%2FynTFv9j5m2%2BR8FhoCTiD8BHMe1Y7h2FHEJYBCIA0eMl%2FgfMe7yf9%2B4x%2F%2Bwca%2F%2FsPFS%2F%2FeA7wIPAQ8Ch4AHjEX%2B%2B8HfAb6NPPeB7zVe5L8H%2BlvQdwN3QX8TZd2Jsg6irG8g7g7gduA24FbgFuDryHczyrvJMMt%2Fo2G2%2F2uG1f4Dhgf8Nxge9F%2Bl5PivVGr8V0g1%2Fss793dednh%2F577OvZ2XHt7badwrGff69rbv3b338N5X90YdGsOezl2duw%2Fv6rykc2fnxYd3dj4mX01WyVdFJ3fuOLy9U7U9Zfu27cqH26XD26XG7VLpdrw42W7bHtiumLZ1buncenhLJ9kyZ8v%2BLfEtqknxLW9skckWyTA0evroFl9mMzi6Z4vZ1ry5c2PnpsMbOzesWt%2B5Dje4tmZ155rDqztX1azoXHl4RWd%2FzfLOvprezmU1PZ1LD%2Fd0LqlZ1Ln48KLO7pquzoVIv6Bmfmfn4fmd82o6Ouce7uicXTOrcxbiZ9a0d8443N7ZVtPS2Xq4pXN6TXNnEx6epNvSA%2BmKjd7ArHTcCfFJ00p9Ud8bvvd9KuKL%2B077FIc1zZ8m51tTpYbZqdLG1H2pN6YqVu8vvXLUm1%2FYbPX80vN7z988KmfUk1%2FcTNw2d8CtuOizuWfOp8921F3fyLlsAntWvzsUbra6JKvL75Kb%2FuaSriaKFJAkItlAig55jkkuf7PyBKLwsoxI0k1kfqR9SEfmtsd1cxbHpWvjOfPo32jHorjm2jjpXLS4a1CSvtY9KMkN8%2BMp7R2LePiqAwdIxrT2eMa8roRy770Z07rb4%2FupjkaZHqWaIEl3ZOnW7VsjXdEpxP6G%2FX274nrK9kubbLVKVuuoVY5acfNWi98i0z%2BjFiVqKatutpr9Zpn%2BGTUr7qgZMdSUuaY585utRr9R7qw3zjbKUWN9Q3PUWFTa%2FKXnPEqfk39yZNvSrRHIbRH2i1C3tJ0G8YMr%2BN26DWH6D4QwoVe%2B%2BocnQ7plW%2FHDiuHFf3WW%2Fw%2BuSP8f3ON%2F%2BS0OEnSRrqmj8pV4l3kFcDlwGbAf2AdcCuwF9gC7gV3AJcDFwE5gB7Ad2AZsBTYDm4CNwAZgPRADLgLWAWuBNcBqYBWwElgB9APLgT6gF1gGLAV6gCXAYmAR0A10AQuBBUAnMB%2BYB8wFOoA5wGxgFjATmAG0A21AK9ACTAeagSagEWgApgFTgShQD9QBU4DJwCRgIlAL1ADVQBUwAagEKoByoAwoBUqAYqAIKAQiQAGQD%2BQBuUAYyAGygRCQBQSBAOAHMoEMIB3wAWlAKuAFPIAbcAEpgBNwAHbABlgBC2AGTIARMAB6QAdoAQ2gBlRTR%2FFXAWRAAghZISFOGgGGgc%2BBz4BPgU%2BAj4GPgH8B%2FwQ%2BBP4B%2FB34AHgf%2BBvwV%2BAc8BfgPeBd4B3gbeAt4M%2FAn4A%2FAv8D%2FAF4E3gD%2BD3wOvA74DXgVeAV4GXgt8BLwIvAC8DzwG%2BAXwO%2FAp4Dfgk8C5wFfgH8HPgZ8AzwU%2BBp4CfAj4EzwI%2BAHwI%2FAE4DTwFPAk8AjwOngMeAR4Eh4CRwAjgOHAOOAglgEIgDR4BHgO8DDwOHge8B3wUeAh4EDgEPAPcD3wG%2BDdwH3AvcA3wLuBu4C%2FgmcCdwEPgGcAdwO3AbcCtwC%2FB14GbgJuBG4GvAAeAG4HpgALgOuBa4BrgauIqsmLpfuhLqCuBy4DJgP7APuBTYC%2BwBdgO7gEuAi4GdwA5gO7AN2ApsATYDm4CNwAZgPRADLgLWAWuBNcBqYBWwElgB9APLgT6gF1gGLAV6gCXAYmAR0A10AQuBBUAnMB%2BYB8wF5gCzgVnADKAdaANagRZgOtAMNAGNQANZ8V%2Fupv%2Fbb6%2F7v%2F0G%2F8vvj9Bp2djEjN6sd9lSHHjSfouQkVvGH4Aic8g6spXsx7%2BryQFyC3mKvEqWkyugDpJ7ySHyXRInPyDPkJcuyPV%2FGRi5RL2emJSTREOchIx%2BOnpu5BAwpLaMi7kFIacqcD5m1Db61y%2FE%2FXXkllHbyJDGQQwsr1n%2BNUr7hzQ8%2BimGXA0xj1bRsHwNtJV90gfab40cGXnwggeYQzrIIrKYLCE9OIXWh%2BdfQdaQtbDMRSRG1pMNLLQB11ZDr0JoGVLBvTB9PtVGsolsJFvINrKd7MC%2FTdBbkyF6bTMLbyc78e9icgnZRXaTPWRv8u9OFrMHV3ax2Itx5VKyDzVzGbmcKcE85gpyJbkKtXYNuZZchxr76tB1Y6kGyPXkBtTz18iN5Kv0gQuu3ERuIjeTr6M93EpuI7eTb6BdfJPc9YXYO1j8neRb5B60GZrjNsTcw9Tt5A7yOPkJOU4eIUfICWbLftiWW0TYZRWz9CbYYA%2Be%2BYpxd8ytuXPMWpfCGvS5B5LPfTHsd%2Fm4HDuSdqTWuwIpqXUGkvVAS9mbjBGWuAlPxvX556Q2os9w4wXPKXL8p1j6xNROd8FewjLUZrcj7s4vxY5PMV7fTu5GD7wPf6lVqfo2NFf3MD0%2B%2Fltjae9l175D7icPoC4eJFQJ5jGHEPcgeQh9%2B3vkMHkY%2F87r8YpffYR8n9VcnAySBDlKjqEmT5CTZIjF%2F2%2FXjsB3fDHP0WRZibFSHiWPkVNoIU%2BS0%2FA0P8Q%2FEfME4p5Kxp5hqXj4h%2BRH5AxLRa%2F%2BEG3raXion5Gfk1%2BQX5IfI%2FQs%2B%2FtThJ4jvya%2FIS9JZqhfkXfwd5g8p%2F4jsZCpWP4%2Fhtq4iyzFv%2F%2BHP%2Bo04iL3jn48unP0Y6WFrJLmYwL5MGrpGLkBOxMbzn%2B05CcG1R9ICjk2%2Bi9lCThv%2BBX1mpFvj%2F4tuujqq7Zt3bJ508YN62MXrVu7ZvWqlSuWL1vas2Txou6uzvnz5nbMmT1r5oz2ttaW6c1NjQ3Tpkbr66ZMnjSxtqa6akJJcVFhXjgnO5Tl96bYbVaz0aDXaTVqlYL5eWFTqLk3EA%2F3xlXhUEtLEQ2H%2BhDRNy6iNx5AVPOFaeIBmq8Ply5IGUXKVV9IGeUpo2MpJVtgMplcVBhoCgXiZxtDgSFpUUcX9IHGUHcgfo7pmUyrwixgRiAYRI5Ak3dNYyAu9Qaa4s071gw09TYWFUqDRkNDqGGloaiQDBqMkEaoeF5o06CUVycxIec1TRyUic5MPzau5DT1rYjP6ehqavQFg90sjjSwsuKahriWlRVYG8c9k%2BsDg4WnB24YspHlvRHTitCKviVdcaUPmQaUpoGBa%2BL2SDw%2F1BjP3%2FVHLwy4Ml4YamyKR0K4sfa5Yx8gxdU5tlBg4J8ENx869xfc9biYvmSMJsf2T0Iv0kccM1Nc6hOa4N5wh3i%2BYJDey%2FVDUbIcgfj%2Bji4eDpDlvgSJlkS643IvvXJaXHF10iv7xZWx7L0hWLYp1NSb%2FN2xxhvfvzxQVIiaZb85cVUOrgfiSrh3ef8ayn0rB0KNeELYkszvikcbIaJ9SWM2DZaWIH1fLx5iLTVDR1e8JLQpnhKaxq2NCBSS07R2XhfLwmOb4ikNcdLbn8wVL2lCXjSRpgFaMfQGaVmhjq5HScXoG4OVAd%2FRClJJuul9xN0NqJRw00DXilVxf69vBdrnqkCXLxiPdsN83aGuld20lkK2eP4b%2BDj8oAJZLjzbF1KLxHjsuDZHF%2BiSfUo3rS1EBJrxJzRtMi7Y4hoepDU6bXKgS%2FIRkQyfkkxB1QXlIKDkNLQgMxhZG1p8QTRu9vO%2F3JKPPwBuI64buycVbkJ9%2Fp7453zlrfHU9IbyA00rG8fd4AWFIsBuMFnav79PmdoiaQzcgo5WZwt9hqJCGTqAy7q4jOdkUbQWvYE4mRPoCq0MdYfQhqJzumjlUFuz%2Bm2fF6Lbq6y2k61k%2FgUhfr2GX4uTYPv8LhGgO0%2Fx5girV1qtLDydhceCLV%2B43Couw%2B%2BQOQMDKwaJkkObsm9QYkLdcH13fHakOxRfHgkF6X0WFQ7qiCk4v7cBvbcZnjPU3BcK2ALNA31Do%2FuXDwxGowObmnrXTES%2FGAi1rhgIzeuajMpljmCvbxe9Fwdpl9rnT0NRMpk2GJKu7RiMStfOW9T1qI2QwLXzuxIy9pp7p3UPZuNa16MBQqIsVqaxNJImCdAALWkuAjqW3vdolJD97KqKRbBw%2F5BEWBxPhDiJ9A%2FJPM7G0g2G2QdF8d2J%2FiEVvxIVJagQp%2BNx%2B3nqvGRqHa7Y6JXHCAYSbP7hnvkP3wmMGtRRXVQfNclmGSalVZJAzGNIq5fIUZNklnyDKBNPgGi8kh7UR32PspJ41GPSfqSkcftRejKZTGiycQXhI%2FmDd4KST9C5qOuoiaB89hcpptEfuBDvGrQxDDRNgRW0%2Fe3pXjPQ2029B3GjreJXikuhOhKXQ3W4Y40pbgitnBY3hqbR%2BHoaX8%2FjNTReG5oWl9wSKnsITnegNwRHjD7Vhdcd3Wj%2BNtq95ZzA0Ojo%2FK7gWd%2B57iD6%2FBJgUVdcH8FAp85pQ7rpFL2Inh7f399H74N0wpdR19Pa343OLgpEkta4HiXokyUgRTPLQ%2FsbMvWjraFBsvz7EYjv7453R%2BiHdq2ldxQI2OKkJTQxrgnzMtVh%2BkEl3QOOUDntuUgaN%2BRcQ0mPeyPzuniMD0F8GEYU%2BkRaE%2B68P4RL%2Fb0BWB1tZB76Mh8sDLQdImYlfL4qvJLB4EteJPSxlByj2RDXF6NA%2FFJtLEaB%2BNV2wyj04VnommQCfLYtbsQdhceZMpkB1sGlVnov%2BL0GN0%2BT%2FoAW0zFE5oYuhu%2BnN80%2BSovLcXNOax9GN57fiJhQjciMsnQ5NIqWcYbHaumTm2B3uISh0QdDl1AXJ36KCkN09KPtj%2FgeRUcl3QNfjIgvjhQV6r4Ya2bRAwM687%2FPwO2lM48xLQUP0k%2BHNTBtcKy9BZroABtqG5RnIQVYYjzQFsKgJudQYKKjoPsEAyu6aSrc8hzmy0JflQhFjCWiwzQrfMA2ic5KaAjXWQgB%2FA7EV18YXDMWbMblZkwGc4oB9htGxVC%2Fv84Xj6Fl4jJLQmskMBCwhSaG6B88qoLeAPSinsa6BZo%2FWh3tNPv7A13L0dhhnubegeYBfEigvw%2FZaBtMflJ8Q%2BSCItEvJPRDGIRaIb5%2FTqC3O9CLqanU0RUM%2BtAbwYFVffFoqI8OBXPw%2BfidgyEJ1DdAmzjpxof64loMTKv6VoaCGHAQ183syuoHn867DfENDIQG4swRNCMxig%2Bj27VSwu%2BmSKhvJZ1C4%2FMCfStZ3mbcLrMOvT9fUwh9eSXultodz4Vvf5Hl9E%2F%2FQAil9fRGYAn7gGMgUDsAF9yD0UMV7l%2FQi6GKjkgBVtV9PoRg11Ya6kZBPKE%2BhybkXYDezfrIYI8253wM7YvxjRGeWMdKxZ3N7YrPEZlYf6KpNkfisqcGF3GncWkuPBvsT%2F0UjKfOaYV5o2h6Ppo7EJcxvPLqYflbaVa4Bl5hPBti2CDCuhgGSTHaiHFoiQ82%2Fcp4orIQgu16orqPhFSNpE%2F1F%2FKw8jZ5WFaBe8jD6gJgBulXZZGHVV3AAMlSXiBLVJXkoLKcLAL3Kp%2BRHnkzyVHOkAk0Hq8GrgIOalaQgzSsqmHpqO6Vf4Z8QdIhP0KCCN%2Bq3E2y1ENkgrKT5Cv5pFvOJqfwYuQOxUMkvNRqwv21AI8AW4CVwFw4D%2FZCGmzCXtVycJAU4hufduIjfpJBMnHdjO82mrCPlY71pIOkEg8xIJWM9CkkjWSTMMnBdyexa0YC%2BNphiHiJFl7YTbLwnltHckkByScRkody6c9T5CkpJqvky5RUJab8RLVZ9an6as10zY%2B1d%2BhadR%2Fqv2NYbvjIpDXdbQ6ZN1jWWk3Ww7ZFdpP9dsdep9WJU0qufe5M943uP3jMnimetd4i70c4gHqVbx4%2BjYxsVX6NHTkFd1BLZpJZ5I74VZGuxzEez8UNTZSOH3c1NuqKtE9KDXiAAPbbdXgV3xC1qmTzybS0%2BtDJCZoDir11SCo6Vq89gDdJ9cOvDz9bMvz6OUdtyTmp5Hdvvv6m7YNn7bUlFW8%2B%2F2YZThakpJlPxpB1QuhkbIKiORBT7PU0f1Qfq4%2FK2gMxFOKtj6Q9G3m2JPJsBMVESsu6JXvQzpBikbXaFE0oq1iekBuuqqgor5MnVIZDWRaZxVVWVdcpFeWZsoKUPKZOpmFJ%2BfXni5TZwxr50lD9ggp1Zpo1xaxRy%2BleR9HkHNu8xTmTizO0ilajqHXavOppWe2xpqxXtPYMlzvDodM5MtyuDLt2%2BFW15dO%2Fqy2fNahin92qaCYtqc9WvmHQySqNZijTm1owKdi6wOq0qYxOm92t0zrsprzGJcNXu9JpGekuFy9reCbMGRr9VHWpOgX1HiavUbs%2FSrJH3z5mskkzQkNJER4aff%2BYETFGIXBm5P1oGo3KsdG%2FZvbXxP5G86QcernQKM3MDoVzPjQZTd6sjJDBLLlVJmKymeQjoadCvwwpIVPI5MiY6%2BhUd5L6%2BnpHbW1JSU%2BP3VNrh7RX2M6V2yvKSqVID99Cx0EDXzQTRZpyPoyNL3N8OV5R0FgxEZSCystxuzWsxnKVoGJRQlnhcFW1xKvJow0pQdV2nWTL8ftznHrVxuE%2Fr1MMzlB6Ro5V0kkJlTk1NzNQkGZR7ZZ%2BL%2F1wittnUSlak16aNPKM3qxXqS0%2BtyphtOgURWc1HhjejR7YN%2Fq%2ByqTORJtm7floOpkUgUWP2qSZ4PePWhn%2F5aiZ8V8xwaXxbx%2BF2SJPyhXom16pBH02LBUmnPNUp6QCMoGUSsWD%2BgVo4M%2Bfo5BK3mS2sb14Bs16MOjFkbujsaAzPCQVHos5501QDUkFR2MT9KVDUnEihpxo1WciFNQkKRbehitZ69S4kq2VtmNXSiZaLG%2B1KpOs1qVEl%2B1uvfTnN86cd%2Fuv9tWsW9Ts06kVlc6os5TP3jx7wYEV1RP6b1o8c2tHpVVr0CgnbV6HJSU%2F1zf%2F%2Fg%2Fuvu%2FzI0tcgQKfxZnmSEl36nNLcpuu%2FsGe3U%2FsmxouCWvsmQQtEd83V90IP%2BCAD%2FsGbYnRjPqg5PTCXk4bjOVMgaWcDpjJ6YWNnKfkcnimNG7RtKRFGSMd%2BF%2FUomBm0bRTsh0e0SuZEpYO35AUHlTPJ%2FXn6scs%2BDw3ZFlpj2%2FQAjOajsUsHWqaMhFDUpitnrkAaqJgVniCvbKqIogera0slkMhOzWV6sYFD7x%2FaOSvnvx8j5Tz0Nt3dxyv3Pi9q48M7vnellr5zoc%2Be2CuP1d1ea5%2F4XfePrj2%2BJVtn9vr9v8ALQVPruzBkxeSR%2BhzD6blJtsJmLUTxngqMHsqdh02yB2S7VG93hlwBvBwaUOSLmreH5ZOh6XnwlI4rEnFcyTMHbmgQQ1%2FXriyns1b8NglrLvZ%2BGOX09YTZgUYY2hxbgW5zTT7sZi5Q0MLSMRQAjMDisBmYrIBXWgN1oKCdmqYcVLZozKYdcO3UMPIq3RmnVqNPyMaKaFD31HpoWfJks5sUE13%2BBw6biSdw5fi8Nl1I%2Bv0tnSnI82mHSnT2X107Ht49FOpCz7LRfqovU7We2Z7jngUkrQamFmNMawGZlZj12E18hjagmH09EmXNNNgm8ucj1QSOd8AjrJIeBLeR7gHt%2FNOIrukLl1KMNWblaLTu4Ke1GCKLk1n0qrVWpNO9YpQyVptx12m8bt8lLh4U8WZNHZ7jHF7YHZ7YNZUXajUY0RvnesakiLJapNKzorb8x21ztXQS2MVIu6SN0veZV2sDpR2GFc%2FfMaTr0vJ8tJblZ6j7qo9xefUw8yPiNv97D69PZ1bFqOBnUwhh6hlj%2BZarSlJqzJGN2SM2wa%2FT3sYC8OqKbQtZmYaiovLaZct9yJtuRcJy21IVU67bDlNYiOZNXMNxdZcVWpWR2onbZZw%2FZ7aeqkk2Qfhr0WzLIH%2F90UtX8jAfDxyiAenI2wu2ntuyO122cWQS3soHXM9UqbiqQjTLpu0jepSsyvNXJ2WGwq5RtYEpqbLsqxz%2Br1ev0NXmDY3I9efYZcmZlSVl3kltEunP9UdcOimp2D4NGaU58pv1O6d1HJ72%2Bf%2F0JppvZu1qu%2FlZRk8%2Bf7hn1b29%2FaUzD48W34SowOatkkLz9Y%2Fek71tjqIuVYuuZt5trQUaqMU6tZSqFtLoW4thdoIZqyI6gOkFO%2FyFJKZND6YtRkwGzDAbMBg15Er8xQGDANJlfIT1nkh2m7UGCTGu7eesfYzaEW%2Fzj8Ws85T05Rwb2xUOO%2Fe6KwmaSrm3caNBqq32255%2Fdavv3B9Y9utr9964%2FMHmo7nLv7Gpk3fWJYfXnTHls13Ls2Tb7%2F788FlCw%2F9696Dnx5ZtuCBf3x3wxPXz5p%2Fw6nVW05fP3P%2BjY8zXz%2F6qfI0PF46Zpn3UIsMZmuSjwpmj8oYpgGz7sGu41E1tBF57BnUgBnUgBk2k1makRHAtYwhuTxB7DlDkuGoRmPC4xmPujpMdHaRnAryJiaaF%2FV6Gpr6eAzJXTT9sRjLgCY2NuujDSp0QbOCf1ONc%2FzK09Gd37%2F4Fr0zmEr7WEGa5CqYuXb9jPzjkxb2FN7zzVmrm7OVW%2Fru2jB5pFj0ONpktJ76JZcsnL2u0jL8Sd70fjoGYjZmhF2qSCN5grWUTFuxvVqHZ6umz1rNnrWaPns1bS3VaC0n86MI5tfbqeGgGCMtYxgQzAwIZv7FDgMm0ottGC1ObIpK0ahnCp77eLDDk5yHUVP1nKsVc4vy55OuEQMGDJYojtKsx2PIGKQ5T8SSWWm3ZEarFR2T9kulWPmS9dyeTIWOD1r0TKfbLVWGc8NhMYoaNSnZmWnBFKNqp6uobv6krcKuGFWdZVPT2rfOyg1NW1IbqCzKS9lm0Y0MN85Jra%2B4%2BaHG%2Fml%2BuGIduh1cTlnlwvrQ8Mtj9n4k169WzDULNjZMXT17YoolMnlW2cj%2FZGcoV81Y69FqRmYEJ82BB8xCDayB184mVzP7Z2RT4%2BdlS2mUw2lSnkcKm6XCVKnQK6XCpMesmP8yQZuhV8RQEXXQqFRvqjec45%2FrVTv4WOOorbc7JDrZpXO3slLS0yP19PRgmus7OZYMxkQ6akg6Vy1WUf9WVcX9WQWbtmI%2Bq5VPqiypuRnuoNdu0ioj3TrJkZeVHnToVdJWSVqr6GBKf7ZZ0WXSuamkUmO%2Bpkqw2SuG28%2BeUtXTeDp7pb1yyeg5pV75GanAxtS%2F2NMHrNP800qmKUa9p9IEF1VpQ4OqpC2v0kafu3JI%2BihqIbm5ViKZCG2hZGJyXAC%2FTee2jJGBMrPVxCFZF02xe35MKm2V8qTTlRKplCori6cWDEm%2BqPW5LCkrS5XxbnHblNdMM1WkhLox1iLtbO6ytEfM2c5ElvbUlvC%2BXI6mubTHFzUbPVKl58cxWl4WK9AdI1lYeKDM4ox3Y8VtpimvxWi53hI6pUvOZWjRkR7WbOkiAYPFBL5YYI6wYgKd5I0t7upUtBpcWj5NdleUV1Ur9bZ0X5rfMunmjulbO4rqtj20do%2B7bFbtlL7WMpMOI4HWN23Bqsq%2Ba%2BeH7z%2FQuGKav3vO1I1TvCYT3I5pUX1zTvOqqTM2teU0V86Z4MsIZehsqdbUjLRQhrOw89L5ZzxF9fnN86Y1oo4Ooo5eUG%2FG%2BnwKOUHr6Hh9vWQIViU7Ofh9anUw6%2Bw0zKxeNSR9HPW5InSgiQSQIkJrMUI9SYTWW2RINkT1xGWomhBUqbFcUJ8It%2FmabTNqIQfVM%2BloQr2oB34hOU6ft3yP7yTPF6YZsYjmWdU0L8aXmWzeSJ2Dh7oGKTlC54r1xvmR2c7XY2LY0drdMG%2BdrLxQ0X9TT6S1uTkX00IXBmKN1hnwpmJUzmtvaclbfv3CvEdclQuigbpoU27jnoa6rupU6a3tp65stocn5m%2BAE1CpMD9T1zD3gD%2FDf8qvCdlmXRHf3nT5iimOgmnlIwfnLZzcv5vOLxfBxgHlGSy3fkotPJiOmeNp6lrBb1Dr0pnkMZiPsIk4LrAJOqwIZuPy%2BQn66Ls0Aybqxqi5xCJZUt%2FyRw3mFn%2F2kCQfc7Yp75XRrxnozS1lhUOSZlAPQw8%2FH6GLuwjaeXLUPgM%2FQafoUZM%2F9a0YL8BJSzgZc7aVKe%2FFaCHHaSF6WgoWeszkyEZXev9%2BqcdWfqEsLPrOL%2FSUgKzWpk5u7yrpu33lhKmbD3ZHOhonePUa2WG25k7unLhzXzDaM7l2QX3EpDVolW%2FbU%2B3m1JwMR3T30e1XPbVrki0ty2txeh25%2FmBe8OQjC6%2FoimRHQjpnBlpuL6x6F85yhbEifpx5F3%2F9JMnoq6U%2BpdaAdllrgylraWuspY2z9hQO%2FBJSwm1eQls0roPZ%2FIAxMrF4pC6hDdjgDDYba3N9Kgv6vjrhbYODUh21zMQWHhova76YZ%2FKpkJhtwnPAcRhERi%2FNeSzmbbPQvFgF0sx0OsCa79ick06SxvuIcrdnbH6JxdP4JXS1cpfWnp5Cd1%2BmH1zcf8PCvPLlNy%2BbfUVUm%2BKnbVh%2FqGFvYz1aLFrw1OCUaHNuqmiwO2cumHnF4PJtp66c3tQgG8V8c7gJbXX5nmjj5SvRdhvKYN0eWPcgfHcEL7jfZdYtKKmqr9pYpThpb3cGYFWnM1hIJ%2BOF1LqF1OyFzIujzXxyvDFyf0SmGxPHqTeoVCWbOpi1aBZGNjB34ypq72Cw8On9qptU8mmV9JxKUqnSS14Lt3nf7bVsssgW%2FbvprDn3JD04W3cy45f%2FLsKbNvW7GAJRAVmqwqdjO1gZ4ZLX4EEs3ndjxGLDN10US7r%2B3RjKopsXdOnJ%2FDZbgUrYhQuOa8FwKeO3NGRXbhWrC61yMDd1OJHZvKkjuqK1xKQ1ahRZ0RqrFmyObnxwy8TJm%2B%2FtX3dbb9Eh5ZKdU5bUZWFFkBtsv3hBsSvNpbWkOsxOq8mY6nXW7Rrate3Ry5oat36zy3n5rcUzVlZTj5GDM4RXqy8mk8m11PYJt426CuYifEmPTJl5Ygg2HwMzF409hk8SpQXYM3su6rDZsaVmOFc1PS18rrQlMMPWQpdH58rpRkXkTMUHdMZwJlJBd3ui9irDuRhSlobPxZJp2RSs%2FIKpPGuJLjZiwVbj5rEY5sToxtZFKvlqzA80Wldmvi%2BnMmB5RmfUqx3WZ3TwtN6AU7fPZlPBc%2B4LtaxvC03LNmHeYHV6LGq9Ue%2Bt6Ji4XGtPc2YHPn%2BPTjHovpDiCmQ70%2BzanqXXLMg3W01OrN0VMmHkFuU65aekDvu8yyQ3a6kuR9F02uun69A4pwdsTmnG9Ir6odGP6foSzPo7%2BI0T9FK9djZk1Gx1SDNm%2B1TWUqVCq6WtFc4BNj0dNUMUVWh9Pm1FkYrWQ7QSDZd00Y%2FoCtiQrasgJ2oE51hLtUpN2yumeW%2B7XL01yjuTWwoC016uaVv8cmA2Wz9hDnaOTg7OvciHvkjFWVoBHkze6PTNjhHNdjaC34j4Q2sml5VranslZnK55r0do4VPVt6J0eJrpr0cq2kLLH45ho9I7qfUoyB0BNtPxkZI1BTWsmygDOdq4KTdnuScWWwnV2OSgn1m%2BpdWn9sTLKcT6bFJSZ3sxLQ614KJNh9Dr3NaLwull%2Ffsn1Xd73N4pla917BpbnHlRYc2rz%2B4vNAWLAuUlZTn%2BLMrl1w2I3%2B6X7LZ7SMjK3tKp5d4Vi4uaynxzFvW8U4g36u%2Fckf7yjqfsi3kz15YMuvieYUZbkdxZqhYNsjBKd2T6jZ1luVEuyuDdTUVqakzCqf0hnN6ps3cNb9IrwuOfLBkdaCmNa97lb%2B6ZXjpxHpZl1qUn%2Bea2pBRWkd70kGsDO%2FF%2FKacr5SP1VdKBc5kTwHzLgTBuhCNoMOrk05uPJlGOogYqV8zUg9nZM7NSK8ZSBSXSGZBKtYumpNFbdnNqTPYoEDnNJjSJLd%2F%2BJSGjQhHC1KLaGLMZsaS0%2B5FfQ%2BbKGJVSAcBO5sbarTjFojJbQY7n6%2B7lHt1Dj5Z8Ra3ltbtaUQwFT1KK%2BYw029qXbR7RjBV9BzZOnNpY3ZX5%2FD1Imb8xKW9dcqq6%2FrofP0q7IN1qEuwDxYkD9KedLI%2BNDu0MaS4qcFgBjCzEws7WZh1E4RZn2LxsJT7FN5f4U0Rt%2BaXN6SSZseu08cnDP4o%2BhG%2BLF13LNXWymz44rlIclBNjqnMow%2Bm0kTHYzwVTPeTsS1DbrekmZz0RQpty2jEUt0XbeMsnDQxQjFmHeVKutMGV6SVSicW5NcCvN1IdWg3LtLBLYE9wY1sT5C1ELgBZgk6c6M77GDelAie6ZjB1sweJPkUdDw6yqJw1xfWdfKev3yfY7d3vqr4Xamfw6g8R8pkns7nsOHD2f512GY0STNyvfTvprlS87g2ze6QtW14MsawOJhNJVlbz8x0o3ozM8sNtMkbaJM30CZvYE3egBHl5JyoXZo5pw4zUfbg42akbJcAYdYEGCN77il8s6qc2DB7bG%2FD1FITNU9tq2suqmktmjHWVbADMH5TqTa5M4AXa8ktAtpz2PdBfYPttPMci7W3TWWlWWIXFse6Ei0Pg%2FmFJqZLrQu605cikpXgSq6H%2BZrBpX6OdzOnLqWwsbh2axMduDxBp9Zd2FBcu22s12kc6R53hk0748bWmu7GUltRR%2Fv07IU7Wv1jVSiHar%2FQ%2F74co1yJ4VFR9Ebdzs7ZaSVT88oaC5zomDOED0Otl5MhVutWXuu06pPujFXBuJpNerExr5ZsAXTJlmmks2Lu1egIxp0c829ouieTjo15KkNRW0FqdquoLjp2jXm2SHLPK1lDvkHu3IxwbmN5aJ0g03%2BqjwvN%2F9XubczQd8z8D%2B7tAmPCiL3Uu9FV2OuwIt01fYbZMb0%2BX8pzSPl2ugMTNklhnRTWSgWKlC9LbCcUhgKzdg1mSzXwhTuldNqaWWKQDOO2YOkMedwW7GP4uiLB%2BwErmbkJ1YntUilhbcPuoJxcCNOVWbLFiyUaGr748bEdVgk7rG10h1UeWwFTw4rF71ftsCqvT9z6%2FS0bH9hQVbv14a3g6kd8detmt65tDPrq181uWdcYkP604dGr26ddemwLuA28p%2FXy5bWVyy6f2XZ5X23l0sup9Q6O3Kq8AOvRfYJBaj26TxCsoi9u6cgAZm6BhtmsFII1OvgODKUuvkXANgu8dKOH7xb82z2CVtvsr9wj%2BN%2B3CJDzP20RfHlUdX31FsHXl%2BY1To1miyEE7S%2FF5XNo82fM7ChaPkC3CCrYFkFzbuOuhrru6jTpnR2PXzHdllUZGqkTOwOqd9Cn8RrXqL%2BkoC7fNePKI9ubLlsx2ZnfUDZyJ45nrtgD6%2FbCunclrXuSe3aY12%2BMUE8coT6ZG4y54whdxxbgCDJriBXJBgpm%2FhzM1rWM4SEq2DrWldNqnBLxq2zFdB2b1lZD17G2mWrMSv%2F9OpZugNFlrMiXRjMei6W12WjWYzGWFx38%2FDJ2rCXa2Q7j2J6%2FRzRN15fXsXo6kfGnaPPbWlpzqUnL%2B29eltfcNL2AnitISbdrv7SWHTkmLCudza8NWcV61p4zKX%2B9MPXIP%2FmClm%2FGYEHLvaj8IGxcwXdij22aIIWtyWYLZq0VzJsvFbRdW2nzdZAoJjuEDoCEdm2ShladE9VH2sJWV6DVRTcD2FAmldCdFbYiZeYbjLCEhtj5lDAZG6XGdV06xf430z3eMDXyg7JGr9N5MrJdqaUTJobGtUY27ORMnVibYQ5mZ5hUiqQsd2fa9Xq9LqV4RvVwXEz3zvvDK6oac62KzmDQW3ywScfoOflZ2KRVsrFWZyppr2%2Bf3b6v%2FUi7emrSBGDWtVkYAwb49FHYg4XhIRmjoU0dkl6L%2BrPLs8tNPtpsfbTZ%2Buhg46MjlY8ONr7H8D1ruMKoAQFiiiLeRJdYYZRXbzpikk3Fv6s2vGefY%2B%2B1b7Ir1fZqu3vyq1N96vw299t80xDWO2dnJzts52zMcUbGvVAoSW7G8D2AnOri38XshvdixG6zB%2Bw4psFKzJ%2F8aoyVqXa%2FLXYTUWyEFUv3A8Y5VpVovfxMTrEmGf7i4QaN%2FGzF0stnlS5sKnUbVBqj1hipX1BT0Fjuy43O6eyI5ubP3T03u2VivkurKApONOizqlpLCqL5rrzo3M550VzJ0hRDe%2FKkpmT7nXg77Qv4HKGqnHBlnj8rUrdg8oS%2B1kKTw2UzWd02e6pN6051O0Ol6bkT8gJZBZPxH7%2BXSHD0b%2FJ61ffJRHIdrc1j%2BcQeKkq6B8aoFTCrTTBzE4xRDUW0oZs85qJzoZYM8zlPSxm6%2BaCW7dOeO0sH%2Fgq%2BzVV%2B9gzbOkTR52JI64l6zOdinhYtzZCIIQdbhKbZzoqBX8XXNV%2FcH5Bd43cR2JqS7irI63W2QH6xp3lFNONSq4O%2B2d8rFjZv0a1vh%2FWt6ume7PQUnVqvVi3OyLJZ9JocvMWRLXyD4EXxqvRFvoUwYuhZpjfo1RYvbHQr3TVUHh%2BbS%2FkxgzLm0vaaS9trLn0rk8vcbC5ttTip8MkJ3vP9yf4AZhYEf8yGOiroGoAmEBHv8wjpk6jeWdSaa1SntmLaqj6%2FdUidgNg5HGvA3OfqkxksNMf4DUOaZ%2FzcVuwXjm0U2tnOVVX1WAR2Ch0ZLk%2BGXTPzdjZp0qbwzRdPSUtp3e4m7Bhi5ejQj01Vd3bOmrz6uuVylpiNDn84e1lDTlenvF3E0JaGt1rKblixUDLTlvYojn1hlKdLD7%2BO%2Fs3xS5lcZEpscQjzsPMJYPGC35n0uY4k433i%2B9FqJKjGfMwu5dqkPLWUlYeIKVlSdpYUpBIneLKDUoDFBqTsgJRrlXYEpSDd7NLbXS3BADwJQm9H9XA8QbpLSUN04wf8ftSEMoJ5rUFjWquRu23UAt0Ei5BID5tzReibs54IfYOWPCdG36RFfMdJULKp2QcZ8UFjZTCHXh%2BB20i6dK3Ex79c6fx7b4%2FTU%2B3kEw1ltyQr8shZlTktLzMzL9WiGnlWpaanAjwZIRwVG1Epn8nYd%2FZ5Mu1a5R6V3mDSfv5d%2BlpNpbMYlIUmh17BWlXGH%2F1wmskk%2F1mPrTNZZ6T1MmH0U%2FWVqJcmqYzXy3Q41ykwAl5U4J1ujVRNOadYCgelcEAK%2B6VwphTOkHLTpTyVlK9IEydJkyZKk4qkyYWSLYADLfiPKLElPmW8aEBEACXYMD6yaMpRvJWcaaXR1qmtLB01e71ttm2jbZ9NZYs63C22itac1ok3FUqF9Foh9fk2p7tldeHOQrkJsZ4ZelodL1Cb95yprz8Lm%2FOaOf9Sk7%2FW5LNiViXRjKmtVpvfRj9KZeKfE2UfNKdQUtiHOPAh4cKqQllGU1Xxj0GNvYDq6okso5%2BUdhav%2FWi3wtbvWNUpuVp2po%2BfAxFDwLhaHCfVV6rUIx8pZk9epr8g1aQ8IctHFHNafqY%2FF6GRT9QqLBw96VkOnfKyjP%2FNh96BPoeTIfJLsvSijJfRaV6cwVTu0aZYz9ezfECvH956vtatKVq9EZWuxfGbNL0elW7GOIKNi2GvCMk6A1pAPnpmO1pACbmft4AymNqOVl9CPVsx9WmTiiW8TX7%2FBGSlV%2FIkvRftpizKLelpTynAZULzTCZSTUiqMkrGALqUkdaz0VhWmt8aMtozWu3JbS%2Fqm%2BhbaLb9zt5AoyLpL%2B1EvqhxfHJUAJsEUV%2FmThGnKM8fojz%2FatrJOg19My0pDTpnrj8z5DKqfvuSyujKwllKu6SXvCMf6SRnbiAjlGJQnX1OZbD7fRk5Dlk%2F8kmhxWlSY1NeK60c%2BSZIUZucFumk9KDFaVYpGoN2ZFCaDVJUxhTryFJYrxtLnZfw3waIkF5uPRss4aZv3cPspEQJ0lTqG%2FWyPseOldjR1BYrRggs49qxu4tN9XIs5M7Cj%2BCREzmpNAn2q1qsapoIy7Z2OjZiO71ceHF6xIq2rmqJKrrxyo66sbMMEpXySxqdRTf8ostHe790YGSfzUnPYMkqI97Q07iR7dIhHHvTNDt9dm16MMvidqfa5HXBHBx202osbnvA4vWk2YZv19p8WGmcGv1IOqDcxlbB5fQZBwmOCO0%2BacgMYZ1vxeuBs%2FVn6WBPB%2FkTNC6KSC%2B6Sf3ZpIMTO8Z0tB5bTyUP%2BEgH9Kl5%2FkAeWqU3L%2BDPS9V%2FMawEAoU%2Bo9FXGMgqolw0nBfkEfgCEPxZWhFsfAfucgN5A2fQ8%2Fk94rTO6RNoeRq9Aj%2BBG4z8APd3VB9FkN4cM%2Bi4fb8NJXWTiynWTy8pbgKod5RG3lIM6iexp%2BdhpdrUpKQExQxCeEtKUIaHu25xjlf7kMqckuFKDTpUGrlHZXZmuvB%2BTaX%2BwGzVqbRmp1mz22zVoxummGn5TdIxuVieglPyAVr%2BMaI1nsOBA8ydzuJTjqmM52L0nACfXeI9E9%2BlZNVc7LCPLHXgR%2Fo2KlMtfZKb6Q%2BHMzX2NNRZC%2BYsT7PzXhHJSEuOpn5h2yJHbFtgOnc6mmOVZ%2FYWSeM2JOjuXgqd36TQY00pXqpOyUV4Gxrg08AAujw9QQxmq0gwm%2BSA36Z2x0iL737gPLuBHiWLEoUu9KN65CgxzDbIhI2%2FCGHDEP%2BdKnoTBioMBF%2BJxgspA46R0XNR4hjZ%2BfMqcBC2N6mPwAsPPsGkU3eMwPwHex80OztdRgv4T6fLMNccO2usUp4uWR%2B%2FbNeDqyKlsfj%2B3eC4xReZPLO0c90Ud%2BbUlS01nVPQUuWB2%2F412Lfwux%2Fde%2BtHjB%2Fuu3NHZ3XqnBsej9388%2F0TsxuWbrmK1u8jeOF0j9pDiiUTq4Xs7EwpO0PKTpdCPik7TcpOlcJeKeyR8tmmkoP6y1JqCzOtkFKJUOOTfDofwRUwMzlj1A%2BYmRzMppP59GCaJdNLM3mN9K8RU6Q3aC2Bnz%2BKMsGnaVHj4k%2FTqQ7CqBzkuBffL3A6hqT6o6G5%2BdjT0%2FLzn%2BX1w%2BhC3OqRs%2FQFIHsRGPkxsz3hk59kBRx1RkO0hOMxFKGhZYiDonBkyY6HVxdBugfPHBi8joa%2FRarOSW6l4jgzvrtwj8Zg1g4v0ZqMGg3O6EqWT%2BnrPkVj1EsFKpPD68BMVPOuzqJXN9IFkNaWhmO6dr3y29sMKnOmx%2B61mTRPKSq80sXb1s9u1KNvSPivmhCcAgji%2Fd%2FPWJ2Y86ukSKaUn0FnNlFqfA81flRy06MpbubF3dSYbjTnExU5%2BEdqkzVS%2Bxj%2BO3FGmBQmNNKJjRG2NtpragOBWrTC4hMVbk3xPBt2p%2FKEHTHsYTVKzz7TaeSbkbN0ZcoaMm3FhB3E8p3kRRTTMvD6h5eiocWcNyVKwPqTFnR%2BbOAWpRPHLxwY0vDXr%2FRbI9R7wAB6q354gsVl1SoGq%2BmzhWtrHekT5lSy40IY%2BFQ4b%2B%2Bd1H3RpKUHeord06%2FeeFauwPcK1G0OHJ7X2jLdKZkej1kyLPn6xcsjkZkTs7LysnSOTBeWmhZXdsg7YcmuprrdNx7Z8qLewU5Mr4RHuh3nCuvI59zqudVSbhXbQFWY1U9wo1cnLQvGNz4wjFbTs9J5qJU81EZeFPbNs8wu31i%2Br1wp%2F%2FdHMB%2FDSVSCHoMCqV%2BhpwmwHQN1ku4jOp1enEQqjJoKJ34YyMJJL3VhB07bG5OOBm9Be7BLQF8oSLYXaQXh50zP888zyauI1pHvGAoqZCXZY1kTP6RnvIwKK01NixtzOyiQbRCgxPOjodgTYF9w4FXFhiJxpt9FDxUk394ptzfvH4xNjs2vsuJ7OtgW1BoKpq9tadjUUZzbsWfBlK5wutefIU%2FRWQ3qFMdIRqi1dOOhjbXSvWu%2BvXGiPdVrMdnTHHacY8dxrkDj6ra6ZfV%2BU1qObA0G9KjN7LyR29TyhL4Bumc7F7V0CH2jlEwjb7F6cuYXSwVqKZ%2FN9Qtwpt8gNdI%2BEqDV1SiV6eCHyvj5111lUm1Za9naMiVSJpUNyYU4zWWxBPCfEqLeHm6I1ccbx2h9TKIuC1nB7%2FNDitsnSVWTmietmqRkY0UxJEeilpIc7Jn9PRDQVn1YMA9G1Q1q%2BZli%2BjqcHk2Cw6HvwiPsEB4C5ewkBzoR60XYjLQGon%2BPoYCCqg9jBfO0tAxsOCRPG9MX3rxK6H7DBds21ePO3omTtmPDRJVyKKW0Y%2Fd3N0U6pham6OGOdMa8KXMr%2Bq7vKpQn3Nobu6U7t3zd%2FVs69i6J5tqPZE3rrZ%2B6ZFJ6as2iae03yI%2FNf%2Fie69dMMtocDn%2BaO82itjqs7ZceWuIvnbTqhnkLvrmjOX%2Fm%2BoH7mvcfiZWWzF4xYdLyxhw67OJHwjdT8G1z%2FGgIvjU4lf40RBr6YmuXb1n7fwCxnfOHCmVuZHN0cmVhbQplbmRvYmoKMTcgMCBvYmoKPDwgL1RpdGxlIChNaWNyb3NvZnQgV29yZCAtIGR0TGljZW5jZS5kb2N4KSAvUHJvZHVjZXIgKG1hY09TIFZlcnNpb24gMTEuNi42IFwoQnVpbGQgMjBHNjI0XCkgUXVhcnR6IFBERkNvbnRleHQpCi9DcmVhdG9yIChXb3JkKSAvQ3JlYXRpb25EYXRlIChEOjIwMjIwOTEyMTI1MDE3WjAwJzAwJykgL01vZERhdGUgKEQ6MjAyMjA5MTIxMjUwMTdaMDAnMDAnKQo%2BPgplbmRvYmoKeHJlZgowIDE4CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDgyMiAwMDAwMCBuIAowMDAwMTg2ODg0IDAwMDAwIG4gCjAwMDAwMDAwMjIgMDAwMDAgbiAKMDAwMDAwMDkyNiAwMDAwMCBuIAowMDAwMTg2ODQ4IDAwMDAwIG4gCjAwMDAwMDAwMDAgMDAwMDAgbiAKMDAwMDE4NzAzMSAwMDAwMCBuIAowMDAwMDAxMTEzIDAwMDAwIG4gCjAwMDAxODQwMjkgMDAwMDAgbiAKMDAwMDE4NDA4MiAwMDAwMCBuIAowMDAwMTc3NTgzIDAwMDAwIG4gCjAwMDAxODQxMzUgMDAwMDAgbiAKMDAwMDE4Njk2NyAwMDAwMCBuIAowMDAwMTg3OTIzIDAwMDAwIG4gCjAwMDAxODczNjUgMDAwMDAgbiAKMDAwMDE4ODE1OSAwMDAwMCBuIAowMDAwMjA0MzMwIDAwMDAwIG4gCnRyYWlsZXIKPDwgL1NpemUgMTggL1Jvb3QgMTMgMCBSIC9JbmZvIDE3IDAgUiAvSUQgWyA8ODQzYTlhMWJkNTcyMDczZjg5MDYwZTJmOGZiNDgxNmY%2BCjw4NDNhOWExYmQ1NzIwNzNmODkwNjBlMmY4ZmI0ODE2Zj4gXSA%2BPgpzdGFydHhyZWYKMjA0NTUyCiUlRU9GCg%3D%3D\"\r\n }\r\n\t\t \r\n ],\r\n \"additionalInformation\": {\r\n \"remarks\": \"\"\r\n },\r\n \"agreementDetails\": {\r\n \"signerIP\": \"103.156.134.109\",\r\n \"signerPlace\": \"bangalore\",\r\n \"signerName\": \"nandhakumar\",\r\n \"acceptAgreement\": \"1\"\r\n }\r\n \r\n}\r\n}\r\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/GenerateOrderSSL", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "GenerateOrderSSL" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "218" + }, + { + "key": "Date", + "value": "Tue, 02 Apr 2024 10:53:06 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"meta\": {\n \"ver\": \"1.0\",\n \"ts\": \"2024-04-02T16:23:04+05:30\",\n \"txn\": \"226556589893593280\",\n \"status\": \"0\",\n \"errorCode\": \"EMS-1140\",\n \"errorMessage\": \"Insufficient Credits. Please contact your Account Administrator / Finance Manager.\"\n }\n}" + } + ] + }, + { + "name": "SSL/TLS - OV Wildcard - UCC", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "if (status === \"1\") {", + " pm.test(\"Request Successful\", () => {", + " pm.expect(status).to.equal(\"1\");", + " if (res.orderDetails && res.orderDetails.orderNumber) {", + " pm.collectionVariables.set(\"orderNumber\", res.orderDetails.orderNumber);", + " }", + " if (res.orderDetails && res.orderDetails.requestNumber) {", + " pm.collectionVariables.set(\"requestNumber\", res.orderDetails.requestNumber);", + " }", + " });", + "} else {", + " pm.test(\"Request FAILED: \" + (errMsg || \"Unknown error\"), () => {", + " pm.expect(status).to.equal(\"1\");", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"productCode\": \"{{SSL_OV_Wildcard_UCC}}\",\r\n \"accountingModel\": \"1\",\r\n \"saveAndHold\": \"0\",\r\n \"emailNotifications\": \"0\",\r\n \"requestorInformation\": {\r\n \"requestorName\": \"{{requestorName}}\",\r\n \"requestorIsdCode\": \"1\",\r\n \"requestorMobileNumber\": \"{{requestorMobileNumber}}\",\r\n \"requestorEmail\": \"{{requestorEmail}}\",\r\n \"requestorDesignation\": \"{{requestorDesignation}}\"\r\n },\r\n \"delegationInformation\": {\r\n \"contactName\": \"\",\r\n \"email\": \"\"\r\n },\r\n \"organizationDetails\": {\r\n \"preVetting\": \"\",\r\n \"organizationNumber\": \"\"\r\n },\r\n \"certificateInformation\": {\r\n \"organizationName\": \"eMudhra Inc.\",\r\n \"organizationUnit\": \"Technology\",\r\n \"streetAddress1\": \"1712 South East Bay Blvd\",\r\n \"streetAddress2\": \" Suite 360\",\r\n \"locality\": \"Provo\",\r\n \"state\": \"Utah\",\r\n \"countryCode\": \"US\",\r\n \"postalCode\": \"84606\",\r\n \"domainName\": \"{{wildcardDomainName}}\",\r\n \"additionalDomains\": [\r\n \"emudhra.com\",\r\n \"emconnect.com\"\r\n ]\r\n },\r\n \"technicalPointOfContact\": {\r\n \"pocFirstName\": \"\",\r\n \"pocLastName\": \"\",\r\n \"pocEmail\": \"\",\r\n \"pocIsdCode\": \"\",\r\n \"pocMobileNumber\": \"\",\r\n \"pocDesignation\": \"\"\r\n },\r\n \"additionalInformation\": {\r\n \"remarks\": \"API Testing\"\r\n },\r\n \"autoSecureWWW\": \"1\",\r\n \"agreementDetails\": {\r\n \"signerIP\": \"{{signerIP}}\",\r\n \"signerPlace\": \"{{signerPlace}}\",\r\n \"signerName\": \"{{requestorName}}\",\r\n \"acceptAgreement\": \"1\"\r\n },\r\n \"csr\": \"\",\r\n \"numberOfDocuments\": \"\",\r\n \"documentsAttached\": [\r\n {\r\n \"id\": \"1\",\r\n \"fileName\": \"DTC.pdf\",\r\n \"description\": \"DTC Licence\",\r\n \"base64Value\": \"JVBERi0xLjMKJcTl8uXrp%2FOg0MTGCjMgMCBvYmoKPDwgL0ZpbHRlciAvRmxhdGVEZWNvZGUgL0xlbmd0aCA3MjggPj4Kc3RyZWFtCngBpVZLcxMxDL77V4gCqQOt4%2Fd6edMHpYUDndkZDiwHJpNSmKTQFv4%2FsrNSNgnTadrpwapWkvX49DmXcAqXMNq%2FNjC%2BBl3%2Brseo0sr6%2Bf9ZSMrVOkKonapi7WE8g70GwtweDxsrpbV24KsomhmMmsaCgeYMvoB8sDXEcB7kwyHsFuERCfTlcVEYkINtklq5PRRonpU3mQ3J4QkF5RBP873oP3cXcqfLY7BbTANIVQQLkjXk0y5u5XhsvTMUpY5RcXcgdY6MYdiUMqYPpruaPWznsdWFotw4gus8yNMP4Ss0J3DYlJHdOB%2BxOh9jvVFVMnk%2BsDSfnHLzcx5VK23qqKuEk1cuJeuCw0KrEFO0Ce6FCxPWcBFoYINBJJGBUHXlJ5oQCzZ3DHtNHaSzphitfEbicxJorGz0Isd3QvJAXhYFSLZoO9yA5KtfUTIMlwWUXtMk39CdNEG6m07CBtfKQu%2BiDH0skjeFbQac8Sq2WjkvigDPiQv5lnLixFeT4az5Ikpzj4qeK4QctIt12S8fcZPYrdc%2F1vF4D%2Fog1iLzxx1QZaroVUIkexMIzaKwTR%2FNoyMkte9Iav9BNSp7qK6s8hXSW6yNqm3txQy8QzFTHulguqyzGmlwCn3XTnUOZ0ykApOwOYk7E6oLfrlEJNTD0nSkHR4aM%2BQajTCfMHbf4QzELYlkjeh9SMomH2AtL3m0IJJNq%2FWVU6G2Jkddo4n3eTFxFY6786Q7P6zoP67o%2BfloF%2BDkbjBub70QgumAg3D7eUtpJZl3aIlwLszeYtP2GG1rZZzvN728rqKP902j8ptta4cQE4s3%2B15Rralg1%2BiqXh5kP2b%2ByYETLz8pYsz7GSLMIOAbtaybsk4451TKdrhznWtPdQ6f4SJ%2FMHlnnUN6yPvpY1I%2BgbNBxQRXk2wlRp8mV%2BPJ7z9%2Fv03h6gemglZlYZ2NiLO16PiWjo5nBg5%2B4cN7%2Bg8KcLz%2FCmVuZHN0cmVhbQplbmRvYmoKMSAwIG9iago8PCAvVHlwZSAvUGFnZSAvUGFyZW50IDIgMCBSIC9SZXNvdXJjZXMgNCAwIFIgL0NvbnRlbnRzIDMgMCBSIC9NZWRpYUJveCBbMCAwIDYxMiA3OTJdCj4%2BCmVuZG9iago0IDAgb2JqCjw8IC9Qcm9jU2V0IFsgL1BERiAvVGV4dCAvSW1hZ2VCIC9JbWFnZUMgL0ltYWdlSSBdIC9Db2xvclNwYWNlIDw8IC9DczEgNSAwIFIKPj4gL0V4dEdTdGF0ZSA8PCAvR3MxIDkgMCBSIC9HczIgMTAgMCBSID4%2BIC9Gb250IDw8IC9UVDIgNyAwIFIgPj4gL1hPYmplY3QKPDwgL0ltMSA4IDAgUiA%2BPiA%2BPgplbmRvYmoKOCAwIG9iago8PCAvVHlwZSAvWE9iamVjdCAvU3VidHlwZSAvSW1hZ2UgL1dpZHRoIDE0MzIgL0hlaWdodCA5OTggL0ludGVycG9sYXRlIHRydWUKL0NvbG9yU3BhY2UgNSAwIFIgL1NNYXNrIDExIDAgUiAvQml0c1BlckNvbXBvbmVudCA4IC9MZW5ndGggMTc2MjcwIC9GaWx0ZXIKL0ZsYXRlRGVjb2RlID4%2BCnN0cmVhbQp4AeydB3gV1bqGufcc7ymeI3ZUFDuKgFIVRFApCoiKDaWEGnoX6b333nvvTXrvECCQACGB0EISQgIhkN4TuO%2FOwjn77EBIIAIJX555NrNnVvnXu9Zs5v%2FmX2vO%2B108r00EREAEREAEREAEREAEREAEREAEHjIC0TExN7L%2B3%2FXrN4JDQn18A%2BR6i4AIiIAIiIAIiIAIiIAIiIAIiIAIZEsCPn4Xr4WGX0cCyBZ%2FiYlJAYGXJWVky7GqRomACIiACIiACIiACIiACIiACDzKBFAwfPwCQq6FZRsRwygx8fEJkjIe5YGttouACIiACIiACIiACIiACIiACGQzAoQrsAVcvBwZFZ0tojAcG5GUlIQ%2B4%2BcfmNJSrfAgAiIgAiIgAiIgAiIgAiIgAiIgAiKQJQn4%2Bgf6B1y6FHw1IjI6KSnZ0f%2FPXt%2Fj4uNDQyMuBgX7XQjKZkrUfWsO83S0iYAIiIAIiIAIiIAIiIAIiIAIiMADI0CkQlJyNptIkrb6kpycTJsfGPAsrgOkzVZnRUAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAERCC7EuCNxNpEQAREQAREQAREQAREQAREQARE4KEmkF19crUrHQRYPSYxKYlFYqNj46KiYyKiorWJgAiIgAiIgAiIgAiIgAiIgAiIwMNMgNfRRsXExMbFJSQkJiVn87e6pMOzf1SSsABuXHxCZHRMeGRUWGQUn9pEQAREQAREQAREQAREQAREQAREIAsRCIuIRG%2BJjolFz3hUnPlHsp3EYBCAQV9nocEpU0VABERABERABERABERABERABEQgDQJRMbFMN3gkvfxs3ujk5OtIVWl0vU6JgAiIgAiIgAiIgAiIgAiIgAiIQBYlwLyDbO7VP2LNS0pKYhpRFh2NMlsEREAEREAEREAEREAEREAEREAE7kggNjaOaQiPmLufPZuLiKG5JHcc8EogAiIgAiIgAiIgAiIgAiIgAiKQ1QlIysgGugbTScySnll9NMp%2BERABERABERABERABERABERABEbgjAZaFzAa%2B%2FCPbBCJqtCbGHQe5EoiACIiACIiACIiACIiACIiACKRBgIfj9z%2FIPyIqKjLqLl%2BvmZCY%2BMjqAFm94bxRN42hqFMiIAIiIAIiIAIiIAIiIAIiIAIikDYB3gYSEBgUHHKVnbRTZuLZqOioE36hm9yuREbfjZSB8JK5C2Vcvnx5%2Bsw54ydOOXX6zJ8kFGDwITf3fS4HXPYfTM9mmeF%2FIeC454n4%2BxKFEhcX53XCe%2BGiJSNGjxsweNjUabN27dl39eo1y5jk5OT9B12trxndAUIWmlGSkJQcl6K6hEVExcTFJz5i02HQNmPjE2i4%2FYVP90XHxrGl7kcOkvh2iijpOXs%2Ff2RuZ4l9cx6GfcgwzBx48hWe4MoqrcBO%2B%2B0%2Bg4UVDA2r2xHjuBmi99m29Fd3nwEyurgkzcCjagDCJ%2F3WKqUIiIAIiIAIiIAIPEAC3MZ4eHp17tJt%2BIhRgUGX75uXER8XM3bdxbK9T4WGR0VEZljKCIuMikvItNeX4JsPHzmmWo3av9Sq265D5%2Bjo6Iy65%2BlJz6tjGzVv9WN1p2o161Srcevt55p1rM0q0%2Buk97oNmw4ddkNksA7%2BGTuenid69RtY3alejdr1a9V1dqrXsGadBtjTrGXbFb%2BvZmVOKp2%2FcHH3Xv3uuvb4hAT67i4GvBmZoeER3G9HRcfcRQkZykIt18LCx46fOG%2FBIiqNjY%2FftmNX%2FwGDzvqcv2%2FXSIYMzvTEEAi5Frry99U7d%2B0Ji4ikfPwdDvr6X0COO3jo8Hk%2Ff77ae9%2Fbd%2ByaO29B4KXLHCc9PpHlFpEMdDNmzT7gesg%2BS6abbQrEALrv8pUQY%2FmfVEumFAuNk6dOz5k3n09DhgGG8d6nzxw4eMj96LGgy8EcMUjvsUbKSUNousfCGS2Xgq9cuXrNbFw1NOc%2B9DVmA8flwMElS5djAPViQOp%2BJw3HFy5esnrtOs5mCs97JOaQ3VhuAWQMYCddxuaQMo2vAE9nF5PysPuR6TNnnTpzlip4lrFo8dL9B1wfQjJptFenREAEREAEREAEHk0CeBlHjnm0bvOrU%2B26NWvVxk27GHQpQ3dNd8eN6STB1yIr9Dn1YiOPrUdC4uLu5uWb3IZlVkhGQkJC15598Nlr12%2FUqFmroKCgu%2FbT08iIjtHq1%2FaIA3Wdm9Rr2PSWW50Gja3NKgrf38396PqNmw%2B7HfnzojL27nNp2LRF9dr1h48au3ff%2FoCAi4RhHPM4PnfBoiYt2qBmEK%2BycNFSJI4eve9ex7i7lTG4tT54yK1Hn%2F70zsixE876%2BP7Zo5TRhRf8UYlS1WvUuhoalnT9%2BpChI3K%2F%2FKrLAVfchLsb9lkrFz8OCGgNGzVZsGixUST8Ay5OmjyVn4smTZuztWrddvzESRcuBnKWDoJSt%2B49a9eph8oBIrywo8cIIzpp3CKOrF6z7pfqNUeOGgPbPxsFw2PK1Olt2rbbtXvvnz1U7rEtRLwsWrKUX%2BBD7u6QxFrXQ279Bw5q0bJ14ybNmjZr8Vv7jqhJ4RE2b%2F1e6gL7mXM%2B9A4D%2Bx6LSm0GZo8aM7bdbx1%2BbdfebF279Zg5ew4%2BMo1KnT4Tj9AWBtuAgYPbd%2BzEIFyybHmXLt1Pnz3n0O98PXvufN16DeB5ITDwPgzCDLURSkc9jgOwfYdOBiB29urdd9mKlT6%2BfulkCApkRjRGS0tMwwaAjJswkUty%2Bcrf4xOTkNG4qOmyh41MGk3QKREQAREQAREQgUeTALcr3GS2bNWGjbvlbj16Nm%2FRirtBHmll%2Bl2uA%2BHY2Og1B4NfbOzxZL1jbWf6MrXkLkIyKDPxHlbJCA0N27Z958ZNWzZt3rp23YbW7TqiMKBjNGjcfPHS5Zu2bOUU24WAAEtPuMcdo2MQ9TFj1pxr10JvubXr0IVACCNl2FeH0sKTWaSMQ4fd%2F4xlTj29Tjg3acG2Y%2BduolPsq2YfTWP46HHEaSD1sPXs098hQTq%2FUnJ4xn0x7rdd3Y%2BgpUydMXvL9p1EjHTt0YeH1Pdyv03e2w1yTpkNd6%2FMp2VxzHGOmFEyYuToN956Z%2F%2FBQ7jkJoHDqDZfKRaDb1e4Qxb7cm6ZxSRwOOXwlTJTH0mjIodT5L2lwTSTwVC%2FQUNELdwc%2F4sXe%2FbqU6%2B%2B88BBQ9at38hD7YGDh3C2d59%2Bp8%2FYfEbK4fHuhk2bzQw1PKm2v%2F7G7wnlGAsvBASuWbfeUjYczEj9lQJpfhpNMwlSZ8QYHHZcQpxWei0y5tayiT18h0LSrjrts2nYnPoURbGNGj22Q8fOyEQx8fH79h9o1rwlCsbkqdM2bd2KxNGpc1c4T5s%2B8xoP7NN9%2BRgj7dvFrIGZs%2BbUa%2BCMvOTgF5vEaRSeujSHtsAcGRyzp04n6GYu1vbrP7CBcyOMR1IgsWWJQ0brODupa7E%2Fyz557YsyZ8lFLAGyD6oagw1PHIBEs2CSfXaScSHza7%2FXZX9YpC2%2ByNpuWaxVeBoGkyYNm%2B94igSWDfQIYjXXV4%2BevQEIxTFjx3fs1IUjjA1%2B%2FB26jIypzUYTW7FyVS2nOkgZ5rqzyjfG2NfIPtoFF7LfhQAKhxjKJHFB9oRNLquQtHfSAJV2OWmfTbtSnRUBERABERABEXgECXCf43Hcc%2BjwEW7uR7hfmj1nLjMXRowc5R8Q4HAHmLlwWN4Tz6n%2B%2BHNPNziWq5FHvrZeJ%2FzCmLic0VqYnhATe5fzLAjkmDRl%2Bk%2FVa%2BObp2z1UTCsQAhCDsxxNIduvfoSqJtOPz3tZEbH%2BKm604JFS26Xsn2nbrfUMUhvkzLcj67bsJnb3fiEzHxjC6%2By7dlnQA2n%2BswguJ1hy1b8bnSee9ExWJ01o71MegYqi3UMHDqC%2B2R8Mf%2BAwBZtftu2czc37RktjRtmSiNa28fXH72Ou3f7oU75fCUkibPs4%2FWk1jFcD7vj1OOS%2B%2FoHUJp9dr5SIGHh5877EmPvULiDqWSkCjwINlMvTqp9MDzZycLcDTaOm68c4dEzKanLKpB9jrBZR6wdY1JKRRdNqJVVDmnMWeQanlNDAzL2xZJr9px5RF%2FwOJhTs%2BbMRRYgCh0DKISNGuctWIjThMNlSiO7teGV81h50OChlGPaxQ5n%2BTTmGZsdPi3L4QN%2F4Fy6zBI6%2F6VmmJaSAMGEaBCy2PcCX7Ft4%2BYtPFzGiSYkw4TNWyWzY5QNnF%2BYYBLZ%2BTQJzFe6j6oxgFOWwbaMKZagoXEWDvb1moy0lIaT3T6jOYXZnHIokwIxg0ACpvVhNiUTxoAasH3nLgOZT56wIwehpxFbwlfMoCj7oWKOGPuNkdRu4JCMfdM6WyfOnou2cOSoh9Uo%2B%2FYabdA6ZVVECTQW1KY0vrLDV9NMUzgHMZLRwnBi31ZIRBQ%2BNQrMxElTMNseggMfYzMJ0L5AZL7aBkbEf4a0qZSzpsvsjeQUk5UaNW6K%2BBwdF8dARdNIrWNQLFWQ0RjMVzby8pUy0x4JNMohI%2BaRkezBV65aeU2ZfJKYU1xTdK6pxTplbKB3%2FPwD%2BK0wBnAWRPyw16lbn2uKXxiSsVEvcz2IieresxdFccSUQy4znBxoUMiy5Su5TpmRZCEyxsAcembQWuWYRpkEDjqGOchwJRd1WXZaDbF2KI2zZkgYDZO81llO8RUUqSlZDDlr1UJGisJOPq1yqILEHLSK1Y4IiIAIiIAIiMCjSYDbg9Bw240oLiG3N9xNte%2FQkSdo3HIEXgrmILcckdG2%2B6hM4WNbry4WMSCaHTZWxjhyJjRva8%2FnGx0jJOOZBseGrAhITuSc7SzJzIbcccfa8Ueu37h%2BO9c7jeMEcvQbOAS9wtIubrmD547LTOBEGkWl%2F5SlY7DExO1ypaFjkAWzjx7zWL9xCw%2B%2BM3GCyXFPr1%2Bc6qEV3HKeDstiEKACCqNj3Mu8ktiMz8hgrNLBxGDMnDMfAvGJiQSLdOjcbcPmrRy%2F4wixT8B4ZqiPHjOudJnP3%2F%2BgSJGiH9Z3bsTMB4Y9yfhk2PPos1jxEgXfL1zl629%2FX7Xm87IV7OMx8r7z3uKly3AzSVCocLEaNWsfO%2B6Fe0h27OTGG2%2F040%2FKcLZEyU9%2B%2Fa0DroFxPO3NYJ8sZKxdp36hwkVJXMupLpE%2F1MWcC4Qak4AVUb7%2B5jvsZKtY6SsiGdBtyNitR6%2FPPi9vPVLnID5vyY9LozA41EV7MYkw%2B0qVv%2F6gUNHCRYr%2F8NPPhFiYZCk%2FAhFDh40o8fEnBQoWYgZN334DroRc4zgGkBcXhhitXr36QgYthXD3Tp264kwZXKRhBwdkxKjRRGik%2FGLEEDzAQ3ncYWbZE4zBY3E8SuIiCC2g2ONeJ5h4snnrNlrBg2DW5OnQqTMKKhv7nOrTtz%2Flk5La12%2FYhEff5td2RNdPmjKV8qmODaSoEzQKjcU2i%2BG39pjN5WDMxio2Wk0wAMbv3rsPH5AnzvZkKISVbBFYkFkogUpZ1oMaTatxl1hmgSqwn0988PMpopZpL5LCmHHjycVZDAamkUQMLuLzu3TtjnLCKGICDq5ZRMqvKM7d0mUraKPtVKfOxCuYZpoyCVBxbtiYcZV0%2FcbO3Xtw%2FPHE7Q2mi5lxQHUUwnHSU9SWbdtNGpqDF9%2BlW3dW3wUCG%2BuQjB034bcOHaEHww0bNwMEgDSZ0DvkHZBiIWobJXAKz7dTF1t7OU5vkp0yaRFmo1Ax9WDN2vXUCC6UihPep3jW37N3H9oCH0zCuyQxWYyOwZAwMLGEfXIRxkMtXHq0kXKoiNqxEH3M9Bp5ceqpCAMolr5zPezWp19%2FhrSVgDb27t2PvPQ4g9Z%2B2gh88P3paGyjRbfTMWz2XL4yeMiwKVOmUyPwScyYHDJsOB3KxiQOqHKcjSZg8%2FIVv5uRgMHTZswECIWwER0BWC5JetnkhS39YoolgefJk8NHjjLjhOajD5tiOUUhrCHDMDAAYWLceYyxdAzzI2DMoEyiaJAmVq1eS0s5yBEWA%2BndN4VGu%2FaDhw7jaiI7BvMDYrqYqrms3I4c5VrjP%2Fqp02ZQI%2FRoDvEql4NtE4soZ%2Bv2HV2792CBLEq21zGwkyFKvZadXBQoNqZnTVvMJ0e4heCnEiCUz68E04usCBzK4ZcK5pxp2%2B43Yrf27HMxVdOhZNm2Y%2BeMmbOxlnHSq09fDmIwQ4WB171HL%2B9TtqAamgZwRg6XPG1MbYO9PdoXAREQAREQARHIxgS4teBh4qAhQ5lFwjMsbtVSdIxOM3jSGho2eOjwxk2acy80d%2F4C7q%2FunUNYRJTrqWtzdlyavvVSvyX%2Bneb4EYlRse%2Bp5xt6IGKwsVOwnVfdsedaTTvfa6H%2F%2BPWBM7dd2uB2Jegqdyx3kDK4pUk9CeJ2EoH9cQQBXsZB0MUt5QvrIJ57q187PCgdAyMd%2FpAUYmJjWSVjw6YtzHPPrAkmc%2BcvQp3Yf%2BCgPSKzj7Kxeu16VqVo3LwVMzvYmHsCutQp03ME4zM0ohir5%2F0uzJq3gIVGUXgmTJ42duLk8ZOmNm7emvVMfl%2B7PiQ0NP23tdwhDxg05Kmnn%2F%2F2ux%2BGDh%2FZuWv31994%2B8uKX%2BFZUAh3yL%2B26%2FDc8y9%2B%2Fe13%2FQYMQqPAu3%2Fz7Xfq1W%2BIR8%2B8kpGjx76S53UOfvFl5b79Bzo3bPJS7jyVv%2FrG57wfziymDhk6%2FIUXX%2BZIvwED69ZvcPOsrx%2B34vat5quPry%2FaAgkaNGzMSgjoFagiL770ChoFj2Kxc%2FPW7WgmKBj4xWxILu%2B8m3%2Ft%2Bg0s08FChRiJMoDrgdkkxv9i4Y5de%2FYaN8eqi1O4qM88m%2BvjUmWQGvr0G0CBH370cYq7Z5saM2TYCAz%2B4cdqeKDVazhRLM4C2SmWs%2Fi5CBGz587Dq2Kw8aR4wsTJHLfKNynxYnBXocdXrMI7w39nWgdOByIGfhOaA%2F4%2BrcaratSo6cpVq3lojm%2FFxH9OseH%2Bk5E4ASQFHkDjtqB12J5B9%2Bi1YOFidCdO4RyhsdAiHE%2B8fn67OIsPOGXadH7E8IP4TTO2URGuE946vi2%2FYLQIPsbXxkLO4vThltK0yVOmYSS%2BMwUyU4bCaTg1oiQQ28%2BqIKhSnMIqswAy%2BgygGjZugsc9f%2BEi%2FERq2blnD2Wy0S7sxMVmZg0lUwjDAANozrz5C5kdMG78RE7xWadOfSaSGI%2BMjHRr48bN8NToPjrXyakuvh4TTOw5s88o5Yea9GCsV88ZrxaDOU75qB9UbUI4SIaRpvk42mgCrLyxZet2CFA7mg9dADTgE%2FwAMfxT8qJL8IoiJBpg0l56kGLpC%2FYpCueUYYCkQ6fg2OJy0il0HJ1LXnxhEmOYg44BTEoAL448HHCKsYS5SKa7KQpWXFnYgM3oABDD4SX8gLGKAkMC%2BHOWkhl%2BTHmgXhQAlj1hFiQdRI9TBRuTRPDlWRPDdFMaOgZiDvZQF2Wy4W7TNBo4YdJkRgJNw4ZVa9bSHOolmITWQQAyw4aP5BRVmw5FMsI8TEJMgAPzWRo0aIRJICUvhnHN8n8oIg8OPgIUKgTKA%2F1Fp6OHUBQ2UCyiH%2Fvs0ArOptYxbP0bF4fOQHUoPySjfIaKkQfpFEMDyQ6FDZu3btsBPQbtxMlTuPZRiuh3%2BoXhN3rsOFZZQehgH%2BnAdBkSH4nBa69jQAY7bY1yboQAsm7DxmEjRhJ2RTAPpxyGJZVCgJ5ltDOoaB0yL6OO3wSq4DfEJj21a89tBhvXHdUhcvIrh7ZDSk6huACfS4aLmnge5KDYhATmslE7WhCW8AtDgS1btkF%2BTG2Agz36KgIiIAIiIAIikI0JcCfAvSJ3y9y8LVi4iEeZ3EZyOzd9xkzuGTZu2rxw0WLuhXBbeO5277cNxIYu2XP5jZae%2F65zlNALtmedjz3X0BaJYW3MLuEgG2efqn%2FsiTpHWTQj8EokbscdO8K8RCM9TrR9GvSBgUOGm9Ue7GeUWAqG2TE6Rnh4hH3eu97PaDzG1u07U2%2B8NYOD%2BLlIGcc8PO%2FaGPuMQ0eMAkLgbVY3jbH9xdr%2F3XUoSEaHUzwz%2BufM%2F%2FGXWnQE0214mwzS0y%2B16mFtjdq2lTqOeBznbvmOg4QEuAB84rmjFeDOoEvw%2BHvQ4GFIE%2Fv2H%2BT9qkycf%2B21t2rWqsMduPEQETpw7es3aGR0jFFjxj351LNVv%2FvR%2F8JFEnADP3jIcFQCRA8iQ3bv2ffqa2%2F%2B8OPPPMfEKaCl%2FQcMfvOtd5YuX%2BkgL%2BCYjJsw6dnnXkBYIBlng4KvEJKR64XcPXr1wRLu2yt8Uem9%2FO8zj4CzbKyZwNdy5b%2FAU2MrWuwjIit41h%2BZ8pYBJr98W%2FV7PCzTRosGFs6eNx8%2FAteAxvK3aMkyhAuc5eTrN3bvdcnz6ht16zvj6XCWXHjoNGHPvv3USAN5No3TwWICOB141rgqOO%2FWk2KrFiq16mVtBBwTXCqygxF5AW2HLFhCS3HA8Rl%2FX72GfbLwyWa6D%2B8e1wx%2FjYzEfuAD8jiebiIvafBe8QT37HXBEtqCg8OTXDPbhfQ4QTiDvMiYlFjFkTlz55PG%2Fcgx0qMh4C6xmjFnjalGncDr5wgbs07wbQcOHMKvH8mwEL8Ph9SUhjOIa09HgGj%2BgkVURHACVWAYPhoZeYiMt05injXj2LJPD9JeNAS8RSBwBA8XZ5YdRh214MhjIV%2Bxh%2BajaeC%2BBV0KxnjcUgxAh0k9qo3xVIQb6%2BzcmEVTMYMspETCwsgdu3ZjFZEb7M%2Bbt5B9mo8igRmMbbJTO%2FXSp8QgcZaMJKY6fu2RaCiZNJhNG5GA%2BIqF%2BI%2BUhlNJXRxByqhbtwFGkpIjZMe%2F5qA5a3QMxBa%2BUjgbs3uozuaAR0bTEWhEjG2qJgF9ipSEpMZXE4VCsQzFqFjbHBZCJuhBnG4KwQzYoo%2BR2FwOjEzOspIDeUFNwAyjjlqwkK9p6xi40oxJk5LyKYfZKFRKRcyuQvWiLprgddIb85h0yT6VwtnoD7j8UCVsgIwMUZtwEWcTPZD40AcYwPFJSUgWeP1cLESOMRgYYAQUcR1RDgIIQPjKMMB4aCDmMD7R1uISE2%2BpY1A4wgiiGYIn6fktYjhxUduyJCRSJoIAZaJHkZLqECu4WNC6DRwuIq5KRgtdhuVkRyxCZqHH6QW6m9q5B6AcKx4DFFxcjElAmTLpApqDLEN2yoGG2djnCPIFA4ahThPYiPfAHsYG1XHhYAxqDij4ClVAMVzNKarmJ%2BLEKVsUDRvRUww2xDSKpYPoR5CikpEd2kSsYbBVtXZEQAREQAREQAQeQQLcJAwdNpxnH1fDbLO%2Fo7hRDL5i5pVwU8x9C%2Fc%2F3EjXqlWHJ4yZcufA60i2Hg0p2uEEC2JY2sUtd55v5MEbTJhmYtbQuGPvcDeYmGh7GWhG%2FwgzOHHSG%2Fdw8tQZDtqF%2FVf85cbNWvHmTZ5%2B%2Bvicz2gtDukzqmPgX6feXA647ty9d9OWbRs3b%2FU%2Bddqhirv72nfAYFodGhZ2d9nTn4vxdsc%2BtU%2FA7e6U6bOQL%2Bw7xdrnuKvbkZi4dOkYplhupxOTk%2FGUj3udxJsbPmI0OgbjnCYQofF8rpdYSZVKScyNPbfi%2BQu8X6duA%2B7V8QHRMf71xFOLlyxjnwRcGqdOny1UqGjdes4JSUlEp5N95ao1XD6c5TrCA%2BI6wqm3v%2FM3p5wbNclf4APMMECoi9t14jF69OqN3uB62J1gjFZtfuXe3uSlxuYtWpOAU5jXvWfvvO%2Fmp3DqxafjOJ4RhVC4w0ZjKRBpBfff0%2BvE7Lnz0THGjJ1Ae3Fncr34MkvOnvA%2BTVF4MQibFIWra%2FwjJprhyuGF8RUfGWGTB6%2BmdQ61WF8tHQPLcaBwUqiFfRLQUnsdw2QxDgseLn4KMRikZAMF3jEHffz8eGKOS4WfyyN4vGCY0xA8oxkzZptuIj1uI%2B7P%2Bo2b8EbBjoOGN9q374Cw8Ei44TpRGr48ebGB4Jl27TsQlkCfkhgzsIE%2BOnPWh7NoFMg1iDYGJglIhsOIu4pMhJ%2BOUoGMgE2kp%2FvwnZFfSEB6EzdC1AE10nY8Smo3JTA2sJAAD1JiHsc5Gx5lqxoPEWtxqCmfbfyESWg4OOxmYFhgrR2Op6FjYAbEcPnbt%2B%2B0ecs2bMTzZcyb5tB3aD74mOifcOMggxlXEf%2FR%2FM5jLfbgMiMTISYwjwCnHq8ZF5VTpOdnkOxM%2BiA7RxhXACFWgVMYBh%2B6iY6jWD5pFI4qUs9Jb9v7ZGk1uXDJidUBHVOQ6HSKAgUTbUjJCDSt5pNRx9ijBGzmMoQeag%2FCEdiBzxHaSL0AxHLcf4rCf8cMjqRHxyAXfYR6T6yIbSRE2UYCRmIeVWMAQ92mWa1bz%2FAD6Xl%2FfyOeMMxw1dExjHRgLgfqJaiGI8gUDEtjMN2KF092upVrkLZTKat3MhoJTzLFMvDQH6jIqIW30zHgzLwhIkPoTaZaAJm2mxIwjdECZDQHKsIewki4QHjlDTWa4Y0Eh44EcxLTOjRDLkxgYvYtdQyaDxzGADcJxJPQfEYC%2F8uaxlpD0dqhB2kaSwaZnoW%2FeQjCcdM1QEYKwwCyGBTYBhy6lRgP8ho76TtSslYMzaQvuFJoMpcDG9NM%2BL%2BeBFal2hEBERABERABEXgECXAzMGjwEJuOEcqdflQE9652OgZHuMfAr%2BE1rIRtc6uTKYh43uV25tqXfU89Xf%2B2UgbxGG%2B28Jy1LYh1Mu44o8RYddc6huV6s7zGqLET0lgoAymDGAAW%2FGzdrkNg4D29jzWjOoZlpP0OD%2FC5WyYYw%2FaQPeluNBz70sw%2B8zWIdvA6cTL1qcw9ktGXruIU8I6S2%2BkY9EuGdAzuz3l5K7pEiZKl8r1X8LXX3yIg4Y038yI%2BJF%2B%2F3rxl67fz5uPG29xXcyPNzXypTz61Xx%2Fj1VffsJxcriMCmZhjQuwE45BJ%2F0gE6Awmu%2B3KSnnaTjmpr6AvK33F%2FA5cRXNnbq64l17Kg44B8K3bdqCuMI3FuvrwIEaOHoPIwLNjTEVVIHCC59ck5kk0bbml50t78Sx4dPtJ6c8QZEx7iTAZO34iGVu1%2FvXFF19BMKHVbOwQPUIVTJmhCYgwOPv4EexgBo4bDj6PpI1zZN8ijlgHM6pjUBFxETiA5i0J0OCICYHAWbPfcHnwamkRwx6%2FD1fZwOEIj8g5S%2FQ7OkZKFx%2BmQJxxfEbswWvDI%2BYBtImTITvFsqyBfb%2BwT0ZaQYQ%2FDjW%2FflaLMIlTbAwGnCwqsreK%2FYYNmxC3RgfhYjNZgAQ4qtRO1fiMpmTmwkCS42ydu3bD0cOL5BS14PNSo1n6gLrQGXBCbS%2Bb%2BO95JYaMsSQNHYMEpGT84KgCgVgL6sUVxTHkOEba6xh8RaqiOpiQ0fQpVjENh2fx%2FO%2FA8EbHICTDZKdreKxPsSa4hZRMDkJPIL6FvqCEAYMGW3yoGoWHLkPVoZkkZnFRpicwGukOzrI1a9bS6Bh45ZCEMMnMhYOcQnYW67BEA3LZk6ci7ERfwio8d0rzOO5lmp8eHQOD6S%2BayUiwH8wYQCGQYfCgjThUSvdROM3hcmCfKBczTgjJMDOhEMY5wq8Bk1NoqSmBUcESJYgMVEqLyJi6WEJ0qPSWOgb2oCSgF8GZTqRqh%2BxgofloMlyqFJJaxyD%2BgU6kBFvKJs0Zb4zPNHQMILBREVIG3U16tCwuDa4g00EOxOhiOpofIgwjMcYYhYr28hPH5UzVlMMtB2ILchB8oGR0DNQ2GmiNPWZsoVog0dBSsjM8KI2M%2FI5Zyexr174IiIAIiIAIiMAjRYA7hNQ6Bqvp4RahYYCCewyjY%2FBMinuJzIITFxt9JiC85sizuVKW93SIx3i%2B4bEPO59YdyiYZOkUMYxh9%2B7Ls%2F4DMxSsp%2Fy33EHNcKrXyGW%2F67349feuYzDB48BBV9b59DxxIjk5c0QMWsRiaz%2FXrLN02cq0WxccfGX4yDE8WUs7WRpnYzMSO0H%2FZqKOwR04btcXFSu%2F9XY%2BZrXjOPCUHxfDisfg%2BenLr7zGg1F8AXMV8OS3%2BIclWY0Tn46nydyrIwJs2rLFRAJQYMDFwNKffv79Dz9xyTBdhXiMHbv24FiZkcmNNymt2%2B%2FIPxbOJWOzFq3ezVcAP5fEXI8kW7t%2BIxqC0THwO9AcmHViFUXtTDlh4gk2c0levRb2xZeVMIzHx6xZitJCIWymXsvX4EjtuvWxCn%2BNaxnbJk%2BdbtMxxtniMfBlKJBADuIfiP9nw6dgIQW8WpqD44BXwm8C1fGVmel85YE7KKyKqI59vBj8L%2BPqZkjHoFgeLmMbvYALZsymyTj1%2BHrMO0CgOOh6mI2Hy3zatJrYO%2BgYlMnKAGgLzOPAC2NjGQ18bdwowjboWSIiWrVqiyiEM2UawieVkpGW4nPh1xNmb5HHKkOAp8M49ViL8MK7j41hNtsOu%2BFNUwgl4LUR5LN0%2BQrWPcCVo2rj%2BlEaC7ngyBOEgLDAKfxK1lrEHoQpvuIzIlxQEY%2F10YvMYh2mNw1k8AIZXAwno2NAyRhJvZiEX4%2B6xVljCSYhuBGFQi8TccFEQo5TnYOOwdNw2kuBFGKqAwvokEFsOkZKPEZqHYP0VAQZRx0jJV6FIBAUCRrOJ0CMSXwlZgCHFAGK4c3%2FL0zSoVOMjkFfYz%2BTMjDD2G%2FkJq44bCYNTSDchYlCN7Efsg0JCqF8zMA7xmCWTyEvW3p0DNARHoDrjYRijQQIoEhwikophEqBbA1CxCUqRVKgRUbH4JIx3Ox1DEOG4zSZuAuWfUDQQHlDyuAyZ9IEYxvRwxpClEkDiVCh3lvqGIwKhCkiHFgNgxIYnMgRhDkxc8SiwQ7ikpFKjI5B%2BdiAMRwHO7NpiDrjxw2GkEw7HgOkYMQehCxaTYAWQ4JxQlCEdfmTwIxMrgt%2BOTGJDrX1rIcnq69gLY2iBIrCBn6m%2BJEh3IsrkeazhC%2Bzb4yOgchmGFIaiVnnhHVQzAVFRhO0w9hwWKrXjFV9ioAIiIAIiIAIPGoEuAP5Lx0jKpq7QVwJ7n%2B4c4AG9xXcIhKPkbk6BiXHxsYcPXftlaYeLzB%2FxG59DPafrHdszNqLN5IzNu%2BABztJScRN3%2F0fC2UMHT6KxRZuKV9wMGUphvrVatbhHRk48ndfEzMakpJa%2Fdqe967e3ftKCFNhhTQmPjBBICn5nlrt0AraxUqevJnl0qVLDqfsvxIaUbVajZWr19ofzNB%2BXAZlMe7bMyseA3dg5%2B69eV59HX0Am4l5JsKZR7ovp8wrYZ85F%2Fj1%2BPiEYXMJwBcv5pU8b1jrfNrmlfz7SVbDMBHyJEP%2FIaKjdZt2ZOc2HsUAB5aSyc6nywHX9h07s54JlxW36DgFXGhcfXg9%2BPusj8Ea%2Fjx35gjO0Tfffm%2FWx8AuNAReL8Lin8EhtlgIvCrEgypfVyVkAjec0jhImD1hGBMmTiGIYu78BYAyv2OUxiwAPqnR1%2B8CL0P58aefOYvNtJpJ7jmffMbEYzADAoOnTJvBcRLQHMSEQ25HzBqPeP24mSgbOCOUhuV4izzh3%2BdygFX4zLqm5Dpx8hSyAA4%2BeUmWWsdg7QVoYDOb%2FbwSzMNbgT%2BOtm09jXiby8NGMlwwqmaWPeVTF82HGpoPXjNF4eHeLh4DLBSFv9y3%2FwBcJzjgZEEYL4%2BnuvQskgTqgVlegGe%2BpKdA3FjkC%2FxrdlhqABeMaRGcwhKqRqXhkT2WcwS3mnLoAljxFfMwidkQiAzwoddAx3GbwSx5OmUaz6bBSLGkwau1TrEkAk2gRVSB08cwICNtxxi0Gp5HEz1CyaQ3QCiQ6QwYhivKQUOACBbGGCVgBmMPMQQdgxJosjGJXJzCWwcIeg5G8tXEexAEwll6lv61YJr2YgCrPOPd0wTQ3TIeIw0dA52HXBSF5YwHNsqhIkQVHFjkFNBRNa1AY%2BEI3i5W4V%2FT4wxppA%2FS8wlqWsQneU00wuDBwzhFYvLSTGjTg9RCe%2FHK6VzOmhqNjnHGx4eKSGltJKZka30MvH4CbAgVsEUmJNjKJDuTU1jqAW%2BdsAHIABzI1Ei9pKfrGbSkTEPH4Cx9jc0MCXKRHdeePqUtlI98Srt4P441hPhZYN4N0Gip0TGYf8RZyuEIG2dZT7VFi9YoEnxlcR4iVVgrg%2BYYkhw863MeJQQCmGp0DCJDDCuYI8jwHwdlcoTJpKiRDIm04zG4nBlFsKUKcmEe0VnUy3Cic6kIjYtP9hlL9B29yQ8mifmd4S0zKKtGx8BaeopLgFOgQLSh0%2BksfgNZUAiBAuGCcmgslsMNZYkhB2oaZS5brj5uTpBijL5KYm0iIAIiIAIiIAKPLAHupux1DDhwhFsIbqjY4Ss3FX%2BSjsELVVcfDGZVTwcRw6Zj1D%2F222xfKs9QMAb3crd8VegdnWvecuLmfoTb%2BJGjx6FUGBGDHWvfOsKrMZh3gDcadJtlMO9Yl5XgXnQMXkbLzTMihsdxz3sPQbFMsnYWLVnO3BkkndDQW6%2BSwb03c086desZdg%2FLaEAgQ9cdt9CZpWOwjAa33KxKUe2XGtxjc3eNT8dLQIh8wFuhohPep5lFQpgEEdR4rzw0JNQBT99%2BnU%2FEB6Zg4K2TACC86wQdw2THcWaOCaoCk%2F1PnjrD08YKX1Qk2GPXnn3cwJP%2Bx59%2BQULBReLmH3%2Fq5%2Bq1KLxylW%2BaNmvJW1NLlCyNJQRdYAkXYOcu3ZE1kEFwXti6dO1B4vYdOpurlQQcRMcoVLgYL3g1XjCnKJngom%2Bqfo9ji1%2FAFU2LSpUqs237TtwWYt0%2F%2FuTTp59%2BHh0DUQM3kDe9UsiixcvwqXHVazrVISKFyBAMRhshAAP%2FiEvM%2FCbgCuGY42jwaJXGohgQmYMQUa%2BBM3M6qI5k9joG77jkMS6OLf41Py%2FYZukY2I%2BXN3nqNDwgPHR8dvwjNorF5cHjRv1AMWAWAwbgEqIJ4FQSpYA3lIaOATo6BX9%2F7boN2IPlZsMtIqgAy7GZEngETGlE6TPdg2GAWovbNXb8BNMvtIiHy%2FQ%2B0Tj4lSzjibfF7yEZccYhQDAPD7UpClcRpxgfE%2Fvx%2BxAfePLOnC%2F0E3jyYg68V5agJCXHSUZp%2BIO4h2DhwTS5GAwcR%2FsykAHIDvoSTcDZRC6AGxZiMC6keYaOf0eBvM6DjJjBogSE6OCWQhIRBgIITfituIpkpDokAnxAJtcAlrM46cR7MPKhyhEjJZGA0AJQ00HUS3sphIrIfhc6hpHRaIu1AZZYCzqU108gBdDRXDJANvNK6Ck4EKiA2TBkZgFNY5YECfD9yUs5uMn16zckMAB6jFsWlqS9SEDMOiHKBYMZgQwqUvJfGDoGQxchgpkm5vIhDXEynLXXMSiZEYWqgJtMSnCZaJwRI0eT0iLD%2FxFcO5iNYQwbvG8z1QV%2F%2FJbxGIwTBg%2BroeKA01PQIBmJFy5cAlKKorMYivQaHBiBBjh20juMEMIeqIhBzhF%2B8FnikvTUi7hhUNBAG42Ul%2FPaaJz3Y3hAgClCnAImtXOW2AkkKRrC5BfaSI%2FTv7SRHdClPa8EO9EnGerEDmEJw4ChiBlcGuzzNIIVd3v27INGR0rKpGdpBT9xtJdLj2GPAfzGggLdhn0iSfwCAvhBMJcea6fwC0PwBl2M5UTjcL14ep3EZl7Ew9igK5FQCGeiUi4Beo%2BUhKDwy2BuUWiXNhEQAREQAREQgUeQAHcCDjoGELh%2Ftu4QuIv4k3QMFvzsv%2BSCeVkJIRnmHSXmBazs8zLWwCsR6XlHidVrUTExLHBhOePp3yEMo9%2BgoSy8ULOus5EscNIbNm3Je0XZMUf4fLDvK7Gag4jB6xh4bSI3e3f3nlmrqNvtxMcnjBk38acaTl269z7s5s67L0xKNJOAgIvTZ82tVbchMRv3uLIoopPlslmdmMYO9%2FaTp81EYDESk8Pnz7XqZGh9DEY499toC7xuFf%2B9SNEPeVsH8yxwi7j%2FZ2P11EJFinEk77vvsW4GN%2FzERfxSvSZ%2BFk8zeSUxrzdt1qIlr15FzcDlR8TgzSPGgeKWHm%2BofIWKuV54CTGEU%2Fnyv48vQ3s5xfSBJ3I%2BzQtHAi7a5phzBJ8df61c%2BS%2FLlvuCF6Ow1uJbb72Lh0uTSYBf3Kx5K17M%2BuZbec2yFTh9%2BESGHg3BCa1e0%2Bmvj%2F2ja%2FeelGYYsoOv8be%2FP86ioJSDYbPmzDOm5stf8INCRVgbJFeu3LjwyTdukJjoi08%2FK8uyHtCASd538g8ZOoKieISOt44x9o4DbhQKBi45%2FiZuOBs7uMB4LggF5teDOR0cwUEjMUdwSXijKK8ZxUPBckLiyYJrA2pGMnj5ygNxs7VE8kiJTMBs4PD8l7P47%2FikODuIHiFXQ7EZj5hQMWQWCsFUKiL0HcefJTqp0Qgg%2BHfG6bOw4OfSj8zaMLmIuGDuCZ4a1uJn4cmah%2FvkwvsjegE%2FFHcM95MExLSb1nEW1QWvjbwYi%2F3oGAcOHuI4G5M7YOLcqDGtwGYsZxFFWs2Gv0xptAItAo%2BMZHjHNBM7KY1QELIbU6mIrkcOIjG1k553UdI6TMKxNb3PJ%2B98IQH2G3S4eLAiOogyyc5sIGM5%2FjLJGBJmpU1qwSVk1OEtYgaOLTRYqxaHkSMYTC7KhBJVYAlOK%2FUSvmKGAfAJV0AGoR8pijRIMZQDPcrhiE0CatoitY5B0xiueN8QM2SwgaAU2oWrTrHkvRwSwjqujDqq45UWOMWMNAYqtbBhCbMSaCPZIYljTsQI8h2DHGvpDhxtyqEizKYijKQttN1s7Ldp0w5ZD3UC%2BAhH1EhKNio1Kc1IQO9C4eEsG73DJUCl1Eg3QQYViKEOZFx4tC9LPCG6gB9njqB0YQZqCbElNJa%2BoxWUz7DEx6chFIuDb6QJTjai3BatoIolZCQSCctpnTGbHQrBBi4xAJKGBlIIXcwFhbDDbwK9ZtEwrJDO6AjMxmAmxdDqYcNG8pWU1EVf00ZaZHqfgYRyQjJ6EK2GLMiwlEN1BHLQuRjIeOY4Qx0lExGDs0ht1X6ujt4LClKyg52kpFiaRvm8roXrAh2DEcIit7xeh8YaFHQcKKgOpZcs9DXHoUAVvKuaaW6mpUiX8ESABQu1cI3Yrvp5C6jdXCn6FAEREAEREAEReAQJcDuUWsew58Btw5%2BkYxCP8cOwM7y1BCnjhYYeNUed7TrP753WnrxuFVkjT7PjHj6hrKtnb0za%2B8xTuJ1jnvZxdIwBg4dZy3uyPsbIMeMDg4JCQq4u4KW0f0RoGB3j2rXQtEtL51krHoMFVG%2BXpUPn7paQYqUhMnndhk226SSZtLCnVbL9TlRU9MzZ6BXOP9eq27Frj5FjxrH%2BJ2%2BnRd75sbpTxy49mJ5vn%2F7u9lkig9cDpN2t1lmG4n7Xw207dG7SvHXTlm3sN94j03fQEO6T039ny8jnJnnLNmZqT0FhMFEZCxcv5ZGiKQQvw%2F2oB24gbgJOSvDVq2DfvnM3uTiFI8mbB3GF9roc4Jnp%2BImT8Bwx1fgX7GAtRfF2G7wYHkEad4%2BzbLgePFrl4bj5igvGJYY8QpQIRyCJQ4dIwtqe3PxTFPaQBa%2BZB%2Btsq9esxx%2FBBnsy2M%2BN%2FemztsUkzXHexMoDX1qEC2a1aP%2FBQ1OnzaAzcRz8AwIZezizmEoWfARSogmwXgQLGxL1TRVsuEK4eyyegG1WjexwigZChkfe5OKRN14%2FB61kPL0lSgHLTTOZDsOjcDQcTOX1HPh3yDWGNs9b2efpNg%2Bmb2679nDE5KVMnsMSyMEDXCBA3ng3FEtGjlsN5AgpebZLw0kDYWQNe5vZBwVpCFfA2puGhUfQO%2BgMeIj4pLje1GhysYOfRc9SNQ1kYRCyk4uzJi9CCgSQaDADvcU%2BIy4wASFMR0LwMWSsMnESt27bgduI3MHTZ%2FgD38QDAMcqxNRCQ3DMkdcI0We1DbAwYEyXmQR8AhYyNIHexAwIYLaxkOwsnoBnjQLDo3OwW%2BXTFhxYbGAhFMQB0lvtpUU8yge1KYQqiJnBs8bJxXnnqy3vufN0FgWaNAxggGOqQQRSvpKLxA4b6TlO1yA3UQtjAGvpEfoFA7AEdHQ9bcR45iYQZ4LTTcSFsZzyOUuUCCE6lEDQC9IKp1geg7e%2BoopQDmlMpZDHSIAw4G2fKTsYRiuwAWecsBPsITGfVMdXMxJIw%2BgyNXKWHTMSIANMOJtW88nPDoWjfVnlsM8RzOYIlrAcClc0%2FcsA44qggfbF0nz4I18gZyExccqUfDHw0k2bUyyn34HP4ASLqcg0kPL5NYAGEVZYTouAYzWfHWZzcGVRuBk2lIDAwthjXQ6GCtcOJlECKRkM8KFD2cdIIqysa4Qj%2FEaRhfEMTyNOGmh%2B%2FgEMIQonDYbBEDtpKe2lcK5ELg3zy0wCKsJC5CauGsQNTKW9tIiWItdQOzR4pyqXG%2FxNQxhXXMemy6iRQmgC6Rk%2FtxxdBos%2BRUAEREAEREAEsj0BbjwGD7FNOGUHpyn1xqNn%2FBHeu8pMW4dl8%2B8FDgsDnL4QXqzTiZx1j37U%2BeS8HZdCw6NY1fPAyWs1Rp59odExXmUyd%2Ftl3mySzlqw%2F66DE9Ax%2Bg4cYnQMxIrmrX%2B13kWSkJDQq%2B8AIyZwilUjMlfHoFJUAiSCW26459bcFksoYP0K3lV41421yknPDrHNo8dNaNn2N7SdH1A0nJv07NP%2F9zXrMuutrKxnks7%2BNcmQB7it5f429YZjwhjIUGn2Yx5HkjtqIpwtF4OiOMgVweNCPrl%2FNvumCu69ScxBczYuwRbC4WAARZksPCmmKMs2U685Qi5u6QnYqF7DyfWwO74S9%2Bc%2F%2FVzdrDJKLSaXVbtljFWa2aE0PD574znOV6q2P%2FiHtbZGUSZNsKogPfsOBnPJ4yIRpYBLThaHSimZLDSHojDAvihTGgctJuyQwErGVwo0tpl9vjpsVl5TPolNduu4fSHGNlJSCJ98JTGbg818NWksLBTCM3RsM%2BZZhZuMplKraofSTC4rjf1ZK4vVZOusxYFTJOM4OzyM5hE2fp85YiW2mZeyqAg7pkzTOiuByW4s4dOeAKfIReHGBj4d8poCOW612hzh0xToUAsprSOkAbWV0VZR%2FH%2FGACnT%2BP%2FCWEUVJKMcy2Z2bKvItmjJHBYkEaQAfGHWoiROAGeclKZ2K7spga9sOOAEABC2wUEHIx3GldUKduwTUwhfzcapW7LCBodclvFWpQ5HUltrpWTHFHi7Yu0tT22SKSft8jGGjFZeK7E5YurloLHE6lCOsM9Zy1SwkMV82h%2B3lZ%2ByoE1qY0jGRjmkSX2W0ky9lGl0DBQPEnOcXNZZMrJvGcZXYxsHTZn6FAEREAEREAEReDQJcIPBGnoEmvKAmAeOPLVx2HiYSGAzAa48K%2BF%2BI7Mo4Tdscb%2FydgvP9rP9eHEJX7mho3CEi2vhkTO3Br3%2Fm1eb6b7cF6ezRu5q0uOY3zINExxmzJprm1dSpwGf7Tp2uRZ6M%2BiCmAdiM6rVqGNO9RkwODY29paFZPQg8RgIJugYvzjVY7XPW25UinbAbBe2jJafielDrl71v3AB8YRXcsTGxmViyRQVm8HbUW5ib72leuybzpHzYJNxAaLJsMg%2FE0aYvVK4SHEW8GRmB5HzGGbu8x%2BghVzyRHETDI93mYmX%2FwNs0UNYNWOAR%2F%2BMAYLwkekeeKc%2FWEQ80Gf9B%2BaMMOqYC4PGzowDwoHSJoPnS8AAk1MIXNFAfbA9mNHa6S8UDPqOwCf6MaPZlV4EREAEREAERODRJMBtA7GjrKhWt54zc05Tb7Vq1WbKLbHxBHNm4g02jxZ3Hb%2B65uAVZgnzEMwePmt7Imsc9wmdvf1SSCgTgR3Dku0Tm30i%2Fe%2FxnR28%2FoNAeh49E0zrfuRofPxNVQSJw9v7FAcJvnUhSD4kJLO8eAwePGxE1x69u%2FXqm54ts%2Bp92MohsCQTx1XqsfHwH6H5bCwBynxwFgpgegiB2dzbPyRYmFyAhmm%2FOMbDjzRrWUhHE2%2BPK2cmZWQt4%2F8MaxF2mLDDkpgsn8JEpKPHjjMRKe3LgbNMdjBzJdJO%2BWcYrDLvhQDdTd8xucaam3MvpSmvCIiACIiACIjAo0MAKYMJxUc9PJkbnno7cNCVeSV4MdxsZC4TBAreLogEcctiETcc9I1bJjMHeab%2FsLnnsif9BBJSVoFIo38fhVMETjPLg2kdfD5UIdMoKvZB3Y9CX9z%2FNuJ6AxnU97%2Fqh7NGM%2Bqsz%2FQYyf9QXD6Z%2Fv9UeqpWmnskoL67R4DKLgIiIAIiIAKPLAHuIrhjxHu65caph%2FkJF2rI3b1uNf2OtlL%2B2QTi4jXZ%2BdaC3iP7o6SGi4AIiIAIiIAIiIAIiIAIiEC2JMDahKwV%2BWd72Sr%2FPhBgjclsOUTVKBEQAREQAREQAREQAREQAREQAREwBBAxEhOT7oOLrSruD4F4Xn9wm3lGGvMiIAIiIAIiIAIiIAIiIAIiIAIikKUJMJ1EkRj3R164n7XwGhd6NkuPTBkvAiIgAiIgAiIgAiIgAiIgAiIgAg4EYmLjtCbG%2FZQX7mdd9CzLZWi9Pocxr68iIAIiIAIiIAIiIAIiIAIiIAJZkUB0TCyP7O%2BnW626HggB1IyExMSY2NjIaNtrSRmrYRGR2kRABERABERABERABERABERABETgoSaQsloCbixP53m5apIUjAeiKTzQShE0mEDEQijxCYnaREAEREAEREAEREAEREAEREAEROBhJsATeaIvkpP1RpIHKiWochEQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQARG4E4GLgYHaREAEREAEREAEREAEREAEREAEREAERCBLELh0%2BbI2ERABERABERABERABERABERABERABEcgSBO4Ur6HzIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACD55A8vXrl6Lj1vsFT%2FcO2H7xanh8YkLS9RPXIheeCxp23G%2F%2BmSDf8JiE5OQHb6gsEAEREAEREAEREAEREAERyLIErqf8OZh%2Fy4MOafRVBERABOwJJCZf942ImX0q4Metx4qudq2%2Fy2uNb7BfROwKn6Cftx17fbnL91uP7bp4LSohyT6X9kVABERABERABERABERABEQg%2FQSio2J%2B%2BKbFsIHT7bMkJyd3bDesacNekZHR9se1LwL2BK4HByfs3h2%2FbUfith0he3etP7N%2F4%2Bl0b6dctp85eCn8in2B2s%2FSBOKTkk%2BFRo339C%2B7wa34GteP1x4uusb1263Hpp8IGOFx%2FstNR%2F5vyd7ym45suRASmZCYpVsq40VABERABERABERABERABB4ggfCwyFxPlnSu08XehqSk5ErlnT8q%2FFPotXD749oXAYtAkrd3eMUqIa%2B8ceXFVyOez7Ox8Ns5epfK0f3j9G%2BP9fik2DinoxdPWmXe407o9IXR%2Bw9RyNXQ6N5D19VsOqvhrwsatrNt9dvMGzd%2Ff%2FjkRf6VnIPrdD55zKdJx8XOKWcb%2FbawTss5DTos2bX3VPi0%2Bf5VG0Vu3H6PljyC2RExPEMiBh3xKYV8sdq1tcvJAW5n6%2B7yZL%2FUusPfbDlSaLVrjsV7y26UjvEIjg41WQREQAREQAREQAREQAQykwA6xsvPlW5cv7t9oegYX1dsXKr4Lw46xtWrYQnx%2F%2FUgleknJmN8fAJnrUKSkpOvXAlNuNVTV5LFxsZZKbWTRQlEte8YkitPyGt5Q15%2FJyJP3k0lCuYYUPb%2F%2Bnyezu1vKSlzdC9Zb0mPzCJwafJCn8Llro6anBQd4xsU3qzTolcKd325ULfXi%2Ffk07n7yuAWvb1yvOqbq8zere5vl%2Br7arEerxbt8cL7XUv%2FNH7puFXnq9Y%2F%2FW6Z6BVrEmLirIGdWbZl73LikpL2BF7tfPBUybWHCMPoefjs4CM%2Bg46cG3bUp63LyfdXueZZtj%2FnMhfpGNl7GKh1IiACIiACIiACIiACInB%2FCBgdo0mD%2F9IxWKnPXsdA1li2ZNNnpZzefLlcgbxfdek44trVm3EandoP%2B%2BHrFj06jyr4TpW3XilPriNuXnNmrixSoOobuct%2BXKzaiuVbrIbs33ekypeN3ni5bIG8lbtSiII9LDRZcCe8Ru0ruV9HxLDXMVAnMrT9T8%2FSZac2zazW9x%2B%2FbUTnWeer1r1Q8ZcYF1eK3bTL%2B%2FPvR%2Bcp0v2ND3s17b36Stv%2BJ3Lk9Xu1vMv2owU%2BH%2Fh6sZ5vl%2B7ffeCqM31G%2B75TOqh1txvhoW7elxq0nR8dE59ZVmX7ciITEzf4Bzffe4LQiwob3fu7nevvdvaX7R7sN9x9YqD7uW4Hz3y0zu2vS%2FYZHWNrxueVXLgQ5Ol5xkH%2FRGs65X2eLdsTVgNFQAREQAREQAREQAREQATsCRgdo75T56ioGGsLD4%2BqXMHZFo8RGkHipYvXP%2FH3IpXLN5w7a1WXDsNz%2Fr1IrWrtTKxF3Zod%2F5YjPyknjF0wsN%2Fk3M%2BVfjtP%2BQ%2FyfT1s8IxJExay885rX5w%2FH0Ah7m5eb%2BYu%2B3np2vNmr%2B7WadST%2FyjatkX%2F5OSb4Rz2JmWV%2FYSEhJiY2CzdhHtBHV6rTqboGOWmNbsXM%2Bzztu%2B9Mleh7k4tZh3sPPJC0QqXO%2Fe%2FERl5OTyuU79VrxTp5txtpaVj7Nt29PWSfSo4TV0%2FZXVAFafzJSon7Nwbd%2F3G2Fl73yvdt3D5QbFx%2FxV3ZF%2BL9v9D4PqN4Nh4VvKst9OzyCrXKluO9nY7i3BRdcvRPMv351i87%2B2VB5x2eA454tPV9XS5De5PLdlXYt3hWd4BwRmUiZo37f3m6%2BWRMv5T9Y0b%2FAQVLfw9W1KiVg21B6N9ERABERABERABERABEcjmBNAxXnvp85ee%2BaRowe%2BKFKxq2wpULZT%2F2%2Bdzlij7iVNYWATPQD8q%2FOPHxX62wicG9Z30xN8Kb9uyHzT1nDoxLeW4x03%2Fol3rQf967IPZM1YaavPmrM7596JrV%2B%2Fga91anZA4%2FHwvmlMtm%2FYlo5%2Ffza%2FmYNb6HD5ketnStc%2Be8ctaZmeWtf%2BtY7yz8aP8OXqVZJ5IxrbOxUpPapBZJrXtsfy5fJ2eL9D5vQrDxvScc%2FKr2uc%2BrBi9eeuN5OQ1m48Pmrrrcut%2BxGP45il%2FcNfxbj0Xn%2Bo5zC9f6aA23ZPDwl09L1apNem5fB1zv9%2B1ULmBsXEJmWVVdi2HV5NciIxdeCaIV5MUW%2B364zaPAUfO9XM%2F99n6w88vcyH64n%2BW7OPzleX7q24%2BgpTR6%2FDZipuOFlrlWm%2BXJ29lDYmNJ%2B4rnXAaOXd%2F%2BaUyx4%2Bftk%2BPjlEwfxU26Rj2WLQvAiIgAiIgAiIgAiIgAtmeADpGnhc%2BLVOyxpgRs0cNn8U2evjsEUNmfPDuN5%2BWrMn7Sk6eOPvCUx%2BPGDLTQuF5%2FAxHRg6bxRHiMYoV%2FM5aRmP8mHkIINZj0%2B1bD%2FzrsUKL5q%2BLjYkt9eEvxT%2F4YcrExWzTJi9tWK8bp%2Fa7HLWKzXI7bZv3f%2FbfH3kcO5XlLM8Ug%2B11jKsvv%2BlbsdK6swfXe%2B295bbhxK2Pr%2FPavd8n08bAlv3nRs5xGbfg4Ji5%2B4fNO3D4dDArdoZMnJMYco0mE2AR9OuAEznePpu7bFxUTILXmWtjpkcfPMKpK7HJU1e4DZ%2B5l7xj5x%2BYttwtMSk5Uyhl40J8wqPHefp%2FscG92OpDtXYc540kXV3PFFlz6Iml%2B%2F7CFJIUHYPPx5bse27Z%2Fs%2FWu%2FdzY7mM81W32N7HivSx6GzQ5XRHZTRu2OOV3J%2Bm1jHeL%2FA1m72OwSQ4AsC8vM5ER8faww8NDQ8Pj7T1dfC1Y8e8r127uZhPUlISUqSX5xliq%2BzTs8%2BaP97ePufO%2Bicq3sMBjb6KgAiIgAiIgAiIgAiIwAMlYOaVNHXu6WDFN5WaMFuEt7Lu2X0Yb33e7FVWggv%2Bl%2FK%2B%2BkWPLqM5go5RJP%2B3IVdCzdmbOsbxm%2BEZxGwgVixesD748tWCeavkyfXph4V%2BLPb%2B92zEeLC%2Fb6%2B7VWyW22nXaiB6znGP%2F3pGbLWCyfuXL4WcO3fB3j%2FCM4qNsa1xyhQe%2FCPjWFlZ2EE4Ou8TEJOSBvg4ZRwkJMbkMinj4xJiomPtF6Jk6VR8MbrSvij2mfNywT8Ix439uLh4NvsEly5duXDhEu%2FYtQ5Sr7211vFb7tjrGCG5X79a9aeohMSo8MjbbBG3OR4ZnXnv9o3ZsitsxKTwMdPDx04PGzM9ZNTUq%2BNmhoyeHjJiCtvV0dPOl651MkfBUzk%2FvjJw3NWRU6%2BOnckWMnIqZ0NHTSOXLe%2FoaRFT59%2BQ63rLXrc7OOf0xXIb3EqsOdR494nhx3yb7zv5wWrXf%2F6hYBCMYTakDLanl7p8vO5wh%2F2nRx31rb7N46M1h5y2H9%2Fgn9637hodw8vrrF39N7g6HHSMAweOflm%2B%2Fou5SuV6rmT%2B974aPWqONearfNX480%2BdWrXom%2BflT3M9W%2FLtN7%2BYMnnx3r1uZUrVyPXcx2xlPql55MgJq%2FzNm%2FeW%2FLDa88%2BW5NTnZZz27XWzTmlHBERABERABERABERABETgwRIwOsbt3lcSGRl15rTvi09%2FPLDvZMvOo%2B4nn3vio7Gj5nIkPTrGovnrcdvRLr6r0jwyIjoiIooNfxkXg4ehVrFZbicNHQNxg%2BVPcz9b%2BsWnSxXO%2F%2B3iheuN7DB04LQPP%2Fihd49x771VCQ3k%2FXe%2FXr50k2k4z3zHjZ6X%2F%2B3KHC%2F03jcdfh2a781KBLRwtuZP7X74poX18pdOvw39qNCPrHzIKWQK57rdXslVhopYZ5VwGqQSUyCBIt9WbpL72U%2BYv9PEuSfRNRhsVvMgl9Mvv730TCm2Cp%2FWdT3oQRYyVqvaunyZOmGp9BBToMOng44R8u2P12JjrmX8j8fkDiXf9deLtbp65Xj7ZI73b7d55yjs%2FT9F2W6XIOV4gdNPlbmeIiXdtSWPQsZN%2FiGdXc%2F0OHx23HG%2FJntO5F%2Fl%2BpfFN8MwLBHD2kHKQOIotuZQu%2F2npnj5dzxwepC7z6Hgm0ERd8SFjsG8EsK3kP74yTJbSEhogfe%2BsuIxuOjefKPCW29UGDNq7qKF6yp96fyvfxQaOdwWNsbfZ2Wc%2Fv5YgeJFf5g4ceHECQuZjYLWwZob9et2WbZsU5fOI5%2F8d9Hvq7YwOt7BA8defrH0R8V%2Fmjd31aSJCykzf76vfM5dMEXpUwREQAREQAREQAREQARE4MESSFvHCAuNYDXLzz%2BpzYqdvilLW6A8dGw39Kl%2FFnPZZwvIr5OOeIwFc9eSEsUDr9kKwMDpmDltueV0P1gId1f77XSMS0FX8r1ZMf9blZYsXL900XoiT5hrQ%2BA6tfTuPvZvOQoUe%2F%2B7CWPms97pB%2Fm%2BQeW4fDmEU6t%2F38YCqqgKK5ZumjB2Pm91efbfH65fu4tTlcs7f%2F6JU%2Fwfb7xtWLcr74Lx8w2kLyp8VpdomamTl6xdvf3byk2f%2FEcRkwUX7%2FNPalEvU4RWLt9S9atm1NuoXg8WJKBPv%2F6ycd7Xvpg9Y8WKZZvRl%2FK9UTEoMBghpdNvw1o06YPolB4gD6GOEVijm1eOvCdzfHC7zTtHEZuOkQMdo9Dt0hCwcTrnp9Ix7jgGLkfHH78asS%2FoGut2lljnxrtI7FULFvnkiO3zj8AM29nFeyttPrrF%2F4pLUOjJa5HhfwzpO9bVpHHPp3IWK170R%2BSITz%2BpZbYypWo%2B90yJwh9UJTADnbBp417%2F%2FmfhNau3m9K4rEqXqoH%2BYBS%2Fz8rUypP7U0%2FPm9FTCBR%2F%2Bd9831Rp%2Bp%2BAjcqNcr%2FwCUFKlFbHqdMrL396ytvHFLV0yYbH%2F1lo3Nh5d7RTCURABERABERABERABERABO4DAXQMgit4X4l9XdzJVyxbn%2BUszMIXmzfszfVUSfzxgX0n1avViZU8mzfpbQIMalRrV%2BDtyleCb84rGTNyNs64NdViy6Z9j%2BV4b85M25wUojjee6vyW3nK9%2Bg6plunkbzHBBee%2BSb29Wat%2FdvpGOd9LnRsN%2BSAi03n4Q%2BtgGVRFy%2FcwH7vbmORHXbvPJRy5saYkXOey1nC7ZAXX7%2F%2FugUBFUwqMaemTlr89OPFN6zbzdcqXzQqV6aOpWMQPENKf79AZpdA8vflW00W1i2hK%2Fv0GM%2FXNb9v%2F%2BdfCtJf5lTgxWCkFec6Xfm6dNFG7Fm1cps5tXf3YSqaPmWZ%2BZr%2BTwcd4%2Bq3P%2FH%2BzciM%2F0VHR6e%2F0rRThnQZ5fvel%2F4FvzWbb4Fvz%2BWrci7f1z7vf%2BdT8Ltz%2Bb85%2FXQZ7%2F8p7P1Y8bN5v7I%2F5fPeN1Yu%2FwLfBJSudT32v%2BbgpF3vo3z2UnTc1JP%2BxdYeMjoGqsUTS13e%2B%2F3gZxvckSxKr3d%2FY%2BWBfyx1MWqGee%2FqlgshEQmsVpKBP3SMZ5%2F%2BqGb1di2a92n%2Bx9asaa%2FcL5Yu9P63%2FBaxInHJj34uU7pmlN00pbFj5%2F7fX%2FNv2byPmjhFSmvaFDNKHvtL%2Fi6dR1hG8EqUJ58oxkLEFMWskw%2BL%2Fbhli8vGDXs2bdo7Z84q5qqgk1iJtSMCIiACIiACIiACIiACIvAACbAIw8%2FftTaLdlpmMPuga8eRrZr2s5wCIrprV29fsli1Lz6rO2n8Qush5pCBU5s06BGRsoAe2Vev3Pb91819z998CwnvWv2qQkPzZhPOep%2F0adW0T%2BmPqrPyxq%2BtBmb1OO3b6Ri0lEUn1q3Z2avbmGYNe%2F1UtdXTjxebMc0mFPTsOgYJ4vKlm%2BrNogVrn%2FlX8b273WJj44sV%2FB6BwqhDpFy3dhdBL2noGNabX3bvOjSgz8QWjfogRiGSdPx1KNlZlzX3M594HPVmn7%2FY2NjiH3zfoHYX9jv8OoSX3vL%2B3MH9pwwZMKVz%2B2HoGK2b909JmIEPex0j%2FLW8e%2FPmL1wiw3%2FFihWrX79%2BBmpNM2mAT%2BDx%2FSe8XE96uXofc%2FH0PuydFBqWFBYWtfdA9K59N8LCApv0OJHjnTMvfJbo58%2FXxEvBkVt3xxw8fPVisPteT09bxpN8eruffmRfp5sm4FucvJxKx8i9fL%2FTjuNTvAJ4H%2BtYD7%2BvNx19btl%2BBx0jMoM6BvNKWNfi9GlfBws%2BKPgN80o4SHjSO299We2nNtYVxMHfV2xBx1gwfw376BgkZqKcKYHAsH%2F8X0F7HaNli75P5Szu7x%2BIDsmaGKyMwUa8BxtfX8hVqq5TR5NXnyIgAiIgAiIgAiIgAiIgAlmIQKas208IeJLd2pJZqPkOpt5Ox4iKimYSTa4nS%2Fz4bcvuXUY3bdjLpmNMX052dIw3Xy5HdIQpCh2DySO4VLxMoeA7Vdq3HWJVsWbVjjvpGIEkZrVVSqhYrkGXDiO6dhhBPEandsM4TuDH6y997nPuZnRHdDTrk3xndIzqP7Z96h%2FFkJI%2B%2FbhmmZI1P%2Fu4VpkSNfr3nmhVnc4dex0j4vV3N7%2Fyxt9y5nwyg3%2BPP%2F54uXLl0lnjHZP1GLY%2B%2F2cDi1Yc%2Bt5nA76pO%2BWAV9D1qKjANj38P%2F4qaMHKAyeDgtrY3rvq83K5816%2BZ%2FxsalL4klXnSlQ%2B3bb3oGFrC301qmC5QUUrDiv38%2FjYdE95uKNV2TuBo46xeB8BGF1dTx8LiYhOSN4fFOq8ywtl4951jDTeVwLh4OBrRQt%2FX7liQ%2FuparNnrXzsf99bs3oHCdKpY%2Fj5BQYGBrN0xhfl6%2FHepSNHTrIdPXrSw%2BO0j4%2FWx8jeY1mtEwEREAEREAEREAEREIHsT8DoGMzmcGjq%2BjU7%2F%2F1%2FhaZOXGyOex4%2FTZjEjGm31TGIx2Cli5JFf6r%2BQ1vr7SHr1%2B22dIyvKzYua1sf4%2BYCnrxchqCOgAuXeUfJS0%2BX6tB2iMnFa01Y1ZPVS6h34rgFaBoue9yMDfHx8fbxGCzcyttSyIUwZf%2F82qEhaX8Nr1n7Su7XQ15%2Fhw0dY0ueNx9%2F5plnM%2FiXM2fOChUqpF1R%2Bs926PP7C%2Fk7v168R4f%2Bq4NCY%2BJcDvqU%2BDqwipP7yp3VW8517rbiStv%2B6Bh%2Br5bfs%2FnIh5WGzl58MPH6jQSvk%2F6Va%2Fp%2B9v3ivrM%2BqzEpd%2BHuxb8cEhuXsYkP6Tcym6VMrWO89fuBPofPnA2zTRfyCIlosvvEyysO%2FKk6BiOZqXA%2F%2FdDqhedLHfvjPciM7Zo1fmPiiXkNdDp1DF%2FfAK4js7DGpUu2hWvMH%2BFP1kK7fxzTvyIgAiIgAiIgAiIgAiIgAiKQxQigY%2BR6suSxI97MQcCNMhttmD1j5eN%2FfX%2FenNXsR0fH9us9IeffCrOoKV9vGY9hlsto0aQ3kRVbNruQjDerElZhWx9jrW19jFq%2F%2FMa6Iqe9z7Pvd%2F7ipyVqvvtGRYI6Dh30ePaJD1k7lBAXvLb5s9cQ%2BMG0EZK57HUnu3OdLuYVrrw8l5feNqzXjVNMeGFeSfdOo8zkoKDAK0wOOpVSeFDQlYsXL5MmPX%2FhdRtceem1e9QxnnjiiUqVKqWnuvSkadV1aYnKwzbuOX09IiK4y0CfvJ9cHDh64owdH3wx9KUPujbtvdrSMVy2H81Xpn%2Bewt1qN5996jzuavLVibP9i1Xwatr1t07zi387Kib2pmqUnnof5TToGFPs18dYvA8do%2FfhM2dSdAyiMu6DjpGU8pLcTRv35Px30bKf19m54yDaRbt2gx7%2F%2BwetW%2FY3Kl86dQwTdDF71u%2BP%2F71QlcqNuHAoasigqbwtZdbMFY9yR6vtIiACIiACIiACIiACIiAC2YBA62b9%2FpLj3bKlnHgbyLeVmrDxYpFlSzZe8L%2FEEqa5nytd7fs2rM%2FJe1T%2FliN%2F35TlN5n9wUtbLgbc1ArmzVn1%2BF%2Fe37n9IDS8T5zjRasEVNSs1o5FRQiZQCRBc%2BDU3NmrnvxnUdZZRdDg9SKP%2F%2FWDvK9%2BwYIArG3y1RcNCdvAAGI2eL%2FqP%2F63QLXvWpGFZ8dtmvdnRdYKn9WjwPferPTvxwo3rt%2BD6Iv4hAQWJyHXl2UbMNOEAl95vgxaCrJGpfLOpUvUuHYtXS9CjV%2BxIuTVt6%2B8kOfKi69GvPjaxudy%2F%2B8%2F%2F8k8kQz9EY8xbdq0zBoMu%2FafuxgSFe%2Fi6vNh5cDKNQ8t2VqzzYJXi%2FfMW7L3mx%2F1ctAxCnw%2BMG%2FJPq8V61G47MBpC%2Fcno2X4nA%2Bs3cK3zLdbRi%2BOT0jKLKuydzlXY%2BMXnw38atORt1cceGflAT4%2FXnt44JFz51J0jONXI9u6eBdbfSjvyptnf9nmsS8wNCZFeUg%2Fmfp1O7POp4fHKfssDPK8b33BZnQMTk2atOj1V8s9%2FeSHzz1T8pknP6zj1NGKqfiw%2BI%2FvvP2ltT4GAkWOHG%2B3%2F%2B0%2FM7lYSvTvjxU8d86fcoh96td3ArEcTz9ZnFUyWDfj55%2FaWGvw2tugfREQAREQAREQAREQAREQARHIQgTmzvodicDpl%2FZ8mu3n79uYt4cccT%2FBop0sW8HrXTau383%2BhLELaNrSRRt4sal5Cwxf9%2B1xa1C788kT50yrPY6dat6od%2BVyzq2a9R3UbzKzUXjhC6cQH2ZNX8FrVat80ZBXkIwbM%2B%2FXVgOuBF%2FjlL9fEKJEpfINbFWv2MrqrF07jSQ8g1O832T86HnkYpmOyRMWFcr3TbOGPc0sksTExBlTl%2F3yfWssRMpwO2x7YQrxJAP6Ture%2BWacBkfu8Icksnx5ZOt2kW3axbb57XirtnUbNmyUkT9nZ%2BdZs2Yxp%2BYOFaX%2FdFhYSN8Rp18rea7ToDETNr%2F72aBc%2BTu%2FVrTHq0V7EI%2FRoNvKK817e%2BbI4%2Ft86b1bjrxVss8rRbqjY7xSuFuu%2FJ1%2Bbjj92KlLsL46aU5AuWrXo2PTX%2B2jnDIqMeng5bCeh8412O3FVn%2BXV2sX73lnLl6MsgE8Fx4zztOv%2Bd6T9VPOkmDQUR9CNRKS0I0y8Oft7cMbRhzeCMxgPnjgGJsZ1aY4JkytXbNj6ZKNboc9reWIOeXm5ul68Jg12HgpyY7tB86etakW5u%2F0qfO7drkyqeSPAzdOnDi7evX2hfPXuuxztz9uJdCOCIiACIiACIiACIiACIiACGQzAvbu1R2bxntUd%2B1wtZKZxTdYAcM6kqEdHlWjn1iCiesBj%2BefKDFkYKZFPmTImPuWmLeTRG7cFe%2FhHRmfvOvAuX2u5%2FYfPm82l0M%2B3j5XEk77Rm3bH7vPPexa5AF33%2F2HfawE2%2Fee8j57M04mIeDi9QwGDNy3NqoiERABERABERABERABERABERABEXgYCCxeuO65Jz6sU6PDwL6T69bs9MTfCrdu3s96fJxRC4nWYB5KsYLf9e05rlunkfnfqpz31Qpn7lYVyWjtSi8CIiACIiACIiACIiACIiACIiACIpC9CRC4Pnn8orKlnYoWqFqq%2BM8D%2BkyMiIi66yYTCnL4kCevf%2BV1q6yAUf2nXw%2B5Hr%2Fr0pRRBERABERABERABERABERABERABERABFITSEpOJpTCesVq6gQZPRIaGhEeFpnRXEovAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAlmCwPd122oTgexHoErNFj0HT8gS16CMFAEREAEREAEREAEREAEREAERSD%2BBlwqW0yYC2Y%2FAM3lLV2vwW%2FovBKUUAREQAREQAREQAREQAREQARHIEgReK1JRmwhkPwIvFihbo3GnLHENykgREAEREAEREAEREAEREAEREIH0E8h%2BDqxaJAIQkI6R%2Fh8BpRQBERABERABERABERABERCBLERAPq8IZEsC0jGy0K%2BQTBUBERABERABERABERABERCB9BN44bmPtYlA9iPw1BPFfvy%2BVfovBKUUAREQAREQAREQAREQAREQARHIEgSez1lCmwhkPwI5%2F17kh29aZIlrUEaKgAiIgAiIgAiIgAiIgAiIgAikn8DzT5bQJgLZj0DOf0jHSP%2FPwEOa8vr16xEREWFhYYmJiQ%2BpiTJLBERABERABERABERABETgvhPIfg6sWiQCEHhodYyEhARPT68j7kcjIyIdLnfcdh%2Bf8%2B5uR2JiYhxOPTxfAwIuHjp02NfXz94kLD939tzhQ25XroTYH7%2FH%2Fbi4%2BKVLlk%2BaNDUwMOgei1J2ERABERABERABERABERCBbENAPq8IZEsCD62OER0dPWni1CGDhvv7X3D4GYmJiZ02dWbvXv28T55yOPXwfN29a0%2FfPgMmjJ9kL1kkX7%2B%2BbOmKPr36HzvmkYmmxsXFzZo5Z8jg4RcuBGRisSpKBERABERABERABERABEQgSxPIlj6sGiUCD7OOMWXy9GFDR6b2zZOvJ7u6Hlq7dn1YaNhD%2B6uyZ%2Ff%2Fs3cecHZU9du%2FqaSH3oUg0hGQpqKiIAoqRaSD0qUq4J%2BqFCEhvffee%2B%2B9b3ovm832ku3l3r29l3m%2Fcydc900gJoiSbJ75XC9zZ86cOed7Juvn98yvrO%2FcqVvHz7vOn78wFe6BjjFr5pyOHbrs3Zv%2BDY4cHWPsmPGwwgnkG%2BxWXYmACIiACIiACIiACIiACJzQBGTwikC9JHBC6hjxuN3uqKioCIfDqb8q1dXV%2BzMyMzIyKyurCN9IHWfH6XJlZWVnZOyvqqz%2B4lSCHkpLSn0%2BX1VVFQEs2dk5nv8%2FgAXxobi4OD19X052rsftqdthPB4nguNLr0o1s3SMLp274yaR8r44RMdwuz14mzidTusqumUwaBHMi7CasrJy5kLii7y8%2FH3p%2Bxgtl9PSGjAzcn4h46R0jLzc%2FAMHzDEXFhbRQ2ow7NAnQS6cKigopL11ivtwl4qKyurqGugxmP%2BfXN0OtC8CIiACIiACIiACIiACInCCEaiXNqwmJQInoo6BGT5%2B3ET0AWx2%2Fo4gOKxfv6F7t14dP%2B%2FCh%2BOrV62xrHhUiz179vTvNyh1au3adUnviMTs2XPxlxiTdGMwz3boMmzYSHQD6w%2BTs9Y5Y8asTh278uEs4SE5ObnWqWAwuHTJ8q5denD88w6dBw8elpuTd%2FifM0vHwEeiU8duOJbU1tbS5hAdY9OmzfSwfPlK63LmNWa06VaBRONw1DLs3r36Dxw4xBoDksiSxctWrljFBJO37kIDsm1wraljjJ3QtXMPxpl0AunCJTh%2BeL0HU4vYaxxTJk%2FjKqur8eMnWtEu3Khv3wHdu%2FXu2aMPcTq0icVih89FR0RABERABERABERABERABE5EAjJ4RaBeEqgHOsbWrdsxzwcPGrpx46bNm7aQVQODnYP8ncHLAqu%2Fb58B69I2bNm8jVO03LFjF6fmzJ6HdtG9a8%2B5s%2BcRpTJu3AQkBWJAkD6QBaZOmdGhfecpk6bu3Llr2bIVdNKv30BUjng8sWL5KlqOGzth%2B%2FadyznVpcfAAf9fEgzr7xs6BsOYO3f%2BtGkzSZSxcMEi3C3ovG5cyZF1jAH9ByNKjBo5dtPGLdy0d6%2B%2BdIiaMXPmnB3bd5LYk5%2FIFyT5ZGM8%2FOzfd%2BDq1WmbNm5GOeGmixcv5Y4%2Bn3%2F8%2BEmcnTVrDnlTZ86czf7UKdPRc%2FDEYF4wGTRo6NSp09et28AgT8S%2FzxqzCIiACIiACIiACIiACIjA4QTqpQ2rSYnACa1jEAeBd8SokWPwYUhVBiGkAtlh0sQpxIxMmzoDw5%2BICetfdH5%2BIX4UEydMxvCfO2c%2BcsTaNWnWKbJw4NGBaECoBXEcNBs9epzff7AeCjICssbmzVurKqv69O4%2FfNjIVBWVpUuX08%2BWLVsP%2BaOBjsHxFStWVZRXokjQITEdtJk1a24qP8aRdYz%2B%2FQYOGDCY%2BBer58WLltLh7FlzLJcJomCQZfr1GUADnE%2FGjpnALVIzJTilT2%2BuH0IKkd279qBU4H%2BCpkFXyBcQYLK0qaqqRuTBrwNB45Dx66cIiIAIiIAIiIAIiIAIiMCJTkAGrwjUSwIntI6B%2BECyCGIikDJworD%2ByCBEkEWTRBmcIs4CiWP58hUbNmzks3zZSn4OGTyMsBF0DNwS0tMzrKuIs0CgoAxKKBjCqQNnBhqn%2Fmo57A6KpXK73btNTWDE8FHr123YsJ4%2BN1l%2BEYsWLkk1tnYsHQNfDn5u27qde6F%2B1NY658wx%2FUCsPJ9H1jH69R1IqAtqjNVhsgBK57S1662f1JylRknvXv2YpqVjMLVUnk%2BOTBg%2FCT0nP79g4YLFpgPG1BkbN2wiAGf9%2Bo1cyCzQVdAxmDX0%2FMdxBVtrvvoWAREQAREQAREQAREQARE4VgL10obVpETgxNUxCAkh9SWpOLHWJ06cgpvBIf%2BoCQNBCiA0gw9mOx92cFoYOnSEvcZOxAfW%2Fd69po8EW01NjaVj4OCxZk0ang%2FY%2B9aput%2BbNm2hE3w8Un3SIR8CUg6JyKirY%2BD%2BgdxBn0SXENCRvK9Zr%2BTf6hgM1ftFjgtcR%2FAJQc2wxkNd2sN1DJQW6yyDmTF9NoNEzzEjWZIBKZ07wqEb3xDDHwM%2FDTQQZj1yxGif3193mtoXAREQAREQAREQAREQARGoBwRk8IpAvSRw4uoYGOOY7ZUVlfhjjB41LuWPQfQEdUDYTB2j30CCJoi2KCo8YH6KDhCKUl5WjrBg5sf4Mh2DfjZuJP1mp5UrV6f%2BcOHjQVURXCC2bTM9K9AiDhQVW32Sa5Q%2BcedINbZ26uoYHCkvqyC6BAGE0fJd1x%2BD2BPrEkZFeAhuFVaeT0SYY9UxUv4Y0UgUbadr157kILUiaEgQylCtMRcnx0xkCjci%2FEQ6xiFrp58iIAIiIAIiIAIiIAIiUD8I1EsbVpMSgeNfx6DC6SF%2FQ5AarHolJcUlpLAgXqNXz76pZtU1dsJJKL1BEAflP5A7cnP%2FVU8E6QOJgw5nf6WOEc7JzunSqduE8ZOtoic03rZ1Ow4MOGNYyTdGjRqH24Y1KrJVFBUVHV7m4xAdg8Zbt2y1fDlSOgbd4mKxcOEiqytCSIYPG%2FW1dQzcQnK%2BqJxChVlkkP79BzkcDkJgcAVZMP%2FgXbgXPh5lZWXsSMewyOtbBERABERABERABERABOolARm8IlAvCRzPOgZGPbY5gsPq1Wsppcpn1crV2P5ul9tK%2FmDVXbXsdIIs9u%2FPzM7KmThhCmY7eSRwzNi2bUfHz7uSLXP79h04Y9BPl849ZkyfhRJi5cc4LK5kFAIFfhfoJPhdLFiwCOGCTtAEkBdwY0DZoFwI2TMmT5rKvXKycynzQcTKrp1mDZS62%2BE6BjedPGkajVM6Rn5BIfIInXOL9L37qGzCKTSZr%2BGPYdUrGTpkBHk89u3LGD9uEoOcP88sv0LIzOBBwwgnQcooLCjauyed%2FB6Ek%2BCvUqm4krprpn0REAEREAEREAEREAERqF8E6qUNq0mJwPGsY1A8FBUCwx9JwfpgmyNukJ2SQqJIHJaOgfJAgdGDCSs%2BNxuT2TIQMP0lCK8grQQuGRykH%2FQNTHjrqtmz5vJzzx4zTwUbxj45M3HtQMTgJ0oCvhypq9AW9uzZiybAKZfLRVzJ5x0Y0sGxzZu7IJXFwuwruSXTcnaimskXB8z%2FktCjX98BXEhv%2FIxEoozcmiDfzKhrFzPwxNIxqCQyZMjwVM9rVq9t%2F1mnVIEV8mOMHjWWgVl5PseMGY8GQpCIBarj550nTJhcW1tr3R01ZuiQ4czXvBfVZrv1IvsHGUW4EeVcYaL8GBYofYuACIiACIiACIiACIhAfSIgg1cE6iWB41bHwMrOycnFX4KyGqkPP0n4gHBBGY596Rle78FaHsngjgPU41iXtqGgoLBulAfiAyEnmzdvwRkDRwWr%2FAcHUTNIUkHsifVnij7378%2FkjtEv8oUSsbI%2FIwvdYOvWbagcdf%2BahUJhIjjWrl1P%2BQ%2BCSqLRWN2z1j6JO%2FB8sMI3Ume5L24hyfseVBhw8OCma9akUdSV7BaEwGRk7EejwHlj%2F%2F6s7OycVGwLpVFRXfi2emOcVmMUG7J65uXmZ2ZmVVfX4NeB4sG3Jcikbk00DRg5tXXLNjqxNJmAOcf9DIDeUi21IwIiIAIiIAIiIAIiIAIiUD8I1EsbVpMSgeNWx6gffzc0CxEQAREQAREQAREQAREQARH4tgjI4BWBeklAOsa39SdF9xUBERABERABERABERABERCB%2FyqBemnDalIiIB3jv%2Fp3Q52LgAiIgAiIgAiIgAiIgAiIwLdFQAavCNRLAsetjkHOh5KSkrFjx07UJgLfKgEewnXr1imFyLf1f766rwiIgAiIgAiIgAiIwNcmUC9tWE1KBI5bHWPTpk2ffPLJqFGjRmsTgW%2BVAA9h7969%2B%2Fbt%2B7X%2F70MXioAIiIAIiIAIiIAIiMC3QkAGrwjUSwLHp47hdrs7d%2B6cmZn5rfxj101F4BAClLPp3r377t27DzmunyIgAiIgAiIgAiIgAiJwPBOolzasJiUCx6eOYbfb33nnHafzYFHU4%2Fkvg8Z2khDAJWP16tUnyWQ1TREQAREQAREQAREQgfpBQAavCNRLAsenjuFwON577z3UjPrx10OzqAcE0DHWrl1bDyaiKYiACIiACIiACIiACJw8BOqlDatJiYB0jJPnj5hm%2Bp8QkI7xn9DTtSIgAiIgAiIgAiIgAt8KARm8IlAvCUjH%2BFb%2BnuimJxwB6Rgn3JJpwCIgAiIgAiIgAiIgAvXShtWkREA6hv64icDREJCOcTSU1EYEREAEREAEREAEROC4IiCDVwTqJQHpGMfV3xkN5rglIB3juF0aDUwEREAEREAEREAEROCrCNRLG1aTEgHpGF%2F1T17HRaAuAekYdWloXwREQAREQAREQARE4IQgIINXBOolAekYJ8TfHw3yWycgHeNbXwINQAREQAREQAREQARE4FgJ1EsbVpMSAekYx%2FqnQO1PTgLSMU7OddesRUAEREAEREAEROCEJiCDVwTqJQHpGCf03yUN%2Fn9GQDrG%2Fwy1biQCIiACIiACIiACIvBNEaiXNqwmJQLSMb6pPxHqp34TkI5Rv9dXsxMBERABERABERCBekng1OY36iMC9Y9As4bX3v%2BbV463f7MOh%2BO9996z2%2B3H28A0npOWgHSMk3bpNXEREAEREAEREAEROHEJ3Hv3S%2FqIQP0jcPedz334fq%2Fj7R%2BmdIzjbUU0HukYegZEQAREQAREQAREQAREQAREQAS%2BioB0jK8io%2BPfFgHpGN8Wed1XBERABERABERABERABERABI5%2FAtIxjv81OtlGKB3jZFtxzVcEREAEREAEREAEREAEREAEjp7A19YxYrFYeXn51i3bVqxYtXnzloKCQp%2FPl7pveXnF%2Fv1Z%2BfkFsVg8dZCdA0XF%2BzMyKysq6x780n2Xy5WZmbV61Zotm7dWV1Uf0iaRSBQUFHKLmuqauqeKig5wVVZWtvVhv6CgqG4Dut21c9fKFas3bdpS8WXDcDqdXJWXlx8OR%2BpeyOys8WzevLWm5kvSiXi9vj179q5cuXrD%2Bo3FxSWMsO7l2j96AtIxjp6VWoqACIiACIiACIiACIiACIjAyUbg6%2BkY1dU106fN7NypW8fPu3Ro3%2BnzDp07dew6aNDQjRs3R6NRGE6bNsM6uHv3nrpIhw4Z8c9POixcsLjuwcP3t23d0ad3PzqnE767d%2Bu5edOWVDMkguXLV3L3Du07L1%2B2ou7xwYOG0t66kGvbf9ZxyODhqQbFxcX9%2Bg5Mddu1Sw9EidRZdpBfBg0cyoV9%2Bwyw2x2pU%2BgSQwYPS13Ys0eftWvXpc6ygzIzeNC%2FGjC21avWSsioi%2Bjo96VjHD0rtRQBERABERABERABERABERCBk43A19AxSkpKB%2FQfjFGPdoEsMG7sxFEjx6AJIGj06N7b8p2YPn0mZzHnBw4c4na5U1SHDxv12acdFy1ckjpy%2BA5eDZ06dkNMQC5AhejSuTv98J2bm0djdJIli5fROUf4XrF8ZaqHSCQycMAQGg8aMGT8uIljx0wYPWrcnNnzrQZer3fggMFc0rVrjxHDR%2FXq2ZeWfHAasRqUlpZxuTXs%2Fv0GpXQMvz8wfPgoxtOta89Ro8b27tWPNkx%2F%2B%2Fad1oXcd8Tw0Rzp0qX78GEj%2B%2FTuT7efd%2BiyLz0jNTbtHD0B6RhHz0otRUAEREAEREAEREAEREAEROBkI3CsOkYoFBo7doJl1K9YsbKmpgYrPhAIHCg6MHnS1BXLV1nxFJaOgbjRsUOXBfMXpYIsjkbHIDZk8ODhKAN4Qbjd7p07d3fr1hORZOXK1azO6tVr2UcoQFU4RMeoddTiR4G%2BsWP7zng8zsDCBIdEDoaH4ONhaRTr128gSCQnOxfVBfFh5ow5dFtbW9uvz0DmxZjpvK6OkZGRyRG63bRpczAYLD5QjNzBhfif8JNrM%2FbtPyiqrFiFWpL0%2BhhAgwkTJhF9QwNtx0RAOsYx4VJjERABERABERABERABERABETipCByrjrF3bzrOEhjpRHYcAgrpgM06aOkYSA1dOvfAxieRhXX8aHQMWno8HqfTZV2CEIELBArDksVLOZKbm9%2B3T%2F%2BZM2YPHTqCYdT1xzhwoBgXju7deu3dk44ugYhh9WB9z5gxizCTYUNHWuIDB2fPnoskgm8JLdFnZs2ai0AxZ%2FbcQ3QMlBPuzilGZXW1edNWJBEUj8zkvBYvWkrPSB8e98EGa9eso%2BeePXq7vzhSdyTaPzIB6RhH5qOzIiACIiACIiACIiACIiACInAyEzhWHWPG9FkY9f37DeTCI3BDx6DZ6FFjichAbSB3BJ4VtD9KHSPVM44cZKJACaG3XTt3W8fJ1elyu0eOHMPBujoGfhEoJ8gLeGXwGTRwyLy5C2jLVQgskyZMQW3AaSTV%2BZo1aWTYIAykuspMForvBkoFvhwMuK4%2FBnk7US1QSAg8sa7FJYOfNOMUR2bPmkvPI0aMZt%2Fa9u3LIK4ERaWkuOSLY%2Frv0RKQjnG0pNROBERABERABERABERABERABE4%2BAseqY0yaaKoB5MQ4csQEOgYOCTTO2JeJtoDgsGD%2BQugeomOEQqYjRGqzcoSmFoHjixYtRZfgcvwoKAiSOuX3%2Bwk8OUTH2LZtB2EseFMgIJg3TaYJHTN6HI3piiQeDAkdJtXJpo2bLYGioKAwdXDb1u2H6BhVVdV02OnzrqNHj0tP34ezx8QJUyxpZdWqNVzINOmZcJtUJ1RLYRiMfP%2F%2BzNRB7RwlAekYRwlKzURABERABERABERABERABETgJCRwrDoG%2FgzY7DhaoAwcAZelY0wYNwlpYvGiJQgOGPXULeXCVJ5PlJBhQ0cQM4JHBB%2ByVaxLW5%2Fqk6Ko48dP4kI%2BVCGhkkjqFDskuDhcx8DlIy1tPTVTcJwoLCyaOmU6agOj3bFjJ%2Ffi1uxPr6NjbNiwCcmChJ9k5Eh1friOwSn0ChQPnDf4tj5MB6WCsrOctZiMHTs%2B1UlWZpalY2RlZqcOaucoCUjHOEpQaiYCIiACIiACIiACIiACIiACJyGBY9Ux5s1biO2fDLIoPQIuS8egaAg6BprDsCEjEAFGjBhNvAZiglWvBG2BAiIIAvhO8MHwX7M6zeqTTBeErhCawcE5s%2BdZsSF1b%2FelOkbdBuwH%2FMF%2BZiemK0jCSEyZNA1PEnwnUs1WrVzDYA4psfqlOkY0Gtu5Y9eE8ZModDJp4lQkEdSM7l17WbVO5s6eT8%2B4mqR6JosI98WLo6ysPHVQO0dJQDrGUYJSMxEQAREQAREQAREQAREQARE4CQkcq45RkF%2BAttDx867Tp81IlQKxuNXWOq3SqPxM6RhWss3s7BzLgcEKx7B0DHJfUNqjoKAw9bFyaJCHkygSUy3p2Xf37r1W54d8UyHF8sdYuWJV3VN1FQ%2FyXfTvn5RNFpllXq2snmTq8Hq81iXTppnBL6TRqDuR7QSnJPNjMJ26Paf2ubU1PFJ%2FWLNbunS5pYc47AdzhixbthIdA08P9JbUhdo5SgLSMY4SlJqJgAiIgAiIgAiIgAiIgAiIwElI4Fh1DCx3wj2w9HFImDJ5WkFBIT1UVVYhOAwePKx3r77FxaafxiE6BpLFwgWLuQo1AwPf0jG%2BivbWrdvonMbz5y%2Bkagn9s9ntdkqaconfH7A7HCUlJUMGDzfbzFvIWTQHblGQX0jxEUJL8ILgw%2BVILigMxJVwIbk3%2BUnPy5auqKysJGtoMnFHF2swJAKtdTrpas3qtbTBSSMvr4Cf5NbgWrOa6oFi7kLeTqJIuC%2FuJTt27LKmgFcGXVmDIRyGnBgoGEyTCimpAi5WS30fDQHpGEdDSW1EQAREQAREQAREQAREQARE4OQkgKn%2B3nvvoRIc%2FfRrauzDh43ETsdyx7%2BC2BAKoSZN%2B04IBcgFdHWIjsERfC1QHpAI%2Fq2OsXTJMlQCeiZpBtVOBwwYzKdf34EICIgVVEFln%2FgUpANUESJcGADNKiuriED59J8dGEmvHn04bt0rmSDUFECCwRA5PznLIOmZ%2FmlAs7JkFRK73UHLAf0HIcVwqluXHtyFDzVVuZa6JFyF5wahIvTAZ%2FbseSQp5RQbYgWSDgfpkHQfjIodvvPy8q0G%2Bj4mAtIxjgmXGouACIiACIiACIiACIiACIjASUXga%2BgY8KHu6YIFi1ADLA8HzHZUhUmTpmZl5Vj0ZiTrlZAfw4q8sA5mZmZj3eMgsSgZ6PFVnJcuMcM0aImeQP%2FWB5UAGQTFYOHCxSghHORsqg1xLjhgIGXMnDGbkTAeqwEDqJuhArmG1BadO5peGTRApsB3whoGRUlINMpxq1t6pgE3mjZ1Bg2mTp3OvnUVzfDoqBuKQgPiWSZOmMwltOGDP4blBPJVc9TxIxCQjnEEODolAiIgAiIgAiIgAiIgAiIgAic5ga%2BnY1jQ3G5PcXFJenoG9T6cTmfdSqw%2Br6%2FWUevxePCgSBFmHwGE41awRur4ITtEjtSam%2FOQj5U9g2u%2F9KxVs5VbMBICPaiNgt%2FIIWoDN6LMCrEhlE%2FNzyuwAlWsuzN4YlgOuSM3soq98k3qUa4iAQj9HzJg6yf3ok4K7ii0qZum40sb6%2BARCEjHOAIcnRIBERABERABERABERABERCBk5zAf6JjnOToNP3%2FEgHpGP8lsOpWBERABERABERABERABERABOoBAekY9WAR69kUpGPUswXVdERABERABERABERABERABETgGyQgHeMbhKmuvhEC0jG%2BEYzqRAREQAREQAREQAREQAREQATqJQHpGPVyWU%2FoSUnHOKGXT4MXAREQAREQAREQAREQAREQgf8qAekY%2F1W86vxrEJCO8TWg6RIREAEREAEREAEREAEREAEROEkISMc4SRb6BJqmdIwTaLE0VBEQAREQAREQAREQAREQARH4HxOQjvE%2FBq7b%2FVsC0jH%2BLSI1EAEREAEREAEREAEREAEREIGTloB0jJN26Y%2FbiUvHOG6XRgMTAREQAREQAREQAREQAREQgW%2BdgHSMb30JNIBDCEjHOASIfoqACIiACIiACIiACIiACIiACKQISMdIodDOcUJAOsZxshAahgiIgAiIgAiIgAiIgAiIgAgchwSkYxyHi3KSD0k6xkn%2BAGj6IiACIiACIiACIiACIiACInAEAtIxjgBHp74VAtIxvhXsuqkIiIAIiIAIiIAIiIAIiIAInBAEpGOcEMt0Ug1SOsZJtdyarAiIgAiIgAiIgAiIgAiIgAgcEwHpGMeES43%2FBwSkY%2FwPIOsWIiACIiACIiACIiACIiACInCCEpCOcYIuXD0etnSMery4mpoIiIAIiIAIiIAIiIAIiIAI%2FIcE7Hb722%2B%2F7Xa7%2F8N%2BdLkIfFME0DFWrVr1TfWmfkRABERABERABERABERABERABOoTgXA4PGHChEmTJnm9Xo82EfhWCfAQ7tmzp3379shr9elfmeYiAiIgAiIgAiIgAiIgAiIgAiLwDRIoKCgYMmTIO%2B%2B88742EfhWCfAQdu7cefny5d%2Fg462uREAEREAEREAEREAEREAEREAE6h%2BBRCJRVlZWoU0EvlUCPIT4g9S%2Ff1%2BakQiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAj8VwkkDIPPv9%2F%2BTaMjnT7aW%2FybQRy8hdVb3e9%2FXXewyb8OfO29RCIRi8US8fjX7sFIJOqSTQ34YIfm729uuF9%2FlN%2FAlczzi6las%2FzyPo9htl80tbr74teRn9R%2Ftfry23%2FJURbZXIO6H1oduSPrwTCfjfqyfF8CRodEQAREQAREQAREQAREQARE4L9DAEsqEolgUsXjcXZCoZC1bxlZfFtnaVZ3MyIxIxyPeEM5Wfmr1m3dm1PsCBm%2BhBFJGKbVHjcSkUQ8GDbiMSMeNWJxIxI3AlHDEzOChhHhk4iHo7FwKB4NxGN%2BI%2BE34r5EzGPEvEbMZ0QDRiRoRLicexrhhOE3DI9heA0jEEvEEkY0EacD81wibt4iHDYCwUQoZEQ5zR25PcfjCe7LxQHD8CeMYNQIBhIBTzwR9CcCTiPoMCJ2I%2BpM9uyKhP2hcCJKs7DhDxsRc8yJaCQWCSYSUVMsSKBGmJu1COxEk5v10zprEbNO8R0IBDL2ps%2BbNit7T0bYF%2BBsKGZOKWTEmR5jjRixcCLsD%2FujsVAkGgyHmVw0moj6o6FQAkbMK2S4%2FSY0CMUNn2HUGEaFYVSaF0aNSMTw%2Bg17rREIhKMhtxGlAXMFsDliGEcijMGkxIIkt9RorcFbpzjI2DifOhsOh2nAWWvjLD9pwE926NZqzD4tU7NmvOYnnlxta5GTIzFXg9kwJj7m0n%2FxCRsGnxBXJcJGuNbweAxvyGAu3ljQzxIk6I0ViRmxSJxF51cwEfeFQwhD5m2iXBQ0whEjFOPpYsF8NKQVz0MoavgihodT4UAiWGtEaoxotdkgHo3Fwv5AnPWN8xhFjHDAfNjiYYYYi%2FhjEZ%2FBWMxhhRMJ7hY0T8WSNwoGDB6wYMjw%2BA2X2%2FB5jLCXa8MhT23A5TKilUaYdak1DDePm2H4wuYAzcmy3kFmGI8EguEQi2P%2BcystLZ0%2Ff%2F7KlSurq6tZo7q0rVXQtwiIgAiIgAiIgAiIgAiIgAiIwBEIWCYqDbBJLSuV72AwaFmvlpHLEUvi4Jvjppkfjuzfm%2F7Cn1%2F5xT339h81odQVQMdAq0BIMK1oXjVHTRM0GvBF%2FL44UgMaRxDFIh6we8vzivZu27Fj65bCwpzc3H2VlYWRiCsWc0VRF6Ie09CPmYa%2BKURgFyeNXYx0zMMwikg0hlEdRmIIBaNY%2BtyFD%2FYsdr0pm8Sw%2Bc2X%2B8mNYZhSRpCRIVD4jLA%2FkeAaTNCI04hyM5eRcKKexLBI0TIQQ7BtUTwwXc3e4rFwJBpCEAGO1aHFytpPGfiY8%2BCyLHoLF%2B0dDseq5SuGDxi8ee16rxPrFgAxTGhvLOSOoS%2BEg%2FGQL%2BwLRZmHLxoOII2APxgOBNGTzEmjakTMkfhMCSMeMVwJRIxEqZGoMhJIIkYwmPAHDZcn4XKxXLWJcG08ho6BMBCMxlOrxn3ZGDBH2KyfzILN2rfO8s0RGtDSWne%2BWWs2DmJrM8dUJyn5gh2fz8ezkURuqgtoDFHW6AvdgnuYOoo5m%2BTHUjMO0TRi6BwRt%2BHxGl4EqUg8kIgmJalQNB4wSYCfFaUDRhA2JQ165MGImEoBikSYB8DUMZDADuoYrKClY4QjvnigJFJbFHPlR5w1Ub8pxiDBwScUjvMgIFPEw4lYOB4NxqLBOCKTKZ1EYvEgzzdXB33upKgSMpWnYNBVUpK3dVvFvoxAyQHDW4uOEYv6%2FfEgj6zDiFcZcYQmVpp%2FCIgrqCwBV7iisHLPll15Gdnu2lr%2BDfn9vqqqqv37948fP37WrFkIGia9L7bUimhHBERABERABERABERABERABETgcAKWxco3p7BVsVstc4qfmLSYqGzscNw6yw727N69ezMy9lU6ykuqC0dMGXn%2BFd%2BxNbM99MIza3Zs98SiYWz%2BGEZhMBbCpMZeDUVjvnDUH0lgW8ZcXm96ekb3Tt1fePLZJx954vGnnnzmzy88%2Ftwf%2F%2Fz26xPnTynzVnhjnmiCt9d4QWClIikkDfq44feEqqtqKypqiotLCwuLvF5vOBLBgsY5wPS7wHHCHCpyBm4KCd55I3rgvGH5J%2FBWHNPUPGk6bPDmPhyJR9AKPPGwJxHBEcTLq%2FJohMmi3ZiSSCQa9QdiNE4gZHAB%2BouJyNoAYhHjJzsYoRaiFCWrAac8Hk9WZuaW9RuL8wtDuIskbfAQ7hZxNAtEjDAKBh4NiVikvLjIba9JMARezTORRBy7HJZJZ4C46WARMCfGjKoNo8ww7BwIBRIen4Hqgprh8%2BLRgT%2BGMxbzoQnghIIDAxJQch39fn95eTnE%2BMk6Mjzrm%2FGwWZOy5sIUmA5nTRTJHb7RMaxvhBp2rMvZp1trypbQASJ0BsvVAtqmxwR9J7vn%2BBfqBYeTN%2BW%2BfNAmkoITl8bigWjCh9%2BJ1%2BcoKsw9UJjv93jQwWJIGXQXNZwOV0VppcPhjCBccDGEkLJw5jEdKnCuwGci5jZiQUv1YBFxv%2FGbeoUn7M2oLNhVnpfvqXaZ4KP0FjOfl0goiTiEZIaQkWAlEEPM54fu8BPyRYKekN8XDiIZcQQXIH8ktGXXjr6DBowdOzpj29aow266DOFjEw3nl5emFxfuLSrIqa72JmWzUCRRXlYza%2BrcN15989mnnvnzcy8OHTp0bVraipUrFi9ZnJOTs3Pnzj179jidLBqETC6wZUutiHZEQAREQAREQAREQAREQAREQAQOIYDphClqmU6YqBinljHFQbfbzVtjPAo4iFVrmVrsZGRkdOjQoX3HDgvWLNhatLXXlD5nX3MeOsYvH%2F3N3NWLvNGk6RjFm9%2BFYR0Ju6ocB0qqC4rtB4qcpSW%2BmmKfI23Xtj%2F%2F%2BZUfXH3jFZdddfYFFzZs1dLWvEmDM1r%2B5vknlmdsrU6EcD0gqAAVA3UiTgiAL%2BQurlw9Z9Hgrr26fdbxs08%2B7dS504IFC3bu2pWbm8uwMQEZcCgaDibMABXCTxyGUWUGX1ghGOY%2BR%2FDDQJHBsEVQSUozmLFIBqaJyncE%2BSCEX0QAqxTXCj5Y%2B5jRgQghCqa1j2dGXXuTfdMgTzo5AJABAMdiZR2kAcfNLjCcsdaRFMxYmJhlJuNfwE3xvnA7HMsXLuzZufOw%2FgM2r01zVxHpgqcB7aIMCVPd9G4J8Xaf0ZsxI66klEHwQhCZhaCSEH4juAoEowkzIgI3AD%2B6iBlSY6o73JRR7du3b%2BDAgYsXL66trWVI5qiSG%2BgYduqRqDtsrrLkC2QrngGeBEussHQMrqIDhBG73Y5WY%2FUQwS8hQeCGOUjTLMcc52PuIRugLCVYVq%2B5sqbggJCQdK8wFQozJoQ4jqjfCPs81WVrli%2F5%2FLN%2FfvzxR4uWLHHUYuMTDBQpKSydMGp8h4%2FbTxw9viivAM0J6SqcCAViuIIgRHEHU8FwGbijJHvmMpw0gqaO4Qp6M8sK95cVVgc8TDiBwxCaBT4fsYQnziVxJmC5%2BiAXmYoRsJOzcEci6HJQdSUYuUm%2BPBpKy8kYPGfamHmz0tP3RoguiRAwErFXVS9cvHjC1Gnjp05btGJVWY0Df5hwNFFSUt6v36Drrrvx9NPObN68xc233vrm%2F%2F1fn359J06aBFLrmbHWIrUu7Fg8v61vnmHGZsksaWlpCxcunDlz5vTp0%2FletGjRunXrkF84W1lZScuvN0ikGxTIr3etrhIBERABERABERABERABERAB0xRPvgjGRMV6xYzCYnW5XDt27MBs2bhxY0lJCccBxSns1smTJ996660%2F%2F%2Fntg4YPyCvNXLtl%2BZ2%2Fuf3Syy%2F5xyd%2Fz8rJNF9xJ216Ml8Eo8HC0sJ5S%2BdPmjN1yvxZ0xcvWLtze5nHXe31rUvbMHrIyH9%2B%2FOlv73%2Bgxeln2Jo0tp3S6JZ7fjVp6aLqkGkdETtA2Irp3e8LRGpdWdt3tH%2FvvZ%2FeeOPll7T73uWX%2Fuint73y%2BmtdunadO3euDzcD693%2BwVQEZuyJZXVi7CNf4LrAh30P%2FZluGUnjGvua%2Fs2PGfmCTcx30uXfjQTjD%2FndPo8%2FRNRK1I2mkbQtsTrZrAeGAykRAHocx5zfvXv30qVLN23aBCWIfdHAostsrJuZ7%2FqTQRK8fCdewb92%2Bcpf%2FvT2c9ue9p3Tz3r9uRezd6fHgnihMCoMWjI1IBmYdjeODqgTSARMjYQe2NQAMkNOAjiQ4GQS9IZ8zgSRD6aCYaoH6BhJ5QUVwlqyV199devWrYgPlhbBRKwVZ4ch8o1BnZziwfQXKBhZWVnkcCD8YfTo0cuWLSssLOTZYO54YuTl5a1evZqep06dysTpNhAKumNhj5HAc8Ts0JQokklRmGfSTYJcEiSRIJyHvCfJGTJYYi%2BYbTBuppAgfMafy0J%2F8Pfrrrzq6iuu%2BvSz9lk5efTh9vg3rtv85KNPXXbJZc8%2B9fSGtHUBP14nBB1Z%2FSBkmdoFCgY6BikyuJ0ZWGTe3Yxs8oYD1T6XJxQk5Qc5W8zUF2bYUKw2HHZFcGph7eFMbAlqiJl0BSeNiJnPI4GfBtkx8NWpCfrskUCtEa6I%2BkrC7opIsCYS8gQDcVw%2BuCYYLS0smTlt5piRY0aPGD1zxuzc7PxAAFAJrz%2BwZNnyB%2F7wUMs2bW2NGjVp1uyXd%2F967Phxu3btsrxZrCcKXBb85INmPWL%2F02%2FuywNcXFyMqxUKIarXe%2B%2B99%2FTTT%2F%2FqV7%2F6%2Fve%2Ff8EFF5x99tl8X3vttb%2F%2B9a85ztl%2B%2FfrRMj09navQPI9y5EyT9tOmTdu2bdv%2FdIa6mQiIgAiIgAiIgAiIgAiIQP0igA2CfYrpjT3FPhYWuQeXLFnSu3fvUaNGbd%2B%2BnZenHMfm4kXtyJEjf%2FCDHzz04B%2BWzZkfrnZ6CsoWj506Y8iY3C3pUWcw5kUTIJIk4Y3Ey1yeNdt39R05pkv%2Fwd37D%2B09cOS0mUty8yrJN%2BB3%2Bt01juryylkzZn7%2F%2B9e3atX21LPOffqFV3fuyTKlFIxarxlcYubTwJmBDAy11YsWzPjnp%2B%2B9%2Bc7rb%2F397Q49u3bp1eOzDu0nTZxIHAK2KjY7ljvWMwa5pU%2BYlq1p%2FR9MJIk2YprNydf%2FppRBz%2BYHaSAYqyXlp5lWNE4qhXjQH%2FEVlh9YlrZ6xYZ1hRUVHtxUyKWA4JF0YzBN9OTGz9QOZIqKij7%2B%2BGOMvt%2F%2B9regw64HGt%2BmD0YsEiA2AYUn6SiB3wLShOldkUiUHSjp273npRdc3NLWpLmt0d0%2Fu2PZ3IURbzARxEZnSmgy5NMgoMHUJYiw8ZD1Iek84MNMJ6LE7U14Cb7Aj8SzO3vfvpICL54zZOnwY%2BWbS8kbc9IvIET88Ic%2FfO2113i3zvt0jGjEDSxKGrAxC%2BsbS5bwE9rX1NTwMPDdvXv3yy%2B%2F%2FDvf%2Bc6FF1541VVXtW%2FfPj8%2Fn1OIGG%2B%2B%2BeYNN9zQrl27733ve4888sj69et9wQCOEe6k64UZVQLbEEoLHhGmooHUgoLhMiJeM7YHFxfylbAmfhYALcaMnCGzhTdUsG13n087%2FekPj33wf%2B8tX7rS7nCZASLhaFlZVc%2BefZ977sU%2BffqRToUUE0HyiJiRSqaHDUuNdgErS8dAlLCeBjMhbBzpglwiZB8Jm%2BPh0XJGqoqrNu%2FeuzUnq7qWwBBTCEoEvfEgQouVLoMnBbEjRLrRYMTrj3hKqos37FyfW5bnjuEBg86GXkQoSgJfGEaNahL2hqsKy2sOVNpLKmvLqsO%2BIFFByEQV1VX9hw669KorGjZramvYwGaz%2Ffbee9emrePBgD%2Ff%2FFvjb4n1IKW%2B%2F5d%2FXaxhsKBk6nj%2F%2Ffd%2F97vfXXnlleeff%2F6ZZ57Ztm3bFi1aNG7cuEEDc%2BR8s88RjnOWNldfffW9996LpoGrBj3wwNPbEQbPBBFFu3Xrdttttw0ePPgILXVKBERABERABERABERABERABI5AwLKesLnYaIZJi1WOrFFQUIBLBg7k2LwcsTb0jWHDhmGtP%2F7Io5uXrDUcMaM6Gi90hTIrE2WU1TArf%2FAmm1ScmGf2WKLI6dtXVJ6eX5qVU5aVcaAou9JPezfqhBlXwXvxmoqyCePGvvW3%2F%2Fv7Pz6ZN3dJTYXHLBaBHYnngfkdT%2FgwM3lhHnA4S%2FJK9mUe2JdRkpNVVjRv6aKOnTuNGT3a5%2FFi8pspITChcJfgmw9TsT7WETSD5AFcMlzYnsgCHCdwxe6uzsgu2rHLVVQYdFbjTRAIu9dtW%2FfUi09f%2F8Nbrv%2FRrW988PesohJiNriCDVZYaoCCkvWTI%2BxjweGG8eijj2LrXXzxxbyqRhBIEkUrQVEgPgRPELOSiumVgdqC1MKAEobH4ZwyduJ1V1zTsnGzlo2aPnTv7zev3YCOYWosSa%2BCZIgNfgKmZQ4zDH7LxmaffJdm6pGAv7a2ate%2BHW988H%2FvtP94yYqVO9K2Z2xOLy0oYVSsmuVW0aNHj0GDBiFE%2FOIXv%2Fj973%2BP0YrTSHJO5qRoxrzQKObNm4fWYWVsyM7O%2Futf%2F9qyZctmzZo1atSoadOmf%2FrTn5BBEDpwg7n55puxbRs2bNikSZOLLrqInjOzMsnlyoDxUDg4XNwVCOKgCkwyd2qAuBGDch3mUauEjBnrY%2FhJY5HUMUxto3hX5oju%2FTr9%2FbNt6zZ7XKgypMBAhUjkF5e%2B8tc3r7z%2Bhhdfe23b7l1%2BAkpYiqSCkcwqagaq4I9BtRv8MXCisEKBrAkic5CIJIHTR1LMyt6U%2FsbLb%2F7orl%2F99P57hw4b6ikpNqiN4kdf8ePWYnZDhAtDSVBExltWWbBh65qRYwc%2F9Pj97Tt%2FvD93TyThD8e9obiZ%2FMWLxsKdrMfJUsp46lgXxBfz31F4f07m2x%2B%2B3%2Bqs0xqc0qhh08b4Y9zzm98uWLjIy0P7BXaen9QTZT0z1rD%2F298MgGeDVeZf9GOPPXbdddchTSBTsKxHv9Geq1C0nnrqKbJ%2FkLyUPs0n%2FLCNg2VlZTyEqKCnnnpq3759D2uiAyIgAiIgAiIgAiIgAiIgAiLw7wlYxhTfGFPWhknLDnElWOKoFrwvttrwZp9THJ84ceL111%2F%2F8EMPz5s1r6qoinKppmZRGzXc5GjEVDSLbpIFwhsniIBX5OaLbdOqwdbDGcIsfsobePI58Ga%2B0qyEGa91Oku279myNyujstbpDyXTVWC3W%2FqD%2BSqdQhbBWMQTitSG4rVBw4fnhCseStuysU%2FfvpPGTwy4fearf9PUTyoY7GBLmq%2F6k2KIpYdwMJn0gDCT2qQUYMY9uINbFi%2F%2F8OXX%2Fvzgg28%2B89TSOdP83pr0rJ3P%2FeWFVuecappyDW23%2FPyOeUtXebBczdCV5DySOg%2BIsNeQAgDCcfZXrFiBJwYXoWMMHDiA7AEWt0Aw4PK6yOdpChMMkU7oBjEjKbygceRl5XXv0uOPT%2F7pwQf%2B0P7TDum7001HCyYUIvUoqUIiyDQOv7%2FG5fOEktJPMlzDTEZpJkM1821UO6tmLZl9y50%2Fuejqy%2B6774Gn7nvsj7977LUXXp40aRKaA8NAlSKrCSN86aWXUB7OPffcXr164UBirTUNEDHY8NMgoGD48OFWGg1WH7njlVdeue%2B%2B%2B%2B6%2B%2B%2B5nnnlmwoQJBAXgkzN79my0LCaLxME7ejrs0qVLQWFBmLwVBOmYAkPSH8NUWsykqabXQjKiAzGG4ZgRG6bPDXoDMSisEB8zgIZFLMkuGDdo5IAufUrzS3hq8FsJRGJ2n39x2tof3vVLW%2BOGP7zrjtnLFrsChNeYLjJEqJjBNGYMiRkahJSRrJNqimE8BaayhdMNihAj4ZFwxT1FjnH9R3%2FnwkttjRvZWjd%2F5oXn96%2FfnOARCuNHZCbZwHEjGKHsboxqMjll%2BT2H9nnyz0%2B%2F8f5bTz77VK9%2BPfPys83QE9K%2FRFiXL4KVrAeP%2Fvlw40Ak6nJHAl4WsNxROW%2Flor%2B897e77vvtr%2B%2F93R%2BffqZzx87r160P%2BJkyMzY3yPNtSRnJdWDU%2F4uNZ2PMmDHPPvss%2F5xRq8wH%2Fj%2FYWrduTT%2F0Rp%2F0fPgE8MQYMGAAfkE8M7hzIJ4c3kZHREAEREAEREAEREAEREAERODfErBsbb5pyTfmOSYV8QUEsPOGvWPHjiNHjuQ1PSIG0QQYWVjEOB4QIP%2F5559v2LI1p7jU5fIFK52xkppESaVRVmVUVgUrS8I%2BezCOB77pyo8da9bHJBdlMGQEAoaPF9%2BIGOVGpMCIFhmJcqSFQNTujbnJEol1R4lR3P8xP03pAAvRHFYoGnGHw45QpCaQcHuNoCsezC7IXbxw0ZoVq%2FDhN%2B1VXoCbVmvyg8sCIkbSOcRMJ0Gn2LSIKMkMmUgZeGXgG%2BGutA%2Fr0uO7bU7lDXTbhg0%2BfOcvubl7ps2ZdOGVF9maNyT1aJvzz3%2Fs2RfWbtzhD5Ab1BwIlEDEDjTIHbFy5UpcVtgHC5kxnnzySV5q%2F%2FGPf1y1aiW%2BEDQDJjqG2%2BOkDkog4HXW2v1uMkNisBM7EyMAhDHDtaCweNrMWa%2B%2F%2BRZ2bo9evffsTcfOJVlJAGcKI1QTdG3Zt2f5%2Bo3ZheVWTVtsdezlBC4J5Jag3GegdsriGdf99CZbU1uLZi1OtTVraWvUunHzF55%2FnoQYDI9hoEchXPTp0%2Bf2229%2F6KGHCARAabFOmYCTITOZmZkzZszAH4N0B9bzgO1Jy549e5IEg4QGlgsHAhcBR1dccQUKximnnIIzxnPPPUdcidfjiZJ1gjqwjMpUlpKCiykw4DqBlJFUMxA0zJwW5CalXirahRmfYUai4IBiDsKoLq9Zuzxt%2FvT59rIay5HFH4kVVVWNmDntyttusTWx%2FeBXt09eOKfWxwiTOoYZoYJ0AQfuxz3YQ8RAKDEdV%2Fjg2IJzBD5C5mPgjjtyK%2Ft16tPm1LNsTRrZWp3y8BNP7FixPuHCAcbsgBUmKMcVoSYvJW4T2w%2Fkvt25%2Fe%2BeemzAmJELVy7PzMouO1BasD%2FnQEZuyBeiDSVWHQnDx4U8e6aPCZ4fiYjdWbQ%2FY%2B%2BurVWO8qARckQ9%2B4qyZy2aP2z0qH%2F8%2FR9%2Fefm1%2Fr37Zmbst4RBFDAeHmhbagYrxQ4%2F%2F6sbN%2BW57dSp049%2B9CNW8D9QLw69tHnz5vTJHwcrGC01C54ZhIsf%2F%2FjHOPZwDS4cI0aMSJ3VjgiIgAiIgAiIgAiIgAiIgAgcKwHsNzbrXTDfxJI88cQTVmj8NddcQ6kCvA7okzZ8E2ZC7seMffvIOFBRlLt93uyMseNLhoyo6TMg2HdQZOgwd%2F8%2BoUljoovmRJfNjy5ZEF2yKLZkYXTR%2FOj82ZF5UyPzJkUXT4ktnBiZPy68YHxo6dTAhsWJ4sy4u5KgBN7Wk1ECC9GqH4EHR1LJMJ0PDDw%2FzJO8KydRhJn5obqkvOpAaSIUj0Wo%2FYERya6ZTNOUNXgtTukOyngGkvkZzHf2UacRp2gpCT9dJI6gHKfdMWX4iJ%2FfcP3l559zw2XtPvno7bSNy3sP7dX23FOxlxu0anbtrbf2HzrCQ7YKU8QwoSY5meoEHvJz5szBewH5AsnC8sf48MMPP%2Fnkk1WrVmHvg9FqzAyIL3A4qpctWTR0QP%2FZU6eW5RXE%2FeHaSntOdn5JWSVpFpyh4Jb0PS%2F89fVLr77ynvt%2BN3nKpFp7dbKKKTwC1Y6KJWtXjpk2bdOuDLQeMzVDSpyJkgEiVhv1rNqz%2FqFnH7%2Fmput%2BdNMt111w2dVnt7v%2Bsqs%2F%2BvBD0jBaGhQiARsrS0gIGTsZP7Ngww3g4DgTCYaNZlVRUcFx6yA%2F8cHA5OQglzMpjO41a9bccccdp59%2BetMmTZAyfvazn%2FE84MJRUV6euy%2BzKr847gnibQIxekFVMNGxKgga6BuBSDK1ZzK7J6E2lr5hqhBmoA7xI75gtLramV9Q7CV0JByt9fn35uUOnzzxlfffOffKy5qfc%2FrDzz%2B9dstGP%2FlM2DD5cbZAZDIFDNObAuULlxe6Y%2F0t%2FwiULU6augaHQOcKL563%2BJf3%2FOaSH3y%2F3S3X%2F%2BPT9gX7D5h6BzoYmVSRlXiQGF3ycrs%2FnLZ119xFK6m4E%2FTH4sFE9o6cqcMmzxk901PtoQ0JV51xw0P1VyKNEO18ZP4gUUbhtAnjunfvtDdzN%2FoIsh1ldLyhwM7du17580vXXHr54394ePmSZVZuTHgiMbFGfIOX5UBcsp40c4Lf9MYi8u%2BX%2FC141yAmsHyHKhH%2F8W%2FLPwdpiyzB3Is7opghYvCcWLcj9urcc8%2FF7eebnpz6EwEREAEREAEREAEREAEROIkIYLdibmBDYVWxEV%2BAx8V5552HrUppEnwzSPmIOWpZsjTGbI%2BZ%2FhWuffMmfXb7zQOvuWLxVddsu%2Fh7hee1s198WXW7S0svbnfgokuKv%2Fu9ssuuqLjsyorLrij53iUFl16Ydfk5Gdedk33D%2BeVXfbf44ovTv%2Fe9rTf%2BYM1dd%2FjGjDSK8gyz4EbAE4s4jASCg%2Bk4Yb5Fp6wlL%2B6T7%2B4jyWycZJvA4AxFEj6MYrMmhdeHh0YMgcJpFl014xYSpqMDRqWPXAfk6vAYvmrDV4Vvg5mIEscMMywD%2BaM4L3vU4P7PPfHQ88881rtP57lLZn7S7Z8tz2qNjmFr2uiO3%2FxmybKVlF3BzYNEn9bGY4HJuWHDBhIVjh07loQAcGPjBfSQIUPQfMzcnskNUEQMQC3g9%2BzZue3l5569ot0lj973wIp5izzVzu2btg0fPnr%2B0mWOcMiViFaFfbPXLH3jw3deefPVKdMm1FSVmGEXJGoIusPO6gMlBTszMw5U2T3kxLTiZSw0IaJPzLQOZQHH9GVzJ86YMnf6rCHte%2FZ497MxA4dRbtXyuMBAZkSMh8Vl7czlS770T83Ietat9bUa05L2yCDk%2BmAjlsRqg6BBdADpPYlPsd6to3QhdCCMbN%2B6bcqo8evmLgu7%2FCgSDNBuxKrMhBWmGGWWXCE1iRNjP2F4Mf6TFUtQO5Llbfgv1jsfbzRW4nDsLympDgediVi%2Bs7rfhNFX33bLOd%2B9%2BMwLz7vn%2FnsnTp5U66g1E6aSa4RngIotJPtEzTC9XAggMSN3LB3L0iLM8B36RdrgE6I4a6yq2r56w%2Fp%2Bo4cNnjR2R0Y2MUOmzwjPEYoIHaQUkaRjR5j0ny5yayR1DY%2Bxf132yG6jx%2Fee6C5jAobbiJMPhOfN5Xbv3bFj85o1Jbk5u7dufuOvr%2F3h0QfXbEpLCnDc34xFcdY6Fs%2Bb369Tt%2BnjJ1WUlkGYxwb9h5Cf5cuXoy2wXvwEO%2BvCKb4t5t%2FUN%2BuLpEBc2M9%2F%2FnMCSazsnUfWLay8KHhZoGqy4bxxNNIHPdP4rrvuIsEsk0KyILEn%2BVWse3GW6ieKK%2FmmllX9iIAIiIAIiIAIiIAIiMDJSQADB6MJAwqzFzMW1QJj5x%2FJjdx9vJTnuGXzWt9JSrzZLV7W96OHWzd%2BzWab2bhxUcu2oUYtI7YGUZstYrN5bLZam81raxiwneK3NXbbGjgb2apb2Era2Mpa2bxNGscanlrT7IxdzU%2FbculVkd79jYz9RnlpwlkTiUcwgfHYJw1lMgaELJ7JnI7EI%2FAW3iwWEY34gwk%2FP5PGKYpFLIG%2FSKURrzQidiMcSJj2qhH2GyGCOJyJhCNgVNcaVR7DzSlfhFoXnhgZHUnPUV40avjAO%2B647bLLL776%2Bstfeuulf3T6%2BOxLzm%2FS4pTmbVu%2F%2FNrrBQVFMaSScCIWNqUe6%2FFAqVi8eDEpJsiKmXqrjqWPlGH5P%2BChATHag9TMGBEOVhQXjR069B9v%2Fm1U%2F4HFmXmBWu%2BalWu6dOs5avLkYg%2FlUmJOI5pbW9KpX5frb77mkYd%2Bu2XlIsNdY%2FhdCZfD8LmiIU9twOUmPMRMWZkMkfAh2cTwAaASR7KeabjSV1vjcnjtTldBpSO3zFlh1n7l%2FrzrZ03ZwXC2NAps5NRcDn%2FgWWIa0J7LmQ4PABuzs5a%2BsLCwa9eulLRI2bOXXnopYSYEDpQUlyyYMWfFjAVBt1mEoyIRLjOipUYc7SgZOHFQCkg4o0ZN0EylgmLAdPCwwT3m4EoauaUlE%2BbPGTFnWpajCmGqIhZcsXfLPzq3f%2FXNv7777jsLZs9xVlYnFYcEMTXhQNDtcvsdtaanB5lP8aVAtkgmfEW6sFwwqFhiRhshJYTieE2YAT2JqCfoqagqqbZX%2BsP49pjnaYbmRG0YqJoyCxPgg9%2BL5Rtk7bgTOSt3Dv2419iOgzzFdkYfDLpCARcZa%2FOy973wzFM%2FvPGGzz%2F7ZMHCuS%2B99tJ9Dz%2BwfMNqHECo68odzWo1hKvU2HN27N6yJi03K9vj9bA0ZCC5%2F%2F77b0xu99xzDylNiOqq86%2Fs8PX5mkfok0VEefvJT36CLnEE%2BcLSLshiccYZZxA9REaLX%2F7yl7%2F5zW%2BowfrjH%2F%2BYsjXnnHMOMgi6hCVkfVVXtLnllltQRMkCiqyRaiYd42suoS4TAREQAREQAREQAREQARE4jIBlunIYa5dMfRQyyM3N5T07Vq1lCLPDZprnCAvxSOH%2BTe3fePAnLWyP2WzDG9r2NG5c26Bh2GYzGjVNNGgUsDVw2Rq4bQ39tqZRW%2FO4rVm0YWN%2FY5u7ic3TtEGoQeNwg5bupmcVt70g%2FaKroh17GDvTjdIyo6bGINYhYXpNIE0kfSdMM5OX7Gb%2BC2Iq8M0geASjlZgTYgsw7Ml%2BGUvYE5EKI%2Bw0i2gGE1Gf4XYSAGPU1hiOCsNTYgQLjSifiri9tCJ9Z%2F72jd5yfnqDPvv6jave%2Fcfbv3%2F8wfsee7Dn4H7LN6z9%2BPP2Tz77zGt%2F%2Bcuc2XMCZOPAxOW1OsELX7wfx9Ik0SXpBfCcB5pFDKufBBQY%2FsRu7N69m1wTZvVSpINIOBLwhTyuyqLCgoz9NUUlcW8Qf4x1aRt6DRg4fvbM8pDXZSRqEv59pVnvffS3ti0aXnvBmdN7dY4WZBkOu1FRlXBip7vDMS8WMbY1ngPmxPkEE16HK6%2BoYF9JTrGvys%2FcuZ%2Fbbzo8MCjyW5JhIxBgyRg5q2bJGuywmqm5HPYUmAcslYMLWf2NGzeS%2BwK5hq7ogVIm48aNoxgrOoZlxt50002oOjBB7%2BrcocNfX35lw6aNdmKOEoFyI1xl6hhm2hMz6QO5KvyJuDMUqHQzeFwnOI5CgGBVFYtQm4SVzcrP6z9uRMcRA3ZUFpQZwUojWBaq3V%2BYnZmenrt9V4AcLMgTfIgsCsdKyytWbtywcf2GqIc8IshNpnNOSsdAoDBvaukYZsxIAjmK%2FwYTgZAfpD7zE3LGIjVGohaXCjP3rM9huOwGwB1uw%2B42ql1GpdOocBiVDqPGaVRW58yc0ffVl4a8%2B5Y7c7cRqDXdfDxV0aoDK2dN%2BsGV38VUf%2FCh%2B8ZPm%2Fh5z66vvfO3tds3B8jtgn8I%2FJPuIp6q6lnjJjzz2OMvPvc8j9DKlStffvllq7wp%2Fi1IB4R74OqD4nTkBTIX6Rg3QjwIEULEOHJCDMaAgw0ZVKjBiscRwVNETq1evTotLY14IiKSODJy5MhPPvmEPDAsfatWrY7g10EtG3y6DpE7pGMc49KpuQiIgAiIgAiIgAiIgAiIwFcSMAWKZIAJdi6b1c4yezme2g4awvFoTuamj95%2B%2BEetG%2FzBZuvVwLapgc3ZqFG8ceNokyahho2ctgbltoZVtlPcDdv4GrYJNGgVatTC35jjNofNhr7hbNCs8pQzC9peuPuCK8J%2F72Cs32KUVBhOLNxgDAOccIzkK%2FGkzUq4AOIF7%2B%2FN8hxRAgowTjFTTTM4HnT67bXO6ojfbhB2gqjhipfkR9ZvSCxZYSxbYSxeZCyfa6ydFd88J7xxUdnsqav79FjQq0vemqXh6gM0dnqrt2XsWLx%2B1dKNafuLC2sDgZyCgvUbNu7euaumrCJKXQmz3AaJF0wpAAgYmNjsCxYsoJIpsQAcARQHsROpSYofC%2FUaRowYgc2I%2BV9dVRWPRSnRmQgTJJKMeiAbZTjur3VnZebMWbJ4xbaN9njIQcBL3J22Y%2B2Lzz56ekPbNa2b93vhj%2B6li4yMTGPHPiMrx6itjEVdxMkQEYOlbqo33oSnzD532sy%2FvPGXNz95b%2FbqRZVOO%2FZv0OVLuAi3MduFgiGUB8ZmbQwVacIa8Fc%2BBMkTrD7N2HAYQLggbwY7lpMJMTWbN2%2F%2B6KOPiBrAKKaUCXpOQUEBx7dv3%2FbkU0%2FccON1oyaMqiR%2FhOkNE3YbMT5W8AVKgsvu3Ldr37aN2z1uP4EmzoTpeEOm13Iz4CPmtzvWr0%2FrNqhXp%2BG9d1Xnlxv%2BGsPnT3pamDJEWXU4p9g4YDdcUdO%2FIhLfsntPjxHDx06cGKz1mnErfJI6BkuCP4ap5cAK2SepaKBFUZWE3LOkio3SscdjOF1GbUHcsdOo2WaUbkrkrDF2LUtsWBBbOjM8Y0Jwwsjg%2BBHeEYNq%2Bnar6N25clD3kiFdZ73%2B5Ls%2FvvzT392SNb5XdNMSI2%2BvYS%2BO5uya1bfTDZec16SB7Y5f%2FWzoxJHz1i4fO3vmvqIiUquYuhPDQMsIhgv2Zbz%2F17%2B0btrk3LPPxt1pypQpuCsgHeAggY6Bh8MjjzyCuME6HnmBjvUsjwFPLA4VKS%2BalHdEagdFgoqozz%2F%2FPKVRV61axZqy4l96I4ZH2Roe79GjR7%2F55ptU8qWOaqqff7sjHeNLqeqgCIiACIiACIiACIiACIjA0RPAPKfxF8aume0Tqyd1kB3LB4M2liGPeUtj0ipGyguX9Prs6VNbvWWzTWnUOLdZ61CL0zzNWpW3aR247Wbvfb%2Bt%2FP0DNQ8%2FVnv%2F45k3%2F3znNTcW336n55EHqx%2F5TeVDd9sfe6D2qcfLnnwi%2B7FHs155JThpopGZES%2FK9x4ojJPwMOlDgFUajPMOnVwWRBIgB2Cjcu%2BYP0oEQNI49Rll%2B4umjZ08dOjwDbu2VQYdEV6pVxzwLl504IN%2F1jz9uv9Pr4Qffyb45JO1zzxa8eoT%2Ba8%2Bt%2B6ppybed9%2FEZ%2F5YOGNyrDg3EbCTfTJgRB2JSInHlV9ZVW53Bqn0SX4HHDDCkTg5GiOBOGU1eJUfiVgxGngmUO6BQh5Y9JbJCTQyD%2FDCGvkCHYNvkoogaxBtgfxhll0hQwUv2UnmQOADekyIypyevLLSAkeF03Qj8Qfinoy9G9999tF2DW0%2Fatyg%2Fa03p7%2F7vr9L78Cn3YPDxiSy042II2L4iZMJ8Ho%2FSOxDrCrvQPfPO11z7dU%2Fu%2FsXI6eOK61M3ov%2BCdkwczsc9LtgsVi4lJSRWtyvekJoyRJbigfzYrOeAY5g2FKthqQHlEFhgjhmYMwyR9LAwiQjY98HH7376JP3L5w3OVhbYbrGIOAEcY9h9ULhGNVko1mFeaPGjRs6ZFh5WSWPnTeWqEzEio14XsCzZuO6kf37vPvGq48%2Bcv8rrz2%2FeXtakPq6cR%2FeNRFnTda69XMGDBn5z47Tug%2FYsXhVoNpFlpS0Ldva9%2B07fORoPzoGYgUPCuk6k%2FErBKqYjzX%2F4zg%2F8N8xg3co1%2BqNknbFXRlK2xycOi84dkhoWIdA%2F%2Fc9nd9wfPCC442nHC88XPX474rvvj33pzfl%2F%2BTmjJuuWXvpecsvOWfV1Rctv67dmAtbdjrF1u%2B8Uzb%2B%2Fvbivz4X6tfdWLvU2LVhY99OP734nGY22x133jZh7vQ8e1VeVY0jSCZTM%2F%2BoOTAWPRAqzs3u2aXDbbfedPevf4W3w969e5ECUISIzUHEQNAgtGTy5MloYuY%2FsW9oY%2Fm2bNny1FNPfZWIgbBAseBHH30URwu8iXjIraflCPe3GvBIsPqExqB%2BEG9ylMVbpWMcAaxOiYAIiIAIiIAIiIAIiIAIHA0BDFXLKrFMJ8totaxX9tkw1a02mLEEGrAFSFIRChsl1ZUDxgw754rpttNyml9c2%2FaS2sanZzZtlXHVZbHhfY19W42izPienZkjR497%2Ba%2F9Hnl87ttvl8%2BbZhzYbVTsMSr3GlX7jPI9RjFvw7Pj%2FtKS0v2z50zsN6j3us0balxOYiHISBEwUyGaER34Q2AI4hhBiU2qpmKYYqKGqwOTh068%2BpIrLjj3vLfff3vzzvWhqmIjNyswbFT6L%2B7PPu%2B6sjOvqGn1HXvb88pOPzP%2F3LN3X9xu1aVXzrr2hjWPPBZesdTw4MHhpiAINnBJwLtq%2B47JcxetTtviwpXAzAzJy32iEPDwp%2FInqTsRAMwNSpj51dXVBN1QlhQ4ILKsfmx5LHpCMHBOYB97kONJFYFclpGQxxshSoU6oWaBUP4bQa%2FxmzEwhMowLW%2FUXjy9%2FT%2FuPKXJ%2FTZbl%2FO%2FM%2Bvam1deefOGdjfsfODR6JqlRrAiYrhJluGhMSEZpsoT2b15S58%2BvUaMG5FVlOMLeElpYdYICSdCDDli6g8pe5MhkdaDb4ZkreZXPRtcYq56Ut2ipSVb8ZN9IkfwIiDpQd%2B%2BfQmcYbKWsMMpGrjczo1bVy9YMrEyZ7tRUWwUlhiZhcb%2BIqOsGv0gFnQFEv49pdkjpowdMXJEZVm5VTeVIjK5Ee%2BCPVvu%2Bf0957RtdU6LZmee0uT6C86d3LNr6EC%2BWaXXbi9el%2FbWgw9ef%2BZZF7Vo%2Bb3Tz3ry%2Fj9sWrfR5fGn5%2BSNmzZr1sx5AaJpcCBCMTqob5nJLczKNQgIwYThMZN44qtjN7xe6qniBpK%2FP%2BO1dzdf9%2FM937s8v91ZBy5oXXhOy8LTmxef2qzytBb201o6WreoaXmKs02L6manHGhkK2vRuKpt85I2TbOb2fY0tO1rZss9p03Gd7%2BTeetN7r%2B8bEwbXzVu6PO3XnvpGa1feemZ7VkZtfG4N1mF1aTIs2Sl%2F8Axx%2B%2Fen7Fj2rQJU6dOLiws5OEhZwVJPt96660zzzwTTwbUDGI6KHHLSn3VAh3TcdaFf7B4TZBa80s9JQgzufrqqz%2F%2B%2BGOkOW5qrfvR34KnhWeABwMvDkSYs84665AoksNvKh3j6PGqpQiIgAiIgAiIgAiIgAiIwJcSsExd7Bc29lOma%2Bo4liyn%2BMbyIpKCpBAVlZUJqkwW1%2FgHT15y%2BU%2FWt%2FhuXuMLixqeVtKgxd7Wp2%2B75brIgimGu5RKp7s2rrn%2F1%2Fdcc%2BnVl1%2Fw3cu%2Fc%2FFrr7xQWJVL6oGA4Ygazojh8htOMlkUuQ%2B81%2FWjS2666txLv%2FOze%2B6av2SJ1%2BfH7yBOKQrSU2D3Y0%2BjHxjRADpGMqAEo7Uqt7Lbh53PaHFq04aNf3n3naPGDXbk7DHys%2BPDRuXdeOeBUy52NT43aGsbt7UJN2rmatw0v2nLbS1OX3X2xdvvuicyf4HhrDEoFRLzu2OhXKdjzqq0Hv2Hjx41pZBCnL644QvH3e5EGBuYN%2Fi%2BUNRPelGLCSjY0DSwQ1PKBqfAa4GyOLMPzFg8FgwFIngP4N0RRGKIJkjnEeO%2FkWCcKiyxEHEmRDwE7Ya7omjyuJ433%2FJxizZjzr54%2FhntVre%2BaGurdjtuuyu%2BdJ4RLIUYlrjbCFBpJeHBg8NP1EdNdWWNqyYQ491%2FJBowc4XUOjxbtuwsKy1HTbFkFgSHnTt34i6CAwD7DOxLn4TUsLkqqcCY02Wm%2FOSbI3v27HnjjTeuvfbaDh06EFmQmi8d0hJ3FZevrNadGS9NN1avjg%2BeGO82yugx3pi%2B1CirIMoklPAUBMu35%2B1K37sj5Ma1xFRjcJcoCnkmpy392T0%2FbdOyyVlNGl3cqNFPTj19wit%2FDa%2FeZBRXG%2Bm5m%2FoNvOM7F51us7VubGvcxHbZ1Vf2Hz68oLSiqsaVvb%2BwMKuQMrp1RQzKofKQmJFRpo6RrMAbiVKzpsSw1xqVCaPayNiZ%2FdCL%2B864ruiUs1y2JuFGjSNNmgabNPY2auRq2MjVqKGzcUNH04b2Jg2qGzawN2robXWKr2Wz2oYNCInyNmrsb9KkpkHDkubN97Vus%2F3Si8ufeSIxevDqz%2F%2Fe%2B93XF8%2Bd5iC4x8oPysOLiGF9zNgWkmT4PSF7tZN%2FQBUWVZ4fSsDgCPH973%2BfZJi4ZDzwwANr164lQc2Rl%2BkIK1j3FAVQRowYQRpR4lYOlxSIZyHHBXc%2FcOAAI6l74THt85hzI%2F44UK%2B5TZs2h9%2Bo7hHpGMfEVo1FQAREQAREQAREQAREQAS%2BlABGqGV0m%2Fa5WS3UfL2easlZ9vnOysqihGL%2F%2Fv0RNFAWjOKSqoFDZl930%2FI252S3PLes1ZmVzdvsOf3UtBsvD88fZ3hK%2FeX5IwYOuODcSxo3aNvYhnXT9OYf3j592eISjz0cJ%2FIBJwtyd8bdQf%2Fi1avuefDBhi1aYu%2Bce%2F6FfXr1Ky8qTaY7oBXRJGgBBHfwZp0Yj0hNOMrLbkI98nfmf%2Fy3j05reRrm2F333TV9waSws9gozomPGJF3%2FU%2BLG5%2FjaXhGyNbcaNTSaHpKpHEjb%2BNWtS3PzT6j3f6bbotOmGxUlBleB9IIkRoV4WhmSfWSlVumT16wO21vqCqQcCEUuBMxT9Qg34QnGPVBxuLAjsUESlj3%2FLRw8c1mahdJNwYMVSrGMkF%2FJFBeU0kW0JIDxS57LYkdUGboIhiPBGKBIM4eEcQJMkmWRJYu2fviK1MvumJh24u2tblkf8t2uU0vyr7lF4m5Uw1vQdRMXeqtitYWFeUV7NmXu2tvUWaWvbIiQA0WM3NIPEEcjD%2B8du3m555%2FZfqMWQ6HWcQT%2B5RgAaIVqCpCCAA%2BJAwvtbiH7zA1GlgTQfQgTwK1ZXnhTlYQflKi5dNPP8WFALcTWtI5r%2BOTJjlePdjqSAglidwt3k87Ff%2Fs4cKrfltx1f1Vj7xpLF5lOMrjhqvGsFcFK4JOquAGDFfQcJoqjicR2VddMGzGiFffevqZe%2B9882e3d%2F7JXbN%2B%2B8fslz6s7jC4utvwac%2B%2FfnPbU9s0tjVp1cDWwnb6VRd%2F0Kd7TlllxBePVgTjdrxQTE8M7HCyhjICcm6gJJj%2BGIyIrKduCrOGvYavzKj1GFVGotJI3%2Bl%2F%2FA3PWT%2F2Nm0Xtp0RsZ0WaHh6bZNTy5q3KWhzavaZp%2B8559Rt57TZfu6puy44Y%2B8FZ%2Bw767Ss09oWtmpd3qKto%2BUZvtbn%2Blud5WnW2tmm7Z62rbZecan%2Fo%2Fdiqxc6923xVBWTw8SJDEYCWhKikMKFJBOWlIFXDs4LhlktOMruF5x5hPiXRbqVF1544cUXX0RVwNeFSBCoHr46x3SEW5B49t577%2F3SAiV4YlAOlSghfEKOqduvaswscCyhykld1eLwfekYXwVQx0VABERABERABERABERABI6SQNL4Nt%2BnWzt8s4%2F9a1m7lrXFT3rLy8ujBCdlNwvQMQyjYPmifg%2F86p02LXo2bLimefPs008raN1642ktVt58eXjBBMNbbs%2Ff36Vj5zNOb2eznWazYd2cdtMP7x43f1mpJ4A4Yb4rT34iocTGTXvuvf%2BxBg0oztj4%2FHMu6tutb2VhuWmEmskbKVMSTfiTORoTIWxBdyQeIOFEGDcOR9%2FOfU5tdWrzli3%2B%2FNZLO7K3JuKuRHmuMX5M8c23VzU919ewbcjWyGjYxGjaKN7QFmvQJN74tOoW5x24%2FIb4yHGUR0m47eTO9ONq4vKNmrrgpdf%2B%2FsTDf37xoRc%2Fff3vmxatDDuoZOENGQ6%2F4Qgn%2FBDCvsdytxBhfrKxDyg2QKU2i57VwB8K7tmf%2FknHz55%2B7tkPPvhg2ZKlYVxZzOqgTI48HKFozJcIOg2fPVFdZqxcVfvGexuvuGVp0zOX2ppvaNA819a2%2FNY7DeJxPAWhRFWWr3D6mrlvvfWX5x557JmHH33msUc%2F%2B%2FAfW7ds8vkpvmpm3nA7veMmTr3y2hveff8D5AuGxDCILMAfg7f8%2BGOQt9Ma7ZEfD%2BaFnwBG7nPPPcdL9ldeeQUNhGgaDmIak8DBVGkoWsqWdNXgF0kwzOIkRmFi98rS3%2F8xv811JQ0ur7JdUXLpndHOfY2svYloDYVAvAnSmpKmImjYfYad6iohj8%2BbUZ6flrF%2B1rIJ47v9c8zTT8%2F69R%2BWXX932qV3rrnut3Nvvvf9dt%2B%2FtlGTUxrjkGGztbWd8cPLu04bV%2BJB%2FKHYScIUClAMkllhETGoflJt6RjMkEeInKhoUeGQz%2FBXG%2B4gJ2NlRvou44UPE2f%2B1N%2FwMm%2FD75Q1OCOrQZt9jc%2FMufDKitt%2F6XvqKffrLzn%2B9qrzk3c93T71df3U%2B9mHgY8%2BjH%2FwUeJPL0du%2FqXnzCu8rc4PNmoRPaVZSetWW88%2BM%2BvBexPL5hlOaub4%2FPEYoU%2FmI4peRXQIriH4hJhJS%2FhXxMw9pGilFi8PCZCtDZLIXKRboXQvi0ViVaI8EIiOvEb%2F9iwCxYABA9q14x%2Fgl2w333wzuVzwo%2Fi3%2FRxNA7QX%2FjLceuutuJR8yc3qHJKOcTQ81UYEREAEREAEREAEREAEROAIBCxLqm4DjF%2BMLDYOWvuWxcqrfCpsYs%2BWlpYFve6x3Tvc2rrJLTYb9Uo%2Bb2Qb1azJlCaNJrduufjWG8PzZxnO6nBl2YTho84%2Bp52Nl%2Bi21u3OueZvr3%2B0e09eACXA9LKPma4MZL%2BIU1%2FU26vHgJ%2Ff%2Fusbf3DbYw%2F9aemi1R4nJSsROnBcwEbFDg2a1UioOxoNcxxXDgzXRCiyYuGCe3555y9%2B%2FpOxE8b8P%2Fa%2BO7yJK%2B%2F6jrpsudu40YupCaETaiAECCkESCUJIST0FkJoIaGGXkINofeO6R1sg6kuuOHeuyzL6r3e74yU5c3ut%2B%2FuJnn%2F25nHjxhJI2nm3Ds8z%2B%2Fc8ztHpVFQu4kqa%2BjFizV9hyrF4UZGZiEMZRg3n3HyGBvhWYlYyQ%2BubdKB%2FnqE1iioUQulh0pvScope%2BeTiUQQwuMFSok0TCBb890iRWEeulhsVG1wKxt0inplPYo%2B1O9ADOcNBwzA4oUI1TyA8mKInd%2BqU%2Fzjdjdo1Mdjz7Z%2F5WVUcp07dNq74xeLx8wB122iLlhdsJ0zuDSzju0aiX%2Bon77ocdteJ8T%2BGwk5QEgG4dX1HuC%2BFkv1lQ6Hpqih8vDFM7Bk7NG5c%2Bd2bbu%2F3GniF%2BMeJdy3GEwwPwVU1bWKTVu3topp%2B%2BWECaiFvaeC08O5eCiH32QAoDJwtnjRu%2FPizF9cBd5CawMkHH1efRU%2FNXjQ4D179mACoLjGhk%2BxFIZn8HDZaADyGJmgUQYsQanryY2CgW9W%2BrRR8RsbmDBloxjzh5%2FRa1eoVq6lZhMuGpaf4If0YDMMboOpoKj8WOzFg8cPJN6NLb58ImXhN1d69n4Y1T49KOZKcMwySfhQwmtNSFSArEXH5u3f6D524ZR7eakqFwxZ3Q4L65zCToa%2F6TEgyWBDe50uCwQquEpQGRYX7FysbMyqwQmewyF3IzV13kpFs76JwuizvMDtjGgVw1%2FB993SLObga6%2FHTZqiP3GSPk2iJcVUXk1huqIso%2FVltKqI3oujy9bYeg%2Bvk0UqhMJ6HsmTiR%2BER9zqO0B96hRVoH3GZIMEBsACaUTBgscA2cL2tiAuFvMBdIpO79aCv%2FIOByaSdwiAvHdG4RGk06ZNm%2F46wwBiBJ4V%2F9R%2Bs2XLlmgOAmHyYsS9c%2BBPPOIb4BWzYsUKtMb8W3MM3AUcj%2FEnQOY%2BwiHAIcAhwCHAIcAhwCHAIcAhwCHwewT%2BRSHjrXNxAKot1O8orOCKgPVirPPGx90e9%2BHbITwSTUhHQt7mk7EMmULIKl%2B%2FC7362y%2FdoCqkbtozHz4ZNuzt1jGdX27bbd7X3z2%2Fn2HXsFYG0FgY2TpWZ3GbWK8Iq7WuSn73Rvyh%2FScfJKYqNei2oPhDDcqyA6zBp5X1xrDqUQejE4F9w4nOFGNtdeG1i6fOnzpaUVSChA4KPw2tjsbdqxr0tpwfZCASB2HcPGJlGAuPZycCJ5Fo%2BIGKsBi6%2BQCtVMAQ02p3KLW2pMzyr75ZEtW2e6OotjGRMX3bvrJl8bKazHTqNBjt8rSCxyfOHNm5cyeCMqFG8Hp4AhCWhfmbfMXbdgG1A1CCISooDu9bSo369KVLb4x4q1XLVmNHv3%2F3%2FBUn%2BinQ6OB06WDuCTLGQxZRmG%2BotTT%2BqWnakjstu%2BzyD%2FiBIb%2BCx2BITe8e7itnqLYWHI7Z4apSqO7dS9y3Z%2Ff2LRsO79%2F1JCHeqFBTqxvGq5AelFRX%2FbTup9ZtW06dNiU9PR01snf48IjCGWeFV3Bi3jP3vvJilLGDDXjjYGw4BovsJ06c2LN798kTJwry8u1WNgQFnwL3xNJQTgcYDFAIdjfbJoReIacT7EKp7frptK59KqTRKoGPifB0EomudRu6ei0tKcH1mrzKCR1OV0PtDQ6TIT2nat%2BhSwd37s67e52mJ5p2rYp%2Fqflzf%2F96%2F4i4gMhv%2BBJwZW14gndf7b%2Fyh4Wnzh%2FJKEzVOdSwFKl3O%2BF2AeICZBCmBBpJLC5YZbDUgMFk1ttBD7E6DTawFwE0LtiSgAdTU1c9Lcm0LF3xpGOvJQKfdwjp6ZnDLQlp5yfr1LzpR2%2B9%2B%2FjKXZtc79IiV8ZktcFb1WByq9zOWqoroQ%2Fj6cxF8uhWxRJeOo9c5THHJH7Hur8mv3TLoda7bVZEyLrtyIE1ejxZEdICBsPDY0DRA%2FaLIj%2FX6IR65m%2FMEnZwygAcwGJ0oHVBE9DAgQPBGv3%2BPv2j%2BzDtRG5OeHj476QQv%2B2C2Zg%2BfTpmspdC%2BaPf%2FPvj8Q3FxcUgMWAW%2Bp%2BQGF4eIyIiArTY77%2BH2%2BcQ4BDgEOAQ4BDgEOAQ4BDgEOAQ4BD4P0EA5ZV38d1TGrLRFSiOUGeBzZgyZbK%2Fn0QgII0Ylsd4j2E%2BJWQyIeulgXe7DnDuOUFTc2h2oe3egzvr1x9ZuOD89z%2BUHsaL6TQ9i%2Bbn0pJsWoG8kkKqrKS6epdW5TKZjVqDol6rMztMDmqGvQDsMFhFhtsJDQZr8IlMCoR7wFEBzSZ4z2h3Nlis9QZttVFbD0EAezT0EWYLTU6rHPqeXBhsYnxcfL5byDMQRkMYHWFMjFgh8a%2BKaOne8AutqHQ5jEhUNTtprdr%2BMKNky8Ez38xdumreiou7j1SmZDhVDS6nIbssbfKCSW1fjomMimzbtu2kSZPgAwBCAIUnHsFmYAfMBjImbt%2B%2BDS9NbNhH%2FwXKUmwILymtkR89dWbn1h0J124ZKuuojr0ExKGg4cDrWcpyB2hC0Ojpncf6Lxdcb9xxm79sCZ85QkgBn1H2f5VePUc1dZAxOHCVDmoyWRrqFSpljVZda9Vr2XhaE4XNh9burtNpT58%2F%2BWrf7stXLEVfCU7AWyCjWH5xwtj3PkURigNwFTh%2F7OMA7%2BYdbhyDt%2FQ6PagZrVpjRZsDy1%2F8xmNgYugNBhiJeiQPrPCBZadcOqc8p%2ByXjbHNWydJAuukEgefmPlE4yujH35C7z8C8YH2F7auN1mpSem2yWF7ojbROqVFW6N0KWrpkzjdnAlpTYNqJSKnb1BWSMQakXQEIYMDw7bOW1yUnqrXK2wOuF%2BACoC2AT0qbBoJOC0X2jnsbpyPyQimy%2BG2I9qGwtJUa7MZzDYLNEDguMxo4VGxzS9lz1XLlsa2f%2BkDAb8pYa1b0LAiZkjTJk3eHzvu%2BMlLilqTqs6a%2BjQ3JSWnAjOMWmuoxkYRJltBnydBklEf0yFdRC4QZi0hPxCyu8tr6oRUTy4JunvAUyEDR0sdemCIgWajV0GpgOQw6Q1O2HkYHXB99TirAHCAjCgZL6UA5EGIwU91%2F%2F79cCD5K3cxZuCGDRsCAgL%2BgcdA%2BipsP8HI4Xf%2Fyvezc9bJRtgsX768Y8eOQqHwH37of3sKPUZkZCScdv7ir3Mf5xDgEOAQ4BDgEOAQ4BDgEOAQ4BDgEPj%2FEUAVhgoX7o6gL1D1YEP1ildQyI8aNQoCcSJiohnyNiEbiP9%2BEnSc539THPEgoEldr9fNw9%2FXvzGy8vWhKQNevd2r0%2BM%2BL5e81U8%2BcmDt4B7yof3Khg3PHjYm4%2BOvatdupumZaCyhNhSiNhg3QomPP1S6bGmMYtViM5gMFkRxUCu6L2xIFUVpbnHYLNoKed6zrPvZOU912joX%2BjRsWOy3o56nWc9Lh40s4%2FtriNjM8MwMoyZExWc0Er7KV1QaJMtt1dyxeQOtzLW4lMhYRcELC1GVyVVRpysrqlHkV1vLlVQL%2BYe1tq5iy8lfo7u3IUIikUqwkI11Z5iEwCkCcAENIIPaEzXj6dOn4UWAt9CLsW3btsTERGgzUKXivKwud71aW1ddZ4ZGBb0GaHOwuY1Wu8JuaXAid5W9UtY3FVGb8UnaT2ef841cxjBzCKvHSCOkvMvL7vMnqBpFNKQUrLGGZ4NQxbPSjxoejI%2BV5TfgLQm6Jys%2Fc8nyRffux0MWgnPzUhPez3iJC7zi5TG8b2HfS2Lg8cWOd%2BjB0kAQ4z0YlA0yS%2FCU%2FRQ6e0BoQPiA91gGg%2B0BYrt9HAZL%2BsMrU79cIJPuFgmzA2R2qcDCYxQMo%2BzQybHnAK1Hdc8KIxBlS21KalO47UDQDdoK4g5q0rnPnzUPfUMXGsbGgjC8UrHspjBwa2D0vtGfFsbfd1p0ZhhqoEkDxA0cUnBxYDDQWmJmdS02q9NiA9XjBI9hN1sVet2T%2FNyLd%2B5evXk37Vmmuq7ebdIjaZe6Gmh5tmHNqgtdug%2BRCUWwc5ASCY8EMrzhXfud23bEXG5w1jifXkme9unMaV%2FPjnv0QOXSW2Ad6qx2q%2FNo4g06e25Vy9ZZfoKbPLKVkM3%2BIXfHTrOnFFK1i%2BrtbqS9WPAP%2BBQzUmXBtLBDDM4A9iHQNcFxxQUeg20nwQB5eULcWQAS%2Bzk5OQ8fPoRSorS0FAd4R%2B3PPaJnBBPy%2F3fdRJ7Ijz%2F%2ByFr1%2FoUNUwCnBwNYWL60a9fuPycxXugxOB7jL8DPfZRDgEOAQ4BDgEOAQ4BDgEOAQ4BD4H9FAJU4chOuXr3qTYFEUYxFZKwdJycnfTfvu1btY%2FgSfleeeDkJesJvlyJo9YAfnuQb%2FlDomy72qRb6GXj%2BaolPmS8%2FW0yKZaQ6gKhkxCQlBgFR8nxLeE2fhXSs%2FmAifZBE4ahparDb0RNi02JR3%2B2CEyhIDFTqCBKFYsDqxhq8tchSl1VVVFFWKa%2Bounzl3JzFU0d%2BPHTMh2%2F%2BuGR%2BZmYWlBouK6JZbbSwsHzkh5W%2BoRqhD2QYckLKCFOHAE1fgTyAn99Imt6%2Bie3nFbQyVe%2BuVlGUxqgyWVUGGlNYbgBlJ4waYaVgdZRUlc%2Fbsda3dQThE5FYLBKJ4JqIZW4AAtSAhncD1ZOdnQ0jzWvXrqEp4MKFC6B6UKWi1kOzA6JWPUwFKlk8YUkM%2FIrZ4VS62CYHA1bkcZgZZhE6mvDU%2BOms635RMMdYScgpQrIJKXm5vfvEQdb3A5wIG3TiMZB0Yx9kDzxDrNRgx3fi3BHjotBrM3LSbt29WlZeDKkFTs%2Fb%2F4LCE5Uyez5%2F2zy8FNtmggvxvouD8RTjC89JsFXe4cbheB2P%2BB7seAtYfLGXIcHXWe02B9tggssAwaDV37qybXCf9xkyX0Du%2B4lNIpGNYeNKywKClBOnuhKTqMWMlpBae71BV%2B7WV1Eb6nqWx7AjwkOlsP28zdC8PRX42QhjBPvEiCrE4U9bdc9esMyWV%2Bi2G7Vo9mCvGrkfoBbQRuQZL1BRdjAhLGvEdiI5XJoG1aFTJz%2BcPLHfsOGjPxy7Y%2Fuu3IwsqxpJu2qQJ7Q4w7pxw9VefV4PFPN9CSMhCEJpyvCXjfm6IS6H1rpMuapnl5%2BsmPHjyjk%2FZiQ%2BdICQwFjpK%2Bizu871K9SDBueHh2UG8pMCmNgQ2ekuPXOWbnLn1FAtpg3rTGt3oAPKWk9ttdSN5hBwXibMMFAZuEy7ES0vsKsFmAAZj8Af8OJeO3fu3MSJE0eMGDFu3DiEwvxFPQa%2BGQKhHj16YNK%2BkEZAjPHyyy%2Fjjsbw%2Fa%2B3%2Fb97A0ONM4fuaPLkyS1atPinia4vfvGf7oAPBNf3736He59DgEOAQ4BDgEOAQ4BDgEOAQ4BDgEPgDyNQUVFx%2BPDh9evXFxQU4MOofdALD5uI2bNnvz%2FqvS6tmrfgk8%2FEvufFzfPFMReEgWt4zHIxs0RI1sI3gCGVjMAglmjFArWYp5USrYCYBYT6wnKTWIhfA79pfkiH%2Bg8n0KQUqqqh5lqrE9kJZpSaOrfDhkQTVHisVIIN9oB1YpVacTvz8cHzp86ePX%2FpwqXPJ4wNbOIDA1GelDRpEbF1xw65QouVflbdUF5a8eFnNSHRWl%2F%2FeoZXSEgmVA2QBAh5SiEp8xUWNo90rFxMCx%2FbXZVGqoaGw%2BKyItgV0gqTxmCrVFKlmY0ENTnr1Zotp4%2BFdIohAsZbkcXExAAT6DGAhtcNw0sCgNlAHYqVboCzZMmShIQEGGWglgQ3YMUSPXgGNBiAKLHAsxRMBL7bWapX5ylqSurlFdVV1aX5tuoyeuee44tv0qLbX%2FDzjfUVpEiFcrGoqstLrsN7qbwCpf4%2F8BhuyBLsFoo%2BHFTxbpqSW7Bl96%2FfzJu14Ptvd%2B7chmV9%2FDxKTohGvAzGi0fsgMfAu2AnQFygl8HLS%2BAp3EE3b94Mi0h8EIOOI71v4WB8BE%2BxYQcfQaENrsZsYRkPtmsFjSdalebMiXUd2rxPyEKG3BeJDTyhnfAsAqZSKirs3sv8825aV2%2Bwa7IU%2BY%2BfxdWUZVB4VoBjQaOFydiQmVYz%2B9ta%2F3B8xMkQp4Bn5wt0wuCCZp2KJsxwPH4Gxwkb4l3QPoSeCEgkNHY2ZhXyHdaeA2zAb2IVt91RUlj44RfjhCFBhC9o1qrtiuWr8rKe23QaaoUphwI9Ta59ex8PHDIvIOgdkfhNnngMTzpWFHxxyqKK2Lir2w%2Bt%2BWHl4rkL530%2Bcf%2FcJfVnrtI7D93Hz9p%2B3mqbOV03cGBNeGSpr09OkOhplPTJoB5Fy37UXr9NYWLKtrc47SYLDE8R6FtHXXlGQ2JxRXJ%2BlQ5RrKCdWL7M6jRhrrE%2BsV5gQWIATwzWp59%2BGhgYiGkGeUN0dLSXK%2FvDN%2B3vPgArG9aptU8ffC3YDPSYIE9k1apV4Kl%2Bd9Qf3sVQY%2F5DejRkyJDevXu%2F%2Buqrffv27d%2B%2F%2F4D%2FZcNbOACngQ0HYxs0aNCZM2f%2B8A9zH%2BAQ4BDgEOAQ4BDgEOAQ4BDgEOAQ4BD4dwigkoqNjUU4IwofVFsoeBFW0qxZMx6PL2WYDmLeBwGiNX4%2BSX5R2fzA9TzytoD0E5PuEvKGkKwRkCQBkQsYHZ9n5zEWQsyE2D1%2FNgIew1dNIguCW9eO%2BpjevGrLfmTWFFioWkENdfBURFEOQgLr1%2FCP0CFYE79traquvvX0wb4Tx7bv3L1m7four3ZBrwc6Ang%2BROIv%2BXrq1GdZBR4hvotWlFR%2BNr6yUWOlb4CCx8snJJUhJQyjYdglfgXh14Y1cs%2BdQzPvu50VLqp0UZ3NZTC7zPnVxRfOxx7fvjv%2B2HllbjnW1iEESczO%2F3DGjPZdu7Vs1QpNJePHj4fu4smTJ5cvX4Y2HnaFjx8%2FhvEp9Bjff%2F99t27dQHSgXsPrtbW1KFThggDJgxURH%2BAxoKUAlcFafrgVBt311McHLp87df1y7JXzN6%2BeqXmaQGMvuD6fUdLq5aSI8NQI%2F5JAmVIsrurysuvQHloLHgMiFZavgMgDpT8LEMtjoOEGGg%2BqbDCs3Ly1VacOIl%2BRWCJo3brFxk0bvefgbX7x8i1gITCULPPgaRrKyMg4cuQIDCHRI4DKGuYn%2B%2FfvR9W5ceNGuKC8OBKf9ZIe%2BDh2YEGJmYBrvHnzJoplvMiqIMAtKOt1B%2FbsbNViOmE2CSQpkkAjkTgJz%2B3DV4l5xY2iTJ9Now%2BTDA1Vt9MTdhzb%2FigtHlSMy6RXV1SkJd6%2F%2BPPme8PelvuF4yMUlJcP4xLwzERYGhCRPXCE7dQVqgQLgRYaKDLAMnkaaaCcgUEGTEqQYot%2BFc%2BUthmMDxLu9ezXjwj4hMdv2brdtq07FVUQtIDuMVCnktaX0NhzuUPePu%2Ff5Iq4yUVB0%2FOClkf9291%2B%2F%2Btfv5jYo0VTsZ9IKBO2lEhmdeyeNXaG4%2F3Ztk7DNWEd9P5NzJJQE1%2FSIBSXhAXk9WirXPEdzU2hDTVUrXOZHBa9RafT69ngFnctpYlFpVsPnd29L1ZerGOlI%2FAkBeuCcXOythjADRvwBCEGjQS0EyAxIG%2BAYSaEE3%2BRbQASGDUQDgcPHpw6depHH30EaxcMGcYLr%2F%2B7W%2F9fvY%2BPK5XKuLg4zH%2Boj8BIgMGDBunixYt4%2FIfN%2B%2BLZs2dxGHqvvNulS5dKSkr%2B1W9w73EIcAhwCHAIcAhwCHAIcAhwCHAIcAj8OwRQm6BofbF5D8dqOwQY6NlH34S32kL1inTF4JCQFo3CJvbodPD17mciQ%2FP8w8r5fud9eD%2BFkzlNyKQmZGGM8GS7wLSYqKKWTUuaNi2LjK6IjFa1aa3r1LYmpomqcwddlx41HXqmdemd%2FslHVdvXPTq0OT%2Fvnomq1GyIhZU1lESlBT0G6j6vFaYV0Z42nclYUFxy7uzFVavXduzakfAIEaDwIz7gMaZMe5SSqTdZrDYTLc5XTp1R07hVrY9vHcOUEZJLSBVhDAKxhe%2BjYHwg1XBPnUWT4qmtwqP617rd4DEMRy6f6NWvV9OQsF6tO145eNqtsWOVX2NzPS%2Bv3Xfk%2BJo1a1HyIwQEigWUhK1atYL5APIgsCqNugyV3YMHD6BkgHwFhyGJEnYHLF0A7gHdMVDxg3gAPWNDjCycS53lDfWn791ef2DXxr07d%2Bzdcer47oK4S%2FT4cdfHE8sbx2SFBGcH%2BRQL%2BJWEVLzUyX10P1VUszwGMlwc1GKFRgItHmhT%2Ba2vxKF3JidnjfzkM75UIhDz4FwikYhGjx6NzgIMHAYXjyiZ%2F2GUUSkjfDMiIqJp06ZoliktLcUra9asadOmzfz589Ea4%2BFh2I9jPODxiR1MDCgxQF%2B8%2FfbbrVu1%2Bvqrr9B5BKEG3oSdCJQwps3rLrdps0%2Fof9M3qtwv0sL4OghxixmDmJHLAnSv9Hdt2GEryr9w%2BcyqbatvpyRYHQZdTcXDSxeWzJrx5YD%2B%2B1rFKEMauxmhi8c4%2BQyCZoyEqfUNKHqpl%2F2n7TSnmJrMrJGIBZIMKFuQagPbUzuybcBjwK7DDrjdLotGe%2BfGze59XiViIRFL2rZ%2Fec%2FuAw1yZMKi8wQWGQpaX0zPx2YPGHqLH5zNCy0SRGczjR9Kmh9v2mFqREQboMdjbT9bEfIZX3ImsHWFb0cTv4VT3JhKwq18XzWfqRPzqwIDHO%2BPoqcOO9MTyxKupV69XJ2T7zCaAROELEpKwRckZOYuX7Vt9owfY4%2Ffeno3vbZE4UAMD1ifv3mS4I4DyPCyQKUP%2B03wGBBOiMVimGGWlZX9u7v237%2BPIQOLBXoKhAZ%2BBXe0dxD%2F%2FSf%2F5RGYS5DusGocz4ap%2Fq8372EvHnEwvuFf%2FgL3JocAhwCHAIcAhwCHAIcAhwCHAIcAh8C%2FQYCtVT21FeoL7HiPxg6qFRQd3uV77CCI89dff124aNHq7xcm7dhQ%2F9O8jK4d68OaGHiBFXxpho8owY8fFx1QMrx%2FyTvDSj98X%2FvtXPmCRRVzF9bP%2B8G%2BcqNz607jji32g7%2B6j%2BzTb1qdu2z%2Bg5%2Fmn1n6zY4Vs5NTb6JVwEjhE4F1fQ%2BPgdofkgz0lZhdqFXdCMDUG%2FKf5%2B7auWfJ0uU9%2B%2FWQ%2BIuFPnypTNi%2Bfas169bfuPPgbvy9J0kP6xPuGOfO03Z4pcbXX8HnwR%2BjgnXJ4OnEfjpxaKUktDSklfPr2fR%2BHDVWsa6PTi3K28q64m9%2FWuAT4iPikTCJ7Ke5i2uzy8EToDpHraxs0GIhG%2FUgFA4HDhyAyYC3zQT1ZlRUFCIbUHWiYIThJw7wdpR4SQC2Y8LocFpRr6JihS0CzDIQWurWOu1FWmVWbXluTWlhZUFdbb5NUUjj7ri%2FmlUb2bI4IKDCT1onFKr4wrpuXeGP4a5DaAY%2BDzIDDT4YLpgw4HsNbvhjwM7T6Eh8kDx01AdEKGAEWNMnAQGyd999F4vjGEEcjUcvoYF9nJh3wzkvXbpUJpOBkAGhge6houIiZGi2btV6%2Frx5hQWFmAaYFuyPQTbAunqyShB8DxiPnTt2fDd37oF9%2B6srKuCg4bBYXaAX0lKs82Y9a9Y8RRJR6dvSIG3s8PAYLnQSIbVEIKwPbqIfP50mJt06cmz9pg0XHiXoLAZ9VeXZnTvGvjX0teaRPwUHlQWEmgnPRIhDwNiFxMQjcqm4KKyF6ePp9NYjqmctTXEmBjtCSMzIsoEbrNuOthT2AmGyAYoHJhSlBYXvfzpWEhYsDQ4Z8sbb167cMmkNrG%2Bsy4gRdzeU0iuXng964zKPn8bw66QBCmFIhU%2FkVWnAIoQIE%2FIKITGEDCBkNmGuCMJq%2BE3NvAgLE2LgSWtEvDwZKQrk1Upk2sZta94ddXX8R593bvtepw6Xdv1qb2gAZJi2sPesctkTU7NmT1%2FULLpDmzZduvR%2Bbe2uXwvUCoTPWpxs9M%2BLEcGdhYybMWPGBAUF%2Bfj4YESwoavr39y03NscAhwCHAIcAhwCHAIcAhwCHAIcAhwC%2F60IoFD1khgorFDhomjFK78H48XrWFGtq6srLCqqLshz5aVZ921O7BzzTBZYTKR1giCVMKyEF1gdHkO%2FnP2o3xunh47QnTrrTEm3ZeU7M4tpRjnNr3RW17gUVbS%2B6Nbe1VPeffXdvu2H92k%2FZ8anqSnxMI%2BAAMKGVXPKpnOi6GSbJ7Bsi3YMVO%2FwmDBbdIqG3Mzc5KTUi1djv1%2F53eiP3vxi3JjlSxYtXPj9Rx%2BPHzxk6Nsj39o8acLzMR%2FIW7crE0ugx1AzBD6TSp6wUuKf6RP6QBr%2BuFE7%2FVff0vgEt15OHRCA6NxWfXZu6rjZXzK%2BPIZP%2FCTiyZ99mXE%2Flc2YsKId4X%2FQQAsAem28PAZaAMBjQMwAGQa6MLyIeZHEPssIoe7HVemsJXmlGc%2ByyiurLBarAyaZbpfGYcuuq0ouzs2uLq6qL7Pb66lDQZMe0CnfKMKaVPv4NkjEWpFAyxPWdensPrSXwj0D8GAxH8igu4T9drPLZXTDoxRnaHDmFpRNnD1XFhrC4xM%2Bn8hk0mnTpiHBE2OHFgY8%2Fp6e8p4k1tPv3LkzY8YMeHpgB5cGHmbLz1s6dui49MclZSWluAoILTAl2OPx5yU1qBvfWFNdXVRYWFdT64A0AvMFaSMI6Lh9w%2F7JqLLQsFpBmFXQxMk0chCpnTBOwjgYYmR49eKAmgFvqjbtebT7%2BM6fd1yBC4fbVZ2XM%2BezjztEh7YW8ecIBc99%2FCwioZlH3ALGJWHMYqIU8ap9Gxn7vkePXnIrdQj2QCxLPbVr3JDuQJbh6d6xYeKC4AG0kL%2FANtVw%2BsL5CbNnfTLhq5%2B37qosrQHXweazoq%2BEqqm6AjxGyqv9TqLniBCdAKEqEp3I%2F7lf0Fk%2F36U%2Bgo98yAgxGS8kP4kkl0RBebwwBT9M6RNS4yPL9hM8DmMyGonqJAEaUfT90CbfBsm6gPrg8zZP%2BbomPQXJO%2FgZPXXXuxyZWQULpy7yFwSxyiFfv%2FE%2FLkisK1VQh4l1fGHTfzAu3rsP4IN0mjVrFtiMjz%2F%2BeMqUKSDNfn8PcvscAhwCHAIcAhwCHAIcAhwCHAIcAhwCHAJeBFCiYoPmHA4PkFvABAPuB6jKsYGy8NawWDv2Lut7P4IXWbJBpyo7uHt%2B08gpfP58wvvVJ%2FC0b8henvRykw6KGQvWdu05uWePnEuXqVLHZn9onbTBTrXIykQFblY0lH6z8Cu%2FYAZFd6Cv8MORw5PuJ6CfH1aW8MNE6wR6SrRWa6VaVWfQwU4AyZ7sojsbsYlqFMwGFBv28uq8hIQrD%2B5evXLu1Jh3Rwf6R%2FL4ErFUODgq7GS7DgXhjcslUpWQp%2BdjTZ%2BvEIivCYWLRcJpAvEi%2F9A7n32tv5fgNsOOEfGYFqozVJcVfbd0njAAphvEx0c8%2F9u5Rc8L8Fvwo2BLZZAqHjID1w41Apov0Fjx2muv4XHevHlwxUQdysLiyZ5gOQbPZjFb8p7n%2FLrll9lTvxn%2FxYQZs785cOhwfn5B5vPnW%2Fft%2BXLu7E%2Bmfv3l7KmLli%2BIj4%2B11OfRpEQ6ZXZ9cESdQGIQiUwCoZbwqzt0dO7aRstLqNnuQoX8G5WB80KfidEJn0%2B4XJrdGp311OXrX06ZMmToa4MH9R33%2Bafnz8firHAiKJZxSv8w4fE6KmiMO9pk0CyDqhlPwVNdv3b9qy8nnDt1WqVsYD8COw5YfeKK2F22OYZ9BBbsH7vn7ZdhuSa9iZ487hrQS%2BMr0%2FADzCTISmRO4uMiAogrnBgFkUAu9U9u3Sn%2Bk0lZB2Nvnrpy61FKeY084caVPi%2FFSBkSzpCJhDwWiMxiqV0A6oO1hLUJGQ0PVEZgQ5ve9o2%2F0spal8WkozCgcOnZoBkrm8lrsXgoFlBfuEw8sK4TNUrFw4z0uMdP8gvKHbAQwdmy8xZpNBqqraZxcVmDhlxmSB5hTPAUJQIjz6ckMORJkyZH2zefHhMywJ%2B8LiZjxILJhL%2BeiO63aFfV%2FzXVkEHVA3pmdWv1rFlouV8jhU%2FTixL%2FLwjpTUg%2FPrNg8KvpsSeoBUYYoOPctVpNbnLW2Q37Jr899q0Bw94bNWbfqePFetwPDhiBescFbAbYJDR9oHWrsLDw%2BPHjsLBAm9LRo0dxJ%2F7DkHFPOQQ4BDgEOAQ4BDgEOAQ4BDgEOAQ4BDgEvGvBKMBRycK1Dy58oC%2BuX7%2BOeurUqVMI3YDtA4pC78Kxpzpkl%2FXZJgWL1apQXti2s2NIkC%2BPBPLJAD%2Bfj3xk0OTPjm598utpH%2FXu1a9vjzOnT1q1JngbunUuNswUUR3sruVuYeqAie%2BTAB6RMJDRj37z3SdXEyiSKOAsAP8Doyvpee6vZ04v3LFpyb6tR%2B5eTK%2FJr3eo8Q7rkgl1BGgO%2BGa6oNA3WVR1V0%2Bd7Nu1N59IBXwxT8hrR8iORhG5oVE1Mn%2B9RGhkiI0nqBQKVgtYx4MghrQXCuaOGJF695bFaXbZbVRnp0oLHDLOXDg9cNiAl7p2HPh6%2F7PnzhgNRshC4GeAP%2BSnsA4RHk4AtScKz8TERCygw%2FOzzNNRAkzwLiBCigdoHy%2BnoVapD%2Bzd1yKimRCOkzwRsjN69ulz6vTZW3fuTpg%2BPaZ71xYvd2zXrfNrwwfs279RW%2FGMJt2nk2c2BEU0MEK7UODgCw2EXxnT1rplI8tjWNCfwtbinr4ftKdYbfDFsJmpBxCcap3BnPr8%2BYVLZ87HnszMSAM1AQYDkxyjjBPD6XknPJ5iB0%2Bx4TyxeXfwiLcgtLgXF19bWYVWERv%2BcF3oJQH3ZEPrhgUpqywngiPxbfhJVjfD0kogoKjW4Ny2xd400gF5A19qIj5WIqWCICoLtgsEdgHP4COq8ve7FBTyU7P2h6csiD9%2B9cKFu6dOnd%2BwZnmzqBCYnYTzyZeE3GF4DTyRyUNiuAXExWcdYrVEUh3UwvDN9zQr222DiQoiTZ0ICLG6TTa72Wa14DxtdiTDsMEw1A6zDLvFade7nCZWT%2BLhX3CebCorZpiaGuU0Jbl09IfJEoESJAZD3AhV4fuomjZXDR9R%2Be2Miwsmjh%2FerX%2FHyFdjGg1vE%2Fldr1eezptj2fMLPbjfvXObffFC5chRVc1fLpI1OSvxnU7I656%2FKS0j7m9dQ3V1Wnn5teuXtmzZsnXhsr0zFu%2Bds%2BTUhm1Pb9xugPUrhgJ2KVCOeGYLfFRgsrp%2B%2FTrEf2A64R7E3YeOEmh%2B4EPC%2FQfFIcAhwCHAIcAhwCHAIcAhwCHAIcAhwCHwTxFAtYuqfPXq1bt27cKiMJaDt23bhsLqxo0bMH58IX0Hm4E1YrYEQxuB1VqVU7h5wdII%2FwAiJkRC2vtJh4l9BhIyPqLZngmTRvXu0fvVrqeOHbE1wPQCNhcgMdgCDhyE3m2%2BW5jx2qRxxN8XcRKygEbzZ36f9zDbrYIPBhvBUVoin%2Frtgsh27cSRIeKIgGY926%2Fet7lYWcJmVMB7AEvubM6mlRXvo9dCVR93NvaN3oNExJcQ9FSQLj7iozHti6KaV0hlWoHAzDB2IqzgCX7kM6EMG2HRVCT4oGev0wcP1aoa7GYHVTmoBuflLiovPnHx9L7j%2B89fiy0pL2Y9IZwuk9VlhR7jt%2FV8B1bPgQNKeRAX2AenAUBYYgedIfX1yC4BF4Qok%2BTkZISYYDt%2B%2BGjfbr2bRTZpHN2keZs2oz74IDb2QnWN%2FOmz9F8OHFqzZcv2Pb8eOXkot%2FCpw1ROk%2B7RKTM1oVEaRmhjiANSAcKvahNj3byBlpWwuSRouWFJCIgLcFYmG9u%2BYEdah13v0BvtKqtdZdA3qORaTZ1Br8G5eSkLPHp32A96%2FDFw%2Ft5X2Dc8Gy4Bx7PUhtNlt9hcdhABVq1ag3RVvGYwGVPT067dupmc9kyt06L7iJU3gEkxmHT1KrNKy3I99SrTkh%2FkYmhIiJHHN4klRqHMGRpNX36F9nhFGeJbJRaW%2BvgeEYhGQr3gF7xu1sITh88d3XNw3cJv24cFBBPSkpCpDHnAiGsFftU%2BElvLUNok0MXH8BEjEdZKwxUjP3Gdv0rVWqvD0kBtWrfZ5sLcAg%2FjNJkMeqPOZMP8tLBOsQ4HAkwMbpeZ7Zn6PY%2BB9FMtNdXRtNSyDz5J95UZhchG4VsJXyP0s%2FcbTH%2FeRnMynNXZTxMv7Ny9bs3mZQd3%2F5xy5YK9II9WV9OqalpaRtMz6ZmLxlHj8sLb3vAP%2FlnEn8yQrwhZFhb4ZPF3jpy0uHPHu3bpJPOTRUplbUS%2Bb3XscvaXX7W11RR5wlBiQFTk2XA3wW2mXbt2%2Fv7%2B6E6aOXPm2rVrMXkwbRCIA20MO9LcxiHAIcAhwCHAIcAhwCHAIcAhwCHAIcAh8PcIoIpFJZ6amgruYv%2F%2B%2FSjG0YwAs0psWNBHzY4DUHWhzoXMALUVXsFT1LCK%2FJJtc3%2BIkspAHTAi0sPf92Ox37uEzGnc%2Bt73yxa%2F9964EW88jo11K7QwonSbjRa7zuY2uKkBgRtVBu2UH1f6RTSX%2BIQ2b%2F7S0b2xhlqLV4yhrzNfuXS3z4AhRAAVA0MQHhEinbVoZk5xhtWh9bQUePQYyNBkPRtN1KAtfpoyZ8KMlhFtfaUBgSFBIzvExL8%2BtDqmY5nUV8NaH%2FBshF9OeCt4pAlDZIS05glGt%2B%2ByeMqsq9dvW%2FVokPGkoiDG02mvM2pr4MFhVJvtJsSKsJ6cUKA43CZPMgPICuDAXr5Hz%2FCCDcAr6AtAR8DAgQORY9K%2BffvXX3993759FaCBiktvX7q%2BefWGmTNnL1390%2B2EeHmdAg6VBqNFqdLW1jfUKuoVKrnZoXQ76%2BjTeDppujosWsMXWWF0SRgrEVS3irFtWk%2FLil%2FwGJ6WDpwUeA2zy2kzKLU5z%2FLiEh5fS0h89jxLr1chsgOjioHzDrWHqPifffAVGEcMOt71vuUdYlwdrohtGrE7oPXAI9tP4mFsMp9nTZ0xvdMrnafMmJ6Tl4sC3G611dfUJt6JO773wI0z50vTc0ypWQ3TppbwePWENKATRMav8ffRt42hn31EF8ws694220%2F6nMffRZh3COkiEsx8%2F8OEW3F6uTzr4rmRzaJgrTmIkFWEl0eC5b6N67t1dk54z%2FFaNyOCSwjPxUjr%2BYF5bbpqf1xPS%2BXIXfXkwKC7Cc4p2MVpwgHEZnXB%2BRMkBtJMrOCboMeA5SYLA0YM9BerxwCPoaMmBc1IL%2FtkXJp%2FoJZA9yI18PzLxcGKAUPp7v20poratUpzQ7lGUdSgqFKqLGiZsThdesxfCzXZKCRGhRXulT9XdhmY3Khpgn%2FAUYYcJuRSaHjupElFB%2FaunzWtbZsWApnERyYJ9ZX0eqXDvv076jU1uG1gS2p1OSFrwRljdnz33XdSqRStTBKJZNiwYXgKFtHbaYID%2Fv5O5Z5xCHAIcAhwCHAIcAhwCHAIcAhwCHAIcAiwZSyqWlRM8EnIysp6%2Fvw5nnpxwVsodfEWdvCIyhctJ4i0QJHF1vKoeavk139a30vmH0hIBJ98Ehy4wq%2FRPOLzS8fu2j2HHq%2FfeH79an1WJlUZqMHqthn0VG8Go0F1VodebTTfe5qzYu0vX038bs2aXYW5tYgmYZ0qtC6z1vEoMXX4myNlfoEyf38%2Bnx8SErB82aLy0mykirAyDNTf7B9KWL0bXgRGva2uIfFy3E%2BL10%2F6evrkmdOOLFus%2FXGZacCQKv9AjVhoYtjwi3JCNjGkOyFtCB6Zz5p0%2BP6Try6euWjToKnECcsEVJjwI4B7gokiAsQO%2Fw32JVh1ONw2o6UwvyAtLQ3dN0AATA4gAibYAbfjpQWgxPjss8%2F8%2FPy8OSZInfjyyy%2BfPnliMZp0ClVtWWVObl5%2BRZnWZGK7NzwuDqissYMqG%2BWtAx4i9lr66DadPEPVqImGEcEe0034DiKEYal90waIVKjFxQpRvD6fIFlYXsOi16nuXr311WcTB7w2rMeAwV9OmpQQf8tsVHtKd3YkwbFgHL3cC55iNMFXYPO%2BgqvA%2BXufeqkq2JCCwXBbWScT72XKFXVXr1%2Fr078fLq13376xFy5gtpQWFa9duer1vv17tn9pcNfeY99879QPy%2FM%2B%2BaTcV4JOjToBqfQh2YFCRb8edNNKeul43defZDaJTiO8fYR8QMirAmbuyBHZ9%2B9Ro1Z38%2Bq6rh0m8sgihlzgS8oEjSsbtbfPnEZP77J9PRaJLU4idvNkenFQUXhr3Rff0OR8qjZSG%2BsVYnHa1AatskFhMhusTrMVRiFoLbFZdMr6WkWd0mxCJxPLY%2BAPcwaeImwEDgRBSvo8q3Lc15kR0fVimZJI6iQhBYFNSoe86zhwhNYr7C4LEMQf4lPhtonAHPTugMCwePBnv61ebfhlf1r%2FN55GNCsOjswV%2BmaJZCnBkanD3z708UcfdO8ye%2BbU0V%2BNe%2BfjUZNmfLX1143pBcl6pwaTCQHCZjiveoYA0wmTRCBAeDDLY%2FTv3%2F%2Bbb75BXwne9N537PhxG4cAhwCHAIcAhwCHAIcAhwCHAIcAhwCHwN8jgCLXU1TZUZWDrEAB5a1qvTwGalsQFxC6l5WVoZF%2F%2Fvz5CLbAUzZXpKa2ZtPmxeFhnwjIeIbs8gu459%2F4sqRRYte%2BzsPHbQ8SjRnJVNXALl7Dw8DtaKB2hdskNzVUK%2BU6gw3pFuUVDanP8nPzqjQ6VNqsN6LZAmMDWlvVcPTgiRmTp3%2F07qixo0avmD8%2FOf6ORaWg8LSE3yS7sI4%2FT4amGZ6Namq2WZSmsrzaZ%2Bk5yTmZyuxn9Fys%2Be33Sv3864V8A4PuDEZJ%2BHE84QqGN42QmUS4uWWPqwtWFCc9c7HMigv8BbpeNGzLgRtcicVhQa4pG7lqtiLttL6q9vKFizt37ty%2BfTvaRmBfgNrfiw92ACDEGFeuXBk%2BfLhYLAbxgrIUj5%2BP%2BzwpKclpgxbF7jBZ7C4nUjZMKGHZNhW2NQY5I2gJAZUBYsNGTU67nCbfc8%2Beq4xoquKJnYRPiRA8Rl2bdo5NG2lpMbpqoCZAVe6hPlCuIw8WlhWGjCep82YuGD5iVP%2BhI%2BbMn%2F%2F8%2BTO7zeDRY%2BCL2Q1j%2BnseAyfsPW28iLfwFBsGHcGyMHotKigohjNmYZG2QcUmxbpcao3mTkJ839cQQ0p69nn1zLmzEOqkJqWMeusdVOAi2KISEsITT%2Bo3%2BFzfAamBftVSvlLM1IhJSoi4asxweu0MzU91Ht5dNXR4rk%2FoFUawnJCJAmbt4L7lcMXMz3YdPvi4T49zfqI4mSDDPzRP2CynXT%2FH4f20JNW5bqUpqoVVEqjhSerFvkV%2BjTQDRtEjV93lCriegsuSqxqepqU%2BfPxApa63ua0YM7vdVJiVvnbFssnTpx49f06hQV8MO2HAS73gMahFRXNzqyfNSGrVItPfJ4UhGUKfx7LQB9371m%2FcQmsqbS4T4ngV1K3wsBlGp8eDxA1OC%2BhDAGIzFOcdmDxhdqPQbSJJhjRQ7h9RGRieFhL5eMiwDW8O6904askPP5y5dSP%2ByYP0589qakptcJRFCww4LNibgENCS47Viok0d%2B5coZC1lkXQ6tSpU2HviQBWvPX39yj3jEOAQ4BDgEOAQ4BDgEOAQ4BDgEOAQ4BD4H8QQKGKMhaFrbe2fcFjeF8HiQEdAur3DRs2jB07tkOHDps2bYIwA1aKtLLMunndzeaRJ%2FyE54S8TLG%2FRhxZKokoe7mna89%2BWlxA9QpkjlKTxW1zqmzW%2BOL8rbFnlm7dumTthoMHj2en5Zl0VqvFrTfZ9VYXSkWoHFBrou63mh0ahTo3Of3ehWupt%2BIb8oupWkeR74n6zuqAj6MFRIMFLRVGaje6DUhNdWCZHcQDTDhgsEGNDfTWTf07I3N9fWpEjEEE20mRhSet4ful8f3iBIFXBBF3YvoXrd5qKihElis%2BC9cNmIxi5R08htGDB6tGMFmowYzfVdbIExPuwXrx3Llzd%2B%2Fera6uBufjJQfAEqCoRy8ACtIuXbpERUXhsW%2FfvqNGjTpw4ACOhNGE02QFa2O22xqsxgadzgH6AaIAmB%2Fgz8r2yOBLWOsPt5JmPXXPW1Qb0bSeEbuIkBIRy2O0buvYCD3G73gMT10OHsPuhn7EadWZMpOfX7525%2FTla0lpaXa7Ed0T6IdBOxDKbmwsl%2FG7DSOLsUYDEU7PG2iCN7Hz4MGD4ydOHDty9OiBQ%2BdPnUlLTlEq6sFvOFzOovLSOfO%2F6zNwwJx532U8zzKbTcUFhWuWrRjW77W%2BHbu89lK3Ia%2F0ejem0%2ByA4MN8JkPEbxDzVVJeUlRg2ZRP6bNEqiijWWm6mXNLo9smSQNvyHyPBMr2dm5bvHoxvXCGfr%2B4tl27Sn9flcy3zDc0y69t0TvjnU8eUKuCnj1N%2Bw%2FX%2BEfUSH2qZJJCsb%2BySRfXzGU08RmtUzt0huzi4mPnzh46crBOUYMLBbINDbVnjx7s3C6GEQrGfv1VckaW84UZ6W96DAO1qmhBYe2sb%2BPaNL%2FhL7gEc1Ee7xxPuC80InX6TFArDrtORS1yt03J%2BrWwJqYgQ0BhsDPUbXHq5E9vnBnWo10TQr4mMPSQNAREVfiFJgWGPhg6bP1773aNiv52ztznJRUYZzvUPWh1sYEpQ08K0mlZSYjDxnqqYBZhUo0YMaJz584jR46EMwY6TUASAvP%2FuT%2F%2F%2BB5GE7ctHHrxVX90Q%2FAu2BUEFYHS%2FOO%2F%2FI%2BfwJfk5OTcunXr%2FPnz0JlgHxPvHw%2FinnMIcAhwCHAIcAhwCHAIcAhwCHAIcAj8QQRQj6PwQemEwgqEhrfIxYvYUGehroHS4N133%2F3ggw%2FGjBkzaNCgkydPqlRqtq2jqsy%2BdV1Ky8ikEP88P3%2B1JNTJhFULgivadXHt2UeriqgDVaABK9CwFiioqlm2eVvLTt0kfmG%2BfqEtmrX8acnSssJCm8kEQQDqbQtOAPaRrBGHzWGH1yRaWawOg8UFRQQYDDQRwF8U3RB2Jzoeauu1FdWVaq3cjV4UlIcIIGWPwo%2FB%2B8DlNChp3C3d6NHZAbJKMaMTC8x8MXgMNRErBX51Qc2e%2BTU%2BH9bixJj375w6UFlZCC0Cgi50bjf6CFheAL4EsCO1QaSBNgUbls5xbohngQ8mUEIhhkfvjlfGgNzSd955JyAgAE0lvXv3%2FuGHH06cOAHyB80XOMxps9t0BovekJSWcuTiuTv37xnUqKPZ9hX27288BqJY7OBRclKc8xaVhUXXEYmTiMBjwNmjpkUb27q1tKTot74Sjx7D6ekrsTh0Wp1KV69xWyh8PtUWO%2FgLuC9Qh9ntgubDw2L8bT5gZHFpeMQGg4tHjx6BfoFiBOeJi0LBGx8ff%2FjIkUMHDhzau%2F%2FciVOPEx9WlpejcQZyBiSCPEh6cvbi%2BaRnqSYLvtxlMRirikvvX791Yufe0zv3HVy%2F7b2OXfoTsoCQmzxSJxJopaJnraIrF4IWyHArFLRG6dixr%2BrVwemNIgvCwzPDQ%2B40CS96e6hr6kT30KH1fv4GscAiEeT6%2BKe372Nc8wutqGQFDPfvW8ZOeBYSlhoiyQwVZwX45fhFZb06tHTj9qLb8XlPUuNuJ2zf%2FsvadWuyczO1BpXWrKquKtm%2FY0ur6CiIHEaMGX0r4b4DNASQYGU8%2BAeNJnpqVdPiYuV3Cx%2B0aZ4g5d1D2CshVwk5LBRnf%2Fo5TU%2Bl5gYn2kDMGpfZyNJZDQZa20Br5LS8mOama66d3j1tbLtoGbxWxhJyjxEqpI3yJf5J4VEPx4xe9fH7XVu1Wr5sVWGFAiocG4gykH7oh4LliAGkFvQYeMZ2jmD%2BAPa4uLh9%2B%2FYlJCRgHxMGNyNGDNPNu%2FO30fsD%2F%2BKbMbjw7IXjzR%2FdwFIiU%2FjatWuguf7AT%2F6zQ0FiYHZ9%2B%2B23%2Ffr1A%2F%2FZq1evOXPm4MT%2BTxiSf%2FaD3GscAhwCHAIcAhwCHAIcAhwCHAIcAv8tCKBcwobaFuUPdlBbYWkeDAbyQ1GMY40YC6lbt25FzQtCA0agEGOwcRVo%2F5BX2HZuSG3aKCcgQOkbYhU2cpCgCn5AYZuX3Dt30IpMo6tGTfWgPOR2S3Jq9ozxs%2F0ZfymRBgsC%2FIXCsR%2B88zD%2BmlWnsBvQcWJyWfROs9ENdwYrCh0kcTiMNosKIRQuuxm9Hi67wY2%2BDDY1E8ac5XUNz4vyKuXFNgtKeANLqrhYSgD5JSro%2Fm1a%2BizJ8uX45yF%2BuTxSw2M0hK8jPCPDtwnF1UJJAo%2BH1oa3pMy4z9%2B4kXRNZdOyX8v6P7JCCfaLzBB1wPcTZ4G6k30dxIOXFABKqDFRbwIxHAtCAMh06tQJVTM2FGuQM3gbT36DlPWasJYUFHz%2B1RfBjcPffX908sOnbh2aWNxsHwu%2B12O5YGDdF1T0ebJjzvzisCb1Yj87I3IToY0IKpu2NP%2B0khYVsnITNKHAq8GJkFhjdX3l02cPYmNP3zh%2FNSc9%2F%2BGTtKzCUrh1sE0qLvSfsOKDF384Ve8oe4cYgwiyBUUusj7z8%2FO9QbHojgGnodNq9RqtUauHswdYG1wFvgTVtc5i0pqNOrMJPAbmClJZXRabw2g1K7XakuqU6%2FGf9xncB6m7DHOBEdYKpVqpT84rHRTrltOyclpnpgo7fZZr%2BH5xTts2iuCgeoGwxs%2BvNjRcHx7uCg4x8XhWtP8IyLNAv%2BShI2ncE6qxIFLVnJCY8cX4vSE%2Bu6XkmJTclPHviKWHI5seGTf%2B6LqNx37edXDTnnVLVs%2F9ZvaRYwdvxl3Lyk9XKavvXrnQr1vXsPCw2YsW5haXoQPJ04jkHULwUzpWj1FSql%2BwJL99mwp%2FoZxP5IQpI%2FzCRlHWydPoo%2Fu08BktSbOnP7Y8vEfvJtDLd%2BiJC%2FTQMfrzZvO8b1JGDJgdLIriEx6PwNj2BsNUMLICWah88OCGtT8dXP79W0OH7N1zrKYWcxa%2BHJ6kHk%2BDEjxJIfuB7ghjwwLr4Q%2BhnQDyeMS89953xcXF9%2B7d%2B9P1PubnkiVLGjduHPLHt8jISHBxhw4dgsroL%2F4PmJGRMW3atPDwcDTOoM0Kj6GhoeBC8Tpuor%2F45dzHOQQ4BDgEOAQ4BDgEOAQ4BDgEOAT%2BaxHwVrXekspbd6PEwOo8Clu06kPx3r17d4gNjh8%2FDmEG%2BI2SkhIEs4LiYNeXq8vs29ZlNYsuDW6k8m2kEoaqheGFftGPW3eoWbSg5Nrxh7l3s7TFcre53mEryC5ePm1xM2lEiDAgWCgL8ZF%2B8dmoqxeOZibfq8hPZ6kMh5lajdSKlBA4TkAfwVpU6Nx26Ctgv4l2DzV14hGUgtZFNTaHyqQ32LRO9JWYoNhneQxQEDrqrofRhUNLs9M0X46%2FJxXfJSSDkEqBUCHxVQqESoYpEQoey6TbRfylXVse2jwvP%2Beetq7EZda5HDAvhZOjDUaktMFENWaqt0IHgqRXi90AmoWa0NticNtMbiv4FgvbxOB2KJSKg4cPtY6Beyi79ezR88yp04grxYxC1gnqfdAHZoPp%2BrVrA4YMIgLS%2FqUOh3bvbSitKU8rrM2ssKqsTlwn8lAcRodTTTOTHd8uKIlsrvENsjEiGyM0MqLypi0sS5bAl5LqLSyVYXE5dQZFWfH6lT8M7NO1e8d2fTp2Hta7%2F6DefZcu%2FrEgI91t0sIyFYwQRXsCa9rJSllgDwqyCkQVyBc86vT6xMQHDxIfVFVWGfQGlsJiDUTZv9%2FuBTzHIEAGw37SaXZaEHPL9t%2FgULRpwC%2FCBg9Yz%2BEgVjS6%2BnuPNo0YPUPkv0PglygNqZcGqqQBhQP6qPdup%2FI6ikRdpZvWqhyxp2tGvFknC9ITPjxA1DJ%2FnUisJ8TKJ2qGVPsKbkeHXhw1xpCUbleZNAp94ZXrZz%2F7eE20%2F88ysotHzgnIHYngWru26T8uLr1xPfPS9TtHzu9et33ilxN%2BWLJ48%2FbNV25fUSiqa0qLdm%2FfvmzlilsPEvWQ6Xj8MTxUBhtX63ZhHNW0vFK7ZGVB29ZKKeOS8hxCgUngowgMNfTpZ5o6KW32lw8WTrny8ajjvXtd7tIjufdreb0HVfUeWP1Kt4K2bZKahG4NEPTkkQBCRiOmhAgKpJGVHbo5l%2FxInz7KuHt9w7p1GZlF0HGAQmGpDKDrQKiK2%2BTpXcJ8hlbGe9N5b0DccXiKDdwF%2FGe%2B%2BOILEGKwK%2Flz%2Fy%2BBx1i0aBGyXL1z8j9%2FhLtLz549f%2BMqMW3%2BwoZpcvDgwSZN0HnzdxvSfCAUgfLkL3w391EOAQ4BDgEOAQ4BDgEOAQ4BDgEOgf92BFA9oZhC3eHdoMdAHVRUVDR58mRkKKAIwYrqypUr0bmPIgvuELNnz7567ZpV2UArK12bN%2BU1aZEf1LgkoEmuf7O0oJbn%2FKO%2Bl%2FjOatXy8%2F7dh7874JvFs55lPzOZTVaNIeXW%2FTWLl074YvxHn3y8YP7cud9O%2F%2ByTMe%2B%2F99bUr8fnZqax9TJqYzSJeOp%2F7NngU%2BERSEBboMayv9mgsbFJmhBOsJYF7Bo7pAl2qCbYhgsIFdjXwXvo3Q41LSmomzrjpl%2FQLYaJY0hKoO%2FTYNlTmU%2BKryxRKrsVEHA2vFHWF2P1h36p3bejbMc2V9xdmpZO0zPo81xaUExLK2h1Ha1TUmU9hb9ofRWtLaHVBVReQJUl1FBFLXXUxbah6M2amwl3uvXuxfCEAkb01pC3Hscl2jR61lIBDhsIFYGyw2qLf%2Fh4yIg3kSHbrUvHnRtWr%2F1x0efvvz92zIcb129OSsnMzy8tfP7cXFlKn6U5v19W3rRlg1Tm4AmMEt8amX9x61aOeXPpo4e0tpYq1bRBjWDQmrhbk17vH0oI%2FiIIiSSkMbwaBg1IP3mUKqqoXkU1atAL1GCkRmg99HDRgJjD4rIjWxYdC9XyuuXLf9qxdae8osbTbcFapZrB2LBkBVv3s2hanWiFYF0pqcUKbQQrwWHdQ1kNCbJdrG4dCA5WTWJw11e6z519PuTNmwHBaT6yMrFMI%2FSTB4YVv%2F%2BO9uoxqq2jOgvVYaQMtCzfumZDTXiHOhKSJhLf9xOkiXgVhNSKmDQfwRERb7pY%2BF501Pzx4%2BNv3FHIG0yYiIf33hr95tXIwAcCJkMsyPMVpbdsJp8ykT687yopVObkpD16fDH2wp24%2BCcpqYVlpXpMNoulqrq6uKK8wYBOI48MA4wLSyaA7QI3hVwcI61RyDdsze3aWSsTOKU8nYCnEgrrhVJVUHhOq%2BZ7WwVvjWl0KDjoNBHvJWQ9IUcFoiTf4FLfsCJZcHZgwM3QoLki0duE%2BYYvOS2LftK2t272QnonAaSNVV6bl52TV1SeVVRZoVSBJPKKbjCNNW7E66JbhvULfSHpwQ2Im449Tbcbl7t48WLIKHDflZWV4cU%2FseH%2B%2FfHHH4ODgxmG%2BTsS4V8%2Bwc0%2BcODAU6dOqdWY2H9pwxUhimXChAlez9vf%2FywkGTNnzoQE6C%2F9APdhDgEOAQ4BDgEOAQ4BDgEOAQ4BDoH%2FbgRAXwAAlB7YvIQGxO0oQ8aOHetNUoDzw8KFCyHDgC3k2rVrY2JilixdKs8vQLHvWrc5p0mblKAmySGtboe2ORHcarFvKGItmnvqax4hndvHXDp2zFBTB8kETBkqy4rikhOuPbz1KOXRrFkzQoKD%2BXxeZGT4wQMHtBoNKxjwGHSgywUBGljv956YvK7u8JHDK35aicRPuRoxri64WKAYh7yAdTuAUt8Ixwez0w4DTTPkFNSuoqVFtd8uuhPd5g5Pdpohe4S89SLmFxH%2FjNjnlMDnmEB62i%2BoYMhw9RcTnvYdcKNT58pPx2lnz9HMnqNdsEi%2FfLlh7Trjz5uNWzbrf96k%2B3mDfsMq408LbavnGTcsVGxcoDi6yZF7j1oq3c4GWB4U1pTOW7z49aEjhg4esW7JuvoSOTUgNwMcC9t9A1R1Dlpcr161Yf1bw4dM%2F%2FqzLau%2B7%2FVSSxFSTXikfdfO361Yue%2FQsUsHj8lv36Pxj1xLV1e2jKkTiCwMUy%2BW5AT65bRoahzzHl27yr3vV8ehfdYTh00nDucg9HRQ35EhfkN9xEOlwmES0WsMmdqy8c0ZE7WnDpsuxRovX3ImpdBaOdVqWFrDAYtJh8Fl1bhsWrv9wZPkIUPe%2FPTDz1ISn7rAV8AZ0mbSu6w6HMM2peAFD1UEA1WQH9RoZ%2B07wMuACvB4iMCOw8FmklpYUqOBVmTQ9attnV5pkEotQr6Rz9fzfMobRVXMmGBKu0mtcmrWsX6qTrPbaXTfemB9bUKd38tXRdKtfBIrIGUypjhIeNJPOIGQlwkrcvD39xn94QfnYs%2Frqstpaa7%2B5w1FHV9WigK0EpnKR1ISFlj1ak96%2BjiVl7n0dQZ1nVqtNVlcoFgwY0BweUxHPDmrHg6DvRg0x4DEQJ4JdCX4wzSp15T%2Fuj%2Fr1d4aqdAmIkoJr0YiUPPgB%2BufL5P96kM2isgdviSX%2BJ4lzHxCfiYkRShTi8PrJKHFvoHpoRHHfYK3BESe6tDz2egvahavcd9%2BQKsUVN5Q%2BOjprq3bZs%2BfPx6U34olh65fzFPL65ysSkZDbSqXGaPgcLN5JV76wvvfD249TBUEH8%2BaNQvZJTweD8zhn%2FufCd8Mk5agoKDfEwj%2Fet%2FX13fw4MEIJEKf1J%2F70d9%2FCm0y69evx38UuIp%2F%2BF2Qot9%2F%2Fz1kXb8%2FntvnEOAQ4BDgEOAQ4BDgEOAQ4BDgEOAQ%2BM8RQPXktRb0llHeD6KeAmWBgJIBAwa0b99%2ByJAhZ86cwSptaWnp0qVLoQyfOWtWEaQLlZXOA%2Fvz3no7c%2FDw9Nffie3ab33LjuPDomIICUQWJ0MkUuGg%2Fv2un401VMmphe1xsNtMGqe%2Bwa5PyUr76OOP2TKHYVDafPfdd7m5ufh1L5HiZVSwjw2FVWJiIooskUj03siRD5OfahxYUKeegFhWDsCKMwxOu0ZvsbKZJ3ZqoHY1LcyXL1oW377HHZ%2BIA4Q3g5DPCFlGyDkkUxAmjsfDX0pA4LOgoGs83mnYPPr7Z0RGPItolBYdntEkKrN548zmTTKaRT9rEpUcHZ4aEZYdGlQcGZbWPPJS00YX%2B3fTntxD64upWemwabUWfXJW1oUbdy5euZ2VUQDhg9vuRh%2BGE1ksCAZxO1VOd73VgSSRi%2BdOXT17ZO%2FmFS%2B1CmfrO4Y0bd%2F663nz9hw6evvUefntRPAYzlUbi9t2qBCLdeAxBKRcyisPldW3aqx9pZ2q1yvyfj0rBvfPHzooeXD%2Fu316nnqp%2Fe6mUb9Eh%2B2KCl0i4G2QCG51iskdOujZsCEJH36k2HuAFhVRrYoawGPA78Nuog49skQNhhNnz%2FfrN3jkiFE3z1%2BzG6AUgAMGGnsc4IB0YC48cgyWCEDhj54SakCcix22HC94DBMrgal3O%2FUOrctR6y5MojOn2Ru3MAslbj7PSvhGIq0Ka6ZZtsBd%2FYy6lOwvW6G0QbatjeaU0SUH6tsNjZX6byQklsfUBwgrAn2O%2BflOFIj6MExLP5%2BWzZt07dZt1eo1ldmZVF1DL19U9BqoF4WZhH4GobhKKi2Jbupat4EW5lKT0m7WWOHECprG06%2BBGYHmG48Egn3Fo8fwxI3AJxa6EvhUgMTAn1JXfvB45qsDFIzIzBc2BPpXymQKnszAC8nxC9khISv55JpPwDNR6AHCzGDIMh%2F%2B3ajo8uYdilt3yO7UMbt%2F36dDh6d9MVG%2BeqPr0g2aU0jrVFSlMxaXbV%2B1NjQ4hCcQMFIJz8%2FntVHvXkmMr9YqDQ7kyyB0FyYY4N1Yl5UXPAYmPG5DMIpw1zx8%2BDACfNHP9Vf6SiDqAI%2FxH%2BoxQJsMHToUgcJ%2F2pHj9%2F%2Fh4EIQUPL666%2F%2F01%2BHkwwiWvR6iKq4jUOAQ4BDgEOAQ4BDgEOAQ4BDgEOAQ%2BBPIgCuAJ9ESeUlNFBSYUNJBbNKVBxbtmyJi4tDeYVXYP134MCB%2Fv37I9GgqqSY6pU0%2FYHt5H7H6eP2EyeyVq8%2BMWnKgnfe7tqiaUhYkH94cNteXRavWFZRXI6kStTJWNJHWqodfhdOW1pWxkcffQQeA1ujRo3AYyCTEaeBk8EPeTecBs4KrfQw0uzbty806nAgPHLyeIPZCMNPC2waUBZj6R08BnwHUAWyPREWuFpQ%2BGNUlNUuWxffbWB8ZNtjIhl4jC8J2cGwXhlyQjQigdFHgj6CSkJyPQYa8AJVIy1UxGsQMPVCnoLPr%2BPxFTxeHY8n5zEKAd8oDtT7RlT4RSX7R8S36WRYt5Hm57kNWjQp2J12g8OhtNpVVuy49A5nTUNDYXlpjbrO7Mb5sJ4eFRpd%2FIPES7GnH8ddi7t44pN3Xm%2FeNDyiSfiYL8Zee%2FJYrtbaNCZaB1uPIvvmneltO%2BT5%2BOolIquM7%2FBjHAF8g4RRCXh6X5E5LEAbGlTpJyvylxWFBBU2CisIC8kPCc7397%2FHMKkCRhkarAoJTvKVnWrXoWDTzxQL37oGdqQ8PIaVuoBWrVZ76NjJbl16jXj9rRvnrli1yKpFLwlkGEh%2FwR9rpgoGgBU3wB%2FDhcFD6w6rx2A5AbiQoAY1sO9UanVVNcUWVSHNfGD6%2BCNDWGMD38dEGBMRWIhMG9Xetm2TS1tgNlQixdTSoNLrNVaYttZp6JlE9bBx10Ki9jPkNiEqPr9a6hPfrOWhnn1XDRw88623f5w9Z8%2FOX7PSs5x6rduiog%2Fv6Ya8Y%2FJrbGUCzURUS0SV%2FlGur7%2BhDx65zfBNwWWxfAX%2BcNqYaJgUOE0QGh4egxXueEw%2BMFs8PAbmIHgMlU5%2B%2BkLJG6P0vpE2YagqJApxt5qQVuaQ9nnRbXdFhKwI9bvSqn3OKwNud%2B21s3%2FXQ%2B%2B99uzzjxRff1U3Y3Ltopman1c6Lp1BmCytKqeKWqpVU4PerWqoKy7auX1b65i2AqmUiEWEz2%2FXufPOXbtU9UqH0egyIV4HviXo1mHnNmY4S9V5lBigNTDt8SJkGDdu3IA7zZ%2BWRvznegzcU2g%2FGTZsGG6x%2FxMSA5eQl5f36aef%2FlN3DlAr48aNg4spLhlTids4BDgEOAQ4BDgEOAQ4BDgEOAQ4BDgE%2FjQCKCtAYmDDN6C2elFSQR8OBgP1lLfmwgFgG%2Fbt24ccExvcNbF6ry2hVVlUXkRry10lBYaCnILkR7%2Fs3jZpwTfj581esXvn%2FfR0E1sBe0pKCBVsLtgv6G3WKnntgvkLoqOjAwMDW7Zsia58eId66zjvOjVb3Xk2OI4iqxHLu1Kp9O133om7n6CzoTGANat0Yw3ejKYHt1tl%2Fn%2Fs3Qd8VFXexvEgSFcUpSMggiCKigUIuq7ttbxr29W1rm3XdV17AQs2EFQUlF6l996l9w4JvYckhPTepvf3d%2BbivFlWEFDRkGd2Nkxm7tx77vdO%2FHzOM%2Bf8j9fudAdMaUsXoYa%2FKJSZmfJVnwXR%2F7Og6TWjL6r3fuUK71cpP71aheSK5fOionKioooqRBWfE8Xj9KioFH6l1OQ55Vgyw82DqChXFA%2FKecud4z%2FnnGD5CsEKVXzlLnScUzf1nPqx5eusanhVxuvvhzbEhgopTMphKUlqFmyl%2B8%2FP5Lz85Rs3TJ47c3vcbnvIRSzAhJkDaZljxo%2F%2F6P13vurywawxQ%2BZOHP7N159179F11pKF6U6GCIR9Cl2h%2FYmuvoPWXNk69qKLUs6rll2lfGHFcrbKUfnlo7Lp75eLKihfLpMGE7BUr5ZatVpCpUqJFSumVKmac94FaZUq5VauZK9a1Vbh3EMXXDi1Zcs93%2FYOJR8xk0p%2BGI%2FBkAhamO2wL1%2B97s477vnH0y%2Fs3rw94KTGBf9jOVAzJIMGk3UczTGYGvNDjsEkGdP5ZKwGRUxZIsbNqjE5CXG7ipN3hTYsTb%2Fv%2FswL6hVUON8dVdkTdZ67fF3vlX8IjvguN2nLppjF29aviV29dtOmTQmJ8aHs%2FFBsvLdLn9VXXje6XNRK8MtVzqp20aLGl8V98LFz1bqcmB2Ze%2BMK0nO9NMzNgrS5oZ1bQ692tNW7PL9cjWDFmp7zGhbWapkbfZ939KRQXnY4FjBzRyg7Si0PJvTgWSLHCIc0VPIgzSBD4LNjZpd4QqzSunj5wWf%2Bdbj5tYmXXL6zRav8Bx8O%2Ff310N87Fr3w%2BrLnnhr%2FxJ8TO38UHDrWP2OGbeMix65Vwe1rQ0vmuqaNzJ02zL5yRih5T8ieHXLmhey5IWdByGsP0WKXY9%2Bhg1%2F06tnsqtbnVK0WdU6FGjUufPbJvyXt3R%2FiOlP60%2BE2E47CAQZ%2Fa5Ebf1x8%2FvlQ87fGXxw1dXnp9P6iTz7H4K%2FvwQcfXLJkyWlnJiVbSIMPHjzYqVMnynv%2B92AMniGHnD179i9yrJLH1WMJSEACEpCABCQgAQlIoEwJmDDAy6Ka%2F7%2FuKo%2FpT3GjSoYVKTAMg64HzyDD6pCM02BYeCDo8wdYCyLV40oO%2BHKDjpwAXbmAx%2Bv3JOZlbTx8aH3iod05WRkuj52iCPTw6VjSNw4P%2Fme6hdPjYZjHu%2B%2B%2By1e3H374IXX%2FOC5HtBYA5THHolsUboifIRmscUCxAYbcp%2BdkFXvphYa7eNTHcPq8GYVpuw%2Ft2bnrSFaajaVEQk4vAUtebsbwsfPufXhs06v71r2kc4NaXZrWmnlp3QN1LjpUseKWqKiNUVFxlSpmnn9harXzkipWzqxSPavyedkVq2VXqJpbvmpe%2BWoF51QrPKdaUfnq9grn2yrULChfp6Bqs31VGs%2BKOm%2FiRU33vPi2f31MqNhJjzjAKqzhvjMxEIt6JqdlTp02%2FaueX2%2FYtJ5%2BuM3nznI69yenvv7mG1c2b3pdy6aP3nvbjNFD9u2MOZx8OKu4iHoEDlOFkn%2F8ofgjuf0Hzby2zeJGDWPq1tx2YaWtVcptqRi1rWq5A7WqxdWrubfWhdsvvmBb7Yu316%2B3qdbFa847b3XVaqurVlldudKOi2rur11rR%2FWqO6pUjG3SeGybNjHffBNKSgxRmMFdjK6HxWBMTGGCn5SMrO5dvxjz3RhHDiVJiQDoQVOTlFVYmLNjxmyEIy0uGXVKKZ9hZmLwqvnT4DXqYjA7xUuK47Tlpvsz4oKLZu279Y69lWumla%2FhLl%2FTcW6d7EoNnTff7xs9MnbjvCGj%2Bw7u22dInwFDhg6bOXt21s49oZSc0Jot8c%2B%2FOKd2%2FQ3nVissVyOlap2tt91VPHlKKCM7xNAUmy9EhU7yCJ836MwN7IwJdf8y68obU6rW9lSpb6%2FeKLFSo51N2tkHjQ4VMCTGBGV8PIkHzJ2xOeH28zP8iTMRDWdniKlUylgI8jq3O2RzFGzdOuLZv%2F3rgmrPV6%2F0j0Y113z2TnDFotCGDf6Na%2BLXfb9p0RTbtk2hw4mhzOSQn0VI8wO5iTGjB372%2BJ%2Beu%2B26t5%2B6N27LklCAyp0FQW9BMAAqCYr5I0nLzRk4YmSzK1uXO7dSVFT5ClHl21517YwRY12UjQUy%2FJkxy5eE%2F9Cs%2BAJX61cru7A%2B%2F0b7tG4nn2MwFIqlUX%2BRahW0mZDzvffea9asWYUKFY4pi8GvtWrVovQHk2V%2B5tmdFoneJAEJSEACEpCABCQgAQmcVQJWQEEHii%2BCI10MHrAWJB0iIgu%2BY924cWNycjKnTbeL0hl0RrLzsvM8eXkh1jbIYypHsSfP5mWZUj%2Fd3MJQiF4fCyuG%2B7vECma%2BB11KVrx0uwLuAD1gE0MwAIOOD2Mt2D%2FxBXvmJ0e0cMk0uNEqWkILCU8Yr56RmeHxeVlWw4zL9%2Fu8xfZd6zd%2F1enj15554em%2FPf38q%2F%2FuM2bIrtSDLI4SKirMmTFv9P8%2B%2FFrVC16oWPmxqpWeOL9K%2F6YN4m%2B5peh%2F7k66oe3Ops2OXHNd4U23Zl7b9sjlVyU3uyrz8mtyW7TJu7xNYbM2RZdda2t6re3Sa%2ByNr3Y0utrW6Jr8Bm0yGrWNqXf1hOqNRjW9dte7n%2Fo3bwsVOlnl1QwCoAdLFhG%2B52blzZk%2Bq9eXPTZvXEew4%2FQ4893uvYmHH3roIVaPoGdbq2L5h%2B%2B6dcn82Q4H9UDNLI1CMgJrUkRqVt6kKaueemrPo39Jf%2BSBw%2Ff8MaZNiyVN62689vLD996W%2FvD9yX9%2BIOnBBw786X%2FXtms%2F7PwLukZFfRYVxU%2Fu8xpcknj7Hal335V65x0Jf%2FnziuefPzRhfCg5KWQrIL3geoYX6jA5BsRun3%2Fn9t1HDiWZrj7jLMz8EZrAgBaPk4oY4diJ%2Fr%2FplwfJBkwGYpaE4ZkfxmMEPSzGQvFMVzAroWDCyC0337b1%2FHoHK9ZMr3BRcuV6By9o5nny5eCShYlJ21ZsWb5yxYp1K9etW7dp645deckpTMEIpSS7h4%2FcfdefYi5unFGxTnLdyzPf7OjbsjGUVxDKd4QKvaFcb6go6He4YjavGvTaC%2BNvuXlx46Zba9RNrVL3cMX6m6LqbGjUNqNbH8%2FeuCCjYrxm7gh1SDkHC5LT4kPH4B%2BCGIZiELuF%2F%2FUF%2FYQelGqhSoar6HD8G%2F94rFK5qHPKRZ1ft%2BLXQz7Oyj0YCBSyEktaICfOmVrkLQhQPcTDARh%2BZEtL3del40uNL65Mida6dar1G%2Fx5Wn6CM1RkC%2FI5cLlMRmToEjIyvuzbr16Ty6LKnXtu%2BUrnRpW%2FvGGTbz7%2BjEEmIVt4PIZZVdjkGNZnm787PuE8th7w2IoWT%2Fu%2FMiefY1DekwVeP%2F%2F88x07dkT%2B9E7juIweYVAHkUiTJk3%2Be40SK9O49957V6xYwamdxv71FglIQAISkIAEJCABCUhAAscI0HWybuGulRmhQYhhZQiU91y9ejVzSfjJYgoTJkygOAYlQPv07TN7yZzDBUmFgUK%2BrHf77UwZYWCHwxewM9XDfHsdKmCVSyIL5izQS%2BabcFfA4fI6AkwtMaUXuHEIjstBeUBqQU%2BKXzk0DWDgh%2FUrP63unmkh4YUZNhDg63ef21WQnjmy76DGNWpViSp%2FzrnnRp1b7vo7b5m2akGBIz%2FkcGQtWv7N3Q%2F%2BMSqqfVRU66iollFRr13eZG%2Fn90PjxgXHjCnu09fZt793wCDb173yun6e%2F0l3W9ceru49vZ%2F19HXt6e%2FSM%2FBJz8DHXwc%2B%2FCrYuUeg85fu9z4reP39I6%2B8s%2FP1jls%2B7JIxc04wMYkaC36KS3AydJWdwaDTLOfhLnDErt04Y%2BKkg3t3U9nSGzCnvO%2Fw4Y8%2F%2FvCmG667%2BrLGLevXanJRja4fvn%2FoUJzN483zBgrD5UPMftjhtm0FUyb5p4wPTRoVGDU4u9cX8R%2B%2Fn%2FbV584Rw%2F3jxvvHTfRPnOocPeHIl9%2BOv%2FvBt%2Bte%2BnKN2m%2FXavBp0xarn%2F67a9DQ0KSpoQkT%2FRMmFsya6d6xPZSTZXIMD6MFzKgFCknQ0aY9LsbNsGgGq72YchI0mmTILPdBoMIjgg7T2zQ5BtEMYzIIjjyYm2cICLi6RWZH5AZmBE5GfNrEEXuf%2BNuRm%2B5IvvHmpOvax7f7Y9zt9%2Ft69qcOpz9Y7GAoCHMmOIgvVGB3m2kFAVeoMDu0LSb1gw8WXXnt6hr1trZplz9yWDD7ME315xblHTySdzA9eWfivh27%2B%2FT7umXN81gEp3vdi6c3brS0Zt351Wp9F1VtdL0rFj73auK8JcFcKqKYaIxRPHzYyDFoF0%2BYaUzmo0eEQUZjzs489FODlc8is0vc7tzsgb2%2FuOWWNte1bfmHe9qNnz06vyiTYRUUWiHMsQVZYsdHisMnkvqcDnfxgV1bX%2F7bYxdWiGI14loXVn3tzX9u3x%2FrCjmL%2FTZHkCVrOStDl55XMHTU2CbNWkaVq1i%2BfEVikiuat%2FhuwKCMhEQzDsTjZuAR2RyfautPAJDMzEwGHfGT2NBKM8yfx%2BneOLOTX6%2BEsRNU7iWCoEQGWaXVqpM%2FMn%2B2BIwjRoy4%2F%2F77GXHx39NJTDnbcuVatWo1YMAAgsqT37O2lIAEJCABCUhAAhKQgAQkcDwBei50fOg9sYGVGNDdoG9lBQjUx2CxEoILan7yvW2jRo34DvfCC6kOWPORxx5ZsXZZYXEuc1PMBARvgOKbXpvXSy%2BNrmN4kD3fktN99JmRGHzLTJhBEYkgVRo4GMfioEQWhBhW341n%2BJVD0zmy8g3SjIyMDMp0MHiDG6UI%2FWbfdAK9VPEoTs%2BaNmLsLde2bVKvYd1LLqnboun%2FPvP4jDVL811F9LyLYndOfPuDJ5o0v%2F2CmtE1L2jfsM47Tz%2B6deVid15mwFZAic4gNSSLixi5ESpgDECBGQnAPbcwlFMYyi40VTe5Z1j3vFBGWujw%2FlDS%2FlDWkVBuasieH3QUH9i%2BffWSFYUUruSsCCMKA%2BaBzZ93JCN%2B7%2F687EyCHAYIuAL%2B9NycvXt3z5s%2BpWeXj%2F%2F9t8evbnLJi88%2BPWnixLmLlixatzm5gMk3zImgZinzHYpDuZmhjKRQ0oFQ2uFQdlYoPdM0rMAWyiow7clzhbKdgfishO%2FXzuzef%2Fhbn37%2F9cAto6bkr40JHckM0anPKQhl54YYLMMJOmwhF0U5WSCDqhAmo6CBdp%2B%2F2Olm5QyTVrj8QatiA6MUwp19rhhPhz8NXDuTZbBd%2BAITEoQzAnsomBdgIIaZj%2BR3%2BfKOONYt9Xz3XfDr3sHe3%2FoH9fYOHeAeMTy4bk0oN51wh88C%2B%2BRo6Tm2Jas3Tvt%2BfnzWYTe1WIuz82dNWffi31fec1fMSy8UbVvtChbY3AWJ%2B%2FYvnDJ38aT54%2FuPGTN8dKcPO9WsXuWSclFvtW8x%2FfH7Ft3%2FP0NbXv7PqKgP6l468flXkhauMkupcIBwjmGijHAbPQGmeZhiLJwIJ%2BbymxyGD0%2BQz6DJ1ChS4Qk6HYf37106b%2FbUMaPnTpuWlphoFlQh2DHxTng8B91ua%2FyKK%2BjLKU7YtO3fDz%2FGaqbVo6LqVK3y7tuv79m%2Fm6EV5h7we%2F1mRAtHd3p86zfGPvHUc5dd3qpuvYaNLm366JNPbNiyyebik12UnH74SEoiH3zwuPFXlpiYuGzZsvnz5y9atIhMgL8%2B66Xj%2FcH%2B5POnlGOQM1Brt2rVqtHR0V999RU1TBj7RHrJX%2BIJDsQhcnNzDx06REnSd955p0WLFqzRfLwQo2HDht26deM0Oa8T7FMvSUACEpCABCQgAQlIQAISOEkBOhdWjGDlCdZja1wEQyP4jnj69OmMwWABhc6dO7NGKvU2iTLOP%2B%2B8h%2F%2F80KoVSz1Ou8ks6CUzraSYCormi%2FAfen%2BhAEuheujtmpH9fB3OxBNWRXWaARV8dW4GY3BQq500g1%2BtG09y4%2Bjp6emxsbHMPWElx1WrVsXFxRXbixn4wQwI0yu1O7PjjyydOe%2BLrt1fe7dTt8H95m1em1iUQ47Cq5RvKNi%2BZ%2B3QkcPe6dS%2F09tje3%2B1cd2KLBdLjASyvU4GFNAd584X6GZ5VMaEEK0cbUr4kdUfDucwZlgCRSMCeaFgfngiiD0QdKSnJ3%2FxefeHH3pk6fxlrny%2Blv%2Bhz0v%2Fz%2B33ut12j8NBIRAzq4bv%2BL0up91RkHtgW2yvTz%2B%2Bqknjh%2B%2B%2F%2F6%2BPPNKy9bV3PPCX2cvW5Dq8JqNhAgdTHkgeWGGkKCvkZGaFiRK4mUkrVr7Aahu57v0xBzYt3bR0xtJ136%2FOO5xdlJoXt3V3yoEEr535DSzM4TIrtzAZgqvDKAx68SwEa6aWsKaqGYXBtAa3k8oeXJ3wtaPYCavEknOYRMD83xzVCPAPIxi8TqYZGYXwk1y%2FAj4mJo9iN2Z1mNyUUPzB4O5dob3bQvGxoSO7Q0f2hTKPmKVS3E5G27BDuz%2B4dV%2FCa%2B9%2FcttfHho2e1JaUWqIcqzZScED24Lb14cOxgZcaQUhW7Itc9369SMGj%2Bj1ac%2Bub3fp33vAWx90rFK9So1KUZ2euyfh%2B9GOZVNnvfL84xee%2F%2FXdf0qa%2Bb0JdviMmc%2BT%2BRgStdBkH4maz%2BfwetzEC%2BZ8A04fw1EYzMMSIQyy8DhshY7iArMwrsvjyM7PSUx1ZheZ%2BiQA8AEmu%2BBqMuyEOw%2F4iJhAI%2BjIzOv23odN69avc%2F55LZo0njhubE5OtpvBFYQn%2FqDT7vLhyWUKhGxFrtiYnX37Duz4Xucve%2FVavHJFjq3QGfTuTNg7dvbEWQvn2J12PuogktRt376d8QzffvvtwIEDV6xYQXJoff7NVT%2Bt2%2FFyDHKG4036IM2oVKkS9TlvvfVWCnXyx75161YiRAIN8kNGiZAu8pNEkWo5KSkpzBEaNmzYv%2F71rzZt2jAM40cLYlgjMerWrfvKK6%2BwN873tM5Gb5KABCQgAQlIQAISkIAEJHCsAAECN57lJx0ouhs8sDpZ%2FKTbQobQt2%2FfpUuX8F1t7969X3rppeeff77zBx%2FMnTE9NzXFFE40Mw98IYfHfDNOzUq6fsVEGRTh5KfP1DY0XZgAwzGcfhdLkVJpkRiCo%2FCdb6QIBo%2B5RVpCX4wOlNXFY0R6r%2FCNoe%2FpGWlu0yt38x04a63yNb%2BroDj%2BUPyu%2BLi4vKwsn4sKlHRGTYeWshV2VyA9uyDuUNaB%2FblHEpzFBcxx8QUDLuZU%2BOm605E0Ex44Z074aDfY6gnTuvDd1I6gaojZwB6kXkOISTN0d10Or33L9thnnn%2B%2BVavWQwYMy2ekhOn8mzuW%2FEvftzjkN5UpmHrBdAOviTYKszO2rFnV4%2BOPrmp66V%2Fuu79D%2Bw58GV6vafNuvfsnZObQgeY7%2FaAZKuAMuYtCbpYcLXZ7WPbUYDG%2Fgc4yUYvP5U%2BOT5k5aeaQvkOGDxw2f9bc%2BAOHtsXEvPrqK%2B93fnf7rq0eZkWYeIEpDC4z8IDhB%2FTfjRedejOph04%2Fe%2BK8sTBthdGUlgiPmzBxDoFF0Glz%2BVmfFBW%2FL7c4f%2B%2Fhg1nF%2BezIfFAolmH3Ovx%2BClzazHqsxUFHlpknkpvhTthxeOfSuD0rd8Us3rN5RV7cwVCene05DuVME%2FNs%2FSdMebPbZyt2xOa6CgLESIGikD8%2F5MsmW3KbWiuOPL8zMy937964WdPm9%2Fy8z4TxU77t3%2BeC2jXOr1Xpk64vpR5aWxi%2FYewXnW67tPanTz%2BRtinGfN4oCspZYEMk4%2FE5nG7mseQXFsUnJaVlZ5Jm4G%2FuxHXmhAN5eTnr1q1ZvGRhakqyyWpcgWARFTPCAzBIikwMFV5%2BhpgGACIzEwv5Pf6ALeBftGXdB726%2F%2F3d1z78utu%2BgwdcRQ53EXNQjKnfwRZsGvZlf3ZvRlp2QmJSUmpadnEh2Zot6F25bf1n%2FXsMHD2syE7lVXPjT4ziEocPH6ZKTHx8PLNLGHTEnyEvIX16tx%2FNMQgxGEPVvHnz6tWr%2F%2BjACWIHbqQZ9erVu%2FLKK2%2B%2F%2FXZq8FJct2vXrmQs%2Ffr140%2Fwk08%2BIZT461%2F%2FevPNNzMG4%2BKLLz5BMMJRCDFefPHFDRs2%2FCKLup6eht4lAQlIQAISkIAEJCABCZyVAnSarPEPfDvMt67kCXSvrM4UHRC6V4yFSExMpH%2FEV7GkGdyIDRz0O63VH6hxyNfdZhVUb8jmMV3LItaboDxk%2BLt%2BXqWKgtfh9NkYyu9hqoO7wONnJgtjFtwMuuAQSUlJ9OPoxHHoyHFpDBP214ZvDMngtmvXrqycLJff7Q%2FQX7X7C%2BhB02GnGKSLLnEB0x2CXlMbki46382TTzjddGtND91MRgnPqzCPw312floPCE%2BoWmBmVYS%2F0TfjHsJ369ejT7IpvXqnM2gv9he76Mf73Wu3bHr0qadaXtl64KBhuUzlMD1pc6cfbIIcszxpMC8UoBgEAYHP5ynMz92wauXA3t983PGdpx5%2B%2BPMuXR%2B8%2F8Fy5Ss2at7qq%2F6DD2eZHIOv9sk7Aqaugyvkt%2Ft9DncA4oCT4RxMXDAdX1rqz8%2FN3xqzdd3adZs3b96zb098UsLsBXNv%2BMONdz5019zV83O8%2BU4TITH4gpMK17c03XXOxAyJMSFGuJ2mrTxtEg3GyoRjHHPiJiohw%2FEQAZmJJwGX3b55W8x7XT6auej7zKJ8FoNx2F3xiUmxB%2FduPLQ7Lj%2BZKMPnYyRFjic5bt2Sqb1GdO81%2FuseI3sMHjtw6%2FIVzH8h52DCBSDUfd2Znb2J3rrTXBU3xVSCxbwSCJGQFFBIk4VViGBoHovAxB1KWbBoVWzsztVr1j753BMPPnPf1OXjM2wHk9K3Dhn6eetWDTq%2B9kLirp3hERSMCmGURaC4yJ6VlVNQWGSzO3bv3Ttp6pTvlyw%2Bkp7GqAwUsDCjJUKhlLSUMePH9vym5y4GkIDA8QgxSI8KvPY8lszxGyPiCFY1Me0LksDYiCjM1QwdCtjWZR76%2FuCWmLT4Qj4OTkal8EEKZxfWsA2uIm%2FnQ8A%2B2XnYOBwfsSv3im3rvhrSe8SUccUMlTn%2BjU%2BvGe9yulHGj%2BYYRArPPvssgyjIIVnv2EotTvCTFILJJixoQunOK6644qqrriK4aNy48UUXXUTWcYI3Rl7iKJTdIMQgqzz%2BueoVCUhAAhKQgAQkIAEJSEACpyNAj4nggqHjjCTnxgx9ulF0prjRJ6Ibwlh3XmXX5AzEDqaTFQg47EUFeZluVzFdW4IF%2BsOmdgXdbsZm2FgPgq%2FtGadBvkG33uPx2ewe4oviQMDucua7nDb2S1LBVJGxY8eyXONbb7312WefTZkyJTExkQZY1T45OsflJ4emMYzQSElNSc%2FOsDltXrvNn19EFUlyDJfHQ185N%2BTLp6gmMyS8ZpwFnWefm5DERZtZeYVKHOG1Kjx%2Bl6MgJ9NVXEg3n2kHXjff2HNGfCtv1h%2BN3MMrkPBtvMk7wkt1MPXE6%2FfxZX%2BxL0ApC%2F%2BhpMQe3%2FT6x79fXrJ6TaHDafVlrS6s1Yt1m1ER9IkZ4WEihOL8vO1bNs%2BdPo1SDN%2FPnr11S8zXX%2FX84613PPXs35etWZ%2FvdNsCARvZjlUAxAzNcFMGhK43vWCn181oFhOvhMeQMCWHcRpeH9DkL75CV%2FGareuefvm5Vz9%2Bc83eDVmBgiIz14L8IzLEgv67SSjMHsLda5O3eLzFhUXOYls4x7CqmbAJjTVjUY6uw%2BINFOTlT54%2B9dr2bTt98tHu%2BLh8uy0uIXHOwgVDJoweMGnkgthVqe4cR5CimIX2jMSNm5aMmz9mwtKJ45ZMnrV0TsKWbcGE%2FFAhq3NQGTSUHV7FhuVsqDIaZHQJeVSQwS2M42E%2BEg0OXwLTAFOPI9%2FmTaSqSF5xXlbeqlUrFqyaf6QgwRcqLMhPmDZ12H0P3DZqzJDc7AzmGAU8fh8jbJzuObPm8BGaOnVaVlZ2XHz8wsWL12xYn5aVyWIm5BgmyjCBSjAtI23MuLHdPu%2B2bed2rhqfLSIHmpSWnh2XlJKeXwg0bSD1cFK01hQLNaVfyM4cwQCjfRhjkxt0mWE2Jv%2Fh8gQ9Do%2BXk4gs%2BGp9FPgQ8GkgjWAMCDczosa998CeOQvmrlm%2Fxs0Un%2BPf%2BPBTfYI%2FyeNvcqJX%2FjvHYDrYM888Q6le%2Fpq2bdv29ttvszrqCYZSRLKI03tAwY2WLVtSN4N1jvjrO1Fb9ZoEJCABCUhAAhKQgAQkIIHTFSDKoI%2FDgqoUxCBeoA%2FFM%2BQVdKl4THZB54gkgd3zk%2BcLCguWr1k%2Bdvq4nUl7coN2W8jH%2FAGHGYFB95kqGdSLYHIEtRqYBWIrLMpyeYtc%2FiKXixEKdoo0eAgiPJ6cnJyRI0e2bt2ab3ipE1i5cuUOHTpQUJSBGVauQn0MtqErxBEpKjhnzuxBgwcN%2Bm7w%2FEXzM44cYZkUsyJouPJGUcBjTeKg%2F08nlF4%2BYz34It5BAU6PKzMnp7iomJVaM9OT58%2BdOWRQ34kTRu3cGZNfkGVzFnqDLqffzjISZC50R%2FlmnvkYzh%2FujnCVSzO2gdiAZIZKIOD4yEg8e%2BIOrNyyKcNu4yh85Wz1iOl8mo4r%2F0eLX8yIDrq7zGnwsEYIi1%2F43G6vi1zFt2f33ukzZi1bsSq%2F2MZytIUuF0VS2Umhx51tL7KZ0pS8md4yC3Oy%2BAa9YgbJYMG2BEleNyUtAhAwhsGdVpwxY8WchTFLU1xZhZTVMCNj6D9GQotjPxbsiM7%2Bju3bDx08yOwacxCzngntJMTgMSkIkZQZm5GdlTNg8OCmV7R84vlnN27fVuCw79q%2Fb86iBSOmjR86bfS8TcviClNyAkWMUbEYbf7iIr8j21%2FEzBGzHmke03FM2kJ8kclYCJZkIYoh5nIx8YeIiEvkdAdNiGE%2BNua44YEkHvN6YTDk4qRJdlxej89Z7Mg9uDd20czxX3R595HH7x80atCeuH2MvjAiLndCfMITTzxRqVJF1veMjYl1ulxMHSJ4orJnOJagMiwfO8Zt%2BPMK8petXDF6zJj9h%2BKI2Kh4YpYJDnjjcjN3pScn2RgZYsaEMI%2BHtO7o1cTL5aZiC08wdoldEvzxoSTb4jCFFG0ptpMzmfEuhCDA8zbrbk6HoUGmZgfna8vKTok7lJmSSohz7CX54Xf%2B7phpwtpA%2FOn98Nyp%2FVsyx7Amdzz33HMrVqzgj4gd8ddEeEjFm5tuuomJISeYY3IaIQYJBvvkr5hJKAcOHLCOeGqt19YSkIAEJCABCUhAAhKQgAR%2BSoB%2BkxVQ0P1hwANjIawQg5%2FWA16lS0WUQQ%2FICjcIOnbt2fVaxzdu%2FtPtI%2BZNPOxibQx%2FdsiZE7LTpzZD%2BPm62V4cYrEMvzs%2FP2Puwplbd29xeItCQSseoGICX5IHUtPSBg8efMMNNzBzn%2FHqDGK%2F7bbbJk6cSFEOWk2IQX2MxMREDs2AkD59%2BrRv3%2F7iOrVrXVIv%2BpabJowe7cjKMV1vUxAjkOdxFATcDh8VPG2uQntOelZ2Tm6Rx5Xtcx9MT926Y2d8XHxOVvaUSRNbXdGi5oU16tau9fZbb%2BzZu5sFSOlOM1clXHEjXMsivICmtYwmX7tzZ0SEOZAJabibahKU8Cy225Iy0g6kHWG4QWRdC9NRtBIMOrNEEibd4C3mfMNphhmFQG7ASp50hemAO5z0rcklKFnqKKCOot1%2BJDNj2cb1M5csPpiaanGHYxMSBoYShAdL0I8O%2Bj1eSkFQCIJKHQx88RG8ZBTnZrvogxMo8SsjY0z%2FnbaYUMW6Wb%2BEf%2Bda79ixffyECUuXLqUB7JlcxgybYN9U4nCwP07T5Bjp6Zk9e%2FdtcnmLh598cu3mzaZmpgfg4nxHQUZxdpYzPz%2FozAs6yCqY2hOuMUpP3qxAynomLIZi8gsGMTDHKJwYUNwz20P6wu%2BUT6GaipOPCkcimTHTf9jeSVUT8gszRoIBQDTKhByG3LvvwO63X%2FxHy4tr1q9Rvfp5lWs3qtvp0492Hjxg93qKbMXTp0%2Fr0CGarvd1bdpQppIClZw08YOpf8KnlhNkSV%2FETSjnSk5J2btvX04BE3BMA5kUlB8KZgdcWX5nQRA9RlKYsStkINzDSRQpBM0j4qIxJpQIMtSHAUWMnzGVN0gvTD0Vdu9hSgxjZzw%2Br93tZ9IT4zTIYggxTALmD9ndgQJbwEal1P%2B%2FLD9cnqP%2F0vcnu9u5c%2BfPzzFIFSh28cILLzC5o%2BTe%2BAwy3oOlRnjJqpjBlqeRWpR8C6M7qLzB3JN%2F%2FOMfLLzyc8aTHAOiXyUgAQlIQAISkIAEJCABCRwjYI2v4Cc3Ojj8pNPHNtavPOBXHjO7hJv1K19%2F79y96x8vvXh1h%2BsHThkZ78jNCvkzQ%2B7skJvlSJgNYUYv0MNzMVmiYP3mNQ8%2F%2FfBr776%2BdstaF%2F1dhyPkoCdL%2F93jcDoPHTpEcMG8EuoHfvTRR8wroSYGB6I3R3xBrkJmQp%2BUihx%2F%2B9vfWCfFdJ3KR1WtUf21l17au3EL81ZonTPAvBIPY0JMSYzc%2FJ1rN82bPnP%2Fvv3OYDDVbVuxdcuY8RPGjRgzbcKUjm91uvD8i8qfc27VStVefunVvbv3s4gFfUoXZSl4EP4WnSyi5N36dt1UDTVLe1APgdQk4PN6DhyKm7Vg3ryVS7PcR8dj8C4DR7JCJ5weMv1wZlPY6LCH62%2FQKSbBCIdBbnrXtJuN6S%2F7PAW52fv37l65fCkDTvr073fPgw9ef8sto6ZMc1ijKtgbd4pP0hSOEf6in7EC1kwJv4fdmn2ZWqTELXSXGUFjxqSYba0owxyHG4374Y7qylUrv%2B7Va%2BKUyTYHwQFlQJl3Q47B20yfPcBQBjOaIZCVVzBi7IRrb2zXqfOH%2B%2BMOmZExZj4ODWL3oJgaHIywoXwEd9IZM5CDD5IJl1jDlFkj5BIB%2BvK0iZIQOaFgMT1%2BuvbkFeRCTMcxZT%2BId%2FjAhHv6PE%2BRWNIB2DhXTpY4yBu0OR0xO2JfeerpyypUqVuuwgWVq5SvVOH2P93bd%2BTwzTt3JKemjB8%2F9sYbb%2BDTcc3VV48cMSInO9tM5SB%2FC89RMkEGN9YxcbkZiHIkOaWwqNjFiB0%2F5VAJr8wMIFPGJZxZhauIoMvpc8Hc3E0GxZsN8dGJR%2FypsOZIRkFOrp0ioXziKZXB0CVyEi9TXw7s3bth1ZqYdRsO7dxdmJJuqriYiIbcJnwnCgtfe%2BuyHPOTZpIysTIIf3THvHSSv%2FL2zp0716hRg2CQkRgUlrH%2Bco95u0kjd%2B0aMWLEI488wnrKjIkiizjV4Rlsz7t4L2U0Hn300eHDh%2B%2FZs4c9H3Ms%2FSoBCUhAAhKQgAQkIAEJSOAXFAh38MwP9knXie9t%2BUlvlRtP8piet%2FWz5IOMzMxRo0d%2F8OlHy2PWpfnsWaEAUUYWowI8hdl5WdSgYF%2BkAztiNj%2F9z2cvaFirYcvLHnj8kZEjhtvTs0N2D4UZrcyEw%2FHtM0U%2B6VLt37%2BfcRc8z4Gso9Mk2kCfjkKj9913nwkxKpxTrlrFc6tVevrJJzYvXxlgcYeQnxyDEQisG0oH2pGevWj8lAFf9YrZvIXYJSfo25uWPGXqtBeefPam6zvcevMdD933yF%2F%2F8tS%2FX3x9zqyFOVmFThu91fDwBTOy4%2Fh3kgG3KVbKCitm9ILfu37z%2Bm8H9h09dXyOs4heMF1g%2Brrmulg5BkM0KG3Jl%2F2kGUxYMaua0qvm63oPU0SKPB4bOQ4FPNzOrVs2dHzj5Yfvv%2Fee2275403RV1%2Fd%2BrwLa1a5qNZr7314YF%2BC6cXTnyfE4AE%2FOSV61Tygza6Aj5SARMHKN8gVTLQQnuJCa36IPY52hmkaj7iH20jHds36dX0G9J86a2aRg6DBDMQw7%2BHOG12u%2BLhD23bs3LZ7b%2ByuPTPmfv9mp%2FcXLFpiszvpv5syI%2BQYVPJgaVfGcnC9wut7cKLkJ6aFRBsU2ODX8AfIpBn04hmnEKAeaCCzqCBu9z5Hana4EiynQyjkJdUi2jGfGYav2PjkQBwkNzITPNibh7VCfKn5OctmzOn1wpv%2FvOvB%2B2%2B9s1atiy%2BsV6dVh7Z3PPinf%2F77X717f%2FOHm2%2FiA9Khfft5c%2BfmZueEm0qZVMatmOiJjzKnlp6avnTJskWLlhTkFxL%2BkCsxisJkFKRMDL4IfxQILDgrm5cBSB6m25gowxTWCE%2FxoSgGE2gYxON2H0qMX7F25bY9Ox0%2BBltwTdiRPzUzbfS4MU88%2Bfj%2F3nv3vXff9ciDD3zx8Se7N2z25BWZgRnkNqYQbvjymM%2FKj9wws%2F40fuS1k3uKi%2Fv6668zv4OAYs2aNT8aYlh74u%2BagRNUsWBYFGMz2rZtS%2FRxvEVUzV%2Fff97Yksob7dq1%2B%2Bc%2F%2F%2Fndd99RdZa%2FZdp%2Fcs3UVhKQgAQkIAEJSEACEpCABH6WQLjHGaBExuHDhxkRceTIEUZBWE8yLsJKFawe1tEinG5PakJS3Nbdhbn5joC%2FMBRk0kiqqyh2766Na9YWpWZQ6KA4JXNwvwG1m1wSVa1SVPXK5c%2Bvdvfd965fsMKXR80JE4%2BYrISv98O1AqydW8%2FQ3%2BdJ6%2Bh0teiIUZyQ6gfnVjw3ivHvlSucW6PqKy%2B9tGfTFr%2BDHCNAr9Me9LjMl%2Bp%2BR3LGvO9G9%2F7s8y0bNzrMop5Bin%2Fu3rWn8%2Bsd%2F9DupmefeW7K1OnLlq%2BMjd2em5tPVMPqG05WBaEHTvcrcre6%2FCV%2B8gpfuNvcTr5zp0tsd9hWrFzWq3fPCVPGFzpZncXkGPwkyjDdON5oOuDhzjyZg6njQY5Bf91rKooyJsHvtfH1fdCXnZMxctiAZg1rV%2BC0oqLOjYoqx1Ks5c%2BtWrPW48%2B%2FuHbNpiD9eu5EShzY4XLbnWY%2Bg8vkCQQhHmY6mKVESTBYGNYbKKanHD6o6bgfTTh48eh5RU6HzX2%2BPfv3zV0wf92WjTYPhmYT2m7iDL8%2FMyNj1uzZQ0eOHDp23MDhI%2FsOHjZt1ryU1Awzd4KOPyEVCQZ5DrOI6JITN4SXG%2BU7eFNOghzDY4Yt8CufCoIL%2Bvim6oXDZGT5Htu2fbumTpu6O2arJ5eZR%2F6AiyqxLOHKlQpQepURIGbFXkpUUGSTPYerc9pcntSAK8FTnJGSnrx22%2BoJM7%2Ft%2BkWzZs1Nr7riOdxr1avz0UedX375pTvuuO3djh337d3jsNmIw4wCkYoZnhJkzZyAz39g74Hhw4YP%2F25ERhrlOrhG4UogfNjMYAqPiSLMu8gxfEU%2BJ%2FOHeEzA4g4yt8gM5%2BBkqfhJxuK0O9esXDl4QP%2FFi773uFnjhOE8ZsZRSmbKiLHDH3zkgehbom9of8Mtt97ywQfv7dga6yqmFCplb83QFJNrsavj3CIf%2B%2BO8%2FtNP8%2BfTo0ePp59%2Bet68eYzN%2BOk3cKUKC2NjY8ePH9%2BtWzeGcNx9993M9mratClhCKuWkFcw8YSfPOaZSy%2B9lFfZhvWXP%2F%2F8c961detW1jk6mQNpGwlIQAISkIAEJCABCUhAAj9fINzxNh1buseJiYmU2Zw2bRo%2FiQ6INejF0RWyUgW2tDajP06XMLy%2BKv3TcD0MBsOHQvGpqdOmzhg%2FeHjGnniez9x%2FuPsn3apffHFUtcpRVatElT%2F3ytbXjxk42p5RxHftTE4x%2BwkviWLNH7F%2BtaIMnudmPeZ5CpAOGDDgrrvuuvzKK5pdc9Uf%2F%2Feu0SNH5B9JofQEY%2FTNvBJWEiFLoLeakrF8zKSBX3wVu2mTM%2BRnERPKM1AjcufazbOmTF25ZkV2Ya6Hb%2BnJUZg7wQOvm8VmzdwBvp43HXn6vOFv3un88iW8dacLHvJTR7SYeh8czucqLMpft2bVoH69J40fY7MV8DbGR9DpDVfVCA%2BdCM8FMXNSGAlg6iqYPjXdZWph0NEPj6ogZ%2FBn5qTOnjb%2BqT%2F%2Fqd0Vza9oULdxrYsuOu%2F8clHnVKhy%2FnP%2FenVz7DZr8AMd6%2Fy8rOSUxOy8NKfXxgq21J5gvVj6zuHExMwEKUjPzE1MdWcWmnkcptdtcgzuR3MM%2FiFk4B7OLBhykFeYn5iSlJaT4WL5lfDT9PV5G4MQklOSFyxdPGnWzFHTpr7z8adPPPv3BYuXFxYUm3U7XO6grTjExAFqaNg9ZpkRYgcGIoSniaDAuXGSrqBZBpdqGIkEAhyVcQgFvlCRPS09Zc2WdfOWLty%2Ba4cjn4qkHI0NQhmh4OFQMI61T9kXyQ9rsYYXMDFGbn9cZka%2FpbM7jhvU57uh62YsiFsbO33MxL899UzrG29ocs2Vl19%2F7X0P3T9y5PAlSxYtXLggJmazg9os4U9PuOJH%2BNyoc4GW13%2F4UOKsaTNnTZ%2BVQ3EVKLi%2BxER2F4sI%2B0lmOA0CCapckIxRk8NMLiGSoaqLGRRjEipOEEOiqWLHjHET33%2F1tWljRvvcLJrDsBInaYjLb0%2FKSFy6ftm4GRNGTh47cfa0Lbu2ObwkF5Rt5fNGYEZQZK7JCW78oXE7wQYnfom%2F1i3hG38%2BJ96y5KscETNCS6qALl%2B%2BfNSoUV988QUrmzBO48knn3z88cefeuopal%2FwDM%2BPHj2ayipMCuM%2FGrzr57S2ZBv0WAISkIAEJCABCUhAAhKQwE8K0AGxMgrCClKLffv2jRkzZtgwvrEevmLFCmZ5RDop4X6hyRV4CzcqGfJlOmGFGQAQ7jXTOUs4fGTa5GmTho%2FJjktmaoAv1zZv1rzrO3SoUa%2FuhQ0bNry85RNPPbdu2TpPId19eoWmp0aCQe2LmTNnfv%2F99yxwQLfIep7DcWMDmseTRBkZ6RlLlizpO7B%2Fv%2B%2BGLl69Ijsr03yZTseQkQiMUWAZTHqY9FYzsjfNWzhhyHe7t28nRjBf7LMRXVKWV6Uggpdii7SUr8NJMZjl4fR4XUyMcDntbqedDqxJM47OLwhvSFzDKdO%2F83uKTAXIIJM5bC67y%2BU4sG%2FPpPFjZ0%2Bb7LTTETdvgyFcacHEFJGwgl4vxzfdas6ESTr%2FH2KYYQssRFKYm7F59fKRA%2Fp8%2FPYbr73wj0cfeqh5s8svbX5Fj2%2F6pKSlh2dWMJDDSYix7%2BDu9OxkE18cXRWVeiBuh7PI6Sj2Oh3ZKalH9h%2FKP5IWKKbcpul0W9NN6IcfHY%2FByA3TKTcdchrjYuFbsySKKbMRrv4Qns5AI80qpi44Ct2uPQkJH3Tpen37DsO%2BG5WTnWvea4IdAoHwui1Ue7BT8IEaF0cPhwJnRZkMBjAQHzHbKCUUsHGx7R5%2FesH%2BDZunTJ44c97shNSkAnuxiU%2BIFwgtQsEMRjKE%2FPEhdx55GFeSnCY8d8bkXU7Pii2bW91z6zn1a1SreeGDt98z8psBwwcMWbhg0cTp07r3%2Bebr%2Fn0WLlmUmZnucjNUxelhlRzTDs7UDC85eufEwxmOrbA48VBCQlyCkxwmHFLZM3O3rl67eN68LRs25KalBRxO6qfy4SUEC884Mn7hyT2mqSb6MJ%2FKEPsZ0rvvkw8%2BOLT3tz4GqFjFSgyNx%2Bl3Fnttufb8tPzM%2FUmHEtOTnQEWlzGVbWmCh0vOSJzjxxTmjyv8YTGHOd0bfy%2BnFGKUPA6fAN5OoMF%2FEPjzZKgGQ7NYQoUbj63IkVcZ9WH9hZZ8rx5LQAISkIAEJCABCUhAAhI4AwKR3ID%2BC90TsguWXmXlU%2FosdGciHS4eWDerSaZTRw%2BPrjLD7PmanoINPhZByCOLOLTvgI3iA3SRvf7M9MxJU6b88%2BWXn33hhS969ly9Zm1hXhHbmx6x6U8HKYvxzDPPtGrV6rrrrmOMOuMurM7RD0djeIApK8GT3KgfmJGVdSQ1pYheMN8Cm6UxzaIaRBnhaMKEMnmp6cvmL1i5dGkGuzLdS6Z3mHqPLFZh%2BuhkFNxM59b8ZN9OJ9VGHQf279u8aVNiQryX8hVsZuZ8mHIN%2FDRzDUxaYmo5Wl1%2Burdut4sg5VBcXFJSIvNSrB1yOAIB%2Bqol7%2BZEOVT4Hj606QeHu9ThnjaOfq%2FbUZyTkZ52JOlIYuKGdev69u37dc9eMVu30xk1%2FXFaS8UGt4t2Uuvhh5452QzDVFI3bFi%2FcuWKHdu2ZqSk2lmE1BleaiScV3AgFtagwAPnwW5Y4YQ7e8nMyOSb9JjYmEPx8RT5pJwnIzHo9nLetNDAhi8P%2Fe5NsbHPPP%2F8Zc2bf9atWzKjX2iNkTZ3tgvnAOGPgUmlzHnxkxkutIx8go8G3XtyJBaCcRUUJu7Y886rr1111ZV%2FffzR%2BQu%2Fz8zOMkN6eBcTNajOauKOYK7fVcz0IIpl2hyMnQjvkbqwzgVLl7aOblv5vCqVq1bpcGO7zh3f7dH9C%2Bq4skxJcnrawYT4hMQEcMJtN6344U6eAW%2F4w%2FbDczzDEBw%2BUeYDHH7D0gUL7%2FzjrVddccXNN900ZODA7LR0LDh2%2BPKHT%2ByHCxr%2ByIb3TY5RVDxkwMCnHntscP%2F%2BfIbMNQoPteB4rOeSmZN1MD4uZlvs1BnTlixfymI0pmSombxkriXCZlfHv5nPiaHUTQISkIAEJCABCUhAAhKQgAR%2BYQF6W6b3Fu5z8ZOBE4VFhXyNawIQemsBU3Bj9%2B7dkyZPnjBhAqsxklpQCdDqMrMNc%2BqnT5%2FeuHFjq3zgvffeu3btWsIKs1t6oOFpLPzKTngLz9B6vssmZrFm%2FdPvNj3mHzbmVWp3MIzkxXDVQSIR62zZgO2TkpJYyzIhIcEaCW%2FtkJccTgczaJ55%2BmmClLfefJPHNMw6Om%2FngQk%2F%2FqvjybMci4axN6v7bx3rNH7ShvC7jvZbiZISExMZGEOOdIK90STG%2F%2Ffs2fOWW265%2Buqr77zzzv79%2B3OO7I0es3WOvJ228dgKgjivosIiCj9SBPLWW2%2FlfB%2B4%2FwFWsz14MA6f%2Fz5HiFavXv3ggw9SEqFTp47QscP%2F3uyYRtIAbtaT5hKGb4QwtI3W%2FvnPf3733Xcpp2BdZauFHMhsZS6umzsNZrURMyYivB%2Be4cJ9%2BumnTz%2FzzFNP%2Fe2NN97o3r17jy97kF7wdj4Mq1avWrBgQUZGunUh%2BGldQV4NH%2FxoY6wmHfMz6XBS584fRKpXPvnEkxs3bLA%2BXdYn5JjtI7%2ByzYb1G7799ttZs2aZVOSHG4fmTOfPn8%2FMC0pf0tTJkydzRiY0CZ8OgOw5sh89kIAEJCABCUhAAhKQgAQkIIHfUICeGn20SL%2BeX%2BniMRydSCE9PT0jI4PBHnRg6cpxs7rYrFQyduxYal9wmzNnDvVFeQunEOn08Ss3tucZbuzfijWsbXgmsjEP6EXS3Z47d%2B727dvZjGesG8dibHxqaiopCtvwZLgJJhjhJZ6ns9mvb1%2FemJKSwjNH3xb%2BJ3zYH%2BkLW6dgNaDk9j%2FzsXWO1imfYFdsBubKlSuHDh3ar18%2FusxEQHl5ebyFhqFkNYyfnA43HnCj9x0fH0%2Fxk4EDBzLqY8SIEeyBsTc%2F2rNmey4Zc3m4QMz9sQo58uQJWnW8l6xD08EnJjp48AARUGQ%2FPKDB1huti0Jj2CDSJJ7kUpLt7Nm9m0CDZT25EY5Z7WGaA2fEp4hfrX3y0zrf4zWm5POs1kFWM2TIED5%2BgwYNImrjw2AdmuNGGlnyLdZjXqJVhw8f5oMdaT8vcWg%2BaYxK2rFjB62NiYmheSU34I0lf%2F3vPesZCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAr%2BggM%2FnO3jw4CHdJPCbCvAhzM7O%2FgU%2F2NqVBCQgAQlIQAISkIAEJCABCZx9AnFxcX369Hn%2F%2Ffc%2F1E0Cv6kAH8LPP%2F987ty5Z99fmc5IAhKQgAQkIAEJSEACEpCABH4RAbfbPWrUqNmzZ3t1k8DvQIBUrXv37hkZGb%2FIx1s7kYAEJCABCUhAAhKQgAQkIIGzTCA3N%2Fftt98uKio6y85Lp1N6BRgdtGLFitLbfrVcAhKQgAQkIAEJSEACEpCABH49gby8vHfffZc049c7hPYsgVMS6N279%2BrVq0%2FpLdpYAhKQgAQkIAEJSEACEpCABMqIgHKMMnKhS9FpKscoRRdLTZWABCQgAQlIQAISkIAEJHCGBZRjnGFwHe4nBZRj%2FCSRNpCABCQgAQlIQAISkIAEJFBmBZRjlNlL%2F7s9ceUYv9tLo4ZJQAISkIAEJCABCUhAAhL4zQWUY%2Fzml0ANOEZAOcYxIPpVAhKQgAQkIAEJSEACEpCABCICyjEiFHrwOxFQjvE7uRBqhgQkIAEJSEACEpCABCQggd%2BhgHKM3%2BFFKeNNUo5Rxj8AOn0JSEACEpCABCQgAQlIQAInEFCOcQIcvfSbCCjH%2BE3YdVAJSEACEpCABCQgAQlIQAKlQkA5Rqm4TGWqkcoxytTl1slKQAISkIAEJCABCUhAAhI4JQHlGKfEpY3PgIByjDOArENIQAISkIAEJCABCUhAAhIopQLKMUrphTuLm60c4yy%2BuDo1CUhAAhKQgAQkIAEJSEACP1NAOcbPBNTbf3EB5Ri%2FOKl2KAEJSEACEpCABCQgAQlI4KwRUI5x1lzKs%2BZElGOcNZdSJyIBCUhAAhKQgAQkIAEJSOAXF1CO8YuTaoc%2FU0A5xs8E1NslIAEJSEACEpCABCQgAQmcxQKnlGP4fD673e5wOILB4DEmbrebl1wu1zHP%2F%2BSvfr%2BfHTqdzmP2yTMFBQU8ae3Z6%2FX%2B5K5OaYNAIGAdlwen9MZT3Tg3N%2FfIkSOZmZmcyKm%2Bt%2BT2VoNp86%2Fd4JIH%2FU0eK8f4Tdh1UAlIQAISkIAEJCABCUhAAqVC4JRyjP3793%2F22WdffPHFwYMHS54dmcOwYcO6du06ZcqUks%2BfzONt27Z169Zt4MCBhYWF1vZZWVljxoz5%2FPPPu3fvnpKSMmHChC5duixcuPBk9na8bUhgioqKSiYw8fHxHPebb74hYTjeu37O8yQwO3bs6Nu3Lywffvjhp59%2B%2BuWXX06aNCk1NfX0dgsFIOyEB6e3h9LyLuUYpeVKqZ0SkIAEJCABCUhAAhKQgATOvMAp5RjZ2dlECq%2B99hr98ZJNJd9455133njjjfXr15d8%2FmQeb9iw4a233iIbyc%2FPZ3uihj59%2BrArnnz%2F%2FfcPHz48ZMiQ119%2FfcaMGSezt%2BNtQ2pBCNC%2Ff3%2FSDGubffv2cYiPP%2F44LS3teO867ec9Hs%2FUqVM7depEy99%2B%2B%2B333nvv3Xff5aSg44inF2UkJia%2B%2BeabOPPgtBtWKt6oHKNUXCY1UgISkIAEJCABCUhAAhKQwG8icEo5Bi0cPXo0ffOvvvqquLg40uC5c%2BfyJEMOImMqIi%2F95AOCi%2BTk5PT0dCaYsDGpAl11%2Bv7sMycnh2eseRnMMfnJXZ1ggwMHDrzyyisffPBBpIXM8uC4HO4Xn7FCM1auXAkIOcm3335LtkNwkZSUtGjRIkaA9OvXj%2BErJ2jq8V5icgosZCNkO8fb5ux4XjnG2XEddRYSkIAEJCABCUhAAhKQgAR%2BDYFTzTF2795Nb5rbnj17rPYQCDAHhG77tGnTrBoXjEbYtGkT80EYSjFr1qzIxA2iD%2Fry33%2F%2FPaEEQzgY1EHfnARj%2Fvz5dPx5F%2F193kKOQW998uTJCxYsmDdvHtuzQeRwHJQdLl%2B%2BnETlu%2B%2B%2BYwPmWVjHZazF5s2bx48fz06YmcK0DisbIa%2BYPn26NcBj2bJlMTExlJggHmG3S5YsiQQybLx3714GfgwdOnTcuHFbtmyJBA5MS6GFRCs0mKEd1qmxZeTUSl6ajIwMBl0wdmLQoEGRnVsbcFArnLF%2BpZwIR8GBIzIlhyEiHCiyK04KJRx4dc6cORs3brRkIjkGG3OOvJHzZfgHbYu8t1Q%2FUI5Rqi%2BfGi8BCUhAAhKQgAQkIAEJSOBXFTjVHINinlZqESmFQbJB7NCxY0dCAJpKRkGxC0IDOvLcmExBp95KIcgTGBHBbBHCDeZZvPrqq%2FTiSR7IQCi7YbPZ%2BPXll19mb9wie%2BABO6E7bzmwzSeffMIzvMv6ya62bt1KNDFgwAD2yZMc1xoOQfLAuzhcZLe8yvZkL4QGbEN7rHITBA4EIwQFkd2yEzrU1jgQAg1mvvBMr169aD%2FTQ9iMXxlfYU2HKXmNKOXBS%2Bz50KFDJZ8%2F5jFxB6M1OLvIEXlMphEpB0qAQ3tosHXDBGR%2BWjkGXKNGjYoosRPOi2IjxxylNP6qHKM0XjW1WQISkIAEJCABCUhAAhKQwJkRONUcg1YxtoFeM114a3USEgb69T169OBXhjTQE%2BdX5pgQOOzcuZNCl3TD%2Bcn0DYZbfPTRRyak6NSJNIBqFYxtIMegM042QmLAWAUGQjDYgxCAEQirVq1i%2BAQvEQsw3oBDswdCDCtAYCTG6tWrqS9KBmJlEXT8yRmWLl3KaBCGKLAfjsI%2BiSyIOPiVOhXsn1iDkQwMdeC4tId9sueZM2dyUjRs5MiR7JbD0QZazlgIggVuTKVhDxya9jDyhOOSKvArp3nMlRo7diwCX3%2F9dWQ4xzEb8CtWNIkjchSOxZkOHz6co7PD2bNnswFFMGgtLezZs%2BfixYtJjfi15HgMBqvwdiqI8l6iJNpptS1SAOS%2FD1panlGOUVqulNopAQlIQAISkIAEJCABCUjgzAucRo5BAkA%2BQC9%2B%2B%2Fbt9MdZQYMONbEDjWepEZbnoENNGkC2wAAMetlWnsCvjEAgN6BvzjaRORRWnU%2FCAVrCHhi5wdsJKyLzLwYPHsz%2BrRyDpIKIgM57ZG4FwzA4qOVGOsFwEdIDpnuwW1pIEEFlDF6lp89OOHpkogcNsHIMJrbQ97fiESaMRFY1ZSIJLWcbxpmQz5Bj8JjNrDKbpC7kJ%2ByfPME6euSnlWOQqJyg8gb7ZOfkEmvXrrXeyHHJWKxzh4LsggaXLEPKNBYaQNbBubMxZUvZgKEvTCfBliocjMdgn9b5RhpTGh8oxyiNV01tloAEJCABCUhAAhKQgAQkcGYETiPHoFNPJ508gUECcXFxVufa6t3zqzVmgO42D7gRJtC5ZmMmWVg5Bn1%2Fa8iBdYLH5BgM4bD68mzMBkQTVJmI5BgUxGBXVMD4URzawFQLRjhYEQFtoG0kGGxM5GLlGFZawjORHIPQg2YTAjDggfEhkT2TeJB7cLg1a9aQSJBj8Jigw9qAERpU7GSf1tSVyLt4QODAOTIi5UerZ1hbsk%2FeS0xB8BJ5L0Q0g2YT5kycOJHDMaoEbWsD4gvOi1cJLjgLEhV4I8485lXoKKMR2WEpfaAco5ReODVbAhKQgAQkIAEJSEACEpDAGRA4jRyDVq1YsYJeNl17xl3Qd2aGCP16nj948CDRAdkFQwsYUUBVT27U0uTGAAYWByEZ4NXY2NjIqZ1SjmGNzaCQRWTURGQ%2FdPOtmSDMcGHmi1WCg7ZZpTmsHIOBHJFyFpEcg%2BEcPKZV5BgEGpEdsrKJlWOQDBCncLIlR1%2BcIMcgOSFVwIE6opG9RR5YwQV1SgEki6DMReQlhlKQS9AMGMlq2IDZIpEcIyEhwcoxGOXCYBUyEA5ByMM8mpLOv8YyspEWnpkHyjHOjLOOIgEJSEACEpCABCQgAQlIoDQKnF6OQWeZTMDq%2B9ObJqawzj07O7tLly6kB1TJiGgwsIF1OviV6Q8kAww5sCqCWhucUo5BTQyGMXTu3Dkye4JAg9yAuST05TluZBQEwQW9ftpm5Ri7du3ijTQ48sZIjsHADxCIBXg76QGRhdUwToo9kEgwTCKSY3AU69UT5Bi81KdPH%2FbGmbITK%2BFh8REcaD%2BjOJjGYh2dnVMAxNohQz6YkMK7mK7ClhQLpcFwRXIJinJwOgQdLOFKe6g7Sq5C0ME6L9YeWME2MhnHeqaU%2FlSOUUovnJotAQlIQAISkIAEJCABCUjgDAicXo7BIAEKXdKtJhlgFESkQgUNtmZV0EOncOW6deuoaEGywYANa1lVK8ewsgXr7E4pxyAJIT%2Bhs89IBipyUFyCo9AMxn5Q25MHFO6gYAUjKDioNSjCOhZjGHiJDSjNwWgNkoT%2FrvPJbgku2CG7ZVoHiQFBwYgRI0gYKANijcc4mRyD82JwiNVOdsgbSXXIKDg0DWDlFOqIosGB2D9H4VWOyJQZNuYZsg72wOgLYhCaxNt5lTZDzY3t2TkbMAzDajD1QtkAAZINioKSgfBqqb4pxyjVl0%2BNl4AEJCABCUhAAhKQgAQk8KsKnF6OQZMoLMkip%2F%2F%2B978JNErO8mBoBEUk6HG%2F8sorvMqN7jblKJlAQQphhQOMjoicFH1wtiGXsMZsMI7ipZdeYm4F5TfZhoEHrHXy4osvEixYb2FpUTIKxioQCPBGjkI2QrkJogmWCGEuBs%2BzB3ZIDsAD61gELyQq7JY2815awogI3k57eGwdiFKi%2FGo1m52QKlCewpqHwkmxPsu%2F%2FvWvyFQRRllQJISGsdBJ5FxKPqDUBqMyyFI4IvvkRtsY9UHOY0UN%2FLSyIOtE%2BEnzWPzFGl8BKWM5OAXexVmwExpAy%2FmViIMDka4wQoMN2DMnws0KPUpmSiXbU4oeK8coRRdLTZWABCQgAQlIQAISkIAEJHCGBU47x6Brz1AKAoTIxIdIy%2BmDM3OEoQuMIuAnSYI1t4Iog1ETvIuDRjamGCbDNqiYwZgHniTNINlgMVZmSfAr0zGYNsJRSlbgZPYK21ABg3qhPLAqglrvZVc8T%2FkOWkXJUN5Y8lgkAIyF4FUyBMpfsDHtiVTa5FjUx2CcA9EEP2l2pDYFD8hPOBY1Nq2W84y1f2t0hPXkMT85axrPrsguGCXCiUeaam3JBuyEKSQckeVRmMByzB4olMHwDF5lhAlnzU%2Fio5IjLmgwcQfO7B9DC%2B2YnZS6X5VjlLpLpgZLQAISkIAEJCABCUhAAhI4YwKnnWOcsRbqQGVNQDlGWbviOl8JSEACEpCABCQgAQlIQAInL6Ac4%2BSttOWZEVCOcWacdRQJSEACEpCABCQgAQlIQAKlUUA5Rmm8amd3m5VjnN3XV2cnAQlIQAISkIAEJCABCUjg5wgox%2Fg5enrvryGgHOPXUNU%2BJSABCUhAAhKQgAQkIAEJnB0CyjHOjut4Np2Fcoyz6WrqXCQgAQlIQAISkIAEJCABCfyyAsoxfllP7e3nCyjH%2BPmG2oMEJCABCUhAAhKQgAQkIIGzVUA5xtl6ZUvveSnHKL3XTi2XgAQkIAEJSEACEpCABCTwawsox%2Fi1hbX%2FUxVQjnGqYtpeAhKQgAQkIAEJSEACEpBA2RFQjlF2rnVpOVPlGKXlSqmdEpCABCQgAQlIQAISkIAEzryAcowzb64jnlhAOcaJffSqBCQgAQlIQAISkIAEJCCBsiygHKMsX%2F3f57krx%2Fh9Xhe1SgISkIAEJCABCUhAAhKQwO9BQDnG7%2BEqqA0lBZRjlNTQYwlIQAISkIAEJCABCUhAAhIoKaAco6SGHv8eBJRj%2FB6ugtogAQlIQAISkIAEJCABCUjg9ymgHOP3eV3KcquUY5Tlq69zl4AEJCABCUhAAhKQgAQkcGIB5Rgn9tGrZ15AOcaZN9cRJSABCUhAAhKQgAQkIAEJlBaB3Nzcd955p7CwsLQ0WO086wXIMVauXHnWn6ZOUAISkIAEJCABCUhAAhKQgAROQ8Bms%2FXq1Wvbtm2n8V69RQK%2FuEBRUdFXX321d%2B%2FeX3zP2qEEJCABCUhAAhKQgAQkIAEJnB0CsbGxXbt27devX3%2FdJPCbCvAh%2FPrrr4cOHXp2%2FGXpLCQgAQlIQAISkIAEJCABCUjgVxLIz89fsmTJct0k8JsK8CHUSIxf6W9cu5WABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAESr9AMBTwlcV70F%2F6r53OQAISkIAEJCABCUhAAhKQgAQkULYEgvZU7%2FRrytx96pW%2BpQ%2BXrSuts5WABCQgAQlIQAISkIAEJCABCZR%2BgaDtiHdsrTJ3H32Bb%2B4tpf%2Fq6QwkIAEJSEACEpCABCQgAQlIQAJlS8DkGOPqlrn7mIt8824tW1daZysBCUhAAhKQgAQkIAEJSEACEij9AsoxSv811BlIQAISkIAEJCABCUhAAhKQgATKioByjLJypXWeEpCABCQgAQlIQAISkIAEJCCB0i%2BgHKP0X0OdgQQkIAEJSEACEpCABCQgAQlIoKwIKMcoK1da5ykBCUhAAhKQgAQkIAEJSEACEij9AsoxSv811BlIQAISkIAEJCABCUhAAhKQgATKioByjLJypXWeEpCABCQgAQlIQAISkIAEJCCB0i%2BgHKP0X0OdgQQkIAEJSEACEpCABCQgAQlIoKwIKMcoK1da5ykBCUhAAhKQgAQkIAEJSEACEij9AsoxSv811BlIQAISkIAEJCABCUhAAhKQgATKioByjLJypXWeEpCABCQgAQlIQAISkIAEJCCB0i%2BgHKP0X0OdgQQkIAEJSEACEpCABCQgAQlIoKwIKMcoK1da5ykBCUhAAhKQgAQkIAEJSEACEij9AsoxSv811BlIQAISkIAEJCABCUhAAhKQgATKisBp5Bi%2B8XWDE%2BuEJoXvE%2Bv4x9f1jjujd%2F%2BEuqGJdWjD6R93zEW%2BebeWlWus85SABCQgAQlIQAISkIAEJCABCZwtAqeaYwQn1HGOqb%2Ble7MJr7Qe%2Fe%2FWaz5pkTusYWjSmcsx%2FBPqJPZtvPqTFrTBM%2FZ0j6sc42z5AOs8JCABCUhAAhKQgAQkIAEJSKBMCZxSjkGIQYbwcNsbateIPr9q9HlVomtWj25%2FefvJr7UMTDzdSOEUx3KEptbu8tdWlSvedG2Tto7R9Xyn%2BPajQziUY5SpT7lOVgISkIAEJCABCUhAAhKQgATOFoGTzzFIDDzj6j9%2F6%2FVVKkaTY7Rr3u72K9s2ujj63HM7dHu0dWjy%2F8%2FyYOIJk03%2Be74Je7Ce52fJ%2FIHHvIU7IUPkgRU4RN5ivcqT5Bhd%2F9qqSqUObS41OcaP7vBoWHGCiEM5xtnyAdZ5SEACEpCABCQgAQlIQAISkECZEjj5HIOSFPu%2BaXpFw%2BgLqkV3fujazCGNi0ZcsvqT5m%2F%2B7%2FWpAxpZqUVgAmUr6tpG1c8Z2qBgeAMe84yVKlBPwz%2B%2BHk9mD22QP7xBYEK9yEuuMfXsoxo4R9fnEK4x9YtH1j%2F6lol1fOPrFY1owN74yd5MXY4SOYZ3XD33GHOsvO8asGVkh8oxytRnWCcrAQlIQAISkIAEJCABCUhAAmVH4BRyjEl1tn15WbO6JscY%2BHzr0PTaoUncTdRgDZYgRsgb3vDbp1vff327Fg3a39SiXY%2FHr84Z1pDnPWPrr%2Fm02Tt%2Fuu4PV7RrVrd9dIv2Xf96bdZQ81JwQt2xr1x5b5t2A%2F%2FehpIXj99047%2FuvNE2sj573tfr0o73XXdH63YtG7S%2Fs3W7z%2F56dXyfJhzXGo9xY7Mbt3S77OlbbrzykvY3Nmv%2F0Z%2Fb5Ay9hOoZPx1iME5D4zHKzkdcZyoBCUhAAhKQgAQkIAEJSEACZ5HAyecY%2FvF1ikZecvc1N1avEt2kFkHENQe%2BaRKaWM9aN4RX87%2B75OF2N%2FBqvQvbX9%2B0Xa3zo5n98dwfryPEsI9u8Kc2baOiOjSuFd2yfvSF1aKrVop%2B4fbrKRkamlTvoz9fc0656D9d3444IiqKghvRrtH1F3%2FQokX99lUqRdc8r8Pl9aMvPr9DVLkOL95xfWh63c8ebVW9Crtq3%2BqS9pUrdqhdo0ONqu2rVOzwxePXBCbWU45xFn08dSoSkIAEJCABCUhAAhKQgAQkIIH%2FEDj5HIN8gMhi42eXXde0PWFFtcrRl9WNfv2eNvF9LjVrsE6u%2B%2BUTVxMvXFq73aL3rygc0XjKG1cSaBAyLO3cPDSt1ux3rujyyNV7ezY93K9pjydbk3LUvaA9v4am1P304WuqV6ZkaPsGF0W%2FdOf1szu2TBnQ%2BOYW7XjyxsvazX6n5b6eTRe%2Bd%2Fmj7W9c%2BXHz0EwzHoMDXVi9PeM0Rr7UaumHze%2B%2Bpm31Ku3vbdO%2BYHhDa4bLT6QZGo%2FxH58C%2FSIBCUhAAhKQgAQkIAEJSEACEigdAqeUYxAOUMLicL8mH%2F3l6msatydnIM1gvZIdPZp6xzV46MYb%2BfXaJu36Pde6%2F%2FNX9X7m6kvrtK94boe%2Bz7YKTa3D2qwpAy5d%2FMFlJA8fPNim4UXtiTKWdW4ZmmpyDN5I7dBhL1wVnFgvNKP2wvebXVC9AxssfK9FaJqZvUIdUQpoMIHFqo9RrQrDMKKHv3hVaHodZpr0f741IzduuKx91pCG%2FgknsXKKcozS8fFUKyUgAQlIQAISkIAEJCABCUhAAv8hcKo5hm88lTxNKEGa0f2xq5ncUa1S9JM33ZAxuMkfWrZjjET9mu3rXNChTo0O%2FGxSi3SiAxUzvOMbEG60adK%2BaqWbLqjWod6F0XUuaF%2BnRjQjN6wcgxSideN2hSOo%2F2l2PvzFVqyK0qph%2B8zB5hlrcIVVhcPKMapW6tCqQVtmsjD6IjSlzqTXL69cMZqBIpmDlWP8x%2FXVLxKQgAQkIAEJSEACEpCABCQggbNJ4JRyjHARjEa2UQ1Dk2ub%2B%2FTaH%2F%2BlDUMpKOm5%2F5vL%2Fqd1W0ZoPHHTjdu%2FbEbFzi3dmm37ollM9%2BaZQxrNfLtljWodLjov%2Br0HrlnVpdnUN6%2B6tDYlL%2F4%2Fx6h0bvQDN9zoHluPhVYZejHulZYU0GDMxu6vL%2BVAPGnyDbP6yX%2Bsu%2BocE95%2BSp2JrynHOJs%2BlToXCUhAAhKQgAQkIAEJSEACEpDAjwucfI7BcAj7qPrP%2FfH6xzvcsK5Ls4zBjQ9%2B2%2BSv0TcQONxzzY220Y1ev%2Bdahkm0bNBu2YeXhybX486CIxNfbUUt0J5PXcOIC8ZXZA1pHJpT%2B6snr6pRNbrkeAxyjMei2x7NMSbV2f7lpQQdbPPsLdenDmwcmli%2FYPglw1%2B8cvLrV1j1Magg2ubSto7RyjF%2B%2FLLqWQlIQAISkIAEJCABCUhAAhKQwFkpcPI5BkUqFr7XnGEVJBL1a0bf3JLlQqIvqN7%2B%2FKod%2BjxzFRUwdn3VtHUjZo5EN7o4%2Bi9tr3%2B47fXN60XXqHbTpm7N5nVqTvJQ6%2Fz2D9143RM3XVv7AgZj%2FMe8EnKMR3%2FIMQhMfBPqffSXa2pU7XB%2B1ei2zdo9Fn3d7Ve1JSRp27ydbUyD7o%2B1Uo5xVn4adVISkIAEJCABCUhAAhKQgAQkIIETC5x8jkFljOKRDfo%2Fd2X75tTBiGbJkvOqRDeuHc2qqfZRDXiVeR9ruzS%2F%2B9q2NaubZVUpcFH3wuinbrph%2FzeNbSMb%2FPt%2Frq9zgXm%2BWuUOrDxyRYN2FMpY%2BJ6pj%2FHxX64555wOD7drZ43HoBoGE1g4VrfHrmlR35QSJTnhcDc0bTfo71cFJ9fr8kircyvcRD2NyHiMCa9eXqF8h6ubRKs%2Bxokvt16VgAQkIAEJSEACEpCABCQgAQmUaoGTzzGIF8xyIZPrZg65JObzS2d3vGJJ5xZxvRsHJtQjdrBKcTJmo3BEw83dms58u9WC91rs%2FrqJe0z94IQ6bOAYXX9HjyZzOl7Bq7aRDQ%2F1vmRnj0b53xGA1E0d2HD7l40S%2Blxi7cT6yVuCE%2Bol9Gm8%2BIMWM95qta7rZRmDL6EEqG9CndSBDdh%2BX69LPGPN0iTsIWdo%2FfAzjVxj6pXcyXEfa72SUv2pVeMlIAEJSEACEpCABCQgAQlIoKwKnFKOYcUC4ZKbjL4wd9YuoQhnybiA%2FMF66eir44%2B%2BajKQ8Fv4aTKK8GOe5L2meie7%2BmFdksje2DNPRt4VWbjk6PYTj4YnbG9WLQk3JvLen3igHKOsfuB13hKQgAQkIAEJSEACEpCABCRQqgVOI8f4iYjgP2ON3%2BnGyjFK9adWjZeABCQgAQlIQAISkIAEJCCBsiqgHKOsXnmdtwQkIAEJSEACEpCABCQgAQlIoPQJKMcofddMLZaABCQgAQlIQAISkIAEJCABCZRVAeUYZfXK67wlIAEJSEACEpCABCQgAQlIQAKlT0A5Rum7ZmqxBCQgAQlIQAISkIAEJCABCUigrAooxyirV17nLQEJSEACEpCABCQgAQlIQAISKH0CyjFK3zVTiyUgAQlIQAISkIAEJCABCUhAAmVVQDlGWb3yOm8JSEACEpCABCQgAQlIQAISkEDpE1COUfqumVosAQlIQAISkIAEJCABCUhAAhIoqwLKMcrqldd5S0ACEpCABCQgAQlIQAISkIAESp%2BAcozSd83UYglIQAISkIAEJCABCUhAAhKQQFkVUI5RVq%2B8zlsCEpCABCQgAQlIQAISkIAEJFD6BJRjlL5rphZLQAISkIAEJCABCUhAAhKQgATKqoByjLJ65XXeEpCABCQgAQlIQAISkIAEJCCB0iegHKP0XTO1WAISkIAEJCABCUhAAhKQgAQkUFYFgsVJ3lHnlbn7iMq%2BWe3K6jXXeUtAAhKQgAQkIAEJSEACEpCABEqrQNCd69%2FwRpm7r3s1sKNHab1marcEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAR%2BawGv13tQNwlIQAISkIAEJCABCUhAAhL4GQK%2FdcdOx5dAGRLw%2BXxJuklAAhKQgAQkIAEJSEACEpDAzxAoQ31InaoEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCfwfe3cBbUeRvX8fd3d3d4K7u7u7u7u7h%2BDuwd3d3YIECwka3J2BkfV%2BhvpR%2F37PvbkEy4TwnMW6U6e6rL%2BnOmv203vvCoEQCIEQCIEQCIEQ6JDAd999%2F847H3zw%2Fsf%2F%2Bte%2FOmyYiyEQAiEQAiEQAiEQAiEQAiEQAiEQAr%2BawL%2F%2B9e9HH37mmqvv6PXqW%2B12fv65HpdefNNHH37a7tX%2BsPLrr7%2B9%2FZYHrr36zuuuueu6a%2B686YZ7H3rg6Z6vvvnvf%2F%2B7H6z22WdenneOtdZfZ48vvvi6H0yXKUIgBEIgBEIgBEIgBEIgBEIgBELgb0Xgxx%2F%2FudmG%2B4441MwXXXBD2xv%2F7rt%2FrLvmroMPNM1ZZ1zZ9mr%2FWdO79wfTTr70CEPMPOxgMw49yPTDDDK9v%2BOOPt%2BWmx7wxmu9%2F%2Bw1P%2FlE9wnGWnCJRTb%2B%2FPOvzPXZZ1%2FcecfDD9z35Lfffv9nT53xQyAEQiAEQiAEQiAEQiAEQiAEQmCAJ0DH2HKT%2FUcdbraLL7qx7c3yYTj5hK7LLrHFo4880%2FZq%2F1kjrGOGqZYba%2BS5l19qy%2FXW3NV%2Fa6y8o5qhB55%2BrdV2%2FvTTL%2F%2FUZT%2F15AuTjLfI0otvVnSMF1%2FoOf2Uyyww9zoff%2FzZnzpvBg%2BBEAiBEAiBEAiBEAiBEAiBEAiBvwOBjnUMBH784cevvvqmJSjj%2B%2B9%2FeLfPWSB%2B%2BOHH99776IMPPmn2%2Btc%2F%2F%2FWP738oWSO%2B%2Furbd3p%2F8Pnn7UsKH3%2F02bvvfGjSdvl%2F%2BOGn7777oQW0e1UlHWP6KZedfKLFevR4s7Z5pttL88y25jijznPbrQ%2FWSgWzWMlnn37RrCzl%2F%2FznPx9%2B%2BImVfPPNt%2FWqSnf3%2FT%2F%2B70ZKPUTW4xBbX6uO8dlnX2r59JMvTDbBonN0Wq332%2B%2F7qntj8E8tVRRMHbxtQZd%2F%2FKOPd9q2fWpCIARCIARCIARCIARCIARCIARCYMAm0LGO4eqJnS9ca9WdH3rgqcLhnz%2F%2B64Zr71552W1GHmbW8Uafb7ON9pVeo4norjsfWWvVncYYac4Jxpx%2Fmy0Okl6jXH3qie7rrL7LQfuffNYZVyw097ojDdVp9plWOe2US7799rva%2FZVXXt91p6OmnHgJg%2FNhOO3kS7%2F55v9dfenFXjtvf%2FjE4y482vCzr7z8ttdfe1dTJ6mDFB1jsgkXffbZV2qlwm47Hj3MYDOcctIlpZKCcdbpVyw4z7ojDtWp03QrHHnoGR99%2BElt3%2B2pF7fd4uCJxl1olGFnW3yhjc4566qyzu%2B%2F%2F4dbEGtDGCmNreHM0y5fdcXtxY%2BoKTrGsktu8frrvffevbPxrXaCsRZYYuGN992rS4kuee7ZV7bf%2BpBJxl14pKFnWWT%2B9c849bJ21Qzqyhab7r%2F26ru89eZ7dWEphEAIhEAIhEAIhEAIhEAIhEAIhMDfmcAv6hibb7zfSEPOcv21d6MkKehJx180xohzjjvqvIvMt%2F58c649wpCdyA533%2FWYq5wNBKeMPfLcY486z8Lzrj%2Fv7GsON%2FiMs8%2B0qtSXrt5956MuCWAZfYQ5KRgLzLXO2KPMM%2Bpws7PiC38hGBpLajHHzKsutsCGk0%2B4mLwWe%2B12HC8ODegDs820yohDzWLYhedbX19SyeWX3FL6Nv9WHeO5nyWUcnWHbQ4ddvAZTzvlUl%2FJI9tve5gcGlNNsuRiC27Ef8Nc6625W0nO2e3pF2eedgX6xryzr7Xo%2FBtQIZSPPOxMziR0jOWW2nLMkeZ64P4ny7Duetedjhx20Bku6frfwJyqY7z22tubbrDPNJMtNdbIc40z6rxSdmy28b7cNro%2F%2F%2BrsM6868jD%2FvRGDTzzOQsMPMdP%2B%2B5zghygD1r89XnljwrEXJOlIr1ErUwiBEAiBEAiBEAiBEAiBEAiBEAiBvzOBX9Qxtt78wDFGmPPG6%2B9BiTLA8J98gsVuvfkBIQ%2B8CI4%2F7jxmuAQUwh%2FeeP2d2WZcZZJxFyF6%2FPDDP7%2F66tv99upCythxu8P%2F%2Fe%2F%2F3HvP4xOMucDYo8wt4cbnn335zdffntTlolGGnXX5pbckDmi%2FwzaHkQv23qPzJx9%2FTjDhETHXrKtrzxWElMEtYeRhZzvq8LNM%2BsOP%2F7zislvJCwvOve5HH7UepFJ0jEknWKRbt5fM6z%2FZSh1fYtmTjrfIY48860auuuI2bhJLLbrpyy%2B99u%2F%2F%2FOf113pTJ7heXHX5ba4ecuApboo3Bbnjnz%2F%2B87ZbHrAMck2PHm%2FAxRVk%2FDHmf%2FBnBxU6xh67HkNtuOySm%2FWtcSWffPLFF19%2B%2FdCDT08%2B4aKUmR6vvP7ll18LK6GHDDfYjHvtflwZ%2FM47Hpl0%2FEWQ4W2ie%2FOD8E033Hf1Fbc3nVKaDVIOgRAIgRAIgRAIgRAIgRAIgRAIgb8bgb7VMW64F5nTT710%2BCFn3mev4yulTz75fJ01dpUI9P33P77o%2FOsIEZwQjMl1gYFPJZhuimXm7LSaLJf33fsEJ4q1V9vlH9%2F%2Fo3R%2FoXuvKSdenHOFRBmv9eo987QrzjTNCm%2B%2F9f9iKM487TKuC1defuuz3V6WZWKe2dd8%2F%2F2PxHEYXFTImqvuNN7o8z%2F80P8vqsXIdAxZPUcfYQ55Pjdef69N1t97kfk2GH3EOUYddraDDzjlxx%2F%2BSR%2FYfJP9LPXyy%2F7rzvHPf%2F7L364X3jDS0J322PVY4%2B%2B9%2B3HcQogwxBaXpLTo0vmCnbY97PXX3ta3L3WMkufz5Zd6TTXJEvPMvsann35uKJ8D9j1xuEFn3GfPzmVwNaefcumuOxzZq2f7597%2B1Cl%2FQiAEQiAEQiAEQiAEQiAEQiAEQiAE%2FkugL3WMm37SMfbds8tIQ83C5K%2FseDtQIagZDPx99ug8wpAzC6BYaJ71eEosNM%2B6QidoFzNOs0LPXm8JjuDVQOX4%2Fh%2F%2Fp2O8%2BEKvaSZdav451%2BG68MhD3QRrrLbS9k3fg%2B%2B%2B%2Ff6jjz6TFJSrA4eHicZZSDSKkct%2FlI1xRpvnumvurIspBTrGjFMvN8ows4rm4Ocwzmjzkixk%2BJToo%2BTM%2FOjDT%2BX81EAYi0X%2BtNT1Zp1hZVNstN6eEoCIGeF2opcGW292gCm4UpTBv%2Fv%2BH79Kx3ih%2B6t0jLlnW6P6jTz84NNTTLiYueaYeTVyyrXX3NVultGWm8rXEAiBEAiBEAiBEAiBEAiBEAiBEAgBBH6VjrHzDkdITXnlFf8Nvmj5cGPYabvD2f7kCL4Qow3%2F3%2F8U%2FDfLdCu98sobRcfgHSGKpPRt6Bif33P3Y1JnbLDO7v%2F4WeVojn%2FuWVe1jjz8HGOMONeEYy1w9ZW3N1sql7gSUSeXXXKLLKNPPP786ivvYNnHHHVOadm79wedpltJag6JPkb%2FaZ2W%2Bt%2FyCHNssPbuZXmPP%2FbcFpvsP%2B5o8%2BoodQZB4%2BYb79Pd1d%2BpYxjkySe6UzAEpxh8mEFnmGma5a%2B56g7uK2V5%2BRsCIRACIRACIRACIRACIRACIRACIdAnAr9Kxzh4%2F5PpCeeedXUdjXzhZNI333j3u%2B%2B%2BF47BH2P%2FvU%2Fo9epbr7z8evlPuITsE7w17rn7cf4YfdIxqA0cJ5ZbcovmcatffPGVbJnSZVx68c3iWQgIcoH2%2BHnknj3eNHgJ36jrUSg6hnNXX%2Fw548QD9z814VgLSub58k81Tm6dc5bVxx9zAb4Qr%2FZ4s6xTUs2er75VTkf96suveW44R9V93X7rgzQHMSmzzrjya73aiSsx4567Hds2P0ZZWIs%2FhngbNyjdB%2Bxvvfnunbc%2FvMn6e4058lwzTrP8qz3eaN5FyiEQAiEQAiEQAiEQAiEQAiEQAiEQAm0JVB3j8stubfdqM8%2FnJV1vomNstdkBJaeE9u%2B%2F97FEnXJcyFzR9YLrORisvtIOJXyjjPZi955SZyjfc9djfdIxBI%2FoLmZksvEXff75%2FzunVZfOx5439WRLXX7pzSU%2FxszTrNBMImFqiTfLLM2%2FRcdonrtKRdlmy4OHHnj6PXc9VgZRXx3CIpPnaT%2Bfwao7dwh5QcW8iDrZaN09ll5ss56vvlmGlY5jxWW3HnHITnff%2Bdi%2F%2Fv3v1VfanudGcc%2F4qcF%2Ftt7iQAlLW%2FJ8NnUMmT1E32j86Sefr7%2F27sssvjnZpAxOCHIcreibu346trVU5m8IhEAIhEAIhEAIhEAIhEAIhEAIhEC7BIqOwZ3gwP1OfvqpF556snv5T%2BwDzwdhFE0d46233ptrltUcJHrMkWeTFDgbOMx0mEFm2GKT%2FYzzzjsfLjjPeiMOPcsu2x%2FxQvee%2FA26Xni9RBPrrrGr80adV9InHYODBBnhiEPP4M7B6eKxR5%2BT7fOSi26cYqLFJ59g0WeefunHH380BSFileW3efSRZ3r3fv%2Fuux5dapFNuFX06vl2y3211TE0ECfivBJnubo7X2%2B56X6LcVDIOWdd9frrvZ0Vsv%2FeXcYbY76zz7zCUjmNDDXQdFtsvJ%2FzWcwljsZdSMfx3HOv6CsNiFNWV11hO%2BqK%2Fw47%2BLSJx11YNE27OgYXDke4SpHBrwMxzhgbr7vXYANNvdF6e4l54f5x%2FbV3dZp2RYOX02mb9%2FL1V98cfeTZB%2B9%2FCj7N%2BpRDIARCIARCIARCIARCIARCIARC4G9LgP4gtGGIgaYlZRAo2OPlv1GGm23D9fZkd2%2B%2B8b7DDz7TddfcVRA5cVWAxtADTzfxOAuNN%2Fp8Qww0zTKLb1ZjIu69%2B7HZZlyZCCD5wyTjLezQVdLBZRffxKXhrjsfcSDIemvuVvNj0DomGXfhOWZarThsfPzRZ5tssM%2Bwg81gARQGk9IHLr7whv%2F8%2B7%2BJI17v9TZHCJqJq6x%2Bq%2BX7cehBpzqGteW3ozyYlCjRrdvL9RI3jP336jLoQFNJwaELf5Ljjz3PcSfDDDqjNVitG1ly4Y2f%2F0mp6P58D6lKBx9oajfIRYQLitwdnY8%2B1yAG7P78q7PPtIqrsmdIH8qvg3vGiEPNcvFF%2F01%2F%2BsTj3eUXXWSBDYo%2Fxrfffm9GuPBcYekt8RTGssQimww18PS6G9xdGJxe4Yeoqy2Fl17sOfao89B27r%2F38ZZL%2BRoCIRACIRACIRACIRACIRACIRACf08CFIarr7r98ENO5w7hb%2F2PRHDFZbfKenHdtXe5xPWi8nGW6NGHnykN5nZbHXLGqZeJCqmXFLhhnHj8hdtsfuCWm%2Bx%2F1GFnvvD8%2F3WUieKow8%2B88vLbhHWU9u%2B991GX4y4487TLv%2Fzi%2F04DYeZfevFNO257GF%2BIg%2FiHPPlCc%2BTPP%2Fvqoguuc3Xzjfbdd4%2Fjb7vlwSIsNNsof%2F7Zlyd36dr5mPM4ZjQvvfXWu8cedc5JXS4ssgkPkIcf7HbgfieKMdl%2B6%2F%2FeyIcffFLbv9P7g1NPvmS7rQ%2FZfKP99tv7BO4fzbm6d%2B958P4ncRGR2lSszaVdbzrikNO7Pf2i7lxWjj3q3PPOuebbb78ro732Wm8wN91wnxOOvxBPlR%2B8%2F%2FFZZ1yx3VYHm3rfPY%2B%2F9eb7a5xOXYCCo1vOOPVSMHOgSRNLyiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiHQLwn85z%2F%2F%2BeKLLz755JPvv%2F%2B%2B7bz%2F%2BMc%2FXPr3v%2F%2Fd9lJ%2FUmPZVvjtt9%2B2rOebb775%2BOOP%2FW2p%2Fz1fcbjqqqv233%2F%2FHj16%2FJ5x0rd%2FIPDjjz%2FaOR999JFN3lzPd9999%2Fnnn%2F%2Fwww%2FNSo%2BJvaS%2BpXGzjS5G8zFys75Z1qblqhoPoJGbn%2BbD%2BK9%2F%2Fauss6Vjy7AtCy5XjWk97V6qw7Z7tTl4yiEQAiEQAiEQAiEQAiEQAiHQXxFgHO2yyy7TTTfdQw891HZh%2B%2B233zTTTHPHHXe0vdSf1Dz44IPTTz%2F9mmuuSbVoLun888%2BfeuqpL7300mbl7ywzZjfaaKOBBx74zjvv%2FJ1Dpfv%2FkIA9f9ttt%2B26666b%2FvTZaaedrrvuuiod%2BHG32mqr008%2FvWng%2B%2BnPO%2B%2B8bbbZplu3bm1XThPQy3NUBjTyfffd11T%2F%2FvnPf77%2B%2BuuXXXaZB%2BrVV19tjvDMM89s1%2Bbz6KOPljbPPfcc3cywm2yyiWHtdiup3c1bht1rr71eeumlWq%2FwzjvvHH300VtssYW%2BFnb%2F%2Ffc3O2pch91tt90M21xtc5yUQyAEQiAEQiAEQiAEQiAEQqB%2FI8CmW2uttdq1zRk%2BLKCRRx65f9YxrG3IIYccaKCBDj300Cbb448%2FXuVpp53WrPydZUAYhsMOO%2Bzdd9%2F9O4dK9%2F8VAQ4V55577gorrLDeeusde%2Byx9skGG2yw%2FPLLn3HGGcXXgqax4oorrrrqqvfcc09dpJ%2B%2Bc%2BfO6h955JFaWQs333zzSiutROPq0qXLMcccQ1VbY401Hn74YQ0oGLfffvsBBxxgwOWWW86kL7zwQu2oQAAx7CqrrKJB%2Fdx7770u9ezZc%2FPNN1d55JFHHnfccWuvvbZH9amnnnKJgmETHnTQQa5avOnoIerL57PPPttjjz3U77vvvjrqtfrqqxMrytU333xzyy23LMO6%2FTLs448%2F%2FnPv%2FG8IhEAIhEAIhEAIhEAIhEAI9NcE6BjrrrvuEEMMcdddd7VdKF%2F6t956iy3WvMTx%2Fuuvv27W1DJLsOVS8y2w6I%2B2ASClr16GreM0C9bQp0uaMQOHG244ksU444xT32KrP%2BGEEwYZZBDGqXJZQ3MltaZZyTb86quvmi%2BmWxascdExHnjgAcNaM3oKbT8uNd%2FmNxu4lw5up9ky5T%2BDAHmBaMAH47XXXvOL%2B7l79erlKyngySefNOP111%2FPxqdLbL%2F99rwayhr89DQKHZt7rFz69NNPdd9www0JFJoZk7a28sorEzSU7QTeFqutthpJgWvE%2Buuv%2F%2BKLLzbv69prr9X41ltvNY6PEBIfz5GhzjnnHHrLFVdc4QG0TsKFBVAelO3MHXfc0TrLsLSIZ599tg7rWbb%2BU045RTONbVd3p6XFaMMtxLBclcqwXDUMe9RRR%2FVpM9dhUwiBEAiBEAiBEAiBEAiBEAiB%2FoFAxzoGo2nZZZd94oknylLffvttJlunTp3EoXjbW94al0tffvmlt8azzjqrS0suuWT1WGCmLbHEEqeeeurWW289wwwzaLD77rt7X1zvnQe%2B99R6GZZp5mVxvcTi8%2F5ar5lnntkb5HfffbdeqoWiY%2FAnIWVYEiGiXGrqGNa%2F%2BOKLn3zyyWxDV%2F31In6ppZbyCls0AXd9b6u9tp533nmFqCyyyCI33HCDV%2BFmNLVVbbzxxmVVOqocaqihrFN3a55nnnkYm00DUMDCoosuOu20084%2B%2B%2Bze4JcEHRIgEEC8dtdxjjnmYNg2pQxr2GeffdikYgTqfaXwZxDwS5EXmO3VOaHMwgfDDineO359v4WP7WTPlB%2FXT98nHcNWsUMEcVS577333qNXCCEhI%2FhxyQjaGMeeJDi06BjCVYgML7%2F8csv92iF2BXnkjTfeKJc8NTvssMO2224r6wWVzLDytJjUQ2qEqmMQLigYasSklI5UkYMPPnidddbRnrTCi8NjZUnlqs1pWKE0LZFZ5Wr%2BhkAIhEAIhEAIhEAIhEAIhED%2FRoB51YE%2FBgONV4OXxZbt3TQbn1zAup9vvvkGH3zw0Ucfndnukve83jW7JJnGAgssoMuYY45ZTMWTTjpJvc8oo4yi16ijjqosBoS1pSOHfC15g8w%2F%2F%2Fw0BJeIAB988IFLrDCjDTbYYLQCM7pE7mgKINr40DF0X%2FCnDzWDYFLqmzqG9evOAKw6hhAAixQ4wNJcaKGFXPWVZDHTTDMpc%2FCYaKKJRhxxRKOOO%2B64apilJX8Cc89Xn8knn5zuoRcOZ599dpn0yiuvHGmkkUYYYYSFF1544okn1kwWApamN%2Bxl5J%2B6%2FldvKW%2FGSy9v4WeccUaLJ%2BmUmvz9kwhATUQSrNGiidGp%2FIgCQMxLx%2BAgIRKE8kbgKnljOtAxSFU8OgxYtrQRnn%2F%2BeQ4YdmBVNlQSEOhaLTqGSvKIOBSOQxZAExO3UpQTYgXVyxpsnkLDXrUqUTB0klLjr0mJck0dw6REuaJa1GYGp8xwJjHIzjvvTI57%2F%2F33y1Ubm6sGwaQpIdaOKYRACIRACIRACIRACIRACIRA%2F0agYx2DF8EwwwxT7DviAzNcVkAvcL0OdnIHkYGlr9y1a1cWPfOQzsCwYs7rxbZipnnHrReXjO7du2vJTCNlzD333Mw0jeecc06GP8vRJcPy5NeYFmFVXhlLfMEHnl3GVCyXLrzwwhaAdIxBBx2UuSfAn%2BYwwQQTlBfTTR3D%2BmkdrMKqY1iqFdINmHWLLbaYiazTLNw5JBawBg4Vjz32mGV4Uc6DYrTRRitpDegY7tR0joHwmtvKxxhjDI19Jb9MMcUU9A0dTdS7d2%2BajJu1HrfGEcUajjjiiFdeeYUJWW1et6OxpAfEFotpubt8%2FWMJ0OIIbnaC7denkcWV8JwRzcHhgY6hcRHW7Kh240paxrGFPCk2%2F9NPP928ZBu31TFoCJQuLk%2FLLLOMLksvvbSIjzPPPNPGI19Q3qgudak8NA455BDPRVNw8Ii16Bi2kwQg4kqqu5TNZmo6BmnR8opTR%2FW%2B8OiRRwzbkoC0ufiUQyAEQiAEQiAEQiAEQiAEQqD%2FIcBi6sAfo%2BgYtAI2Ox%2BD8ccfv7qjM8FEiLCz2IbsO9kvr7nmGmWxJ0x%2BSgVJwYtj5z7QGbwOLrfMD4Ebw5RTTsmW93aY54MkhEVe0EBH76Z52hM96AM8McgIEnQY1uA8H6ynqQDoUnQMxqlBGGskCN4OLD4RAQSHkh%2BjYx2D2uC%2BakyH5AZFqSgLNizRg7NEeS9fgFQLURuuHQQK%2BgxNg7BD4qBglDWLLLAet0PHEBozySSTFIu4jJy%2F%2FZ4ABYCLEYcEYVB9mp2OUdNHkNQoDPYSFeLEE0%2B0z8llNqcaXykbjsXx49ahPE02PMchG57CUOsV2tUxPFYXX3yxfSKjBRnB3rPZ%2BHKQwugY9hL57tfqGOYioVBgPJsip%2BzGErriQbNLrVY9hSQ6RvPXSTkEQiAEQiAEQiAEQiAEQuAvRKBvdAxGFqtHoAQJgmd%2B8%2B6oCmpmm202lj4rvn7Y76JO%2BB5QElR6QVx6eR3MhWOqqaaiY1x99dV6HXjggc0B6QbGNCMfCQJIHVDZmEw8pl%2BzfdUxVLL4iBLGZLj9Kh1jvPHGqy%2BjiR50DO%2BsyyzMT6almhImQ8doOa%2BEiwUdQxoQhq0V1gUrWIkaaUMsjI4x6aSTtoQzNG8k5X5AgCC22WabcbFoG6BUZy86xiWXXKKGEMfkp63R3ERI0THkWiFYyQDDxYgnDzcGY5a%2B9i1PJI4QtnRT3ChX29UxasdS8FekFW%2BQs846yxNHcPBpq2NQyWr7tv4YLnGxMEI5BoWgYdmcPciVFBhCor3tAFkeRGWQ4o8hcqpKlHXwFEIgBEIgBEIgBEIgBEIgBEKgPyTQlzqGt8OTTTaZBBctOoY7UsNIF6bBKULUibSZPgrSCTLbxWuw6AX%2Bl3tv6hhsf%2FqAZm2xeDFNuJBIs4xW%2FhqTHtInHaP4aYgFcFCss0ukbTR4X%2FpjtNUxvAcvq%2FpFHeOwww7jKOJepFukWpiXm0pdsDVLIYJedIy2v3K%2Fr6EJ%2BGU32WQTXkPN2flp8ItwcgcZjUzBH6PoGNrwvaGeMfypH8VTwiBydZYP%2BYsOUIa66aabxG7sueeezfwVdZZ2dQx9PRGewdqM1EBzECpiz%2FAbEcdUBQdxTxJZ8P%2BhAdb27eoYrnpMeBBxCxGKRYQRLUXAsTCxSzYnOa4usiQU5abSwqROkUIIhEAIhEAIhEAIhEAIhEAI9FcEqo7RbpLJEkZR4kpY6PQBLhZl%2FewyeQDY7MQKcf3SXLQkBCjN%2BEW01THElTCjuOhLiel9cZEgtBfcwcZkQnbr1k2aTcOapeKqzWqNQvXHKFfZoQ6koCf40DGkGihteHdUaUJNua%2BSH4MLx6%2FSMSg2EoSWNZhUQkWDuxcZFUzK0iyXmn%2BjYzRp%2FA%2FLrH7BIGSKkp%2B2ruTyyy8XP3LBBReoafpj%2BOoBESoiWKl4NdAEaq9mgQbCbYNEUPWB5lXldnUMGUEdACRmqjYWUSLLqKeGEFEPGSlXP%2FzwQ7IGcYP0Udv3SceoDRT4kAgqobNZgw8HIV9LvhdXbc6WAJZm35RDIARCIARCIARCIARCIARCoH8jUHQMUoNIfyc8UhJ8XnvtNb7rLCnnGjDbeUdY9lFHHcVOZ0bxmWcN3XjjjS6JNPGauCTzZOiVV7qUDQbXXnvtZQQZBtrVMXjjy1GgOwGEM4MBfeW9YAqWpjF5xXPJYNDxhGesPfzww9IOmLQFYIuO4Sr%2FEGemNHUMWQLk25Rp0615%2B8xnQzZOi%2F8NOob7Ei1SzozwapsFKo%2BH41RManAZMKTa8F7e7ZiIXewN%2FksvvSSKoQN%2FDGKIxAWO%2Fkyez5Yf98%2F4SiJjxROy5Fa1zXyY%2Bb6KEPFLmbFFx1Bjr7L0BYzwlGhXxzCmLcFTQsHvbseWj%2F1fb8GWaJvn05NCuDM47w6bnKQgTInMUp44USrKniCjGYpQ5itprino9UnH0Mau8yB7ZPhaUNtoJmUxQleMIwzKsBxCPH2eNUe1GqquNoUQCIEQCIEQCIEQCIEQCIEQ6G8J0DG8R2b1s%2BuZ5JJalI9DOhhBfNGZ7eXcVS%2BaJd7UUnYLmgAnBEeplqNMWEzcKlxixc8111zcG5T5PzC%2BStaI4hcBglfJGkw44YQlxp9JRceQX8KAU089tV6yZ5RkmF5Ml6NLRZfIv2F5AkYY%2By0kLUAvVmTTuBML4IQR9QQW7ekDNBZfzeuEUwkulE1azl115KvGPXr0KCOXQ1rpFeUr85N9qr0X7mrEzij7uFNDGURQST1FhRZkneV2XNXMXyaqSARnyDpOpeZSKIOXvzQQ96gxgaVZn%2FKfQYC1bhfRl2wJoSLcIRR8VVm2ELt%2BqaWWuuiii5qzk8s0Y%2B%2FT05r1ykV%2FkzGDPCL1hG3vU1Jb3H333bWxjXT00UcLPKmOEC5xHzKvYekMAlIIGtxCHEpiTFc5YBADyXeG5emhGb2lJYuF2%2BFfQWMh1tW5FESj7LPPPqbj1GTTeiLMVRrYb7yGHJJiWMoh9w9yZd3%2FzUFSDoEQCIEQCIEQCIEQCIEQCIH%2BkAAdg3nFVmJAMd%2FqxztcJx14S8t8e%2FLJJ8vKuVsIJKFmzDTTTISLpt3NVJcpgiPE9NNPL%2F9hly5dHGiiV3m7XZQQX0kKQvVlzmSjlTE5RRBSOnXqRN9wqWnpM8023HBDEgdnBstoN%2FLFW3Uvl03X1DGU1bDU6CRlll69etE6ZpllFqN5A06mYLo6EdUi9957b7O42dLSzQoiEEpQvjI%2FSyRCeZ1NltFR%2BgLEaBTuVMqO5ots98vedGn22WdnHprXOOWQCKJQzXVQBi9%2Fy%2BGb%2FAF4dDTrU%2F6TCLDoOcDwfCBi%2BNj%2FvlYzn1IhZ0tTgrAMXhZ%2BaAoDVaplVTQHaVhcEmalY%2FlI9anQFD1sJIltaQ68npojqPd0aE%2F68HCZhc5QG3jiuCRxgiK5CJhqO7u9x23j8MMPLzutduTaYd%2Ba7tJLL20b6qKGm4dhSRluv%2B2wdZwUQiAEQiAEQiAEQiAEQiAEQqB%2FI8B8Ywox%2FDv%2BNJfN7uaR3qyp5ZK3sGnX12FLmzKdBtVsLPXc%2B4vuUYeqBZf6NJ02Zfy2A9ZLdRwF49TYjdKx3n5dT8uC9ap8Srm25FvCCG2OX8susXzr17az1Esp%2FA8J2Ax2V8sCbIDyi7fU%2B9pufflxXWr7MVRzkI5HtjmbcSjNjvZS23XWBmXYui1LfVlVbdNuoeNh2%2B2SyhAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgb8JgZajEH7xrh3c0HGbX2zQ7N6n2Wt9LTR7pRwCHRNw1kzLeSJt29tazd1Vvrb9Wzq2rW%2F2bQ5eWtaadjv2qW%2FtlUIIhEAIhEAIhEAIhEAIhEAIhMCzzz67xRZbXH311S0obrrpps0333yzzTbbdNNNd9lll2OOOebll19uaeOrYxwvuOCCrbbaatVVV9XyrLPO%2BvLLL5vNHCt51VVXbbPNNqutttomm2xy6qmnfvrpp6UBo1Jfg%2Ffs2bPZRfmzzz7bb7%2F9dtppp7feeqt5qVevXttvv%2F1xxx33448%2FNutTDoGOCbz66qvnn3%2F%2BEUcc0blz52uvvfaLL75ot%2F0999zTpUuXRx55pF594YUXTj755FManzPOOOONN97QwKnBV1xxxUknnVQvnnjiiddcc03L5qSc3HzzzYb1rNVhH3rooWZHU5x99tnvv%2F9%2BbZBCCIRACIRACIRACIRACIRACIRAWwLe%2F%2B62224DDTTQ3HPPTTpoNjj88MPVDzLIICOOOOLQQw%2BtPMIIIxx44IGEi9rsww8%2FXGONNVwaZphhJp100pFGGkl5mWWWefvtt0sbksWGG26ocsghh5xkkklGGWUU5UUXXfS1117TgLlH3FBz%2F%2F331zFL4b333pt88sld2nrrrckd9erjjz8%2BxBBDzDvvvD%2F88EOtTCEEOiZANFh33XVXXnllgttGG2207LLLHnzwwZ988klLL1rHxhtvvMQSS1x00UX1EtHDll5yySUX%2F%2Bmz2GKLLb%2F88k888YQGtjdVrV5yfaGFFtp1111bNudTTz219tprL7XUUtSMOizpY%2Bmll659PRSUwFdeeaU2SCEEQiAEQiAEQiAEQiAEQiAEQqAtgd69e0833XTkgmGHHfbOO%2B9sNjj66KPV77XXXmyr7t27e5c9%2FfTTq%2FFGu3jmUyG4UqhZa621nnvuuc8%2F%2F7xHjx6sRTU777yzNj7777%2B%2Fryw4phydhDcFBw81PEB0F2mi%2FaCDDvrggw82p1b2YrpMR0K5%2Fvrr69Unn3ySrrLIIou0mIq1QQoh0ELAxttxxx3tUnIZNwxb69hjj11hhRVuu%2B22ZkvOFfb2iiuu6NIll1xSL3EZojBcdtllD%2F%2F8efTRR4vo98477%2FBZ8hQYuVy0kz0sdn7tzj1pzz33NOxKK6106623lnoNjjzySEsikpSOlBbayNdff107phACIRACIRACIRACIRACIRACIdCWwMUXXzz44IPPMMMMnBy8WW6msCg6xumnn157iSvhIzHuuOOy1FT6O%2BaYY5JBPvjgg9rm9ddfH2%2B88SaaaKJ3331XecIJJ%2BSG0QwbKY4WOqpkzf2ijkH0mH322RmMZYq2Ogb95Nxzz6WcMCeJLS1RLXVhDzzwwFFHHVXHqfUpDPAE7EN74%2Fjjj6%2BOPYQIwsKZZ57ZvHeyBu%2BgddZZx6WqY9iiIj7sUvu52biU%2BW%2B4JDyk7aVSw99JUNUqq6zCH6OpYwi22nfffWkgfQpv6dOAqQ%2BBEAiBEAiBEAiBEAiBEAiBvzMBL6C9ERbrwcbnEk9w4C9RgRQdgxFXaxQOO%2BwwwoIcF8p875WZY80G3CS23XZbcR8vvfTSddddp8EOO%2BzQzF5IKuF4P%2Becc1Ik1HegY0wzzTQjjzzyHHPMYRBdisbSomMIYOGcr8Hwww8%2F3HDDKTBC25qcfD9Yka56t95cbcp%2FBwJ%2Bfe4TTVeHW265RWzIpZdeWm%2F%2FzTff5Cm0xx57kMKEn1Qdg%2BDASWO99da7995777vvPnkzmqrd008%2FLa6KQiLcSWINmTRa3ITU6HvIIYdIqdHUMahtpBVeSdwwjKx7cfCo60khBEIgBEIgBEIgBEIgBEIgBEKgLQGxHoQChhhDT35CZr5Mg7VZuzqGEA%2F%2BG5QKzbg36CLMv3ZpKZQx%2Bc%2B31Nev5u1Ax5hyyimnmmoqHvvTTjut%2FBslt4A1l7gSfb1e50NiDaIGvBkX%2FyKZhq98M1ytsygQTHTn3t8UapoNUh7gCdgDop9oEXyQJMHYbrvt%2BGmUuyZWSGPrQXjmmWfuvvtugoM25RLBQWiVSBO6h78SZchVS3woV2kXdLNydbnlltPRODWN7VdffUXlo2PYdeJHNKtxJWJb7NXS0ciGtZ5mFtAB%2FufIDYZACIRACIRACIRACIRACITAbyBwwAEHyE1x4YUX6vviiy%2BOPfbYMhx%2B8803Zah2dYy77rpLwgpO8qzCkvvCi%2BY%2BTe1FNlWBZdenBr%2BoY0w22WScKxwJIU3oPPPM4511t27dio7BPYN2IciFa4fQkjKFzI2zzDKLqJboFX1i%2Fret5ykhSy21QZJP4oNTRSoKcoTQj7KT77jjjqaOYWvJCCo1qAAr%2FkWS32rJj6IEKBHZCHG8OK688squXbtKImpwHh0lP4YpDFW8PkSXNHUM0VVcjHiACIkicXiUXOWh0Tb1aF1kCiEQAiEQAiEQAiEQAiEQAiHwNyfgrfGss85Ku3j%2B%2Bee%2F%2FfZbEoEADRKBrIOFTLs6xu233z7UUEM5X1Ub3vJkimYCjRaksilqYJyW%2Bvq1b3QMkSOamdFQhJHiQyLPJ1ORyamS90UdkLrCV2TggQfmq18rUwgBBAhftrpktnQG%2Fhjrr7%2B%2BqBD14kS4Rjjht2gIGlTxwVXbTH2NVDKIoCoyiFOJXeXI4VzgKv2Jpdpggw3sQE%2BT9C%2FKlJNy1bnGlAqPj14%2BxnHWTw1RcQbQQQcdRCGpnh6lWf6GQAiEQAiEQAiEQAiEQAiEQAhUArIa8qwQJDLXXHMtuOCCCyywQDk1lZMGNUCzdnUMqRGpBPvtt58GUme0lSnYfXICEENK%2Bk0NSuM6r8GZe%2By1jz%2F%2BWGBIx3El%2FDEkLtDXSSjCTGQH3WeffaTCcEilidiSxt97773r4Aq77767SgkQmpUph0CTAD8KSTA6d%2B4sRcw555zDicLpwxwqfHhf0BPEkkiRUeWLZl85Qgkd%2FCialaVM1rA%2FHTTsEfD4GNbmF6JiWPVmJFbw3KiBJ80ROHsIMOGb0axMOQRCIARCIARCIARCIARCIARCoBAgAnhrLKjE2aa8Mjp16iQcQ4E%2Fxswzz%2FzRRx9pVnSMZvoLb5adoCrEoxxYKcbEKSess2ZuQ%2FKFQI%2BJJ57Y%2B%2BjHHnvMca4yiEoUUMkbZKGFFhpnnHFkA6Bp9KWOobv4l8EGG4zwQqYQ%2F%2BIWqCW%2Byj%2Fg7XYZnzAic6kVOsKyzpjC35yAICPbmPdO5cCJgj8GkUF8h5gOygPfCdFSDitZffXVnbLqo8aJPFw46HXyZtS%2BUn3SMexGGojDWGkUNYNo0TFKAg1BIoY1SxnWSSjls%2BWWW77xxhseDcMKjKrDlgQaN9xwQ61JIQRCIARCIARCIARCIARCIARCoBKQ4VASCeeZeuNMTKAJ%2BCt8g5HF3aL4zMtrQTHo0qULccAl8R3C%2BdUwx4rhxnmeRkFbYNMZweAG8XabPCJEhas8yYIHvgGbR14y%2FagfPEBkUNSr73UMAzIJLcCHjmFVHP4djDLqqKNWb3zv2WUuXXjhha2t3mwpmO61115zIy31%2BTrAE5AAVg5PyWC5AJWbLck8KQmUB48Anx%2FKRvnQJcgULvlKqZCPxR6WFkPslb7ay16rxgh2r1S3GtdQLKIHVY2CIWakd%2B%2FezWGFX%2BnliB%2F1dD8ZOXhrCFEpG5LQR07xZAmbGuB%2FjtxgCIRACIRACIRACIRACIRACPwGAgwoakDLkanGEY4xyCCDeInMvCr%2BGCI7pEZccsklRXPoQjdgFdYZWXNiPQgXTDABHXQGssYYY4zBTCttmHgTTDCBjow46RCNLL2Gk17L4SPFfcJVB7%2FWMUvBgQ6TTjopsaXElZRK5004HFZ7cSXFCYQqwiXDFGYXGiDtJ5GE637LaCaSA8GlMm%2FL1XwdsAkQH4SQ0A0cWGPDOJSH3wUBrellUQnIjyG%2Bo567SouTlUWNTBeXX365w3eU7eQilHH7oZDQLs466ywCiPyf9rkIkTpaLcjzaQE1P4bQEttVYwqJYQVzuXrooYearnZJIQRCIARCIARCIARCIARCIARCoBDg2MDvYooppnj88cdbmIgoWWyxxeaff36uC1JhCA8Zf%2Fzx5QL1V7yJvJ1eNLd0IUFQFUYbbTTRHFwj5Nl48MEHm23MIsPh6KOPrgFnCek4qB%2BlAbVkhx12IJW0XYmJuFUYzfvr5mjnnXeeVTFCi47hnbh33NNNN50AFp8ZZ5yRBaqy2UWZjsH2pIFUQ7KlQb4O2ASEO%2FGIkC1W2AgRg8sECYL7UNu7djArv6NmngpbkUORI0v0JcRJb1sPbLXTZJS1h0UzETScV%2BKMknZ9fvg4mb3qe%2BY1yGGHHSZ6xbAiUExBu2u7ntSEQAiEQAiEQAiEQAiEQAiEQAiws1hMrDPWfVsaXjRLGuC9sEAMzcrH6%2BO24kDtaxxeE95uM83abcZg5KWvAXmkOanGRrYSDvx1tFLQjKjiQIcWq1C9ShElzYnEucgd6tPB62xTCCto9mqZMV8HeAI2m5QUtiIPjT7drPgR%2B6TtRrLr9BWEUpOx1BEIg1JeSAjjkamVLQVb1LAlOKVe8lx41gxr8FqZQgiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAj8tQg4EMT5I4516OBIiL%2FWHWW1IRACIRACIRACIRACIRACIRACIRAC%2F0MCDo686667zjrrrLN%2F%2Bpzz06eUVXbt2tXRq795ec6gXGGFFTp16uQ0yb4fxDmqF1100bXXXtuifjjI1cIefPBBJ1T2%2FWhpGQJNAjbPs88%2Be%2Butt95222233377Y4899sorr%2Fzwww%2FNNs4Xvu%2B%2B%2B15%2B%2BeXmTvv8888fffTR66%2B%2F%2Frrrrnv44Yft0tpF96effvqRRx5xQqvRbrnlFoOX8U1Ryv7ecccdNL3aS0tXHShcaxQcJWwWLZvjNxsoW9Vzzz3Xp1nuvPNOfd2CQcrUCm7T7dQTjT2YDzzwwL333ts8UtY%2FBU899ZQu9eBXNXpZ5JVXXmmQ%2BhS%2F%2FfbbBWAZv3mPanr16tVcMCVTAyO3PaO22UzZA%2B6mXnjhhSb2ljbNr%2B4C9ptuuskv8tBDD3VArNkr5RAIgRAIgRAIgRAIgRAIgb86gR9%2F%2FHGdddYZqA%2BfkUYaqUePHr%2F5Hhkas88%2B%2BxhjjMEa6vtBXnrppZFHHnnIIYe8%2FPLLm70YLJa51VZbNStTDoFfRYCNfPzxxy%2B77LIUthVXXHG55ZZT2HfffZ9%2F%2Fvk6Dhtf%2Fcknn1wNasb1TjvttMwyy6y88sqrrLKKwjbbbMM2L12%2B%2FPLLXXfddb311uvduzcJbqmlljJm%2BZiiFJZffvlVV12VAFK6eDQOOOAA47RsclLD3nvvraWnoK6nbeHEE0%2FU9%2BdJ%2FnsjpWzZa6yxRvfu3Z944onVVlvNpKW%2B3O%2F%2B%2B%2B9ftAgm%2F7bbbrvRRhsRGerg%2Fik47LDDtH%2FyySdVkjjOOOMMN6vGegy1%2BuqrX3zxxUQbymcZsAxeZ%2FfVAiiQdUyFZ555xiBHHHFEi1jUbFPKHnDoTNr2Utua1157za9mVSuttJLxraf5i7Rtn5oQCIEQCIEQCIEQCIEQCIEBhoCXpF7Lnn%2F%2B%2BeyvM888c%2FLJJx9qqKFYWDwxLrzwwiuuuMJr6N98syyjueaaa%2Byxx%2F5VOobGY401Fsli2mmnfeutt%2Brs3tWq3G677WpNCiHwawmQJogATO8TTjiB64JX%2BUxs6sSGG25YpQwv99WcdtppRcegTrD62csnnXQSB4Bu3bqdcsopbOett96aZ4IF0DH23HNPI2hJ9%2BOTUFwUdtttt9KruEaY7r333isLJlOsv%2F76zHAdv%2Frqq3oXdIz99ttvzTXX7OCRsSpLLbPwi9hll13oDFZbZiEyfPrpp48%2F%2FjjZYffdd9dA%2FVVXXVWs%2Fj322MNqOVntuOOOm222WdMbxNN65JFHGqroMzfccANKO%2B%2B889133%2F3iiy8aZPvtt1fjMXSbbtBH5VFHHaXy0EMPLTVWRWGot6PA%2B4W0cvTRR%2F%2BijmFkQPhcNbu3W3aD2GpsdnfKO4X6QbfxizQ9Xtrtm8oQCIEQCIEQCIEQCIEQCIEBiYB0FkssscSYY47ZtKGYDF4iq%2FFe9eqrry5GiogPlYyjG2%2B8sa3DBhvtmmuu8XZVrznmmKNFx3jjjTeYSBpUs7GFoV5Fx6Ba7LDDDjztS4O2OgYrjCXF6b340pdm2nsfzd78%2BOOPRQe4SqgpHvU9e%2FZkt5ra6%2FWWSZlmN998sxt0mzi0XPWVlce%2Ba%2FGZb9ssNf05ASIABYPpLQykLtVPzwrmrvD111%2BrbOoY2pP4uBmwr4usUXqdfvrpSy65JLnPV0JE1THqmAq0BToGQ7tZWcpEQlLJuuuuyxuq%2BD%2BU%2Br7RMVpGo64QH8gFzXqBJJSWU089tVa6NVKGSjv8iy%2B%2B6EDH8OzY7QcffDD9gYJRRzCFNVM2OJPUSsIFNwzPVK1pKfwZOoZ%2FW0zapUsX66zTiYOjbLR4g9SrKYRACIRACIRACIRACIRACAyQBL799ttFF11UGEjTJqJUDD744KOPPjo%2FDcKCN9F8JJiByuUjBuTcc88tJh4FgHEhGqVc0mu44YabcMIJizDi6nnnnUfWKFeHHnroQw45pO1bWo1pKaOMMoqVmJR8UWi36Bi%2BTj311P%2B3iIEGmnHGGYvByKhkYA4zzDDTTDNNvcrq4WQy2mijlZoRRhjBmuuPeMkll1hkuTTEEEOIIGjmDSjNxBoMNthg888%2FPxuwdkzhL0fARi06hh%2B0Lt4m5JVByuBrobLqGMp2Ah8GakOLXkfREnhCADFguzqGeg3oGDWWpE5HYSPQEQSocLQCakPNHfFrdQwd3U51oqhTFB3DApqiHCnGg3D%2F%2Fff3pY4BSFOEcZvSg%2FgHwT8UdaIiKVALa01L4Q%2FXMfxY%2Ft3gbcINozmX344fS%2BfOnZviRrNByiEQAiEQAiEQAiEQAiEQAgMegXZ1DHYKMWGQQQaR6WKLLbbwdZNNNmHyc0rnss5Fnzgw3njjFSuPiUR%2FIF%2FwM2faeIs96KCDTjTRREXH8OqWxDHppJPSEEgHs846q5H5ZrSQ1NggFBVxLlQFkSnvvPOONk0dQ0pA4xBJmJ8c2lmFVuhtLDPQe2epA6xwpplmYrj5FLnDUGuttRZ%2FDK%2Fdhx9%2B%2BOmnn74My2glxbgFEo01zzPPPPoKtGlZlRfTCy%2B8sLfYzZfRLW3ytf8n0K6OYdl%2Beja%2B7aHc1DFkkNhyyy3FlRAf%2BnR3zbiS2qYDHcNjQt%2Fg5kEZIJJ4rGpqzT9Wx2j6Y9AuZN4gTTD%2Ff1HHcBckC3Klx5zbFR%2BqqrTUGyyFfq9jiHQTXOZHaQkh4YjFecxvgXzLIvM1BEIgBEIgBEIgBEIgBEJgQCXQJx2DFuGFb7HfmQn77LPPXnvtVcM9GGIcFbylhcUrZiJAdXVgLnGTGGeccagc3qJ6W8pNwoEjBSD%2F9mGHHVaWgJb3p0XHWHzxxWUjFPBuQNPpQjZRLvkx2GIsLM75ZSj24AwzzEAw4SviLmQLJE1wjy9XGWJUDqIKI7HUeL3uprwoZ%2FJIz8gzhBhSLlnquOOOSxJpq1foXu%2B6NM7fvxyBPukYdhcdo%2BhXTR1DBgzCHRedkiiGe4a9J8LI7vJX2kwD%2Fip%2FDLtdsgjPQomrkueTpiEwqpD8A3UMHgtSZ5AjSA2XXXaZ59QjwJPBxu6b%2FBhW4vniLiLWTPyLmBQ6pIer5Rfv9zqGaDLKj2QdzeQeLavK1xAIgRAIgRAIgRAIgRAIgb8JgT7pGAMPPDCDqAmB%2BXbMMcd4Sb355ps7VpUmwByTNIPHAjFBGorSWM3cc88tkER7L08FenDVcLLDgT99jElAmG%2B%2B%2BbxFbQ5e%2FTHYjMXvQhiIKAC2npXUPJ8c5iW%2B4FzhBBMnLxjZ1CZyF%2BJKyqRlWM0oLSSR8pXhaeUkEdkzGKfcNozP0aKsSmpEwsvEE0%2Fs5MrmqlIeMAh0rGNccMEFbrOpY0icsummm1Ydg1C28cYbUzxkivDXAR%2F24a%2FSMfg22K6kuaKqiU%2BhpB1%2B%2BOHl6x%2BoY%2FA%2BopBYJLcKf5Wl8Sy72hPXQX6Mkuez%2FNxSzXDqIBrQQIh7npHqOlIaRMcYMJ6L3EUIhEAIhEAIhEAIhEAI%2FEUJdKBjsOPKTTEDHWLC0qcM0A2mnHJKAoKQDW75rDmaBlmgxGto781vOa%2BEslH8HPhF0C7Kh2%2FGqKOOyu%2BixTKqOgZ3DoMwLY2%2F0EILCfavOgZzz5KsQWjJZJNNVo5ZmWSSSaqOIVPoK6%2B8UtbswAUt64GtDE9HNNIxuPcXdcWwPy9qaKsS1cKNpBxFUUbI3wGGQJ90DB4LjP2SJbKpY3jpb%2Bf4lF3Kk0EyWMeP8qmgD%2FwGHUN3ggBVTV7Kc845R2QWbwdeSeWg1T9QxxBCQi2555576HWSdlot34zyO%2FJ06ksdo7T3aBM3OGJRM6QVbXol9Xsdo8SVcMmo%2F86URXJ0oc%2F4R8NPPMBs19xICIRACIRACIRACIRACIRAxwT6RsdgO7DxKQaOd2Td0AQcfSgRaPXHcKke6sEoq%2F4YXkNzcphuuulEczguxHtef0kWGgs5aS6sRcfg1OGFONmhHGIiFYbGogBMyh5k%2FZnFSuacc07xIL9Wx2Clyp4x%2Fvjjs%2FXKqizMAggvhm2uKuUBg0C7OgbPH2lgxVCIdXKbTR3DNnCpbSJN%2B0Tghjwwv8ofw0SSVPDlWHvttYWWmNHf4jghXYap7fZfPHe1%2BUN0nOez5sewnzmByClR1DlqDG8oj5XsH3U06oTboX64tTfffJPzBsnF3dUGfFGE2HCIIoPUyn6vY%2Fjnwjr9IjVwrCxGQlHqEGmoKbPUdaYQAiEQAiEQAiEQAiEQAiEwQBLoGx2D7SAKg7FTCfCLEFfidbYaIgPBwdvqctW70ZlnnrnkxzC4QHsJMZrnRDAbm5ZU6dWiY6jkWcHxw8g%2BRcdgoCk7%2B7J08a582mmnJUf8Kh2DdsHkkRTUqsT%2Bl6H8lQCByVa%2F1kLe81YUf91C1THqMSLs4ksvvZS7AqcFu9StVR2j%2FOJyv1DMhH6UU1nLvXsQmNLSTfwqHUNODNoFM1y4ik0rysP%2B9xRsuOGGHCQ8LxZTdIwanNUx6o51jHpeiUU6Kog3hbS3bsosZApnsLrTOj6JQ5iYtDNWRXUksPB5KHEopY0gL%2Bv0AJZUIaWyL3WMY489tk%2BPT62XyBfnml2nLqwUmoqKY2K0NCbZp151yq1nufqcNNu3DJWvIRACIRACIRACIRACIRACAwyBvtEx%2BGPIqCmagwXHB8P72SGHHJKOUbQLyoCjQJxhyvnc0Q%2FSaZbwE9IESs4oESEiBoRJJWfFQQcdNOKIIzLfWFVNhm11DFdZNxwwqo7B5DGyhBvONJGb0XGoLolzEb3iLuTH6Ju4Ei73Rmb4CCrhy8EOsn72nVVx9W%2FJ80khkT7Uy%2FRqOjXXnPJfhQCrWShHCQkhvtmKe%2B65p4gSvgo1EKlFxyAvUB6IAAIrnJTqKFLKBn%2BGZZddllDgxrkDGYSNT52oHEzkqqwUVTBhWfMWMHXN6lka0yI4NXHSkAJXcISULxSG448%2FnrpSPh4ux%2FqYpQ5eC32pY2hvbXQJuTg8I74%2B8sgj%2FEmsWZiYO%2BLgJElvkRGsk74n7MUtqzT1E0884cZlttHALTQlgr7RMTifCMyRNfTnG7rUHRGCeKc4pllsTjkLxkPth6CL%2BoeitlTmsvXaa6%2BBr9L9WjwhxZNoMY4r8sz6vfymoPnHpCT%2FdEdWzmesgkohBEIgBEIgBEIgBEIgBEJggCTQNzqGG5cKQwYJuoEPBUBCDPkl%2BN67xMBhmJAyylVHsjpolaxRdAw22gknnCCfRrnq72KLLVYuNXm2q2Nw7y%2FRJVIOakxk2G233WTbKENN8NPHvFJhuPSrdAyWESVEpo66qjnmmOPJJ59sLkmZ8EKuWWCBBVi1LZfy9S9EoOgY5AW6gb8%2BHCRsWpEU9S5adAz1Iim04YBBhSgfvQhfJUVtX567KhmLxCwtTg5lUruLGd65c2dD0TGsrS7PChnsMtO29VzSt%2B91DI0d3EMocCOUQx1pF3QMNe7IX3dE1ak%2BJwpS04h5%2BfmOV6R7uOWW%2Fd83OgbxpHk75Y4MTr6gb7hanE%2FoGC7h4G%2F9WBg4jz32GAiyjNZoL4IqEUOzsjwFUhKxsfB0p1SmM844ozp7lPr8DYEQCIEQCIEQCIEQCIEQGMAI0Bm8x%2FQSU%2Fh8vTXGlzNJ5Y5oWgSyUni7yurhJ%2B89rwaC0%2BtbWmXuE127dmVWEARoC6yzOqBK71iZGOrbfcXMUHKJ5dLip%2FHee%2B81V2K17E1vjbmFcIm3ErEhCl4luwu%2BFnVSDvw6ivovt%2BCvxmqahiFLyotpgSreOzdvvy6bAWU6N97kUK%2Bm8Fch4Ofzft%2B2lLjSX7vC9mj5TdnXvAWEUTTr7Ssd7UwOPHaCnVY3vK1oY9j2JSyloNCXF4EpirOBSqKHr2S62rFCIxp069bNtuSioEFZnhWWj68uVRO%2B9lIwi62rWVFU6iXCi14W0LwFj5uWhqpPlnvnLuKOaAWUnGbjMhS5gJeIBm4ckLYr91Ty1mg6otQ1lIKnCcz%2Fu5Of%2F8fahG4Zzb8G0GGrsRiWlhsvzT2n7h1ei2kOzjNKXw8yLcVNNR2o%2FBuCZzOPR7NjyiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAv2egDMfHVXgb7%2BfOjOGQP9MwIkhngvnd7Q9OuQPWXYZvO2xI20H19LnT1pG2%2BlSEwIhEAIhEAIhEAIhEAIhEAJ9T4BR43xGxxp%2B%2Fvnnfd%2Fr97S87LLLlllmGQcmtgxSDlK0kpaP0xVdammcryHwGwgwzB0V6rhPR3O27e5kUpeaJ3i2bdNujf2pYz3qt902fVPpgFFHCR977LHNQ4FLR4%2BnKRxO2lZbIDi88cYbLnU8BXnkqquuOuyww5yg2ralYR2y7DhUBSfJXnrppUceeaQaLcvUf8gz6IRZd%2BGYVBOZpe0yUhMCIRACIRACIRACIRACIRACv0iANbHJJpuMMsoo99xzzy82%2FkMaHHrooQMNNNC5557bMhrbba655hpuuOGGHHLIIRqfGWaY4RdttJah8jUE2iXASD%2F11FPXXHPNhx56qG2Drl27rrrqqm0VtrYtW2ruvffe1VZbzZY2vkuffvpp7969v%2F3225Zmv%2FhVl1122WW99dZ7%2B%2B23Wxp7PNdYYw0qR0u9r7169dpwww333Xffb775pu3VWkPHOOaYY9xg9%2B7da2UtfPbZZ6becsstrZww4iF1RxRODU4%2F%2FXTl%2B%2B%2B%2FX%2FmHH34gbnzwwQfa1L59U%2FDvzC233LL11luvvvrq1rDWWmsdcMABPXv27Ju%2BaRMCIRACIRACIRACIRACIRACTQLsi3XWWWewwQa76667mvV%2FXtl7XjrGBRdc0DIF42i66aYbfPDB2WvsqS1%2B%2Bmy%2B%2Beb777%2F%2FJ5980tI4X0PgNxCgM3Tp0mWFFVZ44IEH2na%2F4447tttuu6effrrtpY5rOBhsv%2F32N998c2l24YUXbrrppt26deu4V9urdIw99thj4403Jia0XPV4Lr%2F88kSYlnpfqXx77723S0SGtldrDR3j%2BOOPJyC88MILtbIWTE3l8KwRNLT0kPpnoegY1113HSxF%2FTDXTjvtdNRRR%2F0qlYbTF1eQFVdccf311z%2FzzDOvvvpqbiG%2Bbrvttm%2B%2B%2BWZdQwohEAIhEAIhEAIhEAIhEAIh0DcE6Bjrrrsu94cOdAx2jZew3hG3NZTUqOerz1RR5pmvcZ2XRzo7he1T3lOX%2Bg50jKmnnnqSSSZp%2BzK6Dmi1TDwNeODXSq%2BG%2BaszrMrVjz76SMFKeMJbldk5sdf23llbEs2kuaQ6lELp26xJeYAh4Ec%2F4YQTWNDt6hg2cLHi3a%2ByHWX%2F2Dl2y8cff1w3jDY2vC1XsdjzKnVRsMFOPPHElVdemQODvkYozXQnxxmqbYCGGvW8OIy555579knHIL%2BcdtppddJaMIWQlhZnDDUWacy6bGsrOoZALbfmajO4RjOz66XgRpo6huV5kHVXLyqE78dee%2B3Fe8rdGUcYTr1HS9LYSjxEdXkKpRdNsrqCaHD22Wcvt9xy5513Xl2hlpbUsrAypok0M5fHualqukE1rtbp6g9XGmtQL9WC9nr5h8JN1craUaW7c7X4nLi7cpvNdbp3t9nsXsdJIQRCIARCIARCIARCIARC4M8mwKDoWMd48sknl1hiiZFGGmnEEUecZ555vLOuS%2BKcv9BCC4088siuMkl8ZpttthdffFEDBpF3u%2BOOO644kVFHHZXlyHoqHX9Rx%2BjTK9q7775b4Im5RhhhhE6dOl1yySXFgGIDLrLIIjPOOONiiy022mij8ePwKnz22We3HvNqb4VzzjnnjTfeyPN%2FyimnHH744ccYY4yNNtqobRYClstuu%2B028cQTP%2Fjgg%2FU2UxhgCDBFO9AxrrnmGpvnsccec7833HCDMueNHXfc0QPCeCcC2NuUBE4FQj%2B22morDhjF1H3kkUc01sXG40q09tprC8TQa7%2F99it2tPwVIjVKR54bVJRq%2B%2Btiyxlwgw022GeffWxLvhzt%2BmP0Sccg0%2B2www6WyhK3cooKiUCwWFn20UcfXdJcMLq1EdNhJVbrqokuvvjiIgIQEA488EDBKRQbLYuOUTJpXHTRRW7WvwMCTKxTYAinDquVQ4NiI1SEO0rZIQY56KCDdt55Z0tq7hmPKmcSiXGalcQNlHh6kBzVm%2FeUU06h4ViYv2eccUaVIC6%2F%2FHJU8YfOVYi09Fu4nfpb3HnnnQXp9ddf7%2B4szMiFwMknn0yHKVNDxL3E7bjkXvixyMZTLgl7obT4ff0KrrpBoKAjWaDhNsu%2FbBoTUoDiOUNyKX3zNwRCIARCIARCIARCIARCoF8S6FjHeOqppyaaaCKxHswi%2FyefAjDeeOMVx%2FsePXpMM800IkSoHEwGtr%2Fy2GOP%2Fdxzz7GDWH%2B%2Bzj333IwCeoLyoosuWqyJX9Qx2vXHICwYXOoMNg53dHqFWBgWFlZsjbKSQQYZhDsHv%2FdHH31UY5NOMcUUbB%2F6hjJFhduJZbCzJptsMjVsrmpOFubMHFbSMMMMU2ME%2BuVvkbn%2BbAId6xi2E63gvvvuswxGujK3CloEm9rmX2mlldjvoi1OOumk4447TpINekVxMJAfQ2PW%2BiuvvGJ7b7bZZqussop9qCMj%2FZ133rFj1ZAUmMnMZ33LQyRwg9Ft5IMPPlhgiKdMWfdfpWMYxEqkm7B7Gd1mkUeXhS68xeKXXXZZS%2BKcQHKh4dATSBmHHHIIKYA4Y9lXXHGF%2B%2FVseorJBcS9Fh1DS6siWl577bU8MSzeLZgOKDXGrzFiNBnShMEto%2F6URjv88MPVU0JqpQLPDf%2B8%2BChwBdHLPxTEgfPPP3%2FXXXdVPuKII4qTCfnROi3bA0tLIb%2FQJ8tvQaNwvwYnO%2FgXybB%2BBUuyYPeOth%2FOUCXoxq8vpMXXbbbZhh%2BIfKqaKRedp1wykX8x%2FHD%2BBdOSVOKfiDKmLVHW%2F8QTT%2BhoeS1uJ827SzkEQiAEQiAEQiAEQiAEQuDPI9CBjsEAYR2w%2FeuLVIkQaQVc3%2F1%2FexHupAC2DzPE8hgR00477VhjjcWy47O99NJLM6bKG1VT0Dq4czz%2B%2BONadqBjTD%2F99NQGSgKpgR3hw6mDTWcKVgzhwpvZgkLaQz4VM888M7PLdHQM43sVq6XpnnnmmTHHHJOHRnHtYA0xSayWOcmgMwJLhJcIZ5JmdEAZmT%2B5BALl1Xapyd8BhkDHOobtXeJB3K89z6Tl2FB2gi3B6pe55eGHHy40WLU0AS4cvrLodeTboGyKkhjTblf2pBiE2Wtzlo7quTR07tzZ88VmNwgzuVjEFAl2NzXj1%2BoY%2FAfY%2BAakAfLukL%2BiKAD%2B7r777pwWOIRYCavcTUlVUdxIPKpuiqcBFw4OVJw6lNvqGNQAd1dux8Isj0RAn3F3hvVPhClKiArlxPg33XRTudPyl6bBe4HSUj2ymldLGUCIKBLFN8NopA%2BTFrcomoNh%2FTpl2Z5uo7nlIowUwjCWef0uHnZrLj%2BcFXrqtfcPlGefdkEqqe4ixTmnKFd%2BSh2pE%2BVedHSn%2Fn3zVV8j0HAKVb8aIYUHSNsbSU0IhEAIhEAIhEAIhEAIhEA%2FINCBjsFm4c8gNoThI6bDx4tXJ5ssueSSnMAJC%2FwWalJENhTrjHrAH8OYLCMmlRQB7EGvueedd96hhx66nATRgY5BeaA20CvqcSXcPJiQXND5gSy88MI1EJ4NxeIjqngVzvKyTn2r9zhLh8rhja1VFYZMLV4lXrmWr6SPmWaaiQzSjLXvB7Qzxf%2BWgG3TQVxJi47BVrW7yoIpcsxhn7ph7G0NdNGgqWP46m0%2BDwFamTIruBxBwuz1aPgIKuFx4aW%2F54sgQGSoqgWT%2Fzfkx6B%2BFB2jiHgUCQ4GJrJ4UWB8G8gvHiL3XvNjlJsiC5BTPLYvv%2FwyQe8XdQy9%2BJaw7qmLRQCkFTD83azoDIsnEXCXaokLox5wyuLHYpYyb8tfDykXF4PwzaiXyCb%2BhSFHWDYdA%2Bp6xIw4Ms%2B%2B1RbRQ5fbbrtNgyuvvFKZjqHcDH8rbjZqKB66%2BOeCokIhoYVSSygkIs50pGM0RRiShR%2BOt4z2wHIX8e8Jfxv%2FBKkXmQJFXW0KIRACIRACIRACIRACIRAC%2FZJABzqGsHciBmGh5UOUcNTjggsuSD2obzb9X32e80XHsH6RHVJSNDuKSflFHYNHhxG8ueagTiHxYR8xHJh%2BxA22A0ukwmFZGJ%2FZUnQMx7NWG7PoGAy06lbBmqONCI0v3Rl60TEqyb9P4dfqGDX5ra1FxPB2viZtoF30jY4hhYLIBa4F%2FA2YyT56%2BTCQ5VtgjBu26m%2B2epECqrJRfxor0ZddX2tqoeoYZbfTTzgymcLzSBygBjDAmzqGcu1LIrA2jg2%2FTccwDn3GrVE42fjECq4gVTwss%2FgXRhCKaJSaRqPOruAXIXR4lnmGNAUQqTkoPEQS%2F7AU%2F4eampUI2aJj3Hrrre63qWNQNuos5WrxnPHvlTQgFlzg4KPc1DFKWV9Aio5BNvFV9owyBXp4irUpziF1lhRCIARCIARCIARCIARCIAT6GYEOdAxu57JMTDjhhKync37%2BiFWnBrDOJJ0YZ5xxCBplqcwNFhMVQi%2FCQknI6W0vLYKB4%2F%2F588coxkUH%2FhjOK5l00klLuHqTAFWEDMKuaZpI3mXTMfiK9L2OIZy%2FDBsdo4n371Pu9zoGo1uwBiOdlc0WlnfFR4EMwmGpvNmv2sjv1DE8HTa2HBf0BGKgZ5MyIPMGQa%2BpY9R8lX73s846i0VPNvzNOga3BKoC5UQOCpIIr62W7YS5rJtEmJZLfLp08e%2BJgn8T5Nzo2bNn7csFi5OJSBP%2FQP1OHYM0QYKQhZVgwgGDcGFSLhlW7vYtrG90DD8WVxMqk3uhfsjsWpeaQgiEQAiEQAiEQAiEQAiEQD8mUHWM%2BrqzLsCLSMKCyI5mWv7iT%2B5dpP9XP%2FDAA7PISns10uvRMVhJPMAl5OQ%2BUYeSmm%2BooYbqGx1Dok6R6bVjKXiLKgPG5JNPXt6NqmSSmK4EtkTHaMGVr30iUHWMdu3QtnElv8cfg4JnGTVDRTn7oyzM7uVZ5NHjbyDoowoLQjNkt%2FBk9ckf48wzz2x7a9UfwzMofQc7nT5Qm5EImjqGspCTugyZK4ge5A4BF30fV8K%2Fwi2UQUwq7QYFg4xZc2bW2UtBBIdVWQm1s17yr4SkFjwxVHLn0KAZDMKJQk1JOVLiSuo%2FUL%2Foj0GaaPpjUB4MRQtFVe4daS6suSxDZhJr6Bsdg0ZElTWO2%2BRCQ3upN5JCCIRACIRACIRACIRACIRAPyZQdIxBBx3UEQyMO3aQDxOjuJp7zcrngaUj052UF6wDR68ec8wx7EEBHTJOLLDAAuw1bzblBJBpk4cGo0xYhzQas8wyiwGZb9Iejj766H2TH4Ns0q6OwYjwJtRKmFo0DcIF53BfvUpmT5FZRLj0TVxJ3%2FhjMF29tG1KN%2F34F8l0fx6BqmPISMlxqHzEO3g7zxeC1cwel%2FjCAuR18RL%2Ft%2BkY7G4GL48IG9XzxRODsWzH8jcgA5pUthaeAHZ1CXngMsF3QmOeTuIvOjh3VTJPq%2F2%2FdXfvrizgpalj8KwgjEiTKxkFs11CGA%2BvMYs%2FBsFBKkvTWQlJ0CNsnUQJSkLf5PmERS%2FJPTyGnKz8g4CnSrkmuChQD6S5cFNtfz6BM1Jk4CnlKc1EKl3r5ItCEyg5NkuwBn3APx0QuUoSqalBf62O4R7dFDmUOOMX5AxmZHh9%2BMaQMmgaHnBTi%2B7pSx3DTck3YsFuk39aM8Ct7f2mJgRCIARCIARCIARCIARC4E8lwM5i%2BNAEfPhXlIK%2FTiZlH7E4vMD1leeDo04VKBUMNPaLF838ydXIOzHCCCMo%2BMjGybYyJkvHV0ktnD%2BioLu%2FbDf3cuihhypz7W65L69Z%2BX4Yv%2FnmurZxlcWhowCTkUceWWG22WZj0GlARZlggglIGfUlKWlFM3ZTzY9R1iMIpQyoy1RTTUU2qV1KvRfijlkxeDmMss6ewoBBwL71Vt1hOvYGEax82PJcICgJ%2FDE4%2BTBX3SxNQ7PqIWCfsHmFbNQcLHwMNChHjirrSBsplCiBRiaJkAEFelBIuAQwrj1oJnJJQa4YtjCbnSpoy5EahFHoYmFOABHF0ALcSjRjRP%2Ffon%2F6H43VM9g9pDwr7HZzOZLDYkxBB3BrCnoRZ9w7rdIgjHGVorQ0sx4paMxFamDj0ygoFbQIDylpwr8ALlEntCweLB4QPiS%2BmtEjbEwNMCFKaO%2FGW5ZdvxqKq4nZrcrU7tQd1INajENjtDCfgshoVJ0yvn83oC6KhwFJELQIqyVTlPGF6mhQjlUizrhfH0gNBZFC%2BU0BJ%2ByUn77QdsmS6CTGITcttdRSNYWOn0YuViP4l6fM4h9DNRb22GOPlZr8DYEQCIEQCIEQCIEQCIEQ%2BJ8QYLM4zNTrSx%2FHEJSP1BNMHv%2B%2F3ZIYC4w1VoMAdk7g3pzWdTKanAXg%2FAIhJGyNWWeddfzxx%2FdqWwNWAB94b5ZZRtrw3DYym4thwqTyRrh5NkEZ0Bth5h4zs5qKdaJSYGrJ0sGcZMU4eaH63qv3ovnkk082aWnpEid2VlJ5O2xSlo532UX30EYX7SXrq11KRy35nLDU6uClPn8HDAIsWc4DNiSZgmpRPr6yYe12zgDKBA03yyVAmRtSuXH7hIVLwSPflRouDRqUbayLMvWs2N30BDKFJ0hih3KIJ4cH2957fAkftGye3OH5sjlte7uRqe7wUIuxP1uAyxdhtc1l%2B2oofhHSa3iE5dukH%2BrlORLtZUA7XCxGuV%2BxJOXeCXQSgTL56SeWZ9gykWdZBgligvVoaSValieRgmGumoSTBkjBcCNGLn2JG7xN%2FBPRogq23IJ1erjcpr40H4JnwVWaKVNUSAquWpgF16sW7E6LqKKxGyRH%2BCfFvKUvnhoUQYaOQangA6aNe%2BQn5metQ0Fk2cjggxKvM0j9NG7ZPw5WVf99M7jfRXbQqpZAxJFGrleOKC23lq8hEAIhEAIhEAIhEAIhEAJ%2FCQLe2zqboJ5XwioRPCLqpGNb5i9xa1lkCIRA3xOgAPCykJSDGtD3vf6klkXHqAFBf%2BAsdCr%2BG%2BTZqor8gYNnqBAIgRAIgRAIgRAIgRAIgX5AwOkDYkbGGGMMzhj87WX4HGSQQcS%2F5%2F%2Fk9wP4mSIE%2BgcC%2FFV4PvC5koOipg%2F93y6sxJU083z%2B%2FvUQaqg0vMtEoxR%2Fs98%2FZkYIgRAIgRAIgRAIgRAIgRDo9wR4aPNCn2%2B%2B%2BWTV8HE8K2%2Fzmo%2Bi368nM4ZACPRjAsJPZLSQSlRMSj0EpB%2BvoWU6ITaWVBObtFz9bV%2BlY5XQg4gheKd%2F8Dn5bXeRXiEQAiEQAiEQAiEQAiEQAoWAKHJx63IFJGY8WyIE%2Fm4EpA2RoKNmwuwfbl9CEsltalKLP2RJMnJIwSGS7g8ZLYOEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQH9FQFpO5wnyDy%2BHNrasTcx7y8mkLQ3%2Bt1%2B%2F%2B%2B47y3MUZrufdu%2Fof7vgzP6%2FJWC3OwLVhi8f5T9qPU74tRv%2FqNEyTgiEQAiEQAiEQAiEQAiEQAiEQJ8IMPb33Xdf56U%2B9thjbds4WXWuuea6%2F%2F77217qH2pOOOGEmWaaaeafPrPNNtscc8wxyyyzlK8SkD711FP9wyKzhv6HAB3jkksu2XXXXXfbbbdddtlljz32OOCAAy644IL33nvv9yySiGGQ3XffvVevXr9nnPQNgRAIgRAIgRAIgRAIgRAIgRD4RQJ0jDXXXHOggQZqm%2BGf0eeowcEGG%2By66677xXH%2BJw2OOeaYqaaaauqpp%2FZ36KGHdhdjjz32NNNMo4as8cQTT%2FxPVpVJ%2B1sCtvTxxx%2B%2F7LLLrr%2F%2B%2Bttuu%2B0222xj8%2Fu60047vfPOO7952R6iI488crXVVutPzh79zTeSjiEQAiEQAiEQAiEQAiEQAiHQ%2FxNggjlPcIghhrjrrrvarlby%2F5deeqnlNFXBJnq1bayGndjSuNnMa%2Bs%2BHc5owD5d6qCXoJJPP%2F3UUSmffPLJeuutN%2Bigg15%2F%2FfWff%2F65GvV9OiHRIuuqOl5wB%2FdSR0jhL0TAz82HZ8UVV3SSpkMofJxtweloueWWu%2FTSS%2Fv%2BRmyMlt1l1xmq7R7WsrnfmlP06ZKR%2B7TxjN%2BnS82RUw6BEAiBEAiBEAiBEAiBEAiBAZhAxzrGmWee6c31M888Uwh8%2FPHHBx988MILLzzvvPNusMEGzcANB7CeccYZiy66qEtrrbVW9YW45ZZbvPK%2B%2BOKLRa%2FMP%2F%2F8iyyyyOGHH97MufH000%2BbQi%2FDHnTQQR9%2B%2BGGl%2Feabb26%2F%2FfZ6LbTQQnvttRdpol5qW9hiiy24jjzwwAPlkrMgrXDnnXeuB8K%2B9dZbG220kVACB8V6F%2B8V%2FEknnWQ9xt94441feOGFOiZT8eyzz1588cWtisjz6KOP1ku1YDFG2Gefff7YEx7r%2BCn8GQSqjvHQQw%2FV8R9%2B%2BOFVVlnlxBNPVPPiiy%2FyrLjzzjvLVbrB1Vdffeyxx9o8pcae12Dvvffef%2F%2F9haiUX5%2FUdu2111JIOHWY4oYbbuD1IRrLmFp6ZO6%2B%2B%2B6mxPH8889zJbKlbXj6Yb0kwwbfJ6EuLh1xxBE2XlVLTERpMakBCS%2FNR6%2FeSAohEAIhEAIhEAIhEAIhEAIh8Hcg0LGOwfAXrEGLgILCsMIKK%2Fg60UQTzTDDDAoTTDBBSZ3B%2Ftpxxx3VjD%2F%2B%2BBJWlDZFymDK%2Beoz7LDD6lWiPxiG5SX1PffcYzRXXZpwwgkVTMG5wnSvvPKK2BA100033cQTT6xAD%2FECvd0fhbm3%2Beab0zHuu%2B%2B%2B0oBUQqPQ69577y01F110ka8iZd54441JJplEeZBBBhGEIhRF2Sw9e%2FbUUu5H6oeascYaq9zLOOOM0zR7y2hMUfeib1IiFCB%2Fib9Vx3jkkUfKgmkI5557rl1X%2FDHsn6WXXpoiV64SKA455BD%2BG927d1dDWFhnnXVWX311GTY23HBDASnHHXccBU8zYsXKK6%2F88ssvm0LlUkst5SsfIUKc7rZuzT9jQ5ZBJOhQWGmllegeNrAnkXq2%2FPLLb7LJJi6tuuqqAlVKtJcpOnfuzGnEJqeeGVDHegt%2FCfJZZAiEQAiEQAiEQAiEQAiEQAj8UQQ61jE4OQwzzDC333676YgPrHs1kiJ6O8z6G3jggZdYYgkjXHXVVYMPPjhjjWu9kyDOOussgSrcIVhnp512ml4cG7z11stQI444IhcIEStEiQUWWIC%2BQWFwybBsQ41Zc6zLzTbbTJzI6aef7hIJxWimu%2Fzyy9u98bY6hmbcLYzGD0SZpck8JHRYgImmnHJKy3ALlkHW4BCi5YEHHqiliAP3wrBVT5%2BRv5Fe4dZaTqNgWnoFf9NNN8XPv91fpP%2BsJDIQ1kgHO%2BywA98GH%2F45a6yxxp577lkcgfjzuHrOOeeU9ds2vC804KehzEeCiEGR8Ou%2F%2Ffbb%2BpIdunXrZliXuCER34pUQnPgccGLw%2B698sorKQ%2BnnHKKS9yEtt56a2KaXgZ59dVX5eiw1Tly2G90DwlItbHZKIRWst122ymTR6yBVELi83zRFUkoNrYR%2Bk%2FOWVUIhEAIhEAIhEAIhEAIhEAI%2FHkE%2BkbH4GbPWhdnwTOBpVYWw7zaaqutRGQwwRhZ5A5GPVlAHMfrr78%2B99xzc7T44IMPCBHkCPEppZcDUl2SlpOxxmFjhBFG8NKZClGuCmDxIlsXSTm4OlA%2FWIIGNCzbjbzABqyNm0za1TFYfxbsBJOSu2DyySefc845xYNYMH%2BMTp061fAWQSXjjjuu6bTccsstS2pTMovGFiCqhZ8Jk7M5Y8p%2FRQJVx%2BAsQSUoHyLDoYceWqKWOtAxaGvEChEo9jl1wu1zxbEt33333XKp6hiCSghfxYVDM3FMLgmnooR4lEx36qmnGsHHDqSZqDEvHUMQE4HFbvRUGpMXEOcNDkIeBwOSOLgMqfcw8ht58MEHo6H9FTdh1hwCIRACIRACIRACIRACIfA7CfSNjiGE34vgSSed1PGsJeijTKqvN8JqZp99djEaRImRRx55pJFG4urAvWG00Ubr0aMHUYIswPOhdGG7LbjggkXHuOaaa%2FSSDaDeAjmC1eYjn8CQP32M5mPY4YYbzphEj3Ztt3Z1DC0JLPw9WHxsT%2B4cxTfDm%2FSiY9TUGW6BiKHSgr1h17Lei9l9JcU8%2FvjjdZ0p%2FEUJFB2Dsw0fCboBwY0uJ2TDj86JyC7qQMdwyzwxuAyRHfylS9hXVAX1HoSmPwYdg3BRM67QMQgU2tuQEsXoLirECOWjpdmlxTDIeeedZ21cPiiE1lNDlvhgSL7hEjWDlMd%2FSeqYv%2BhPkGWHQAiEQAiEQAiEQAiEQAiEwO8k0Jc6hgyfzHzxINX2r%2FN6kT3zzDMPNdRQXlXLp1GsM34aAvkpBuJK6BjVUb%2BtjiH%2FQB2qFkR%2FkA4mm2yyn629DY1sTEEBVI7arBba1TFclfSA%2BiFqQGIB6krJ5tGujuHWOJBw4ZAegeOH9%2FXNe2E8VkeUOmkKfzkCdIxyXkkz4Qn%2FHIEeUr%2Fa2%2Bo5adTtWuNKqihh83Tt2lU2GJKC4BHyBb%2Bdtv4YfdIxLrzwQnKE9BrEE2k0fBR8ai7ZJ5980gptdcugwkmdYc2Fs8gssV0uUUKMT%2Burl%2F5yP0QWHAIhEAIhEAIhEAIhEAIhEAK%2FmUDVMWo%2BzOZQJT8GZ3hRJPwoWPpeLpcGbLfzzz%2F%2F5JNPlm5CRs1RRx31ueeea%2FYtZQ36pGOwyzhLyE1RzTHBJgxDIoaQE5fYawzJtmO2remTjuG19bTTTsszhH8FZ5IiwhQdQ7wJUaUM5cU3zURaUUkS2KcSYjhFpe0sqfmrE7DT2uoYfnT7XDwRtxx7koAgQ0u5U%2B0dDkJPkB9DcJOdWTa5ze9B4EpE7yJ9aNY3%2FhieNX4XvC%2FaPeNVzIgoFaFYpuaAQXPjxUFA%2B%2Bijj8hrpi6RLyKzNKNj7L777pb0V%2F9Fsv4QCIEQCIEQCIEQCIEQCIEQ%2BLUEio7BA0EKTQZd%2BTCmWE8uFR2jHJrgjEi%2BDWy3kl2Q7sHDQUQJNYAjvUscGErUCbnAgaQSJHKkl96wrY4hzaasAprpPvroo4tbsWyOFjIZGkeCRGMuueSSIku40FuGq84HkerTO%2Bh2b7BPOgYDsxykYljrKX3pGFNMMYWEGIII1Lgdq9XAYZfaS%2BWhzLefqOIqU9EtWxglpzk1gUWUAbnD1M36lPtnAn7fomOIMyIL%2BFDhBD3RLggRdhqZgmphP9icvnLDEOJBNKAkaElY4LkhGsU9kjJKagtn7hjW7tKs5PnsU1yJPSNwSTJPYxJGDGIK3bt06eJxk2B2scUWU6ZUuGQ6KUA9gB5JeXQl1KWulEePhGIZMmkUXa5%2FBp61hUAIhEAIhEAIhEAIhEAIhMAfToAlxQOB5c6hQgYMwSM%2BzjmV3JK95iQFKSxYWObl2zDjjDNqKVumnJ8SR8jt6cwOl1hhZAeXOD%2BIy3CYqTIHeGZXOTSk5vkkUJQUoMQEHaUpIFZwlmDBzTXXXHrNOuusDj1xiX035phj0kBkrmDEkTu4SbT7IltjYgIVRXe9fG1%2BiCRya7i7muDC1GWFo4wyihtxOzq65fKqXapPATJqaB3LLLOM81iV11577eq8UQZn4SKglzQLzelS7p8JEByIDH5WikTxwaAq2LFkAUKZlQsSIWc5DURUFDVDM8EjRC0%2Ftz0mu4WoEI0PO%2BwwwUqcMahk9C6XZAp1Uiq5wxSiP5Rrnk%2FePrQR8VNkPerHZZddxtHIvGpMQUIR0uLhIqrsvffepnPoqmQaPDGUnQCrC03DRHrxwTC1p9IyPAsu9c%2B0s7YQCIEQCIEQCIEQCIEQCIEQ%2BDMI0DE4Gzj%2FlFxAYagftht7X%2Fw%2BS5%2FjQZna22TWH9Oe1kFbuPnmm%2BuSuHAwsmaaaaYJJ5yQl4Uxy2kgXiWTRIT5l5beNTtKkixQvB1U3njjjTQQYR20BYPzrq9jlmQF9AQCi9AVLeullgJDkv%2F%2Fwgsv3DYeRPYDR5%2BYop5OUvwxxhhjDHaoezE4w7akzijD8iqRwUDgiXsRbMIRhbjRMiN%2FEu%2Ffpd0oLigtV%2FO1%2FyRAZLjiiisoFRQDf2kR%2B%2B23H62guFiUNdvkRAlhJtwhjj76aPuKQ04Jp%2BIydP311ztulZJAfOA4VLYrRwvn82pGjjAFpUK5Zumky9lCgrDoGKbQmIOTBRjEFJJjFE3PJQ%2BFOKztt9%2BeVELNoFRU9cwKKTAm1csCPFYtDkL9J%2FCsKgRCIARCIARCIARCIARCIAT%2BcALMLtZZux9ve1lefCpYXs15hVpwwCAdNCtLmdlF0GhaWHQSI%2FhbGpTp1DS7K8sj2lYrKF0IBX2ari7AsO0uVQPJBKTa4K5fGzMb6TAECqKK9%2B99Gpzu4V5kKqgdWwr4%2BLRU5mv%2FTKDsk%2BZub9nbZfE2JO%2BIkn1CWfvmD%2B2rPWnn1Dutw2qsbLc3u5QR7E%2BXahdfWwaplzxEHofmQ1QvmVQvg9eaFEIgBEIgBEIgBEIgBEIgBEIgBAYMAmzGZ555xuGVzngdZ5xxvGSv91V0jE6dOvVJOaktUwiBEAiBEAiBEAiBEAiBEAiBEAiBEAiBfkOAZ77UFgMPPDBf%2Fear8LfeekukiTAW79z7zUoySwiEQAiEQAiEQAiEQAiEQAiEQAiEQAh0TOCRRx4RTuLQWI79zZYCRq6%2B%2BmopO%2BKf38SScgiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAj0ewLObnj22WefeOKJDg4H6feryowhEAIhEAIhEAIhEAIhEAIhEAIhEAL9GwHnTl500UW77bbbrrvuKjHmvvvu27lz53vuuafd8yj%2FpMU74HKJJZZwFqoMnH%2FSFBk2BBCQKeW%2B%2B%2B47%2B%2ByzX3jhhSYQBwFfe%2B21Xbt2dappsz7lEAiBEAiBEPj%2F2LvreO2qqnv40g0iXQKilAqIIuVDd3d3d3c30t0N0p3S3dLdIK3YWI%2B%2F%2BLzfl%2Fl51rvf6xwONyF3OPYfF2vv1WOvfXPmWGPOFQSCQBAIAkEgCASBIQ2Bf%2F3rX6uuuqpzPVwjjTTSiCOOKOF3q622%2Bstf%2FvL1jBaPseCCC04%2B%2BeThMb4ewP9je3FsDZpuscUWQ9x1T6v561%2F%2Fus0226yxxhpvvvnmfyw4mXgQCAJBIAgEgSAQBIJAEAgCQWCoQACPsdZaayEujj%2F%2B%2BMcff%2Fyxxx67%2BOKLZ555Zk8uuuiir2cKeIyFFlpoiimmeO%2B9976eHtPLfyYCeAzrfPnll19hhRXOP%2F%2F8dpANHoMYab311nvrrbf%2BM5HJrINAEAgCQSAIBIEgEASCQBAIAkMLAsVjjDzyyPfcc08b8znnnEOVsfvuu3vywQcfnHfeeddccw3h%2FcEHH3zfffd5%2BNFHH5177rkHHHDAoYceeuONNwpwUXWffvrps88%2Bm2j%2FjjvuUPiwww6TYDy2llEW3FhU%2FPnPf857pQzJ4jHoMe6%2F%2F%2F4zzjhD7kknnfTOO%2B%2B0Wi0hgMbll19%2B%2B%2B23%2F%2B%2F%2F%2Fb%2FbwySCwCAi0OUx1llnnSeffLIq9uUxlLSMnc%2BL1rv55pvjbzKICKdYEAgCQSAIBIEgEASCQBAIAkHg341A4zEefPDB1tdRRx2FxzjooIM8QVyMNtpobuvad999X3jhhXnmmcftKKOMMtxww0nssssuIgwofOSRR7qdZJJJECO8VKRHHXXUSy65pFp%2B4403lllmGQ%2Fl%2BiX5EI4DB%2FLnP%2F9ZfAwPJ5hgAs%2BHH354v%2FPOO%2B%2B7777bhlSJhx9%2BWNZ0003329%2F%2Bticrt0HgMxFoPAa3qRVXXHGPPfb44x%2F%2FqFYPj4Elu%2BKKK1ZeeeXllltutdVWW3LJJbfbbruXXnrpM9tPgSAQBIJAEAgCQSAIBIEgEASCQBD4dyNQPMYII4yw%2BeabH%2FPJteeee0488cS4gmeeeUbvDzzwwHjjjYeyIMWnvqCF2GCDDZAJ4oKW7mKWWWZBdNx7770KH3fccbImmmiiffbZh4vKgQceKGuJJZb4%2B9%2F%2FrqONN95Y7vrrr8%2BB5ZZbbvnJT36Cu7jppptYkUIWyJprrrmuvfZaypCFF17Y7VlnndUzfY4nm266KcFG0SY9ubkNAgMjgMewRPmVWHUUQVi1Sy%2B9VBU6n65fCbpslVVW2XLLLa3qV155hfRIFfKkIj0G7iK5QSAIBIEgEASCQBAIAkEgCASBIPBvRQC9sPbaayMNeq7ddtutfDfwGGONNZb4FXWrPK8TDIOEgXEMOeSQQ9TlTuJW8AFpPEONmcPInHPOOemkk%2F7mN7%2BxnT3hhBPOMcccWIvKve222%2FiSUH0ohrggxqD0qCw%2BLNopx5Z6kt8g8OURKB6DysKqFtJzww03tPhffvlltFjxGCLNOqnn8MMPJ8ZAxFWPTcVRZN2XH0ZaCAJBIAgEgSAQBIJAEAgCQSAIBIEvjEDjMew%2Bn3LKKSeffDLfEHE%2B6SgEQtQsi2%2FsscdeffXVW1BEDx1eSZY%2F%2B%2ByzzzrrrJNNNhnOQUXPS4%2BhnRqPE08WWWQRBMWHH35IgMHHZIsttqgsv0QayI23337bNjee5Nvf%2FnaLiaGwNnfddddWOIkg8OURaDxGhXm54YYbcBqYNEFgnGAizmetRmeX%2BBz%2B8Ic%2FtB6FeVFSaJf2JIkgEASCQBAIAkEgCASBIBAEgkAQGCwI4DGcV8K%2Fo7vXzLMDj7H44osLXiFuBh7D2aylx2AJnnnmmaOPPvq3vvWtBRZYYNlll%2F3BD36AcyjuongMUTprLgJfEFqQYdBjkFjopV%2BJRcX5dF5JO3c1PMZgWQzDfKeNx6jVzp2E6xOC4vTTT9922205TOExhF7hu7TDDjt0zx32FSDuTjvttGEeokwwCASBIBAEgkAQCAJBIAgEgSAwhCPQeAw7zm2oTp%2Bceuqpxa9ARDz00ENdHoOyYtppp51xxhmp7lmFqmAwPpPHUAs3MsYYYwiO0Xr5%2BOOP6Tqef%2F55G9917mp4jAZOEv8OBHp4DF1QBFmTK620koAY4rfgMSxLPibSyLc2BofyoOwuu%2Byy9iSJIBAEgkAQCAJBIAgEgSAQBIJAEBgsCDQeQwDPGgD%2FEYoLkT%2BFCBAroEeP8dprr%2FETmX%2F%2B%2BWu3WmABm9d4jFNPPVX1T9Nj0O2%2F%2F%2F77U0011TTTTKOF6uiiiy5Sce%2B998aWDDqPgfRgaQ4WrNLp0I5AXx7DjIT6pLUQxhZ3gcFTxmLGWlx%2F%2FfU1X%2BvTwTokSU899dTQjkDGHwSCQBAIAkEgCASBIBAEgkAQGNoRwGOsueaa%2BARHqa7zyYVScPKpWBbXXHON2YmPQUeB0yi%2FEvSFM1KVZ%2Fftt99%2BlXZ77LHHKuxX%2BsQTTyxYGIALLrig407qBNXDDjtMy0J9Kub8Vp4p448%2FPl0HXoKLijgbTY9x8803a8dxrj3wCgQ6wwwzcAToav57yuQ2CHwaAjgKa88xJdRBrYxVajUuvfTSG220ER7D81dffRWngbhA6FFiOHxHLo5ORJdWK4kgEASCQBAIAkEgCASBIBAEgkAQGCwI4DE222wzfMI444wz5ifXuOOO%2B7Of%2FezKK6%2BswJ7OoKSjcLJD8RgG6dRUET6dxDrccMNNOeWUTDzVHcPKSBRAQJr1V3PBNqA7pp9%2BeueleiIcgZNbhcvAUdB7zDTTTLfeeqvnDEnnWrql6q%2BKnFy0gyep2%2FZrQ9xgkCc5AbNhksSgI1BaCwFhqIy6tZ599llfwdZbb91W4JNPPmlJczaxgJUXxrYb9rNbN%2BkgEASCQBAIAkEgCASBIBAEgkAQ%2BDoRQFZQQdA5vPjJJeE8SuRGGwMuQo4y3fNKROa8%2F%2F77uaLwFsFOKKCWAkIKiHchUmJV55by%2BuuvO9fyn%2F%2F8Z2uQqaiiAyM00lOMl0o9kWUk2A%2BGZ6sooQs75rxUug%2BTDgKDiIDlZIm%2B8cYbPXoe68pKtrS6C9XCtpgfeeQRK7ZnHQ5idykWBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAwRCHglApHvjrC1bkqQ9TAMpjBhYBzSf761786Lqd7ZM%2B%2FYzBOMbb2HJtiEX6u9g3M8AwyR6h8LtxSOAgEgSAQBIJAEAgCQSAIBIFhBgHWkFNTb7311nfffXeYmVRNxNSeeOKJq6666upPrmuuueaee%2B5xkmZjLf7%2B97%2Bvs846884772uvvTaMzT3T%2BUwEsAEPPvhgz6vHKpx99tnbbbeddfKZLQxQAEHhwFZnE7see%2ByxV155BV3WLf%2F73%2F%2F%2BgAMOOPDAA3ued8v0m3YeseGdc845%2FRIgv%2Fvd75xo7AzZfuvmYRAIAkEgCASBIBAEgkAQCAJBYNhAYPfdd%2F%2FGN75xySWXDBvT6c5i%2B%2B23N7Wea%2Fnll2cMKobHmGuuucYbb7y67VZMephH4KabblpuueX22muvv%2F3tb22yyIEjjzxyxRVXfPzxx9vDL5B44403NtxwQ%2B3Xtcwyy2y88cYXX3xx6%2Bujjz7afPPNt9hiC4TG52r%2FySefNDyD7JfHuOKKKxZddNFTTjklao3PhWoKB4EgEASCQBAIAkEgCASBIDB0IbDHHnuw9C%2B99NKha9iDMtodd9zR1FZdddUTTjjh%2BOOPt%2F0955xzejLffPP95je%2F%2Bec%2F%2FznPPPNMPPHEFCmD0lrKDDMI%2FOMf%2F9h%2F%2F%2F3RC2uttVZXeoEcOProo1dZZRV0wZeZbPEY66%2B%2F%2Fi9%2B8QuKoBNPPHGTTTZZaqmlTj31VKtOy3iMrbbaauutt%2F7DH%2F7wuTp66qmnDM8g%2B62FkTvssMMeffTRfnPzMAgEgSAQBIJAEAgCQSAIBIEgMGwg0JfH4GNCnX7XXXe9%2BuqrPXN85513eGfcfffdLLWW9cEHHzAGm0KeCz%2B5vrotyIDEM888c8cdd1Dytw1oW8Y6eumll%2Bgifv3rX%2BvuoYceEjSgNSvB3rQzfvvttxPnK9bNYnI%2B99xz2pRVtmE3t9I77LAD1uKyyy5rWQILrLDCCvXQqPryGIZtJPfee%2B%2Fbb7%2FdalWCs8DDDz%2BsR3MR36Cb%2B%2FHHH%2FMjuPPOO19%2B%2BeXu824adK%2B%2F%2Fnq%2F2%2BjdYkl%2FDQhYruuuuy6Cy2I477zzmnqh8RioAAuAX5IX%2Bt%2F%2F%2Fd81JAvGirXa29u3XN9666228tvIfR1IjJ133rktWr4etB%2B68%2B0oVjzGNttsozUfi45Uac1WO7%2F97W%2BffvppxIWV00ZYPMZRRx3FhcT6d6up1q%2FlbYQ9H1HLTSIIBIEgEASCQBAIAkEgCASBIDBsINDlMdhxrP7JJ5%2Bcpe8aa6yxGF%2BMIzMVVsK28qSTTlpZ4447LnE7nkHW3nvvPeqoo9p3LkCYZj%2F96U%2Bnm266srBwFKuvvnrV8jvzzDOXKaew%2Feixxx6bowdRRBWgile%2B2kFxuG0V7Z43XoWaYtNNNx155JErd%2Bmll0YRVK3ub%2FEYF154YffhSSedpNbhhx%2FOPu3yGAImgMKUq82JJproggsuaLQDtmTuueeurBFGGMH2umlWs7IE2ais0Ucffc899yzEup2ySX%2F2s59NMskkLNPu86QHCwIXXXQRSuHyyy%2B3QpAJllMNo3gMjhs8klZbbTUrU%2FrnP%2F850kABXIT3brVjtKr8DTfcoMyVV17ZM4viMXbaaacupeDVr7zyylrzKWlQv8KzWC260IgsUS%2BKkUOYXH%2F99eutt57nxmkk559%2FfmUhLnxNhmH9y%2BK3Io11rAHceOONWhsmtVU9COc2CASBIBAEgkAQCAJBIAgEgf9kBLo8Bjn9BBNMMNlkkx1zzDEnn3wyzoF5ftppp8GHKGK00UabYYYZ8ADHHnvslFNOOeKII3L5l8UWY9o3a46BP%2Buss37nO99hrCE6WFsaYZQxHnfddddRRhllttlm%2B%2FDDD1UkrZeFA9lggw30UkQBwb8sdRdaaKHhhx9%2Bo402wiew%2BKSXXXZZbAP%2BgTuAivbTbaYLRCDN4iOKULF7FY%2FRE%2FoDM6M8hX%2FTYyBM7Hfr13OMxLnnnovlGH%2F88b%2F1rW8V7YA%2FAYXp22HXI0ZFyc0228wGOtYFJrKEGTnzzDMF3JDFh6VtoNd4qFBYo7PPPvsAgo3uyJP%2B9yFAPoGmwKFZYxawlUOBU93hMax8y8x6E%2FATmeCN8wex5mVZ2Faj1d54DAUsBlEpekbbL4%2BhXwteWAz8nvUgXKe6FTcD86BlDiMVl4Owx6iM8LrrrkOVSLj1UC8W5BprrOGbMgxZQmEgQHw%2BRfF5gtnoWfA9Y8ttEAgCQSAIBIEgEASCQBAIAkFgaEegy2Nw52eGF5NgXtwrxhlnHBu%2BjLgjjjhCFnKj5sti%2BuY3v8kWc2uHGqfR5TF%2B%2FOMfTzPNNHz%2FmX4E%2FAo0zfzaa6%2BN9HCOg4pbbrmltOAV1SYjTpuMOxvWZCGyEBFV0a92RhpppAceeEBdvIFd6arld80116TrkNWeVKLiY%2Byzzz4kE7%2F61a%2B4fhx66KECe5J%2F8A1pPEb5DiiGHmkqC1SG%2BaJQNFVz96SatclOcEKwwSOgss4444zK4ozw3e9%2Bl%2FSCjVxP2q8Yj83%2BbQ%2BT%2BPoR4L5EtHDWWWfpWmgUC%2BmQQw4p55HSYyANbr755hoYwm3bbbflJMJfg6jmy%2FAYfEyQXdY%2F7sunQY9hSbfYLD4fK9%2FROvpFX4jl0s5S4c2EuMC8ycJjoDtwcW0tkRupiCSUGx6j3lp%2Bg0AQCAJBIAgEgSAQBIJAEBi2EejyGCwmTMK3v%2F1tzIO9ZoYbF4ny8XfLj4MMg4V1yy23sNMZ5pVla7hfHsO%2Bc8kSEAU2jhlf9BgoDgSFk16ham%2BaI4aIE4WwXWwkwMILL6zZXXbZZbjhhtNRA58FRynBf4QaBMNgG5qfy3HHHeeXX4knZZm28hLFY8ii5XBpUHrCCScs2oFWpPmV1DiRGKxCNA57c5FFFlHYsNm2%2BuJHgwxpjQsSoiRLk0VMYWJe2BiD4Wsz44wzEnKIXdAKJzHkIIAio5ah5Hn22WeNyu2%2B%2B%2B5LfVF6huIxEAXcN9qYKYVWWmklb%2F%2FL8xi%2BtcZj0GagMlpsDZoQ%2FEnFcrEacWWCydBp0P9YVHgMq8uQiscgGmmCH7E%2BTEd4T4PnVxI9RntxSQSBIBAEgkAQCAJBIAgEgSAwrCLQ5THIHthNQlsw4V1YhYMOOqj8NWxYU9e30BnTTjutQxPKZ38AHgNoNB6kC1pj3eMQEBf4kC6PUWkl8Rjf%2B973EAh65LVBfUFB0Rf2cgxBShQvoWUNjjHGGIbXU7h4jAUXXJCug3SE4oKzQNsBx5Y0HkNFZiN3GK1hcgyYwEO6Dpjg8GLir7zySk%2F7nFw0rlgbiQQ3GXqPLunRUyu3gxEBYTm5chDwcAJCGiAKrA16hoop0XgMgTfbILlpoBEwV1%2BSx7BaEBfUTcJx8Cup80pa2Nsuj%2FHee%2B8hErm3cCGhAKlAGegXQyoew6dnqDVCM1LA2qYvCo%2FR3loSQSAIBIEgEASCQBAIAkEgCAzDCHR5jNrkZXDRSJAl%2FPCHP2SkozIYTZVFD09yrwoeQ1btESMW6DFanE9m2k9%2B8pPyK2FkTTXVVNNPP31JOKg7OPurODCPQekhggEFRQtcAH8PGZLIB4IQLdhGd6gEHwGXBLcRyv%2Be19RvfIxWpvEY5CLSiy%2B%2BONEF81Y4R7cYDLoRfjS4HRYlBoYrSqtL1U9tYqZYF6FBRYwkZanBGAkruO2ztypJDAkIXHPNNUgJAhtCGgkXrQUhBP2PZW%2BR9z13lc5HYZRa8Rhdnw5%2BHDiQQYyPQQFCOOGzwv6RM%2FXLY1hIvjUKEEMi%2BKmlyN9KcJVP02NYveUao2J4jCFhjWUMQSAIBIEgEASCQBAIAkEgCPy7ESgeowTtQgHgH%2Bz5Vqf4AeoClppdY%2BE00RrtxBBRB2UtueSSTD%2FhBegQKpSEitz%2FERe0HAxDsSxQHPxH2izEBBiYx%2BBXQvvBeFTM4Q6tIuU8nYOQHWw93TEDWxbagbq%2B3bZE8Rjswfakm2g8BqEFg3GKKaYQ5LPiJCjmaEtESsUDKQUIpUqrLmCC8J4sU7QMuqNxOAqgU%2FoyKq1iEoMRATQa7RAxhhNz3nzzTT5KLm8fU4fZKPEPHgO5UfFbDJU0yAfCGUR55AM1BZGPhV2zsBQH4DG6567iQPbbbz9OH%2BUqhQT7NB6DuxMxBleXdoqKgSFbujwGKo%2F6osaAS8GzOevEbeJjFCb5DQJBIAgEgSAQBIJAEAgCQWDYRqCrxxDkAXvANGPoCRFgk9rtFltsIYwAK0zaNjH%2FC04TFPJunSwJHCEKpeebbz4qi1%2F%2B8pesKreoDJoEm8V8NJw3Sucgl7cI9oPDSFlzFR%2Bjx68Ej2HDugJmEkiIUEEIgdYQn3OWWWYhuS%2B9B5Jht912s1WNUVliiSV0QQjR86YGkccwSPoKziOcU5i0pUXRnVmUr4qWzYKwBNsDFsFCTWGxxRZjFzN4hUKVy5A0TuEWEUGymr9ADUlJZIhzJVoc0Z6h5vZrQMBxPBQRXDDKH6r1WD4d4q4gB%2Fxa%2F%2Bgp6wqhx5sD%2BeCNI7gwDOrSZmDGUFiicXJRQXr0q8fA%2B3nd3FVoJHgzCWmL9EOSWAn6%2FTQewwIjq0BZ%2BIhwaJa3uDS%2BGkNqPAb1hQgeNBvWG3GUjjypgB7hMdo7TSIIBIEgEASCQBAIAkEgCASBYRgBbACDvU5Q5TbC195tuxzMUfIMBAILrj2XcCpHhYxgs3ezxL1EMtBj2L%2FmlEGtweqvigJZTDrppNKsMJBuuummnrRgnjbHp5566gUWWKBsPUZZC8ehioNcG%2BOBPZhpppmqTb%2FaF%2F6ineDQXhZ5idwmFGnPK0GPgbtwvmpFzGBCYiRam4KdSmuWXam80KBKtlyMSkXAkHv66adXMI3Kxaiwc6tW6%2FGdd95B7FBukLi0h0l8nQhYit6UQ1S5lvT061ASXAFCQIQWITSRdVRDfnEUCAQUBKarqiDxyDmQDLLwG1gRv6Vl6rZpJSMxFHMpIA4tfQUurgk58BhIPAxh47vQJvqqI1NfeOEFuWoZg4v4x%2FdFSWLF%2Bhh16mAdv7K0T6ohBA2m0QCQHmrV2SXd8SQdBIJAEAgCQSAIBIEgEASCQBAYZhDgFWLPlyHGgqtJ2Xe2PW3jGP9AaEEP3yYrQoX9XyeNcvFgDHZDQCBAbD0ffPDB7H0GPnvKpby6LCwxErlp2OkWRIIhxs7Sqa7dqsXGry5YeZq97bbbmnMHe5CNZiR8Oig02kgk2J5U%2FbKcV4Ic0Fo3V9oTz%2FEz7fzKngI230kvuhOxX0%2BAYXbGgKIxNlA0AT%2B6gymqR3A187PadDqJLFoOnIkx93TklhGNe7npppuYon1z8%2BRrQMCisjItxZ53p2ukEz2Dd829yKvkuySuy3333WfxoM6af4eSFpWFYc0gHKxe3kxqtW%2BnzcJpI163LLomPWq824hiloFoKlqg8ahafJGUb4uHboe0yZeiHVn4E5daBq9Ng3zppZcMwydAGdJIMy5d%2BJBPW%2FBteEkEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJB4N%2BNQN%2FDRLo9DpzbLdnSX6BKq5tEEAgCQSAIBIEgEASCQBAIAkEgCASBIDCUIuDERidCOtK07%2FXxxx9%2FVZP61a9%2Btfnmm19xxRX98g%2FOl9xyyy0dK6k7h5w6O7IObB2gd%2BeibrbZZn4HKDMYs4y%2F4eng2na25mAcUrruIvDXv%2F7VC3KeaXs1Vqazgx3m2%2Ff63e9%2B59jcbnXv17moGqmHctVyVKsWusW6aWV8UP1%2BUw6EVb17irG01nTRzvxtTXniuVyHFLeHPQmT6rejKqY7ddvRxj11cxsEgkAQCAJBIAgEgSAQBIJAEBjCEWAW4RAmmWSS8ccff9zO5cmNN944wOARIB999BGTalAMIgzGN77xjZ122qnfBn%2F%2B85%2FLPffcc%2BVeddVVE0000YEHHthvyfbw2GOPVeWoo45qT77mBLOXecv8bIZwdwAPPfTQ9773PROB6AQTTDDVVFNttdVWTz31VLfMAOl%2F%2FvOfgNV%2Bv7TPABWT9ZkI4BNuueWWLbbYYo011lhzzTW32Wabhx9%2BGM4%2BhOOPP96T9f7nWnfddTfYYAN3%2B%2B%2B%2F%2F1%2F%2B8pfWspVvrfpq3nzzTQ%2FfeOMNy3WdddbR4Prrr3%2F66af%2F%2Bc9%2FboUlfCaXXnrp9ttvr7Xrrruum1XpO%2B64Y5NNNrnvvvuq8AknnKDk6quvvvbaa%2B%2B8885PPvlkq%2FLaa6%2Fttddenutro402Ouuss7qMn6m9%2Buqr%2BjIpc2y1WuLFF1885phj9LXddtsZdnueRBAIAkEgCASBIBAEgkAQCAJBYChCgPnGdsMJzDnnnKusssrKn1wrrbTSaqut9sADDwwwEQaUivPMM8%2BgGERXX321Lnbfffd%2BGzzyyCPlXnDBBXLZX1NMMcWJJ57Yb8n2UAFVjjvuuPbka07AbYcddvjhD3%2FYNTPbGO65557RRhttrLHGWmGFFSCK0zBaTBGWppUZIIHx%2BMlPfsLY%2FF%2F%2F638NUCxZXwABrMWqq66KnTjjjDNOOumkIi5eeukl7MTll1%2BOkTjkf65DDz102223XWqppQ4%2F%2FHCvu%2FWFYkIFHHbYYd4OygKxsMwyy6iE3Nhxxx2XXnrpM888U2tVHtdhnXi41lpr7bnnnkVWtKYk0IA61WDpdjSrsC%2FlvPPOk15xxRU33nhj7ISSeC1M4PLLL3%2FEEUfoa%2Butt1522WWxFkV2mdc%2B%2B%2BxjsS255JKeX3%2F99d1epG%2B%2B%2BWbsx3LLLUfIhACkRekpkNsgEASCQBAIAkEgCASBIBAEgsBQgQADjYU18sgj89FgEPVcbQrsNe4nf%2F%2F73%2BsJC46EfrbZZiM5ePbZZ92qWFksOAaXPehmynlePMbee%2B8tLddVheu3y2MYD5EDQUK3wO9%2F%2F3tWXteoLx6DHVoNKtDKG4lGqjDlv2F3JRO67hZutQZuh7GpnabVLx8BtA92gmXqtk2%2FGrz33nvxGIxQXcsyjPPPPx%2BPMc000zz%2F%2FPPdTtFBWm47%2BAob%2Bf333z%2FGGGOorqLGW3mwKNzVBrSsStTcex7mtiFgYRD%2FoOkaR0d0xLQ%2F%2B%2ByzQWfFdi8v4rTTTlMYRdBakEAIYEJQVdLaQVUhOmrFvvfee5QeSAlch1zfy0EHHaR9zEZ9EXrpNiX9%2Buuv03LoSPqZZ55BRBxwwAG1HgwA2YIkueSSS%2BTeeeedmrLma21jSDbddFPSi3Jm8UUYKkpk1113xXXccMMN3Y5eeOEFKg4KE8vVmjTNviPplk86CASBIBAEgkAQCAJBIAgEgSAwxCLQeAzi9n4HKYwACf13vvOdSSeddMYZZ%2BTQwWQ755xzpptuulFGGWXEEUeUZZtYOywjkS4WXHDByT%2B55p133rvuuqvaxGMMP%2FzwiyyyCFNL5tRTT20fnCFWuV0eQws%2F%2FvGPCeYr65FHHmHOf%2Fvb355sssnmmGOOa665pux61txwww1He2%2BLmX7DGKQNVS2%2B%2F0zLRRddlJ7Bc91p8Morr9TUwgsvXL1Tm9Qed%2BuF6SdLRxLk956bpi1v7dgWx9gYgOnT%2FLMir732WtMfZ5xxjGHKKafccMMNu%2FJ%2BdYvHYJN2nW5I%2BvEee%2ByxR3WKjrCJP%2B2003LhwW%2FYdmf8sjGhrc0RRhhh7LHH1uPFF1%2BsvE4vvPDCH%2F3oR94CdQcNQF82QxkeEHQ1Zlpd5LcHASwWeNn%2BjcuyZjiDeCmNo2tVCI3INvbdd9%2Fuy7UqPNFIcXEXXXSRBYNhqFo%2BASvTIn%2F88cc9efTRR60BTzxHHbSWuwkiEEv36aef9tD3wqOkS0FgS3AX2Ay5fn0LFUbGrQZ9jOriKNw%2B99xzKBEdFTPTbcTCMAa0W7E3nzYSjeQKAkEgCASBIBAEgkAQCAJBIAgM%2BQgMzGPYF8YJsL4XWGABhjb1BcudKJ35Nv%2F884855pjM7dlnn90uMHqBlH300UdnfbP%2BmFey2P5PPPEEEPAYJB%2FaYYbLnXnmmaXRGrWV3OUxtCzr4IMPVoshycwfaaSRGIZ2k9EmPDVKMM8u076SSAC7zDPMMIM0%2B5SNxkT9%2Fve%2F73bUUUctmT0KxVCFqsAAsBNnmmkmudos%2B5Rxh8Egn0CJkPTL%2BulPf4pSQEHwKXDr%2Bq%2F%2F%2Bi8zMgDFMC233noruma88caDxqyzziqIQQv5WG%2B88Ri1TV8PWZpiZcw999z69VzEDC3jWOzgIx%2BkzZF1TLUyyyyzuP3mN7%2BJeClXlKOPPhoO3%2F3ud72FyrXt3iVJdKFNtqohYXuqx%2Fz2IIC1sAB4bTTWgkzCi0BYdcNsVi0iDYu%2FcRT1kAcK%2FVLpN5AGp5xyioVUi7wKWMCYh1JrKIblwF%2B5sB9kGz0xUnxfvE5k1frxS2%2FTXUv8R7RQ8WxVtwitojYpdKIV3kNbefsG0OUxaJkQL%2BgaTAh9iO4MzMPWThJBIAgEgSAQBIJAEAgCQSAIBIGhCAE8BvOZ8YuXYOngBFyE7pdddplZOGcEG0DZXia%2F8JWMaywE64%2Fd97Of%2FQxTwa9EI%2FZ8mfP0D82AIjZgjLPgtFN6DAqNV155xS3PETvLiAjCBrddHoOEnsaD%2BN%2FzX%2FziF1poAgYRA9wScsg6%2BeSTpZdYYgl70G6NgY2PvsADGJiwFRgPMRXRGox9dqvC2IlSgDgPBZVh5AYjl48A%2BoVgQzuu%2FfbbT2E6f5MSZwD9Qo%2FB7GW0ljPLLrvsgrThY0LUgVsg1FdSblWv3355jPfff5%2Fu4gc%2F%2BAETUsBG%2FI%2F2IamKYQMHy2FvXWvaJPZgojJpAct2pjkhxqjJqoJFmXDCCbvmc%2FVrat5RM9K7Q0oaAgC0vDFFzdUI64X%2BQiWVvqKhhMjitUHSU1Rbe04e471bb55YBiK0eE38QVoBASvQCLfffrsXZ%2BVIe8uoD0yC78iX5ZtqhaVRE31jWVQBzSov5oZB6gtbqB2LoVXnrIRpsdjaE4m%2BPEZxL744IzEpRKIPxzrvmVq3kaSDQBAIAkEgCASBIBAEgkAQCAJDLAKsZjwGy51i4Vvf%2BlY7sUQsQWNmVk888cTkCpwaqCNYf8Jait6gFmaD6c2%2BLmoCY4CdsN%2Ft1ma0GALYA%2FTIbrvtph22lS7EOWw4IBnIJFAfnhB4yK04n10eAyXi%2BVxzzUX%2FIPIA6kDXZbzTY8iiq68GZXEAMXjRC%2FmVcMeYfvrpK0aBAtpUuKJzuGUSsumoKWyOM%2FGwBygOLZsaeb%2BZom64imiTPMPcS7evIuUGekSWNCsVbngMvgNue65%2BeQxb7eQlHFLMBdVgqE7PNAazM1M8EilLhWJg3poLfYihatk2OiSFZ2TYGqRfm%2FgNsZ6uczsAAugjzMCg8BiYNxRET1xWSwuzwXmnaD1vB033aTyGj4XyAXWApsMvodfwFcQbTX2hOjkHVuGdd97pO2bLQ5RRLEfJLRBu%2Bv1iPIZPBveirkmhRCw%2FHKOBNcqxb%2B95EgSCQBAIAkEgCASBIBAEgkAQGGIRKB6DNIKfvv1Ze%2F0uggFWvDHLteMs5iSrmUyC%2Ba9YaQ8UKB7j5Zdfrtmx8jAVDHCF28X9QW7xGJiNhgPznwqCEefJp%2FEYGrR9XE3RJzhoshz8VSlphIMyq0GDWXzxxVETjcdAFzTzsHxVbGdXYZNiThaPQfnAVaSNtiUUgIYNdERNU%2FILTWAYeAwIaIRhiMfoUfVXF%2F3yGMYjMEipWRTDSOBeWo8SAoFSU8gCDhjZsNgStwzYbrGWHozHtdQ0h7pfghyiI9FFevQYxBIM%2FDYdnAMVBOLIcmoPJbwXhEA7jxgRIV4KHqOiW1RJpBkChDcKrsOCt8IrbItcndapqaWrwbOJCGrx11vudmQAon1qx4HFFc7CekM%2BECNV8JYqTI%2Fhk0TBdev21WOgxQwSnVK0mMKWtIl40rfrblNJB4EgEASCQBAIAkEgCASBIBAEhkAEyh7nPdETB6A7VKYTk3m%2B%2BeYj2GBEO96RQcTloctjsLaQGGQDLCaWFOueqB49wgtDU8VjNA8RTwgPyB4EK5D%2BNB6jxsC0t4Eu0maFp6i4l8VjNEN%2BUHgMhmE12OUxEA74GS4bRiv%2BgIsQxcV%2BxKLQY3yFPMbdd9%2BNEcK3GADbFqFBgME4hTx%2BxnNij355jHJ1Qa0wPLuDbCEfa175%2FUwESBEEGLEmWwwKIpnNN998%2B%2B2378ZNpZARlYIfU9ddqFgLNIhGqiO5DiLBJHS5LLXwD15NRQRVvpEhvhHMmw%2BkmDF8iHgmmLSeYVvMViMODf%2FWeAZ1xUjhA9WNsEGoo4WKKdoa6ctjqKJT4qXGY5iCgeHH4oLUcEsiCASBIBAEgkAQCAJBIAgEgaEFgcZjVGTCnmHT1fNuKONaFgkBnQNXC6EebC7jMRzwUZvL1AsiXopQQctRjfAcIVcoz5HiMYrTqFxbySiR0kgwz6W7fiXsOEYi5xHih2YGaoTzi9AQjLuKj%2FEleQzTISbhV4KiKU%2BBGlsF5zRBcT4%2Fk8d47LHHqlb3t%2FQYtrybIcw4pd8wzdNPP13Jcpkpp5uqyN8By9HlMWy%2BV3XGctFH3S68uO5t0oOCgLfgbB3CnsZFcCyywrFJDU9lqCa8LE4o3TbJaaxGS64EEpVlkXPQ4K9Rt4gCTRHSaNa7s0qxHO3z4RuCkROgg5tJpcUX7epANGLV4TqQGF56tyNZpfTgsVV9WaWoOa1V1Jd66Lcvj%2BELMmVcTevL4b8WJ6eYxpO06kkEgSAQBIJAEAgCQSAIBIEgEASGcASKx6BJYHMJvEAhX5c9XJEPcQss6MUWW0wkB5YXm12EB5ddbLfcIig0HNjKPsJjzDPPPDwjKBnk2qFGOKjb4mPQZohZwX5Xkj7fOR30FbUZ3S%2BPATf6DS2IBWEk7DukCh6Dicda%2FEp4DBvZZlH0Av8Xw8ZmsBPFL2UzshMH4DGMgZhEiA8BQk2%2Fxx7EY%2FCaccqJmcJTYAT2rLlAjNuOqTmBQl0nttC6IIVsrDvJBZgVHwPBAklnxIDdkRbIlqmmmkpgT30REkBDHFS5zeGlrTEuBniSRiW150kUArgFJ4BYQg7sIMDgvoGXcNuNtEkbI3gm9ULPO0VZED%2F0aGDwFcKkoCMc42uJWs%2F4AdxdqTu8fSoaS0tsGS%2Fulltu0TLywdJSEU9lGN1XYzX64hAjVj7Fji%2FFu3ZZYL5Tn6fGtYYG0ZdQom5pdXo0FX15jOL9qEQcZ2zKlgeeUC9O3un2nnQQCAJBIAgEgSAQBIJAEAgCQWCoQIB9RKzOxOYSIlJEuzh9MKn4VpSZz6x2aCkaQTFuIHaKmYQcTFR0ggktATONleQWX%2BEoEBwFO90tU0tJFIS0y3MKhwq4IWQiww1KrHJZDExpYTalnQ4pLWSoCJxuKUCc9KFl4SkqOCEz03MjKZDtobPrUQc2zY3ZsSlTTjlli0tQbEyL82nKHEYULncAbAAlidYwCSqaoATPAmNz7KmJ1%2BEUOsIScMBht5qR23KH4RuCnWBs1kjqlwsJakibYASphGZZwezZKsBEJfX3XBwMkVQlwOWqA1xwHXPMMYeHsurAF7DoyBMjhIaETltr1SZ7FjEiq86a6Y4n6YYAcQJJhgUg%2FIXwFKgqVACioAogsrhHYRt6nDUq2IUViwdoTUmgCIp58Jk4vxVrQd3RpE1WmqNR8SRyHYmCSaCLQEeo6CweJekiuq0hN3wvhiSL%2Fwv5hwtdJnwHnkRfPjG5NBj6woxptq8cCNnFR6kWUmvcrEX8UNeUNWj6hx56KIqsFUgiCASBIBAEgkAQCAJBIAgEgSAwtCDAOGJqORnBnjLjqHtV%2FEDGjkNIWUyMI0YQCb0qNTuBCh2%2BwCayvWuLmURB8AqBDZlLZBj2rzmSiDVh79iJCWxAHdHMMxIxJ6eeemqLUYA0IHovKQJuQckmnsdFHHzwwUy2JZdc0vMm0ScCcVym3xoJE17EDLvYDFLN0vbb9W7cgjYVbnvuxk%2F%2FULvkVR37Ie6EXszF8yIu8BhUH9iP5tjClrTVzm4twT8uQkesVP12oytok4LC3KEKT12bwl133dVwq07JP1Rn20LDeDTrFbQZ2fdnw2q8GaRmASU4KI%2FG0Xu1036NShRWIyxPn%2FY8iR4ECGDQCKJDoMtwPo3EUEyWVUpT1CNysIDxTmr1NOXWOhHhhKYIrWfBkN90y6Ay5AptgS3BQtSrsTJ9SpaoT6Nb2Dqk0KCo8cX5QOpypomKBqak8kQdPjd9yS1KpNuCtKGq0g2jUQWsN2fCmjLa0LcQEqMHt9wGgSAQBIJAEAgCQSAIBIEgMIwhQIHAIut3Uszz0ie03B6DvScX3dHj%2BN8qflpClZ42P63kl3n%2BxXrpO%2F3PNYaB0eg7a08%2BL3qfazz%2FOYUh6eqZb89abbloKLRDjwCm5Up4KZ%2F2gcjVbLcvhBhqqx2%2B021nUNID9zVwC4ZhyQ1cJrlBIAgEgSAQBIJAEAgCQSAIBIEgEASCwFCNwFfLHX21rQ3VwGbwQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASGJQScpOCchXZ92nkNX2zKmv1iFb9Mra%2B8U0c8FD7dIye%2BzAhTd0hAwMmqDgvudyRO9HB67wDnegjU6bjV7nqwQv72t7992ufjvNSeg1y7%2FbZvsD30RGu6aE%2B6Cf3K%2FbRgoTrqOc611VVFxe6wW1YSQSAIBIEgEASCQBAIAkEgCASBoQUB9tchhxyy8MILL7DAAvPNN98iiyyyyiqrHH300b%2F5zW%2B%2B5BQuv%2FzyBRdc8KabbvqS7QxK9WY%2FOstyoYUWOv300wel1iCWOeuss%2BBjLvPPP%2F8yyyyzxRZb3H%2F%2F%2FYNYtxVrI2xPkhhcCLz44otHHXXUzjvvvOuuux5%2F%2FPHvvPNOGwkz37rdZ599dthhh3333ffKK6%2Fsl3%2F45S9%2F6RjWN998U8U%2F%2F%2FnPl1xyyZ577rnTTjvtv%2F%2F%2Bd955Z5cAef3113WxyyeXTl966aXWV0s8%2BeSTKj700EOe%2BB5vueWWAw88cMcdd9xjjz2s5A8%2F%2FLCVRFBcddVVhid3v%2F32u%2B6665RvuY888shhhx1mXno79thjX3755ZZlFtdcc40qKu61117nn3%2F%2BH%2F%2F4x5abRBAIAkEgCASBIBAEgkAQCAJBYChCgB202mqrfeMb3xhnnHGmmGKKySefXNo1%2B%2Byzv%2Frqq19mIugR7Rx33HEDN8JM22abbZiBH3%2F88cAl%2B839xS9%2Bsc466zz66KOVe%2FHFF%2Bt02223%2FbTd6n4bGfjh7rvvrs3RRx8dPuOPP770yCOPzBgcxAFfccUVa6211hegPgYeVXK%2FGAKvvfbaZptttuyyy26%2F%2FfZbb7310ksvzer%2F4IMPtEaeccIJJ3iy4YYbemhdLbXUUmeeeWaXK1DsT3%2F6E5YDB2IBkEyceOKJim200UYIBJ%2FSSiut1Li7t956a6uttsJ96cia1Okmm2zyyiuvdEduoeIc1lxzTYyH9IUXXqi8BWMAG2%2B88ZJLLmml%2Ffa3v1UFPXLBBRfIXX%2F99eUqI41pKYrsrrvuqt4xFT4oszDN6ssgTzrpJINcd911VVRdsxgPmpPuSJIOAkEgCASBIBAEgkAQCAJBIAgMFQiw0RhEI444IgPqvffee%2Ffdd3ECxWww9Lo7y6bzuUQFNnzvu%2B8%2BRt%2FAODAhJ5poollnnbXfje%2BB68pllyEWbr755ippP%2F3BBx8su69b93ONvFtRmiGpC3YffGzB2xCfd955PTnooIMGhS2xt64we7On2dx%2B%2FQh4XyeffDI%2BgeiCFU9KQWyDDcA1Gcxjjz22%2FPLLUzv4Cigf8HjIh7XXXruH0COcoFm6%2FvrrVXn66ad9LBZhVdEC9kOtUjugHfR13nnn6egvf%2FkL2cZyyy1HYtFdNtb%2Fpptuevjhh3v4xhtvqL7ddtuRbRgAig8ZqIXqC9GBiJBrEcp9%2FvnnSYOQFVa79nEXci1%2B35HbM844A5VxyimnGOSvfvUrA7aMq%2BKvf%2F1rSg98C%2F3G1%2F8K0mMQCAJBIAgEgSAQBIJAEAgCQeBLIlA8BoGB%2FdzWFBNpggkmmHPOOZv4%2FPbbb7dlbBt3vfXW60oLbPWeffbZbCJGE6X9Oeecw7B6%2BOGHNUV7v%2FnmmzOsqlmGni1sNqOLwsFWtec2u1dccUVSBzoH9iBjn4H285%2F%2FnLF25JFHrrzyytq0S%2F773%2F%2BeoccGtKes%2Fccff1xdg9T%2BDDPMgCXgFEPRgTOR5eHVV19dnfo1cvbdEkssseqqqzIkW3AAz9mPLEQtG5Jh2LPWe6vYEsVjnHvuue2JwU833XTolxdeeKEe6tqu%2BgorrAAiW%2FM1a1v%2FrMuZZprJCFEf9utr398YLrroIrMzHQVaI619CVNmbHLw%2BWL0TreppBsCXpOlZQk1tynvCFPB4cJLQbtZon5beWyAhdFd8Jg9K9MrLm8UBAie4cYbb6wq6DLOI0iDZ599VlrJNdZYowkwVPER4bW6y4wXiZXAH0oLt912m9ZoitoAfJWW%2FWmnneaJknIvu%2ByyllufHpqCC4nPR3eNIfFE1zgZ80JRquh7bBVxcZ5wS2lPkggCQSAIBIEgEASCQBAIAkEgCAwtCDQe44477mhjtvPLh2K22Wb7wx%2F%2BwByzqzvqJ9c000zDJJ9wwgkFAVCYTUelMNxww3k41VRTjTbaaJVmN8ktHYL9bum3335bfAnFNDvJJJNIIEmYkFT3Y4wxhlquscYai8LBtvUcc8yhQF2LLbaYaAZMObeTTjrp1FNPLfGd73wHicHe%2FPa3v01J4onRqWX%2F%2BtJLL3WLMdCpkdv7RpIMP%2Fzw3%2F3ud0cZZRSJvffeu9wE0A5KInA8n3LKKQ1ArpmqpW73Kh6jJtKeIxmUr4c4h1KwYDaMTbOGyjJFquh3pJFG8kQvM888M2D1vttuu3nCkQdoEjPOOOMzzzzTWq4EVYwsA7N73pOV2y%2BMADINBYGsaC4VxAxUDRQUNAxeDdaoaZCwDRakN4uUaD3iIrSAX6piFhgSD5PQCtBgWK5FfaDpUFt333135VJrKMx1pduFyBiET7%2F73e%2BUIfjBgBlka01QC62hHTzxWZGL1KdXBbBwniAo8I3IQ7qRtnr1hU7h1aUvq06zjbpRFynXw8%2B0HpMIAkEgCASBIBAEgkAQCAJBIAgM4QgUj8HWZg1Ju1jltA2MaL78tnefeuopxAVbmynEXCLI%2F%2BY3v0newBJkvtFR4BYEBGAP3nrrrdNPPz3rHplg1mQVGiGqlyZ1kKY9UIwlyPHf7cEHH0yfwC6beOKJf%2FCDH2jto48%2BYk4Kp6kRXvy2uZmQCAfDo514%2F%2F338SoVdoOiwwAYbkIZoDLwDzQSBm94WjZ%2BnSIHtIwKMDwj5w6APMEnlEq%2FhvSjH%2F2oBq8ixgPZ0tcRpl8eg1GpI4yEjpiuRmhb39RUZzwav1vxE5iQYiMgSVi%2BVP3kK1xgDBg%2FQ4bBej3iiCO0Q0Ni8JpqFz6HGACSqrSHSXxJBLwCgiIvtCkirHbLcssttywmQfuWipWMQEBiUErwQ2mF5VotuAjfgrSvAymhDO%2BSNjCvDPNA7eOJ9Wnd4j0IJ4TWpAPxTXVZEf4j1okYL41%2FaO1I%2BDoQLKoXl1XipVJuVDEKDTxGsRzdigbsMzHOFjemm4s%2F1CbyxNy7z5MOAkEgCASBIBAEgkAQCAJBIAgMFQgwn1lSTGkWPZ8IBEV5aiAWHKNgCtgGuYIZstoUFoDCPi%2F6Ar%2FhoSxC%2BjZTFiIjnfuGJ10eg%2B%2BGknKLJWBJsRwrZAR2gqxCWFEtq4XH%2BK%2F%2F%2Bi%2FciFgB1axhEMDbpEZcoEFYiCQcxlCb2igLmgoGXRWmutdR8RjVKQKhsvxiMBS2%2F85sxGNgGyqAgCyN%2F%2FSnP8XDkI608pXol8dgPOqIk4IyLFNmL2PTbr5db3vx44033s9%2B9rOyfw899FAdlauLMSNecCks5cLT9PWLJsKB9PSb268cAQtPAAqanEZNFI9hSTQeA%2FXE%2B4Orkc%2BBR1JXKqNWnRVSDIC3ecwxx%2BAxumWKx%2BAhYvB8OtAgvId4XWlQAsflYZsX9yLeHxRH7UlL%2BBzKl8rKKZbj1FNPJaIoCqWKWfY4kwru0SoaFVLOc59nXxIME2gK2un6kbW6SQSBIBAEgkAQCAJBIAgEgSAQBIZ8BBqPwfTm9EF6wTz%2F8Y9%2FzHHD4OWyuz3hMcEJgrbBL92CJzfccAP3DSZ54xCUZyJRJvTlMbTGq0KtySabrCIAiHpR4DD%2F9cuWL4qjeAx8Qtef4t5777XvzFnjW9%2F61thjj60dwS7KRqOIQE20AAXFY2AeNM44VfKee%2B6pjvwyUaeddlqhKhikDEyUi4AelUv%2F4NjZQecxzFHjAjxWdSETDcnsaoQEGPPMM08daGJbH4%2FBtFSSFelYWLdKFp44HIiBtLunX23m9ytHADlGjzEwj4GMeu6559AFeAMkg6NJuHvUSEhoBMVt8SswBs5UHUCPQbyBcNMdEYUGeVohEDBgxUtYDEQRTndtpEqbr%2B%2BO%2BgL1of0WIKWCdfToMfAVPSFkfZicWQiWqJtag5XAjWAdfYCWvcH35OY2CASBIBAEgkAQCAJBIAgEgSAwVCDAYqrzSuwjM3xsWDPzhW4oYTyuQC6Dfe6552YxMaxcKAW2G9UBQb7AFF2H%2FU%2FjMUAheAVlwlxzzcWK16BEUSX98hiiaFYgUBVtbY855phoFs4XwnGQQOiUefiZPAYNv466QRr1RWcikgb1RfEYBP%2F1mj4vj1HRP5iZqjMtDQ9o%2FGXMEb2DbKEq6ctjQBi8iBdsBnOy4UkkIDBjjSS%2F%2Fz4EiF7wcrivEv%2FoyBshDbKS0WjohWIY2gAsD68JM1BPOEmRavAWqVuFReDkviEYRavCScSXYtV5%2B1bCBhts0OJdUBNZw%2FyMSvshwIvvCNfR6laCUAdVol9rCa3Xcrkv%2BfS6OgoD86SReErKNR4z6tKA1QLmsA4xodPoy5y0XpIIAkEgCASBIBAEgkAQCAJBIAgM4QgUj8GybvYR5QCFAA08Q4ylxibCBvCbMBEWVjP0JCgNZDGv2hyd%2B9CjxxAWwM6voBCsLQnkA4NdeE8VK7hE8Rj8SjAJ2ik9RuMxiPBFGFC4glooYEud5oGx1uUxmjHY1WMIxaliiUNqhMIFjDvuuCI3Gsnn5TGackNTDFJuOIiLMmDr7NfWkbAGk08%2BOaKmy2OU%2BB9%2FwnNHgJHyROjBswaZ338fAiQQKAtLGn1RvaDLOI%2Fg35j5uDvUQff4GOsKn1Bvli%2BJaBXCctbCq%2BrWG9aCl1Dd%2Bih4jmAnuBpZJJbujjvu2DgTFSmFdGfNe%2FVCo4hT0defqNgJvlFG24WiBtNVX%2Fi4fAhNoWF58xFDlfTlxCx4EV3wZnxVLMJus0kHgSAQBIJAEAgCQSAIBIEgEASGLgQaj1ECDINn5iy%2B%2BOIjjDBC6ecZcbwkxKUkqJDL%2FrIrzdwjd%2BdRggBhmLPcZQn1IE4muQVph9uKj8HUwkUIFoFSaPvaEm4ZXIrZreZXghZgWmrctjglQ%2BMxmH60CtqsKAHGxhDjD9L8ShiGmJPS6rMiu%2FExkCeUG7xFyi8Aq1CUCH8B%2FdqV1s6g6zFIL7RgeOJ1UIPU%2BKGnKTvsbvE5BgAWLYOl6THgAMDKVaCidmByyrxVnj1rSF3rWJsMT3oVjjCquM31lSAAVTwDDYO1ocFaMJiKOmEHV2YxI7hKruB1ezU8Qbg1KcxBCUHR9aLy0JE0FgP6roQTFUITT0Jx4f3iIug3nnjiiRo8dxWLmS%2BJwugLUg3hNaz5yvVrPA43sbaRLeK0uJVblzR2QmvYv5JzWNWaIi8pvQf6hRyI10yFtala5qui32uvvZazibq%2BstZs5bbekwgCQSAIBIEgEASCQBAIAkEgCAwVCLDE68zQikxYY5YWS%2FP73%2F8%2B7gJ1wPhip4tFyYxaeOGFpQV%2FENCSuVfRM5RkGyIfZDHhi8eoAKElY7CJrEHRQRlT7D5Hr7qtcxZKgIGpQDiIuum0CKeKOL3U0RI1GGdEapP4AbsicIcuXOzNUjto3624FkbCsiMmcVtKDzvsJfwQudTI6zhXhEztxeMllBSFoHqhBkG2OFi2ryC%2FdB0TTDABsgUIarkYjDbcq67ZCXBhRhoX6KMKzDrrrGVvolaQQg5gtVfOzlXLTJUhQWmjkqjpVIN%2BhVEl29Ajdqg9TOLLI4AdchQOroCPkiWKphBHpZyYLAzSGjE5BZdwSg6vEGmiI68GF6cwHqzYvDaMoqEUUxEp4UQSrIj1jytQhssV6sN71xdKxJrRXWmHhK6V1XOeiDVvbKRQ2tHvAZ9cFCCoMMvSp8qNRV9Cdhge9kxJPCHKAr1G%2BFGBSXk8uaougsVCcvnGiTF8DmbRmjXg%2BhbadJIIAkEgCASBIBAEgkAQCAJBIAgM%2BQgwjlj9s802m2NJ22gRFGylmWaayWGsHlLUK4MN4NDhJBH7yJw7qjA2g6xCyEqBQMXQ%2BOEPf0gdUTEtUQSzzDJLc0hh3HG1UAwhgI7AP7SDG2wW60sWCwufwJRjc7WoAkxFGgZUiYp%2BWZcLLLAAOX2pLNiVgjEKm4n9YOthYHRaYSuMUGviKKolfgUmgR3XaApHRRhGcSlKslXNixHalzdwtqY2TQ2JIYFOEQOhG2GAlEIZUzBCjAroFv3kwloUejgWmhPMRoUEYa5yLsCZwBP5wwRulEih6pcSQwwNlm8djdGeJ%2FHlEXj44YetZ6%2FAShOEk0yitYnQOPLII%2FEVyAf8hkNtytL3OpT3lpEGrXAlfALWm2AsqlhgaKu2NhQmc7JivUfVUQ10IJY9qYYBeI5%2F6Lb2yiuveG79CAKDpqhLmzvttJMBKInlI93BcohaY3g%2BsaK%2FDBLdoRaFxv%2FU%2B3%2F%2FSxkiAi3vJ133NCvL3Htome5gkg4CQSAIBIEgEASCQBAIAkEgCAyZCNg4Zgqxp3r8GogZPOy60vPWZ0yx2roTYQOyoZhXDCIt2G6mTKgjQvAPWmg2nVoKoBFY8d2H1ZoWcCO0GUw%2F5INEYzmqgN6rgFtmoJbLp8OtBPOTbwudfA27XDaqol%2BtGXnP1nPP8Fq%2FGmkVK1HdacSlVk9uuzUkIzQRT6rxBqm5mDjipTsp3IVR9cRAaK1VI6bTfZL0V4UAYL0Oa6b7RlrjuCN%2BH15oe0JEgfRoHiLteUvQ3qhi6bYnLaEv5Jju2rJ%2F6aWX8B6NQ2slDcbH2O%2FVHWff4Vm0PlW999T1xCJ09Txvt31XextMEkEgCASBIBAEgkAQCAJBIAgEgWEPAewBfQJnCjE8KeRtJYs4wTuj%2FCmGvflmRv%2BZCOD6EAIYp6%2FK6kdz4SLQC%2F%2BZeGbWQSAIBIEgEASCQBAIAkEgCASBwYWALWCe%2B8I4VEQIv6JDPPjgg4NrPOk3CASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCAyPgoATxOUUVEHIwwRwGxiq5QSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAKfFwHnOTr%2F8dBDDz3k%2F38ddthhzz%2F%2F%2FOdtrco7CPWyyy476aST%2Bj2GchDbdFDpMcccY1AHH3zwcccdd8EFFwjEMYh1UywIDICAA1Vvv%2F32c88916JyRnDPWbqWvS%2FizDPPvPzyy5966ikH8vZt6plnnrn00kvfffddWQrU7dlnn33ttddat93yYuHq4rxPrjvuuMPRvd3cSr%2F99tv6coRx3TqY%2BLrrrtPaxRdf%2FPDDD%2Fcca%2BIruOKKK8466yyDfOWVV7qtORP5lltuMS%2BRau66666er%2B%2Fll19u83r22WcdwtKtm3QQCAJBIAgEgSAQBIJAEAgCQWBoQQDnsOqqq7YDR1pi5JFHvvrqq7%2FYLH7729%2FOPPPMo446ajPNvkA7zj0Zc8wx23gkxh133J122snxl4PS2muvvYZIufvuu2OvDQpc%2FzllPvroowMOOGCppZay7FdeeWWJ448%2F%2FuOPP4YARuKaa65ZZZVVlltuuTXWWGPZZZddccUVMQw9x63iPfbdd9%2FNNttMU1ZXVVl%2B%2BeVXX311rW288cZPPvlk4fnHP%2F4REbf00kvrSLNLLrmkir6OHrTPOeccuTgTz5Eea6%2B99jLLLKO1FVZYwRhOO%2B20v%2F3tb1UFE2JghidXmTXXXPOhhx6qrJdeemnrrbc2gNVWW01rEqZZfRnkTTfdpErNy6%2FxXH%2F99fk0el5EboNAEAgCQSAIBIEgEASCQBAYKhDAY7CbRhhhBBbWDTfcYCO4LmaObeIvNgV2n63kE044odlfX6AdO9FOdJ1tttkYkgbDHpx11lmxGezEng30fhu34a7wDjvs0G9uHv5nIsByp6PAABx%2B%2BOH0CWQJlj27Hj8AEE8wAwgKYgaiCNqGDTbYYJ111nnxxRe7cBFs4ARoOTxUTJn111%2F%2F3nvvfeONNy655BLUx957711L1LpFROy%2F%2F%2F4IPbUcUqxrA%2BgSCMQh22%2B%2F%2Fe677%2F7Pf%2F4T7bDFFlv4Hn2DWkblbbPNNhrUuL4%2B%2FPDDrbbaynisbbn4E5zGzjvvTHfxj3%2F8Y7%2F99lMSJYLBMy9j0JfPUMVXX3113XXX3WijjaqiX2N2W3qS7tSSDgJBIAgEgSAQBIJAEAgCQSAIDPkI4DHWWmst6ouylboD5nJy2223UWW8%2F%2F779ZzNRTnPOiOKeOKJJ8raYk9RPlx00UVk7VWMEv6RRx6hhWDN4TTuu%2B%2B%2BX%2F7yl7%2F61a8YWUgJNqNGfve731VhBdhrGmGmdXvHY4w11lh2lpvRx8pbeOGFUS509a0kg%2B4Xv%2FjFiSeeeOGFF0p7zqa788472XF4DFvhBtnGz%2BJze%2FLJJ6vy1ltvtUa6Cfam4fWr%2F%2B8WS3poRICXx6677rreeus17w8uIYQZaA3rEMNAMmF5t6lZsVgOYob2xGrkcoJqKHID9YepQF9UAV8TPgEZUh4fHKM03lRJGAYECD8plEVr0Dq3yG%2B88UZPfDIGcMYZZ7RcXWtfj574Qqk%2BeJRUrpHwt9I%2B1uL1118nscDJNCeUp59%2BWrMoFCITX7EhcfVqzZ5%2B%2BulID99de5JEEAgCQSAIBIEgEASCQBAIAkFgaEGg8RgE7T1jxmPsueee2IBNN91UMblHHXWUWxYTM58uQnqqqabyW9fss89O3K7Y73%2F%2F%2B7nnnnuCCSaw4YvKmGeeeRQYffTR%2Fc4yyyx07xK8%2BKs7JMN3v%2FvdSSaZxK5xdwDFY7C28BLt%2Ba233jr88MPb%2FjY2DxEaU045ZfXud9pppyWzp%2Fafbrrp2sMRRxyxLFNW5GKLLdaeTz311LUL3xqXYAayW5XpUiXdAkkP1QhYbJtssgkNQzmSmAv2jACDbgfHhXyzLIsNq2kSXVgPBAxt1tY29w2MQXERWAVL1FptBbSAbSh3D5IkH0tzM0FuWPw4k%2FqaVLGMjz32WN%2FXO%2B%2B84xbjgTlBrbTWLHgDQLt5gmaRRkq0XOyfvjwxLzExEIYtmkeJRqiY8DNYR0QHErJVPOWUUwz70UcfbU%2BSCAJBIAgEgSAQBIJAEAgCQSAIDC0IFI%2FB2D%2F11FOZUfZ2XTZzS3NONbHQQgvJvfnmm6nuJ598csRFxdvcfPPN2fvTTz89W8%2BuMRG7WzvdqABhAeaff36F33vvPTwGEYWs73%2F%2F%2B1tuuaXohTavRxllFJvFZc1p2S37jsHVBa1fHsM2%2Bve%2B972f%2FvSniBS8xKSTTjr22GPbv37ssceYlsbJrCMLIf9gmepU16T1ahmGLhQwBtvQRx99tGgbiJeeYAXGwK5cYoklmu3ZHVLSQzsCRDhIMOxcI8escLyEVYGg6JmdtbHddtvh64pkqNz7778fNWGBuUUaEAK5rdAWVcDyxjZQBLnFZtBLkAYRI1lRBx10kGXfZc98ZRtuuCE5U%2FFy1UL75ZZ14IEHknAUs%2BHbETEDWdEK%2BHYs%2BL5xbEg1ECyEHAq0wj43FI2vmNyIc8ouu%2BwyiKFmWgtJBIEgEASCQBAIAkEgCASBIBAEhgQEWDdE8kx%2BNv5oo40mOGddDKgaHsNtookmoqMgZuDTUaJ3FpxNZLdNco89EL9isskmw3LY2u7yGAsssMDEE0%2FMlKsG6SVEAcVylPaePaX3psxvmPTLY%2BhFxIxpppnmgw8%2B0JGoAldeeWVVsd384x%2F%2FeIYZZiiHESEOhhtuODvvlct4xJagWVr7%2FAtMWQyE9qQl2qZ2e5LEsIEA7osJj1hoPAb6QgwKUSmao1PNVAFeIdg5C6x5NmEbjjzySCsfQacY1ksZPEZXQdFVTSjAiYmrCEoBuSFBodGlLLiloNfap9EF2SIkw1ALwViMn0SPM4jVq0Bf7dADDzygWdPsukcJd4N7NFohQH3yjz%2F%2BeLe7pINAEAgCQSAIBIEgEASCQBAIAkMLAo3HYNrgLnj314UHaFM44ogjUA0ue9mEDZ4z0FhzeIBuVI0dd9xRGVvAPTwGToPniCiI1SCrkIWFZKCEp9yYY445uIH0jSnaL4%2FBC%2BAnP%2FkJEUiFvDB4ZqM9bqKReeedVzwNHdlx1hETTxciKFan5RGDP7G7bYvcb0UNZWZWgfz%2BJyDAvULEy8%2FkMSxvKiMkBtaiFnyBQ9iDChNcopguxUSoGIDHwBX4ZCg6eIvwN0EjYFFaVApyi3322adcWvqC7zvCWmDq8H6V2zeoRb88BuGHQZJ59JzKiugTHMZIyFHoOohDuixH3wHkSRAIAkEgCASBIBAEgkAQCAJBYMhEABUgzudII43UNz5GG7CQnhNOOKFYoC1UYOMxRCZsxRAgeAyqdcEHunoMaQoKW%2BGtJG087YdDExhiyBAb4hpsuZXol8dgmvEl%2BdnPfoYqYQZWjA5PkCFzzjnnOOOMw%2BukXx6D14mxITpoS8zFRTrCR6YbU7FnALkd9hBARFh1e%2ByxR2MnyDAcAsK1pDlZ4NnIJHAISIYetyPxKOgcfA6FDDYDD7bSSiu1J54LeEsj4WsSQEOYTYxZ81ESGhSPgZqwepWk4tBaHSnSAzWHFGeR8HZBvLQsTAj%2BofudUkPxK8FOtDK%2BMgQjpq4CdLTn3YTPk%2FOUQXa5ym6BpINAEAgCQSAIBIEgEASCQBAIAkMyAsVj4Ci6wQy7A2aOFV1A3oCRqFNFGo%2FR1WNsu%2B22yog62FeP0cNj2AheZJFFxh9%2FfA2OOeaYXUOsdd0vj8HrHx2B91BMLfSL3fAKX2Cciy66qOid%2FfIYJSlhmSrM8cQlwTuAIKT1mMQwjwBtAxcSUS%2Fae68IFbvtthtarKbPBQkPQCZRziMNE%2BY%2FJcNOO%2B3UYoTKItvAJHS5hdNOOw0HgtnAgaAUtOOQlGpE6Ji99tqLIKTUR%2FQVaI0Kjdt6kRBtQxmCip4sLAr%2FFBxLK0yMpK9GR1jSBEjkST3fMiKFd4w13yoK34HH6JdCaWWSCAJBIAgEgSAQBIJAEAgCQSAIDJkINB6jWUM942QriZghOIZtaxwCTb5taHvWbDRHhzQKgmn2gx%2F8oPgKNAWCosX5lO7hMXQh0qbWXD%2F60Y96TlytARSPYbO7xRMQqcPpJDQVZTYKNaC6dqq8rfaZZprpO9%2F5TvEYDnpAqnB1qVyBGd0yOZtRycxEudSpE1Wmfs2ubdZ3nyc9DCCASeA8RQXRInMi4ogcnN%2FhvZsgTxDcAiFE99SSmrjgt%2FgNn0MXB3oki4oqo2JooEcsOZIP9Ag%2FDqzCRhttJF1VLDksiuNRJMg%2FfFBkQobUbZAAg%2FtJv%2FErjM3ngJEr8ZJVesABB7QzXgX68G2i9VrImtasMYuJQSjSnpRopG%2FJViCJIBAEgkAQCAJBIAgEgSAQBILAEItA8RgIgaWXXtresa3quogrMAn8OASjIJywpctQcsAHGgFFYDp1XonQFvgEu9IiVGhEC%2BxB1tx8883HccOONmtLGr3Q9StR3akoCqhiK7xfcPTuSJEpppiC6adZWgt0Ct0ISXyZnPbNOacowNP%2FsMMOE%2FtCa%2FQYdZyK6gJ7CqYhgoEzKO2hE%2BorsPjiiwufaNPcoSfjjTdeC1ZQY2AhMgZNeQBZfr%2BjzcOhBQHMAw0DSY9lfOONNzqGFa1Rrh84BLeECoceeqgsHJ2LCoK8gcQI10Em0bOMuaVQaGhQ3AmUiHXoO7LkLCTMxoUXXuiWG4u%2BLFdyILc0RbJwcUiJ%2BpQadPgNkWmV8VE4LLgG4NdgdGQNc93Culi9PLOOP%2F54Q9UjLk5UUqE8BPTwsVx11VWtLicv%2FJ7QNNQd1j%2FPFGSgrxUh4%2FttBEsbQBJBIAgEgSAQBIJAEAgCQSAIBIEhHwE8hu1jhIA4FfQV7XLLXCK6QB3YAq7tZvaRGBQOM3VciD1rtMAEE0ygioSzS9hlpayw14x2EHKzeAzpGWecsevpDxbGF2MKNVEnVPYF6pFHHsFR4CK0r3ElF1xwQaL6IjGUt5FtP9rI9e5CPjhUBfVRtiHDk8C%2BxsacVN5gbHNzRanyomoQ9vfshru1Ha8vlmPfIeXJMICAV%2BxwHEY9EsDldZMllMIBxeG5iBaYDcKGuvAGSA9iDBTBwQcf3LNgAIKRQ%2FopxukDoXHIIYc0fRHmwSEj1jnNhr4sSOe00mmQGOEfMAmtZAGLcBMXVO%2FG8D%2F9r%2Byzau4nPiK0SesL3VFOVc7x8T3qSEW9tLrGU9%2FXo48%2BavxVsWbUonYMA%2B80UwgCQSAIBIEgEASCQBAIAkHgPwoBFhxHDGaOwx97LlvPnlCzt4AASA8GHYaB%2FUUej%2Btg7wsFcPnllzvqsbljKPbcc88xlDzRfqWbQ0fB63buuecWsZPMo1%2FAmXu6NjCXLmwra7ZvSaafcydtdmtHBABjw7FUMS049MGWdLMW2Y%2FGrzw3k0%2FbjFZdmXYuZ98e82QYQMByooggTugGwaAjEoqz74U9wOAx%2Fy2zfufOkeqxxx7DGFiNPUQHAlBf5BPUGnQRxcJpEOGARmukXDXrQxMTo%2B8AfKHt85HwORiJMKHti8MK%2Blr7VvSkfV%2BYPd40Bum3fdH9TicPg0AQCAJBIAgEgSAQBIJAEAgCwx4CrDNSDTxGhar4XBNEEdDP26dW3eb156qbwkFgsCCAi8CqNTLhS46Bk4jW2oGqX7K1VA8CQSAIBIEgEASCQBAIAkEgCASBz0SAyoI3CgeNT%2FMKGaAFWgg%2BIOqKaNE3muIAFZMVBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBL4AAPbx4m0sssUQ792HQGyGw507Cc58Uf9BrpWQQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEAS%2BJAIV%2F%2FNzNaJKTwyBz1U9hYNAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAn0REGTGgSOOGvkCoqa%2BreVJEAgCQSAIBIEgEASCQBAIAkHgPwQBxpQDHO%2B%2B%2B25niPSdch1P6cTGvllD1BMnZjpx9ZprrrnrrrsSO3SIejVD2mB%2B85vfONu0nUY6GIfnGNY99tjjnHPOcRzwYBxGug4CQSAIBIEgEASCQBAIAkEgCAxdCPzrX%2F9ae%2B21RxtttNtvv71n5LaJ11lnneGGG%2B7SSy%2FtyRqibm%2B55ZYf%2FehHDkCp61vf%2BtZhhx1WR2SyEBmtiBob3585ZpTOyy%2B%2FLHLpxx9%2F%2FJmFU2AoReDiiy9eYYUVbrrppsE%2B%2FhdeeMFIDjroIN%2FgYB9MBhAEgkAQCAJBIAgEgSAQBIJAEBhaEGBDrbXWWiONNFJfHsMUTjvttEUWWeTJJ58cYqeDdphkkknGGGOM7bbb7oILLjjiiCPqUNf999%2FfmP%2F2t78xFaeZZhrCks%2BcAoW%2FU1SmnXZaBuZnFk6BoRSBiy66aJlllrnxxhsH%2B%2FhffPHFVVdd9dBDDw2PMdjfRQYQBIJAEAgCQSAIBIEgEASCwFCEQPEYI488cl8egx7jT3%2F607vvvvuPf%2FyjzYjO4dlnn3388cfff%2F%2F99rASmsIAOE31vffea1l%2F%2FvOf33nnHQeUSOAcnn766b5qhw8%2B%2BECDmu2b9bvf%2FQ6LQlDxab4tBx54IBnG0Ucf3XpkHk444YSTTTaZBl955ZX%2F%2Bq%2F%2FGnvsse%2B%2F%2F36uBC0QAScazbq0XxV1%2Fdprr%2F30pz8dd9xxb731VoVpOfggmEs7XYWoAxqqtHbUKjQ8bwPoSXz00Uf9%2Buz0FMvt14MAHmPZZZft8hjIrtdff50Up%2B9rqlUhq%2BuH4qGSTeHjDGK5VkXXPcStWm%2B99VZfjuLDDz%2BkEbLmn3%2F%2B%2BfAYX89LTy9BIAgEgSAQBIJAEAgCQSAIDEsIDMBjmCaRA7XDHXfcUVOWmH322UccccThhx9%2B4oknJn5o1hyjbOmll6brkMWzQxZ5g1qnn376%2BOOPT%2FLB9WOEEUZAmMw777xN8IAhQUGgHdTSrMZxCNUX85A%2Fy%2Fe%2B9z21ZM0000z33ntvZXV%2Fd9hhBzzGWWed1R6iHfbaa6%2FVV1992223HXXUUbXMNWaUUUbhI8PSNN9TTjll8skn16xr6qmnVhcvQctB1KGkyyDXWGMN%2FIONe8PGb1Tj9913HzS23nprY%2FNELI7ZZput0BhrrLH2228%2FFm4bRiXQIHPPPffMM8%2F89ttv92TldrAg0MNjPPHEExb5yp9cHKx%2B8Ytf1JK2JO65554tt9yysjbYYIMrr7yylvRll11medx22201fgzb3nvvvdVWWxUNYkkrue6666q4yiqrHHDAAXi8Kmllat%2B3IMvvjjvuuNJKK%2F385z%2Fvy3UMFmTSaRAIAkEgCASBIBAEgkAQCAJBYKhAYGAeY5NNNkEF3Hzzzeby6KOPfvvb3x5zzDHZfYcccsiMM87I5D%2FuuONk8dpAQSAxtthiC3YZzw5Z5513nqyTTjqpwlbMN998ai200EJuNatfpiK6A8%2Fwwx%2F%2BkLoe7TD66KMjFqgvVLzqqqvc4jFYgttvv71h8Ph49dVXZXWv66%2B%2FXtZEE0105plndnUgyvzyl7%2FcaKONZCEx0BpnnHEG%2FuH88883gOqR%2BTnBBBN885vffOCBBx566KHNNtsMOWMWrEv0Cx5j%2Fvnn55NC1FE9ioYqkMiGG27o1sPvfve7qI%2BddtrJvGaYYQbNHn%2F88U2qUVUMCW%2BDDAmPUYAM9t8uj0FFY8VaGxbPJZdcgosg1bj22msNkpoC8YWOsIxVsZA4KBV3ceGFFyrWImwQGu28886IDjyGty%2F%2BxvLLL4%2BjuOKKK4488sjlllsOwaWMNlWRpaTYnqeeeioqQ254jMG%2BJDKAIBAEgkAQCAJBIAgEgSAQBIYuBAbmMTbddFNkQmkk7E3jHOgWaoI0FUiAWWedlY3GFkNcoCwqi2x%2ByimnnGeeeWxtEz%2FIomEo5xR2PWZglllmYfQx7aWxAU3wwLjDBuA0%2BLPgECaddFJNVZtUE7LwHj3w2iJHI6Ay5GIhCPUZpM0RxgAWW2yx8cYbr%2BJjmCxWZMkll6QeqXZOPvlkFbXgVq7CGim5CHHFAgssgEjp8hiICyatwvbcVUTpVDtOS%2BG9wtStLft6WL%2BcDlAi3SdJD0YEujwGgQ1iAcFV47HYCC0q8CYSjBoHKVFZDz%2F8MHbL8sNUaAH%2FoEBlWf%2B77LILdsuLJr1Yf%2F310W4O0KlcC8yqsDyqGMnHc889V1n0RdpMfIxCI79BIAgEgSAQBIJAEAgCQSAIBIFBRGBQeAzb0MJizDHHHIQWTSSvIsbg7LPPRk0svPDCDHxsALuPlYdz4AaChcAeoCZ4Xpx77rk1HiJ8%2BoTppptOfIA777wT%2F7D55pu3odofP%2BGEE0TqoP3gqYEkUdH2t2ZZl9pZb731nCrSyleCacnFY80115xqqqlwCy4eLkU%2B6G7RRRfFYzjjUmElafvFLqAwQbAcddRR7ErlHX8pF%2FuhMB5DyAu3A%2FMYLFORNAhUnI1iImxYzIxLFzWq%2FA6ZCHR5DKFUiCI23nhj2gmRW5APCDSXl%2FirX%2F0KyWBxOswX8%2BBh5ZqUBdkvj2FdUexgLeh8cBTcUvAkJ554osJYLwIPS1Q4Fx9OIWOJJj7GkLlIMqogEASCQBAIAkEgCASBIBAEhmQEBoXHQCyw0MWUQEH84Q9%2F6JmOJz%2F%2B8Y8%2F4Q%2F%2Bvx%2FKDTa%2BDW4nnuAfWvwKxAIHE3wIHkP4CxUQFD0NunWUqiAV%2F19zn6S0wwWgaS361kKDkItoX3HGI%2B4FF9HlMVRBaGBdFNAaJ5HqZc8995Q16DwGO1eoDRyLOX4ytG%2FgbQ4%2F%2FPA67LXvwPJkyEGgy2N4iXw9SClIL5ZaaqltttkGoVELDN9VYS48R4sJw3LDDTcUBTEAj4H0sPCceuPXxf0EreFWWAyRbBEjNEuN6cp5JUPOqshIgkAQCAJBIAgEgSAQBIJAEBiKEBhEHsNm9Pe%2F%2F31BMLouEnwoGH22oQknRJkgob%2F66qvFtXAJMsBCRHF4%2BGk8hjMjZBWHUIiJX6FBJiTmBBPCtYQtWQ1qmZEokEXbzlZF%2B2QVuqjYjNWIg1TwKoKLklU4iqLLY6jLO4afy6677ioKh5Mj6EkQEZ%2BmxzCAblAOO%2BzlV1JxPnWnBWwMlYiwHtqx295XLlKjyu8QgkCXx6ghWcAC2Ir0wosK86BAe4noO9KdY445RlALvIRFqIoC0hU0xi1qznJChmhHtBYtHHzwwZQYVouLMEOa5xS9B%2FWFptriwfJFjzGErIoMIwgEgSAQBIJAEAgCQSAIBIGhCIHGY%2FR7GkjFx%2BBXYufatvI444zDn6Jmhzqwv7zEEkvwKyGYb2E0KhfDwL6Tpqvvy2PwK8E2kOuLsLHgggs2FsKZrSJwio5IhE%2F%2BgTbpngDSN1QmUgWFwjmlQoNW12gQ0UTxKnbAtYzHwGmUO4zy2hS7o%2BIuKk%2B%2F0cNjOJGkgnKou%2FjiixuGffNqWZwQEg5uCEzR%2Ffff32El%2FF8qi5XqpFdnvLaW63l%2BhzQEisdAshkYDkpAToutBolt4Ge02267Wb1CcYp6UWFV5Dqid7XVVsO5%2BRBoNpAV1113XdXiciIgRsXHsIB9FHiMyqpfZIiEz0QgWYFiuCDVc%2BvfgSaJj9HFKukgEASCQBAIAkEgCASBIBAEgsBnIoDHYLsRPzDfLr%2F8cpadS%2BALO8s0GCwvBAUvD%2B2Q01MykCg42oNrvyAAGADhBVh2JPeO%2BeAtohbTj9BCJE9qfCxEv3oMPAYfEIQDPxFt7r777hoUSrE8PtiJ2iTy174zI9ARDEyxLBAFWJHujEj0PVfMgSCGzTBELDg8AnMimgdrVBe2zhEdrFfshCc8CBxfwtLUrACPFVKj9BigYIQq7DnugtqkDnXVIJrC%2Fvucc86pL9yOMdQ5LJwO9PjWW28ZGKBU74nzCQGMh%2FaL1ekOPunBgkDxGJao3i1Xb1DoV6%2BbtAY1gaxDLHiJQrJgsQSwtfZwbmqJCGqxWXJEQYqVpEdFh9S4dQQPlszrxnVU7FAfwptvvmmdoDjwGzQeDsGx%2FBw0rNZTTz217777Wpw5r2SwLIN0GgSCQBAIAkEgCASBIBAEgsDQiwDj3UYz87zn4ijhkFPWGYqjjmbgo%2BGQSreYB6yF8riC2stGOzDHaBU8rIgTDu%2FgskG3wMrzkAVXEDHn55prLuxB7XQ7GYSqQQHMg2Y17vTVClCA6GBjyvKwuiOlwCf0QK1BPIMySo4wwggakeACI%2FJnlRS2whPXIossQmJhLkQgbqsKqYY0PQnGQ%2Fk6MKUKa5n56dhWt1puVRxIoSRKhK%2BBh3qsKZtU67S69gtDLUCm4o6250kMLgQocIiIMBgG4BUT%2F%2BAWVl55ZZSaBLEN6YUspAQvIVwcyYQPRJbFX0IdkhtBXSxOtJULa4GOsCowISoq4xQbFfmMqKuiSLAlycCHkH84Lkd3qA%2B5imnKNzi40Ei%2FQSAIBIEgEASCQBAIAkEgCASBoQ4BFASNPTlB93JoyHnnnVdxAwSgqMM%2BTI3BxcfE%2FjXq4Pzzz1egO98HH3zQkSU09oIAlDEo94knntBy4x%2FsdFN9aJwav%2BoyGDVld3u%2F%2FfYTFsN4WpvMzMsuu4xag8JfUM0yBltuS9QWufgGurYbrjUa%2FpZri1xd9iMDtgxGR1HQY%2By0005K2hYXiZQCBMWhikCdZCeyjLCUFQqjaLRsY93cyUt4l1SIA1Xs4O%2Bzzz5kG3L7ZSo0IqwHrUiRM21USQwuBFBnSAwqixqA92Ldet0oLG%2BzHccj18oR2oKDiRXiGxGZto0Zi4UQ85xsA9ll3VoVLcorzxESJtydhafx7qu32omXVLQqOENplrqpheNo7ScRBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIPAFEHA0qhNXHT3Z73mRTiN1QOqf%2F%2FznL9ByqgSBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQWAQEXj%2F%2Ffcvu%2ByyZ599duDy77zzzvzzz7%2FUUkv1S1bceOONM8444%2BGHHz5wI8kNAoMdgZdffhkd9%2Fvf%2F%2F6FF14YlMH86U9%2F%2BuMf%2F9gt6QnirvtkiE3%2F5je%2F%2BT%2F%2F5%2F8MCcP761%2F%2Fev3117%2F99tsY0X%2FfeN57771GtP7rX%2F%2Fyr1a7bZ16fSjZdvt5E179u%2B%2B%2B22qZl6vdDkrio48%2B6juqVtGwf%2F3rX%2FdbwKuU1Up%2BsQTCeYCK%2F%2Ff%2F%2Fl%2Bz8ztAmb5ZEIB83%2BeeWIH9Pv%2F3PYSS996W%2Fd%2F%2B9rd%2B%2F5%2FVBvDPf%2F7zscceu%2Fvuu%2B%2F5n0v66aefbq8A5nJuuOGGBx98sN%2Fp1Ct76qmnXnvttb7Q%2BdfmxRdffPLJJy281qnhKXzHHXf4X%2Bfjjz%2Fes4SsMQO45ZZbbr311ueff%2F6%2F%2F%2Fu%2FW8WvIWEwhvTLX%2F7yzjvvfOONN%2FrOqO8YegYMkG4ZS%2B6BBx4A4H333ffBBx90s75MGob%2BGf8yLfwn1LX%2BuwtvmJyy%2F6f4n4t%2Fgnw1Fm379k1Wludf8xc0TIKcSQWBIBAEhkwEjj322G984xsrrbTSP%2F7xjwFG%2BNZbb00xxRQzzDBDv%2F9PvPTSSzWyyy67DNBCsoLAYEcAX7f66qvvvPPOO%2B644yqrrMJSGHhI%2Fuq2qh955JFW7M0339xjjz38tiefKzEoRsGgN3jllVdeffXVDKV%2Bm%2FX323777dfMsUFv9t9RkhWDBfXvDCPx39G%2BNhl9Xk3jSe66665VV10Vudr9Jwtt6%2B3ff%2F%2F9X2wMoD7ggAOOO%2B64qs4EPv744zFjg9ia12SQhx56aBl6%2BDEWd09dY1txxRWPPvroHvZMX6effvree%2B%2FdU%2F5z3T7xxBMGXFUY7y%2B99FJ35Vgq55133hegoy%2B%2F%2FHJvFiw9BqwP56ijjvpcI%2FyShaF0yimnQKktA7eM6AGaRWptu%2B22Fme7FllkkWoBIL4v%2F2Isu%2ByyK6ywwjLLLLPRRhvBrdsa02nfffeVu9hii5155pndLCYkZDbeeOMlllhinXXWQVxUrv%2FVnnPOOSuvvPJyyy23%2FPLLa3y33XZD8VVufbb6klUFYDgwFdPt9Eum%2FYu3zz77VO8Gttpqq1111VVdw7Bv%2B4gvCHQHbPX%2B5S9%2FqZLWwCabbCK3ANxggw2%2Bqn8Bbrvttosvvrh6QTGhm7qLue84PRl4Iv1WGaof%2Bgqs%2F0cffdQsPvzwQx%2BCD%2BQzZzSE%2FC%2FjM8fZCqAZl1xyyU033dTnaaWhMloWCtH%2F6P1%2FYWD%2BtpVPIggEgSAQBIYiBPyFT2WBgphooolsGA0wcn9lTT311D%2F84Q%2B7RkEr76%2B1kUYayR9j7UkSQWAIRMDfuttvv%2F0222yDvmNH%2BEt44EGeffbZiy66aLNc%2FBnMWGPvXHTRRc1QGriFnlx%2FePub%2F6GHHhqYNuyp1e8t82GttdZiIiEr%2BrZmqOxlFsRn%2Fm3fb%2BNf%2BUP7zv7ORCwMogzm8w6A3bT22mujp9of4XpkhW299dbdzbhzzz3XH7rw%2F7ztV3k8GMC33HLLkmRcd911jNzXX399EFvztzQQVKkV5Z%2FNYlqYGK0FhM8aa6yx00479Vgc6lqx66677nPPPdcKf66Ef7q32morq0IthAxkNIjWaIi98sor%2FuavAp%2BrZTw27sU67NqJ3gig9t9%2F%2F8%2FV1Jcs7L1gDCD8zDPPaIrhxhgf%2BHWjkjbbbLP111%2FfxH%2F%2ByXXQQQeZkQ%2FHawLR5ptvjvAkUTjjjDPQNV4N9GqcHq633nrmvtdee1144YU6bZ9bMSpLL720b%2FCEE0646aabWi12FoLCWmXRszFZ%2Fdakf1i8CGvVP01qHXHEERgABpru5F577bWt5S8J0QDVvb5TTz1Vd7gsAyPJYBj6rAb4Zk3T%2BA34yCOPVMWAzQvm9sd1hJOxBqznK664Aodmwfskt9hiiy%2BvyvDWvOgLLrhAL%2F448V3oBTs08OyAbLRe0xf713uAxofMrNtvv90Chrzh%2BX%2BZVYeya3xav2P2r9kOO%2ByAvutXetRvlcH%2BkMLNvzMYm2uuucbgX3311SbU8QlbGxtuuGF7MthHmwEEgSAQBILAV4WA%2F82NM844o48%2B%2BnDDDecvuL7N%2Brv95ptv9ncg0WwPj2GzySaIXIy3PydGHHHE4jFsbyns70l%2FLdh8bH%2Bi2wm1Q8pyJFXt6cj%2FOj13%2BR9QN8vfdZrShb%2F3WjvdAtIff%2Fyx%2F0337F32lMltEGgI%2BHum1pJV1zW7WoFu4rTTTmO3Nh5Dlj%2FFF198cXv6Xeu4W2WANMuO8bjwwgv7y%2BrLr1h%2FvB1zzDFrrrlm25Tsdu3zZIAwGb4G86fb7wBp%2FzJ8YRtcs8xw%2F0SA3T87ff8Ul8uQ8V6aVa4K2YyrOySmqG3ugQ3bbvmeNB6M3cpMqyVx8MEH63TQeQzWE3mPrUMGl3GylxmAWugxGYjf%2Bk7Q4tluu%2B2sxrIQewbm1ou%2B9957WYjq9is4YY2yuBnICuuCgal3ll1bIf4lZ2b2%2Bz%2BCvt31PCFK8S989yGstH%2FIIYd0H%2F6708xqKOkXEaEvCpaFFlqoq6fqOwAjxwfCoSfLPw6%2BL0Y927yyrD2vDETlhkkj4X95OBP%2FG%2B2p61YtaNsI7vk%2Fl%2FfOlNYIDqRq%2BZB9p14HosNylYUKaAIM%2BwvIJS%2FlC%2FyD03dU9QTtaZFYUT0FfKGGgdVpm9dYNcZvv%2F%2FCVF3%2Fy8bF%2Be5aa%2BbFdsaEAND%2FuH1uLOjWEZzBMrBCphUeIOGfAp8h7kgZkhgyDy%2F9kksuGaCKBelfS%2Bth11137REODVBrqM4qur62qE488UQsHL6uvdx%2Bp%2BZfSNS966uSzfTby1f%2B0IdfH4iPyKoogqt68bfooEvmvvKBpcEgEASCQBD4NyHgzwx%2FL%2BEfbMGIbjHXXHP5g6r15f8L%2FuIdddRRqTVc0003HbrjRz%2F6Uekx7Ib4a6SyxhhjjOmnn17a32yq%2Bz%2FI8MMPP%2B200yrvYf09aTvpe9%2F7XpWfcMIJTzrppNoQ8b%2Beww47bLzxxqusb37zm%2F7PW%2F8%2F8jeV3cMRRhihsozQllYbXktoSgFS2PbXeMtKIgh8SQTYQT08hj1KAnKO3l%2BgZX88M3noMbp%2F2H%2BBdrpVGI89zvWVi8dgcX8mj8EMIYbvCnG7jX%2FlaexNXwt9UHph%2FfnSvQv7bgw9Wv2mw6%2Fq%2Fl1ipPTwGH1bNtkvw2OwSXUN1fpn0A7%2B5%2BIxjIfDgnEWccH0Q0qwyPqOs%2B8TIQj23HNP3BQKom%2BuJ8gZlt3%2F0959x92SVHXbJ0uUnKMEAckZJWfhQbIEJaOIBJEgGQFJkhEUJahEFZAcBCRIRoICKqAgDwiiBMkgKvq%2BX2a91qfffZ9z5swwMwxw7T%2Fuu3d3ddWqq6q7a%2F16VW3vW2llfM%2B9zjWZgoM5Oob0ug2JeDux5bvRMfaaxG1kz4F1DM8gTtYRe%2BvWBzj%2Box78%2Fu%2F%2FPtnwwDoGSYonTlnSHFy8JRe4Wj3LPJ4WInYKX5F4gj3e9a53eQh6C%2Bxa06DTJYaDp6eKk0coHvY7urLVk51Oi6PAT2KShVajHnjkEfmdaL6YZHPUHo2up60c9qI%2BrHvcxHj9RFr8t%2Bdy93ROBqxwBV1CfU1sWfZs09sWw0PN8%2Fp%2BZUXWU3HCi0ygo60tgNK7o5pgYudOPtuvytIr9lfipLS0iE4%2BOoZ24b26lA78zh1w7%2BsJiaq%2FLe4wbWuObXUO07lHbGI3wB2Rdm%2F%2BuuuKx0CJ4HOo6oT1WJxlQPXdKO26vYtirz1HwR6XMLlG3N13UxbJd%2Bly300%2BnRuBCEQgAkceAU9k6sTlLnc5Y4D73ve%2BxAeR2Ku4kSPOfvazixel5JMRyAUXvehFDboM8MTp%2BXrFK17RQNHbjZOf%2FOS%2BLh3DNv2B6GEA5s2g8Z4EZzjDGWgUxk4W2SBxeNGjLPoGIUW2PDvBt5bgON7xjmd07Zn70Ic%2BVD4Gil6yeKqe5CQnOcc5zrE3wJVOwranPOUpy%2FI2InCoBLgSOpUOuXe0xsXQYyeHvToGMc28EmFCqwive4wnD%2F4Fn%2BH9%2FlwSh8R7cGdW5vvb4CiJbSCncD2WQ7RNfDA6hnf3XHLiANd%2Bn2%2FwZWhYaPS7zyJWcUbUjNk7t2UlmA0XNaePy8Yl3zl0qF%2Bdy09nrRk9PHG3mvFZcJhzD6BjaE0R75NsfzoGp0no%2B44fpFAzR7bv02279XGFpgoH1jEIDpyCnY5Bi6CELJ6KQE%2F4%2FV4CvO9t3IgS1dq5O8Eb60T90OwG%2FqNbt7kME0yuXj6TZkfHWCeujf3pGEoE0HWhnzN4pd%2B7wbtU5dl%2FMDqGd%2FeEoAlv2OYmmGHvrX6b4ADbJmJQuobwwegYogQ9ZbynxtZfcelqMdAOgfftVWWEzTER7%2BGJyQCcueSK0y3NcTAFw%2BoiIypKKTePPwEb5ERHbcxr8WW568WVTof0gJOPQAVdRVlT6EpGInB5%2Brv2HGBj6Q8HSOOQcAhxFzr8qtqkZyEJyIN4iRJanPHWhNnfLUvKHYOJMAymGCwblOLKcifRhWBR9D47%2FErv7irKZV2za7%2FRwjpxq2OsBAezAfs07t7EngWYrFvK3gQguMToNntzcB9%2B7nOfuxNvJr3LwV3a30P1i13vbm56zt5y9%2B7RVchNbiY77YKz9UzWPWerY%2BzN5AB79qmNT3oa1Hve854DnEvnIWTtXXvKHcxOPXknc48hddc33DEOkK1D2s7zl2C1k8OcJX%2B3eoFA5NNnP%2FvZO1m5ubnSd3a6n%2Buoq7fPUR3VfUA33kkMNcHfA3rFSu0k6GsEIhCBCByVBAQQrukkVHphFYboMxDymKBRmHKy7vxGOKc%2B9akvdKELOeSh7NBlLnMZN%2Fwx2KtSwgUxxFejO%2FqD4co8TGUoiv7EJz7xeoXtgXWCE5xAIKjBgCnVEq%2FX0x7BpzjFKbwCcy73yvYa%2B3mLTcowktlB5BnEhdk7rthJ1tcILALGijM7gCJBo9sO5o2prGDgjfaMZg%2BsY3jzqK%2FKyqjb8Gm8m1XKoW6IhdgZ93qH7j2p0eyBz9XhjaVdICJDuAw8r70Dy4PRMQz2EODEebPJO9s7gOdgupDNgyAq7gyYl4XCCbzDlY%2Bwrp0B4UozG0by3ECDTHeA7SHluqsw2GdvKYLElrCDsCKkmZGkrzysmeKxPx2DD86ph2iUh%2F3pGLTTvXHptB3TCrbNMTqGN%2F5MVYUD6Bg0Yb1CZd0Pt5XlenB4xxgc3DM58roQB2pLj3vrbqxR1ojdKXQM5251FTnbv3yfwWjn3Jl1bLfW9WpynzoG341ONRbuU8fgVPJkzaXyoQ%2BQ%2FlZzbOtle17Eu1HP%2FoPRMegMFtWkQm%2BvQa%2F4KQC63MqfkWtyx%2Bz0WFnzMnR%2BzbcSj44xbuPB6BjcIq2ssbiogLug%2BPJ7I5RwJulLOW%2FzPXFMD5HYRyPS510putDQ9oh0UTikImQBV6ttNxbvDpadnq2a2MPRWZSxfcpT5FYJrOezHrXr9J0NgoOoCT3ELctNbI66w5gaszeoHm06z96HJulgIkxWc9DitL4gokNVKadEfUl6GLexncQHFeQbggDLoU4qcZGa14D29oqYpU4MWqagfeoYKCG%2FQ2a%2Brhqto4YW28gKCWbxnDXmWSnXhh4uOkX3QHvttKHWrmL3SX1gVRxhfdhOF44brNjXJY9vz51tDj4ty12U3rW3Xfam18S0AqdsnyBw6fC60wpA2qeO4cLZnye%2BQ8mdeYeG%2B5KRm7rMDXCvYfbw9NXaZbitiG0Xggo6NDG6c66LV5c45O5yTRsex%2Ft7jBoTqo4cfHZuqrJy%2F%2FcswIQe6IYvvGryn7%2FuBsDuTOd0D2GSDr%2FTZ7SFItYjY3JAxpVlv8fu6oTbItqOQAQiEIGjkoBhiWeHCR0TIuvrFa5whTOd6UzGkMwQ%2B33GM57xkpe85HqJ4KWh2IwLXvCCnl8UdeLDGq9KT7te63yOjmEQMtVx4oUvfGEFkSwMtHwMF80fIYN49BAunHi2s51NuLjxoREyJ8XHE1kypZBTnGLAb1TgqToyy1EJqrJ%2B8Ajwm4ymDKqNA426t44h%2F8V%2BsbjzKnyfOoaBotGX0Y5Rk1EN14a7KkNq3v5Y6dheY22H5dwi3gG%2FYzt0pIoYKRkB7owS9f%2FtO2u2GbNRIYQ38A6cwsH0NnC9hmOGHHjH3hRv89%2BaJwHHissg5MAAUizuNshESgNyA0I0Ruiw1s329Nnm%2BRrYOxdJf5fquDelPSwxgkXMYHUlYDPXGHCOAAcBw%2B3o133JnWTbQOtEydwZKDnzetTIXC32ziuhRWidZdv%2BdAyKhBbkkC4xR2PBa6fZbRpryj1IHQNJWDBBj4O89TXoGKi6K6op70bbMU8Xsr28D2Xx2pzrs85lwz51DPfe%2FUXpe3cpc72U9yHPfeoYmhX8KXqvjuF%2BC4IGclfnlOkJOj8gS%2FpYzWHDc0FFeO7cPV8PRsfwuNFq%2BuHCPvnAovfODBoV93TYyhqs4q%2B5BCTmUcrBhbP6%2BWHVMXjZLBf%2BoUcxw%2FtZ2DXT9hpUIgJqR66czuAhZa0MrcZjGh9fXahMGlcncSHoAK6%2Bab51uot02SlgRtf1mNNA7kJeJaxD6uWjkwhd0DqH6vjzarmWzJOVHktGUC47UdIBdq7ryXyff79LHUOLq7KreMdhdyfR2w0YyJjY0nyG2D5tsHN6haxWsBwybhp6BcIzJtmnjkGI0P326jY65E6PpRoxRt9eNmhQ3NDTl7a3oJXAhv3EK2m2k7ZwdiLyKo72mrhhQxcVokPgJVgxXgITYbZ36clcDyQO8LJdX%2FsMT9raMNtzd3Iv3ca96LGi3ZgnYm360l4dQ1t4ZEzwzzZbVVOpNd5zSA5eG7m4tqKNt1HqqBG3z6NtPrYVYZKULj23nTnqRPdDdXTJMHLk2ZEmGOniMj6UrduLozvCwuRAMHcbl4N%2B7hqfgKtVtKeYS28UPJGWev46ZEOXcB%2BW%2F%2FYpZvw5Kr022l56eh0bNIR%2BuDJxaSjdTUktnLXk5ZWgjQhEIAIROCoJcMROdapTCXggUxtNGZKd%2F%2FznF54xvhhxw9ErX%2FnKHu5jlSGldT5Hx%2FCOb1bVWAa%2F4AUv2NExDAbmqPGh%2BSAUCQEbPmavOFdkBdnEeE%2F%2BJPGznOUsEvic85znNE6YQj0NPU0spmE%2Fwy5%2B8YsTOraPm1V6GxE4MIH1nloyvc5g2HhyhAijsm30vgQuBxIfh9f26BhbR8Bgz4jUkMkAz9Cav%2BCogZARDldi645tTTLANvDe7uHpGNYadW9fjbnKZEuU8IOYKzHjXQgGumtYyC1aR71SpIeM1ODaWW91D1XHMEgzVBNs4CXm%2FEjBzip5amo0SNI0cQwlw%2F6tqWOAehnrGtM6anjJKdt7hWIyrp9T6DlA8cfXjUVd3E8EwPgdGX%2BJM1u1h4CjsfZJVTJeoVGlATPDjMCh89p3kZkN9rgXkTsEPNgzOoa7304yPqO2AGTFNkjgFsTFNnxdAc%2FCttlz4HgMHoEamTfHUZ139FsfnDen3WXiQ9UxwOalemlueL99Tal0ZutpY7avsoXXWTrwMl5zc%2Fe2%2FXMdsmHsrVL6hqb0da%2BOgaFOqxQ10kaM0ffWrXuyUu7KU1AEA5gKNbdixxvVTJ4mfCuHnLI%2FHUOGGmv1E50WEKhXKfqGLi2f8d8xl8AlthK4EFSKSmOPkAOevmt2HVUXtRiPTAc%2B1PUx1omzoS9xS1Vz9QSmEouUqO1WXAQjeak4rPVeJBtFTnNoela5P6xqisTg6poLsHr%2BFOer9%2BOubu24bVkFUdUUKlxkZbJj6voKBe%2FVUxgZa3IqWk9WC44b9%2FDgfa65KXkir2uQ2a4vNd0xexW9NnRFNwoGi0Tap8Hy5GZi6Jrdq7w5un0Rz9F2z1lNoBRw3HX1BL3a1706hhYft5QmueyXUn9zfW19UjsF8CCmq9heHw8Ctmn6pZ%2FMIffSdQfT3Oq4FUBg16w6sA15rpf127u0dsFQ5iqlatuGVgRdXfd2b%2BTR6%2Faz6MeyarvhilutSf3Qymu5m0lGtnV5CuCZKuzVMbQOrYAlO5qzryzf2qzFdSH2bEMytKyKuGPsPCyUrsSFnbriceAqGKuY7Z7vcsZhuqX77Rza3l50YxcCRO5aCt3e9jWu3kUf84j0mGD%2Fuu7kQw%2Fx%2BHCW%2BirFtjvw5L%2F%2BelSpiwRrjw2yhuKkV9lt9zM2MAZYAwNYCJvuoq5KcRqeCKtq29zajkAEIhCBo4yAgR99gKrwHfngkA%2BtwH93bw8d7y9Of%2FrTC5lYD03PnRWPIW7QiSZiL2sNtvenYxhnWvOTQOH1hAhwHoSPhyYHUEHz3PQE8eAwdrUWKDOI8x6XHnD%2BGsl4rHsukz5MbOHXrELbiMDBEDDI4fDyhiaxwYmhi9c39vNqbet4RlAGxgYq4jGMKg14DIaln1BS7gnXzEtbPrtxuBGaAfD2nZEhHHfM%2BGoNd7eGuXaMgvT27U6DNCMiHocilDsfwyqZKH3bz71JNHI2OjUUJOWx0BjP3%2FnwCFhl%2BDdpvGsjTRiVsYRiQF5wEW3LXdvGe5wCng5fzPhfpbwnXUdtsJAXQ1FxdXu5LCUmW39TGk6fewUHWS2MLWWI3k5N%2BR1rJMwYbjJxYDXHtsS92wQW5e7MKVjJ3CK0nXJZZaBrHIuDlvVZDapoY1f%2BhTfpTpw31BoLasn%2BF%2FzrtS%2BxSGfge05PcMi5dsqf6yE94PzZUUvmZT0gXIntiHps0x%2FshG6CLrQyT0ozuXPyxdRIj5pXh9Ib%2FHP5MdEf3AZXP3SKlHaqi6L91ZpO3zpB2OotGk5nXli2GxwKCXhYdo6OMRxWGlYZlrNQL3WvVh0ONS%2FPfn%2BVuzqbDS9VWQ6IzuYvXUWNkBmbJeBVeYJwGeQvQzy3CsMUqrF00eW%2FuB5VEyhNsPIRacNXov%2BwgYvHPDnrBtw0H17kMHQN%2BmDi%2FS8HbU5HUi2WjuH3KXbCA1bdbeirGPJKFkB7CAiuoyVeMQwiEhkvbJ3Lfr2IYRPQOPs1hJR0CU3vWtDVV7YeZK5H7wtcMhIIgFn3CheFjkR7WVnpP1IirL8tUKvovRtuFy7AZZ4auVd4Aa3fbqWAvSfu7CFk6d571%2FlU0wObwWCNpVEEWG5Tugq02urqigNTr9jrqrugtvcfdyfVdzFKPxepruhacJsauU9buyW6sa8qGEvoKhLoAEvOddR4Y0eas9NFRJ%2Fxcp8%2FPt3GXzcENxDl6rorWxvuusvrd2sVDOCGSV%2FSOalGTnEhOJ2FOp7SXXRz4axrZ%2B7SitM9mC08Q3vNbYrHravo8C4EFx3FFe1t6dttMJFZewQeyJMlikOJDYp2G3S7npvS6Bj62zqFDiCeyj1hO13OLchtYZts0rvzu5wJF%2Bu%2BpEZSakH9c%2BVpQ6Or0dwVfaUa6c9z%2FaqmU1hlXEfi07v0Eze3uaMuRDbcXkTfOer24qN2bi9zLzLalKEcaOZuKezfcnDjclTTY%2BtEPLURmFsyuoqj6r5uFHCpIHVxbhceDSvGQwdgsDAwd12ZaB3J6DwQuaYUNHfULYG2IxCBCETgKCPAibjUpS4lvsIzwjiQpOBjLOeZZQVOaoPn0aUvfWmxEOvxbYRD2Zh4DAkscEHVXyMxz2KxFoZ%2FquBZQw9ZIwfBe0aSAj%2B2CrbcjHyMGD1wL3CBC3i2Tt3ZI1rDSMBz0H5D2TUoGuHFs%2FIoo1RBPxgE9O1ttzH%2B4dXqeN67GQTyRAxcDX5sjENnw4CHXqH6TjRokcAh4ysbDtk2BuZUGs36yIeH4ujq88NN1zUuco0YL62XdFukRpJym8zl6aNoH1eWcelKyRVihvx9xs5Jtv6qi8%2BcboTJp3NxUSHUzpum%2FekYhmrkQZeYUybz7Ss2xnMiDA6N8XyMDNmpmlu3iMhJRpg03B%2F2yEck%2FPY9mlq4qHmaBthCFHjB0IG2dW1WTfdusAEiRZBHOGvuPD7cUkh5TKjKSsW9muS3ypmREM1f9iykDJsGNVhdzLfktynnxNnjr8%2BknP1O92Zwq2NsmaiCOx6Zixc56CYHkGXi3GlBjoy73Hd6zyH9x5hZ5g5JvMyebTt9ZqdaOGurY%2FDR5OmohtBnhg83lr9j%2BM2t42Fp4nEb6Riy8rZ9h7Pe5YqgR%2FF9tOPQW1WW%2BfajLZaRivZZXyeZmhIlFKGDuXa4ADvFaXo90yNDxzApQxdl1SpucpusxhJF2PDRCedjDxQKQhg6O7%2FD6H8pyUEtRscgVHIMt4%2BeMcajRwJXlssTIs2xVAutqVH4O96PS0xw4MLwoLdrKUwmurQKLldal6Ae6IRIugBdXPJZMfl6LwuhlkxjSbYiBFwvdE4V4Y7JmUncT4nlvL%2BLVzK%2BpwvQx8beZNxt%2FdChMfUg%2F6oyHdUNZOkAehHO2zvDZCV%2FAGH0VY1c1wzeusaTjA6jsVyqywCeowy3e%2BaQMYm7BClDr6CDeWuvHbWyv%2BujVwA%2BKpk7gG35r5xtuGtxePWKrRBBauCNbpNNSnKBHPQr2Spiupy%2FKrKaZs7Si6hYbl9aXP56lzRO9Ne9WiZqZFu5c%2FrkuTKcDbdoHXUKkmCKdpZLDyJfHZWDq3XNyZ3S6TOeIyD7qi6uF2YQBySbO95OFRRhj4tdeh3J19me3Pw15GMMiXjtcUsRt7OGc2u%2FYFomTSuoxaJkjx67ktnQGdz0XOyaTzcQl6U6kqmgv1NHlNw95gbCwpXbFtQcnbJg8Vk5yAcl91VpYN%2BG9OiHAvlccdJroHmwSr9DRrZy89nWhRnq6AmiRvQW9oPsngDRjv1KVzQDlCKNy1a7LKFyS6PtCEQgAhE4UgkYnAif4H%2FtjHOMLUkQ7uFKNxa1Te7w0KRgGw366odFPLA87zwgCBdcOcMJrsFZz3pWR43inLijY9hjOC0xDYS6buDtzRqFxOPYg4D04URPFlI8B9N40leDIqVc5SpXEfVBk3eK1wF0lfkpkx0yXkOY%2FyLnnf19jcAQ4A5sh7teB%2FNDjUm80zFo1JN9jEwMWubjq%2F0jSuh4xjn2OGQY9p2kh3x8dcr6SOMF7o67xF%2FjsPDEuduuoL3N4eWU8Zhs%2F7%2BC%2F%2FefnVzRbXpeg6uSzUr831T%2Fv%2F%2F%2Fa9d34pYpCSMSMmlN1t7mtrYNX40GOblG6a7BbWLenIGo8Z5hm49tpbsw17k26AmKm%2BG3AZ7EuLm3bNPY5kRYtc%2Fp6uVjTEsPWe%2BOdxLvfHVPmCr761zF%2BRySzXf%2BKM6H5dwuiNCexNJsQYEj8TjU7lfSDzvJthCH4T53bnNTEf1nxvxeOLpP7txFjYRlu8UiZ3vkvJrJV3mujzGzsfT%2BipZs7JSAjrF8TKxsa24mSeCvjzQ7fKjT8wqboKdL0JB3IHvJyAAet7suH3aK2zFmDJi%2FqxY2lm3rkBbxCFAEdZrHt7c4Te9Epi47bezNRxo2aCwPBR4unoqQzLVGH%2FDg8JfZ9vjMobHBX8qDurBB5AaXaq%2BDxpvmKc8LaDcHBvDjqEBin7wv9tU1q1l5fEpnA99ZYpqGj26PJJfZNeIJqOfP8hp8cJY4nVdFWJAt%2BzlHbgu6MWGEtZ5lrPK0UoQrWp4iN4g5bGYAX5VbpCz5yNkbaumnUH93pD%2FXuBflHqYCPHYa9FC%2FqqaLxbWwI4CoMqVF07hY1N3NAXkCy44jLH%2BGAci1Z7BhA4OF8VDPtgYLwucIOx1DFwWVRnCF6IV9dkK3ZZfAtlfY9lltamO6xNzMvc1X4g4ThnkLr2MsrU%2BNvPp3p93LxD3ZI2BvP3cf49Vu02PiImKMVpuPs1RZV9QEtAK6nP7JPE3Mzr15Ti0kWB%2BnS6YsCq1njR4rN8lsaJdt6S5MEoFRlrpwn3csYZWsJv%2F566s79gho2pFvPlGv2zz1rrlIZ6cghxn1bdPY5qcTB1R5m78qKHRHtHe5SYnnltLY5m6vS6uC9nUpqbLP%2FhApaCGyMVWTg4c4LVG95ii1be8TxBWhIuQ7QqJTlL5TinPlv4PLHs0nJPie97ynZ9Oy34aPxFpEDze4XfqVmzDJ102VSa7fHWh9jUAEIhCBI5WAgcfIBQSKnYLcnC3v6UdJPMc9DowHqArzEVBBRqBF2O8sYzPJ%2FvfgMcz4sG3E6BBh3PaE5U%2F%2BxnVGTcc%2F%2FvFX%2Botd7GIz99BLLk%2BKtd8Gl2eeUF5gWbJjHTLfxNjV66cdmz1PpfFA2RmS7STr6w8tAQM5fXJbfYMu7raBnPGJEREv3vtBe3jcBrdedbk0ZnxCajM4t8dHKKk0s739a%2BRs5Ll3RO2doPdl85ZHMOrWANv8ICca11H55KZomSvdX%2BLJvH1bpxjBikciPI6d29J3tuXJ%2FeHUG4Ia%2BiplZXKADd6Qcfj2JaaX%2BxwQVrnS%2BSbyVNA2gdy4Y4aL4vxFbRFeQODX7J0wYlRpEGvsakBomobmUEdgD2DPOuRm5V2w%2B5VBPlbrIzcOET3BQJ0Xqb5OYR4D2MlL3QEljTky0ihXXVDyYb%2FEQ96J0%2FRGp3ay0P7JbTLUJWz42OBUutvwDnShraowZmtrHhbXFRY%2BL6HVWQyY4g7JY%2FePZFxaHWyVslJs68JmvRGTxceGBhq8C44NuDQfT5NbzYmYWAJOJTO259qGTtcykp8OLM0UPcbgsCwBZBCtPTaYt2MzI8cN5ElphZ3ifNW3vQrn1FAAeASCt12G8nGi%2FDEf%2BP5qCyH9fFIPHT61gviwfOFZpILr4SGiCDuda2MMs62Jx3dTC%2F74Xht4pnyceaVLB2DPcnk8jzinU4T7hv3Dcxwof3k3HmdzhXpIeUraM%2Fs1xHQzJboA9QReko88ebic0Gk71fEyfQ45URH3uMc9JgZAuXxM6ac1V6ESuy1sK%2BJCU7QHH%2Fdtu%2F9gtrHyYoIs6caykx5ttVOjMdttQdPsvY3oh9xbL74ZPNrXXoP1fJnrNtpajaambonadO%2FDWnvBxVWcXkHVcftCTJvOFTp9w9%2BJk3GD2uquUwvMAbnb3e5mY%2Fa43t2BVWGnmqQtAQmM0UMUoddNKTqPnHcuMdemRnFNud1pKXYyTJ662chlgnkAkYMe5RJzaHvhTLfc%2FnVUp3VDcOErCw0iD1z27%2FWLKYGKFmagFXRmcoG7H8tVU79liazG%2FmHl79wlZKuBdjQZHJityxGCFhPXkb6kW649s6FoVabWuvTYpoJzB0DJXX2bWEdS1ug5sqKKuMTskZLoJyXg1CRyFrxq6tLeuW9s%2BaxtJG0PE1cc%2FY0NLoS9d92tMbalhNdn2xC2tc7gUpGpEQvdq53CpLHfc9ZQkyzJfulntpeORCRhv947A1SBba6RfUpkO8b0NQIRiEAEjkACHiiGgp4Oex9bxhLGBp4vM6alP7hv8xQM54xvbXv22TnGuJm7zzvqyTIj5JmEQgzx9JmH1zLbI9VgwKPN%2BE3RMxSZo2OPMZWPJ9QagTjqHY3nCCdrosr3jqakYaon0ZqZskpsIwJDQC81POZx6PAGjYZDXADdxkhMzzEgFAs0KXXpg%2FSvD4atEY4xubeBBuQG%2Bfxxg1VXhyGWEAjjbQN746XJagZmDHOlcKzmbdocMlQW2GzkaVR2MOVOGgPLvXXZ%2B%2BrWHkKKd%2BgG51unhi7B%2BBmw7a9QF7Ih386Yf5%2BJMQecyzPD%2Fn2mOcBOnpp333gabxNV3G0Mzt1D3DoOcNZBHnKTkRKr7Z3HHkUcuGpaai9hJxoAczHWffIgzfhukuFAqTDM5hZpOANvkg7aarRtU7d3e%2FgmLJ%2Fi2O%2BerPV57geurPQ6w2EC7nY9YsLeqjmkw3OZtew2zVyh0h%2Bqn7I3z33u0QqrsiuBEmdhkxVmoO6gjY%2FjObXazi3CzeGQNTneZmM%2BfFVdceFSCwoed5g3tHN9SaNRZMtjIlNsLXGIT%2Br%2B46LWq9eJCOvhClLo%2Fxb4%2F%2F3fcUgJJu5d3NsR8VbtDmYDdoXuZLhO1EkcHQffLWtr9krjKGnCQEIXOlSDdU4p%2BYn%2BupBXJjsb%2BqpOO71i4ZVmOoO%2BsdrFTttqwbyVCYaa1St1IVLLZm1NynP%2FN3Rx75Weta5Q4THuqFLO6bArd2W1d8ONS%2F93r9j7JmVv4iN2jyeXx4egncmWAWPJ9n4FnWeHBNsLBwSto8rb4ZaWHbFoi05%2FJpUApU%2FqV64Lgz0CI%2BXZU2ANrpS7zzveGKY4BbnPExgPkGwSf2%2F%2FMm9Gv6zdWjIDA3%2B3PW2bYG1LQO4jsO9o%2BytBGxGIQAQiEIEIROC7J8DtNUgjGvAujT1EDQnFN0LzztGg18SlebVqVO8Vv%2FAM7vl3X6hxuBklXpwJVufmcBUN%2B73cFBlr%2FOyr0r1Zm8EkN4c9M7rmUXrdzwzyHT2QguENoFefArzH4%2F5ubOMRcXwMZfkL6uu1LDmFJV4WG8FuczYWZdK8sN7u327z0bzAgne78%2Ftrm%2FdqVC8y3EtbvWK9DOXsaykdRtsd1hpREuS5VaIOaw5HXnrdkq%2Bnz%2BtaQrUFS7sueEnzRvIA5Rqumw7A%2B%2BN6HyDZd3NIn%2FTWm23eMut78zb%2Fu8lwf%2BdqZRECFio8TLLM%2FnL7nuznM2o%2Bbbc3JuEosIe7KrBKY%2B24gUdG0bqBq0nshFuicJeJLJqCOPIuW%2F3ZfUzQkVuZW6vLll6xLAHKIfMd3H655G50c9v3Jl3MwNxRpaHi6hJeymx1gJXJ93CDx01eYJub7f7MIGKwXxQEwdwN2VyqrYwg%2FgE6Mgg9beKgPPUExW3fChGXXN3I6FHK8tcTSjKXobdaU640Qry0O4ljf5Z8X%2BwHRwSaICLPVhVEZoviIKtA7fRw935tK8Me5Lkli0AEIhCBCEQgAgdPQPy%2FsRk1wPDMx4DNx8DPAHgN%2BbifHDrh1sa9B5%2Fz%2FlIaGgkukr9BpmE%2F8cQY21jaX2qGIbdI4OVGCdVgnlkSXANjRVHKtJSZu81OZtNhSBD7K%2Bvg93uryH2bIf2MV20rYu8ypBxemo%2BA%2FwNk7pWW0eDh8PQPkOdRfEgAA9cG9mkgY%2F4xwAZPR2fQTIfVJFh4TKZ7HNYTj5r0ehdnx1WgK37nMrjOdThBhzqSn6vD3PydKVpHoM1cVNNM9H8vgllF1jsCM99m5bWyKJTt%2B%2Bjt0e%2BLba63G4JIs3X7OirNJiC4ox6q9nWEmETjdSVau4CG5ja4487TId1O3SGnM7ubmWexUy5ZwwWuR83t132P%2BmHmxerz3q3bY0Kruxn9duf07%2B1X92HKobuQ58j%2BLBGQI8xV7Vw4NB%2BzyVZ4j1M42nRaV64EQOGgmmtV25Und94zaBABTjanh2yDDQQIQX31q1%2F9MIUFrvyPPhsCWqz5rC95ZeBGrekPR4wNmdo9xJ3k6FOvLIlABCIQgQhE4AeSgEGg2RzeYJq7ZKIHX4ybuRPeYDRoxOvQzv4jBIiBpXhmsc3%2BGgJtQ4Inf%2FvtFCMxse78FNO1uHVG3f4egVqBQbtZLbzFQxSd7yxzisk%2BvSHzTXZ%2BXG8vCm9Ij7wX9HuLOzL2iCcxXKfweMUG%2BxSBkv38RLHrh7VQjoNAjp3VDA5rJkdqei68CU3eRZqYILReZQ%2B1uLk6ADkyro5VOr%2BAo8rBFMV0OJyLlU8bP0gE3Lr1OuE6pj%2FsrZfL1uXGfxdRoH9y%2FPemcS7VxawWObjY997uSM3WPVgy5t4cjuZ7XJ6i%2BNx5xBptQ1bGbBc4RRofTxOLQe1TwXOuO57oFwqPOVCI7VRZEU4kEx3dQlZ27DyYrx61HrjuM%2B42R0FM0cGYVJoIRCACEYhABCLwg0TARF3TPWbi8xFbL%2B%2FjvOPzZo3DuN5L7hRhYO9d1c7Ona9Eoe%2F313M7NTpCvopwsJrcEZLVkZQJr8TcmaPn5Jcjqcpl%2B4NKwMRAPvj%2Blvv4Qa31Ya0XPp4mByNaHtacSx%2BBCEQgAhGIQAQiEIGjDwFvME1LP7A9ZsR8v0%2BXPnAFD99R74WPwBCaw2dDZ0UgAhGIQAQiEIEIRCACEYhABH6oCAjA3juNeoeAH3Q48Dr%2FO%2Bl%2FSL4KzC5c%2BYekratmBCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQicAACFp%2F36%2FZWk1u%2FArBN%2FPGPf%2Fwtb3nLYVqby6p0b3rTmz7xiU9s8zmabO%2BzjlvbDpzAsnvWXbfkuCXLtme1HYEIRCACEYhABCIQgQhEIAIRiIDf2HrmM5%2Fpd8bvete7%2Fsrmc%2Fe7393vUR5RfF7ykpcc%2B9jHvt%2F97rfPDB%2F3uMc56qcwHbWMNmP8LNo%2BU66dfiLQKX4Mfe05%2BmyQIMC0LiK2W6v84qef8sTYz5lt9%2B9sf%2FnLX77CFa5wgQtc4Oip0uxY29cIRCACEYhABCIQgQhEIAIRiMBRScBvrN%2FkJjc5xjGOccxjHvP4xz%2F%2Bj%2Fzv56QnPenLXvayA1jiR7ue9rSn%2FeZv%2FubBLO5Hx1DEfe97331m%2BNjHPtbR5zznOY7%2B4R%2F%2BoW06wIGDFn77t39bMr9puM8Mj4KduPklgoc85CF7f6%2Fwgx%2F8IHpnOctZdg4JSvnxH%2F%2FxE57whISOA1hIx7jsZS%2F7Ez%2FxE%2BkYB6DUoQhEIAIRiEAEIhCBCEQgAhH44STAH7%2F5zW9%2BnOMcR4TD%2B9%2F%2F%2Fr8%2B5CMSw%2F8vfOELB2DixMtc5jKnO93pdrz1fZ4yOsb973%2F%2FfR7d6hgK9QOOh5rn6BhPfvKT95nhUbPz53%2F%2B52kpe6NWkLnFLW5xrGMdi9CxteTFL37xcY973Gtf%2B9rf%2Bta3tvt3tukYl7vc5c53vvOlY%2ByQ6WsEIhCBCEQgAhGIQAQiEIEIRGB0jOMd73j7m%2BwgLuId73iHuAsqhPgHv64IGuf98Y9%2FvJCDk53sZA984ANf97rXffvb37afD06FeNCDHvTrv%2F7r5ol89atfHcJ0DPEed7nLXV796lc%2F%2BMEPFsbw2te%2B1jyLObrVMT760Y%2Ba50JRmUNc%2Fle%2B8pW%2F8Ru%2FoZQ%2F%2BIM%2F%2BLd%2F%2B7fZPzrGwx72MDNQZCjBa17zmrFBoMgfH%2FJ597vf%2FZjHPIbZv%2Fu7v%2Fv5z3%2FeLI8%2F%2FdM%2FZZvEzFDxycpfp7z85S93iGGveMUrZj6Iv4qmRfjlxGc84xkPeMADzH9hnvQf%2BtCHnvjEJ17iEpcwt%2BWe97yncy1qsXKz8cIXvtCh2972tmtqCdvueMc70j2e%2FvSnT0qFUjYYjxVoX%2FnKV2b%2F0jH82KVspRGp8rWvfW2O4v%2BUpzzl7W9%2Fu6%2BMeepTn%2FrWt76VnVMvDWG%2Fn8jUUgx%2BwhOe8LGPfWxOnL%2BWImE5Jg5NXbZHbWuU17%2F%2B9b%2F%2F%2B7%2B%2F2m4nQV8jEIEIRCACEYhABCIQgQhEIALfQwJLx7DS5l4zeLUUgxOd6EQc8Pl77nOfmxM9yoOd87npTW%2FKYed3X%2Bta17LnBCc4AWHExs%2F93M9NUAcdQyjCSU5yEt69ow6JAJHJSBlbHYP64ehDH%2FpQxnzzm9%2F8xV%2F8RQKIxOa82H%2B1q11thBTRIwIeTnGKU8h2DtkggDhLiec617kkPvGJT%2BxEyWxf%2BcpX%2Ftmf%2FVlZTekSc%2Bdn6or0BAdpTKmZxDQT1aEzXOMa17D%2FtKc9LbMdtX2xi12MAYQF2%2Btz1atedcfrt2bpRS5ykTOd6UxjLasICCaVnPOc5xxhQaG3vvWt5cCSYfUzP%2FMzn%2FrUp6RcOoavX%2FziF6klpz71qT%2F96U875PPSl77UWZYQsf385z%2Ff9o%2F%2B6I8iMBDU7na3u51SVGQMZgbV5ZBT%2Fx8ro2o%2Bp0xTokQDmUPrL6VIHaV529vetna2EYEIRCACEYhABCIQgQhEIAIROJoQmEkQ%2FPQJafizQz6vetWrrLfJwo985CNnOMMZrNXwxje%2BkUtOXuDhEis%2B%2FOEPC4Q4%2F%2FnPT0mgPPCUxRtY%2FsJR61h60W%2FPda5zHV8dlQ8dg6RgEoowAL9aIvzg7Gc%2F%2B8lPfvL3vve9jm51DIekFP5hv6gJ2yZiWHFChre5zW1keK973cshMQlECQEhAg8YaVFN7vyVrnSlr3%2F961%2F60pcudKELSXmHO9xBXMe73vWuq1%2F96r4qzoIegiuYdKpTnYpHP%2BKAGAxHuf%2Bq%2FJ73vOfyl788YUFgAynjete7nkNXucpVhCj87d%2F%2BLVnGV9ZaEsTPr9BGcBPswbwVd8G2%2BYh5kNhyH%2FNVob6KSAGKejNHSSsf%2BMAH5EyucZTBmkNgxswroWOoC3vOetazrh9zES4i5UB40YtexABqicxBEJshPMZR8osQGtkOMaEabDBL5bznPa%2FWJMIQVaAARNXIJmPh%2FBUBIvhE%2Fgez7Mn2xLYjEIEIRCACEYhABCIQgQhEIAJHAYHRMTi%2FO59xfq1I6bU%2BHWAmdHByTY64%2Fe1vTy7gj9MN1mqWHHliBT1h%2FGJfn%2FWsZ4kKENugFg7Jn8SxajQTQ2gC9uxPx5iwh7vd7W7KkownfoMb3ICWIo6CjiHDMdIh0y74%2B6c5zWn4%2BwwgvNBJlu%2FvV0Ik%2FuVf%2FuUpnYxAihHkQH%2F4l3%2F5F4npHjbmKMWGjmG2iHgMUoxka1lO8QwCHlR%2FUt7qVrcSTWEhkfm681eog6OkD9DQELKCJIFIMgKF2IxznOMcIljmLHEX1vYU%2BEECwvYgdQzTZOg897jHPSYTTXnNa17TOqJritA73%2FlOMTDW8UCMOgECtWrZKSBEFAfpZu35wd7Q7tZd0V6zDgz4E5Dzg13raheBCEQgAhGIQAQiEIEIROAHjMDoGGIbiAyWbiBEzGfcW%2F719a9%2Fff6v6AU%2Fa8JxXgs12PDzoGc%2B85nFVywmzuIdmwrhZ0MdcuJEDsxsiO3vrpq2wK8Xh%2BBc605ISbWwvY3HkLMQAocufvGLS0lhWAXtrPPJGNKE4BAhFqNjnOc851lzMayWIZOZqyIHVRYIccpTnpJLS6MgTRAryCBXvOIVKTPKkvjGN76xWAg6BqFmzcsQ5OCHSERu8H9lYn1USsVSOZZtszFhFWc84xnVQvwDGqZ4WKbDURNz5EPZmGk1k%2F7e9743IOJAvvGNbxy8jsEAoR2TA8FEY53%2B9Kenz8wezYGJyrL2zne%2Bs1YWQiOMZGrKNjU98K%2FSTD4%2FGH%2BttWIpEk1Ml0Pg0Y9%2B9OhjPxi1qxYRiEAEIhCBCEQgAhGIQAR%2BSAiMPy4CYb3E36m4lRwEJ1zwghfk8%2FqIXjClQpq9OoblLs3XML%2FDbIUb3ehGP%2FmTP8lx5p5LPDrG9ndXzSixSoOQBprA%2FnQMJ5rr4bc%2FTKyY0q2PMatTjo6xfneVMUIRSBNLxyC8zHITMrFWp9NXKIIqM09i7%2BXVhXrAZunnQzkRniEIRAgKHYP%2BYIKGTHwOk44hvUk0yrU86fOe9zwbJrAcks3%2FY5aKuptFso0HMJVGK5gqclh1jKUO8dNNhDF5x1yVKchKp3QMmoxDULPBJBQKz9SUpkG0MZFoEv%2FA%2F9XuZiqJ5KHUWV%2FlDW94w1ZH%2BoGvfhWMQAQiEIEIRCACEYhABCLwg0Fg6Rjcur01Igu8733vM9NBdIHAA6%2BzzUQgUIh5sAjnNh5DGssycKKXe%2BgHRDjmv%2FZrvybb0TH8gsYqwu%2BVWNvhrne9qz370zEsifk3f%2FM3LPzsZz9LcBAIwROfcAh%2BqO3vXscgp9Ax6BWWgxB84iMMgypCTPCxNMd3o2PQE0wVEZ0iBmA7P8WvvVBR4BJBsYCYroKt2SjK3RuP8ZnPfGZSkh1UfKJchMeIxzgYHQNDS4MCTlTRlFNTG2pK4lg2tBGBCEQgAhGIQAQiEIEIRCACETiaE1g6hnU7d0wVLeDHVXnNaxkKv8rxUz%2F1U1xy%2Bgb%2Fl3tubsIESAjbuPCFL%2Bxdv1Us5CNi31nWx1jxGGIzrG7Bg3bUuaIR5GzGh69718d41KMeZT%2FXXhohCrZ9xGYo%2BjKXuQybZ32M71LHoCew59KXvjRVYck4XHvBHiQURh5Ax7DkxS1veUsygpiHMW%2FvXzmY06EKPiJAKD%2BThlRCC%2FJzKqtQYpGYEwESn%2Fvc5xgwOoYFHJwy82XWD9EOq8OkY7BBU%2FpxE00gNmP9tIoVUC0QahmQHcvpG4SjbazIToK%2BRiACEYhABCIQgQhEIAIRiEAEvlcEaALWgeRoEyhudrObWQRjfaxHYUoF%2F5q37oc2iAbzgx18Yf41pcKMDydaJsKvcvCOLaTpq5UHrNgwv%2FTh66ztaZ1PwQC%2BCuEwv0MRtq0XMatrPuYxj%2FHVapwgUDa424985CNtW7pBiIIFH0giFBXTVSSb2RlPfvKTbfvt1OHG9xfe4Nc6Zl6JeRN%2Be3TNK9mZ1qHKFBWJZw0Qv9bKNqUwm0owpZgSQoWgIfiBjzWvxLoWxAc%2FAsLH9xHhwIYb3vCGdA%2B%2B%2Fz5bcJYqVSOBENsEL3jBC0wtsfjGgx%2F84Ic%2F%2FOF%2BApUNfvpEGrEutBqK0Cc%2F%2BUlfH%2FawhymFsoGbtUes5uHrRLm88IUvpBTd5z73mZwZPAuTrnklQmgsxMFCqgvFZhrF3Bww6UuWRbXW6HZ5E%2FkgSffYRqFM5v2NQAQiEIEIRCACEYhABCIQgQgcHQhw6v0EiekPFlLg2m8%2F3H8W%2BpEOMyPGfbaOxHWve93l%2BQrh8LsbVA7LS1I2RGIQE%2FxABufaBBPuMD%2FdX7MnrEsgcoMHbekJP7EhN1EQ4gGGgEki0nPtfX3xi18s5ZOe9CTbvG9Lj%2FrlEaeQAkRNkFPml1P8%2Bgab%2FXjo5MD7tmympS3m90p4%2FWSZ9XslIjokplFMYlU2OYVQMD81ohS5kT5UhOVCPkglqkMWoPAQW%2FzI7Jwo9II8Qr6YdRX8GojlTM2dEVwhHGXS7PzF5FKXupRFRfxq7fYQFciqHUOPggEUmWimmZBEyBHWHZ1fMzGjRCwHqgiwTb2wmrU%2BLEiCFRlkcmawRTAEdSzhRcCJeqmsKksjQyKMn1uVFYHokpe85Cx1sjVM6ZqMvrEiQLZH245ABCIQgQhEIAIRiEAEIhCBCHxvCXDJLQ3B3fb2f%2FuxR2DA2MapN6eD2262BQd8azAv20t%2FP2c5rj1PnNdvjzzpAyIiBEjYEK0hQ38JDpxrPrL1NFY%2BQgW42DPfgR8tpT3rqFkYTpHniq9wSALJHJpkrGKJBAxQHAVjtufolL6khqmyyqrXKkVllSJCg%2BWzUzLhIgxbMy%2BsXOGs7ZwL2wzbi2VlK2xDJmwbPmv%2FbCiUlmKNjhFnZufURRVG1rDTht9MIfvYOTbM755MvWZbsjGYhctgFfRVjbal2yMrDbqSTbnrL6psZvna00YEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRGAvgf%2F5n%2F%2FZu7M9EYhABCIQgQhEIAIRiEAEIhCBCETg6EPgX%2F%2F1X3%2F913%2F9EY94xFe%2B8pUdq7797W8%2F61nP%2BrVf%2BzVpdg4dDb%2F%2B9V%2F%2F9V3veteXv%2Fzlh9u2f%2F%2F3f3%2Fc4x734Ac%2F%2BMtf%2FvIBMnn7299%2Bl7vc5R3veMcB0nQoAhGIQAQiEIEIRCACEYhABCIQgSODwEc%2F%2BtHTnOY05zrXuT73uc%2Ft5P%2F1r3%2F9Kle5yjGOcYy%2F%2FMu%2F3Dl0dPsqmOSe97wnUy93uct99atfPUjzPv7xjz%2FoQQ963vOe91%2F%2F9V9OIdf82I%2F92ElPetK%2F%2F%2Fu%2FP0AOj3zkIxX08Ic%2FXJqvfe1rT37ykx%2FzmMd84QtfOMApHYpABCIQgQhEIAIRiEAEIhCBCETgCCHwsY997KxnPesFLnCBz3%2F%2B83sz%2FKu%2F%2BqtXvvKVAhX2Hjpa7fn0pz997nOfm7xAhfiLv%2FiLg7Ttfe9733GPe9xb3OIWk%2F6%2F%2F%2Fu%2F3%2FSmN73%2B9a%2F%2Fz%2F%2F8zwPk8JnPfOaP%2FuiPlCgNHeOSl7wkFej7ImTlAJXqUAQiEIEIRCACEYhABCIQgQhE4PuCwAF0DFEKr3vd60wt%2BeIXvzh1%2BeY3v%2FmCF7zgAYd8nv%2F853%2FjG99YdfzWt741h0Q4vPrVrx4pQJjEm9%2F85t%2F5nd%2F50Ic%2B9OxnP%2Ft%2B97vfwx72sG10hwQmaNh5n%2Fvc5wlPeAJjVoY2PvKRjwh1cOjxj3%2B82IntoZ1txhz%2F%2BMc%2Fz3nO4%2B%2B97nWvnbU%2BBJa88IUvZPUDH%2FjAP%2F7jP2Y2yeIVr3jFr%2F7qrx7zmMe8xCUuIf%2B%2F%2B7u%2FI9e8%2BMUvltWXvvSlV73qVb%2F3e7%2B31AkZkjie9KQnMeNv%2FuZvnvGMZ6jRu9%2F9bvNxzna2s5361KdW6ze84Q2yZRhuL3vZy8zWURwmoC1r%2F%2Fmf%2FxmN%2B973vmav%2FNmf%2FdnEgayjbUQgAhGIQAQiEIEIRCACEYhABCJwYAIH0DE44Ne85jWPfexj89llYuLJLW95SzEPP%2FIjP0IusPFzP%2FdzMxuF0HG7293OHvuPc5zjEAfufe97%2F8d%2F%2FIez7nSnO9l%2F2tOe1s4TnOAEtk9%2F%2BtO%2F8Y1vdIjX%2F%2FSnP%2F3EJz6xnfP3HOc4xxxy1EoXZz7zmR06yUlO4u%2F5zne%2B97znPfbv%2FZBQbnazm5kdQzMxEUYmEywxKT%2F72c9e73rXkwOzj3e849lQi0984hM%2F8zM%2FY3t9%2FvAP%2F1AtKCFMFXHxkIc8xCE7JxPTRsgd9Ip%2F%2BId%2FIHo4ROUwtWSdbuP2t789uYNIcve7391XZU1xd7jDHYRtyAfGS1%2F60g6pLBo%2BBI2jf6zLEOhvBCIQgQhEIAIRiEAEIhCBCETg6EDgwDrG9a9%2F%2FR%2F90R%2Bd9SKEHPDBb37zmwtI%2BNu%2F%2FVsbvj7qUY%2FivAuosP1Lv%2FRLXHWTNYgJRINZcnOc%2Bgte8IIveclLxFeIUpCSAOKsf%2FzHfzznOc9pPgj9QZyDNTYdusxlLkM%2Fcejsh3ycRXMgHcjwute97j7XvqBvnPKUpyRlCG949KMfTXj5kz%2F5k2FLKpkSb3Ob2zD7Ax%2F4wI1vfGOlCIf48Ic%2FTIuQ7VWvetW3vvWttAtixUUuchFLZBBnPvjBD57oRCeS56gx1BXZUipk%2BMQnPlEOFJhPfvKTIk9U4QxnOMOLXvQiEodCLZdBoPj5n%2F95OSjxpje9qROf85znqO897nEPJ2L1f%2F%2Fv%2F33b29520Yte1Nc%2F%2F%2FM%2FPzp0g2yIQAQiEIEIRCACEYhABCIQgQh8XxA4GB3DWqBiFc573vPy2f%2Fpn%2F5p6sVt5%2FULwyBBXOxiF%2FvxH%2F%2FxtVIo35yHbtYGr390DNM65izFCZwgVgiieP%2F733%2Fyk5%2BcjDCLZFIh7njHO9761rf2yykmX1AD%2FJ2z5POzP%2FuzFBXrdeylauFNiZ%2F73Oc6JE%2FJiBUT5yAY4%2FyHfD71qU%2FNiTQZi4EQUiaxYA8SxxyyQsjoGKaTMO%2Fa1772Wc5yllEn7n%2F%2F%2Bws1IapIuXQM2wIt1EWsyMxAQemKV7zi6U53OtrL5EnNEIUiAkSchoU4TnjCE1pvZA6RPn76p3%2Fachzztb8RiEAEIhCBCEQgAhGIQAQiEIEIHCqBg9ExpBG9IObhyle%2BMgd%2F8iQ78NbJGkILTnWqUzl62cte1s%2BFXP7yl6cG0DFucIMbiKwYHWNFHYhhONOZzsT359dbtkK4gpQ0EDKFlSsm3ELowi%2F8wi%2FYT3C4whWuIE8f4oA9lq3YqdG%2F%2Fdu%2FXfziF7dIhWAPsRN%2BMvXqV7%2B6uSF%2BhlVKERGkg5vc5CYTVmEPs9ngQxuxUocpHkSGWc1j6RhiM6SkoihRKAU7f%2FInf5KG8y%2F%2F8i%2F2b3UMCoxDFJ6RdyyyYS4MIUUF2QyFVUBlcqUrXUnVVJAY4iitQ0DIzNaRYZ8IRCACEYhABCIQgQhEIAIRiEAEDpLAQeoYZmSc7GQnEz%2Fw7W9%2FeydnC2CagsE9587%2FxE%2F8hL%2FiE0gZd7nLXSgAo2O89rWvnbMICKNjzJIRVtQU6nDhC1%2BYs%2B%2FjdDM4FDGTVqgTk6E8zUy51KUuZd3RndLN7KBUiHmgFVzjGtcgYoziYYKJlNQM0oEYj71mO%2Fqud73rADoGUcKEEUoLocbPoNztbncjfTjrADqGcBGSDhrW2RjL%2FYWCAQQW54rBsOSIBCrLMAuBQmR%2FnwhEIAIRiEAEIhCBCEQgAhGIQAQOhsDSMWZyx%2FYU0RSzPoY0lnSgDwgzWH63wAyag18beec732kBzKtd7WrmlfDWfUwMkWx%2BFoT7z2ffq2NIIOyBkiCxWIX3vve9D33oQ61WYdUI%2BZhgYlkJ8zgcmjzpHjJc0SBj50xFkb%2BIjqV4UFFIBII0nGidjVOc4hRmiDh3TpGDqRxvectbRH0cOB5DCIf5KWbBXOc61zH9ZE0A2aeOMfNWTFoxFUXRFhodsxcK9bWOB4wUFTNxVM3Cocc61rEoG1vmbUcgAhGIQAQiEIEIRCACEYhABCJwAAKjY1hDwlISXPv1EXuwdAxrRHD%2FBWOIXljuvCgFcQU3utGNyBFiIRwSmDEFceEtoTnzJvanY1i%2F4mlPexoJQkzCnMXTt86GXzMxR8MUDIfMLmHDHFWuX27d0THMJREyQfogI9A0TA%2Fxl2RhIglJxA%2BbSi%2F%2BQazIss2Sng5RNlSQsECgEPuh1krZmVdij99gJTWwxCIeJrCMJTs6Bm3nXOc61%2BgYqkD08Ists8ap9AzwI62iWSgbQjVElfjp1cln1jX1W67ztb8RiEAEIhCBCEQgAhGIQAQiEIEIHCoBOoZf6ODaX%2Bta1xJ%2BQJfwueENb%2BgnQsQS%2BMVSYgW5QD4ve9nLiBXWf%2FDDJX6WlPNOBBiH%2FQUveIEcSBB%2BbtUvmFjUwsKbj33sY%2BkDVvukA7zmNa8ZS8wrkczPjxIoRC%2FIxJSQO9%2F5zoqbtTKsqkHiIBrQRpxIcJDPr%2FzKr9AiJBZfsa3Rb%2F3Wb0nzgAc8YLvTNnvsJ4MIfhjbmG1JCpb7VVZmv%2FSlL5XM%2BqV%2BFEWcCbGF0CEihc4goGLWx5CAtaI7ZMWGVcQTnvAEe4gw9qgFg3291a1u9axnPYs2oqZMFQQiT2fNr7uyUMpBYY0Rc16oN0wyVYcctHJuIwIRiEAEIhCBCEQgAhGIQAQiEIEDEzDHQUQBX95Cnbzv9TGzg45x29ve1pQN%2Fr5MaALiE%2FxM6vGOd7zjHve4VAU%2FbzrrTvgrWMIqFtQM80HkZpKI4ARncdgJF2agjBniFoRP8O7nqOgIpVvggu5hDQr6yZQlMcP8qogfNHGIfmJtzHe%2F%2B92TyfwVd0F4oUuY27Ldb1vwg2U2rcApE7Y985nPHLNZbvrJMlvwxiMe8QgVVx2relqsQ9yFVThmPU%2F50CUoM5bp8DMoq4inPvWpamT9z9lDyWGDHMR1CAgh3bz4xS%2B24oe5LWI5xGDc6173krPE4j3udKc7mYNjP0usoSHsZACuzNuIQAQiEIEIRCACEYhABCIQgQhE4AAErALB6zeVY%2Bdjmgk338%2BJUh62szmIG2ZJWD%2FT5JGdbOeQlS7WD7BKwHmX86gWvk5xAh6W%2Fy5zM1AsVSHWgm6wzZMmYEEJh0xsmZ8U2R6VlZxN09h7yInsF00xP4DirLGNHGFjm4kShYW8733vEwHCJLntZCi9PVCss4gSct5WH0AaC1DKnWRW8%2FBTKeatrNCOdfok3mvJStBGBCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEvrcE%2Fud%2F%2FocB8%2FeIskRuk%2BH8PaKyPXz5HH0sOXz2d9ZRQ%2BBb3%2FrWi170ok9%2F%2BtNHTXFHdin%2F%2Fd%2F%2F%2FWd%2F9mcf%2FvCHj%2ByCyj8CEYhABCIQgQhEIAIRiMD%2BCPzHf%2FzHP%2F3TP%2F3f%2FXz%2B5V%2F%2Bheeyv3P3t5%2BP%2F%2BxnP%2FsOd7jD%2B9%2F%2F%2Fv2lORz7mfrbv%2F3bd7%2F73T%2F5yU8ejtPXKWqkyp%2F73OfWnsOx8dKXvlQF3%2FnOd%2B6cu8OTA%2FuVr3zluxFeNMFnPvOZ7yaHHQsP8qsS%2F%2FVf%2F%2FXjH%2F%2F45z%2F%2F%2BX2e8oUvfMHRf%2F7nf%2F72t7%2B9zwTt%2FM%2F%2F%2FM%2Ff%2BI3fONGJTvTWt751aJA1%2FvEf%2F%2FFv%2F%2FZv99n9oP7Qhz7093%2F%2F91%2F72tf2Rw%2F2f%2F%2F3f9%2FnUX1PDnsv2C996UuUh%2F1lq3f9zd%2F8zSc%2B8Yn%2F%2Bq%2F%2F2sn2m9%2F85sc%2B9jEnfvWrX51DavTzP%2F%2Fz5zvf%2Bf7u7%2F5uJ3FfIxCBCEQgAhGIQAQiEIEIHDUE%2BC%2FnPve5f%2FSQz0lPetJTn%2FrUpzrVqearv7e%2B9a15XofDktvf%2FvbHOMYxXv3qVx%2BOc%2Fd3Ckv%2Bz%2F%2F5P6z667%2F%2B6%2F2lOZj9vLZznOMc17zmNQ%2FgLR5qPg960INU8HnPe95OSn79Fa94RQzBPMlJTuLv6U53uqtf%2Feqve93rDocWQUm49KUvfdGLXvRTn%2FrUTkEH%2FxU3VnGc93q4B8iEOvErv%2FIr%2BsP1r3%2F9L37xizspebj8WUdvdrObff3rX985%2BsPwVWsSDbQLFPur73Of%2B9zjHve4973vfUd5ePOb33yFK1zhxCc%2B8Y%2F8yI%2Bc9rSnvc997kPjmnNt3O9%2B98PToROe8ITnOc95nLsViDTBy1%2F%2BctfjJS95SfLRTolkyKc%2F%2Fek%2F9VM%2FJQE1Yx2Vwx%2F8wR%2Bc85znPMEJTiDbn%2FiJn9BjVz%2F8t3%2F7tzvf%2Bc6nPOUpj3e847msrnWta33kIx9Z577pTW%2B6xCUuwdqxhzI5J37gAx9w%2BVz%2B8pffn8C1cmgjAhGIQAQiEIEIRCACEYjAkUHA29g73elOvNGb3%2FzmV7nKVfjmJzvZyX72Z3%2BWl3rTm970cY973NYtOngD5Ml1es1rXnPwpxxqSv44n5oDyJM61MQHSOCFOGXgNre5zf7eax%2Fg3HXooQ996HGOc5w%2F%2BZM%2FWXtmQwDG%2Bc9%2Ffoeuc53rYIjqpS51KVRpGn%2F8x3%2B8k%2FhQv%2FJer33ta1%2FjGtfY5%2Bv7Qz19EnBOL3ShC2mRw9SUXGBOMcuPf%2Fzj723Hd7%2F73ac5zWkc5fz%2BcOoY%2BNzlLncB9r3vfe8%2BG4LacN7znlfbjVz2D%2F%2FwD76SNVC9%2F%2F3v70T09CLnCoSgdfhKo3jgAx%2BopVyDFLDXv%2F71jjrdUSEQEvic6Uxn0oFXiYIlfu7nfo5WNkddIEIm1tE3vOENOp5Tfu3Xfu2ud70ree3kJz%2F5O97xDgnYT0hxFkXi13%2F9129yk5vY%2Fpmf%2BRnihqPCQs5%2B9rMf61jH%2BsVf%2FEUn0jpczq997WsnZ5qGQ24Oq6A2IhCBCEQgAhGIQAQiEIEIfE8I0Ac4LIIHvvGNb%2BwY4FUsV%2Fqzn%2F3s3nf6c0jkwDYufXQMjpj0Dm1f6HOgCAiTjyD5nRNXud72OrR1yvanY0zKfTrpfEBCzTjaEshB%2FopW7gqVnxJVWcqtncsSp%2B89dGAdwxtwp0wOqvCHf%2FiHXmpf%2FOIX5ySipPqgKVEFx6RJifBOlSUzKcDHxrJngEu83TlHVU0b%2BYBsjwRK52h7y3%2B9613vy1%2F%2B8raNVJaRe9t6spLDbW972%2FGOObOT4RyS7YMf%2FOA5RGbZ6hj7ZLXIM8Y0GdWZfLZ%2FNQdjWLjdOdtwOWtCF%2BSA2Lbivjq6bTgQnDI1VZajC7KdCGv9vaXI2aHx4ueoUpw45%2FrLvGW5fNRU3Yk84hacuzVpTufmk7Ne%2FOIXz9dnPOMZiN373veelGKKKHIXvvCF5UloojBc5CIXETYziZ%2F2tKdJLFjCVyEfZzvb2YRAUBfPfOYz7%2BgY8lfKZS5zGRIEY25wgxswZjJhpLAosRaveMUrZs%2BznvUs2dI0fDU5S7akFXOsfNVGmltiy1%2F4%2BpCHPETK3%2Fqt35oTX%2FCCF%2BhChE1g7UFJic7FdhL0NwIRiEAEIhCBCEQgAhGIwPeEAB2DP%2BUN8k6o%2FAc%2F%2BEFe0ukP%2BVz1qlfdLgrxtre9jTfnCKfsJ3%2FyJzlB46ZxwfhEd7zjHcV4OPRjP%2FZjv%2FqrvzpeqtPFq%2F%2FCL%2FwCJ%2BsMh3yudKUrbV9qW1XDa%2BUznvGMTpRSzMM40XzJnXiM973vfWaayENK77Kf%2F%2FznjzyCHm%2Fud3%2F3d73Fdkg4%2FT3ucQ8TNO51r3sxzytsEfi3u93txuPjwVl2QyS%2FAIOznOUsFJjx7GTCd374wx%2FOhRTw75A36QL4p2kOrGOc61znWjqG9HgyT%2F5WFRDYz54b3vCGZA0zet7ylrdIILzBPJepCDutDDkYuec805%2F%2B6Z%2FmYk%2B5QFEkpi201F%2F91V%2FNfn%2B9Z7%2Fa1a7m1bwPpGYxqNov%2FdIvcX69PTdBwBt2nqyUQgVU334m%2FfiP%2FziPe6tFTIZbHYPDu51xwHvlgPNzfZaOoXEf8YhHqPiw4hTPMiYqIsaAk%2F6whz3sspe9rOZghgCDNamHDCUwQBFO9FeQw6qs1vzTP%2F1ToNRIF9KprnzlK9%2FqVreajsRCLS60ZhrOUQIO4zn%2BiKGkjrx%2BrCDly5vlxPtmwFnPelZNuUQDFoo0kMYh6QXqTMADene7292UznIxNmO5Ouo20vPihSgc85jH1FEpDGuGyNBjobJ0qrXC56Mf%2FWi4%2FuiP%2FmgSqP4FL3hBeheYVs%2FQu0gHc8hfKgdR4pa3vCUCsnrjG99ItlKuyxCKbTyGbdcgecGaG0RItZ5eLROnuCS175oAot3VnVDpUpKnhTus8bIKdaGxkJ2OXve615WbxTHmKBvMiGHk2uO6oJ%2BY6rJObyMCEYhABCIQgQhEIAIRiMBRT2DpGNt39FaT4HfP21jTT2zwVWeVPz41J1GovKB03p9D4uEn%2BNzSCt%2Fxco9xDA4vd5IPaPvJT36ySgnS4ED5KjF3kn9kmxc%2FzilpQv728MhoHac4xSnoId5lO3FHx%2BDr0QGk5OkrgjPLkmc%2B85nDzRvtYx%2F72DxNL5HNfZDMx0QPTqtqCra3igWPz9dHPvKRDhErqC7cf9v0AQKC19lTC56sjctd7nIO0R%2FGAT%2BwjsF5HJ96jKFjyISFllt80pOeJB8fLvAFLnCBv%2FiLv2CP0u250Y1uxP3nzquy5RGcK3KAB827H3f4Pe95D3eeh2u6Ct%2FZKZqGcyolZ1ZbOFEdb3GLW6i4fLSFdSY5s1BMpI3lSXEmPjiXs%2F%2FLv%2FzLzrL92Mc%2BFoqxdv5SCaZNVZypT3jCE9ZRE2S0NS1I62Crdlx%2BMoJ8VNM0hGlT8x2GlXZ0yMcUGxXEga7ye7%2F3ezKUQK0dInE4kWxlW7kydPTP%2F%2FzPdRKl6wySqbijMp%2FoCzqVPeBwxokGDimRR09rog%2F4yuUnBVDebGtxTMgphJ05SqMYl19Ig1JYRVjDRGKevpADR2984xv76qNQWUGqj73sZS8b%2BQ5Sh1RZVtN7FyKd0wwOaoBeNDtf9apXzZXCeOoEUcVVMPQA3AZ7SE9ugoiksDK0QWtymezoGCuBvqE5tjqGjkFs0aWnFaTUJ%2BlpILBWiAUhghyxcnBh6j%2BiNaRnmGt2qXYjarH%2FL%2F%2FyLye9xOCTB3e6zcqtjQhEIAIRiEAEIhCBCEQgAkcBgb06Bi%2BMq8ulWu%2BRTZHgV5pQz8vjP3J8LDA4URCiCPjLfDdOKJ%2FUWf5OKL44DV4PV5RDNC%2BCeVjzayZcdTEbnD6un3y8bXcit1pKVfaqmuNGr%2FD2nDETjyE%2BxFHaBS%2BSOzwp3%2F72t4tnEIAhJVfxYhe7mAgEb%2BH5WU4UIS9bDr6vTufxcW%2BdaB0Ajjyt4KMf%2FajiuIrjuvJVuZb8biVOeADXj74hzwmBOLCO4c21nNXdRz6kEqD4wjxEYg6zUZrfj1CiKkO6MIrQ4FPTf5TrdBszSwVwsg9nXBDCdIbHP%2F7xThRNMR63ItZijGQQpRAE1B1nXjwFRlkIi4fhwPpKF5LPu971LksxwKWsyXb%2BgqM4J%2FKpvcSnM8x0DEIBCYXj%2F5znPMd%2BnrV8hF5QEsTGDCuTRAROcISniekD7BQMMw71S17yEl9JMYzRFiqI%2BRwSnEDbIU1Yflal6GNazUqVjJGYCKP6BCjBD2Jm6F160QSKqJoaOarDzCGRFRPkQyUwLwYNetdEJiiU5VaoGMKECGFIOKg4XDQrhYoDUSIDdOnHPOYxM5%2BCgMNyCRxSayVaHlN7OWvHnaeN6PBmkSykzCChyFln05HMM9Ks29CmldJKGkI1RK1Mn1z7ITpMOgbRjOo1UslkovrUJ1eTDYqfurjQVv4myLBZPJLqMFV3Gk1SAhcU7Pa4yiY97GQci8Co18qhjQhEIAIRiEAEIhCBCEQgAkcxgb06hhB9fiVvyM9tmPvgnS8HjS%2FPDRdhzpH0elf4%2BtjJexWBz2fncJkdwCdy1hyaV%2BR8W94fd4m%2FSVJYtZOYm8kD5cbKUzT%2B%2BJuTwIQUR7lUnFmuKO9YCL34BDEPvO95NT8pySaTktTA0d6%2BDbeH2XznpWOYgsEbpc84xWSBZQzXUj4iAagx%2FFweOnfYy3Tet9f0FAD2S3wAHYODzF0FTV18ONSK4CnLxIlPfOITfRVLMCXCKBjDpIbtxATakTRssHN0DJaYQSArzWH%2FtAW3ms4gCIEkQjlBY0UFwGIJC0EU1ACKimS0gglyQI9OIiuihxPF3gi5keEcHav8HR1jlAFNoEYziUBKopPIHFjkw7OWAzd%2FLyvnijaRlXiMHRdYYIPWcaIPnUf3kO0rX%2FnKF77wheorW0bqezzurcCie5A4Rg4imjFJ8IkTBQkw5gEPeABov%2FM7v6PJINXZZD7VIXZJvJijSjojZVBLOObgSKzdJx%2BuvXzMhQEENN2GIDD5kGVUiryjF%2Bk8jpKA9qlFPPWpT9VVtithQiTUQc56puo7StCg3U3O66%2BriW2SUW%2FWztk4rDqGoCm9gri0pIbRMWamye%2F%2F%2Fu8rZWvh6Bj6nuK0tSaj8NiwX5iHxEC5A4wxLkDzksTqbFcU2TG4rxGIQAQiEIEIRCACEYhABI5sAnt1DI7bTAnhxWw%2FgtW5b%2FxN3ujykrbmzTqf63cu5tddOWijY3gZPc6gU7iEoz%2FwKHmv%2FH1ixdan5oRy%2BrhdS8fgoDGMTkKX4KevckciEDFCPxEDP%2BsZzlHeLi9sR8fw3pmnr14yX5lsN0grM2Fh1Z0bK55EmgPrGAwWy4GSD29X%2BAqtYHIeI5%2FylKfMVxg5jAIn1G4V%2FahHPUqJAiHoEqNjmBEgYMDb%2F2XJ2rBmAjtxE2GypbFyU%2FToGODbqdbqy5ueHFiIsJ0r%2FWyMjjH6g2kUJkEQTOTv50GxFRoh25kZIRbCKZrPL18sq2xQAEbzoWOYhzIrgUjpVb4Ym9ExfBWrQ6zYnihCRk%2BQvzf%2BIiJWZ5gVL%2BkYhIgJa9meNdvaRWejY4gfWEu5oi2sYmYnKVHkydIxKCcO7c3HJCMdW%2BnQMcZZPi6Q6bpEDHxGx5if%2F5gE6y%2Fz9AHrrqw9dAkFEV4oV3x%2FGpSgINERSwaUkmESMAbkvS1yWHUMcR1kNLEfS8%2BhY5hkJGaJAdZpYc926sroGERFlugqflRlkaFc6XtEJHlOjWhKRBL629b%2BVdk2IhCBCEQgAhGIQAQiEIEIHDUE9uoY9hAreENC603N4IP72DC1YQ6tZR63FpImDlXHMJlCMmdtdQweLtnEGgUTxj95Ko4%2FJXhgq2NwLflWJqqMbz4p5026%2BQ6kBo4zeWRZ5T3yXh1Dhl7fcxvN6Vgp14Y4Af61swSZ8MEpIeZTsORQdQzv2U0K4K5%2BJ9rgG99QysrTBoBKXDqGQAIe4kgEK5n4EGnEiqx4DDoGAzjRMlfNbVuIkFE7zSSoYLn8KysbOzrGHOKQsoH0QQBRFqVlx05faU0kC%2B4tj17mFrSkOfCCxYRwhIW4cNVNW9BYWIlCoSyREYgq7JnlLA6sY7BWQAjNgQ2wEASoOmQK4QraVxSEShG1VhOLAZh4jKVjiBOw3sh3OuUh3RIWkot1IfapY6xWXjqGyA3zR4RqKHSLVG4kOLWmVBw%2BHWPiMbTU0FZTESxkH2RW05B3NOggslOaWY%2BFhKLbrGRr47DqGObXmJ8iYgquyWSaiZQhKzOnXCOCWFb%2B4oV077WHPX62FRaXnv4vNobktSYfTTwG5UpPWDm0EYEIRCACEYhABCIQgQhE4CgmsFfH4K3MVP3tT1WOX2nmgp%2BKMAeEMzh2co6sWSEu3Ya3ukIXDhCPsVfH4P%2FKk5%2FuPa9X6pMnlcMMFP4y%2F5RnPetjcMy9WbZcoakBLFwphVvwSeUzLrCX3csf5D%2FuzCvhwnurzpvjxd%2FznvdcqMkU5krYz3fj1hFk1iHrgcj%2FYHQMyo95FuvE7QYfeatjiDFQZUsleME9yVRTGIAq8%2BuXjuEoJuprJczlS0o%2FwTDK4mb6rEyQpEJYhtFrfeErnHHrnY5SYYFHIsOaDSGyxYly5uRu7Vw6xtR3fglU02Ayv6xBgaFy0DE4vFZ95BRzzFcOFliw58A6huiOmddDKVonWnKBrGEJi2li03NWpax8Yi7DzCsREKItJnhgnTvNffA6BqQ46BgmX6xMbIyMpp8fqo4hrIW%2BtD13tplH1VkRQbKymIbwEl13Jda1hLvMGhS6IvFqInNWp10pZ%2BOw6hiTnjC4ft9kZldRVBRnQV1KoIrbnvxnBdql9mxLn0NEqtEeHZr1MfYXjrU9t%2B0IRCACEYhABCIQgQhEIAJHHoG9OgZn1vKM%2FG7viOkVPCzevZh8AfM8Gv6sQ8Ie%2BNFmQIyzQ6DgOx8OHUPOqjZrCFgZkkDBi%2Fdem5vptS%2Fnndu71vmU0goGUzr3Vkr%2BFzdQ2ICU%2FFCBIjxuYSS8YKERsyjozjqf7OS800P4el5Gq52VFa2bwUHmmPPTSTEi5y1iwBjRC%2BY78M0PUsfYkQVWq%2B3oGAhbDVJF7n73uzMVRss4EDEEpZgWQY6YeSVyY61WkNIvg7DHbA4LZdBqTBLRFsPND5hy%2F70in5kp4%2BZzOYV8IIMDUBZ4lAn9gb8PFEde9IIoi50JAkvH8FKe8XxhYRhOFCPBHbZnq2PMKhOm0lBFKDNMokWgd6g6Br0IbY3FSKqCaT5M1Ry6okphokR8hBb4WPFDm6qIWgDCHvqM%2BRpanM4GLAMIVrM%2Bxt55JctDl37mlSAAsm3AdRXc8Bd%2BYFFT8gLgB9AxmKerEyv8XCl0OudqYhs4UAloMjLxVWI%2FHKMutAvdVdv5vRKTa4SXAEtJMOdFVmIndDZXkzTzmTk7k%2FNh1TGcpVIK1Tf0CiE9LitfxSA5pKYuK7TpWkoBnCxppVyWT3G0F0E7%2BpgL3JXlMlnqogR6hfYlAKrapO9vBCIQgQhEIAIRiEAEIhCBo54AH0roOwdw%2B0aYyzw%2FRultsrf2PD4uqlUFmMdjFdXAM%2BJ4mnRggyM5iyKOx8RZm1pYyNG53EPOEQ9ISi%2BFxwPyd9zz8Xn5p95cS6AUL99tiG1485vfLB%2Fn8r55XrM6Iq%2Ff0hASmIYwa2lKyaGeEsVvjElsc4pkPn46U3E8NS6YJQoFEkhsJUO%2Bud9uYKFlG21YnYBbCoL0zsKEMTbU3d%2F5RdQHPehBti0yMMWtv5gIrmAPP3Tt3G6Ya%2BBEms%2FaKaUIEDuZMRUR5TI%2Fn8GzHh1DK0jP52W2lOwRGsHUUWAc4mNahtQhYgtv1AZ%2Ff17Ec9u1qT1akLPvKz3HV462KnOf1WuEqWWSDTqG2S6S8WR9xY1I4qtzNYQ9VsX0lQggJoSLPXkSnYYVz9fR%2BZmbwThrfjpRD8FZp5IP%2F5pwJKU91ouwwRifiVIgKwkHslOec9S2Esk78rEuqMrao6Fng6rD9YYCAbMn1voYo7CtdT4R0BXRnh8EgdrP18rHWcNfhAyVhgRh3grlZAVR6HiSwTIxDDL01dwQEy5W0AjDfCgtjBHoMg1nD6tEkkivu2o76o1%2BJcoFWIeEnTgkvsVRldVSPhrXRJ5D8vvOH5C1KVD0qLVzbWgOvVpH2ioqSp9C8SGbKAI9gsac5fKcDq8bUIQcpXto9zmq1i4oO30ssiF%2BY5Vlw3K%2BrNUE251tRyACEYhABCIQgQhEIAIROIoJeOVtdjzvbBzVVTpvmp9r1ga3TnjG1qPhh1rS0OKEpipwbcZr45p5ySvlWiBRMmHzMiEdePPuDTs1YJxBf6kiXuxaKmFK5GaygfNrbQRBFyOMOORcL9%2B9lKcVTEr%2BrJfLDNtJOUfNTZAtm73WH9nB72%2ByjZGqKb5ieXxe4rNWsIeZEX6XZPQNmXjLL0LALA8VtHyoN%2B%2ByMllGJv6qBW93ylp%2FsfrN3%2FxNlVWLtXO7QcYx3WOCT9Z%2BBQ1hGo6lKjjjc8j%2BrY5hp1f%2FvHKuNGvve9%2F7eo2%2BMnHIL7ESiEQRiMfYTmxhp0kxMvdDtNJz8AUnmHgiYEB0h1iU5b2u3Oyh0niVv%2Fibn6LKa1lL2hRo1gidWRhMtZaCorHinltBRTVpINoXNyeu9iVVaQ5ppptpDrEK%2Bg97xEL4gLz0KD6%2BPqCy5itZlJLvTwChEoydfG1NqfXVWt3HQ8fBRBXdcibdSEkHYwxpa84SfoC2WRLLoyf4OIUNlkB5%2BMMfrlApdQ%2F9jag1vdoeFwgxxwIsg4sZugeqGkJ4w2S%2B%2FjpEkLH%2BxtqjRezUXU3HuNvd7raEHfuVzkIV99Mw6%2BPrio6Qieq4LqBbZq%2BcbWgOK3OK65gIkHWIzTJnpKpNeNI6ZENrKk43sNKsZlo93yFhLbqipiF3bIVNh%2FRtyhi1Z5%2BWbPNvOwIRiEAEIhCBCEQgAhGIwPeWwCgP%2B7ThAIf2mf5gdh58nntTclq50qsU%2FqPXyhy6tWfvxt5MJg3VYm%2FiI2nPjg28Y%2BtnCvDYie5g0k7KZY9DPuvrdmPvKXvli236w7G9v6IPNau9tjmFhEI%2BWo65t%2F%2BiI4hRa89k69zDXe7WsMOXz%2F7OIiyYrSPaRBzFthTbRzj2nfz3ft2fkZPysNojzIZEs40p2ltieyIQgQhEIAIRiEAEIhCBCETg4Anwc61dQLgQZi9KxBKXpjlYBWJNEDj4rL5XKbmWYicEIViRYztF4ntlz%2FekXJEtGtFUGgIU%2BcJEGHMuZr2O74k9h7VQP65q8oUwiQlZOaynHw3T04tMrnEpmeEi9OhoaGEmRSACEYhABCIQgQhEIAIR%2BD4lICre%2BoSWa%2BAI%2B3gtviZEfF%2FUiMNokgvLLZLwohe96PvC5iPcSJ6ytWQtZHFIGx7DQpSmwBzhpRx5GRKjyGhWulhTWo68so6anM21MW%2FIXKc19emoKbdSIhCBCEQgAhGIQAQiEIEI%2FJAQoGb4cQ0LO6ylEr6PKm7Rhte97nU5jNbTEAMglmbvGhRH%2F9bk%2BIurUYWjv6kHY6H5KeJh9rnQ6MGcXpoIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAt8lgf8XwAklVQplbmRzdHJlYW0KZW5kb2JqCjExIDAgb2JqCjw8IC9UeXBlIC9YT2JqZWN0IC9TdWJ0eXBlIC9JbWFnZSAvV2lkdGggMTQzMiAvSGVpZ2h0IDk5OCAvQ29sb3JTcGFjZSAvRGV2aWNlR3JheQovSW50ZXJwb2xhdGUgdHJ1ZSAvQml0c1BlckNvbXBvbmVudCA4IC9MZW5ndGggNjI1NSAvRmlsdGVyIC9GbGF0ZURlY29kZSA%2BPgpzdHJlYW0KeAHt0DEBAAAAwqD%2BqWcJT4hAYcCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMDAa2AtxAdXCmVuZHN0cmVhbQplbmRvYmoKOSAwIG9iago8PCAvVHlwZSAvRXh0R1N0YXRlIC9BQVBMOkFBIGZhbHNlID4%2BCmVuZG9iagoxMCAwIG9iago8PCAvVHlwZSAvRXh0R1N0YXRlIC9BQVBMOkFBIHRydWUgPj4KZW5kb2JqCjEyIDAgb2JqCjw8IC9OIDMgL0FsdGVybmF0ZSAvRGV2aWNlUkdCIC9MZW5ndGggMjYxMiAvRmlsdGVyIC9GbGF0ZURlY29kZSA%2BPgpzdHJlYW0KeAGdlndUU9kWh8%2B9N73QEiIgJfQaegkg0jtIFQRRiUmAUAKGhCZ2RAVGFBEpVmRUwAFHhyJjRRQLg4Ji1wnyEFDGwVFEReXdjGsJ7601896a%2FcdZ39nnt9fZZ%2B9917oAUPyCBMJ0WAGANKFYFO7rwVwSE8vE9wIYEAEOWAHA4WZmBEf4RALU%2FL09mZmoSMaz9u4ugGS72yy%2FUCZz1v9%2FkSI3QyQGAApF1TY8fiYX5QKUU7PFGTL%2FBMr0lSkyhjEyFqEJoqwi48SvbPan5iu7yZiXJuShGlnOGbw0noy7UN6aJeGjjAShXJgl4GejfAdlvVRJmgDl9yjT0%2FicTAAwFJlfzOcmoWyJMkUUGe6J8gIACJTEObxyDov5OWieAHimZ%2BSKBIlJYqYR15hp5ejIZvrxs1P5YjErlMNN4Yh4TM%2F0tAyOMBeAr2%2BWRQElWW2ZaJHtrRzt7VnW5mj5v9nfHn5T%2FT3IevtV8Sbsz55BjJ5Z32zsrC%2B9FgD2JFqbHbO%2BlVUAtG0GQOXhrE%2FvIADyBQC03pzzHoZsXpLE4gwnC4vs7GxzAZ9rLivoN%2Fufgm%2FKv4Y595nL7vtWO6YXP4EjSRUzZUXlpqemS0TMzAwOl89k%2FfcQ%2F%2BPAOWnNycMsnJ%2FAF%2FGF6FVR6JQJhIlou4U8gViQLmQKhH%2FV4X8YNicHGX6daxRodV8AfYU5ULhJB8hvPQBDIwMkbj96An3rWxAxCsi%2BvGitka9zjzJ6%2Fuf6Hwtcim7hTEEiU%2Bb2DI9kciWiLBmj34RswQISkAd0oAo0gS4wAixgDRyAM3AD3iAAhIBIEAOWAy5IAmlABLJBPtgACkEx2AF2g2pwANSBetAEToI2cAZcBFfADXALDIBHQAqGwUswAd6BaQiC8BAVokGqkBakD5lC1hAbWgh5Q0FQOBQDxUOJkBCSQPnQJqgYKoOqoUNQPfQjdBq6CF2D%2BqAH0CA0Bv0BfYQRmALTYQ3YALaA2bA7HAhHwsvgRHgVnAcXwNvhSrgWPg63whfhG%2FAALIVfwpMIQMgIA9FGWAgb8URCkFgkAREha5EipAKpRZqQDqQbuY1IkXHkAwaHoWGYGBbGGeOHWYzhYlZh1mJKMNWYY5hWTBfmNmYQM4H5gqVi1bGmWCesP3YJNhGbjS3EVmCPYFuwl7ED2GHsOxwOx8AZ4hxwfrgYXDJuNa4Etw%2FXjLuA68MN4SbxeLwq3hTvgg%2FBc%2FBifCG%2BCn8cfx7fjx%2FGvyeQCVoEa4IPIZYgJGwkVBAaCOcI%2FYQRwjRRgahPdCKGEHnEXGIpsY7YQbxJHCZOkxRJhiQXUiQpmbSBVElqIl0mPSa9IZPJOmRHchhZQF5PriSfIF8lD5I%2FUJQoJhRPShxFQtlOOUq5QHlAeUOlUg2obtRYqpi6nVpPvUR9Sn0vR5Mzl%2FOX48mtk6uRa5Xrl3slT5TXl3eXXy6fJ18hf0r%2Bpvy4AlHBQMFTgaOwVqFG4bTCPYVJRZqilWKIYppiiWKD4jXFUSW8koGStxJPqUDpsNIlpSEaQtOledK4tE20Otpl2jAdRzek%2B9OT6cX0H%2Bi99AllJWVb5SjlHOUa5bPKUgbCMGD4M1IZpYyTjLuMj%2FM05rnP48%2FbNq9pXv%2B8KZX5Km4qfJUilWaVAZWPqkxVb9UU1Z2qbapP1DBqJmphatlq%2B9Uuq43Pp893ns%2BdXzT%2F5PyH6rC6iXq4%2Bmr1w%2Bo96pMamhq%2BGhkaVRqXNMY1GZpumsma5ZrnNMe0aFoLtQRa5VrntV4wlZnuzFRmJbOLOaGtru2nLdE%2BpN2rPa1jqLNYZ6NOs84TXZIuWzdBt1y3U3dCT0svWC9fr1HvoT5Rn62fpL9Hv1t%2FysDQINpgi0GbwaihiqG%2FYZ5ho%2BFjI6qRq9Eqo1qjO8Y4Y7ZxivE%2B41smsImdSZJJjclNU9jU3lRgus%2B0zwxr5mgmNKs1u8eisNxZWaxG1qA5wzzIfKN5m%2FkrCz2LWIudFt0WXyztLFMt6ywfWSlZBVhttOqw%2BsPaxJprXWN9x4Zq42Ozzqbd5rWtqS3fdr%2FtfTuaXbDdFrtOu8%2F2DvYi%2Byb7MQc9h3iHvQ732HR2KLuEfdUR6%2BjhuM7xjOMHJ3snsdNJp9%2BdWc4pzg3OowsMF%2FAX1C0YctFx4bgccpEuZC6MX3hwodRV25XjWuv6zE3Xjed2xG3E3dg92f24%2BysPSw%2BRR4vHlKeT5xrPC16Il69XkVevt5L3Yu9q76c%2BOj6JPo0%2BE752vqt9L%2Fhh%2FQL9dvrd89fw5%2FrX%2B08EOASsCegKpARGBFYHPgsyCRIFdQTDwQHBu4IfL9JfJFzUFgJC%2FEN2hTwJNQxdFfpzGC4sNKwm7Hm4VXh%2BeHcELWJFREPEu0iPyNLIR4uNFksWd0bJR8VF1UdNRXtFl0VLl1gsWbPkRoxajCCmPRYfGxV7JHZyqffS3UuH4%2BziCuPuLjNclrPs2nK15anLz66QX8FZcSoeGx8d3xD%2FiRPCqeVMrvRfuXflBNeTu4f7kufGK%2BeN8V34ZfyRBJeEsoTRRJfEXYljSa5JFUnjAk9BteB1sl%2FygeSplJCUoykzqdGpzWmEtPi000IlYYqwK10zPSe9L8M0ozBDuspp1e5VE6JA0ZFMKHNZZruYjv5M9UiMJJslg1kLs2qy3mdHZZ%2FKUcwR5vTkmuRuyx3J88n7fjVmNXd1Z752%2Fob8wTXuaw6thdauXNu5Tnddwbrh9b7rj20gbUjZ8MtGy41lG99uit7UUaBRsL5gaLPv5sZCuUJR4b0tzlsObMVsFWzt3WazrWrblyJe0fViy%2BKK4k8l3JLr31l9V%2FndzPaE7b2l9qX7d%2BB2CHfc3em681iZYlle2dCu4F2t5czyovK3u1fsvlZhW3FgD2mPZI%2B0MqiyvUqvakfVp%2Bqk6oEaj5rmvep7t%2B2d2sfb17%2FfbX%2FTAY0DxQc%2BHhQcvH%2FI91BrrUFtxWHc4azDz%2Bui6rq%2FZ39ff0TtSPGRz0eFR6XHwo911TvU1zeoN5Q2wo2SxrHjccdv%2FeD1Q3sTq%2BlQM6O5%2BAQ4ITnx4sf4H%2B%2BeDDzZeYp9qukn%2FZ%2F2ttBailqh1tzWibakNml7THvf6YDTnR3OHS0%2Fm%2F989Iz2mZqzymdLz5HOFZybOZ93fvJCxoXxi4kXhzpXdD66tOTSna6wrt7LgZevXvG5cqnbvfv8VZerZ645XTt9nX297Yb9jdYeu56WX%2Bx%2Baem172296XCz%2FZbjrY6%2BBX3n%2Bl37L972un3ljv%2BdGwOLBvruLr57%2F17cPel93v3RB6kPXj%2FMejj9aP1j7OOiJwpPKp6qP6391fjXZqm99Oyg12DPs4hnj4a4Qy%2F%2FlfmvT8MFz6nPK0a0RupHrUfPjPmM3Xqx9MXwy4yX0%2BOFvyn%2BtveV0auffnf7vWdiycTwa9HrmT9K3qi%2BOfrW9m3nZOjk03dp76anit6rvj%2F2gf2h%2B2P0x5Hp7E%2F4T5WfjT93fAn88ngmbWbm3%2FeE8%2FsKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqClsgL0lDQ0Jhc2VkIDEyIDAgUiBdCmVuZG9iagoyIDAgb2JqCjw8IC9UeXBlIC9QYWdlcyAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvQ291bnQgMSAvS2lkcyBbIDEgMCBSIF0gPj4KZW5kb2JqCjEzIDAgb2JqCjw8IC9UeXBlIC9DYXRhbG9nIC9QYWdlcyAyIDAgUiAvVmVyc2lvbiAvMS40ID4%2BCmVuZG9iago3IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UcnVlVHlwZSAvQmFzZUZvbnQgL0FBQUFBQytDYWxpYnJpIC9Gb250RGVzY3JpcHRvcgoxNCAwIFIgL1RvVW5pY29kZSAxNSAwIFIgL0ZpcnN0Q2hhciAzMyAvTGFzdENoYXIgNzYgL1dpZHRocyBbIDU0MyA0OTggNTI1CjUyNSAzOTEgMzM1IDIyNiA0NzkgNjYyIDU2NyA0MjMgMzQ5IDIyOSAzMDUgNTI3IDc5OSA1MTcgNzE1IDU3OSAyNTIgNTI1IDUyNQoyNjggMzg2IDI1MiA1MjUgNTI1IDQ3MSA0NTIgNTMzIDM5NSA1MjUgNDIwIDYxNSAyMjkgNDMzIDQ4OCA2NDYgMzA2IDUwNyA1MDcKNTA3IDUwNyA1MDcgXSA%2BPgplbmRvYmoKMTUgMCBvYmoKPDwgL0xlbmd0aCA0ODUgL0ZpbHRlciAvRmxhdGVEZWNvZGUgPj4Kc3RyZWFtCngBXZPNitswFEb3fgotp4shiqUkM2AMw5SBLPpD0z6AY8nB0NjGcRZ5%2B57vZjqFLs7i%2BEpX97Ot1ev%2B837oF7f6Po%2FtIS%2Bu64c058t4ndvsjvnUD8W6dKlvl3ezZ%2B25mYoVmw%2B3y5LP%2B6EbXVUVzq1%2BsOWyzDf38JLGY%2F6kZ9%2FmlOd%2BOLmHX68He3K4TtPvfM7D4nxR1y7ljnZfmulrc85uZVsf94l6v9we2fVvxc%2FblB0TsWN9H6kdU75MTZvnZjjlovK%2Brt7e6iIP6b9SDPcdx%2B59abmuK%2BH9pqyLqixR8H67kQYUvN%2BtpREF1KobdGsaVN2h4H3ppU8o0Mr2PqPgfexUbVDg3K30iAKLrVWLAgfZVAkFqs9anFFAbW%2BHAqrOgfACTVLCCQ7SVIFwgs47KeEEU2nIQDiB6qBAQEGrJylZBXutFVmD5Q2NqmQVxLcxyBosL6NSJaugVZSSVaAKGMgqUJuKrMHy7hQwkFUwlV5OIKtgDJ0bySrYq4PIYbC4lZJVoDqXBgaLrUpWnquVAkayChbr%2B0ayCtQ6kzXev69ebCSr8J73iZJVoHqTkawCtVZkjZaXT0OVrIIq75nf9O%2F%2FqD9WN%2BvjJrTXeeYS2PWz%2B6H%2Fvh%2Fyxw2dxkkNjD%2Fx2fi2CmVuZHN0cmVhbQplbmRvYmoKMTQgMCBvYmoKPDwgL1R5cGUgL0ZvbnREZXNjcmlwdG9yIC9Gb250TmFtZSAvQUFBQUFDK0NhbGlicmkgL0ZsYWdzIDQgL0ZvbnRCQm94IFstNTAzIC0zMTMgMTI0MCAxMDI2XQovSXRhbGljQW5nbGUgMCAvQXNjZW50IDk1MiAvRGVzY2VudCAtMjY5IC9DYXBIZWlnaHQgNjMyIC9TdGVtViAwIC9YSGVpZ2h0CjQ2NCAvQXZnV2lkdGggNTIxIC9NYXhXaWR0aCAxMzI4IC9Gb250RmlsZTIgMTYgMCBSID4%2BCmVuZG9iagoxNiAwIG9iago8PCAvTGVuZ3RoMSAyOTA0NCAvTGVuZ3RoIDE2MDgxIC9GaWx0ZXIgL0ZsYXRlRGVjb2RlID4%2BCnN0cmVhbQp4AdV9d3hcxd313Hu396It0kraXa20KqsuWcVFWlvFKm6yLVuyLVuy3Fn3Bi7YdBA4QGgxgQAJGBITvFo3gSlO4oSQxIQklFBCIG8SWpxAQkKV9J2Z2ZFlCG%2F%2B%2BJ7vefLJOjpn5s7M3vubmd%2BUOwvbtmxfSYxkP1FIaf%2F6vk2E%2FdS%2BAdrUv2NbgAVJuJEQ9eOrNq1ez8OFIHNkdeySVTw88WFCqg6tWdm3gofJ5%2BCqNYjgYakSnL1m%2FbaLebiWFnAstrE%2FeX3iHISXrO%2B7OPn55DWEAxv61q%2Fk6TfPp%2BFNW1Ymr0tdKO490ou%2Ff5Vkyqo%2Fz2O84hwP%2F%2FEthCXkKsJVNZgQmdhICbmGEEeVPIHF0Ouaior79HcPL7NO%2FidJ1bHoU%2B%2Ft%2BQUVL9w%2BsOqzT4f36%2F%2Biq0JQjxL4D%2FJpvzX8CiGGez%2F79NN79X9hn5S8yKhoUK9MnSf%2FVP4JqSF%2B%2Bekk%2F47UyK%2BQTvll8Evg3yb5RfALCD8P%2Fg341%2BBfgZ8CPwl%2BAvw46SQq%2BVVSCcwHlDG1AqH7gecBNbkIJUnEiPwSSZF%2FSBqBFcA24FZAjbRP4tr9KFEiAfnKY3qv1BYYkq8Q4nIhLhNivxD7hLhUiL1C7BFitxC7hLhEiIuF2CnEDiG2C7FNiK1CbBZikxAbhdggxHohYkJcJMQ6IdYKsUaI1UKsEmKlECuE6BdiuRB9QvQKsUyIpUL0CLFEiMVCLBKiW4guIRYKsUCITiHmCzFPiLlCdAgxR4jZQswSYqYQM4RoF6JNiFYhWoSYLkSzEE1CNArRIMQ0IaYKERWiXog6IaYIMVmISUJMFKJWiBohqoWoEmKCEJVCVAhRLkSZEKVClAhRLESREIVCRIQoECJfiDwhcoUIC5EjRLYQISGyhAgKERDCL0SmEBlCpAvhEyJNiFQhvEJ4hHAL4RIiRQinEA4h7ELYhLAKYRHCLIRJCKMQBiH0QuiE0AqhEUIthEoIRQhZCEkIkhTSqBAjQgwL8bkQnwnxqRCfCPGxEB8J8S8h%2FinEh0L8Q4i%2FC%2FGBEO8L8Tch%2FirEOSH%2BIsR7QrwrxDtCvC3EW0L8WYg%2FCfFHIf5HiD8I8aYQbwjxeyFeF%2BJ3QrwmxKtCvCLEy0L8VoiXhHhRiBeEeF6I3wjxayF%2BJcRzQvxSiGeFOCvEL4T4uRA%2FE%2BIZIX4qxNNC%2FESIHwtxRogfCfFDIX4gxGkhnhLiSSGeEOJxIU4J8ZgQjwoxJMRJIU4IcVyIY0IcFSIhxKAQcSGOCPGIEN8X4mEhDgvxPSG%2BK8RDQjwoxCEhHhDifiG%2BI8S3hbhPiHuFuEeIbwlxtxB3CfFNIe4U4qAQ3xDiDiFuF%2BI2IW4V4hYhvi7EzULcJMSNQnxNiANC3CDE9UIMCHGdENcKcY0QVwtxlRBXCnGFEJcLcZkQ%2B4XYJ8SlQuwVYo8Qu4XYJcQlQlwsxE4hdgixXYhtQmwVYosQm4XYJMRGITYIsV6ImBAXCbFOiLVCrBFitRCrhFgpxAoh%2BoVYLkSfEL1CLBNiqRA9QiwRYrEQi4ToFqJLiIVCLBCiU4j5QswTYq4Qc4SYLcQsIWYI0S5EmxCtQrQIMV2IZiGahGgUouEonS0PyVcmMuv8mDMnMl2gy3noskTmRIT289A%2BTpcmMk2I3MtDezjt5rSL0yWJjKlIcnEiowG0k9MOTtv5tW08tJXTFh65OZExDRk2cdrIaQNPsp5TjNNFifQmpFzHaS2nNZxWc1qVSG9EkpU8tIJTP6flnPo49XJaxmkpz9fDQ0s4Lea0iFM3py5OCzkt4NTJaT6neZzmcurgNIfTbE6zOM3kNINTO6e2hK8Vz9DKqSXha0NoOqfmhK8doaaEbwaokVMDp2n82lSeL8qpnuer4zSF02SechKniTx7LacaTtWcqjhN4IVVcqrgpZRzKuNUygsr4VTM8xVxKuQU4VTAKZ9THqdcXnSYUw4vM5tTiFMWLzrIKcDz%2BTllcsrglM7JxyktkTYLxkrl5E2kzUbIw8nNI12cUnikk5ODk51fs3Gy8kgLJzMnE79m5GTgpOfXdJy0nDSJ1Dn4dHUitQOk4qTwSJmHJE6EkTTKaYQlkYZ56HNOn3H6lF%2F7hIc%2B5vQRp39x%2BmfCO98%2FJH2Y8M4D%2FYOH%2Fs7pA07v82t%2F46G%2FcjrH6S%2F82nuc3uWR73B6m9NbnP7Mk%2FyJh%2F7IQ%2F%2FDQ3%2Fg9CanN%2Fi133N6nUf%2BjtNrnF7l9ApP8jIP%2FZbTSwnPQjzKiwnPAtALnJ7nkb%2Fh9GtOv%2BL0HE%2FyS07P8siznH7B6eecfsaTPMPppzzyaU4%2F4fRjTmc4%2FYin%2FCEP%2FYDTaU5P8WtPcnqCRz7O6RSnxzg9ymmIpzzJQyc4Hed0jNPRhLseD51IuBeDBjnFOR3h9Ain73N6mNNhTt9LuOH1pe%2FyUh7i9CC%2FdojTA5zu5%2FQdTt%2FmdB%2Bnezndwwv7Fi%2Flbk538Wvf5HQnp4OcvsEz3MFDt3O6jdOt%2FNotvJSvc7qZX7uJ042cvsbpAKcbeMrreWiA03WcruV0DaerE64%2BPPtVCddy0JWcrki4ViF0OafLEq5OhPYnXBhspH0JVxXoUk57efY9PN9uTrsSrhVIcgnPfjGnnZx2cNrOaRunrbzoLTz7Zk6bEq5%2BlLKRF7aBp1zPKcbpIk7rOK3l%2BdZwWs3vbBXPvpLTCp6yn9NyTn2cejkt47SUP3QPv7MlnBbzh17Ei%2B7mH9TFaSG%2F3QX8gzp5KfM5zeM0l1NHIiWKB5uTSKFmnZ1IoR12ViLlCtDMREoRaAZP0s6pLZGCiYTUykMtnKbzyOZEyqW41pRIuQbUmEjZB2pIpOwHTUs4mkFTOUU51XOqSzgwL5Cm8NDkhL0boUmcJibstB%2FVcqpJ2KcjVJ2wd4GqEvZFoAn8WiWnioS9EJHlPGVZwk4frDRhpw6phFMxz17EP6GQU4QXVsApnxeWxymXU5hTTsJOrZTNKcTLzOJlBnlhAV6Kn1Mmz5fBKZ2Tj1Map9SErQdlehO2pSBPwrYM5Obk4pTCycnJwTPYeQYbj7RysnAyczLxlEae0sAj9Zx0nLScNDylmqdU8UiFk8xJ4kSio9blfooRa79%2F2LrC%2Fzn0Z8CnwCeI%2BxhxHwH%2FAv4JfIj4fwB%2Fx7UPEH4f%2BBvwV%2BAc4v8CvIdr7yL8DvA28BbwZ8tq%2F58sa%2Fx%2FBP4H%2BAPwJuLeAP8eeB34HcKvgV8FXgFeBn5rvsj%2FkrnM%2FyL4BXPM%2F7w57P8N8GvoX5kj%2FueAXwLP4vpZxP3CvN7%2Fc%2BifQT8D%2FVPzOv%2FT5rX%2Bn5jX%2BH9sXu0%2Fg7w%2FQnk%2FBH4AREdP4%2B9TwJPAE6bN%2FsdNW%2FynTFv9j5m2%2BR8FhoCTiD8BHMe1Y7h2FHEJYBCIA0eMl%2FgfMe7yf9%2B4x%2F%2Bwca%2F%2FsPFS%2F%2FeA7wIPAQ8Ch4AHjEX%2B%2B8HfAb6NPPeB7zVe5L8H%2BlvQdwN3QX8TZd2Jsg6irG8g7g7gduA24FbgFuDryHczyrvJMMt%2Fo2G2%2F2uG1f4Dhgf8Nxge9F%2Bl5PivVGr8V0g1%2Fss793dednh%2F577OvZ2XHt7badwrGff69rbv3b338N5X90YdGsOezl2duw%2Fv6rykc2fnxYd3dj4mX01WyVdFJ3fuOLy9U7U9Zfu27cqH26XD26XG7VLpdrw42W7bHtiumLZ1buncenhLJ9kyZ8v%2BLfEtqknxLW9skckWyTA0evroFl9mMzi6Z4vZ1ry5c2PnpsMbOzesWt%2B5Dje4tmZ155rDqztX1azoXHl4RWd%2FzfLOvprezmU1PZ1LD%2Fd0LqlZ1Ln48KLO7pquzoVIv6Bmfmfn4fmd82o6Ouce7uicXTOrcxbiZ9a0d8443N7ZVtPS2Xq4pXN6TXNnEx6epNvSA%2BmKjd7ArHTcCfFJ00p9Ud8bvvd9KuKL%2B077FIc1zZ8m51tTpYbZqdLG1H2pN6YqVu8vvXLUm1%2FYbPX80vN7z988KmfUk1%2FcTNw2d8CtuOizuWfOp8921F3fyLlsAntWvzsUbra6JKvL75Kb%2FuaSriaKFJAkItlAig55jkkuf7PyBKLwsoxI0k1kfqR9SEfmtsd1cxbHpWvjOfPo32jHorjm2jjpXLS4a1CSvtY9KMkN8%2BMp7R2LePiqAwdIxrT2eMa8roRy770Z07rb4%2FupjkaZHqWaIEl3ZOnW7VsjXdEpxP6G%2FX274nrK9kubbLVKVuuoVY5acfNWi98i0z%2BjFiVqKatutpr9Zpn%2BGTUr7qgZMdSUuaY585utRr9R7qw3zjbKUWN9Q3PUWFTa%2FKXnPEqfk39yZNvSrRHIbRH2i1C3tJ0G8YMr%2BN26DWH6D4QwoVe%2B%2BocnQ7plW%2FHDiuHFf3WW%2Fw%2BuSP8f3ON%2F%2BS0OEnSRrqmj8pV4l3kFcDlwGbAf2AdcCuwF9gC7gV3AJcDFwE5gB7Ad2AZsBTYDm4CNwAZgPRADLgLWAWuBNcBqYBWwElgB9APLgT6gF1gGLAV6gCXAYmAR0A10AQuBBUAnMB%2BYB8wFOoA5wGxgFjATmAG0A21AK9ACTAeagSagEWgApgFTgShQD9QBU4DJwCRgIlAL1ADVQBUwAagEKoByoAwoBUqAYqAIKAQiQAGQD%2BQBuUAYyAGygRCQBQSBAOAHMoEMIB3wAWlAKuAFPIAbcAEpgBNwAHbABlgBC2AGTIARMAB6QAdoAQ2gBlRTR%2FFXAWRAAghZISFOGgGGgc%2BBz4BPgU%2BAj4GPgH8B%2FwQ%2BBP4B%2FB34AHgf%2BBvwV%2BAc8BfgPeBd4B3gbeAt4M%2FAn4A%2FAv8D%2FAF4E3gD%2BD3wOvA74DXgVeAV4GXgt8BLwIvAC8DzwG%2BAXwO%2FAp4Dfgk8C5wFfgH8HPgZ8AzwU%2BBp4CfAj4EzwI%2BAHwI%2FAE4DTwFPAk8AjwOngMeAR4Eh4CRwAjgOHAOOAglgEIgDR4BHgO8DDwOHge8B3wUeAh4EDgEPAPcD3wG%2BDdwH3AvcA3wLuBu4C%2FgmcCdwEPgGcAdwO3AbcCtwC%2FB14GbgJuBG4GvAAeAG4HpgALgOuBa4BrgauIqsmLpfuhLqCuBy4DJgP7APuBTYC%2BwBdgO7gEuAi4GdwA5gO7AN2ApsATYDm4CNwAZgPRADLgLWAWuBNcBqYBWwElgB9APLgT6gF1gGLAV6gCXAYmAR0A10AQuBBUAnMB%2BYB8wF5gCzgVnADKAdaANagRZgOtAMNAGNQANZ8V%2Fupv%2Fbb6%2F7v%2F0G%2F8vvj9Bp2djEjN6sd9lSHHjSfouQkVvGH4Aic8g6spXsx7%2BryQFyC3mKvEqWkyugDpJ7ySHyXRInPyDPkJcuyPV%2FGRi5RL2emJSTREOchIx%2BOnpu5BAwpLaMi7kFIacqcD5m1Db61y%2FE%2FXXkllHbyJDGQQwsr1n%2BNUr7hzQ8%2BimGXA0xj1bRsHwNtJV90gfab40cGXnwggeYQzrIIrKYLCE9OIXWh%2BdfQdaQtbDMRSRG1pMNLLQB11ZDr0JoGVLBvTB9PtVGsolsJFvINrKd7MC%2FTdBbkyF6bTMLbyc78e9icgnZRXaTPWRv8u9OFrMHV3ax2Itx5VKyDzVzGbmcKcE85gpyJbkKtXYNuZZchxr76tB1Y6kGyPXkBtTz18iN5Kv0gQuu3ERuIjeTr6M93EpuI7eTb6BdfJPc9YXYO1j8neRb5B60GZrjNsTcw9Tt5A7yOPkJOU4eIUfICWbLftiWW0TYZRWz9CbYYA%2Be%2BYpxd8ytuXPMWpfCGvS5B5LPfTHsd%2Fm4HDuSdqTWuwIpqXUGkvVAS9mbjBGWuAlPxvX556Q2os9w4wXPKXL8p1j6xNROd8FewjLUZrcj7s4vxY5PMV7fTu5GD7wPf6lVqfo2NFf3MD0%2B%2Fltjae9l175D7icPoC4eJFQJ5jGHEPcgeQh9%2B3vkMHkY%2F87r8YpffYR8n9VcnAySBDlKjqEmT5CTZIjF%2F2%2FXjsB3fDHP0WRZibFSHiWPkVNoIU%2BS0%2FA0P8Q%2FEfME4p5Kxp5hqXj4h%2BRH5AxLRa%2F%2BEG3raXion5Gfk1%2BQX5IfI%2FQs%2B%2FtThJ4jvya%2FIS9JZqhfkXfwd5g8p%2F4jsZCpWP4%2Fhtq4iyzFv%2F%2BHP%2Bo04iL3jn48unP0Y6WFrJLmYwL5MGrpGLkBOxMbzn%2B05CcG1R9ICjk2%2Bi9lCThv%2BBX1mpFvj%2F4tuujqq7Zt3bJ508YN62MXrVu7ZvWqlSuWL1vas2Txou6uzvnz5nbMmT1r5oz2ttaW6c1NjQ3Tpkbr66ZMnjSxtqa6akJJcVFhXjgnO5Tl96bYbVaz0aDXaTVqlYL5eWFTqLk3EA%2F3xlXhUEtLEQ2H%2BhDRNy6iNx5AVPOFaeIBmq8Ply5IGUXKVV9IGeUpo2MpJVtgMplcVBhoCgXiZxtDgSFpUUcX9IHGUHcgfo7pmUyrwixgRiAYRI5Ak3dNYyAu9Qaa4s071gw09TYWFUqDRkNDqGGloaiQDBqMkEaoeF5o06CUVycxIec1TRyUic5MPzau5DT1rYjP6ehqavQFg90sjjSwsuKahriWlRVYG8c9k%2BsDg4WnB24YspHlvRHTitCKviVdcaUPmQaUpoGBa%2BL2SDw%2F1BjP3%2FVHLwy4Ml4YamyKR0K4sfa5Yx8gxdU5tlBg4J8ENx869xfc9biYvmSMJsf2T0Iv0kccM1Nc6hOa4N5wh3i%2BYJDey%2FVDUbIcgfj%2Bji4eDpDlvgSJlkS643IvvXJaXHF10iv7xZWx7L0hWLYp1NSb%2FN2xxhvfvzxQVIiaZb85cVUOrgfiSrh3ef8ayn0rB0KNeELYkszvikcbIaJ9SWM2DZaWIH1fLx5iLTVDR1e8JLQpnhKaxq2NCBSS07R2XhfLwmOb4ikNcdLbn8wVL2lCXjSRpgFaMfQGaVmhjq5HScXoG4OVAd%2FRClJJuul9xN0NqJRw00DXilVxf69vBdrnqkCXLxiPdsN83aGuld20lkK2eP4b%2BDj8oAJZLjzbF1KLxHjsuDZHF%2BiSfUo3rS1EBJrxJzRtMi7Y4hoepDU6bXKgS%2FIRkQyfkkxB1QXlIKDkNLQgMxhZG1p8QTRu9vO%2F3JKPPwBuI64buycVbkJ9%2Fp7453zlrfHU9IbyA00rG8fd4AWFIsBuMFnav79PmdoiaQzcgo5WZwt9hqJCGTqAy7q4jOdkUbQWvYE4mRPoCq0MdYfQhqJzumjlUFuz%2Bm2fF6Lbq6y2k61k%2FgUhfr2GX4uTYPv8LhGgO0%2Fx5girV1qtLDydhceCLV%2B43Couw%2B%2BQOQMDKwaJkkObsm9QYkLdcH13fHakOxRfHgkF6X0WFQ7qiCk4v7cBvbcZnjPU3BcK2ALNA31Do%2FuXDwxGowObmnrXTES%2FGAi1rhgIzeuajMpljmCvbxe9Fwdpl9rnT0NRMpk2GJKu7RiMStfOW9T1qI2QwLXzuxIy9pp7p3UPZuNa16MBQqIsVqaxNJImCdAALWkuAjqW3vdolJD97KqKRbBw%2F5BEWBxPhDiJ9A%2FJPM7G0g2G2QdF8d2J%2FiEVvxIVJagQp%2BNx%2B3nqvGRqHa7Y6JXHCAYSbP7hnvkP3wmMGtRRXVQfNclmGSalVZJAzGNIq5fIUZNklnyDKBNPgGi8kh7UR32PspJ41GPSfqSkcftRejKZTGiycQXhI%2FmDd4KST9C5qOuoiaB89hcpptEfuBDvGrQxDDRNgRW0%2Fe3pXjPQ2029B3GjreJXikuhOhKXQ3W4Y40pbgitnBY3hqbR%2BHoaX8%2FjNTReG5oWl9wSKnsITnegNwRHjD7Vhdcd3Wj%2BNtq95ZzA0Ojo%2FK7gWd%2B57iD6%2FBJgUVdcH8FAp85pQ7rpFL2Inh7f399H74N0wpdR19Pa343OLgpEkta4HiXokyUgRTPLQ%2FsbMvWjraFBsvz7EYjv7453R%2BiHdq2ldxQI2OKkJTQxrgnzMtVh%2BkEl3QOOUDntuUgaN%2BRcQ0mPeyPzuniMD0F8GEYU%2BkRaE%2B68P4RL%2Fb0BWB1tZB76Mh8sDLQdImYlfL4qvJLB4EteJPSxlByj2RDXF6NA%2FFJtLEaB%2BNV2wyj04VnommQCfLYtbsQdhceZMpkB1sGlVnov%2BL0GN0%2BT%2FoAW0zFE5oYuhu%2BnN80%2BSovLcXNOax9GN57fiJhQjciMsnQ5NIqWcYbHaumTm2B3uISh0QdDl1AXJ36KCkN09KPtj%2FgeRUcl3QNfjIgvjhQV6r4Ya2bRAwM687%2FPwO2lM48xLQUP0k%2BHNTBtcKy9BZroABtqG5RnIQVYYjzQFsKgJudQYKKjoPsEAyu6aSrc8hzmy0JflQhFjCWiwzQrfMA2ic5KaAjXWQgB%2FA7EV18YXDMWbMblZkwGc4oB9htGxVC%2Fv84Xj6Fl4jJLQmskMBCwhSaG6B88qoLeAPSinsa6BZo%2FWh3tNPv7A13L0dhhnubegeYBfEigvw%2FZaBtMflJ8Q%2BSCItEvJPRDGIRaIb5%2FTqC3O9CLqanU0RUM%2BtAbwYFVffFoqI8OBXPw%2BfidgyEJ1DdAmzjpxof64loMTKv6VoaCGHAQ183syuoHn867DfENDIQG4swRNCMxig%2Bj27VSwu%2BmSKhvJZ1C4%2FMCfStZ3mbcLrMOvT9fUwh9eSXultodz4Vvf5Hl9E%2F%2FQAil9fRGYAn7gGMgUDsAF9yD0UMV7l%2FQi6GKjkgBVtV9PoRg11Ya6kZBPKE%2BhybkXYDezfrIYI8253wM7YvxjRGeWMdKxZ3N7YrPEZlYf6KpNkfisqcGF3GncWkuPBvsT%2F0UjKfOaYV5o2h6Ppo7EJcxvPLqYflbaVa4Bl5hPBti2CDCuhgGSTHaiHFoiQ82%2Fcp4orIQgu16orqPhFSNpE%2F1F%2FKw8jZ5WFaBe8jD6gJgBulXZZGHVV3AAMlSXiBLVJXkoLKcLAL3Kp%2BRHnkzyVHOkAk0Hq8GrgIOalaQgzSsqmHpqO6Vf4Z8QdIhP0KCCN%2Bq3E2y1ENkgrKT5Cv5pFvOJqfwYuQOxUMkvNRqwv21AI8AW4CVwFw4D%2FZCGmzCXtVycJAU4hufduIjfpJBMnHdjO82mrCPlY71pIOkEg8xIJWM9CkkjWSTMMnBdyexa0YC%2BNphiHiJFl7YTbLwnltHckkByScRkody6c9T5CkpJqvky5RUJab8RLVZ9an6as10zY%2B1d%2BhadR%2Fqv2NYbvjIpDXdbQ6ZN1jWWk3Ww7ZFdpP9dsdep9WJU0qufe5M943uP3jMnimetd4i70c4gHqVbx4%2BjYxsVX6NHTkFd1BLZpJZ5I74VZGuxzEez8UNTZSOH3c1NuqKtE9KDXiAAPbbdXgV3xC1qmTzybS0%2BtDJCZoDir11SCo6Vq89gDdJ9cOvDz9bMvz6OUdtyTmp5Hdvvv6m7YNn7bUlFW8%2B%2F2YZThakpJlPxpB1QuhkbIKiORBT7PU0f1Qfq4%2FK2gMxFOKtj6Q9G3m2JPJsBMVESsu6JXvQzpBikbXaFE0oq1iekBuuqqgor5MnVIZDWRaZxVVWVdcpFeWZsoKUPKZOpmFJ%2BfXni5TZwxr50lD9ggp1Zpo1xaxRy%2BleR9HkHNu8xTmTizO0ilajqHXavOppWe2xpqxXtPYMlzvDodM5MtyuDLt2%2BFW15dO%2Fqy2fNahin92qaCYtqc9WvmHQySqNZijTm1owKdi6wOq0qYxOm92t0zrsprzGJcNXu9JpGekuFy9reCbMGRr9VHWpOgX1HiavUbs%2FSrJH3z5mskkzQkNJER4aff%2BYETFGIXBm5P1oGo3KsdG%2FZvbXxP5G86QcernQKM3MDoVzPjQZTd6sjJDBLLlVJmKymeQjoadCvwwpIVPI5MiY6%2BhUd5L6%2BnpHbW1JSU%2BP3VNrh7RX2M6V2yvKSqVID99Cx0EDXzQTRZpyPoyNL3N8OV5R0FgxEZSCystxuzWsxnKVoGJRQlnhcFW1xKvJow0pQdV2nWTL8ftznHrVxuE%2Fr1MMzlB6Ro5V0kkJlTk1NzNQkGZR7ZZ%2BL%2F1wittnUSlak16aNPKM3qxXqS0%2BtyphtOgURWc1HhjejR7YN%2Fq%2ByqTORJtm7floOpkUgUWP2qSZ4PePWhn%2F5aiZ8V8xwaXxbx%2BF2SJPyhXom16pBH02LBUmnPNUp6QCMoGUSsWD%2BgVo4M%2Bfo5BK3mS2sb14Bs16MOjFkbujsaAzPCQVHos5501QDUkFR2MT9KVDUnEihpxo1WciFNQkKRbehitZ69S4kq2VtmNXSiZaLG%2B1KpOs1qVEl%2B1uvfTnN86cd%2Fuv9tWsW9Ts06kVlc6os5TP3jx7wYEV1RP6b1o8c2tHpVVr0CgnbV6HJSU%2F1zf%2F%2Fg%2Fuvu%2FzI0tcgQKfxZnmSEl36nNLcpuu%2FsGe3U%2FsmxouCWvsmQQtEd83V90IP%2BCAD%2FsGbYnRjPqg5PTCXk4bjOVMgaWcDpjJ6YWNnKfkcnimNG7RtKRFGSMd%2BF%2FUomBm0bRTsh0e0SuZEpYO35AUHlTPJ%2FXn6scs%2BDw3ZFlpj2%2FQAjOajsUsHWqaMhFDUpitnrkAaqJgVniCvbKqIogera0slkMhOzWV6sYFD7x%2FaOSvnvx8j5Tz0Nt3dxyv3Pi9q48M7vnellr5zoc%2Be2CuP1d1ea5%2F4XfePrj2%2BJVtn9vr9v8ALQVPruzBkxeSR%2BhzD6blJtsJmLUTxngqMHsqdh02yB2S7VG93hlwBvBwaUOSLmreH5ZOh6XnwlI4rEnFcyTMHbmgQQ1%2FXriyns1b8NglrLvZ%2BGOX09YTZgUYY2hxbgW5zTT7sZi5Q0MLSMRQAjMDisBmYrIBXWgN1oKCdmqYcVLZozKYdcO3UMPIq3RmnVqNPyMaKaFD31HpoWfJks5sUE13%2BBw6biSdw5fi8Nl1I%2Bv0tnSnI82mHSnT2X107Ht49FOpCz7LRfqovU7We2Z7jngUkrQamFmNMawGZlZj12E18hjagmH09EmXNNNgm8ucj1QSOd8AjrJIeBLeR7gHt%2FNOIrukLl1KMNWblaLTu4Ke1GCKLk1n0qrVWpNO9YpQyVptx12m8bt8lLh4U8WZNHZ7jHF7YHZ7YNZUXajUY0RvnesakiLJapNKzorb8x21ztXQS2MVIu6SN0veZV2sDpR2GFc%2FfMaTr0vJ8tJblZ6j7qo9xefUw8yPiNv97D69PZ1bFqOBnUwhh6hlj%2BZarSlJqzJGN2SM2wa%2FT3sYC8OqKbQtZmYaiovLaZct9yJtuRcJy21IVU67bDlNYiOZNXMNxdZcVWpWR2onbZZw%2FZ7aeqkk2Qfhr0WzLIH%2F90UtX8jAfDxyiAenI2wu2ntuyO122cWQS3soHXM9UqbiqQjTLpu0jepSsyvNXJ2WGwq5RtYEpqbLsqxz%2Br1ev0NXmDY3I9efYZcmZlSVl3kltEunP9UdcOimp2D4NGaU58pv1O6d1HJ72%2Bf%2F0JppvZu1qu%2FlZRk8%2Bf7hn1b29%2FaUzD48W34SowOatkkLz9Y%2Fek71tjqIuVYuuZt5trQUaqMU6tZSqFtLoW4thdoIZqyI6gOkFO%2FyFJKZND6YtRkwGzDAbMBg15Er8xQGDANJlfIT1nkh2m7UGCTGu7eesfYzaEW%2Fzj8Ws85T05Rwb2xUOO%2Fe6KwmaSrm3caNBqq32255%2Fdavv3B9Y9utr9964%2FMHmo7nLv7Gpk3fWJYfXnTHls13Ls2Tb7%2F788FlCw%2F9696Dnx5ZtuCBf3x3wxPXz5p%2Fw6nVW05fP3P%2BjY8zXz%2F6qfI0PF46Zpn3UIsMZmuSjwpmj8oYpgGz7sGu41E1tBF57BnUgBnUgBk2k1makRHAtYwhuTxB7DlDkuGoRmPC4xmPujpMdHaRnAryJiaaF%2FV6Gpr6eAzJXTT9sRjLgCY2NuujDSp0QbOCf1ONc%2FzK09Gd37%2F4Fr0zmEr7WEGa5CqYuXb9jPzjkxb2FN7zzVmrm7OVW%2Fru2jB5pFj0ONpktJ76JZcsnL2u0jL8Sd70fjoGYjZmhF2qSCN5grWUTFuxvVqHZ6umz1rNnrWaPns1bS3VaC0n86MI5tfbqeGgGCMtYxgQzAwIZv7FDgMm0ottGC1ObIpK0ahnCp77eLDDk5yHUVP1nKsVc4vy55OuEQMGDJYojtKsx2PIGKQ5T8SSWWm3ZEarFR2T9kulWPmS9dyeTIWOD1r0TKfbLVWGc8NhMYoaNSnZmWnBFKNqp6uobv6krcKuGFWdZVPT2rfOyg1NW1IbqCzKS9lm0Y0MN85Jra%2B4%2BaHG%2Fml%2BuGIduh1cTlnlwvrQ8Mtj9n4k169WzDULNjZMXT17YoolMnlW2cj%2FZGcoV81Y69FqRmYEJ82BB8xCDayB184mVzP7Z2RT4%2BdlS2mUw2lSnkcKm6XCVKnQK6XCpMesmP8yQZuhV8RQEXXQqFRvqjec45%2FrVTv4WOOorbc7JDrZpXO3slLS0yP19PRgmus7OZYMxkQ6akg6Vy1WUf9WVcX9WQWbtmI%2Bq5VPqiypuRnuoNdu0ioj3TrJkZeVHnToVdJWSVqr6GBKf7ZZ0WXSuamkUmO%2Bpkqw2SuG28%2BeUtXTeDp7pb1yyeg5pV75GanAxtS%2F2NMHrNP800qmKUa9p9IEF1VpQ4OqpC2v0kafu3JI%2BihqIbm5ViKZCG2hZGJyXAC%2FTee2jJGBMrPVxCFZF02xe35MKm2V8qTTlRKplCori6cWDEm%2BqPW5LCkrS5XxbnHblNdMM1WkhLox1iLtbO6ytEfM2c5ElvbUlvC%2BXI6mubTHFzUbPVKl58cxWl4WK9AdI1lYeKDM4ox3Y8VtpimvxWi53hI6pUvOZWjRkR7WbOkiAYPFBL5YYI6wYgKd5I0t7upUtBpcWj5NdleUV1Ur9bZ0X5rfMunmjulbO4rqtj20do%2B7bFbtlL7WMpMOI4HWN23Bqsq%2Ba%2BeH7z%2FQuGKav3vO1I1TvCYT3I5pUX1zTvOqqTM2teU0V86Z4MsIZehsqdbUjLRQhrOw89L5ZzxF9fnN86Y1oo4Ooo5eUG%2FG%2BnwKOUHr6Hh9vWQIViU7Ofh9anUw6%2Bw0zKxeNSR9HPW5InSgiQSQIkJrMUI9SYTWW2RINkT1xGWomhBUqbFcUJ8It%2FmabTNqIQfVM%2BloQr2oB34hOU6ft3yP7yTPF6YZsYjmWdU0L8aXmWzeSJ2Dh7oGKTlC54r1xvmR2c7XY2LY0drdMG%2BdrLxQ0X9TT6S1uTkX00IXBmKN1hnwpmJUzmtvaclbfv3CvEdclQuigbpoU27jnoa6rupU6a3tp65stocn5m%2BAE1CpMD9T1zD3gD%2FDf8qvCdlmXRHf3nT5iimOgmnlIwfnLZzcv5vOLxfBxgHlGSy3fkotPJiOmeNp6lrBb1Dr0pnkMZiPsIk4LrAJOqwIZuPy%2BQn66Ls0Aybqxqi5xCJZUt%2FyRw3mFn%2F2kCQfc7Yp75XRrxnozS1lhUOSZlAPQw8%2FH6GLuwjaeXLUPgM%2FQafoUZM%2F9a0YL8BJSzgZc7aVKe%2FFaCHHaSF6WgoWeszkyEZXev9%2BqcdWfqEsLPrOL%2FSUgKzWpk5u7yrpu33lhKmbD3ZHOhonePUa2WG25k7unLhzXzDaM7l2QX3EpDVolW%2FbU%2B3m1JwMR3T30e1XPbVrki0ty2txeh25%2FmBe8OQjC6%2FoimRHQjpnBlpuL6x6F85yhbEifpx5F3%2F9JMnoq6U%2BpdaAdllrgylraWuspY2z9hQO%2FBJSwm1eQls0roPZ%2FIAxMrF4pC6hDdjgDDYba3N9Kgv6vjrhbYODUh21zMQWHhova76YZ%2FKpkJhtwnPAcRhERi%2FNeSzmbbPQvFgF0sx0OsCa79ick06SxvuIcrdnbH6JxdP4JXS1cpfWnp5Cd1%2BmH1zcf8PCvPLlNy%2BbfUVUm%2BKnbVh%2FqGFvYz1aLFrw1OCUaHNuqmiwO2cumHnF4PJtp66c3tQgG8V8c7gJbXX5nmjj5SvRdhvKYN0eWPcgfHcEL7jfZdYtKKmqr9pYpThpb3cGYFWnM1hIJ%2BOF1LqF1OyFzIujzXxyvDFyf0SmGxPHqTeoVCWbOpi1aBZGNjB34ypq72Cw8On9qptU8mmV9JxKUqnSS14Lt3nf7bVsssgW%2FbvprDn3JD04W3cy45f%2FLsKbNvW7GAJRAVmqwqdjO1gZ4ZLX4EEs3ndjxGLDN10US7r%2B3RjKopsXdOnJ%2FDZbgUrYhQuOa8FwKeO3NGRXbhWrC61yMDd1OJHZvKkjuqK1xKQ1ahRZ0RqrFmyObnxwy8TJm%2B%2FtX3dbb9Eh5ZKdU5bUZWFFkBtsv3hBsSvNpbWkOsxOq8mY6nXW7Rrate3Ry5oat36zy3n5rcUzVlZTj5GDM4RXqy8mk8m11PYJt426CuYifEmPTJl5Ygg2HwMzF409hk8SpQXYM3su6rDZsaVmOFc1PS18rrQlMMPWQpdH58rpRkXkTMUHdMZwJlJBd3ui9irDuRhSlobPxZJp2RSs%2FIKpPGuJLjZiwVbj5rEY5sToxtZFKvlqzA80Wldmvi%2BnMmB5RmfUqx3WZ3TwtN6AU7fPZlPBc%2B4LtaxvC03LNmHeYHV6LGq9Ue%2Bt6Ji4XGtPc2YHPn%2BPTjHovpDiCmQ70%2BzanqXXLMg3W01OrN0VMmHkFuU65aekDvu8yyQ3a6kuR9F02uun69A4pwdsTmnG9Ir6odGP6foSzPo7%2BI0T9FK9djZk1Gx1SDNm%2B1TWUqVCq6WtFc4BNj0dNUMUVWh9Pm1FkYrWQ7QSDZd00Y%2FoCtiQrasgJ2oE51hLtUpN2yumeW%2B7XL01yjuTWwoC016uaVv8cmA2Wz9hDnaOTg7OvciHvkjFWVoBHkze6PTNjhHNdjaC34j4Q2sml5VranslZnK55r0do4VPVt6J0eJrpr0cq2kLLH45ho9I7qfUoyB0BNtPxkZI1BTWsmygDOdq4KTdnuScWWwnV2OSgn1m%2BpdWn9sTLKcT6bFJSZ3sxLQ614KJNh9Dr3NaLwull%2Ffsn1Xd73N4pla917BpbnHlRYc2rz%2B4vNAWLAuUlZTn%2BLMrl1w2I3%2B6X7LZ7SMjK3tKp5d4Vi4uaynxzFvW8U4g36u%2Fckf7yjqfsi3kz15YMuvieYUZbkdxZqhYNsjBKd2T6jZ1luVEuyuDdTUVqakzCqf0hnN6ps3cNb9IrwuOfLBkdaCmNa97lb%2B6ZXjpxHpZl1qUn%2Bea2pBRWkd70kGsDO%2FF%2FKacr5SP1VdKBc5kTwHzLgTBuhCNoMOrk05uPJlGOogYqV8zUg9nZM7NSK8ZSBSXSGZBKtYumpNFbdnNqTPYoEDnNJjSJLd%2F%2BJSGjQhHC1KLaGLMZsaS0%2B5FfQ%2BbKGJVSAcBO5sbarTjFojJbQY7n6%2B7lHt1Dj5Z8Ra3ltbtaUQwFT1KK%2BYw029qXbR7RjBV9BzZOnNpY3ZX5%2FD1Imb8xKW9dcqq6%2FrofP0q7IN1qEuwDxYkD9KedLI%2BNDu0MaS4qcFgBjCzEws7WZh1E4RZn2LxsJT7FN5f4U0Rt%2BaXN6SSZseu08cnDP4o%2BhG%2BLF13LNXWymz44rlIclBNjqnMow%2Bm0kTHYzwVTPeTsS1DbrekmZz0RQpty2jEUt0XbeMsnDQxQjFmHeVKutMGV6SVSicW5NcCvN1IdWg3LtLBLYE9wY1sT5C1ELgBZgk6c6M77GDelAie6ZjB1sweJPkUdDw6yqJw1xfWdfKev3yfY7d3vqr4Xamfw6g8R8pkns7nsOHD2f512GY0STNyvfTvprlS87g2ze6QtW14MsawOJhNJVlbz8x0o3ozM8sNtMkbaJM30CZvYE3egBHl5JyoXZo5pw4zUfbg42akbJcAYdYEGCN77il8s6qc2DB7bG%2FD1FITNU9tq2suqmktmjHWVbADMH5TqTa5M4AXa8ktAtpz2PdBfYPttPMci7W3TWWlWWIXFse6Ei0Pg%2FmFJqZLrQu605cikpXgSq6H%2BZrBpX6OdzOnLqWwsbh2axMduDxBp9Zd2FBcu22s12kc6R53hk0748bWmu7GUltRR%2Fv07IU7Wv1jVSiHar%2FQ%2F74co1yJ4VFR9Ebdzs7ZaSVT88oaC5zomDOED0Otl5MhVutWXuu06pPujFXBuJpNerExr5ZsAXTJlmmks2Lu1egIxp0c829ouieTjo15KkNRW0FqdquoLjp2jXm2SHLPK1lDvkHu3IxwbmN5aJ0g03%2BqjwvN%2F9XubczQd8z8D%2B7tAmPCiL3Uu9FV2OuwIt01fYbZMb0%2BX8pzSPl2ugMTNklhnRTWSgWKlC9LbCcUhgKzdg1mSzXwhTuldNqaWWKQDOO2YOkMedwW7GP4uiLB%2BwErmbkJ1YntUilhbcPuoJxcCNOVWbLFiyUaGr748bEdVgk7rG10h1UeWwFTw4rF71ftsCqvT9z6%2FS0bH9hQVbv14a3g6kd8detmt65tDPrq181uWdcYkP604dGr26ddemwLuA28p%2FXy5bWVyy6f2XZ5X23l0sup9Q6O3Kq8AOvRfYJBaj26TxCsoi9u6cgAZm6BhtmsFII1OvgODKUuvkXANgu8dKOH7xb82z2CVtvsr9wj%2BN%2B3CJDzP20RfHlUdX31FsHXl%2BY1To1miyEE7S%2FF5XNo82fM7ChaPkC3CCrYFkFzbuOuhrru6jTpnR2PXzHdllUZGqkTOwOqd9Cn8RrXqL%2BkoC7fNePKI9ubLlsx2ZnfUDZyJ45nrtgD6%2FbCunclrXuSe3aY12%2BMUE8coT6ZG4y54whdxxbgCDJriBXJBgpm%2FhzM1rWM4SEq2DrWldNqnBLxq2zFdB2b1lZD17G2mWrMSv%2F9OpZugNFlrMiXRjMei6W12WjWYzGWFx38%2FDJ2rCXa2Q7j2J6%2FRzRN15fXsXo6kfGnaPPbWlpzqUnL%2B29eltfcNL2AnitISbdrv7SWHTkmLCudza8NWcV61p4zKX%2B9MPXIP%2FmClm%2FGYEHLvaj8IGxcwXdij22aIIWtyWYLZq0VzJsvFbRdW2nzdZAoJjuEDoCEdm2ShladE9VH2sJWV6DVRTcD2FAmldCdFbYiZeYbjLCEhtj5lDAZG6XGdV06xf430z3eMDXyg7JGr9N5MrJdqaUTJobGtUY27ORMnVibYQ5mZ5hUiqQsd2fa9Xq9LqV4RvVwXEz3zvvDK6oac62KzmDQW3ywScfoOflZ2KRVsrFWZyppr2%2Bf3b6v%2FUi7emrSBGDWtVkYAwb49FHYg4XhIRmjoU0dkl6L%2BrPLs8tNPtpsfbTZ%2Buhg46MjlY8ONr7H8D1ruMKoAQFiiiLeRJdYYZRXbzpikk3Fv6s2vGefY%2B%2B1b7Ir1fZqu3vyq1N96vw299t80xDWO2dnJzts52zMcUbGvVAoSW7G8D2AnOri38XshvdixG6zB%2Bw4psFKzJ%2F8aoyVqXa%2FLXYTUWyEFUv3A8Y5VpVovfxMTrEmGf7i4QaN%2FGzF0stnlS5sKnUbVBqj1hipX1BT0Fjuy43O6eyI5ubP3T03u2VivkurKApONOizqlpLCqL5rrzo3M550VzJ0hRDe%2FKkpmT7nXg77Qv4HKGqnHBlnj8rUrdg8oS%2B1kKTw2UzWd02e6pN6051O0Ol6bkT8gJZBZPxH7%2BXSHD0b%2FJ61ffJRHIdrc1j%2BcQeKkq6B8aoFTCrTTBzE4xRDUW0oZs85qJzoZYM8zlPSxm6%2BaCW7dOeO0sH%2Fgq%2BzVV%2B9gzbOkTR52JI64l6zOdinhYtzZCIIQdbhKbZzoqBX8XXNV%2FcH5Bd43cR2JqS7irI63W2QH6xp3lFNONSq4O%2B2d8rFjZv0a1vh%2FWt6ume7PQUnVqvVi3OyLJZ9JocvMWRLXyD4EXxqvRFvoUwYuhZpjfo1RYvbHQr3TVUHh%2BbS%2FkxgzLm0vaaS9trLn0rk8vcbC5ttTip8MkJ3vP9yf4AZhYEf8yGOiroGoAmEBHv8wjpk6jeWdSaa1SntmLaqj6%2FdUidgNg5HGvA3OfqkxksNMf4DUOaZ%2FzcVuwXjm0U2tnOVVX1WAR2Ch0ZLk%2BGXTPzdjZp0qbwzRdPSUtp3e4m7Bhi5ejQj01Vd3bOmrz6uuVylpiNDn84e1lDTlenvF3E0JaGt1rKblixUDLTlvYojn1hlKdLD7%2BO%2Fs3xS5lcZEpscQjzsPMJYPGC35n0uY4k433i%2B9FqJKjGfMwu5dqkPLWUlYeIKVlSdpYUpBIneLKDUoDFBqTsgJRrlXYEpSDd7NLbXS3BADwJQm9H9XA8QbpLSUN04wf8ftSEMoJ5rUFjWquRu23UAt0Ei5BID5tzReibs54IfYOWPCdG36RFfMdJULKp2QcZ8UFjZTCHXh%2BB20i6dK3Ex79c6fx7b4%2FTU%2B3kEw1ltyQr8shZlTktLzMzL9WiGnlWpaanAjwZIRwVG1Epn8nYd%2FZ5Mu1a5R6V3mDSfv5d%2BlpNpbMYlIUmh17BWlXGH%2F1wmskk%2F1mPrTNZZ6T1MmH0U%2FWVqJcmqYzXy3Q41ykwAl5U4J1ujVRNOadYCgelcEAK%2B6VwphTOkHLTpTyVlK9IEydJkyZKk4qkyYWSLYADLfiPKLElPmW8aEBEACXYMD6yaMpRvJWcaaXR1qmtLB01e71ttm2jbZ9NZYs63C22itac1ok3FUqF9Foh9fk2p7tldeHOQrkJsZ4ZelodL1Cb95yprz8Lm%2FOaOf9Sk7%2FW5LNiViXRjKmtVpvfRj9KZeKfE2UfNKdQUtiHOPAh4cKqQllGU1Xxj0GNvYDq6okso5%2BUdhav%2FWi3wtbvWNUpuVp2po%2BfAxFDwLhaHCfVV6rUIx8pZk9epr8g1aQ8IctHFHNafqY%2FF6GRT9QqLBw96VkOnfKyjP%2FNh96BPoeTIfJLsvSijJfRaV6cwVTu0aZYz9ezfECvH956vtatKVq9EZWuxfGbNL0elW7GOIKNi2GvCMk6A1pAPnpmO1pACbmft4AymNqOVl9CPVsx9WmTiiW8TX7%2FBGSlV%2FIkvRftpizKLelpTynAZULzTCZSTUiqMkrGALqUkdaz0VhWmt8aMtozWu3JbS%2Fqm%2BhbaLb9zt5AoyLpL%2B1EvqhxfHJUAJsEUV%2FmThGnKM8fojz%2FatrJOg19My0pDTpnrj8z5DKqfvuSyujKwllKu6SXvCMf6SRnbiAjlGJQnX1OZbD7fRk5Dlk%2F8kmhxWlSY1NeK60c%2BSZIUZucFumk9KDFaVYpGoN2ZFCaDVJUxhTryFJYrxtLnZfw3waIkF5uPRss4aZv3cPspEQJ0lTqG%2FWyPseOldjR1BYrRggs49qxu4tN9XIs5M7Cj%2BCREzmpNAn2q1qsapoIy7Z2OjZiO71ceHF6xIq2rmqJKrrxyo66sbMMEpXySxqdRTf8ostHe790YGSfzUnPYMkqI97Q07iR7dIhHHvTNDt9dm16MMvidqfa5HXBHBx202osbnvA4vWk2YZv19p8WGmcGv1IOqDcxlbB5fQZBwmOCO0%2BacgMYZ1vxeuBs%2FVn6WBPB%2FkTNC6KSC%2B6Sf3ZpIMTO8Z0tB5bTyUP%2BEgH9Kl5%2FkAeWqU3L%2BDPS9V%2FMawEAoU%2Bo9FXGMgqolw0nBfkEfgCEPxZWhFsfAfucgN5A2fQ8%2Fk94rTO6RNoeRq9Aj%2BBG4z8APd3VB9FkN4cM%2Bi4fb8NJXWTiynWTy8pbgKod5RG3lIM6iexp%2BdhpdrUpKQExQxCeEtKUIaHu25xjlf7kMqckuFKDTpUGrlHZXZmuvB%2BTaX%2BwGzVqbRmp1mz22zVoxummGn5TdIxuVieglPyAVr%2BMaI1nsOBA8ydzuJTjqmM52L0nACfXeI9E9%2BlZNVc7LCPLHXgR%2Fo2KlMtfZKb6Q%2BHMzX2NNRZC%2BYsT7PzXhHJSEuOpn5h2yJHbFtgOnc6mmOVZ%2FYWSeM2JOjuXgqd36TQY00pXqpOyUV4Gxrg08AAujw9QQxmq0gwm%2BSA36Z2x0iL737gPLuBHiWLEoUu9KN65CgxzDbIhI2%2FCGHDEP%2BdKnoTBioMBF%2BJxgspA46R0XNR4hjZ%2BfMqcBC2N6mPwAsPPsGkU3eMwPwHex80OztdRgv4T6fLMNccO2usUp4uWR%2B%2FbNeDqyKlsfj%2B3eC4xReZPLO0c90Ud%2BbUlS01nVPQUuWB2%2F412Lfwux%2Fde%2BtHjB%2Fuu3NHZ3XqnBsej9388%2F0TsxuWbrmK1u8jeOF0j9pDiiUTq4Xs7EwpO0PKTpdCPik7TcpOlcJeKeyR8tmmkoP6y1JqCzOtkFKJUOOTfDofwRUwMzlj1A%2BYmRzMppP59GCaJdNLM3mN9K8RU6Q3aC2Bnz%2BKMsGnaVHj4k%2FTqQ7CqBzkuBffL3A6hqT6o6G5%2BdjT0%2FLzn%2BX1w%2BhC3OqRs%2FQFIHsRGPkxsz3hk59kBRx1RkO0hOMxFKGhZYiDonBkyY6HVxdBugfPHBi8joa%2FRarOSW6l4jgzvrtwj8Zg1g4v0ZqMGg3O6EqWT%2BnrPkVj1EsFKpPD68BMVPOuzqJXN9IFkNaWhmO6dr3y29sMKnOmx%2B61mTRPKSq80sXb1s9u1KNvSPivmhCcAgji%2Fd%2FPWJ2Y86ukSKaUn0FnNlFqfA81flRy06MpbubF3dSYbjTnExU5%2BEdqkzVS%2Bxj%2BO3FGmBQmNNKJjRG2NtpragOBWrTC4hMVbk3xPBt2p%2FKEHTHsYTVKzz7TaeSbkbN0ZcoaMm3FhB3E8p3kRRTTMvD6h5eiocWcNyVKwPqTFnR%2BbOAWpRPHLxwY0vDXr%2FRbI9R7wAB6q354gsVl1SoGq%2BmzhWtrHekT5lSy40IY%2BFQ4b%2B%2Bd1H3RpKUHeord06%2FeeFauwPcK1G0OHJ7X2jLdKZkej1kyLPn6xcsjkZkTs7LysnSOTBeWmhZXdsg7YcmuprrdNx7Z8qLewU5Mr4RHuh3nCuvI59zqudVSbhXbQFWY1U9wo1cnLQvGNz4wjFbTs9J5qJU81EZeFPbNs8wu31i%2Br1wp%2F%2FdHMB%2FDSVSCHoMCqV%2BhpwmwHQN1ku4jOp1enEQqjJoKJ34YyMJJL3VhB07bG5OOBm9Be7BLQF8oSLYXaQXh50zP888zyauI1pHvGAoqZCXZY1kTP6RnvIwKK01NixtzOyiQbRCgxPOjodgTYF9w4FXFhiJxpt9FDxUk394ptzfvH4xNjs2vsuJ7OtgW1BoKpq9tadjUUZzbsWfBlK5wutefIU%2FRWQ3qFMdIRqi1dOOhjbXSvWu%2BvXGiPdVrMdnTHHacY8dxrkDj6ra6ZfV%2BU1qObA0G9KjN7LyR29TyhL4Bumc7F7V0CH2jlEwjb7F6cuYXSwVqKZ%2FN9Qtwpt8gNdI%2BEqDV1SiV6eCHyvj5111lUm1Za9naMiVSJpUNyYU4zWWxBPCfEqLeHm6I1ccbx2h9TKIuC1nB7%2FNDitsnSVWTmietmqRkY0UxJEeilpIc7Jn9PRDQVn1YMA9G1Q1q%2BZli%2BjqcHk2Cw6HvwiPsEB4C5ewkBzoR60XYjLQGon%2BPoYCCqg9jBfO0tAxsOCRPG9MX3rxK6H7DBds21ePO3omTtmPDRJVyKKW0Y%2Fd3N0U6pham6OGOdMa8KXMr%2Bq7vKpQn3Nobu6U7t3zd%2FVs69i6J5tqPZE3rrZ%2B6ZFJ6as2iae03yI%2FNf%2Fie69dMMtocDn%2BaO82itjqs7ZceWuIvnbTqhnkLvrmjOX%2Fm%2BoH7mvcfiZWWzF4xYdLyxhw67OJHwjdT8G1z%2FGgIvjU4lf40RBr6YmuXb1n7fwCxnfOHCmVuZHN0cmVhbQplbmRvYmoKMTcgMCBvYmoKPDwgL1RpdGxlIChNaWNyb3NvZnQgV29yZCAtIGR0TGljZW5jZS5kb2N4KSAvUHJvZHVjZXIgKG1hY09TIFZlcnNpb24gMTEuNi42IFwoQnVpbGQgMjBHNjI0XCkgUXVhcnR6IFBERkNvbnRleHQpCi9DcmVhdG9yIChXb3JkKSAvQ3JlYXRpb25EYXRlIChEOjIwMjIwOTEyMTI1MDE3WjAwJzAwJykgL01vZERhdGUgKEQ6MjAyMjA5MTIxMjUwMTdaMDAnMDAnKQo%2BPgplbmRvYmoKeHJlZgowIDE4CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDgyMiAwMDAwMCBuIAowMDAwMTg2ODg0IDAwMDAwIG4gCjAwMDAwMDAwMjIgMDAwMDAgbiAKMDAwMDAwMDkyNiAwMDAwMCBuIAowMDAwMTg2ODQ4IDAwMDAwIG4gCjAwMDAwMDAwMDAgMDAwMDAgbiAKMDAwMDE4NzAzMSAwMDAwMCBuIAowMDAwMDAxMTEzIDAwMDAwIG4gCjAwMDAxODQwMjkgMDAwMDAgbiAKMDAwMDE4NDA4MiAwMDAwMCBuIAowMDAwMTc3NTgzIDAwMDAwIG4gCjAwMDAxODQxMzUgMDAwMDAgbiAKMDAwMDE4Njk2NyAwMDAwMCBuIAowMDAwMTg3OTIzIDAwMDAwIG4gCjAwMDAxODczNjUgMDAwMDAgbiAKMDAwMDE4ODE1OSAwMDAwMCBuIAowMDAwMjA0MzMwIDAwMDAwIG4gCnRyYWlsZXIKPDwgL1NpemUgMTggL1Jvb3QgMTMgMCBSIC9JbmZvIDE3IDAgUiAvSUQgWyA8ODQzYTlhMWJkNTcyMDczZjg5MDYwZTJmOGZiNDgxNmY%2BCjw4NDNhOWExYmQ1NzIwNzNmODkwNjBlMmY4ZmI0ODE2Zj4gXSA%2BPgpzdGFydHhyZWYKMjA0NTUyCiUlRU9GCg%3D%3D\"\r\n }\r\n ]\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}GenerateOrderSSL", + "host": [ + "{{baseURL}}GenerateOrderSSL" + ] + }, + "description": "This API facilitates to generate new order for all emSign - SSL / TLS - OV Wildcard - UCC certificates.\n\n**Prerequisites**\n\neMudhra provides following values for generating SSL OV Orders. It is highly recommended to keep these values configurable, as they change for sandbox environment and Live environment.\n\n- Generate SSL Order Base URL Endpoint\n \n- Product Codes\n \n\n| **Region** | **URL** |\n| --- | --- |\n| US | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n| Global | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n\n**API Endpoint**\n\n| Header | Header |\n| --- | --- |\n| URL | http(s)://(url host port and path)/GenerateOrderSSL |\n| Protocol | HTTP/ HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n\"meta\": {\n \"ver\":\"\",\n \"ts\":\"\",\n \"txn\":\"\",\n \"accountNumber\":\"\",\n \"authKey\":\"\" \n },\n\"orderDetails\": {\n \"productCode\": \"\",\n \"accountingModel\": \"\",\n \"saveAndHold\": \"\",\n \"requestNumber\": \"\",\n \"emailNotifications\": \"\",\n \"groupNumber \": \"\",\n \"requestorInformation\": {\n \"requestorName\": \"\",\n \"requestorIsdCode\": \"\",\n \"requestorMobileNumber\": \"\",\n \"requestorEmail\": \"\",\n \"requestorDesignation\": \"\"\n },\n \"delegationInformation\": {\n \"contactName\": \"\",\n \"email\": \"\"\n },\n \"organizationDetails\": {\n \"preVetting\": \"\",\n \"organizationNumber\": \"\",\n \"prevettingToken\": \"\",\n \"representativeNumber\": \"\"\n },\n \"certificateInformation\": {\n \"organizationName\":\"\",\n \"organizationUnit\":\"\",\n \"streetAddress1\":\"\",\n \"streetAddress2\":\"\",\n \"locality\":\"\",\n \"state\":\"\",\n \"countryCode\":\"\",\n \"postalCode\":\"\",\n \"domainName\":\"\",\n \"additionalDomains\": [\n \"\",\n \"\",\n ],\n \"autoSecureWWW\": \"\"\n },\n \"agreementDetails\": {\n \"acceptAgreement\": \"\", \n \"signerName\": \"\", \n \"signerPlace\": \"\",\n \"signerIP\": \"\"\n },\n \"subscriptionDetails\": {\n \"validity\": \"\", \n \"autoRenew\": \"\", \n \"renewCriteria\": \"\"\n },\n \"csr\":\"\",\n \"numberOfDocuments\":\"\",\n \"documentsAttached\": [\n {\n \"id\":\"\",\n \"fileName\":\"\",\n \"description\":\"\",\n \"base64Value\":\"\"\n },\n {\n \"id\":\"\",\n \"fileName\":\"\",\n \"description\":\"\",\n \"base64Value\":\"\"\n }\n ],\n \"additionalInformation\": {\n \"tags\":[\n \"(Tag Name:Tag Value)\",\n \"(Tag Name:Tag Value)\"\n ],\n \"customFields\":[\n {\n \"fieldID\": \"\",\n \"fieldValue\": \"\"\n },\n {\n \"fieldID\": \"\",\n \"fieldValue\": \"\"\n }\n ],\n \"remarks\": \"\",\n \"recipientEmail\": \"\",\n \"technicalPointOfContact\": {\n \"pocName\":\"\",\n \"pocEmail\":\"\",\n \"pocIsdCode\":\"\",\n \"pocMobileNumber\":\"\",\n \"pocDesignation\":\"\"\n }\n }\n}\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order.
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | 1234567890 (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | 6288275133 (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | 952a95057654aa1c26a0f287b6368579f5f3ec99f213543df5e0f7eb7f798b3d (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| productCode | 841 (mandatory)
Unique Product Code of the respective product. Product Code values are provided by eMudhra. |\n| accountingModel | 1 (optional)
Accounting Model of the Account.
The allowed value for this field is: 1 - Cash Model. The default value is '1' (in case if it is not set). |\n| saveAndHold | 0 (mandatory)
Should be set to any of the numeric values.
1 - Only Request ID will be generated and request will be pending for payment.
0 - Both Request ID & Order ID will be generated. |\n| requestNumber | 8785645678 (mandatory)
Request Number of the existing request which was saved as a draft.
This field is 'mandatory' only for the requests (Request ID is available & Order ID is yet to generate). |\n| emailNotifications | 1 (mandatory)
Can contain one of the numeric values as under. Default is '1'.
0 - Pre-vetting Organization (Organization & Subscriber Agreement info.) re-use consent notification will be sent to the applicant on order initiation, if \"preVetting\" value is set to '1'.
1 - All Order Notifications will be sent to the applicant on order initiation. |\n| groupNumber | 3589463147 (optional)
Group Number of the group. This is auto generated by the system. Groups can be your cost centers, departments, etc. within your Business.
It acts as a feature / tool for managing account user access to organizations, domains and finances. If group number is not given in the request, then system will consider the default group number and amount will be deducted from the account balance. If group is having its own balance, then amount will be deducted from the group balance else the amount will be deducted from the account balance. |\n| requestorInformation | (mandatory)
Specified below. |\n| delegationInformation | (mandatory)
Specified below. |\n| organizationDetails | (optional)
Specified below. |\n| certificateInformation | (mandatory)
Specified below. |\n| agreementDetails | (conditional mandatory)
Signer details of the respective order to complete the Subscriber Agreement automatically. This is mandatory, if \"preVetting\" value is set to '0'. Specified Below. |\n| subscriptionDetails | (conditional mandatory)
Subscription Details of the order. |\n| csr | Optional |\n| numberOfDocuments | (optional)
Number of Documents attached. |\n| documentsAttached | (optional)
Uploading documents during order creation is recommended for sending us any additional / supporting documents so that they are automatically associated with your certificate order. This additional documentation would help emSign to speed up the certificate validation process. (E.g., incorporation letter, registration document, etc.). Specified below. |\n| additionalInformation | (optional)
Specified below. |\n\n**Requestor Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestorName | John Doe (mandatory)
Name of the requestor / Org. Representative. |\n| requestorIsdCode | +1 (mandatory)
ISD Code of the requestor's / Org. Representative's mobile number. |\n| requestorMobileNumber | 8978945116 (mandatory)
Mobile number of the requestor / Org. Representative. |\n| requestorEmail | [johndoe@example.com](https://johndoe@example.com) (mandatory)
Email of the requestor / Org. Representative. |\n| requestorDesignation | PM (optional)
Designation of the requestor / Org. Representative. |\n\n**Delegation Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| contactName | John Doe (mandatory)
Contact Name of the delegated person. |\n| email | [johndoe@example.com](https://johndoe@example.com) (mandatory)
Email ID of the delegated person. |\n\n**Organization Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| preVetting | 1 (mandatory)
This can be set to any of the numeric values as under. Default is '0'.
0 - No (New Organization)
1 - Yes (Pre-verified Domain of pre-vetted Organization). You are allowed to use Pre-verified EV Organizations in OV Orders as well. |\n| organizationNumber | 2943849 (conditional mandatory)
Organization Number (Org. ID) of the existing organization associated to the respective emSign account of the certificate requester.
This is mandatory, if \"preVetting\" value is set to '1'. |\n| prevettingToken | 0947CBA3C2DF42B0B303590541C8E5B1 (optional)
Please specify the unique pre-vetted token value associated to the respective organization. Pre-vetting Organization re-use consent email notification will not be sent on order initiation, if the organization re-use token is submitted with your certificate order.
To get the prevetting token, please contact your Account administrator. |\n| representativeNumber | 21023 (optional)
Organization Representative Number of the existing Organization representative. It will consider default representative details of the respective organization (in case if it is not set). |\n\n**Certificate Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| organizationName | emudhra (mandatory)
Organization Name of Organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| organizationUnit | Bangalore Branch (optional)
Organization Unit of the respective Organization. This field is not required, if \"preVetting\" value is set to '1'. |\n| streetAddress1 | KIADB (mandatory)
Street Address 1 of an organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| streetAddress2 | Near Airport (optional)
Street Address 2 of Organization. This field is not required, if \"preVetting\" value is set to '1'. |\n| locality | 3rd Cross (mandatory)
Locality of the respective Organization. This field is not required, if \"preVetting\" value is set to '1'. |\n| state | Karnataka (mandatory)
State of an organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| countryCode | IN (mandatory)
Country Code of Organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| postalCode | 560064 (mandatory)
Postal code of the Organization. This field is not required, if \"preVetting\" value is set to '1'. |\n| domainName | emudhra.com (mandatory)
Domain Name of the website / server. If the pre-verified domain under the pre-vetted organization (if \"preVetting\" value is set to '1') is provided then fresh DCV is not required. |\n| additionalDomainNames | support.emudhra.com (mandatory)
Additional domain names of the website / server. If any new Sub domain of pre-verified base domain is provided under the pre-vetted organization (if \"preVetting\" value is set to '1') then fresh DCV is not required. This field is required only for SSL multi-domain / UCC products as specified below.
SSL / TLS - OV UCC
SSL / TLS - OV Wild card UCC |\n| autoSecureWWW | 1 (mandatory)
This is set to '1' by default.
1 - Enabled (Secure 'www' variant of website)
0 - Disabled (Doesn't secure 'www' variant of website)
If user has chosen to secure website with both www and without www variants, then File-based (HTTP / HTTPs URL) DCV method is not allowed to verify DCV.
This is not applicable for emSign - SSL / TLS - OV Wildcard products. |\n\n**Subscriber Agreement Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| acceptAgreement | 1 (mandatory)
This can be set to any of the numeric values as under.
1 - Accept Subscriber Agreement
0 - Reject Subscriber Agreement |\n| signerName | Sipra (conditional mandatory)
Name of the Signer.
It is mandatory, if 'acceptAgreement' is 1. |\n| signerPlace | GOA (conditional mandatory)
Place of the Signer.
It is mandatory, if 'acceptAgreement' is 1. |\n| signerIP | 10.24.108.199.182 (conditional mandatory)
IP Address of the Signer.
It is mandatory, if 'acceptAgreement' is 1. |\n\n**Subscription Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| validity | 1 (optional)
Validity of the Subscription (1 / 2 / 3 Years).
Default value is '1' Year.
emSign is providing subscription validity upto 3 years where maximum Lifetime of 390 days per certificate is available with free renewals. |\n| autoRenew | 1 (conditional mandatory)
Auto-renew certificates until coverage.
Default value is '1'.
1: Allow Renewal of Certificate
0: Decline Renewal of Certificate |\n| renewCriteria | 30 (conditional mandatory)
Time duration to for renew certificate before expiry.
Default value is '30' Days.
30: Automatically reissue before 30 days of certificate expiry.
60: Automatically reissue before 60 days of certificate expiry. |\n\n**Document Attached**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| id | (mandatory)
ID of the document. |\n| fileName | ID Proof (mandatory)
File Name of the document. |\n| description | Driving License (mandatory)
Description of the document. |\n| base64Value | (mandatory)
Base 64 encoded value of the document. |\n\n**Additional Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| tags | (optional)
Reporting tags are labels with different values, which can be associated with your certificate orders in CERTInext. These tags can then be used to filter reports, giving you a categorized insight into your business.
Tag Name and Tag Value must be colon separated values.
e.g.: Department:Technology
Multiple tag names and tag values can be associated with the order.
e.g.: Department:Technology,
Department:Legal,
Branch Location:India
Specified below. |\n| customFields | (conditional mandatory)
CERTInext allows you to use custom fields in your certificate order form. The Custom Field can be used to simplify record-keeping and makes order management efficient.
To enable Custom Fields feature for your CERTInext account, please contact your Account Manager.
Specified below. |\n| remarks | (optional)
Additional Information related to the order. |\n| recipientEmail | (optional)
Email recipients can be provided for receiving notifications related to Order Confirmation, Revocation and Renewal of certificates. |\n| technicalPointOfContact | (optional)
Technical Point of Contact will be notified for emails which are technical in nature such as Order Confirmation with order status tracking link, CSR & Certificate Download notification based on the email notifications configuration set by your account administrator.
Specified below. |\n\n**Tags**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| Tag Name | Dept (mandatory)
Reporting Tag Name. |\n| Tag Value | Admin (mandatory)
Reporting Tag Value. |\n\n**Custom Fields**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| fieldID | 456 (mandatory)
Specify the Custom Field ID. This is a unique field identifier of a custom field.
This is mandatory, if Custom Fields are mandated by your account administrator.
This information will be available within CERTInext online portal. |\n| fieldValue | Dept (mandatory)
Specify the Custom Field value(s). You have to provide the right input based on the custom field type set by your account administrator.
NOTE: The allowed date format for date picker based Custom Field is: YYYY-MM-DD
This is mandatory, if Custom Fields are mandated by your account administrator.
The detailed Custom Field information will be available within CERTInext online portal. |\n\n**Technical Point of Contact**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| pocName | John Doe(mandatory)
Name of the Technical Point of Contact. |\n| pocEmail | [johndoe@example.com](https://johndoe@example.com) (mandatory)
Email ID of the Technical Point of Contact. |\n| pocIsdCode | +1 (optional)
ISD Code of the Technical Point of Contact's Mobile Number. |\n| pocMobileNumber | 9676462551 (optional)
Mobile number of the Technical Point of Contact. |\n| pocDesignation | Senior Developer (optional)
Designation of the Technical Point of Contact. |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n },\n \"orderDetails\": { \n \"requestNumber\":\"\",\n \"orderNumber\": \"\",\n \"trackingUrl\": \"\"\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order.
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | 1234567890 (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | 1 (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestNumber | 6725625162 (conditional mandatory)
Unique Request ID of the respective request. This is 'Mandatory', if 'saveAndHold' is set to '1'. |\n| orderNumber | (conditional mandatory)
Unique Order ID of the respective order.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |\n| trackingUrl | (mandatory)
Order status tracking URL of the respective order.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |" + }, + "response": [ + { + "name": "SSL/TLS - OV Wildcard - UCC", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n\"meta\": {\r\n \"ver\":\"1.0\",\r\n \"ts\":\"{{ts}}\",\r\n \"txn\":\"{{txn}}\",\r\n \"accountNumber\":\"4397827229\",\r\n \"authKey\":\"{{hash}}\" \r\n },\r\n\"orderDetails\": {\r\n \"productCode\":\"849\",\r\n \"accountingModel\":\"1\",\r\n \"saveAndHold\":\"0\",\r\n \"emailNotifications\":\"0\",\r\n \"requestorInformation\": {\r\n \"requestorName\": \"Viña del Mar Viña Green\",\r\n \"requestorIsdCode\": \"1\",\r\n \"requestorMobileNumber\": \"7094940185\",\r\n \"requestorEmail\": \"john.green@example.com\",\r\n \"requestorDesignation\": \"Manager\"\r\n },\r\n \"delegationInformation\": {\r\n \"contactName\": \"\",\r\n \"email\": \"\"\r\n },\r\n \"organizationDetails\": {\r\n \"preVetting\": \"1\",\r\n \"organizationNumber\":\"7156468\"\r\n },\r\n \"certificateInformation\": {\r\n \"organizationName\":\"eMudhra Inc.\",\r\n \"organizationUnit\":\"Technology\",\r\n \"streetAddress1\":\"1712 South East Bay Blvd\",\r\n \"streetAddress2\":\" Suite 360\",\r\n \"locality\":\"Provo\",\r\n \"state\":\"Utah\",\r\n \"countryCode\":US\",\r\n \"postalCode\":\"84606 \",\r\n \"domainName\":\"*.emsign.com\",\r\n \"additionalDomains\": [\r\n \"emudhra.com\",\r\n \"emconnect.com\"\r\n ]\r\n },\r\n \"technicalPointOfContact\":{\r\n \"pocFirstName\":\"Ben\",\r\n \"pocLastName\":\"Dover\",\r\n \"pocEmail\":\"ben.dover@example.com\",\r\n \"pocIsdCode\":\"+1\",\r\n \"pocMobileNumber\":\"7094940185\",\r\n \"pocDesignation\":\"Production Engineer\"\r\n },\r\n \"csr\":\"\",\r\n \"numberOfDocuments\":\"\",\r\n \"documentsAttached\":[\r\n {\r\n \"id\":\"1\",\r\n \"fileName\":\"dtc.pdf\",\r\n \"description\":\"DTC Licence\",\r\n \"base64Value\":\"JVBERi0xLjMKJcTl8uXrp%2FOg0MTGCjMgMCBvYmoKPDwgL0ZpbHRlciAvRmxhdGVEZWNvZGUgL0xlbmd0aCA3MjggPj4Kc3RyZWFtCngBpVZLcxMxDL77V4gCqQOt4%2Fd6edMHpYUDndkZDiwHJpNSmKTQFv4%2FsrNSNgnTadrpwapWkvX49DmXcAqXMNq%2FNjC%2BBl3%2Brseo0sr6%2Bf9ZSMrVOkKonapi7WE8g70GwtweDxsrpbV24KsomhmMmsaCgeYMvoB8sDXEcB7kwyHsFuERCfTlcVEYkINtklq5PRRonpU3mQ3J4QkF5RBP873oP3cXcqfLY7BbTANIVQQLkjXk0y5u5XhsvTMUpY5RcXcgdY6MYdiUMqYPpruaPWznsdWFotw4gus8yNMP4Ss0J3DYlJHdOB%2BxOh9jvVFVMnk%2BsDSfnHLzcx5VK23qqKuEk1cuJeuCw0KrEFO0Ce6FCxPWcBFoYINBJJGBUHXlJ5oQCzZ3DHtNHaSzphitfEbicxJorGz0Isd3QvJAXhYFSLZoO9yA5KtfUTIMlwWUXtMk39CdNEG6m07CBtfKQu%2BiDH0skjeFbQac8Sq2WjkvigDPiQv5lnLixFeT4az5Ikpzj4qeK4QctIt12S8fcZPYrdc%2F1vF4D%2Fog1iLzxx1QZaroVUIkexMIzaKwTR%2FNoyMkte9Iav9BNSp7qK6s8hXSW6yNqm3txQy8QzFTHulguqyzGmlwCn3XTnUOZ0ykApOwOYk7E6oLfrlEJNTD0nSkHR4aM%2BQajTCfMHbf4QzELYlkjeh9SMomH2AtL3m0IJJNq%2FWVU6G2Jkddo4n3eTFxFY6786Q7P6zoP67o%2BfloF%2BDkbjBub70QgumAg3D7eUtpJZl3aIlwLszeYtP2GG1rZZzvN728rqKP902j8ptta4cQE4s3%2B15Rralg1%2BiqXh5kP2b%2ByYETLz8pYsz7GSLMIOAbtaybsk4451TKdrhznWtPdQ6f4SJ%2FMHlnnUN6yPvpY1I%2BgbNBxQRXk2wlRp8mV%2BPJ7z9%2Fv03h6gemglZlYZ2NiLO16PiWjo5nBg5%2B4cN7%2Bg8KcLz%2FCmVuZHN0cmVhbQplbmRvYmoKMSAwIG9iago8PCAvVHlwZSAvUGFnZSAvUGFyZW50IDIgMCBSIC9SZXNvdXJjZXMgNCAwIFIgL0NvbnRlbnRzIDMgMCBSIC9NZWRpYUJveCBbMCAwIDYxMiA3OTJdCj4%2BCmVuZG9iago0IDAgb2JqCjw8IC9Qcm9jU2V0IFsgL1BERiAvVGV4dCAvSW1hZ2VCIC9JbWFnZUMgL0ltYWdlSSBdIC9Db2xvclNwYWNlIDw8IC9DczEgNSAwIFIKPj4gL0V4dEdTdGF0ZSA8PCAvR3MxIDkgMCBSIC9HczIgMTAgMCBSID4%2BIC9Gb250IDw8IC9UVDIgNyAwIFIgPj4gL1hPYmplY3QKPDwgL0ltMSA4IDAgUiA%2BPiA%2BPgplbmRvYmoKOCAwIG9iago8PCAvVHlwZSAvWE9iamVjdCAvU3VidHlwZSAvSW1hZ2UgL1dpZHRoIDE0MzIgL0hlaWdodCA5OTggL0ludGVycG9sYXRlIHRydWUKL0NvbG9yU3BhY2UgNSAwIFIgL1NNYXNrIDExIDAgUiAvQml0c1BlckNvbXBvbmVudCA4IC9MZW5ndGggMTc2MjcwIC9GaWx0ZXIKL0ZsYXRlRGVjb2RlID4%2BCnN0cmVhbQp4AeydB3gV1bqGufcc7ymeI3ZUFDuKgFIVRFApCoiKDaWEGnoX6b333nvvTXrvECCQACGB0EISQgIhkN4TuO%2FOwjn77EBIIAIJX555NrNnVvnXu9Zs5v%2FmX2vO%2B108r00EREAEREAEREAEREAEREAEREAEHjIC0TExN7L%2B3%2FXrN4JDQn18A%2BR6i4AIiIAIiIAIiIAIiIAIiIAIiIAIZEsCPn4Xr4WGX0cCyBZ%2FiYlJAYGXJWVky7GqRomACIiACIiACIiACIiACIiACDzKBFAwfPwCQq6FZRsRwygx8fEJkjIe5YGttouACIiACIiACIiACIiACIiACGQzAoQrsAVcvBwZFZ0tojAcG5GUlIQ%2B4%2BcfmNJSrfAgAiIgAiIgAiIgAiIgAiIgAiIgAiKQJQn4%2Bgf6B1y6FHw1IjI6KSnZ0f%2FPXt%2Fj4uNDQyMuBgX7XQjKZkrUfWsO83S0iYAIiIAIiIAIiIAIiIAIiIAIiMADI0CkQlJyNptIkrb6kpycTJsfGPAsrgOkzVZnRUAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAERCC7EuCNxNpEQAREQAREQAREQAREQAREQARE4KEmkF19crUrHQRYPSYxKYlFYqNj46KiYyKiorWJgAiIgAiIgAiIgAiIgAiIgAiIwMNMgNfRRsXExMbFJSQkJiVn87e6pMOzf1SSsABuXHxCZHRMeGRUWGQUn9pEQAREQAREQAREQAREQAREQAREIAsRCIuIRG%2BJjolFz3hUnPlHsp3EYBCAQV9nocEpU0VABERABERABERABERABERABEQgDQJRMbFMN3gkvfxs3ujk5OtIVWl0vU6JgAiIgAiIgAiIgAiIgAiIgAiIQBYlwLyDbO7VP2LNS0pKYhpRFh2NMlsEREAEREAEREAEREAEREAEREAE7kggNjaOaQiPmLufPZuLiKG5JHcc8EogAiIgAiIgAiIgAiIgAiIgAiKQ1QlIysgGugbTScySnll9NMp%2BERABERABERABERABERABERABEbgjAZaFzAa%2B%2FCPbBCJqtCbGHQe5EoiACIiACIiACIiACIiACIiACKRBgIfj9z%2FIPyIqKjLqLl%2BvmZCY%2BMjqAFm94bxRN42hqFMiIAIiIAIiIAIiIAIiIAIiIAIikDYB3gYSEBgUHHKVnbRTZuLZqOioE36hm9yuREbfjZSB8JK5C2Vcvnx5%2Bsw54ydOOXX6zJ8kFGDwITf3fS4HXPYfTM9mmeF%2FIeC454n4%2BxKFEhcX53XCe%2BGiJSNGjxsweNjUabN27dl39eo1y5jk5OT9B12trxndAUIWmlGSkJQcl6K6hEVExcTFJz5i02HQNmPjE2i4%2FYVP90XHxrGl7kcOkvh2iijpOXs%2Ff2RuZ4l9cx6GfcgwzBx48hWe4MoqrcBO%2B%2B0%2Bg4UVDA2r2xHjuBmi99m29Fd3nwEyurgkzcCjagDCJ%2F3WKqUIiIAIiIAIiIAIPEAC3MZ4eHp17tJt%2BIhRgUGX75uXER8XM3bdxbK9T4WGR0VEZljKCIuMikvItNeX4JsPHzmmWo3av9Sq265D5%2Bjo6Iy65%2BlJz6tjGzVv9WN1p2o161Srcevt55p1rM0q0%2Buk97oNmw4ddkNksA7%2BGTuenid69RtY3alejdr1a9V1dqrXsGadBtjTrGXbFb%2BvZmVOKp2%2FcHH3Xv3uuvb4hAT67i4GvBmZoeER3G9HRcfcRQkZykIt18LCx46fOG%2FBIiqNjY%2FftmNX%2FwGDzvqcv2%2FXSIYMzvTEEAi5Frry99U7d%2B0Ji4ikfPwdDvr6X0COO3jo8Hk%2Ff77ae9%2Fbd%2ByaO29B4KXLHCc9PpHlFpEMdDNmzT7gesg%2BS6abbQrEALrv8pUQY%2FmfVEumFAuNk6dOz5k3n09DhgGG8d6nzxw4eMj96LGgy8EcMUjvsUbKSUNousfCGS2Xgq9cuXrNbFw1NOc%2B9DVmA8flwMElS5djAPViQOp%2BJw3HFy5esnrtOs5mCs97JOaQ3VhuAWQMYCddxuaQMo2vAE9nF5PysPuR6TNnnTpzlip4lrFo8dL9B1wfQjJptFenREAEREAEREAEHk0CeBlHjnm0bvOrU%2B26NWvVxk27GHQpQ3dNd8eN6STB1yIr9Dn1YiOPrUdC4uLu5uWb3IZlVkhGQkJC15598Nlr12%2FUqFmroKCgu%2FbT08iIjtHq1%2FaIA3Wdm9Rr2PSWW50Gja3NKgrf38396PqNmw%2B7HfnzojL27nNp2LRF9dr1h48au3ff%2FoCAi4RhHPM4PnfBoiYt2qBmEK%2BycNFSJI4eve9ex7i7lTG4tT54yK1Hn%2F70zsixE876%2BP7Zo5TRhRf8UYlS1WvUuhoalnT9%2BpChI3K%2F%2FKrLAVfchLsb9lkrFz8OCGgNGzVZsGixUST8Ay5OmjyVn4smTZuztWrddvzESRcuBnKWDoJSt%2B49a9eph8oBIrywo8cIIzpp3CKOrF6z7pfqNUeOGgPbPxsFw2PK1Olt2rbbtXvvnz1U7rEtRLwsWrKUX%2BBD7u6QxFrXQ279Bw5q0bJ14ybNmjZr8Vv7jqhJ4RE2b%2F1e6gL7mXM%2B9A4D%2Bx6LSm0GZo8aM7bdbx1%2BbdfebF279Zg5ew4%2BMo1KnT4Tj9AWBtuAgYPbd%2BzEIFyybHmXLt1Pnz3n0O98PXvufN16DeB5ITDwPgzCDLURSkc9jgOwfYdOBiB29urdd9mKlT6%2BfulkCApkRjRGS0tMwwaAjJswkUty%2Bcrf4xOTkNG4qOmyh41MGk3QKREQAREQAREQgUeTALcr3GS2bNWGjbvlbj16Nm%2FRirtBHmll%2Bl2uA%2BHY2Og1B4NfbOzxZL1jbWf6MrXkLkIyKDPxHlbJCA0N27Z958ZNWzZt3rp23YbW7TqiMKBjNGjcfPHS5Zu2bOUU24WAAEtPuMcdo2MQ9TFj1pxr10JvubXr0IVACCNl2FeH0sKTWaSMQ4fd%2F4xlTj29Tjg3acG2Y%2BduolPsq2YfTWP46HHEaSD1sPXs098hQTq%2FUnJ4xn0x7rdd3Y%2BgpUydMXvL9p1EjHTt0YeH1Pdyv03e2w1yTpkNd6%2FMp2VxzHGOmFEyYuToN956Z%2F%2FBQ7jkJoHDqDZfKRaDb1e4Qxb7cm6ZxSRwOOXwlTJTH0mjIodT5L2lwTSTwVC%2FQUNELdwc%2F4sXe%2FbqU6%2B%2B88BBQ9at38hD7YGDh3C2d59%2Bp8%2FYfEbK4fHuhk2bzQw1PKm2v%2F7G7wnlGAsvBASuWbfeUjYczEj9lQJpfhpNMwlSZ8QYHHZcQpxWei0y5tayiT18h0LSrjrts2nYnPoURbGNGj22Q8fOyEQx8fH79h9o1rwlCsbkqdM2bd2KxNGpc1c4T5s%2B8xoP7NN9%2BRgj7dvFrIGZs%2BbUa%2BCMvOTgF5vEaRSeujSHtsAcGRyzp04n6GYu1vbrP7CBcyOMR1IgsWWJQ0brODupa7E%2Fyz557YsyZ8lFLAGyD6oagw1PHIBEs2CSfXaScSHza7%2FXZX9YpC2%2ByNpuWaxVeBoGkyYNm%2B94igSWDfQIYjXXV4%2BevQEIxTFjx3fs1IUjjA1%2B%2FB26jIypzUYTW7FyVS2nOkgZ5rqzyjfG2NfIPtoFF7LfhQAKhxjKJHFB9oRNLquQtHfSAJV2OWmfTbtSnRUBERABERABEXgECXCf43Hcc%2BjwEW7uR7hfmj1nLjMXRowc5R8Q4HAHmLlwWN4Tz6n%2B%2BHNPNziWq5FHvrZeJ%2FzCmLic0VqYnhATe5fzLAjkmDRl%2Bk%2FVa%2BObp2z1UTCsQAhCDsxxNIduvfoSqJtOPz3tZEbH%2BKm604JFS26Xsn2nbrfUMUhvkzLcj67bsJnb3fiEzHxjC6%2By7dlnQA2n%2BswguJ1hy1b8bnSee9ExWJ01o71MegYqi3UMHDqC%2B2R8Mf%2BAwBZtftu2czc37RktjRtmSiNa28fXH72Ou3f7oU75fCUkibPs4%2FWk1jFcD7vj1OOS%2B%2FoHUJp9dr5SIGHh5877EmPvULiDqWSkCjwINlMvTqp9MDzZycLcDTaOm68c4dEzKanLKpB9jrBZR6wdY1JKRRdNqJVVDmnMWeQanlNDAzL2xZJr9px5RF%2FwOJhTs%2BbMRRYgCh0DKISNGuctWIjThMNlSiO7teGV81h50OChlGPaxQ5n%2BTTmGZsdPi3L4QN%2F4Fy6zBI6%2F6VmmJaSAMGEaBCy2PcCX7Ft4%2BYtPFzGiSYkw4TNWyWzY5QNnF%2BYYBLZ%2BTQJzFe6j6oxgFOWwbaMKZagoXEWDvb1moy0lIaT3T6jOYXZnHIokwIxg0ACpvVhNiUTxoAasH3nLgOZT56wIwehpxFbwlfMoCj7oWKOGPuNkdRu4JCMfdM6WyfOnou2cOSoh9Uo%2B%2FYabdA6ZVVECTQW1KY0vrLDV9NMUzgHMZLRwnBi31ZIRBQ%2BNQrMxElTMNseggMfYzMJ0L5AZL7aBkbEf4a0qZSzpsvsjeQUk5UaNW6K%2BBwdF8dARdNIrWNQLFWQ0RjMVzby8pUy0x4JNMohI%2BaRkezBV65aeU2ZfJKYU1xTdK6pxTplbKB3%2FPwD%2BK0wBnAWRPyw16lbn2uKXxiSsVEvcz2IieresxdFccSUQy4znBxoUMiy5Su5TpmRZCEyxsAcembQWuWYRpkEDjqGOchwJRd1WXZaDbF2KI2zZkgYDZO81llO8RUUqSlZDDlr1UJGisJOPq1yqILEHLSK1Y4IiIAIiIAIiMCjSYDbg9Bw240oLiG3N9xNte%2FQkSdo3HIEXgrmILcckdG2%2B6hM4WNbry4WMSCaHTZWxjhyJjRva8%2FnGx0jJOOZBseGrAhITuSc7SzJzIbcccfa8Ueu37h%2BO9c7jeMEcvQbOAS9wtIubrmD547LTOBEGkWl%2F5SlY7DExO1ypaFjkAWzjx7zWL9xCw%2B%2BM3GCyXFPr1%2Bc6qEV3HKeDstiEKACCqNj3Mu8ktiMz8hgrNLBxGDMnDMfAvGJiQSLdOjcbcPmrRy%2F4wixT8B4ZqiPHjOudJnP3%2F%2BgSJGiH9Z3bsTMB4Y9yfhk2PPos1jxEgXfL1zl629%2FX7Xm87IV7OMx8r7z3uKly3AzSVCocLEaNWsfO%2B6Fe0h27OTGG2%2F040%2FKcLZEyU9%2B%2Fa0DroFxPO3NYJ8sZKxdp36hwkVJXMupLpE%2F1MWcC4Qak4AVUb7%2B5jvsZKtY6SsiGdBtyNitR6%2FPPi9vPVLnID5vyY9LozA41EV7MYkw%2B0qVv%2F6gUNHCRYr%2F8NPPhFiYZCk%2FAhFDh40o8fEnBQoWYgZN334DroRc4zgGkBcXhhitXr36QgYthXD3Tp264kwZXKRhBwdkxKjRRGik%2FGLEEDzAQ3ncYWbZE4zBY3E8SuIiCC2g2ONeJ5h4snnrNlrBg2DW5OnQqTMKKhv7nOrTtz%2Flk5La12%2FYhEff5td2RNdPmjKV8qmODaSoEzQKjcU2i%2BG39pjN5WDMxio2Wk0wAMbv3rsPH5AnzvZkKISVbBFYkFkogUpZ1oMaTatxl1hmgSqwn0988PMpopZpL5LCmHHjycVZDAamkUQMLuLzu3TtjnLCKGICDq5ZRMqvKM7d0mUraKPtVKfOxCuYZpoyCVBxbtiYcZV0%2FcbO3Xtw%2FPHE7Q2mi5lxQHUUwnHSU9SWbdtNGpqDF9%2BlW3dW3wUCG%2BuQjB034bcOHaEHww0bNwMEgDSZ0DvkHZBiIWobJXAKz7dTF1t7OU5vkp0yaRFmo1Ax9WDN2vXUCC6UihPep3jW37N3H9oCH0zCuyQxWYyOwZAwMLGEfXIRxkMtXHq0kXKoiNqxEH3M9Bp5ceqpCAMolr5zPezWp19%2FhrSVgDb27t2PvPQ4g9Z%2B2gh88P3paGyjRbfTMWz2XL4yeMiwKVOmUyPwScyYHDJsOB3KxiQOqHKcjSZg8%2FIVv5uRgMHTZswECIWwER0BWC5JetnkhS39YoolgefJk8NHjjLjhOajD5tiOUUhrCHDMDAAYWLceYyxdAzzI2DMoEyiaJAmVq1eS0s5yBEWA%2BndN4VGu%2FaDhw7jaiI7BvMDYrqYqrms3I4c5VrjP%2Fqp02ZQI%2FRoDvEql4NtE4soZ%2Bv2HV2792CBLEq21zGwkyFKvZadXBQoNqZnTVvMJ0e4heCnEiCUz68E04usCBzK4ZcK5pxp2%2B43Yrf27HMxVdOhZNm2Y%2BeMmbOxlnHSq09fDmIwQ4WB171HL%2B9TtqAamgZwRg6XPG1MbYO9PdoXAREQAREQARHIxgS4teBh4qAhQ5lFwjMsbtVSdIxOM3jSGho2eOjwxk2acy80d%2F4C7q%2FunUNYRJTrqWtzdlyavvVSvyX%2Bneb4EYlRse%2Bp5xt6IGKwsVOwnVfdsedaTTvfa6H%2F%2BPWBM7dd2uB2Jegqdyx3kDK4pUk9CeJ2EoH9cQQBXsZB0MUt5QvrIJ57q187PCgdAyMd%2FpAUYmJjWSVjw6YtzHPPrAkmc%2BcvQp3Yf%2BCgPSKzj7Kxeu16VqVo3LwVMzvYmHsCutQp03ME4zM0ohir5%2F0uzJq3gIVGUXgmTJ42duLk8ZOmNm7emvVMfl%2B7PiQ0NP23tdwhDxg05Kmnn%2F%2F2ux%2BGDh%2FZuWv31994%2B8uKX%2BFZUAh3yL%2B26%2FDc8y9%2B%2Fe13%2FQYMQqPAu3%2Fz7Xfq1W%2BIR8%2B8kpGjx76S53UOfvFl5b79Bzo3bPJS7jyVv%2FrG57wfziymDhk6%2FIUXX%2BZIvwED69ZvcPOsrx%2B34vat5quPry%2FaAgkaNGzMSgjoFagiL770ChoFj2Kxc%2FPW7WgmKBj4xWxILu%2B8m3%2Ft%2Bg0s08FChRiJMoDrgdkkxv9i4Y5de%2FYaN8eqi1O4qM88m%2BvjUmWQGvr0G0CBH370cYq7Z5saM2TYCAz%2B4cdqeKDVazhRLM4C2SmWs%2Fi5CBGz587Dq2Kw8aR4wsTJHLfKNynxYnBXocdXrMI7w39nWgdOByIGfhOaA%2F4%2BrcaratSo6cpVq3lojm%2FFxH9OseH%2Bk5E4ASQFHkDjtqB12J5B9%2Bi1YOFidCdO4RyhsdAiHE%2B8fn67OIsPOGXadH7E8IP4TTO2URGuE946vi2%2FYLQIPsbXxkLO4vThltK0yVOmYSS%2BMwUyU4bCaTg1oiQQ28%2BqIKhSnMIqswAy%2BgygGjZugsc9f%2BEi%2FERq2blnD2Wy0S7sxMVmZg0lUwjDAANozrz5C5kdMG78RE7xWadOfSaSGI%2BMjHRr48bN8NToPjrXyakuvh4TTOw5s88o5Yea9GCsV88ZrxaDOU75qB9UbUI4SIaRpvk42mgCrLyxZet2CFA7mg9dADTgE%2FwAMfxT8qJL8IoiJBpg0l56kGLpC%2FYpCueUYYCkQ6fg2OJy0il0HJ1LXnxhEmOYg44BTEoAL448HHCKsYS5SKa7KQpWXFnYgM3oABDD4SX8gLGKAkMC%2BHOWkhl%2BTHmgXhQAlj1hFiQdRI9TBRuTRPDlWRPDdFMaOgZiDvZQF2Wy4W7TNBo4YdJkRgJNw4ZVa9bSHOolmITWQQAyw4aP5BRVmw5FMsI8TEJMgAPzWRo0aIRJICUvhnHN8n8oIg8OPgIUKgTKA%2F1Fp6OHUBQ2UCyiH%2Fvs0ArOptYxbP0bF4fOQHUoPySjfIaKkQfpFEMDyQ6FDZu3btsBPQbtxMlTuPZRiuh3%2BoXhN3rsOFZZQehgH%2BnAdBkSH4nBa69jQAY7bY1yboQAsm7DxmEjRhJ2RTAPpxyGJZVCgJ5ltDOoaB0yL6OO3wSq4DfEJj21a89tBhvXHdUhcvIrh7ZDSk6huACfS4aLmnge5KDYhATmslE7WhCW8AtDgS1btkF%2BTG2Agz36KgIiIAIiIAIikI0JcCfAvSJ3y9y8LVi4iEeZ3EZyOzd9xkzuGTZu2rxw0WLuhXBbeO5277cNxIYu2XP5jZae%2F65zlNALtmedjz3X0BaJYW3MLuEgG2efqn%2FsiTpHWTQj8EokbscdO8K8RCM9TrR9GvSBgUOGm9Ue7GeUWAqG2TE6Rnh4hH3eu97PaDzG1u07U2%2B8NYOD%2BLlIGcc8PO%2FaGPuMQ0eMAkLgbVY3jbH9xdr%2F3XUoSEaHUzwz%2BufM%2F%2FGXWnQE0214mwzS0y%2B16mFtjdq2lTqOeBznbvmOg4QEuAB84rmjFeDOoEvw%2BHvQ4GFIE%2Fv2H%2BT9qkycf%2B21t2rWqsMduPEQETpw7es3aGR0jFFjxj351LNVv%2FvR%2F8JFEnADP3jIcFQCRA8iQ3bv2ffqa2%2F%2B8OPPPMfEKaCl%2FQcMfvOtd5YuX%2BkgL%2BCYjJsw6dnnXkBYIBlng4KvEJKR64XcPXr1wRLu2yt8Uem9%2FO8zj4CzbKyZwNdy5b%2FAU2MrWuwjIit41h%2BZ8pYBJr98W%2FV7PCzTRosGFs6eNx8%2FAteAxvK3aMkyhAuc5eTrN3bvdcnz6ht16zvj6XCWXHjoNGHPvv3USAN5No3TwWICOB141rgqOO%2FWk2KrFiq16mVtBBwTXCqygxF5AW2HLFhCS3HA8Rl%2FX72GfbLwyWa6D%2B8e1wx%2FjYzEfuAD8jiebiIvafBe8QT37HXBEtqCg8OTXDPbhfQ4QTiDvMiYlFjFkTlz55PG%2Fcgx0qMh4C6xmjFnjalGncDr5wgbs07wbQcOHMKvH8mwEL8Ph9SUhjOIa09HgGj%2BgkVURHACVWAYPhoZeYiMt05injXj2LJPD9JeNAS8RSBwBA8XZ5YdRh214MhjIV%2Bxh%2BajaeC%2BBV0KxnjcUgxAh0k9qo3xVIQb6%2BzcmEVTMYMspETCwsgdu3ZjFZEb7M%2Bbt5B9mo8igRmMbbJTO%2FXSp8QgcZaMJKY6fu2RaCiZNJhNG5GA%2BIqF%2BI%2BUhlNJXRxByqhbtwFGkpIjZMe%2F5qA5a3QMxBa%2BUjgbs3uozuaAR0bTEWhEjG2qJgF9ipSEpMZXE4VCsQzFqFjbHBZCJuhBnG4KwQzYoo%2BR2FwOjEzOspIDeUFNwAyjjlqwkK9p6xi40oxJk5LyKYfZKFRKRcyuQvWiLprgddIb85h0yT6VwtnoD7j8UCVsgIwMUZtwEWcTPZD40AcYwPFJSUgWeP1cLESOMRgYYAQUcR1RDgIIQPjKMMB4aCDmMD7R1uISE2%2BpY1A4wgiiGYIn6fktYjhxUduyJCRSJoIAZaJHkZLqECu4WNC6DRwuIq5KRgtdhuVkRyxCZqHH6QW6m9q5B6AcKx4DFFxcjElAmTLpApqDLEN2yoGG2djnCPIFA4ahThPYiPfAHsYG1XHhYAxqDij4ClVAMVzNKarmJ%2BLEKVsUDRvRUww2xDSKpYPoR5CikpEd2kSsYbBVtXZEQAREQAREQAQeQQLcJAwdNpxnH1fDbLO%2Fo7hRDL5i5pVwU8x9C%2Fc%2F3EjXqlWHJ4yZcufA60i2Hg0p2uEEC2JY2sUtd55v5MEbTJhmYtbQuGPvcDeYmGh7GWhG%2FwgzOHHSG%2Fdw8tQZDtqF%2FVf85cbNWvHmTZ5%2B%2Bvicz2gtDukzqmPgX6feXA647ty9d9OWbRs3b%2FU%2Bddqhirv72nfAYFodGhZ2d9nTn4vxdsc%2BtU%2FA7e6U6bOQL%2Bw7xdrnuKvbkZi4dOkYplhupxOTk%2FGUj3udxJsbPmI0OgbjnCYQofF8rpdYSZVKScyNPbfi%2BQu8X6duA%2B7V8QHRMf71xFOLlyxjnwRcGqdOny1UqGjdes4JSUlEp5N95ao1XD6c5TrCA%2BI6wqm3v%2FM3p5wbNclf4APMMECoi9t14jF69OqN3uB62J1gjFZtfuXe3uSlxuYtWpOAU5jXvWfvvO%2Fmp3DqxafjOJ4RhVC4w0ZjKRBpBfff0%2BvE7Lnz0THGjJ1Ae3Fncr34MkvOnvA%2BTVF4MQibFIWra%2FwjJprhyuGF8RUfGWGTB6%2BmdQ61WF8tHQPLcaBwUqiFfRLQUnsdw2QxDgseLn4KMRikZAMF3jEHffz8eGKOS4WfyyN4vGCY0xA8oxkzZptuIj1uI%2B7P%2Bo2b8EbBjoOGN9q374Cw8Ei44TpRGr48ebGB4Jl27TsQlkCfkhgzsIE%2BOnPWh7NoFMg1iDYGJglIhsOIu4pMhJ%2BOUoGMgE2kp%2FvwnZFfSEB6EzdC1AE10nY8Smo3JTA2sJAAD1JiHsc5Gx5lqxoPEWtxqCmfbfyESWg4OOxmYFhgrR2Op6FjYAbEcPnbt%2B%2B0ecs2bMTzZcyb5tB3aD74mOifcOMggxlXEf%2FR%2FM5jLfbgMiMTISYwjwCnHq8ZF5VTpOdnkOxM%2BiA7RxhXACFWgVMYBh%2B6iY6jWD5pFI4qUs9Jb9v7ZGk1uXDJidUBHVOQ6HSKAgUTbUjJCDSt5pNRx9ijBGzmMoQeag%2FCEdiBzxHaSL0AxHLcf4rCf8cMjqRHxyAXfYR6T6yIbSRE2UYCRmIeVWMAQ92mWa1bz%2FAD6Xl%2FfyOeMMxw1dExjHRgLgfqJaiGI8gUDEtjMN2KF092upVrkLZTKat3MhoJTzLFMvDQH6jIqIW30zHgzLwhIkPoTaZaAJm2mxIwjdECZDQHKsIewki4QHjlDTWa4Y0Eh44EcxLTOjRDLkxgYvYtdQyaDxzGADcJxJPQfEYC%2F8uaxlpD0dqhB2kaSwaZnoW%2FeQjCcdM1QEYKwwCyGBTYBhy6lRgP8ho76TtSslYMzaQvuFJoMpcDG9NM%2BL%2BeBFal2hEBERABERABEXgECXAzMGjwEJuOEcqdflQE9652OgZHuMfAr%2BE1rIRtc6uTKYh43uV25tqXfU89Xf%2B2UgbxGG%2B28Jy1LYh1Mu44o8RYddc6huV6s7zGqLET0lgoAymDGAAW%2FGzdrkNg4D29jzWjOoZlpP0OD%2FC5WyYYw%2FaQPeluNBz70sw%2B8zWIdvA6cTL1qcw9ktGXruIU8I6S2%2BkY9EuGdAzuz3l5K7pEiZKl8r1X8LXX3yIg4Y038yI%2BJF%2B%2F3rxl67fz5uPG29xXcyPNzXypTz61Xx%2Fj1VffsJxcriMCmZhjQuwE45BJ%2F0gE6Awmu%2B3KSnnaTjmpr6AvK33F%2FA5cRXNnbq64l17Kg44B8K3bdqCuMI3FuvrwIEaOHoPIwLNjTEVVIHCC59ck5kk0bbml50t78Sx4dPtJ6c8QZEx7iTAZO34iGVu1%2FvXFF19BMKHVbOwQPUIVTJmhCYgwOPv4EexgBo4bDj6PpI1zZN8ijlgHM6pjUBFxETiA5i0J0OCICYHAWbPfcHnwamkRwx6%2FD1fZwOEIj8g5S%2FQ7OkZKFx%2BmQJxxfEbswWvDI%2BYBtImTITvFsqyBfb%2BwT0ZaQYQ%2FDjW%2FflaLMIlTbAwGnCwqsreK%2FYYNmxC3RgfhYjNZgAQ4qtRO1fiMpmTmwkCS42ydu3bD0cOL5BS14PNSo1n6gLrQGXBCbS%2Bb%2BO95JYaMsSQNHYMEpGT84KgCgVgL6sUVxTHkOEba6xh8RaqiOpiQ0fQpVjENh2fx%2FO%2FA8EbHICTDZKdreKxPsSa4hZRMDkJPIL6FvqCEAYMGW3yoGoWHLkPVoZkkZnFRpicwGukOzrI1a9bS6Bh45ZCEMMnMhYOcQnYW67BEA3LZk6ci7ERfwio8d0rzOO5lmp8eHQOD6S%2BayUiwH8wYQCGQYfCgjThUSvdROM3hcmCfKBczTgjJMDOhEMY5wq8Bk1NoqSmBUcESJYgMVEqLyJi6WEJ0qPSWOgb2oCSgF8GZTqRqh%2BxgofloMlyqFJJaxyD%2BgU6kBFvKJs0Zb4zPNHQMILBREVIG3U16tCwuDa4g00EOxOhiOpofIgwjMcYYhYr28hPH5UzVlMMtB2ILchB8oGR0DNQ2GmiNPWZsoVog0dBSsjM8KI2M%2FI5Zyexr174IiIAIiIAIiMAjRYA7hNQ6Bqvp4RahYYCCewyjY%2FBMinuJzIITFxt9JiC85sizuVKW93SIx3i%2B4bEPO59YdyiYZOkUMYxh9%2B7Ls%2F4DMxSsp%2Fy33EHNcKrXyGW%2F67349feuYzDB48BBV9b59DxxIjk5c0QMWsRiaz%2FXrLN02cq0WxccfGX4yDE8WUs7WRpnYzMSO0H%2FZqKOwR04btcXFSu%2F9XY%2BZrXjOPCUHxfDisfg%2BenLr7zGg1F8AXMV8OS3%2BIclWY0Tn46nydyrIwJs2rLFRAJQYMDFwNKffv79Dz9xyTBdhXiMHbv24FiZkcmNNymt2%2B%2FIPxbOJWOzFq3ezVcAP5fEXI8kW7t%2BIxqC0THwO9AcmHViFUXtTDlh4gk2c0levRb2xZeVMIzHx6xZitJCIWymXsvX4EjtuvWxCn%2BNaxnbJk%2BdbtMxxtniMfBlKJBADuIfiP9nw6dgIQW8WpqD44BXwm8C1fGVmel85YE7KKyKqI59vBj8L%2BPqZkjHoFgeLmMbvYALZsymyTj1%2BHrMO0CgOOh6mI2Hy3zatJrYO%2BgYlMnKAGgLzOPAC2NjGQ18bdwowjboWSIiWrVqiyiEM2UawieVkpGW4nPh1xNmb5HHKkOAp8M49ViL8MK7j41hNtsOu%2BFNUwgl4LUR5LN0%2BQrWPcCVo2rj%2BlEaC7ngyBOEgLDAKfxK1lrEHoQpvuIzIlxQEY%2F10YvMYh2mNw1k8AIZXAwno2NAyRhJvZiEX4%2B6xVljCSYhuBGFQi8TccFEQo5TnYOOwdNw2kuBFGKqAwvokEFsOkZKPEZqHYP0VAQZRx0jJV6FIBAUCRrOJ0CMSXwlZgCHFAGK4c3%2FL0zSoVOMjkFfYz%2BTMjDD2G%2FkJq44bCYNTSDchYlCN7Efsg0JCqF8zMA7xmCWTyEvW3p0DNARHoDrjYRijQQIoEhwikophEqBbA1CxCUqRVKgRUbH4JIx3Ox1DEOG4zSZuAuWfUDQQHlDyuAyZ9IEYxvRwxpClEkDiVCh3lvqGIwKhCkiHFgNgxIYnMgRhDkxc8SiwQ7ikpFKjI5B%2BdiAMRwHO7NpiDrjxw2GkEw7HgOkYMQehCxaTYAWQ4JxQlCEdfmTwIxMrgt%2BOTGJDrX1rIcnq69gLY2iBIrCBn6m%2BJEh3IsrkeazhC%2Bzb4yOgchmGFIaiVnnhHVQzAVFRhO0w9hwWKrXjFV9ioAIiIAIiIAIPGoEuAP5Lx0jKpq7QVwJ7n%2B4c4AG9xXcIhKPkbk6BiXHxsYcPXftlaYeLzB%2FxG59DPafrHdszNqLN5IzNu%2BABztJScRN3%2F0fC2UMHT6KxRZuKV9wMGUphvrVatbhHRk48ndfEzMakpJa%2Fdqe967e3ftKCFNhhTQmPjBBICn5nlrt0AraxUqevJnl0qVLDqfsvxIaUbVajZWr19ofzNB%2BXAZlMe7bMyseA3dg5%2B69eV59HX0Am4l5JsKZR7ovp8wrYZ85F%2Fj1%2BPiEYXMJwBcv5pU8b1jrfNrmlfz7SVbDMBHyJEP%2FIaKjdZt2ZOc2HsUAB5aSyc6nywHX9h07s54JlxW36DgFXGhcfXg9%2BPusj8Ea%2Fjx35gjO0Tfffm%2FWx8AuNAReL8Lin8EhtlgIvCrEgypfVyVkAjec0jhImD1hGBMmTiGIYu78BYAyv2OUxiwAPqnR1%2B8CL0P58aefOYvNtJpJ7jmffMbEYzADAoOnTJvBcRLQHMSEQ25HzBqPeP24mSgbOCOUhuV4izzh3%2BdygFX4zLqm5Dpx8hSyAA4%2BeUmWWsdg7QVoYDOb%2FbwSzMNbgT%2BOtm09jXiby8NGMlwwqmaWPeVTF82HGpoPXjNF4eHeLh4DLBSFv9y3%2FwBcJzjgZEEYL4%2BnuvQskgTqgVlegGe%2BpKdA3FjkC%2FxrdlhqABeMaRGcwhKqRqXhkT2WcwS3mnLoAljxFfMwidkQiAzwoddAx3GbwSx5OmUaz6bBSLGkwau1TrEkAk2gRVSB08cwICNtxxi0Gp5HEz1CyaQ3QCiQ6QwYhivKQUOACBbGGCVgBmMPMQQdgxJosjGJXJzCWwcIeg5G8tXEexAEwll6lv61YJr2YgCrPOPd0wTQ3TIeIw0dA52HXBSF5YwHNsqhIkQVHFjkFNBRNa1AY%2BEI3i5W4V%2FT4wxppA%2FS8wlqWsQneU00wuDBwzhFYvLSTGjTg9RCe%2FHK6VzOmhqNjnHGx4eKSGltJKZka30MvH4CbAgVsEUmJNjKJDuTU1jqAW%2BdsAHIABzI1Ei9pKfrGbSkTEPH4Cx9jc0MCXKRHdeePqUtlI98Srt4P441hPhZYN4N0Gip0TGYf8RZyuEIG2dZT7VFi9YoEnxlcR4iVVgrg%2BYYkhw863MeJQQCmGp0DCJDDCuYI8jwHwdlcoTJpKiRDIm04zG4nBlFsKUKcmEe0VnUy3Cic6kIjYtP9hlL9B29yQ8mifmd4S0zKKtGx8BaeopLgFOgQLSh0%2BksfgNZUAiBAuGCcmgslsMNZYkhB2oaZS5brj5uTpBijL5KYm0iIAIiIAIiIAKPLAHupux1DDhwhFsIbqjY4Ss3FX%2BSjsELVVcfDGZVTwcRw6Zj1D%2F222xfKs9QMAb3crd8VegdnWvecuLmfoTb%2BJGjx6FUGBGDHWvfOsKrMZh3gDcadJtlMO9Yl5XgXnQMXkbLzTMihsdxz3sPQbFMsnYWLVnO3BkkndDQW6%2BSwb03c086desZdg%2FLaEAgQ9cdt9CZpWOwjAa33KxKUe2XGtxjc3eNT8dLQIh8wFuhohPep5lFQpgEEdR4rzw0JNQBT99%2BnU%2FEB6Zg4K2TACC86wQdw2THcWaOCaoCk%2F1PnjrD08YKX1Qk2GPXnn3cwJP%2Bx59%2BQULBReLmH3%2Fq5%2Bq1KLxylW%2BaNmvJW1NLlCyNJQRdYAkXYOcu3ZE1kEFwXti6dO1B4vYdOpurlQQcRMcoVLgYL3g1XjCnKJngom%2Bqfo9ji1%2FAFU2LSpUqs237TtwWYt0%2F%2FuTTp59%2BHh0DUQM3kDe9UsiixcvwqXHVazrVISKFyBAMRhshAAP%2FiEvM%2FCbgCuGY42jwaJXGohgQmYMQUa%2BBM3M6qI5k9joG77jkMS6OLf41Py%2FYZukY2I%2BXN3nqNDwgPHR8dvwjNorF5cHjRv1AMWAWAwbgEqIJ4FQSpYA3lIaOATo6BX9%2F7boN2IPlZsMtIqgAy7GZEngETGlE6TPdg2GAWovbNXb8BNMvtIiHy%2FQ%2B0Tj4lSzjibfF7yEZccYhQDAPD7UpClcRpxgfE%2Fvx%2BxAfePLOnC%2F0E3jyYg68V5agJCXHSUZp%2BIO4h2DhwTS5GAwcR%2FsykAHIDvoSTcDZRC6AGxZiMC6keYaOf0eBvM6DjJjBogSE6OCWQhIRBgIITfituIpkpDokAnxAJtcAlrM46cR7MPKhyhEjJZGA0AJQ00HUS3sphIrIfhc6hpHRaIu1AZZYCzqU108gBdDRXDJANvNK6Ck4EKiA2TBkZgFNY5YECfD9yUs5uMn16zckMAB6jFsWlqS9SEDMOiHKBYMZgQwqUvJfGDoGQxchgpkm5vIhDXEynLXXMSiZEYWqgJtMSnCZaJwRI0eT0iLD%2FxFcO5iNYQwbvG8z1QV%2F%2FJbxGIwTBg%2BroeKA01PQIBmJFy5cAlKKorMYivQaHBiBBjh20juMEMIeqIhBzhF%2B8FnikvTUi7hhUNBAG42Ul%2FPaaJz3Y3hAgClCnAImtXOW2AkkKRrC5BfaSI%2FTv7SRHdClPa8EO9EnGerEDmEJw4ChiBlcGuzzNIIVd3v27INGR0rKpGdpBT9xtJdLj2GPAfzGggLdhn0iSfwCAvhBMJcea6fwC0PwBl2M5UTjcL14ep3EZl7Ew9igK5FQCGeiUi4Beo%2BUhKDwy2BuUWiXNhEQAREQAREQgUeQAHcCDjoGELh%2Ftu4QuIv4k3QMFvzsv%2BSCeVkJIRnmHSXmBazs8zLWwCsR6XlHidVrUTExLHBhOePp3yEMo9%2BgoSy8ULOus5EscNIbNm3Je0XZMUf4fLDvK7Gag4jB6xh4bSI3e3f3nlmrqNvtxMcnjBk38acaTl269z7s5s67L0xKNJOAgIvTZ82tVbchMRv3uLIoopPlslmdmMYO9%2FaTp81EYDESk8Pnz7XqZGh9DEY499toC7xuFf%2B9SNEPeVsH8yxwi7j%2FZ2P11EJFinEk77vvsW4GN%2FzERfxSvSZ%2BFk8zeSUxrzdt1qIlr15FzcDlR8TgzSPGgeKWHm%2BofIWKuV54CTGEU%2Fnyv48vQ3s5xfSBJ3I%2BzQtHAi7a5phzBJ8df61c%2BS%2FLlvuCF6Ow1uJbb72Lh0uTSYBf3Kx5K17M%2BuZbec2yFTh9%2BESGHg3BCa1e0%2Bmvj%2F2ja%2FeelGYYsoOv8be%2FP86ioJSDYbPmzDOm5stf8INCRVgbJFeu3LjwyTdukJjoi08%2FK8uyHtCASd538g8ZOoKieISOt44x9o4DbhQKBi45%2FiZuOBs7uMB4LggF5teDOR0cwUEjMUdwSXijKK8ZxUPBckLiyYJrA2pGMnj5ygNxs7VE8kiJTMBs4PD8l7P47%2FikODuIHiFXQ7EZj5hQMWQWCsFUKiL0HcefJTqp0Qgg%2BHfG6bOw4OfSj8zaMLmIuGDuCZ4a1uJn4cmah%2FvkwvsjegE%2FFHcM95MExLSb1nEW1QWvjbwYi%2F3oGAcOHuI4G5M7YOLcqDGtwGYsZxFFWs2Gv0xptAItAo%2BMZHjHNBM7KY1QELIbU6mIrkcOIjG1k553UdI6TMKxNb3PJ%2B98IQH2G3S4eLAiOogyyc5sIGM5%2FjLJGBJmpU1qwSVk1OEtYgaOLTRYqxaHkSMYTC7KhBJVYAlOK%2FUSvmKGAfAJV0AGoR8pijRIMZQDPcrhiE0CatoitY5B0xiueN8QM2SwgaAU2oWrTrHkvRwSwjqujDqq45UWOMWMNAYqtbBhCbMSaCPZIYljTsQI8h2DHGvpDhxtyqEizKYijKQttN1s7Ldp0w5ZD3UC%2BAhH1EhKNio1Kc1IQO9C4eEsG73DJUCl1Eg3QQYViKEOZFx4tC9LPCG6gB9njqB0YQZqCbElNJa%2BoxWUz7DEx6chFIuDb6QJTjai3BatoIolZCQSCctpnTGbHQrBBi4xAJKGBlIIXcwFhbDDbwK9ZtEwrJDO6AjMxmAmxdDqYcNG8pWU1EVf00ZaZHqfgYRyQjJ6EK2GLMiwlEN1BHLQuRjIeOY4Qx0lExGDs0ht1X6ujt4LClKyg52kpFiaRvm8roXrAh2DEcIit7xeh8YaFHQcKKgOpZcs9DXHoUAVvKuaaW6mpUiX8ESABQu1cI3Yrvp5C6jdXCn6FAEREAEREAEReAQJcDuUWsew58Btw5%2BkYxCP8cOwM7y1BCnjhYYeNUed7TrP753WnrxuFVkjT7PjHj6hrKtnb0za%2B8xTuJ1jnvZxdIwBg4dZy3uyPsbIMeMDg4JCQq4u4KW0f0RoGB3j2rXQtEtL51krHoMFVG%2BXpUPn7paQYqUhMnndhk226SSZtLCnVbL9TlRU9MzZ6BXOP9eq27Frj5FjxrH%2BJ2%2BnRd75sbpTxy49mJ5vn%2F7u9lkig9cDpN2t1lmG4n7Xw207dG7SvHXTlm3sN94j03fQEO6T039ny8jnJnnLNmZqT0FhMFEZCxcv5ZGiKQQvw%2F2oB24gbgJOSvDVq2DfvnM3uTiFI8mbB3GF9roc4Jnp%2BImT8Bwx1fgX7GAtRfF2G7wYHkEad4%2BzbLgePFrl4bj5igvGJYY8QpQIRyCJQ4dIwtqe3PxTFPaQBa%2BZB%2Btsq9esxx%2FBBnsy2M%2BN%2FemztsUkzXHexMoDX1qEC2a1aP%2FBQ1OnzaAzcRz8AwIZezizmEoWfARSogmwXgQLGxL1TRVsuEK4eyyegG1WjexwigZChkfe5OKRN14%2FB61kPL0lSgHLTTOZDsOjcDQcTOX1HPh3yDWGNs9b2efpNg%2Bmb2679nDE5KVMnsMSyMEDXCBA3ng3FEtGjlsN5AgpebZLw0kDYWQNe5vZBwVpCFfA2puGhUfQO%2BgMeIj4pLje1GhysYOfRc9SNQ1kYRCyk4uzJi9CCgSQaDADvcU%2BIy4wASFMR0LwMWSsMnESt27bgduI3MHTZ%2FgD38QDAMcqxNRCQ3DMkdcI0We1DbAwYEyXmQR8AhYyNIHexAwIYLaxkOwsnoBnjQLDo3OwW%2BXTFhxYbGAhFMQB0lvtpUU8yge1KYQqiJnBs8bJxXnnqy3vufN0FgWaNAxggGOqQQRSvpKLxA4b6TlO1yA3UQtjAGvpEfoFA7AEdHQ9bcR45iYQZ4LTTcSFsZzyOUuUCCE6lEDQC9IKp1geg7e%2BoopQDmlMpZDHSIAw4G2fKTsYRiuwAWecsBPsITGfVMdXMxJIw%2BgyNXKWHTMSIANMOJtW88nPDoWjfVnlsM8RzOYIlrAcClc0%2FcsA44qggfbF0nz4I18gZyExccqUfDHw0k2bUyyn34HP4ASLqcg0kPL5NYAGEVZYTouAYzWfHWZzcGVRuBk2lIDAwthjXQ6GCtcOJlECKRkM8KFD2cdIIqysa4Qj%2FEaRhfEMTyNOGmh%2B%2FgEMIQonDYbBEDtpKe2lcK5ELg3zy0wCKsJC5CauGsQNTKW9tIiWItdQOzR4pyqXG%2FxNQxhXXMemy6iRQmgC6Rk%2FtxxdBos%2BRUAEREAEREAEsj0BbjwGD7FNOGUHpyn1xqNn%2FBHeu8pMW4dl8%2B8FDgsDnL4QXqzTiZx1j37U%2BeS8HZdCw6NY1fPAyWs1Rp59odExXmUyd%2Ftl3mySzlqw%2F66DE9Ax%2Bg4cYnQMxIrmrX%2B13kWSkJDQq%2B8AIyZwilUjMlfHoFJUAiSCW26459bcFksoYP0K3lV41421yknPDrHNo8dNaNn2N7SdH1A0nJv07NP%2F9zXrMuutrKxnks7%2BNcmQB7it5f429YZjwhjIUGn2Yx5HkjtqIpwtF4OiOMgVweNCPrl%2FNvumCu69ScxBczYuwRbC4WAARZksPCmmKMs2U685Qi5u6QnYqF7DyfWwO74S9%2Bc%2F%2FVzdrDJKLSaXVbtljFWa2aE0PD574znOV6q2P%2FiHtbZGUSZNsKogPfsOBnPJ4yIRpYBLThaHSimZLDSHojDAvihTGgctJuyQwErGVwo0tpl9vjpsVl5TPolNduu4fSHGNlJSCJ98JTGbg818NWksLBTCM3RsM%2BZZhZuMplKraofSTC4rjf1ZK4vVZOusxYFTJOM4OzyM5hE2fp85YiW2mZeyqAg7pkzTOiuByW4s4dOeAKfIReHGBj4d8poCOW612hzh0xToUAsprSOkAbWV0VZR%2FH%2FGACnT%2BP%2FCWEUVJKMcy2Z2bKvItmjJHBYkEaQAfGHWoiROAGeclKZ2K7spga9sOOAEABC2wUEHIx3GldUKduwTUwhfzcapW7LCBodclvFWpQ5HUltrpWTHFHi7Yu0tT22SKSft8jGGjFZeK7E5YurloLHE6lCOsM9Zy1SwkMV82h%2B3lZ%2ByoE1qY0jGRjmkSX2W0ky9lGl0DBQPEnOcXNZZMrJvGcZXYxsHTZn6FAEREAEREAEReDQJcIPBGnoEmvKAmAeOPLVx2HiYSGAzAa48K%2BF%2BI7Mo4Tdscb%2FydgvP9rP9eHEJX7mho3CEi2vhkTO3Br3%2Fm1eb6b7cF6ezRu5q0uOY3zINExxmzJprm1dSpwGf7Tp2uRZ6M%2BiCmAdiM6rVqGNO9RkwODY29paFZPQg8RgIJugYvzjVY7XPW25UinbAbBe2jJafielDrl71v3AB8YRXcsTGxmViyRQVm8HbUW5ib72leuybzpHzYJNxAaLJsMg%2FE0aYvVK4SHEW8GRmB5HzGGbu8x%2BghVzyRHETDI93mYmX%2FwNs0UNYNWOAR%2F%2BMAYLwkekeeKc%2FWEQ80Gf9B%2BaMMOqYC4PGzowDwoHSJoPnS8AAk1MIXNFAfbA9mNHa6S8UDPqOwCf6MaPZlV4EREAEREAERODRJMBtA7GjrKhWt54zc05Tb7Vq1WbKLbHxBHNm4g02jxZ3Hb%2B65uAVZgnzEMwePmt7Imsc9wmdvf1SSCgTgR3Dku0Tm30i%2Fe%2FxnR28%2FoNAeh49E0zrfuRofPxNVQSJw9v7FAcJvnUhSD4kJLO8eAwePGxE1x69u%2FXqm54ts%2Bp92MohsCQTx1XqsfHwH6H5bCwBynxwFgpgegiB2dzbPyRYmFyAhmm%2FOMbDjzRrWUhHE2%2BPK2cmZWQt4%2F8MaxF2mLDDkpgsn8JEpKPHjjMRKe3LgbNMdjBzJdJO%2BWcYrDLvhQDdTd8xucaam3MvpSmvCIiACIiACIjAo0MAKYMJxUc9PJkbnno7cNCVeSV4MdxsZC4TBAreLogEcctiETcc9I1bJjMHeab%2FsLnnsif9BBJSVoFIo38fhVMETjPLg2kdfD5UIdMoKvZB3Y9CX9z%2FNuJ6AxnU97%2Fqh7NGM%2Bqsz%2FQYyf9QXD6Z%2Fv9UeqpWmnskoL67R4DKLgIiIAIiIAKPLAHuIrhjxHu65caph%2FkJF2rI3b1uNf2OtlL%2B2QTi4jXZ%2BdaC3iP7o6SGi4AIiIAIiIAIiIAIiIAIiEC2JMDahKwV%2BWd72Sr%2FPhBgjclsOUTVKBEQAREQAREQAREQAREQAREQAREwBBAxEhOT7oOLrSruD4F4Xn9wm3lGGvMiIAIiIAIiIAIiIAIiIAIiIAIikKUJMJ1EkRj3R164n7XwGhd6NkuPTBkvAiIgAiIgAiIgAiIgAiIgAiIgAg4EYmLjtCbG%2FZQX7mdd9CzLZWi9Pocxr68iIAIiIAIiIAIiIAIiIAIiIAJZkUB0TCyP7O%2BnW626HggB1IyExMSY2NjIaNtrSRmrYRGR2kRABERABERABERABERABERABETgoSaQsloCbixP53m5apIUjAeiKTzQShE0mEDEQijxCYnaREAEREAEREAEREAEREAEREAEROBhJsATeaIvkpP1RpIHKiWochEQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQARG4E4GLgYHaREAEREAEREAEREAEREAEREAEREAERCBLELh0%2BbI2ERABERABERABERABERABERABERABEcgSBO4Ur6HzIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACIiACD55A8vXrl6Lj1vsFT%2FcO2H7xanh8YkLS9RPXIheeCxp23G%2F%2BmSDf8JiE5OQHb6gsEAEREAEREAEREAEREAERyLIErqf8OZh%2Fy4MOafRVBERABOwJJCZf942ImX0q4Metx4qudq2%2Fy2uNb7BfROwKn6Cftx17fbnL91uP7bp4LSohyT6X9kVABERABERABERABERABEQg%2FQSio2J%2B%2BKbFsIHT7bMkJyd3bDesacNekZHR9se1LwL2BK4HByfs3h2%2FbUfith0he3etP7N%2F4%2Bl0b6dctp85eCn8in2B2s%2FSBOKTkk%2BFRo339C%2B7wa34GteP1x4uusb1263Hpp8IGOFx%2FstNR%2F5vyd7ym45suRASmZCYpVsq40VABERABERABERABERABB4ggfCwyFxPlnSu08XehqSk5ErlnT8q%2FFPotXD749oXAYtAkrd3eMUqIa%2B8ceXFVyOez7Ox8Ns5epfK0f3j9G%2BP9fik2DinoxdPWmXe407o9IXR%2Bw9RyNXQ6N5D19VsOqvhrwsatrNt9dvMGzd%2Ff%2FjkRf6VnIPrdD55zKdJx8XOKWcb%2FbawTss5DTos2bX3VPi0%2Bf5VG0Vu3H6PljyC2RExPEMiBh3xKYV8sdq1tcvJAW5n6%2B7yZL%2FUusPfbDlSaLVrjsV7y26UjvEIjg41WQREQAREQAREQAREQAQykwA6xsvPlW5cv7t9oegYX1dsXKr4Lw46xtWrYQnx%2F%2FUgleknJmN8fAJnrUKSkpOvXAlNuNVTV5LFxsZZKbWTRQlEte8YkitPyGt5Q15%2FJyJP3k0lCuYYUPb%2F%2Bnyezu1vKSlzdC9Zb0mPzCJwafJCn8Llro6anBQd4xsU3qzTolcKd325ULfXi%2Ffk07n7yuAWvb1yvOqbq8zere5vl%2Br7arEerxbt8cL7XUv%2FNH7puFXnq9Y%2F%2FW6Z6BVrEmLirIGdWbZl73LikpL2BF7tfPBUybWHCMPoefjs4CM%2Bg46cG3bUp63LyfdXueZZtj%2FnMhfpGNl7GKh1IiACIiACIiACIiACInB%2FCBgdo0mD%2F9IxWKnPXsdA1li2ZNNnpZzefLlcgbxfdek44trVm3EandoP%2B%2BHrFj06jyr4TpW3XilPriNuXnNmrixSoOobuct%2BXKzaiuVbrIbs33ekypeN3ni5bIG8lbtSiII9LDRZcCe8Ru0ruV9HxLDXMVAnMrT9T8%2FSZac2zazW9x%2B%2FbUTnWeer1r1Q8ZcYF1eK3bTL%2B%2FPvR%2Bcp0v2ND3s17b36Stv%2BJ3Lk9Xu1vMv2owU%2BH%2Fh6sZ5vl%2B7ffeCqM31G%2B75TOqh1txvhoW7elxq0nR8dE59ZVmX7ciITEzf4Bzffe4LQiwob3fu7nevvdvaX7R7sN9x9YqD7uW4Hz3y0zu2vS%2FYZHWNrxueVXLgQ5Ol5xkH%2FRGs65X2eLdsTVgNFQAREQAREQAREQAREQATsCRgdo75T56ioGGsLD4%2BqXMHZFo8RGkHipYvXP%2FH3IpXLN5w7a1WXDsNz%2Fr1IrWrtTKxF3Zod%2F5YjPyknjF0wsN%2Fk3M%2BVfjtP%2BQ%2FyfT1s8IxJExay885rX5w%2FH0Ah7m5eb%2BYu%2B3np2vNmr%2B7WadST%2FyjatkX%2F5OSb4Rz2JmWV%2FYSEhJiY2CzdhHtBHV6rTqboGOWmNbsXM%2Bzztu%2B9Mleh7k4tZh3sPPJC0QqXO%2Fe%2FERl5OTyuU79VrxTp5txtpaVj7Nt29PWSfSo4TV0%2FZXVAFafzJSon7Nwbd%2F3G2Fl73yvdt3D5QbFx%2FxV3ZF%2BL9v9D4PqN4Nh4VvKst9OzyCrXKluO9nY7i3BRdcvRPMv351i87%2B2VB5x2eA454tPV9XS5De5PLdlXYt3hWd4BwRmUiZo37f3m6%2BWRMv5T9Y0b%2FAQVLfw9W1KiVg21B6N9ERABERABERABERABEcjmBNAxXnvp85ee%2BaRowe%2BKFKxq2wpULZT%2F2%2Bdzlij7iVNYWATPQD8q%2FOPHxX62wicG9Z30xN8Kb9uyHzT1nDoxLeW4x03%2Fol3rQf967IPZM1YaavPmrM7596JrV%2B%2Fga91anZA4%2FHwvmlMtm%2FYlo5%2Ffza%2FmYNb6HD5ketnStc%2Be8ctaZmeWtf%2BtY7yz8aP8OXqVZJ5IxrbOxUpPapBZJrXtsfy5fJ2eL9D5vQrDxvScc%2FKr2uc%2BrBi9eeuN5OQ1m48Pmrrrcut%2BxGP45il%2FcNfxbj0Xn%2Bo5zC9f6aA23ZPDwl09L1apNem5fB1zv9%2B1ULmBsXEJmWVVdi2HV5NciIxdeCaIV5MUW%2B364zaPAUfO9XM%2F99n6w88vcyH64n%2BW7OPzleX7q24%2BgpTR6%2FDZipuOFlrlWm%2BXJ29lDYmNJ%2B4rnXAaOXd%2F%2BaUyx4%2Bftk%2BPjlEwfxU26Rj2WLQvAiIgAiIgAiIgAiIgAtmeADpGnhc%2BLVOyxpgRs0cNn8U2evjsEUNmfPDuN5%2BWrMn7Sk6eOPvCUx%2BPGDLTQuF5%2FAxHRg6bxRHiMYoV%2FM5aRmP8mHkIINZj0%2B1bD%2FzrsUKL5q%2BLjYkt9eEvxT%2F4YcrExWzTJi9tWK8bp%2Fa7HLWKzXI7bZv3f%2FbfH3kcO5XlLM8Ug%2B11jKsvv%2BlbsdK6swfXe%2B295bbhxK2Pr%2FPavd8n08bAlv3nRs5xGbfg4Ji5%2B4fNO3D4dDArdoZMnJMYco0mE2AR9OuAEznePpu7bFxUTILXmWtjpkcfPMKpK7HJU1e4DZ%2B5l7xj5x%2BYttwtMSk5Uyhl40J8wqPHefp%2FscG92OpDtXYc540kXV3PFFlz6Iml%2B%2F7CFJIUHYPPx5bse27Z%2Fs%2FWu%2FdzY7mM81W32N7HivSx6GzQ5XRHZTRu2OOV3J%2Bm1jHeL%2FA1m72OwSQ4AsC8vM5ER8faww8NDQ8Pj7T1dfC1Y8e8r127uZhPUlISUqSX5xliq%2BzTs8%2BaP97ePufO%2Bicq3sMBjb6KgAiIgAiIgAiIgAiIwAMlYOaVNHXu6WDFN5WaMFuEt7Lu2X0Yb33e7FVWggv%2Bl%2FK%2B%2BkWPLqM5go5RJP%2B3IVdCzdmbOsbxm%2BEZxGwgVixesD748tWCeavkyfXph4V%2BLPb%2B92zEeLC%2Fb6%2B7VWyW22nXaiB6znGP%2F3pGbLWCyfuXL4WcO3fB3j%2FCM4qNsa1xyhQe%2FCPjWFlZ2EE4Ou8TEJOSBvg4ZRwkJMbkMinj4xJiomPtF6Jk6VR8MbrSvij2mfNywT8Ix439uLh4NvsEly5duXDhEu%2FYtQ5Sr7211vFb7tjrGCG5X79a9aeohMSo8MjbbBG3OR4ZnXnv9o3ZsitsxKTwMdPDx04PGzM9ZNTUq%2BNmhoyeHjJiCtvV0dPOl651MkfBUzk%2FvjJw3NWRU6%2BOnckWMnIqZ0NHTSOXLe%2FoaRFT59%2BQ63rLXrc7OOf0xXIb3EqsOdR494nhx3yb7zv5wWrXf%2F6hYBCMYTakDLanl7p8vO5wh%2F2nRx31rb7N46M1h5y2H9%2Fgn9637hodw8vrrF39N7g6HHSMAweOflm%2B%2Fou5SuV6rmT%2B974aPWqONearfNX480%2BdWrXom%2BflT3M9W%2FLtN7%2BYMnnx3r1uZUrVyPXcx2xlPql55MgJq%2FzNm%2FeW%2FLDa88%2BW5NTnZZz27XWzTmlHBERABERABERABERABETgwRIwOsbt3lcSGRl15rTvi09%2FPLDvZMvOo%2B4nn3vio7Gj5nIkPTrGovnrcdvRLr6r0jwyIjoiIooNfxkXg4ehVrFZbicNHQNxg%2BVPcz9b%2BsWnSxXO%2F%2B3iheuN7DB04LQPP%2Fihd49x771VCQ3k%2FXe%2FXr50k2k4z3zHjZ6X%2F%2B3KHC%2F03jcdfh2a781KBLRwtuZP7X74poX18pdOvw39qNCPrHzIKWQK57rdXslVhopYZ5VwGqQSUyCBIt9WbpL72U%2BYv9PEuSfRNRhsVvMgl9Mvv730TCm2Cp%2FWdT3oQRYyVqvaunyZOmGp9BBToMOng44R8u2P12JjrmX8j8fkDiXf9deLtbp65Xj7ZI73b7d55yjs%2FT9F2W6XIOV4gdNPlbmeIiXdtSWPQsZN%2FiGdXc%2F0OHx23HG%2FJntO5F%2Fl%2BpfFN8MwLBHD2kHKQOIotuZQu%2F2npnj5dzxwepC7z6Hgm0ERd8SFjsG8EsK3kP74yTJbSEhogfe%2BsuIxuOjefKPCW29UGDNq7qKF6yp96fyvfxQaOdwWNsbfZ2Wc%2Fv5YgeJFf5g4ceHECQuZjYLWwZob9et2WbZsU5fOI5%2F8d9Hvq7YwOt7BA8defrH0R8V%2Fmjd31aSJCykzf76vfM5dMEXpUwREQAREQAREQAREQARE4MESSFvHCAuNYDXLzz%2BpzYqdvilLW6A8dGw39Kl%2FFnPZZwvIr5OOeIwFc9eSEsUDr9kKwMDpmDltueV0P1gId1f77XSMS0FX8r1ZMf9blZYsXL900XoiT5hrQ%2BA6tfTuPvZvOQoUe%2F%2B7CWPms97pB%2Fm%2BQeW4fDmEU6t%2F38YCqqgKK5ZumjB2Pm91efbfH65fu4tTlcs7f%2F6JU%2Fwfb7xtWLcr74Lx8w2kLyp8VpdomamTl6xdvf3byk2f%2FEcRkwUX7%2FNPalEvU4RWLt9S9atm1NuoXg8WJKBPv%2F6ycd7Xvpg9Y8WKZZvRl%2FK9UTEoMBghpdNvw1o06YPolB4gD6GOEVijm1eOvCdzfHC7zTtHEZuOkQMdo9Dt0hCwcTrnp9Ix7jgGLkfHH78asS%2FoGut2lljnxrtI7FULFvnkiO3zj8AM29nFeyttPrrF%2F4pLUOjJa5HhfwzpO9bVpHHPp3IWK170R%2BSITz%2BpZbYypWo%2B90yJwh9UJTADnbBp417%2F%2FmfhNau3m9K4rEqXqoH%2BYBS%2Fz8rUypP7U0%2FPm9FTCBR%2F%2Bd9831Rp%2Bp%2BAjcqNcr%2FwCUFKlFbHqdMrL396ytvHFLV0yYbH%2F1lo3Nh5d7RTCURABERABERABERABERABO4DAXQMgit4X4l9XdzJVyxbn%2BUszMIXmzfszfVUSfzxgX0n1avViZU8mzfpbQIMalRrV%2BDtyleCb84rGTNyNs64NdViy6Z9j%2BV4b85M25wUojjee6vyW3nK9%2Bg6plunkbzHBBee%2BSb29Wat%2FdvpGOd9LnRsN%2BSAi03n4Q%2BtgGVRFy%2FcwH7vbmORHXbvPJRy5saYkXOey1nC7ZAXX7%2F%2FugUBFUwqMaemTlr89OPFN6zbzdcqXzQqV6aOpWMQPENKf79AZpdA8vflW00W1i2hK%2Fv0GM%2FXNb9v%2F%2BdfCtJf5lTgxWCkFec6Xfm6dNFG7Fm1cps5tXf3YSqaPmWZ%2BZr%2BTwcd4%2Bq3P%2FH%2BzciM%2F0VHR6e%2F0rRThnQZ5fvel%2F4FvzWbb4Fvz%2BWrci7f1z7vf%2BdT8Ltz%2Bb85%2FXQZ7%2F8p7P1Y8bN5v7I%2F5fPeN1Yu%2FwLfBJSudT32v%2BbgpF3vo3z2UnTc1JP%2BxdYeMjoGqsUTS13e%2B%2F3gZxvckSxKr3d%2FY%2BWBfyx1MWqGee%2FqlgshEQmsVpKBP3SMZ5%2F%2BqGb1di2a92n%2Bx9asaa%2FcL5Yu9P63%2FBaxInHJj34uU7pmlN00pbFj5%2F7fX%2FNv2byPmjhFSmvaFDNKHvtL%2Fi6dR1hG8EqUJ58oxkLEFMWskw%2BL%2Fbhli8vGDXs2bdo7Z84q5qqgk1iJtSMCIiACIiACIiACIiACIvAACbAIw8%2FftTaLdlpmMPuga8eRrZr2s5wCIrprV29fsli1Lz6rO2n8Qush5pCBU5s06BGRsoAe2Vev3Pb91819z998CwnvWv2qQkPzZhPOep%2F0adW0T%2BmPqrPyxq%2BtBmb1OO3b6Ri0lEUn1q3Z2avbmGYNe%2F1UtdXTjxebMc0mFPTsOgYJ4vKlm%2BrNogVrn%2FlX8b273WJj44sV%2FB6BwqhDpFy3dhdBL2noGNabX3bvOjSgz8QWjfogRiGSdPx1KNlZlzX3M594HPVmn7%2FY2NjiH3zfoHYX9jv8OoSX3vL%2B3MH9pwwZMKVz%2B2HoGK2b909JmIEPex0j%2FLW8e%2FPmL1wiw3%2FFihWrX79%2BBmpNM2mAT%2BDx%2FSe8XE96uXofc%2FH0PuydFBqWFBYWtfdA9K59N8LCApv0OJHjnTMvfJbo58%2FXxEvBkVt3xxw8fPVisPteT09bxpN8eruffmRfp5sm4FucvJxKx8i9fL%2FTjuNTvAJ4H%2BtYD7%2BvNx19btl%2BBx0jMoM6BvNKWNfi9GlfBws%2BKPgN80o4SHjSO299We2nNtYVxMHfV2xBx1gwfw376BgkZqKcKYHAsH%2F8X0F7HaNli75P5Szu7x%2BIDsmaGKyMwUa8BxtfX8hVqq5TR5NXnyIgAiIgAiIgAiIgAiIgAlmIQKas208IeJLd2pJZqPkOpt5Ox4iKimYSTa4nS%2Fz4bcvuXUY3bdjLpmNMX052dIw3Xy5HdIQpCh2DySO4VLxMoeA7Vdq3HWJVsWbVjjvpGIEkZrVVSqhYrkGXDiO6dhhBPEandsM4TuDH6y997nPuZnRHdDTrk3xndIzqP7Z96h%2FFkJI%2B%2FbhmmZI1P%2Fu4VpkSNfr3nmhVnc4dex0j4vV3N7%2Fyxt9y5nwyg3%2BPP%2F54uXLl0lnjHZP1GLY%2B%2F2cDi1Yc%2Bt5nA76pO%2BWAV9D1qKjANj38P%2F4qaMHKAyeDgtrY3rvq83K5816%2BZ%2FxsalL4klXnSlQ%2B3bb3oGFrC301qmC5QUUrDiv38%2FjYdE95uKNV2TuBo46xeB8BGF1dTx8LiYhOSN4fFOq8ywtl4951jDTeVwLh4OBrRQt%2FX7liQ%2FuparNnrXzsf99bs3oHCdKpY%2Fj5BQYGBrN0xhfl6%2FHepSNHTrIdPXrSw%2BO0j4%2FWx8jeY1mtEwEREAEREAEREAEREIHsT8DoGMzmcGjq%2BjU7%2F%2F1%2FhaZOXGyOex4%2FTZjEjGm31TGIx2Cli5JFf6r%2BQ1vr7SHr1%2B22dIyvKzYua1sf4%2BYCnrxchqCOgAuXeUfJS0%2BX6tB2iMnFa01Y1ZPVS6h34rgFaBoue9yMDfHx8fbxGCzcyttSyIUwZf%2F82qEhaX8Nr1n7Su7XQ15%2Fhw0dY0ueNx9%2F5plnM%2FiXM2fOChUqpF1R%2Bs926PP7C%2Fk7v168R4f%2Bq4NCY%2BJcDvqU%2BDqwipP7yp3VW8517rbiStv%2B6Bh%2Br5bfs%2FnIh5WGzl58MPH6jQSvk%2F6Va%2Fp%2B9v3ivrM%2BqzEpd%2BHuxb8cEhuXsYkP6Tcym6VMrWO89fuBPofPnA2zTRfyCIlosvvEyysO%2FKk6BiOZqXA%2F%2FdDqhedLHfvjPciM7Zo1fmPiiXkNdDp1DF%2FfAK4js7DGpUu2hWvMH%2BFP1kK7fxzTvyIgAiIgAiIgAiIgAiIgAiKQxQigY%2BR6suSxI97MQcCNMhttmD1j5eN%2FfX%2FenNXsR0fH9us9IeffCrOoKV9vGY9hlsto0aQ3kRVbNruQjDerElZhWx9jrW19jFq%2F%2FMa6Iqe9z7Pvd%2F7ipyVqvvtGRYI6Dh30ePaJD1k7lBAXvLb5s9cQ%2BMG0EZK57HUnu3OdLuYVrrw8l5feNqzXjVNMeGFeSfdOo8zkoKDAK0wOOpVSeFDQlYsXL5MmPX%2FhdRtceem1e9QxnnjiiUqVKqWnuvSkadV1aYnKwzbuOX09IiK4y0CfvJ9cHDh64owdH3wx9KUPujbtvdrSMVy2H81Xpn%2Bewt1qN5996jzuavLVibP9i1Xwatr1t07zi387Kib2pmqUnnof5TToGFPs18dYvA8do%2FfhM2dSdAyiMu6DjpGU8pLcTRv35Px30bKf19m54yDaRbt2gx7%2F%2BwetW%2FY3Kl86dQwTdDF71u%2BP%2F71QlcqNuHAoasigqbwtZdbMFY9yR6vtIiACIiACIiACIiACIiAC2YBA62b9%2FpLj3bKlnHgbyLeVmrDxYpFlSzZe8L%2FEEqa5nytd7fs2rM%2FJe1T%2FliN%2F35TlN5n9wUtbLgbc1ArmzVn1%2BF%2Fe37n9IDS8T5zjRasEVNSs1o5FRQiZQCRBc%2BDU3NmrnvxnUdZZRdDg9SKP%2F%2FWDvK9%2BwYIArG3y1RcNCdvAAGI2eL%2FqP%2F63QLXvWpGFZ8dtmvdnRdYKn9WjwPferPTvxwo3rt%2BD6Iv4hAQWJyHXl2UbMNOEAl95vgxaCrJGpfLOpUvUuHYtXS9CjV%2BxIuTVt6%2B8kOfKi69GvPjaxudy%2F%2B8%2F%2F8k8kQz9EY8xbdq0zBoMu%2FafuxgSFe%2Fi6vNh5cDKNQ8t2VqzzYJXi%2FfMW7L3mx%2F1ctAxCnw%2BMG%2FJPq8V61G47MBpC%2Fcno2X4nA%2Bs3cK3zLdbRi%2BOT0jKLKuydzlXY%2BMXnw38atORt1cceGflAT4%2FXnt44JFz51J0jONXI9u6eBdbfSjvyptnf9nmsS8wNCZFeUg%2Fmfp1O7POp4fHKfssDPK8b33BZnQMTk2atOj1V8s9%2FeSHzz1T8pknP6zj1NGKqfiw%2BI%2FvvP2ltT4GAkWOHG%2B3%2F%2B0%2FM7lYSvTvjxU8d86fcoh96td3ArEcTz9ZnFUyWDfj55%2FaWGvw2tugfREQAREQAREQAREQAREQARHIQgTmzvodicDpl%2FZ8mu3n79uYt4cccT%2FBop0sW8HrXTau383%2BhLELaNrSRRt4sal5Cwxf9%2B1xa1C788kT50yrPY6dat6od%2BVyzq2a9R3UbzKzUXjhC6cQH2ZNX8FrVat80ZBXkIwbM%2B%2FXVgOuBF%2FjlL9fEKJEpfINbFWv2MrqrF07jSQ8g1O832T86HnkYpmOyRMWFcr3TbOGPc0sksTExBlTl%2F3yfWssRMpwO2x7YQrxJAP6Ture%2BWacBkfu8Icksnx5ZOt2kW3axbb57XirtnUbNmyUkT9nZ%2BdZs2Yxp%2BYOFaX%2FdFhYSN8Rp18rea7ToDETNr%2F72aBc%2BTu%2FVrTHq0V7EI%2FRoNvKK817e%2BbI4%2Ft86b1bjrxVss8rRbqjY7xSuFuu%2FJ1%2Bbjj92KlLsL46aU5AuWrXo2PTX%2B2jnDIqMeng5bCeh8412O3FVn%2BXV2sX73lnLl6MsgE8Fx4zztOv%2Bd6T9VPOkmDQUR9CNRKS0I0y8Oft7cMbRhzeCMxgPnjgGJsZ1aY4JkytXbNj6ZKNboc9reWIOeXm5ul68Jg12HgpyY7tB86etakW5u%2F0qfO7drkyqeSPAzdOnDi7evX2hfPXuuxztz9uJdCOCIiACIiACIiACIiACIiACGQzAvbu1R2bxntUd%2B1wtZKZxTdYAcM6kqEdHlWjn1iCiesBj%2BefKDFkYKZFPmTImPuWmLeTRG7cFe%2FhHRmfvOvAuX2u5%2FYfPm82l0M%2B3j5XEk77Rm3bH7vPPexa5AF33%2F2HfawE2%2Fee8j57M04mIeDi9QwGDNy3NqoiERABERABERABERABERABERABEXgYCCxeuO65Jz6sU6PDwL6T69bs9MTfCrdu3s96fJxRC4nWYB5KsYLf9e05rlunkfnfqpz31Qpn7lYVyWjtSi8CIiACIiACIiACIiACIiACIiACIpC9CRC4Pnn8orKlnYoWqFqq%2BM8D%2BkyMiIi66yYTCnL4kCevf%2BV1q6yAUf2nXw%2B5Hr%2Fr0pRRBERABERABERABERABERABERABERABFITSEpOJpTCesVq6gQZPRIaGhEeFpnRXEovAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAlmCwPd122oTgexHoErNFj0HT8gS16CMFAEREAEREAEREAEREAEREAERSD%2BBlwqW0yYC2Y%2FAM3lLV2vwW%2FovBKUUAREQAREQAREQAREQAREQARHIEgReK1JRmwhkPwIvFihbo3GnLHENykgREAEREAEREAEREAEREAEREIH0E8h%2BDqxaJAIQkI6R%2Fh8BpRQBERABERABERABERABERCBLERAPq8IZEsC0jGy0K%2BQTBUBERABERABERABERABERCB9BN44bmPtYlA9iPw1BPFfvy%2BVfovBKUUAREQAREQAREQAREQAREQARHIEgSez1lCmwhkPwI5%2F17kh29aZIlrUEaKgAiIgAiIgAiIgAiIgAiIgAikn8DzT5bQJgLZj0DOf0jHSP%2FPwEOa8vr16xEREWFhYYmJiQ%2BpiTJLBERABERABERABERABETgvhPIfg6sWiQCEHhodYyEhARPT68j7kcjIyIdLnfcdh%2Bf8%2B5uR2JiYhxOPTxfAwIuHjp02NfXz94kLD939tzhQ25XroTYH7%2FH%2Fbi4%2BKVLlk%2BaNDUwMOgei1J2ERABERABERABERABERCBbENAPq8IZEsCD62OER0dPWni1CGDhvv7X3D4GYmJiZ02dWbvXv28T55yOPXwfN29a0%2FfPgMmjJ9kL1kkX7%2B%2BbOmKPr36HzvmkYmmxsXFzZo5Z8jg4RcuBGRisSpKBERABERABERABERABEQgSxPIlj6sGiUCD7OOMWXy9GFDR6b2zZOvJ7u6Hlq7dn1YaNhD%2B6uyZ%2Ff%2Fs3cecHZU9du%2FqaSH3oUg0hGQpqKiIAoqRaSD0qUq4J%2BqFCEhvffee%2B%2B9b3ovm832ku3l3r29l3m%2Fcydc900gJoiSbJ75XC9zZ86cOed7Juvn98yvrO%2FcqVvHz7vOn78wFe6BjjFr5pyOHbrs3Zv%2BDY4cHWPsmPGwwgnkG%2BxWXYmACIiACIiACIiACIiACJzQBGTwikC9JHBC6hjxuN3uqKioCIfDqb8q1dXV%2BzMyMzIyKyurCN9IHWfH6XJlZWVnZOyvqqz%2B4lSCHkpLSn0%2BX1VVFQEs2dk5nv8%2FgAXxobi4OD19X052rsftqdthPB4nguNLr0o1s3SMLp274yaR8r44RMdwuz14mzidTusqumUwaBHMi7CasrJy5kLii7y8%2FH3p%2Bxgtl9PSGjAzcn4h46R0jLzc%2FAMHzDEXFhbRQ2ow7NAnQS6cKigopL11ivtwl4qKyurqGugxmP%2BfXN0OtC8CIiACIiACIiACIiACInCCEaiXNqwmJQInoo6BGT5%2B3ET0AWx2%2Fo4gOKxfv6F7t14dP%2B%2FCh%2BOrV62xrHhUiz179vTvNyh1au3adUnviMTs2XPxlxiTdGMwz3boMmzYSHQD6w%2BTs9Y5Y8asTh278uEs4SE5ObnWqWAwuHTJ8q5denD88w6dBw8elpuTd%2FifM0vHwEeiU8duOJbU1tbS5hAdY9OmzfSwfPlK63LmNWa06VaBRONw1DLs3r36Dxw4xBoDksiSxctWrljFBJO37kIDsm1wraljjJ3QtXMPxpl0AunCJTh%2BeL0HU4vYaxxTJk%2FjKqur8eMnWtEu3Khv3wHdu%2FXu2aMPcTq0icVih89FR0RABERABERABERABERABE5EAjJ4RaBeEqgHOsbWrdsxzwcPGrpx46bNm7aQVQODnYP8ncHLAqu%2Fb58B69I2bNm8jVO03LFjF6fmzJ6HdtG9a8%2B5s%2BcRpTJu3AQkBWJAkD6QBaZOmdGhfecpk6bu3Llr2bIVdNKv30BUjng8sWL5KlqOGzth%2B%2FadyznVpcfAAf9fEgzr7xs6BsOYO3f%2BtGkzSZSxcMEi3C3ovG5cyZF1jAH9ByNKjBo5dtPGLdy0d6%2B%2BdIiaMXPmnB3bd5LYk5%2FIFyT5ZGM8%2FOzfd%2BDq1WmbNm5GOeGmixcv5Y4%2Bn3%2F8%2BEmcnTVrDnlTZ86czf7UKdPRc%2FDEYF4wGTRo6NSp09et28AgT8S%2FzxqzCIiACIiACIiACIiACIjA4QTqpQ2rSYnACa1jEAeBd8SokWPwYUhVBiGkAtlh0sQpxIxMmzoDw5%2BICetfdH5%2BIX4UEydMxvCfO2c%2BcsTaNWnWKbJw4NGBaECoBXEcNBs9epzff7AeCjICssbmzVurKqv69O4%2FfNjIVBWVpUuX08%2BWLVsP%2BaOBjsHxFStWVZRXokjQITEdtJk1a24qP8aRdYz%2B%2FQYOGDCY%2BBer58WLltLh7FlzLJcJomCQZfr1GUADnE%2FGjpnALVIzJTilT2%2BuH0IKkd279qBU4H%2BCpkFXyBcQYLK0qaqqRuTBrwNB45Dx66cIiIAIiIAIiIAIiIAIiMCJTkAGrwjUSwIntI6B%2BECyCGIikDJworD%2ByCBEkEWTRBmcIs4CiWP58hUbNmzks3zZSn4OGTyMsBF0DNwS0tMzrKuIs0CgoAxKKBjCqQNnBhqn%2Fmo57A6KpXK73btNTWDE8FHr123YsJ4%2BN1l%2BEYsWLkk1tnYsHQNfDn5u27qde6F%2B1NY658wx%2FUCsPJ9H1jH69R1IqAtqjNVhsgBK57S1662f1JylRknvXv2YpqVjMLVUnk%2BOTBg%2FCT0nP79g4YLFpgPG1BkbN2wiAGf9%2Bo1cyCzQVdAxmDX0%2FMdxBVtrvvoWAREQAREQAREQAREQARE4VgL10obVpETgxNUxCAkh9SWpOLHWJ06cgpvBIf%2BoCQNBCiA0gw9mOx92cFoYOnSEvcZOxAfW%2Fd69po8EW01NjaVj4OCxZk0ang%2FY%2B9aput%2BbNm2hE3w8Un3SIR8CUg6JyKirY%2BD%2BgdxBn0SXENCRvK9Zr%2BTf6hgM1ftFjgtcR%2FAJQc2wxkNd2sN1DJQW6yyDmTF9NoNEzzEjWZIBKZ07wqEb3xDDHwM%2FDTQQZj1yxGif3193mtoXAREQAREQAREQAREQARGoBwRk8IpAvSRw4uoYGOOY7ZUVlfhjjB41LuWPQfQEdUDYTB2j30CCJoi2KCo8YH6KDhCKUl5WjrBg5sf4Mh2DfjZuJP1mp5UrV6f%2BcOHjQVURXCC2bTM9K9AiDhQVW32Sa5Q%2BcedINbZ26uoYHCkvqyC6BAGE0fJd1x%2BD2BPrEkZFeAhuFVaeT0SYY9UxUv4Y0UgUbadr157kILUiaEgQylCtMRcnx0xkCjci%2FEQ6xiFrp58iIAIiIAIiIAIiIAIiUD8I1EsbVpMSgeNfx6DC6SF%2FQ5AarHolJcUlpLAgXqNXz76pZtU1dsJJKL1BEAflP5A7cnP%2FVU8E6QOJgw5nf6WOEc7JzunSqduE8ZOtoic03rZ1Ow4MOGNYyTdGjRqH24Y1KrJVFBUVHV7m4xAdg8Zbt2y1fDlSOgbd4mKxcOEiqytCSIYPG%2FW1dQzcQnK%2BqJxChVlkkP79BzkcDkJgcAVZMP%2FgXbgXPh5lZWXsSMewyOtbBERABERABERABERABOolARm8IlAvCRzPOgZGPbY5gsPq1Wsppcpn1crV2P5ul9tK%2FmDVXbXsdIIs9u%2FPzM7KmThhCmY7eSRwzNi2bUfHz7uSLXP79h04Y9BPl849ZkyfhRJi5cc4LK5kFAIFfhfoJPhdLFiwCOGCTtAEkBdwY0DZoFwI2TMmT5rKvXKycynzQcTKrp1mDZS62%2BE6BjedPGkajVM6Rn5BIfIInXOL9L37qGzCKTSZr%2BGPYdUrGTpkBHk89u3LGD9uEoOcP88sv0LIzOBBwwgnQcooLCjauyed%2FB6Ek%2BCvUqm4krprpn0REAEREAEREAEREAERqF8E6qUNq0mJwPGsY1A8FBUCwx9JwfpgmyNukJ2SQqJIHJaOgfJAgdGDCSs%2BNxuT2TIQMP0lCK8grQQuGRykH%2FQNTHjrqtmz5vJzzx4zTwUbxj45M3HtQMTgJ0oCvhypq9AW9uzZiybAKZfLRVzJ5x0Y0sGxzZu7IJXFwuwruSXTcnaimskXB8z%2FktCjX98BXEhv%2FIxEoozcmiDfzKhrFzPwxNIxqCQyZMjwVM9rVq9t%2F1mnVIEV8mOMHjWWgVl5PseMGY8GQpCIBarj550nTJhcW1tr3R01ZuiQ4czXvBfVZrv1IvsHGUW4EeVcYaL8GBYofYuACIiACIiACIiACIhAfSIgg1cE6iWB41bHwMrOycnFX4KyGqkPP0n4gHBBGY596Rle78FaHsngjgPU41iXtqGgoLBulAfiAyEnmzdvwRkDRwWr%2FAcHUTNIUkHsifVnij7378%2FkjtEv8oUSsbI%2FIwvdYOvWbagcdf%2BahUJhIjjWrl1P%2BQ%2BCSqLRWN2z1j6JO%2FB8sMI3Ume5L24hyfseVBhw8OCma9akUdSV7BaEwGRk7EejwHlj%2F%2F6s7OycVGwLpVFRXfi2emOcVmMUG7J65uXmZ2ZmVVfX4NeB4sG3Jcikbk00DRg5tXXLNjqxNJmAOcf9DIDeUi21IwIiIAIiIAIiIAIiIAIiUD8I1EsbVpMSgeNWx6gffzc0CxEQAREQAREQAREQAREQARH4tgjI4BWBeklAOsa39SdF9xUBERABERABERABERABERCB%2FyqBemnDalIiIB3jv%2Fp3Q52LgAiIgAiIgAiIgAiIgAiIwLdFQAavCNRLAsetjkHOh5KSkrFjx07UJgLfKgEewnXr1imFyLf1f766rwiIgAiIgAiIgAiIwNcmUC9tWE1KBI5bHWPTpk2ffPLJqFGjRmsTgW%2BVAA9h7969%2B%2Fbt%2B7X%2F70MXioAIiIAIiIAIiIAIiMC3QkAGrwjUSwLHp47hdrs7d%2B6cmZn5rfxj101F4BAClLPp3r377t27DzmunyIgAiIgAiIgAiIgAiJwPBOolzasJiUCx6eOYbfb33nnHafzYFHU4%2Fkvg8Z2khDAJWP16tUnyWQ1TREQAREQAREQAREQgfpBQAavCNRLAsenjuFwON577z3UjPrx10OzqAcE0DHWrl1bDyaiKYiACIiACIiACIiACJw8BOqlDatJiYB0jJPnj5hm%2Bp8QkI7xn9DTtSIgAiIgAiIgAiIgAt8KARm8IlAvCUjH%2BFb%2BnuimJxwB6Rgn3JJpwCIgAiIgAiIgAiIgAvXShtWkREA6hv64icDREJCOcTSU1EYEREAEREAEREAEROC4IiCDVwTqJQHpGMfV3xkN5rglIB3juF0aDUwEREAEREAEREAEROCrCNRLG1aTEgHpGF%2F1T17HRaAuAekYdWloXwREQAREQAREQARE4IQgIINXBOolAekYJ8TfHw3yWycgHeNbXwINQAREQAREQAREQARE4FgJ1EsbVpMSAekYx%2FqnQO1PTgLSMU7OddesRUAEREAEREAEROCEJiCDVwTqJQHpGCf03yUN%2Fn9GQDrG%2Fwy1biQCIiACIiACIiACIvBNEaiXNqwmJQLSMb6pPxHqp34TkI5Rv9dXsxMBERABERABERCBekng1OY36iMC9Y9As4bX3v%2BbV463f7MOh%2BO9996z2%2B3H28A0npOWgHSMk3bpNXEREAEREAEREAEROHEJ3Hv3S%2FqIQP0jcPedz334fq%2Fj7R%2BmdIzjbUU0HukYegZEQAREQAREQAREQAREQAREQAS%2BioB0jK8io%2BPfFgHpGN8Wed1XBERABERABERABERABERABI5%2FAtIxjv81OtlGKB3jZFtxzVcEREAEREAEREAEREAEREAEjp7A19YxYrFYeXn51i3bVqxYtXnzloKCQp%2FPl7pveXnF%2Fv1Z%2BfkFsVg8dZCdA0XF%2BzMyKysq6x780n2Xy5WZmbV61Zotm7dWV1Uf0iaRSBQUFHKLmuqauqeKig5wVVZWtvVhv6CgqG4Dut21c9fKFas3bdpS8WXDcDqdXJWXlx8OR%2BpeyOys8WzevLWm5kvSiXi9vj179q5cuXrD%2Bo3FxSWMsO7l2j96AtIxjp6VWoqACIiACIiACIiACIiACIjAyUbg6%2BkY1dU106fN7NypW8fPu3Ro3%2BnzDp07dew6aNDQjRs3R6NRGE6bNsM6uHv3nrpIhw4Z8c9POixcsLjuwcP3t23d0ad3PzqnE767d%2Bu5edOWVDMkguXLV3L3Du07L1%2B2ou7xwYOG0t66kGvbf9ZxyODhqQbFxcX9%2Bg5Mddu1Sw9EidRZdpBfBg0cyoV9%2Bwyw2x2pU%2BgSQwYPS13Ys0eftWvXpc6ygzIzeNC%2FGjC21avWSsioi%2Bjo96VjHD0rtRQBERABERABERABERABERCBk43A19AxSkpKB%2FQfjFGPdoEsMG7sxFEjx6AJIGj06N7b8p2YPn0mZzHnBw4c4na5U1SHDxv12acdFy1ckjpy%2BA5eDZ06dkNMQC5AhejSuTv98J2bm0djdJIli5fROUf4XrF8ZaqHSCQycMAQGg8aMGT8uIljx0wYPWrcnNnzrQZer3fggMFc0rVrjxHDR%2FXq2ZeWfHAasRqUlpZxuTXs%2Fv0GpXQMvz8wfPgoxtOta89Ro8b27tWPNkx%2F%2B%2Fad1oXcd8Tw0Rzp0qX78GEj%2B%2FTuT7efd%2BiyLz0jNTbtHD0B6RhHz0otRUAEREAEREAEREAEREAEROBkI3CsOkYoFBo7doJl1K9YsbKmpgYrPhAIHCg6MHnS1BXLV1nxFJaOgbjRsUOXBfMXpYIsjkbHIDZk8ODhKAN4Qbjd7p07d3fr1hORZOXK1azO6tVr2UcoQFU4RMeoddTiR4G%2BsWP7zng8zsDCBIdEDoaH4ONhaRTr128gSCQnOxfVBfFh5ow5dFtbW9uvz0DmxZjpvK6OkZGRyRG63bRpczAYLD5QjNzBhfif8JNrM%2FbtPyiqrFiFWpL0%2BhhAgwkTJhF9QwNtx0RAOsYx4VJjERABERABERABERABERABETipCByrjrF3bzrOEhjpRHYcAgrpgM06aOkYSA1dOvfAxieRhXX8aHQMWno8HqfTZV2CEIELBArDksVLOZKbm9%2B3T%2F%2BZM2YPHTqCYdT1xzhwoBgXju7deu3dk44ugYhh9WB9z5gxizCTYUNHWuIDB2fPnoskgm8JLdFnZs2ai0AxZ%2FbcQ3QMlBPuzilGZXW1edNWJBEUj8zkvBYvWkrPSB8e98EGa9eso%2BeePXq7vzhSdyTaPzIB6RhH5qOzIiACIiACIiACIiACIiACInAyEzhWHWPG9FkY9f37DeTCI3BDx6DZ6FFjichAbSB3BJ4VtD9KHSPVM44cZKJACaG3XTt3W8fJ1elyu0eOHMPBujoGfhEoJ8gLeGXwGTRwyLy5C2jLVQgskyZMQW3AaSTV%2BZo1aWTYIAykuspMForvBkoFvhwMuK4%2FBnk7US1QSAg8sa7FJYOfNOMUR2bPmkvPI0aMZt%2Fa9u3LIK4ERaWkuOSLY%2Frv0RKQjnG0pNROBERABERABERABERABERABE4%2BAseqY0yaaKoB5MQ4csQEOgYOCTTO2JeJtoDgsGD%2BQugeomOEQqYjRGqzcoSmFoHjixYtRZfgcvwoKAiSOuX3%2Bwk8OUTH2LZtB2EseFMgIJg3TaYJHTN6HI3piiQeDAkdJtXJpo2bLYGioKAwdXDb1u2H6BhVVdV02OnzrqNHj0tP34ezx8QJUyxpZdWqNVzINOmZcJtUJ1RLYRiMfP%2F%2BzNRB7RwlAekYRwlKzURABERABERABERABERABETgJCRwrDoG%2FgzY7DhaoAwcAZelY0wYNwlpYvGiJQgOGPXULeXCVJ5PlJBhQ0cQM4JHBB%2ByVaxLW5%2Fqk6Ko48dP4kI%2BVCGhkkjqFDskuDhcx8DlIy1tPTVTcJwoLCyaOmU6agOj3bFjJ%2Ffi1uxPr6NjbNiwCcmChJ9k5Eh1friOwSn0ChQPnDf4tj5MB6WCsrOctZiMHTs%2B1UlWZpalY2RlZqcOaucoCUjHOEpQaiYCIiACIiACIiACIiACIiACJyGBY9Ux5s1biO2fDLIoPQIuS8egaAg6BprDsCEjEAFGjBhNvAZiglWvBG2BAiIIAvhO8MHwX7M6zeqTTBeErhCawcE5s%2BdZsSF1b%2FelOkbdBuwH%2FMF%2BZiemK0jCSEyZNA1PEnwnUs1WrVzDYA4psfqlOkY0Gtu5Y9eE8ZModDJp4lQkEdSM7l17WbVO5s6eT8%2B4mqR6JosI98WLo6ysPHVQO0dJQDrGUYJSMxEQAREQAREQAREQAREQARE4CQkcq45RkF%2BAttDx867Tp81IlQKxuNXWOq3SqPxM6RhWss3s7BzLgcEKx7B0DHJfUNqjoKAw9bFyaJCHkygSUy3p2Xf37r1W54d8UyHF8sdYuWJV3VN1FQ%2FyXfTvn5RNFpllXq2snmTq8Hq81iXTppnBL6TRqDuR7QSnJPNjMJ26Paf2ubU1PFJ%2FWLNbunS5pYc47AdzhixbthIdA08P9JbUhdo5SgLSMY4SlJqJgAiIgAiIgAiIgAiIgAiIwElI4Fh1DCx3wj2w9HFImDJ5WkFBIT1UVVYhOAwePKx3r77FxaafxiE6BpLFwgWLuQo1AwPf0jG%2BivbWrdvonMbz5y%2Bkagn9s9ntdkqaconfH7A7HCUlJUMGDzfbzFvIWTQHblGQX0jxEUJL8ILgw%2BVILigMxJVwIbk3%2BUnPy5auqKysJGtoMnFHF2swJAKtdTrpas3qtbTBSSMvr4Cf5NbgWrOa6oFi7kLeTqJIuC%2FuJTt27LKmgFcGXVmDIRyGnBgoGEyTCimpAi5WS30fDQHpGEdDSW1EQAREQAREQAREQAREQARE4OQkgKn%2B3nvvoRIc%2FfRrauzDh43ETsdyx7%2BC2BAKoSZN%2B04IBcgFdHWIjsERfC1QHpAI%2Fq2OsXTJMlQCeiZpBtVOBwwYzKdf34EICIgVVEFln%2FgUpANUESJcGADNKiuriED59J8dGEmvHn04bt0rmSDUFECCwRA5PznLIOmZ%2FmlAs7JkFRK73UHLAf0HIcVwqluXHtyFDzVVuZa6JFyF5wahIvTAZ%2FbseSQp5RQbYgWSDgfpkHQfjIodvvPy8q0G%2Bj4mAtIxjgmXGouACIiACIiACIiACIiACIjASUXga%2BgY8KHu6YIFi1ADLA8HzHZUhUmTpmZl5Vj0ZiTrlZAfw4q8sA5mZmZj3eMgsSgZ6PFVnJcuMcM0aImeQP%2FWB5UAGQTFYOHCxSghHORsqg1xLjhgIGXMnDGbkTAeqwEDqJuhArmG1BadO5peGTRApsB3whoGRUlINMpxq1t6pgE3mjZ1Bg2mTp3OvnUVzfDoqBuKQgPiWSZOmMwltOGDP4blBPJVc9TxIxCQjnEEODolAiIgAiIgAiIgAiIgAiIgAic5ga%2BnY1jQ3G5PcXFJenoG9T6cTmfdSqw%2Br6%2FWUevxePCgSBFmHwGE41awRur4ITtEjtSam%2FOQj5U9g2u%2F9KxVs5VbMBICPaiNgt%2FIIWoDN6LMCrEhlE%2FNzyuwAlWsuzN4YlgOuSM3soq98k3qUa4iAQj9HzJg6yf3ok4K7ii0qZum40sb6%2BARCEjHOAIcnRIBERABERABERABERABERCBk5zAf6JjnOToNP3%2FEgHpGP8lsOpWBERABERABERABERABERABOoBAekY9WAR69kUpGPUswXVdERABERABERABERABERABETgGyQgHeMbhKmuvhEC0jG%2BEYzqRAREQAREQAREQAREQAREQATqJQHpGPVyWU%2FoSUnHOKGXT4MXAREQAREQAREQAREQAREQgf8qAekY%2F1W86vxrEJCO8TWg6RIREAEREAEREAEREAEREAEROEkISMc4SRb6BJqmdIwTaLE0VBEQAREQAREQAREQAREQARH4HxOQjvE%2FBq7b%2FVsC0jH%2BLSI1EAEREAEREAEREAEREAEREIGTloB0jJN26Y%2FbiUvHOG6XRgMTAREQAREQAREQAREQAREQgW%2BdgHSMb30JNIBDCEjHOASIfoqACIiACIiACIiACIiACIiACKQISMdIodDOcUJAOsZxshAahgiIgAiIgAiIgAiIgAiIgAgchwSkYxyHi3KSD0k6xkn%2BAGj6IiACIiACIiACIiACIiACInAEAtIxjgBHp74VAtIxvhXsuqkIiIAIiIAIiIAIiIAIiIAInBAEpGOcEMt0Ug1SOsZJtdyarAiIgAiIgAiIgAiIgAiIgAgcEwHpGMeES43%2FBwSkY%2FwPIOsWIiACIiACIiACIiACIiACInCCEpCOcYIuXD0etnSMery4mpoIiIAIiIAIiIAIiIAIiIAI%2FIcE7Hb722%2B%2F7Xa7%2F8N%2BdLkIfFME0DFWrVr1TfWmfkRABERABERABERABERABERABOoTgXA4PGHChEmTJnm9Xo82EfhWCfAQ7tmzp3379shr9elfmeYiAiIgAiIgAiIgAiIgAiIgAiLwDRIoKCgYMmTIO%2B%2B88742EfhWCfAQdu7cefny5d%2Fg462uREAEREAEREAEREAEREAEREAE6h%2BBRCJRVlZWoU0EvlUCPIT4g9S%2Ff1%2BakQiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAj8VwkkDIPPv9%2F%2BTaMjnT7aW%2FybQRy8hdVb3e9%2FXXewyb8OfO29RCIRi8US8fjX7sFIJOqSTQ34YIfm729uuF9%2FlN%2FAlczzi6las%2FzyPo9htl80tbr74teRn9R%2Ftfry23%2FJURbZXIO6H1oduSPrwTCfjfqyfF8CRodEQAREQAREQAREQAREQARE4L9DAEsqEolgUsXjcXZCoZC1bxlZfFtnaVZ3MyIxIxyPeEM5Wfmr1m3dm1PsCBm%2BhBFJGKbVHjcSkUQ8GDbiMSMeNWJxIxI3AlHDEzOChhHhk4iHo7FwKB4NxGN%2BI%2BE34r5EzGPEvEbMZ0QDRiRoRLicexrhhOE3DI9heA0jEEvEEkY0EacD81wibt4iHDYCwUQoZEQ5zR25PcfjCe7LxQHD8CeMYNQIBhIBTzwR9CcCTiPoMCJ2I%2BpM9uyKhP2hcCJKs7DhDxsRc8yJaCQWCSYSUVMsSKBGmJu1COxEk5v10zprEbNO8R0IBDL2ps%2BbNit7T0bYF%2BBsKGZOKWTEmR5jjRixcCLsD%2FujsVAkGgyHmVw0moj6o6FQAkbMK2S4%2FSY0CMUNn2HUGEaFYVSaF0aNSMTw%2Bg17rREIhKMhtxGlAXMFsDliGEcijMGkxIIkt9RorcFbpzjI2DifOhsOh2nAWWvjLD9pwE926NZqzD4tU7NmvOYnnlxta5GTIzFXg9kwJj7m0n%2FxCRsGnxBXJcJGuNbweAxvyGAu3ljQzxIk6I0ViRmxSJxF51cwEfeFQwhD5m2iXBQ0whEjFOPpYsF8NKQVz0MoavgihodT4UAiWGtEaoxotdkgHo3Fwv5AnPWN8xhFjHDAfNjiYYYYi%2FhjEZ%2FBWMxhhRMJ7hY0T8WSNwoGDB6wYMjw%2BA2X2%2FB5jLCXa8MhT23A5TKilUaYdak1DDePm2H4wuYAzcmy3kFmGI8EguEQi2P%2BcystLZ0%2Ff%2F7KlSurq6tZo7q0rVXQtwiIgAiIgAiIgAiIgAiIgAiIwBEIWCYqDbBJLSuV72AwaFmvlpHLEUvi4Jvjppkfjuzfm%2F7Cn1%2F5xT339h81odQVQMdAq0BIMK1oXjVHTRM0GvBF%2FL44UgMaRxDFIh6we8vzivZu27Fj65bCwpzc3H2VlYWRiCsWc0VRF6Ie09CPmYa%2BKURgFyeNXYx0zMMwikg0hlEdRmIIBaNY%2BtyFD%2FYsdr0pm8Sw%2Bc2X%2B8mNYZhSRpCRIVD4jLA%2FkeAaTNCI04hyM5eRcKKexLBI0TIQQ7BtUTwwXc3e4rFwJBpCEAGO1aHFytpPGfiY8%2BCyLHoLF%2B0dDseq5SuGDxi8ee16rxPrFgAxTGhvLOSOoS%2BEg%2FGQL%2BwLRZmHLxoOII2APxgOBNGTzEmjakTMkfhMCSMeMVwJRIxEqZGoMhJIIkYwmPAHDZcn4XKxXLWJcG08ho6BMBCMxlOrxn3ZGDBH2KyfzILN2rfO8s0RGtDSWne%2BWWs2DmJrM8dUJyn5gh2fz8ezkURuqgtoDFHW6AvdgnuYOoo5m%2BTHUjMO0TRi6BwRt%2BHxGl4EqUg8kIgmJalQNB4wSYCfFaUDRhA2JQ165MGImEoBikSYB8DUMZDADuoYrKClY4QjvnigJFJbFHPlR5w1Ub8pxiDBwScUjvMgIFPEw4lYOB4NxqLBOCKTKZ1EYvEgzzdXB33upKgSMpWnYNBVUpK3dVvFvoxAyQHDW4uOEYv6%2FfEgj6zDiFcZcYQmVpp%2FCIgrqCwBV7iisHLPll15Gdnu2lr%2BDfn9vqqqqv37948fP37WrFkIGia9L7bUimhHBERABERABERABERABERABETgcAKWxco3p7BVsVstc4qfmLSYqGzscNw6yw727N69ezMy9lU6ykuqC0dMGXn%2BFd%2BxNbM99MIza3Zs98SiYWz%2BGEZhMBbCpMZeDUVjvnDUH0lgW8ZcXm96ekb3Tt1fePLZJx954vGnnnzmzy88%2Ftwf%2F%2Fz26xPnTynzVnhjnmiCt9d4QWClIikkDfq44feEqqtqKypqiotLCwuLvF5vOBLBgsY5wPS7wHHCHCpyBm4KCd55I3rgvGH5J%2FBWHNPUPGk6bPDmPhyJR9AKPPGwJxHBEcTLq%2FJohMmi3ZiSSCQa9QdiNE4gZHAB%2BouJyNoAYhHjJzsYoRaiFCWrAac8Hk9WZuaW9RuL8wtDuIskbfAQ7hZxNAtEjDAKBh4NiVikvLjIba9JMARezTORRBy7HJZJZ4C46WARMCfGjKoNo8ww7BwIBRIen4Hqgprh8%2BLRgT%2BGMxbzoQnghIIDAxJQch39fn95eTnE%2BMk6Mjzrm%2FGwWZOy5sIUmA5nTRTJHb7RMaxvhBp2rMvZp1trypbQASJ0BsvVAtqmxwR9J7vn%2BBfqBYeTN%2BW%2BfNAmkoITl8bigWjCh9%2BJ1%2BcoKsw9UJjv93jQwWJIGXQXNZwOV0VppcPhjCBccDGEkLJw5jEdKnCuwGci5jZiQUv1YBFxv%2FGbeoUn7M2oLNhVnpfvqXaZ4KP0FjOfl0goiTiEZIaQkWAlEEPM54fu8BPyRYKekN8XDiIZcQQXIH8ktGXXjr6DBowdOzpj29aow266DOFjEw3nl5emFxfuLSrIqa72JmWzUCRRXlYza%2BrcN15989mnnvnzcy8OHTp0bVraipUrFi9ZnJOTs3Pnzj179jidLBqETC6wZUutiHZEQAREQAREQAREQAREQAREQAQOIYDphClqmU6YqBinljHFQbfbzVtjPAo4iFVrmVrsZGRkdOjQoX3HDgvWLNhatLXXlD5nX3MeOsYvH%2F3N3NWLvNGk6RjFm9%2BFYR0Ju6ocB0qqC4rtB4qcpSW%2BmmKfI23Xtj%2F%2F%2BZUfXH3jFZdddfYFFzZs1dLWvEmDM1r%2B5vknlmdsrU6EcD0gqAAVA3UiTgiAL%2BQurlw9Z9Hgrr26fdbxs08%2B7dS504IFC3bu2pWbm8uwMQEZcCgaDibMABXCTxyGUWUGX1ghGOY%2BR%2FDDQJHBsEVQSUozmLFIBqaJyncE%2BSCEX0QAqxTXCj5Y%2B5jRgQghCqa1j2dGXXuTfdMgTzo5AJABAMdiZR2kAcfNLjCcsdaRFMxYmJhlJuNfwE3xvnA7HMsXLuzZufOw%2FgM2r01zVxHpgqcB7aIMCVPd9G4J8Xaf0ZsxI66klEHwQhCZhaCSEH4juAoEowkzIgI3AD%2B6iBlSY6o73JRR7du3b%2BDAgYsXL66trWVI5qiSG%2BgYduqRqDtsrrLkC2QrngGeBEussHQMrqIDhBG73Y5WY%2FUQwS8hQeCGOUjTLMcc52PuIRugLCVYVq%2B5sqbggJCQdK8wFQozJoQ4jqjfCPs81WVrli%2F5%2FLN%2FfvzxR4uWLHHUYuMTDBQpKSydMGp8h4%2FbTxw9viivAM0J6SqcCAViuIIgRHEHU8FwGbijJHvmMpw0gqaO4Qp6M8sK95cVVgc8TDiBwxCaBT4fsYQnziVxJmC5%2BiAXmYoRsJOzcEci6HJQdSUYuUm%2BPBpKy8kYPGfamHmz0tP3RoguiRAwErFXVS9cvHjC1Gnjp05btGJVWY0Df5hwNFFSUt6v36Drrrvx9NPObN68xc233vrm%2F%2F1fn359J06aBFLrmbHWIrUu7Fg8v61vnmHGZsksaWlpCxcunDlz5vTp0%2FletGjRunXrkF84W1lZScuvN0ikGxTIr3etrhIBERABERABERABERABERAB0xRPvgjGRMV6xYzCYnW5XDt27MBs2bhxY0lJCccBxSns1smTJ996660%2F%2F%2Fntg4YPyCvNXLtl%2BZ2%2Fuf3Syy%2F5xyd%2Fz8rJNF9xJ216Ml8Eo8HC0sJ5S%2BdPmjN1yvxZ0xcvWLtze5nHXe31rUvbMHrIyH9%2B%2FOlv73%2Bgxeln2Jo0tp3S6JZ7fjVp6aLqkGkdETtA2Irp3e8LRGpdWdt3tH%2FvvZ%2FeeOPll7T73uWX%2Fuint73y%2BmtdunadO3euDzcD693%2BwVQEZuyJZXVi7CNf4LrAh30P%2FZluGUnjGvua%2Fs2PGfmCTcx30uXfjQTjD%2FndPo8%2FRNRK1I2mkbQtsTrZrAeGAykRAHocx5zfvXv30qVLN23aBCWIfdHAostsrJuZ7%2FqTQRK8fCdewb92%2Bcpf%2FvT2c9ue9p3Tz3r9uRezd6fHgnihMCoMWjI1IBmYdjeODqgTSARMjYQe2NQAMkNOAjiQ4GQS9IZ8zgSRD6aCYaoH6BhJ5QUVwlqyV199devWrYgPlhbBRKwVZ4ch8o1BnZziwfQXKBhZWVnkcCD8YfTo0cuWLSssLOTZYO54YuTl5a1evZqep06dysTpNhAKumNhj5HAc8Ts0JQokklRmGfSTYJcEiSRIJyHvCfJGTJYYi%2BYbTBuppAgfMafy0J%2F8Pfrrrzq6iuu%2BvSz9lk5efTh9vg3rtv85KNPXXbJZc8%2B9fSGtHUBP14nBB1Z%2FSBkmdoFCgY6BikyuJ0ZWGTe3Yxs8oYD1T6XJxQk5Qc5W8zUF2bYUKw2HHZFcGph7eFMbAlqiJl0BSeNiJnPI4GfBtkx8NWpCfrskUCtEa6I%2BkrC7opIsCYS8gQDcVw%2BuCYYLS0smTlt5piRY0aPGD1zxuzc7PxAAFAJrz%2BwZNnyB%2F7wUMs2bW2NGjVp1uyXd%2F967Phxu3btsrxZrCcKXBb85INmPWL%2F02%2FuywNcXFyMqxUKIarXe%2B%2B99%2FTTT%2F%2FqV7%2F6%2Fve%2Ff8EFF5x99tl8X3vttb%2F%2B9a85ztl%2B%2FfrRMj09navQPI9y5EyT9tOmTdu2bdv%2FdIa6mQiIgAiIgAiIgAiIgAiIQP0igA2CfYrpjT3FPhYWuQeXLFnSu3fvUaNGbd%2B%2BnZenHMfm4kXtyJEjf%2FCDHzz04B%2BWzZkfrnZ6CsoWj506Y8iY3C3pUWcw5kUTIJIk4Y3Ey1yeNdt39R05pkv%2Fwd37D%2B09cOS0mUty8yrJN%2BB3%2Bt01juryylkzZn7%2F%2B9e3atX21LPOffqFV3fuyTKlFIxarxlcYubTwJmBDAy11YsWzPjnp%2B%2B9%2Bc7rb%2F397Q49u3bp1eOzDu0nTZxIHAK2KjY7ljvWMwa5pU%2BYlq1p%2FR9MJIk2YprNydf%2FppRBz%2BYHaSAYqyXlp5lWNE4qhXjQH%2FEVlh9YlrZ6xYZ1hRUVHtxUyKWA4JF0YzBN9OTGz9QOZIqKij7%2B%2BGOMvt%2F%2B9regw64HGt%2BmD0YsEiA2AYUn6SiB3wLShOldkUiUHSjp273npRdc3NLWpLmt0d0%2Fu2PZ3IURbzARxEZnSmgy5NMgoMHUJYiw8ZD1Iek84MNMJ6LE7U14Cb7Aj8SzO3vfvpICL54zZOnwY%2BWbS8kbc9IvIET88Ic%2FfO2113i3zvt0jGjEDSxKGrAxC%2BsbS5bwE9rX1NTwMPDdvXv3yy%2B%2F%2FDvf%2Bc6FF1541VVXtW%2FfPj8%2Fn1OIGG%2B%2B%2BeYNN9zQrl27733ve4888sj69et9wQCOEe6k64UZVQLbEEoLHhGmooHUgoLhMiJeM7YHFxfylbAmfhYALcaMnCGzhTdUsG13n087%2FekPj33wf%2B8tX7rS7nCZASLhaFlZVc%2BefZ977sU%2BffqRToUUE0HyiJiRSqaHDUuNdgErS8dAlLCeBjMhbBzpglwiZB8Jm%2BPh0XJGqoqrNu%2FeuzUnq7qWwBBTCEoEvfEgQouVLoMnBbEjRLrRYMTrj3hKqos37FyfW5bnjuEBg86GXkQoSgJfGEaNahL2hqsKy2sOVNpLKmvLqsO%2BIFFByEQV1VX9hw669KorGjZramvYwGaz%2Ffbee9emrePBgD%2Ff%2FFvjb4n1IKW%2B%2F5d%2FXaxhsKBk6nj%2F%2Ffd%2F97vfXXnlleeff%2F6ZZ57Ztm3bFi1aNG7cuEEDc%2BR8s88RjnOWNldfffW9996LpoGrBj3wwNPbEQbPBBFFu3Xrdttttw0ePPgILXVKBERABERABERABERABERABI5AwLKesLnYaIZJi1WOrFFQUIBLBg7k2LwcsTb0jWHDhmGtP%2F7Io5uXrDUcMaM6Gi90hTIrE2WU1TArf%2FAmm1ScmGf2WKLI6dtXVJ6eX5qVU5aVcaAou9JPezfqhBlXwXvxmoqyCePGvvW3%2F%2Fv7Pz6ZN3dJTYXHLBaBHYnngfkdT%2FgwM3lhHnA4S%2FJK9mUe2JdRkpNVVjRv6aKOnTuNGT3a5%2FFi8pspITChcJfgmw9TsT7WETSD5AFcMlzYnsgCHCdwxe6uzsgu2rHLVVQYdFbjTRAIu9dtW%2FfUi09f%2F8Nbrv%2FRrW988PesohJiNriCDVZYaoCCkvWTI%2BxjweGG8eijj2LrXXzxxbyqRhBIEkUrQVEgPgRPELOSiumVgdqC1MKAEobH4ZwyduJ1V1zTsnGzlo2aPnTv7zev3YCOYWosSa%2BCZIgNfgKmZQ4zDH7LxmaffJdm6pGAv7a2ate%2BHW988H%2FvtP94yYqVO9K2Z2xOLy0oYVSsmuVW0aNHj0GDBiFE%2FOIXv%2Fj973%2BP0YrTSHJO5qRoxrzQKObNm4fWYWVsyM7O%2Futf%2F9qyZctmzZo1atSoadOmf%2FrTn5BBEDpwg7n55puxbRs2bNikSZOLLrqInjOzMsnlyoDxUDg4XNwVCOKgCkwyd2qAuBGDch3mUauEjBnrY%2FhJY5HUMUxto3hX5oju%2FTr9%2FbNt6zZ7XKgypMBAhUjkF5e%2B8tc3r7z%2Bhhdfe23b7l1%2BAkpYiqSCkcwqagaq4I9BtRv8MXCisEKBrAkic5CIJIHTR1LMyt6U%2FsbLb%2F7orl%2F99P57hw4b6ikpNqiN4kdf8ePWYnZDhAtDSVBExltWWbBh65qRYwc%2F9Pj97Tt%2FvD93TyThD8e9obiZ%2FMWLxsKdrMfJUsp46lgXxBfz31F4f07m2x%2B%2B3%2Bqs0xqc0qhh08b4Y9zzm98uWLjIy0P7BXaen9QTZT0z1rD%2F298MgGeDVeZf9GOPPXbdddchTSBTsKxHv9Geq1C0nnrqKbJ%2FkLyUPs0n%2FLCNg2VlZTyEqKCnnnpq3759D2uiAyIgAiIgAiIgAiIgAiIgAiLw7wlYxhTfGFPWhknLDnElWOKoFrwvttrwZp9THJ84ceL111%2F%2F8EMPz5s1r6qoinKppmZRGzXc5GjEVDSLbpIFwhsniIBX5OaLbdOqwdbDGcIsfsobePI58Ga%2B0qyEGa91Oku279myNyujstbpDyXTVWC3W%2FqD%2BSqdQhbBWMQTitSG4rVBw4fnhCseStuysU%2FfvpPGTwy4fearf9PUTyoY7GBLmq%2F6k2KIpYdwMJn0gDCT2qQUYMY9uINbFi%2F%2F8OXX%2Fvzgg28%2B89TSOdP83pr0rJ3P%2FeWFVuecappyDW23%2FPyOeUtXebBczdCV5DySOg%2BIsNeQAgDCcfZXrFiBJwYXoWMMHDiA7AEWt0Aw4PK6yOdpChMMkU7oBjEjKbygceRl5XXv0uOPT%2F7pwQf%2B0P7TDum7001HCyYUIvUoqUIiyDQOv7%2FG5fOEktJPMlzDTEZpJkM1821UO6tmLZl9y50%2Fuejqy%2B6774Gn7nvsj7977LUXXp40aRKaA8NAlSKrCSN86aWXUB7OPffcXr164UBirTUNEDHY8NMgoGD48OFWGg1WH7njlVdeue%2B%2B%2B%2B6%2B%2B%2B5nnnlmwoQJBAXgkzN79my0LCaLxME7ejrs0qVLQWFBmLwVBOmYAkPSH8NUWsykqabXQjKiAzGG4ZgRG6bPDXoDMSisEB8zgIZFLMkuGDdo5IAufUrzS3hq8FsJRGJ2n39x2tof3vVLW%2BOGP7zrjtnLFrsChNeYLjJEqJjBNGYMiRkahJSRrJNqimE8BaayhdMNihAj4ZFwxT1FjnH9R3%2FnwkttjRvZWjd%2F5oXn96%2FfnOARCuNHZCbZwHEjGKHsboxqMjll%2BT2H9nnyz0%2B%2F8f5bTz77VK9%2BPfPys83QE9K%2FRFiXL4KVrAeP%2Fvlw40Ak6nJHAl4WsNxROW%2Flor%2B897e77vvtr%2B%2F93R%2BffqZzx87r160P%2BJkyMzY3yPNtSRnJdWDU%2F4uNZ2PMmDHPPvss%2F5xRq8wH%2Fj%2FYWrduTT%2F0Rp%2F0fPgE8MQYMGAAfkE8M7hzIJ4c3kZHREAEREAEREAEREAEREAERODfErBsbb5pyTfmOSYV8QUEsPOGvWPHjiNHjuQ1PSIG0QQYWVjEOB4QIP%2F5559v2LI1p7jU5fIFK52xkppESaVRVmVUVgUrS8I%2BezCOB77pyo8da9bHJBdlMGQEAoaPF9%2BIGOVGpMCIFhmJcqSFQNTujbnJEol1R4lR3P8xP03pAAvRHFYoGnGHw45QpCaQcHuNoCsezC7IXbxw0ZoVq%2FDhN%2B1VXoCbVmvyg8sCIkbSOcRMJ0Gn2LSIKMkMmUgZeGXgG%2BGutA%2Fr0uO7bU7lDXTbhg0%2BfOcvubl7ps2ZdOGVF9maNyT1aJvzz3%2Fs2RfWbtzhD5Ab1BwIlEDEDjTIHbFy5UpcVtgHC5kxnnzySV5q%2F%2FGPf1y1aiW%2BEDQDJjqG2%2BOkDkog4HXW2v1uMkNisBM7EyMAhDHDtaCweNrMWa%2B%2F%2BRZ2bo9evffsTcfOJVlJAGcKI1QTdG3Zt2f5%2Bo3ZheVWTVtsdezlBC4J5Jag3GegdsriGdf99CZbU1uLZi1OtTVraWvUunHzF55%2FnoQYDI9hoEchXPTp0%2Bf2229%2F6KGHCARAabFOmYCTITOZmZkzZszAH4N0B9bzgO1Jy549e5IEg4QGlgsHAhcBR1dccQUKximnnIIzxnPPPUdcidfjiZJ1gjqwjMpUlpKCiykw4DqBlJFUMxA0zJwW5CalXirahRmfYUai4IBiDsKoLq9Zuzxt%2FvT59rIay5HFH4kVVVWNmDntyttusTWx%2FeBXt09eOKfWxwiTOoYZoYJ0AQfuxz3YQ8RAKDEdV%2Fjg2IJzBD5C5mPgjjtyK%2Ft16tPm1LNsTRrZWp3y8BNP7FixPuHCAcbsgBUmKMcVoSYvJW4T2w%2Fkvt25%2Fe%2BeemzAmJELVy7PzMouO1BasD%2FnQEZuyBeiDSVWHQnDx4U8e6aPCZ4fiYjdWbQ%2FY%2B%2BurVWO8qARckQ9%2B4qyZy2aP2z0qH%2F8%2FR9%2Fefm1%2Fr37Zmbst4RBFDAeHmhbagYrxQ4%2F%2F6sbN%2BW57dSp049%2B9CNW8D9QLw69tHnz5vTJHwcrGC01C54ZhIsf%2F%2FjHOPZwDS4cI0aMSJ3VjgiIgAiIgAiIgAiIgAiIgAgcKwHsNzbrXTDfxJI88cQTVmj8NddcQ6kCvA7okzZ8E2ZC7seMffvIOFBRlLt93uyMseNLhoyo6TMg2HdQZOgwd%2F8%2BoUljoovmRJfNjy5ZEF2yKLZkYXTR%2FOj82ZF5UyPzJkUXT4ktnBiZPy68YHxo6dTAhsWJ4sy4u5KgBN7Wk1ECC9GqH4EHR1LJMJ0PDDw%2FzJO8KydRhJn5obqkvOpAaSIUj0Wo%2FYERya6ZTNOUNXgtTukOyngGkvkZzHf2UacRp2gpCT9dJI6gHKfdMWX4iJ%2FfcP3l559zw2XtPvno7bSNy3sP7dX23FOxlxu0anbtrbf2HzrCQ7YKU8QwoSY5meoEHvJz5szBewH5AsnC8sf48MMPP%2Fnkk1WrVmHvg9FqzAyIL3A4qpctWTR0QP%2FZU6eW5RXE%2FeHaSntOdn5JWSVpFpyh4Jb0PS%2F89fVLr77ynvt%2BN3nKpFp7dbKKKTwC1Y6KJWtXjpk2bdOuDLQeMzVDSpyJkgEiVhv1rNqz%2FqFnH7%2Fmput%2BdNMt111w2dVnt7v%2Bsqs%2F%2BvBD0jBaGhQiARsrS0gIGTsZP7Ngww3g4DgTCYaNZlVRUcFx6yA%2F8cHA5OQglzMpjO41a9bccccdp59%2BetMmTZAyfvazn%2FE84MJRUV6euy%2BzKr847gnibQIxekFVMNGxKgga6BuBSDK1ZzK7J6E2lr5hqhBmoA7xI75gtLramV9Q7CV0JByt9fn35uUOnzzxlfffOffKy5qfc%2FrDzz%2B9dstGP%2FlM2DD5cbZAZDIFDNObAuULlxe6Y%2F0t%2FwiULU6augaHQOcKL563%2BJf3%2FOaSH3y%2F3S3X%2F%2BPT9gX7D5h6BzoYmVSRlXiQGF3ycrs%2FnLZ119xFK6m4E%2FTH4sFE9o6cqcMmzxk901PtoQ0JV51xw0P1VyKNEO18ZP4gUUbhtAnjunfvtDdzN%2FoIsh1ldLyhwM7du17580vXXHr54394ePmSZVZuTHgiMbFGfIOX5UBcsp40c4Lf9MYi8u%2BX%2FC141yAmsHyHKhH%2F8W%2FLPwdpiyzB3Is7opghYvCcWLcj9urcc8%2FF7eebnpz6EwEREAEREAEREAEREAEROIkIYLdibmBDYVWxEV%2BAx8V5552HrUppEnwzSPmIOWpZsjTGbI%2BZ%2FhWuffMmfXb7zQOvuWLxVddsu%2Fh7hee1s198WXW7S0svbnfgokuKv%2Fu9ssuuqLjsyorLrij53iUFl16Ydfk5Gdedk33D%2BeVXfbf44ovTv%2Fe9rTf%2BYM1dd%2FjGjDSK8gyz4EbAE4s4jASCg%2Bk4Yb5Fp6wlL%2B6T7%2B4jyWycZJvA4AxFEj6MYrMmhdeHh0YMgcJpFl014xYSpqMDRqWPXAfk6vAYvmrDV4Vvg5mIEscMMywD%2BaM4L3vU4P7PPfHQ88881rtP57lLZn7S7Z8tz2qNjmFr2uiO3%2FxmybKVlF3BzYNEn9bGY4HJuWHDBhIVjh07loQAcGPjBfSQIUPQfMzcnskNUEQMQC3g9%2BzZue3l5569ot0lj973wIp5izzVzu2btg0fPnr%2B0mWOcMiViFaFfbPXLH3jw3deefPVKdMm1FSVmGEXJGoIusPO6gMlBTszMw5U2T3kxLTiZSw0IaJPzLQOZQHH9GVzJ86YMnf6rCHte%2FZ497MxA4dRbtXyuMBAZkSMh8Vl7czlS770T83Ietat9bUa05L2yCDk%2BmAjlsRqg6BBdADpPYlPsd6to3QhdCCMbN%2B6bcqo8evmLgu7%2FCgSDNBuxKrMhBWmGGWWXCE1iRNjP2F4Mf6TFUtQO5Llbfgv1jsfbzRW4nDsLympDgediVi%2Bs7rfhNFX33bLOd%2B9%2BMwLz7vn%2FnsnTp5U66g1E6aSa4RngIotJPtEzTC9XAggMSN3LB3L0iLM8B36RdrgE6I4a6yq2r56w%2Fp%2Bo4cNnjR2R0Y2MUOmzwjPEYoIHaQUkaRjR5j0ny5yayR1DY%2Bxf132yG6jx%2Fee6C5jAobbiJMPhOfN5Xbv3bFj85o1Jbk5u7dufuOvr%2F3h0QfXbEpLCnDc34xFcdY6Fs%2Bb369Tt%2BnjJ1WUlkGYxwb9h5Cf5cuXoy2wXvwEO%2BvCKb4t5t%2FUN%2BuLpEBc2M9%2F%2FnMCSazsnUfWLay8KHhZoGqy4bxxNNIHPdP4rrvuIsEsk0KyILEn%2BVWse3GW6ieKK%2FmmllX9iIAIiIAIiIAIiIAIiMDJSQADB6MJAwqzFzMW1QJj5x%2FJjdx9vJTnuGXzWt9JSrzZLV7W96OHWzd%2BzWab2bhxUcu2oUYtI7YGUZstYrN5bLZam81raxiwneK3NXbbGjgb2apb2Era2Mpa2bxNGscanlrT7IxdzU%2FbculVkd79jYz9RnlpwlkTiUcwgfHYJw1lMgaELJ7JnI7EI%2FAW3iwWEY34gwk%2FP5PGKYpFLIG%2FSKURrzQidiMcSJj2qhH2GyGCOJyJhCNgVNcaVR7DzSlfhFoXnhgZHUnPUV40avjAO%2B647bLLL776%2Bstfeuulf3T6%2BOxLzm%2FS4pTmbVu%2F%2FNrrBQVFMaSScCIWNqUe6%2FFAqVi8eDEpJsiKmXqrjqWPlGH5P%2BChATHag9TMGBEOVhQXjR069B9v%2Fm1U%2F4HFmXmBWu%2BalWu6dOs5avLkYg%2FlUmJOI5pbW9KpX5frb77mkYd%2Bu2XlIsNdY%2FhdCZfD8LmiIU9twOUmPMRMWZkMkfAh2cTwAaASR7KeabjSV1vjcnjtTldBpSO3zFlh1n7l%2FrzrZ03ZwXC2NAps5NRcDn%2FgWWIa0J7LmQ4PABuzs5a%2BsLCwa9eulLRI2bOXXnopYSYEDpQUlyyYMWfFjAVBt1mEoyIRLjOipUYc7SgZOHFQCkg4o0ZN0EylgmLAdPCwwT3m4EoauaUlE%2BbPGTFnWpajCmGqIhZcsXfLPzq3f%2FXNv7777jsLZs9xVlYnFYcEMTXhQNDtcvsdtaanB5lP8aVAtkgmfEW6sFwwqFhiRhshJYTieE2YAT2JqCfoqagqqbZX%2BsP49pjnaYbmRG0YqJoyCxPgg9%2BL5Rtk7bgTOSt3Dv2419iOgzzFdkYfDLpCARcZa%2FOy973wzFM%2FvPGGzz%2F7ZMHCuS%2B99tJ9Dz%2BwfMNqHECo68odzWo1hKvU2HN27N6yJi03K9vj9bA0ZCC5%2F%2F77b0xu99xzDylNiOqq86%2Fs8PX5mkfok0VEefvJT36CLnEE%2BcLSLshiccYZZxA9REaLX%2F7yl7%2F5zW%2BowfrjH%2F%2BYsjXnnHMOMgi6hCVkfVVXtLnllltQRMkCiqyRaiYd42suoS4TAREQAREQAREQAREQARE4jIBlunIYa5dMfRQyyM3N5T07Vq1lCLPDZprnCAvxSOH%2BTe3fePAnLWyP2WzDG9r2NG5c26Bh2GYzGjVNNGgUsDVw2Rq4bQ39tqZRW%2FO4rVm0YWN%2FY5u7ic3TtEGoQeNwg5bupmcVt70g%2FaKroh17GDvTjdIyo6bGINYhYXpNIE0kfSdMM5OX7Gb%2BC2Iq8M0geASjlZgTYgsw7Ml%2BGUvYE5EKI%2Bw0i2gGE1Gf4XYSAGPU1hiOCsNTYgQLjSifiri9tCJ9Z%2F72jd5yfnqDPvv6jave%2Fcfbv3%2F8wfsee7Dn4H7LN6z9%2BPP2Tz77zGt%2F%2Bcuc2XMCZOPAxOW1OsELX7wfx9Ik0SXpBfCcB5pFDKufBBQY%2FsRu7N69m1wTZvVSpINIOBLwhTyuyqLCgoz9NUUlcW8Qf4x1aRt6DRg4fvbM8pDXZSRqEv59pVnvffS3ti0aXnvBmdN7dY4WZBkOu1FRlXBip7vDMS8WMbY1ngPmxPkEE16HK6%2BoYF9JTrGvys%2FcuZ%2Fbbzo8MCjyW5JhIxBgyRg5q2bJGuywmqm5HPYUmAcslYMLWf2NGzeS%2BwK5hq7ogVIm48aNoxgrOoZlxt50002oOjBB7%2BrcocNfX35lw6aNdmKOEoFyI1xl6hhm2hMz6QO5KvyJuDMUqHQzeFwnOI5CgGBVFYtQm4SVzcrP6z9uRMcRA3ZUFpQZwUojWBaq3V%2BYnZmenrt9V4AcLMgTfIgsCsdKyytWbtywcf2GqIc8IshNpnNOSsdAoDBvaukYZsxIAjmK%2FwYTgZAfpD7zE3LGIjVGohaXCjP3rM9huOwGwB1uw%2B42ql1GpdOocBiVDqPGaVRW58yc0ffVl4a8%2B5Y7c7cRqDXdfDxV0aoDK2dN%2BsGV38VUf%2FCh%2B8ZPm%2Fh5z66vvfO3tds3B8jtgn8I%2FJPuIp6q6lnjJjzz2OMvPvc8j9DKlStffvllq7wp%2Fi1IB4R74OqD4nTkBTIX6Rg3QjwIEULEOHJCDMaAgw0ZVKjBiscRwVNETq1evTotLY14IiKSODJy5MhPPvmEPDAsfatWrY7g10EtG3y6DpE7pGMc49KpuQiIgAiIgAiIgAiIgAiIwFcSMAWKZIAJdi6b1c4yezme2g4awvFoTuamj95%2B%2BEetG%2FzBZuvVwLapgc3ZqFG8ceNokyahho2ctgbltoZVtlPcDdv4GrYJNGgVatTC35jjNofNhr7hbNCs8pQzC9peuPuCK8J%2F72Cs32KUVBhOLNxgDAOccIzkK%2FGkzUq4AOIF7%2B%2FN8hxRAgowTjFTTTM4HnT67bXO6ojfbhB2gqjhipfkR9ZvSCxZYSxbYSxeZCyfa6ydFd88J7xxUdnsqav79FjQq0vemqXh6gM0dnqrt2XsWLx%2B1dKNafuLC2sDgZyCgvUbNu7euaumrCJKXQmz3AaJF0wpAAgYmNjsCxYsoJIpsQAcARQHsROpSYofC%2FUaRowYgc2I%2BV9dVRWPRSnRmQgTJJKMeiAbZTjur3VnZebMWbJ4xbaN9njIQcBL3J22Y%2B2Lzz56ekPbNa2b93vhj%2B6li4yMTGPHPiMrx6itjEVdxMkQEYOlbqo33oSnzD532sy%2FvPGXNz95b%2FbqRZVOO%2FZv0OVLuAi3MduFgiGUB8ZmbQwVacIa8Fc%2BBMkTrD7N2HAYQLggbwY7lpMJMTWbN2%2F%2B6KOPiBrAKKaUCXpOQUEBx7dv3%2FbkU0%2FccON1oyaMqiR%2FhOkNE3YbMT5W8AVKgsvu3Ldr37aN2z1uP4EmzoTpeEOm13Iz4CPmtzvWr0%2FrNqhXp%2BG9d1Xnlxv%2BGsPnT3pamDJEWXU4p9g4YDdcUdO%2FIhLfsntPjxHDx06cGKz1mnErfJI6BkuCP4ap5cAK2SepaKBFUZWE3LOkio3SscdjOF1GbUHcsdOo2WaUbkrkrDF2LUtsWBBbOjM8Y0Jwwsjg%2BBHeEYNq%2Bnar6N25clD3kiFdZ73%2B5Ls%2FvvzT392SNb5XdNMSI2%2BvYS%2BO5uya1bfTDZec16SB7Y5f%2FWzoxJHz1i4fO3vmvqIiUquYuhPDQMsIhgv2Zbz%2F17%2B0btrk3LPPxt1pypQpuCsgHeAggY6Bh8MjjzyCuME6HnmBjvUsjwFPLA4VKS%2BalHdEagdFgoqozz%2F%2FPKVRV61axZqy4l96I4ZH2Roe79GjR7%2F55ptU8qWOaqqff7sjHeNLqeqgCIiACIiACIiACIiACIjA0RPAPKfxF8aume0Tqyd1kB3LB4M2liGPeUtj0ipGyguX9Prs6VNbvWWzTWnUOLdZ61CL0zzNWpW3aR247Wbvfb%2Bt%2FP0DNQ8%2FVnv%2F45k3%2F3znNTcW336n55EHqx%2F5TeVDd9sfe6D2qcfLnnwi%2B7FHs155JThpopGZES%2FK9x4ojJPwMOlDgFUajPMOnVwWRBIgB2Cjcu%2BYP0oEQNI49Rll%2B4umjZ08dOjwDbu2VQYdEV6pVxzwLl504IN%2F1jz9uv9Pr4Qffyb45JO1zzxa8eoT%2Ba8%2Bt%2B6ppybed9%2FEZ%2F5YOGNyrDg3EbCTfTJgRB2JSInHlV9ZVW53Bqn0SX4HHDDCkTg5GiOBOGU1eJUfiVgxGngmUO6BQh5Y9JbJCTQyD%2FDCGvkCHYNvkoogaxBtgfxhll0hQwUv2UnmQOADekyIypyevLLSAkeF03Qj8Qfinoy9G9999tF2DW0%2Fatyg%2Fa03p7%2F7vr9L78Cn3YPDxiSy042II2L4iZMJ8Ho%2FSOxDrCrvQPfPO11z7dU%2Fu%2FsXI6eOK61M3ov%2BCdkwczsc9LtgsVi4lJSRWtyvekJoyRJbigfzYrOeAY5g2FKthqQHlEFhgjhmYMwyR9LAwiQjY98HH7376JP3L5w3OVhbYbrGIOAEcY9h9ULhGNVko1mFeaPGjRs6ZFh5WSWPnTeWqEzEio14XsCzZuO6kf37vPvGq48%2Bcv8rrz2%2FeXtakPq6cR%2FeNRFnTda69XMGDBn5z47Tug%2FYsXhVoNpFlpS0Ldva9%2B07fORoPzoGYgUPCuk6k%2FErBKqYjzX%2F4zg%2F8N8xg3co1%2BqNknbFXRlK2xycOi84dkhoWIdA%2F%2Fc9nd9wfPCC442nHC88XPX474rvvj33pzfl%2F%2BTmjJuuWXvpecsvOWfV1Rctv67dmAtbdjrF1u%2B8Uzb%2B%2Fvbivz4X6tfdWLvU2LVhY99OP734nGY22x133jZh7vQ8e1VeVY0jSCZTM%2F%2BoOTAWPRAqzs3u2aXDbbfedPevf4W3w969e5ECUISIzUHEQNAgtGTy5MloYuY%2FsW9oY%2Fm2bNny1FNPfZWIgbBAseBHH30URwu8iXjIraflCPe3GvBIsPqExqB%2BEG9ylMVbpWMcAaxOiYAIiIAIiIAIiIAIiIAIHA0BDFXLKrFMJ8totaxX9tkw1a02mLEEGrAFSFIRChsl1ZUDxgw754rpttNyml9c2%2FaS2sanZzZtlXHVZbHhfY19W42izPienZkjR497%2Ba%2F9Hnl87ttvl8%2BbZhzYbVTsMSr3GlX7jPI9RjFvw7Pj%2FtKS0v2z50zsN6j3us0balxOYiHISBEwUyGaER34Q2AI4hhBiU2qpmKYYqKGqwOTh068%2BpIrLjj3vLfff3vzzvWhqmIjNyswbFT6L%2B7PPu%2B6sjOvqGn1HXvb88pOPzP%2F3LN3X9xu1aVXzrr2hjWPPBZesdTw4MHhpiAINnBJwLtq%2B47JcxetTtviwpXAzAzJy32iEPDwp%2FInqTsRAMwNSpj51dXVBN1QlhQ4ILKsfmx5LHpCMHBOYB97kONJFYFclpGQxxshSoU6oWaBUP4bQa%2FxmzEwhMowLW%2FUXjy9%2FT%2FuPKXJ%2FTZbl%2FO%2FM%2Bvam1deefOGdjfsfODR6JqlRrAiYrhJluGhMSEZpsoT2b15S58%2BvUaMG5FVlOMLeElpYdYICSdCDDli6g8pe5MhkdaDb4ZkreZXPRtcYq56Ut2ipSVb8ZN9IkfwIiDpQd%2B%2BfQmcYbKWsMMpGrjczo1bVy9YMrEyZ7tRUWwUlhiZhcb%2BIqOsGv0gFnQFEv49pdkjpowdMXJEZVm5VTeVIjK5Ee%2BCPVvu%2Bf0957RtdU6LZmee0uT6C86d3LNr6EC%2BWaXXbi9el%2FbWgw9ef%2BZZF7Vo%2Bb3Tz3ry%2Fj9sWrfR5fGn5%2BSNmzZr1sx5AaJpcCBCMTqob5nJLczKNQgIwYThMZN44qtjN7xe6qniBpK%2FP%2BO1dzdf9%2FM937s8v91ZBy5oXXhOy8LTmxef2qzytBb201o6WreoaXmKs02L6manHGhkK2vRuKpt85I2TbOb2fY0tO1rZss9p03Gd7%2BTeetN7r%2B8bEwbXzVu6PO3XnvpGa1feemZ7VkZtfG4N1mF1aTIs2Sl%2F8Axx%2B%2Fen7Fj2rQJU6dOLiws5OEhZwVJPt96660zzzwTTwbUDGI6KHHLSn3VAh3TcdaFf7B4TZBa80s9JQgzufrqqz%2F%2B%2BGOkOW5qrfvR34KnhWeABwMvDkSYs84665AoksNvKh3j6PGqpQiIgAiIgAiIgAiIgAiIwJcSsExd7Bc29lOma%2Bo4liyn%2BMbyIpKCpBAVlZUJqkwW1%2FgHT15y%2BU%2FWt%2FhuXuMLixqeVtKgxd7Wp2%2B75brIgimGu5RKp7s2rrn%2F1%2Fdcc%2BnVl1%2Fw3cu%2Fc%2FFrr7xQWJVL6oGA4Ygazojh8htOMlkUuQ%2B81%2FWjS2666txLv%2FOze%2B6av2SJ1%2BfH7yBOKQrSU2D3Y0%2BjHxjRADpGMqAEo7Uqt7Lbh53PaHFq04aNf3n3naPGDXbk7DHys%2BPDRuXdeOeBUy52NT43aGsbt7UJN2rmatw0v2nLbS1OX3X2xdvvuicyf4HhrDEoFRLzu2OhXKdjzqq0Hv2Hjx41pZBCnL644QvH3e5EGBuYN%2Fi%2BUNRPelGLCSjY0DSwQ1PKBqfAa4GyOLMPzFg8FgwFIngP4N0RRGKIJkjnEeO%2FkWCcKiyxEHEmRDwE7Ya7omjyuJ433%2FJxizZjzr54%2FhntVre%2BaGurdjtuuyu%2BdJ4RLIUYlrjbCFBpJeHBg8NP1EdNdWWNqyYQ491%2FJBowc4XUOjxbtuwsKy1HTbFkFgSHnTt34i6CAwD7DOxLn4TUsLkqqcCY02Wm%2FOSbI3v27HnjjTeuvfbaDh06EFmQmi8d0hJ3FZevrNadGS9NN1avjg%2BeGO82yugx3pi%2B1CirIMoklPAUBMu35%2B1K37sj5Ma1xFRjcJcoCnkmpy392T0%2FbdOyyVlNGl3cqNFPTj19wit%2FDa%2FeZBRXG%2Bm5m%2FoNvOM7F51us7VubGvcxHbZ1Vf2Hz68oLSiqsaVvb%2BwMKuQMrp1RQzKofKQmJFRpo6RrMAbiVKzpsSw1xqVCaPayNiZ%2FdCL%2B864ruiUs1y2JuFGjSNNmgabNPY2auRq2MjVqKGzcUNH04b2Jg2qGzawN2robXWKr2Wz2oYNCInyNmrsb9KkpkHDkubN97Vus%2F3Si8ufeSIxevDqz%2F%2Fe%2B93XF8%2Bd5iC4x8oPysOLiGF9zNgWkmT4PSF7tZN%2FQBUWVZ4fSsDgCPH973%2BfZJi4ZDzwwANr164lQc2Rl%2BkIK1j3FAVQRowYQRpR4lYOlxSIZyHHBXc%2FcOAAI6l74THt85hzI%2F44UK%2B5TZs2h9%2Bo7hHpGMfEVo1FQAREQAREQAREQAREQAS%2BlABGqGV0m%2Fa5WS3UfL2easlZ9vnOysqihGL%2F%2Fv0RNFAWjOKSqoFDZl930%2FI252S3PLes1ZmVzdvsOf3UtBsvD88fZ3hK%2FeX5IwYOuODcSxo3aNvYhnXT9OYf3j592eISjz0cJ%2FIBJwtyd8bdQf%2Fi1avuefDBhi1aYu%2Bce%2F6FfXr1Ky8qTaY7oBXRJGgBBHfwZp0Yj0hNOMrLbkI98nfmf%2Fy3j05reRrm2F333TV9waSws9gozomPGJF3%2FU%2BLG5%2FjaXhGyNbcaNTSaHpKpHEjb%2BNWtS3PzT6j3f6bbotOmGxUlBleB9IIkRoV4WhmSfWSlVumT16wO21vqCqQcCEUuBMxT9Qg34QnGPVBxuLAjsUESlj3%2FLRw8c1mahdJNwYMVSrGMkF%2FJFBeU0kW0JIDxS57LYkdUGboIhiPBGKBIM4eEcQJMkmWRJYu2fviK1MvumJh24u2tblkf8t2uU0vyr7lF4m5Uw1vQdRMXeqtitYWFeUV7NmXu2tvUWaWvbIiQA0WM3NIPEEcjD%2B8du3m555%2FZfqMWQ6HWcQT%2B5RgAaIVqCpCCAA%2BJAwvtbiH7zA1GlgTQfQgTwK1ZXnhTlYQflKi5dNPP8WFALcTWtI5r%2BOTJjlePdjqSAglidwt3k87Ff%2Fs4cKrfltx1f1Vj7xpLF5lOMrjhqvGsFcFK4JOquAGDFfQcJoqjicR2VddMGzGiFffevqZe%2B9882e3d%2F7JXbN%2B%2B8fslz6s7jC4utvwac%2B%2FfnPbU9s0tjVp1cDWwnb6VRd%2F0Kd7TlllxBePVgTjdrxQTE8M7HCyhjICcm6gJJj%2BGIyIrKduCrOGvYavzKj1GFVGotJI3%2Bl%2F%2FA3PWT%2F2Nm0Xtp0RsZ0WaHh6bZNTy5q3KWhzavaZp%2B8559Rt57TZfu6puy44Y%2B8FZ%2Bw767Ss09oWtmpd3qKto%2BUZvtbn%2Blud5WnW2tmm7Z62rbZecan%2Fo%2Fdiqxc6923xVBWTw8SJDEYCWhKikMKFJBOWlIFXDs4LhlktOMruF5x5hPiXRbqVF1544cUXX0RVwNeFSBCoHr46x3SEW5B49t577%2F3SAiV4YlAOlSghfEKOqduvaswscCyhykld1eLwfekYXwVQx0VABERABERABERABERABI6SQNL4Nt%2BnWzt8s4%2F9a1m7lrXFT3rLy8ujBCdlNwvQMQyjYPmifg%2F86p02LXo2bLimefPs008raN1642ktVt58eXjBBMNbbs%2Ff36Vj5zNOb2eznWazYd2cdtMP7x43f1mpJ4A4Yb4rT34iocTGTXvuvf%2BxBg0oztj4%2FHMu6tutb2VhuWmEmskbKVMSTfiTORoTIWxBdyQeIOFEGDcOR9%2FOfU5tdWrzli3%2B%2FNZLO7K3JuKuRHmuMX5M8c23VzU919ewbcjWyGjYxGjaKN7QFmvQJN74tOoW5x24%2FIb4yHGUR0m47eTO9ONq4vKNmrrgpdf%2B%2FsTDf37xoRc%2Fff3vmxatDDuoZOENGQ6%2F4Qgn%2FBDCvsdytxBhfrKxDyg2QKU2i57VwB8K7tmf%2FknHz55%2B7tkPPvhg2ZKlYVxZzOqgTI48HKFozJcIOg2fPVFdZqxcVfvGexuvuGVp0zOX2ppvaNA819a2%2FNY7DeJxPAWhRFWWr3D6mrlvvfWX5x557JmHH33msUc%2F%2B%2FAfW7ds8vkpvmpm3nA7veMmTr3y2hveff8D5AuGxDCILMAfg7f8%2BGOQt9Ma7ZEfD%2BaFnwBG7nPPPcdL9ldeeQUNhGgaDmIak8DBVGkoWsqWdNXgF0kwzOIkRmFi98rS3%2F8xv811JQ0ur7JdUXLpndHOfY2svYloDYVAvAnSmpKmImjYfYad6iohj8%2BbUZ6flrF%2B1rIJ47v9c8zTT8%2F69R%2BWXX932qV3rrnut3Nvvvf9dt%2B%2FtlGTUxrjkGGztbWd8cPLu04bV%2BJB%2FKHYScIUClAMkllhETGoflJt6RjMkEeInKhoUeGQz%2FBXG%2B4gJ2NlRvou44UPE2f%2B1N%2FwMm%2FD75Q1OCOrQZt9jc%2FMufDKitt%2F6XvqKffrLzn%2B9qrzk3c93T71df3U%2B9mHgY8%2BjH%2FwUeJPL0du%2FqXnzCu8rc4PNmoRPaVZSetWW88%2BM%2BvBexPL5hlOaub4%2FPEYoU%2FmI4peRXQIriH4hJhJS%2FhXxMw9pGilFi8PCZCtDZLIXKRboXQvi0ViVaI8EIiOvEb%2F9iwCxYABA9q14x%2Fgl2w333wzuVzwo%2Fi3%2FRxNA7QX%2FjLceuutuJR8yc3qHJKOcTQ81UYEREAEREAEREAEREAEROAIBCxLqm4DjF%2BMLDYOWvuWxcqrfCpsYs%2BWlpYFve6x3Tvc2rrJLTYb9Uo%2Bb2Qb1azJlCaNJrduufjWG8PzZxnO6nBl2YTho84%2Bp52Nl%2Bi21u3OueZvr3%2B0e09eACXA9LKPma4MZL%2BIU1%2FU26vHgJ%2Ff%2Fusbf3DbYw%2F9aemi1R4nJSsROnBcwEbFDg2a1UioOxoNcxxXDgzXRCiyYuGCe3555y9%2B%2FpOxE8b8P%2Fa%2BO7yJK%2B%2F6jrpsudu40YupCaETaiAECCkESCUJIST0FkJoIaGGXkINofeO6R1sg6kuuOHeuyzL6r3e74yU5c3ut%2B%2FuJnn%2F25nHjxhJI2nm3Ds8z%2B%2Fc8ztHpVFQu4kqa%2BjFizV9hyrF4UZGZiEMZRg3n3HyGBvhWYlYyQ%2BubdKB%2FnqE1iioUQulh0pvScope%2BeTiUQQwuMFSok0TCBb890iRWEeulhsVG1wKxt0inplPYo%2B1O9ADOcNBwzA4oUI1TyA8mKInd%2BqU%2Fzjdjdo1Mdjz7Z%2F5WVUcp07dNq74xeLx8wB122iLlhdsJ0zuDSzju0aiX%2Bon77ocdteJ8T%2BGwk5QEgG4dX1HuC%2BFkv1lQ6Hpqih8vDFM7Bk7NG5c%2Bd2bbu%2F3GniF%2BMeJdy3GEwwPwVU1bWKTVu3topp%2B%2BWECaiFvaeC08O5eCiH32QAoDJwtnjRu%2FPizF9cBd5CawMkHH1efRU%2FNXjQ4D179mACoLjGhk%2BxFIZn8HDZaADyGJmgUQYsQanryY2CgW9W%2BrRR8RsbmDBloxjzh5%2FRa1eoVq6lZhMuGpaf4If0YDMMboOpoKj8WOzFg8cPJN6NLb58ImXhN1d69n4Y1T49KOZKcMwySfhQwmtNSFSArEXH5u3f6D524ZR7eakqFwxZ3Q4L65zCToa%2F6TEgyWBDe50uCwQquEpQGRYX7FysbMyqwQmewyF3IzV13kpFs76JwuizvMDtjGgVw1%2FB993SLObga6%2FHTZqiP3GSPk2iJcVUXk1huqIso%2FVltKqI3oujy9bYeg%2Bvk0UqhMJ6HsmTiR%2BER9zqO0B96hRVoH3GZIMEBsACaUTBgscA2cL2tiAuFvMBdIpO79aCv%2FIOByaSdwiAvHdG4RGk06ZNm%2F46wwBiBJ4V%2F9R%2Bs2XLlmgOAmHyYsS9c%2BBPPOIb4BWzYsUKtMb8W3MM3AUcj%2FEnQOY%2BwiHAIcAhwCHAIcAhwCHAIcAhwCHwewT%2BRSHjrXNxAKot1O8orOCKgPVirPPGx90e9%2BHbITwSTUhHQt7mk7EMmULIKl%2B%2FC7362y%2FdoCqkbtozHz4ZNuzt1jGdX27bbd7X3z2%2Fn2HXsFYG0FgY2TpWZ3GbWK8Iq7WuSn73Rvyh%2FScfJKYqNei2oPhDDcqyA6zBp5X1xrDqUQejE4F9w4nOFGNtdeG1i6fOnzpaUVSChA4KPw2tjsbdqxr0tpwfZCASB2HcPGJlGAuPZycCJ5Fo%2BIGKsBi6%2BQCtVMAQ02p3KLW2pMzyr75ZEtW2e6OotjGRMX3bvrJl8bKazHTqNBjt8rSCxyfOHNm5cyeCMqFG8Hp4AhCWhfmbfMXbdgG1A1CCISooDu9bSo369KVLb4x4q1XLVmNHv3%2F3%2FBUn%2BinQ6OB06WDuCTLGQxZRmG%2BotTT%2BqWnakjstu%2BzyD%2FiBIb%2BCx2BITe8e7itnqLYWHI7Z4apSqO7dS9y3Z%2Ff2LRsO79%2F1JCHeqFBTqxvGq5AelFRX%2FbTup9ZtW06dNiU9PR01snf48IjCGWeFV3Bi3jP3vvJilLGDDXjjYGw4BovsJ06c2LN798kTJwry8u1WNgQFnwL3xNJQTgcYDFAIdjfbJoReIacT7EKp7frptK59KqTRKoGPifB0EomudRu6ei0tKcH1mrzKCR1OV0PtDQ6TIT2nat%2BhSwd37s67e52mJ5p2rYp%2Fqflzf%2F96%2F4i4gMhv%2BBJwZW14gndf7b%2Fyh4Wnzh%2FJKEzVOdSwFKl3O%2BF2AeICZBCmBBpJLC5YZbDUgMFk1ttBD7E6DTawFwE0LtiSgAdTU1c9Lcm0LF3xpGOvJQKfdwjp6ZnDLQlp5yfr1LzpR2%2B9%2B%2FjKXZtc79IiV8ZktcFb1WByq9zOWqoroQ%2Fj6cxF8uhWxRJeOo9c5THHJH7Hur8mv3TLoda7bVZEyLrtyIE1ejxZEdICBsPDY0DRA%2FaLIj%2FX6IR65m%2FMEnZwygAcwGJ0oHVBE9DAgQPBGv3%2BPv2j%2BzDtRG5OeHj476QQv%2B2C2Zg%2BfTpmspdC%2BaPf%2FPvj8Q3FxcUgMWAW%2Bp%2BQGF4eIyIiArTY77%2BH2%2BcQ4BDgEOAQ4BDgEOAQ4BDgEOAQ4BD4P0EA5ZV38d1TGrLRFSiOUGeBzZgyZbK%2Fn0QgII0Ylsd4j2E%2BJWQyIeulgXe7DnDuOUFTc2h2oe3egzvr1x9ZuOD89z%2BUHsaL6TQ9i%2Bbn0pJsWoG8kkKqrKS6epdW5TKZjVqDol6rMztMDmqGvQDsMFhFhtsJDQZr8IlMCoR7wFEBzSZ4z2h3Nlis9QZttVFbD0EAezT0EWYLTU6rHPqeXBhsYnxcfL5byDMQRkMYHWFMjFgh8a%2BKaOne8AutqHQ5jEhUNTtprdr%2BMKNky8Ez38xdumreiou7j1SmZDhVDS6nIbssbfKCSW1fjomMimzbtu2kSZPgAwBCAIUnHsFmYAfMBjImbt%2B%2BDS9NbNhH%2FwXKUmwILymtkR89dWbn1h0J124ZKuuojr0ExKGg4cDrWcpyB2hC0Ojpncf6Lxdcb9xxm79sCZ85QkgBn1H2f5VePUc1dZAxOHCVDmoyWRrqFSpljVZda9Vr2XhaE4XNh9burtNpT58%2F%2BWrf7stXLEVfCU7AWyCjWH5xwtj3PkURigNwFTh%2F7OMA7%2BYdbhyDt%2FQ6PagZrVpjRZsDy1%2F8xmNgYugNBhiJeiQPrPCBZadcOqc8p%2ByXjbHNWydJAuukEgefmPlE4yujH35C7z8C8YH2F7auN1mpSem2yWF7ojbROqVFW6N0KWrpkzjdnAlpTYNqJSKnb1BWSMQakXQEIYMDw7bOW1yUnqrXK2wOuF%2BACoC2AT0qbBoJOC0X2jnsbpyPyQimy%2BG2I9qGwtJUa7MZzDYLNEDguMxo4VGxzS9lz1XLlsa2f%2BkDAb8pYa1b0LAiZkjTJk3eHzvu%2BMlLilqTqs6a%2BjQ3JSWnAjOMWmuoxkYRJltBnydBklEf0yFdRC4QZi0hPxCyu8tr6oRUTy4JunvAUyEDR0sdemCIgWajV0GpgOQw6Q1O2HkYHXB99TirAHCAjCgZL6UA5EGIwU91%2F%2F79cCD5K3cxZuCGDRsCAgL%2BgcdA%2BipsP8HI4Xf%2Fyvezc9bJRtgsX768Y8eOQqHwH37of3sKPUZkZCScdv7ir3Mf5xDgEOAQ4BDgEOAQ4BDgEOAQ4BDgEPj%2FEUAVhgoX7o6gL1D1YEP1ildQyI8aNQoCcSJiohnyNiEbiP9%2BEnSc539THPEgoEldr9fNw9%2FXvzGy8vWhKQNevd2r0%2BM%2BL5e81U8%2BcmDt4B7yof3Khg3PHjYm4%2BOvatdupumZaCyhNhSiNhg3QomPP1S6bGmMYtViM5gMFkRxUCu6L2xIFUVpbnHYLNoKed6zrPvZOU912joX%2BjRsWOy3o56nWc9Lh40s4%2FtriNjM8MwMoyZExWc0Er7KV1QaJMtt1dyxeQOtzLW4lMhYRcELC1GVyVVRpysrqlHkV1vLlVQL%2BYe1tq5iy8lfo7u3IUIikUqwkI11Z5iEwCkCcAENIIPaEzXj6dOn4UWAt9CLsW3btsTERGgzUKXivKwud71aW1ddZ4ZGBb0GaHOwuY1Wu8JuaXAid5W9UtY3FVGb8UnaT2ef841cxjBzCKvHSCOkvMvL7vMnqBpFNKQUrLGGZ4NQxbPSjxoejI%2BV5TfgLQm6Jys%2Fc8nyRffux0MWgnPzUhPez3iJC7zi5TG8b2HfS2Lg8cWOd%2BjB0kAQ4z0YlA0yS%2FCU%2FRQ6e0BoQPiA91gGg%2B0BYrt9HAZL%2BsMrU79cIJPuFgmzA2R2qcDCYxQMo%2BzQybHnAK1Hdc8KIxBlS21KalO47UDQDdoK4g5q0rnPnzUPfUMXGsbGgjC8UrHspjBwa2D0vtGfFsbfd1p0ZhhqoEkDxA0cUnBxYDDQWmJmdS02q9NiA9XjBI9hN1sVet2T%2FNyLd%2B5evXk37Vmmuq7ebdIjaZe6Gmh5tmHNqgtdug%2BRCUWwc5ASCY8EMrzhXfud23bEXG5w1jifXkme9unMaV%2FPjnv0QOXSW2Ad6qx2q%2FNo4g06e25Vy9ZZfoKbPLKVkM3%2BIXfHTrOnFFK1i%2BrtbqS9WPAP%2BBQzUmXBtLBDDM4A9iHQNcFxxQUeg20nwQB5eULcWQAS%2Bzk5OQ8fPoRSorS0FAd4R%2B3PPaJnBBPy%2F3fdRJ7Ijz%2F%2ByFr1%2FoUNUwCnBwNYWL60a9fuPycxXugxOB7jL8DPfZRDgEOAQ4BDgEOAQ4BDgEOAQ4BD4H9FAJU4chOuXr3qTYFEUYxFZKwdJycnfTfvu1btY%2FgSfleeeDkJesJvlyJo9YAfnuQb%2FlDomy72qRb6GXj%2BaolPmS8%2FW0yKZaQ6gKhkxCQlBgFR8nxLeE2fhXSs%2FmAifZBE4ahparDb0RNi02JR3%2B2CEyhIDFTqCBKFYsDqxhq8tchSl1VVVFFWKa%2Bounzl3JzFU0d%2BPHTMh2%2F%2BuGR%2BZmYWlBouK6JZbbSwsHzkh5W%2BoRqhD2QYckLKCFOHAE1fgTyAn99Imt6%2Bie3nFbQyVe%2BuVlGUxqgyWVUGGlNYbgBlJ4waYaVgdZRUlc%2Fbsda3dQThE5FYLBKJ4JqIZW4AAtSAhncD1ZOdnQ0jzWvXrqEp4MKFC6B6UKWi1kOzA6JWPUwFKlk8YUkM%2FIrZ4VS62CYHA1bkcZgZZhE6mvDU%2BOms635RMMdYScgpQrIJKXm5vfvEQdb3A5wIG3TiMZB0Yx9kDzxDrNRgx3fi3BHjotBrM3LSbt29WlZeDKkFTs%2Fb%2F4LCE5Uyez5%2F2zy8FNtmggvxvouD8RTjC89JsFXe4cbheB2P%2BB7seAtYfLGXIcHXWe02B9tggssAwaDV37qybXCf9xkyX0Du%2B4lNIpGNYeNKywKClBOnuhKTqMWMlpBae71BV%2B7WV1Eb6nqWx7AjwkOlsP28zdC8PRX42QhjBPvEiCrE4U9bdc9esMyWV%2Bi2G7Vo9mCvGrkfoBbQRuQZL1BRdjAhLGvEdiI5XJoG1aFTJz%2BcPLHfsOGjPxy7Y%2Fuu3IwsqxpJu2qQJ7Q4w7pxw9VefV4PFPN9CSMhCEJpyvCXjfm6IS6H1rpMuapnl5%2BsmPHjyjk%2FZiQ%2BdICQwFjpK%2Bizu871K9SDBueHh2UG8pMCmNgQ2ekuPXOWbnLn1FAtpg3rTGt3oAPKWk9ttdSN5hBwXibMMFAZuEy7ES0vsKsFmAAZj8Af8OJeO3fu3MSJE0eMGDFu3DiEwvxFPQa%2BGQKhHj16YNK%2BkEZAjPHyyy%2Fjjsbw%2Fa%2B3%2Fb97A0ONM4fuaPLkyS1atPinia4vfvGf7oAPBNf3736He59DgEOAQ4BDgEOAQ4BDgEOAQ4BDgEPgDyNQUVFx%2BPDh9evXFxQU4MOofdALD5uI2bNnvz%2FqvS6tmrfgk8%2FEvufFzfPFMReEgWt4zHIxs0RI1sI3gCGVjMAglmjFArWYp5USrYCYBYT6wnKTWIhfA79pfkiH%2Bg8n0KQUqqqh5lqrE9kJZpSaOrfDhkQTVHisVIIN9oB1YpVacTvz8cHzp86ePX%2FpwqXPJ4wNbOIDA1GelDRpEbF1xw65QouVflbdUF5a8eFnNSHRWl%2F%2FeoZXSEgmVA2QBAh5SiEp8xUWNo90rFxMCx%2FbXZVGqoaGw%2BKyItgV0gqTxmCrVFKlmY0ENTnr1Zotp4%2BFdIohAsZbkcXExAAT6DGAhtcNw0sCgNlAHYqVboCzZMmShIQEGGWglgQ3YMUSPXgGNBiAKLHAsxRMBL7bWapX5ylqSurlFdVV1aX5tuoyeuee44tv0qLbX%2FDzjfUVpEiFcrGoqstLrsN7qbwCpf4%2F8BhuyBLsFoo%2BHFTxbpqSW7Bl96%2FfzJu14Ptvd%2B7chmV9%2FDxKTohGvAzGi0fsgMfAu2AnQFygl8HLS%2BAp3EE3b94Mi0h8EIOOI71v4WB8BE%2BxYQcfQaENrsZsYRkPtmsFjSdalebMiXUd2rxPyEKG3BeJDTyhnfAsAqZSKirs3sv8825aV2%2Bwa7IU%2BY%2BfxdWUZVB4VoBjQaOFydiQmVYz%2B9ta%2F3B8xMkQp4Bn5wt0wuCCZp2KJsxwPH4Gxwkb4l3QPoSeCEgkNHY2ZhXyHdaeA2zAb2IVt91RUlj44RfjhCFBhC9o1qrtiuWr8rKe23QaaoUphwI9Ta59ex8PHDIvIOgdkfhNnngMTzpWFHxxyqKK2Lir2w%2Bt%2BWHl4rkL530%2Bcf%2FcJfVnrtI7D93Hz9p%2B3mqbOV03cGBNeGSpr09OkOhplPTJoB5Fy37UXr9NYWLKtrc47SYLDE8R6FtHXXlGQ2JxRXJ%2BlQ5RrKCdWL7M6jRhrrE%2BsV5gQWIATwzWp59%2BGhgYiGkGeUN0dLSXK%2FvDN%2B3vPgArG9aptU8ffC3YDPSYIE9k1apV4Kl%2Bd9Qf3sVQY%2F5DejRkyJDevXu%2F%2Buqrffv27d%2B%2F%2F4D%2FZcNbOACngQ0HYxs0aNCZM2f%2B8A9zH%2BAQ4BDgEOAQ4BDgEOAQ4BDgEOAQ4BD4dwigkoqNjUU4IwofVFsoeBFW0qxZMx6PL2WYDmLeBwGiNX4%2BSX5R2fzA9TzytoD0E5PuEvKGkKwRkCQBkQsYHZ9n5zEWQsyE2D1%2FNgIew1dNIguCW9eO%2BpjevGrLfmTWFFioWkENdfBURFEOQgLr1%2FCP0CFYE79traquvvX0wb4Tx7bv3L1m7four3ZBrwc6Ang%2BROIv%2BXrq1GdZBR4hvotWlFR%2BNr6yUWOlb4CCx8snJJUhJQyjYdglfgXh14Y1cs%2BdQzPvu50VLqp0UZ3NZTC7zPnVxRfOxx7fvjv%2B2HllbjnW1iEESczO%2F3DGjPZdu7Vs1QpNJePHj4fu4smTJ5cvX4Y2HnaFjx8%2FhvEp9Bjff%2F99t27dQHSgXsPrtbW1KFThggDJgxURH%2BAxoKUAlcFafrgVBt311McHLp87df1y7JXzN6%2BeqXmaQGMvuD6fUdLq5aSI8NQI%2F5JAmVIsrurysuvQHloLHgMiFZavgMgDpT8LEMtjoOEGGg%2BqbDCs3Ly1VacOIl%2BRWCJo3brFxk0bvefgbX7x8i1gITCULPPgaRrKyMg4cuQIDCHRI4DKGuYn%2B%2FfvR9W5ceNGuKC8OBKf9ZIe%2BDh2YEGJmYBrvHnzJoplvMiqIMAtKOt1B%2FbsbNViOmE2CSQpkkAjkTgJz%2B3DV4l5xY2iTJ9Now%2BTDA1Vt9MTdhzb%2FigtHlSMy6RXV1SkJd6%2F%2BPPme8PelvuF4yMUlJcP4xLwzERYGhCRPXCE7dQVqgQLgRYaKDLAMnkaaaCcgUEGTEqQYot%2BFc%2BUthmMDxLu9ezXjwj4hMdv2brdtq07FVUQtIDuMVCnktaX0NhzuUPePu%2Ff5Iq4yUVB0%2FOClkf9291%2B%2F%2Btfv5jYo0VTsZ9IKBO2lEhmdeyeNXaG4%2F3Ztk7DNWEd9P5NzJJQE1%2FSIBSXhAXk9WirXPEdzU2hDTVUrXOZHBa9RafT69ngFnctpYlFpVsPnd29L1ZerGOlI%2FAkBeuCcXOythjADRvwBCEGjQS0EyAxIG%2BAYSaEE3%2BRbQASGDUQDgcPHpw6depHH30EaxcMGcYLr%2F%2B7W%2F9fvY%2BPK5XKuLg4zH%2Boj8BIgMGDBunixYt4%2FIfN%2B%2BLZs2dxGHqvvNulS5dKSkr%2B1W9w73EIcAhwCHAIcAhwCHAIcAhwCHAIcAj8OwRQm6BofbF5D8dqOwQY6NlH34S32kL1inTF4JCQFo3CJvbodPD17mciQ%2FP8w8r5fud9eD%2BFkzlNyKQmZGGM8GS7wLSYqKKWTUuaNi2LjK6IjFa1aa3r1LYmpomqcwddlx41HXqmdemd%2FslHVdvXPTq0OT%2Fvnomq1GyIhZU1lESlBT0G6j6vFaYV0Z42nclYUFxy7uzFVavXduzakfAIEaDwIz7gMaZMe5SSqTdZrDYTLc5XTp1R07hVrY9vHcOUEZJLSBVhDAKxhe%2BjYHwg1XBPnUWT4qmtwqP617rd4DEMRy6f6NWvV9OQsF6tO145eNqtsWOVX2NzPS%2Bv3Xfk%2BJo1a1HyIwQEigWUhK1atYL5APIgsCqNugyV3YMHD6BkgHwFhyGJEnYHLF0A7gHdMVDxg3gAPWNDjCycS53lDfWn791ef2DXxr07d%2Bzdcer47oK4S%2FT4cdfHE8sbx2SFBGcH%2BRQL%2BJWEVLzUyX10P1VUszwGMlwc1GKFRgItHmhT%2Ba2vxKF3JidnjfzkM75UIhDz4FwikYhGjx6NzgIMHAYXjyiZ%2F2GUUSkjfDMiIqJp06ZoliktLcUra9asadOmzfz589Ea4%2BFh2I9jPODxiR1MDCgxQF%2B8%2FfbbrVu1%2Bvqrr9B5BKEG3oSdCJQwps3rLrdps0%2Fof9M3qtwv0sL4OghxixmDmJHLAnSv9Hdt2GEryr9w%2BcyqbatvpyRYHQZdTcXDSxeWzJrx5YD%2B%2B1rFKEMauxmhi8c4%2BQyCZoyEqfUNKHqpl%2F2n7TSnmJrMrJGIBZIMKFuQagPbUzuybcBjwK7DDrjdLotGe%2BfGze59XiViIRFL2rZ%2Fec%2FuAw1yZMKi8wQWGQpaX0zPx2YPGHqLH5zNCy0SRGczjR9Kmh9v2mFqREQboMdjbT9bEfIZX3ImsHWFb0cTv4VT3JhKwq18XzWfqRPzqwIDHO%2BPoqcOO9MTyxKupV69XJ2T7zCaAROELEpKwRckZOYuX7Vt9owfY4%2Ffeno3vbZE4UAMD1ifv3mS4I4DyPCyQKUP%2B03wGBBOiMVimGGWlZX9u7v237%2BPIQOLBXoKhAZ%2BBXe0dxD%2F%2FSf%2F5RGYS5DusGocz4ap%2Fq8372EvHnEwvuFf%2FgL3JocAhwCHAIcAhwCHAIcAhwCHAIcAh8C%2FQYCtVT21FeoL7HiPxg6qFRQd3uV77CCI89dff124aNHq7xcm7dhQ%2F9O8jK4d68OaGHiBFXxpho8owY8fFx1QMrx%2FyTvDSj98X%2FvtXPmCRRVzF9bP%2B8G%2BcqNz607jji32g7%2B6j%2BzTb1qdu2z%2Bg5%2Fmn1n6zY4Vs5NTb6JVwEjhE4F1fQ%2BPgdofkgz0lZhdqFXdCMDUG%2FKf5%2B7auWfJ0uU9%2B%2FWQ%2BIuFPnypTNi%2Bfas169bfuPPgbvy9J0kP6xPuGOfO03Z4pcbXX8HnwR%2BjgnXJ4OnEfjpxaKUktDSklfPr2fR%2BHDVWsa6PTi3K28q64m9%2FWuAT4iPikTCJ7Ke5i2uzy8EToDpHraxs0GIhG%2FUgFA4HDhyAyYC3zQT1ZlRUFCIbUHWiYIThJw7wdpR4SQC2Y8LocFpRr6JihS0CzDIQWurWOu1FWmVWbXluTWlhZUFdbb5NUUjj7ri%2FmlUb2bI4IKDCT1onFKr4wrpuXeGP4a5DaAY%2BDzIDDT4YLpgw4HsNbvhjwM7T6Eh8kDx01AdEKGAEWNMnAQGyd999F4vjGEEcjUcvoYF9nJh3wzkvXbpUJpOBkAGhge6houIiZGi2btV6%2Frx5hQWFmAaYFuyPQTbAunqyShB8DxiPnTt2fDd37oF9%2B6srKuCg4bBYXaAX0lKs82Y9a9Y8RRJR6dvSIG3s8PAYLnQSIbVEIKwPbqIfP50mJt06cmz9pg0XHiXoLAZ9VeXZnTvGvjX0teaRPwUHlQWEmgnPRIhDwNiFxMQjcqm4KKyF6ePp9NYjqmctTXEmBjtCSMzIsoEbrNuOthT2AmGyAYoHJhSlBYXvfzpWEhYsDQ4Z8sbb167cMmkNrG%2Bsy4gRdzeU0iuXng964zKPn8bw66QBCmFIhU%2FkVWnAIoQIE%2FIKITGEDCBkNmGuCMJq%2BE3NvAgLE2LgSWtEvDwZKQrk1Upk2sZta94ddXX8R593bvtepw6Xdv1qb2gAZJi2sPesctkTU7NmT1%2FULLpDmzZduvR%2Bbe2uXwvUCoTPWpxs9M%2BLEcGdhYybMWPGBAUF%2Bfj4YESwoavr39y03NscAhwCHAIcAhwCHAIcAhwCHAIcAhwC%2F60IoFD1khgorFDhomjFK78H48XrWFGtq6srLCqqLshz5aVZ921O7BzzTBZYTKR1giCVMKyEF1gdHkO%2FnP2o3xunh47QnTrrTEm3ZeU7M4tpRjnNr3RW17gUVbS%2B6Nbe1VPeffXdvu2H92k%2FZ8anqSnxMI%2BAAMKGVXPKpnOi6GSbJ7Bsi3YMVO%2FwmDBbdIqG3Mzc5KTUi1djv1%2F53eiP3vxi3JjlSxYtXPj9Rx%2BPHzxk6Nsj39o8acLzMR%2FIW7crE0ugx1AzBD6TSp6wUuKf6RP6QBr%2BuFE7%2FVff0vgEt15OHRCA6NxWfXZu6rjZXzK%2BPIZP%2FCTiyZ99mXE%2Flc2YsKId4X%2FQQAsAem28PAZaAMBjQMwAGQa6MLyIeZHEPssIoe7HVemsJXmlGc%2ByyiurLBarAyaZbpfGYcuuq0ouzs2uLq6qL7Pb66lDQZMe0CnfKMKaVPv4NkjEWpFAyxPWdensPrSXwj0D8GAxH8igu4T9drPLZXTDoxRnaHDmFpRNnD1XFhrC4xM%2Bn8hk0mnTpiHBE2OHFgY8%2Fp6e8p4k1tPv3LkzY8YMeHpgB5cGHmbLz1s6dui49MclZSWluAoILTAl2OPx5yU1qBvfWFNdXVRYWFdT64A0AvMFaSMI6Lh9w%2F7JqLLQsFpBmFXQxMk0chCpnTBOwjgYYmR49eKAmgFvqjbtebT7%2BM6fd1yBC4fbVZ2XM%2BezjztEh7YW8ecIBc99%2FCwioZlH3ALGJWHMYqIU8ap9Gxn7vkePXnIrdQj2QCxLPbVr3JDuQJbh6d6xYeKC4AG0kL%2FANtVw%2BsL5CbNnfTLhq5%2B37qosrQHXweazoq%2BEqqm6AjxGyqv9TqLniBCdAKEqEp3I%2F7lf0Fk%2F36U%2Bgo98yAgxGS8kP4kkl0RBebwwBT9M6RNS4yPL9hM8DmMyGonqJAEaUfT90CbfBsm6gPrg8zZP%2BbomPQXJO%2FgZPXXXuxyZWQULpy7yFwSxyiFfv%2FE%2FLkisK1VQh4l1fGHTfzAu3rsP4IN0mjVrFtiMjz%2F%2BeMqUKSDNfn8PcvscAhwCHAIcAhwCHAIcAhwCHAIcAhwCHAJeBFCiYoPmHA4PkFvABAPuB6jKsYGy8NawWDv2Lut7P4IXWbJBpyo7uHt%2B08gpfP58wvvVJ%2FC0b8henvRykw6KGQvWdu05uWePnEuXqVLHZn9onbTBTrXIykQFblY0lH6z8Cu%2FYAZFd6Cv8MORw5PuJ6CfH1aW8MNE6wR6SrRWa6VaVWfQwU4AyZ7sojsbsYlqFMwGFBv28uq8hIQrD%2B5evXLu1Jh3Rwf6R%2FL4ErFUODgq7GS7DgXhjcslUpWQp%2BdjTZ%2BvEIivCYWLRcJpAvEi%2F9A7n32tv5fgNsOOEfGYFqozVJcVfbd0njAAphvEx0c8%2F9u5Rc8L8Fvwo2BLZZAqHjID1w41Apov0Fjx2muv4XHevHlwxUQdysLiyZ5gOQbPZjFb8p7n%2FLrll9lTvxn%2FxYQZs785cOhwfn5B5vPnW%2Fft%2BXLu7E%2Bmfv3l7KmLli%2BIj4%2B11OfRpEQ6ZXZ9cESdQGIQiUwCoZbwqzt0dO7aRstLqNnuQoX8G5WB80KfidEJn0%2B4XJrdGp311OXrX06ZMmToa4MH9R33%2Bafnz8firHAiKJZxSv8w4fE6KmiMO9pk0CyDqhlPwVNdv3b9qy8nnDt1WqVsYD8COw5YfeKK2F22OYZ9BBbsH7vn7ZdhuSa9iZ487hrQS%2BMr0%2FADzCTISmRO4uMiAogrnBgFkUAu9U9u3Sn%2Bk0lZB2Nvnrpy61FKeY084caVPi%2FFSBkSzpCJhDwWiMxiqV0A6oO1hLUJGQ0PVEZgQ5ve9o2%2F0spal8WkozCgcOnZoBkrm8lrsXgoFlBfuEw8sK4TNUrFw4z0uMdP8gvKHbAQwdmy8xZpNBqqraZxcVmDhlxmSB5hTPAUJQIjz6ckMORJkyZH2zefHhMywJ%2B8LiZjxILJhL%2BeiO63aFfV%2FzXVkEHVA3pmdWv1rFlouV8jhU%2FTixL%2FLwjpTUg%2FPrNg8KvpsSeoBUYYoOPctVpNbnLW2Q37Jr899q0Bw94bNWbfqePFetwPDhiBescFbAbYJDR9oHWrsLDw%2BPHjsLBAm9LRo0dxJ%2F7DkHFPOQQ4BDgEOAQ4BDgEOAQ4BDgEOAQ4BDgEvGvBKMBRycK1Dy58oC%2BuX7%2BOeurUqVMI3YDtA4pC78Kxpzpkl%2FXZJgWL1apQXti2s2NIkC%2BPBPLJAD%2Bfj3xk0OTPjm598utpH%2FXu1a9vjzOnT1q1JngbunUuNswUUR3sruVuYeqAie%2BTAB6RMJDRj37z3SdXEyiSKOAsAP8Doyvpee6vZ04v3LFpyb6tR%2B5eTK%2FJr3eo8Q7rkgl1BGgO%2BGa6oNA3WVR1V0%2Bd7Nu1N59IBXwxT8hrR8iORhG5oVE1Mn%2B9RGhkiI0nqBQKVgtYx4MghrQXCuaOGJF695bFaXbZbVRnp0oLHDLOXDg9cNiAl7p2HPh6%2F7PnzhgNRshC4GeAP%2BSnsA4RHk4AtScKz8TERCygw%2FOzzNNRAkzwLiBCigdoHy%2BnoVapD%2Bzd1yKimRCOkzwRsjN69ulz6vTZW3fuTpg%2BPaZ71xYvd2zXrfNrwwfs279RW%2FGMJt2nk2c2BEU0MEK7UODgCw2EXxnT1rplI8tjWNCfwtbinr4ftKdYbfDFsJmpBxCcap3BnPr8%2BYVLZ87HnszMSAM1AQYDkxyjjBPD6XknPJ5iB0%2Bx4TyxeXfwiLcgtLgXF19bWYVWERv%2BcF3oJQH3ZEPrhgUpqywngiPxbfhJVjfD0kogoKjW4Ny2xd400gF5A19qIj5WIqWCICoLtgsEdgHP4COq8ve7FBTyU7P2h6csiD9%2B9cKFu6dOnd%2BwZnmzqBCYnYTzyZeE3GF4DTyRyUNiuAXExWcdYrVEUh3UwvDN9zQr222DiQoiTZ0ICLG6TTa72Wa14DxtdiTDsMEw1A6zDLvFade7nCZWT%2BLhX3CebCorZpiaGuU0Jbl09IfJEoESJAZD3AhV4fuomjZXDR9R%2Be2Miwsmjh%2FerX%2FHyFdjGg1vE%2Fldr1eezptj2fMLPbjfvXObffFC5chRVc1fLpI1OSvxnU7I656%2FKS0j7m9dQ3V1Wnn5teuXtmzZsnXhsr0zFu%2Bds%2BTUhm1Pb9xugPUrhgJ2KVCOeGYLfFRgsrp%2B%2FTrEf2A64R7E3YeOEmh%2B4EPC%2FQfFIcAhwCHAIcAhwCHAIcAhwCHAIcAhwCHwTxFAtYuqfPXq1bt27cKiMJaDt23bhsLqxo0bMH58IX0Hm4E1YrYEQxuB1VqVU7h5wdII%2FwAiJkRC2vtJh4l9BhIyPqLZngmTRvXu0fvVrqeOHbE1wPQCNhcgMdgCDhyE3m2%2BW5jx2qRxxN8XcRKygEbzZ36f9zDbrYIPBhvBUVoin%2Frtgsh27cSRIeKIgGY926%2Fet7lYWcJmVMB7AEvubM6mlRXvo9dCVR93NvaN3oNExJcQ9FSQLj7iozHti6KaV0hlWoHAzDB2IqzgCX7kM6EMG2HRVCT4oGev0wcP1aoa7GYHVTmoBuflLiovPnHx9L7j%2B89fiy0pL2Y9IZwuk9VlhR7jt%2FV8B1bPgQNKeRAX2AenAUBYYgedIfX1yC4BF4Qok%2BTkZISYYDt%2B%2BGjfbr2bRTZpHN2keZs2oz74IDb2QnWN%2FOmz9F8OHFqzZcv2Pb8eOXkot%2FCpw1ROk%2B7RKTM1oVEaRmhjiANSAcKvahNj3byBlpWwuSRouWFJCIgLcFYmG9u%2BYEdah13v0BvtKqtdZdA3qORaTZ1Br8G5eSkLPHp32A96%2FDFw%2Ft5X2Dc8Gy4Bx7PUhtNlt9hcdhABVq1ag3RVvGYwGVPT067dupmc9kyt06L7iJU3gEkxmHT1KrNKy3I99SrTkh%2FkYmhIiJHHN4klRqHMGRpNX36F9nhFGeJbJRaW%2BvgeEYhGQr3gF7xu1sITh88d3XNw3cJv24cFBBPSkpCpDHnAiGsFftU%2BElvLUNok0MXH8BEjEdZKwxUjP3Gdv0rVWqvD0kBtWrfZ5sLcAg%2FjNJkMeqPOZMP8tLBOsQ4HAkwMbpeZ7Zn6PY%2BB9FMtNdXRtNSyDz5J95UZhchG4VsJXyP0s%2FcbTH%2FeRnMynNXZTxMv7Ny9bs3mZQd3%2F5xy5YK9II9WV9OqalpaRtMz6ZmLxlHj8sLb3vAP%2FlnEn8yQrwhZFhb4ZPF3jpy0uHPHu3bpJPOTRUplbUS%2Bb3XscvaXX7W11RR5wlBiQFTk2XA3wW2mXbt2%2Fv7%2B6E6aOXPm2rVrMXkwbRCIA20MO9LcxiHAIcAhwCHAIcAhwCHAIcAhwCHAIcAh8PcIoIpFJZ6amgruYv%2F%2B%2FSjG0YwAs0psWNBHzY4DUHWhzoXMALUVXsFT1LCK%2FJJtc3%2BIkspAHTAi0sPf92Ox37uEzGnc%2Bt73yxa%2F9964EW88jo11K7QwonSbjRa7zuY2uKkBgRtVBu2UH1f6RTSX%2BIQ2b%2F7S0b2xhlqLV4yhrzNfuXS3z4AhRAAVA0MQHhEinbVoZk5xhtWh9bQUePQYyNBkPRtN1KAtfpoyZ8KMlhFtfaUBgSFBIzvExL8%2BtDqmY5nUV8NaH%2FBshF9OeCt4pAlDZIS05glGt%2B%2ByeMqsq9dvW%2FVokPGkoiDG02mvM2pr4MFhVJvtJsSKsJ6cUKA43CZPMgPICuDAXr5Hz%2FCCDcAr6AtAR8DAgQORY9K%2BffvXX3993759FaCBiktvX7q%2BefWGmTNnL1390%2B2EeHmdAg6VBqNFqdLW1jfUKuoVKrnZoXQ76%2BjTeDppujosWsMXWWF0SRgrEVS3irFtWk%2FLil%2FwGJ6WDpwUeA2zy2kzKLU5z%2FLiEh5fS0h89jxLr1chsgOjioHzDrWHqPifffAVGEcMOt71vuUdYlwdrohtGrE7oPXAI9tP4mFsMp9nTZ0xvdMrnafMmJ6Tl4sC3G611dfUJt6JO773wI0z50vTc0ypWQ3TppbwePWENKATRMav8ffRt42hn31EF8ws694220%2F6nMffRZh3COkiEsx8%2F8OEW3F6uTzr4rmRzaJgrTmIkFWEl0eC5b6N67t1dk54z%2FFaNyOCSwjPxUjr%2BYF5bbpqf1xPS%2BXIXfXkwKC7Cc4p2MVpwgHEZnXB%2BRMkBtJMrOCboMeA5SYLA0YM9BerxwCPoaMmBc1IL%2FtkXJp%2FoJZA9yI18PzLxcGKAUPp7v20poratUpzQ7lGUdSgqFKqLGiZsThdesxfCzXZKCRGhRXulT9XdhmY3Khpgn%2FAUYYcJuRSaHjupElFB%2FaunzWtbZsWApnERyYJ9ZX0eqXDvv076jU1uG1gS2p1OSFrwRljdnz33XdSqRStTBKJZNiwYXgKFtHbaYID%2Fv5O5Z5xCHAIcAhwCHAIcAhwCHAIcAhwCHAIcAiwZSyqWlRM8EnIysp6%2Fvw5nnpxwVsodfEWdvCIyhctJ4i0QJHF1vKoeavk139a30vmH0hIBJ98Ehy4wq%2FRPOLzS8fu2j2HHq%2FfeH79an1WJlUZqMHqthn0VG8Go0F1VodebTTfe5qzYu0vX038bs2aXYW5tYgmYZ0qtC6z1vEoMXX4myNlfoEyf38%2Bnx8SErB82aLy0mykirAyDNTf7B9KWL0bXgRGva2uIfFy3E%2BL10%2F6evrkmdOOLFus%2FXGZacCQKv9AjVhoYtjwi3JCNjGkOyFtCB6Zz5p0%2BP6Try6euWjToKnECcsEVJjwI4B7gokiAsQO%2Fw32JVh1ONw2o6UwvyAtLQ3dN0AATA4gAibYAbfjpQWgxPjss8%2F8%2FPy8OSZInfjyyy%2BfPnliMZp0ClVtWWVObl5%2BRZnWZGK7NzwuDqissYMqG%2BWtAx4i9lr66DadPEPVqImGEcEe0034DiKEYal90waIVKjFxQpRvD6fIFlYXsOi16nuXr311WcTB7w2rMeAwV9OmpQQf8tsVHtKd3YkwbFgHL3cC55iNMFXYPO%2BgqvA%2BXufeqkq2JCCwXBbWScT72XKFXVXr1%2Fr078fLq13376xFy5gtpQWFa9duer1vv17tn9pcNfeY99879QPy%2FM%2B%2BaTcV4JOjToBqfQh2YFCRb8edNNKeul43defZDaJTiO8fYR8QMirAmbuyBHZ9%2B9Ro1Z38%2Bq6rh0m8sgihlzgS8oEjSsbtbfPnEZP77J9PRaJLU4idvNkenFQUXhr3Rff0OR8qjZSG%2BsVYnHa1AatskFhMhusTrMVRiFoLbFZdMr6WkWd0mxCJxPLY%2BAPcwaeImwEDgRBSvo8q3Lc15kR0fVimZJI6iQhBYFNSoe86zhwhNYr7C4LEMQf4lPhtonAHPTugMCwePBnv61ebfhlf1r%2FN55GNCsOjswV%2BmaJZCnBkanD3z708UcfdO8ye%2BbU0V%2BNe%2BfjUZNmfLX1143pBcl6pwaTCQHCZjiveoYA0wmTRCBAeDDLY%2FTv3%2F%2Bbb75BXwne9N537PhxG4cAhwCHAIcAhwCHAIcAhwCHAIcAhwCHwN8jgCLXU1TZUZWDrEAB5a1qvTwGalsQFxC6l5WVoZF%2F%2Fvz5CLbAUzZXpKa2ZtPmxeFhnwjIeIbs8gu459%2F4sqRRYte%2BzsPHbQ8SjRnJVNXALl7Dw8DtaKB2hdskNzVUK%2BU6gw3pFuUVDanP8nPzqjQ6VNqsN6LZAmMDWlvVcPTgiRmTp3%2F07qixo0avmD8%2FOf6ORaWg8LSE3yS7sI4%2FT4amGZ6Namq2WZSmsrzaZ%2Bk5yTmZyuxn9Fys%2Be33Sv3864V8A4PuDEZJ%2BHE84QqGN42QmUS4uWWPqwtWFCc9c7HMigv8BbpeNGzLgRtcicVhQa4pG7lqtiLttL6q9vKFizt37ty%2BfTvaRmBfgNrfiw92ACDEGFeuXBk%2BfLhYLAbxgrIUj5%2BP%2BzwpKclpgxbF7jBZ7C4nUjZMKGHZNhW2NQY5I2gJAZUBYsNGTU67nCbfc8%2Beq4xoquKJnYRPiRA8Rl2bdo5NG2lpMbpqoCZAVe6hPlCuIw8WlhWGjCep82YuGD5iVP%2BhI%2BbMn%2F%2F8%2BTO7zeDRY%2BCL2Q1j%2BnseAyfsPW28iLfwFBsGHcGyMHotKigohjNmYZG2QcUmxbpcao3mTkJ839cQQ0p69nn1zLmzEOqkJqWMeusdVOAi2KISEsITT%2Bo3%2BFzfAamBftVSvlLM1IhJSoi4asxweu0MzU91Ht5dNXR4rk%2FoFUawnJCJAmbt4L7lcMXMz3YdPvi4T49zfqI4mSDDPzRP2CynXT%2FH4f20JNW5bqUpqoVVEqjhSerFvkV%2BjTQDRtEjV93lCriegsuSqxqepqU%2BfPxApa63ua0YM7vdVJiVvnbFssnTpx49f06hQV8MO2HAS73gMahFRXNzqyfNSGrVItPfJ4UhGUKfx7LQB9371m%2FcQmsqbS4T4ngV1K3wsBlGp8eDxA1OC%2BhDAGIzFOcdmDxhdqPQbSJJhjRQ7h9RGRieFhL5eMiwDW8O6904askPP5y5dSP%2ByYP0589qakptcJRFCww4LNibgENCS47Viok0d%2B5coZC1lkXQ6tSpU2HviQBWvPX39yj3jEOAQ4BDgEOAQ4BDgEOAQ4BDgEOAQ4BD4H8QQKGKMhaFrbe2fcFjeF8HiQEdAur3DRs2jB07tkOHDps2bYIwA1aKtLLMunndzeaRJ%2FyE54S8TLG%2FRhxZKokoe7mna89%2BWlxA9QpkjlKTxW1zqmzW%2BOL8rbFnlm7dumTthoMHj2en5Zl0VqvFrTfZ9VYXSkWoHFBrou63mh0ahTo3Of3ehWupt%2BIb8oupWkeR74n6zuqAj6MFRIMFLRVGaje6DUhNdWCZHcQDTDhgsEGNDfTWTf07I3N9fWpEjEEE20mRhSet4ful8f3iBIFXBBF3YvoXrd5qKihElis%2BC9cNmIxi5R08htGDB6tGMFmowYzfVdbIExPuwXrx3Llzd%2B%2Fera6uBufjJQfAEqCoRy8ACtIuXbpERUXhsW%2FfvqNGjTpw4ACOhNGE02QFa2O22xqsxgadzgH6AaIAmB%2Fgz8r2yOBLWOsPt5JmPXXPW1Qb0bSeEbuIkBIRy2O0buvYCD3G73gMT10OHsPuhn7EadWZMpOfX7525%2FTla0lpaXa7Ed0T6IdBOxDKbmwsl%2FG7DSOLsUYDEU7PG2iCN7Hz4MGD4ydOHDty9OiBQ%2BdPnUlLTlEq6sFvOFzOovLSOfO%2F6zNwwJx532U8zzKbTcUFhWuWrRjW77W%2BHbu89lK3Ia%2F0ejem0%2ByA4MN8JkPEbxDzVVJeUlRg2ZRP6bNEqiijWWm6mXNLo9smSQNvyHyPBMr2dm5bvHoxvXCGfr%2B4tl27Sn9flcy3zDc0y69t0TvjnU8eUKuCnj1N%2Bw%2FX%2BEfUSH2qZJJCsb%2BySRfXzGU08RmtUzt0huzi4mPnzh46crBOUYMLBbINDbVnjx7s3C6GEQrGfv1VckaW84UZ6W96DAO1qmhBYe2sb%2BPaNL%2FhL7gEc1Ee7xxPuC80InX6TFArDrtORS1yt03J%2BrWwJqYgQ0BhsDPUbXHq5E9vnBnWo10TQr4mMPSQNAREVfiFJgWGPhg6bP1773aNiv52ztznJRUYZzvUPWh1sYEpQ08K0mlZSYjDxnqqYBZhUo0YMaJz584jR46EMwY6TUASAvP%2FuT%2F%2F%2BB5GE7ctHHrxVX90Q%2FAu2BUEFYHS%2FOO%2F%2FI%2BfwJfk5OTcunXr%2FPnz0JlgHxPvHw%2FinnMIcAhwCHAIcAhwCHAIcAhwCHAIcAj8QQRQj6PwQemEwgqEhrfIxYvYUGehroHS4N133%2F3ggw%2FGjBkzaNCgkydPqlRqtq2jqsy%2BdV1Ky8ikEP88P3%2B1JNTJhFULgivadXHt2UeriqgDVaABK9CwFiioqlm2eVvLTt0kfmG%2BfqEtmrX8acnSssJCm8kEQQDqbQtOAPaRrBGHzWGH1yRaWawOg8UFRQQYDDQRwF8U3RB2Jzoeauu1FdWVaq3cjV4UlIcIIGWPwo%2FB%2B8DlNChp3C3d6NHZAbJKMaMTC8x8MXgMNRErBX51Qc2e%2BTU%2BH9bixJj375w6UFlZCC0Cgi50bjf6CFheAL4EsCO1QaSBNgUbls5xbohngQ8mUEIhhkfvjlfGgNzSd955JyAgAE0lvXv3%2FuGHH06cOAHyB80XOMxps9t0BovekJSWcuTiuTv37xnUqKPZ9hX27288BqJY7OBRclKc8xaVhUXXEYmTiMBjwNmjpkUb27q1tKTot74Sjx7D6ekrsTh0Wp1KV69xWyh8PtUWO%2FgLuC9Qh9ntgubDw2L8bT5gZHFpeMQGg4tHjx6BfoFiBOeJi0LBGx8ff%2FjIkUMHDhzau%2F%2FciVOPEx9WlpejcQZyBiSCPEh6cvbi%2BaRnqSYLvtxlMRirikvvX791Yufe0zv3HVy%2F7b2OXfoTsoCQmzxSJxJopaJnraIrF4IWyHArFLRG6dixr%2BrVwemNIgvCwzPDQ%2B40CS96e6hr6kT30KH1fv4GscAiEeT6%2BKe372Nc8wutqGQFDPfvW8ZOeBYSlhoiyQwVZwX45fhFZb06tHTj9qLb8XlPUuNuJ2zf%2FsvadWuyczO1BpXWrKquKtm%2FY0ur6CiIHEaMGX0r4b4DNASQYGU8%2BAeNJnpqVdPiYuV3Cx%2B0aZ4g5d1D2CshVwk5LBRnf%2Fo5TU%2Bl5gYn2kDMGpfZyNJZDQZa20Br5LS8mOama66d3j1tbLtoGbxWxhJyjxEqpI3yJf5J4VEPx4xe9fH7XVu1Wr5sVWGFAiocG4gykH7oh4LliAGkFvQYeMZ2jmD%2BAPa4uLh9%2B%2FYlJCRgHxMGNyNGDNPNu%2FO30fsD%2F%2BKbMbjw7IXjzR%2FdwFIiU%2FjatWuguf7AT%2F6zQ0FiYHZ9%2B%2B23%2Ffr1A%2F%2FZq1evOXPm4MT%2BTxiSf%2FaD3GscAhwCHAIcAhwCHAIcAhwCHAIcAv8tCKBcwobaFuUPdlBbYWkeDAbyQ1GMY40YC6lbt25FzQtCA0agEGOwcRVo%2F5BX2HZuSG3aKCcgQOkbYhU2cpCgCn5AYZuX3Dt30IpMo6tGTfWgPOR2S3Jq9ozxs%2F0ZfymRBgsC%2FIXCsR%2B88zD%2BmlWnsBvQcWJyWfROs9ENdwYrCh0kcTiMNosKIRQuuxm9Hi67wY2%2BDDY1E8ac5XUNz4vyKuXFNgtKeANLqrhYSgD5JSro%2Fm1a%2BizJ8uX45yF%2BuTxSw2M0hK8jPCPDtwnF1UJJAo%2BH1oa3pMy4z9%2B4kXRNZdOyX8v6P7JCCfaLzBB1wPcTZ4G6k30dxIOXFABKqDFRbwIxHAtCAMh06tQJVTM2FGuQM3gbT36DlPWasJYUFHz%2B1RfBjcPffX908sOnbh2aWNxsHwu%2B12O5YGDdF1T0ebJjzvzisCb1Yj87I3IToY0IKpu2NP%2B0khYVsnITNKHAq8GJkFhjdX3l02cPYmNP3zh%2FNSc9%2F%2BGTtKzCUrh1sE0qLvSfsOKDF384Ve8oe4cYgwiyBUUusj7z8%2FO9QbHojgGnodNq9RqtUauHswdYG1wFvgTVtc5i0pqNOrMJPAbmClJZXRabw2g1K7XakuqU6%2FGf9xncB6m7DHOBEdYKpVqpT84rHRTrltOyclpnpgo7fZZr%2BH5xTts2iuCgeoGwxs%2BvNjRcHx7uCg4x8XhWtP8IyLNAv%2BShI2ncE6qxIFLVnJCY8cX4vSE%2Bu6XkmJTclPHviKWHI5seGTf%2B6LqNx37edXDTnnVLVs%2F9ZvaRYwdvxl3Lyk9XKavvXrnQr1vXsPCw2YsW5haXoQPJ04jkHULwUzpWj1FSql%2BwJL99mwp%2FoZxP5IQpI%2FzCRlHWydPoo%2Fu08BktSbOnP7Y8vEfvJtDLd%2BiJC%2FTQMfrzZvO8b1JGDJgdLIriEx6PwNj2BsNUMLICWah88OCGtT8dXP79W0OH7N1zrKYWcxa%2BHJ6kHk%2BDEjxJIfuB7ghjwwLr4Q%2BhnQDyeMS89953xcXF9%2B7d%2B9P1PubnkiVLGjduHPLHt8jISHBxhw4dgsroL%2F4PmJGRMW3atPDwcDTOoM0Kj6GhoeBC8Tpuor%2F45dzHOQQ4BDgEOAQ4BDgEOAQ4BDgEOAT%2BaxHwVrXekspbd6PEwOo8Clu06kPx3r17d4gNjh8%2FDmEG%2BI2SkhIEs4LiYNeXq8vs29ZlNYsuDW6k8m2kEoaqheGFftGPW3eoWbSg5Nrxh7l3s7TFcre53mEryC5ePm1xM2lEiDAgWCgL8ZF%2B8dmoqxeOZibfq8hPZ6kMh5lajdSKlBA4TkAfwVpU6Nx26Ctgv4l2DzV14hGUgtZFNTaHyqQ32LRO9JWYoNhneQxQEDrqrofRhUNLs9M0X46%2FJxXfJSSDkEqBUCHxVQqESoYpEQoey6TbRfylXVse2jwvP%2Beetq7EZda5HDAvhZOjDUaktMFENWaqt0IHgqRXi90AmoWa0NticNtMbiv4FgvbxOB2KJSKg4cPtY6Beyi79ezR88yp04grxYxC1gnqfdAHZoPp%2BrVrA4YMIgLS%2FqUOh3bvbSitKU8rrM2ssKqsTlwn8lAcRodTTTOTHd8uKIlsrvENsjEiGyM0MqLypi0sS5bAl5LqLSyVYXE5dQZFWfH6lT8M7NO1e8d2fTp2Hta7%2F6DefZcu%2FrEgI91t0sIyFYwQRXsCa9rJSllgDwqyCkQVyBc86vT6xMQHDxIfVFVWGfQGlsJiDUTZv9%2FuBTzHIEAGw37SaXZaEHPL9t%2FgULRpwC%2FCBg9Yz%2BEgVjS6%2BnuPNo0YPUPkv0PglygNqZcGqqQBhQP6qPdup%2FI6ikRdpZvWqhyxp2tGvFknC9ITPjxA1DJ%2FnUisJ8TKJ2qGVPsKbkeHXhw1xpCUbleZNAp94ZXrZz%2F7eE20%2F88ysotHzgnIHYngWru26T8uLr1xPfPS9TtHzu9et33ilxN%2BWLJ48%2FbNV25fUSiqa0qLdm%2FfvmzlilsPEvWQ6Xj8MTxUBhtX63ZhHNW0vFK7ZGVB29ZKKeOS8hxCgUngowgMNfTpZ5o6KW32lw8WTrny8ajjvXtd7tIjufdreb0HVfUeWP1Kt4K2bZKahG4NEPTkkQBCRiOmhAgKpJGVHbo5l%2FxInz7KuHt9w7p1GZlF0HGAQmGpDKDrQKiK2%2BTpXcJ8hlbGe9N5b0DccXiKDdwF%2FGe%2B%2BOILEGKwK%2Flz%2Fy%2BBx1i0aBGyXL1z8j9%2FhLtLz549f%2BMqMW3%2BwoZpcvDgwSZN0HnzdxvSfCAUgfLkL3w391EOAQ4BDgEOAQ4BDgEOAQ4BDgEOgf92BFA9oZhC3eHdoMdAHVRUVDR58mRkKKAIwYrqypUr0bmPIgvuELNnz7567ZpV2UArK12bN%2BU1aZEf1LgkoEmuf7O0oJbn%2FKO%2Bl%2FjOatXy8%2F7dh7874JvFs55lPzOZTVaNIeXW%2FTWLl074YvxHn3y8YP7cud9O%2F%2ByTMe%2B%2F99bUr8fnZqax9TJqYzSJeOp%2F7NngU%2BERSEBboMayv9mgsbFJmhBOsJYF7Bo7pAl2qCbYhgsIFdjXwXvo3Q41LSmomzrjpl%2FQLYaJY0hKoO%2FTYNlTmU%2BKryxRKrsVEHA2vFHWF2P1h36p3bejbMc2V9xdmpZO0zPo81xaUExLK2h1Ha1TUmU9hb9ofRWtLaHVBVReQJUl1FBFLXXUxbah6M2amwl3uvXuxfCEAkb01pC3Hscl2jR61lIBDhsIFYGyw2qLf%2Fh4yIg3kSHbrUvHnRtWr%2F1x0efvvz92zIcb129OSsnMzy8tfP7cXFlKn6U5v19W3rRlg1Tm4AmMEt8amX9x61aOeXPpo4e0tpYq1bRBjWDQmrhbk17vH0oI%2FiIIiSSkMbwaBg1IP3mUKqqoXkU1atAL1GCkRmg99HDRgJjD4rIjWxYdC9XyuuXLf9qxdae8osbTbcFapZrB2LBkBVv3s2hanWiFYF0pqcUKbQQrwWHdQ1kNCbJdrG4dCA5WTWJw11e6z519PuTNmwHBaT6yMrFMI%2FSTB4YVv%2F%2BO9uoxqq2jOgvVYaQMtCzfumZDTXiHOhKSJhLf9xOkiXgVhNSKmDQfwRERb7pY%2BF501Pzx4%2BNv3FHIG0yYiIf33hr95tXIwAcCJkMsyPMVpbdsJp8ykT687yopVObkpD16fDH2wp24%2BCcpqYVlpXpMNoulqrq6uKK8wYBOI48MA4wLSyaA7QI3hVwcI61RyDdsze3aWSsTOKU8nYCnEgrrhVJVUHhOq%2BZ7WwVvjWl0KDjoNBHvJWQ9IUcFoiTf4FLfsCJZcHZgwM3QoLki0duE%2BYYvOS2LftK2t272QnonAaSNVV6bl52TV1SeVVRZoVSBJPKKbjCNNW7E66JbhvULfSHpwQ2Im449Tbcbl7t48WLIKHDflZWV4cU%2FseH%2B%2FfHHH4ODgxmG%2BTsS4V8%2Bwc0%2BcODAU6dOqdWY2H9pwxUhimXChAlez9vf%2FywkGTNnzoQE6C%2F9APdhDgEOAQ4BDgEOAQ4BDgEOAQ4BDoH%2FbgRAXwAAlB7YvIQGxO0oQ8aOHetNUoDzw8KFCyHDgC3k2rVrY2JilixdKs8vQLHvWrc5p0mblKAmySGtboe2ORHcarFvKGItmnvqax4hndvHXDp2zFBTB8kETBkqy4rikhOuPbz1KOXRrFkzQoKD%2BXxeZGT4wQMHtBoNKxjwGHSgywUBGljv956YvK7u8JHDK35aicRPuRoxri64WKAYh7yAdTuAUt8Ixwez0w4DTTPkFNSuoqVFtd8uuhPd5g5Pdpohe4S89SLmFxH%2FjNjnlMDnmEB62i%2BoYMhw9RcTnvYdcKNT58pPx2lnz9HMnqNdsEi%2FfLlh7Trjz5uNWzbrf96k%2B3mDfsMq408LbavnGTcsVGxcoDi6yZF7j1oq3c4GWB4U1pTOW7z49aEjhg4esW7JuvoSOTUgNwMcC9t9A1R1Dlpcr161Yf1bw4dM%2F%2FqzLau%2B7%2FVSSxFSTXikfdfO361Yue%2FQsUsHj8lv36Pxj1xLV1e2jKkTiCwMUy%2BW5AT65bRoahzzHl27yr3vV8ehfdYTh00nDucg9HRQ35EhfkN9xEOlwmES0WsMmdqy8c0ZE7WnDpsuxRovX3ImpdBaOdVqWFrDAYtJh8Fl1bhsWrv9wZPkIUPe%2FPTDz1ISn7rAV8AZ0mbSu6w6HMM2peAFD1UEA1WQH9RoZ%2B07wMuACvB4iMCOw8FmklpYUqOBVmTQ9attnV5pkEotQr6Rz9fzfMobRVXMmGBKu0mtcmrWsX6qTrPbaXTfemB9bUKd38tXRdKtfBIrIGUypjhIeNJPOIGQlwkrcvD39xn94QfnYs%2Frqstpaa7%2B5w1FHV9WigK0EpnKR1ISFlj1ak96%2BjiVl7n0dQZ1nVqtNVlcoFgwY0BweUxHPDmrHg6DvRg0x4DEQJ4JdCX4wzSp15T%2Fuj%2Fr1d4aqdAmIkoJr0YiUPPgB%2BufL5P96kM2isgdviSX%2BJ4lzHxCfiYkRShTi8PrJKHFvoHpoRHHfYK3BESe6tDz2egvahavcd9%2BQKsUVN5Q%2BOjprq3bZs%2BfPx6U34olh65fzFPL65ysSkZDbSqXGaPgcLN5JV76wvvfD249TBUEH8%2BaNQvZJTweD8zhn%2FufCd8Mk5agoKDfEwj%2Fet%2FX13fw4MEIJEKf1J%2F70d9%2FCm0y69evx38UuIp%2F%2BF2Qot9%2F%2Fz1kXb8%2FntvnEOAQ4BDgEOAQ4BDgEOAQ4BDgEOAQ%2BM8RQPXktRb0llHeD6KeAmWBgJIBAwa0b99%2ByJAhZ86cwSptaWnp0qVLoQyfOWtWEaQLlZXOA%2Fvz3no7c%2FDw9Nffie3ab33LjuPDomIICUQWJ0MkUuGg%2Fv2un401VMmphe1xsNtMGqe%2Bwa5PyUr76OOP2TKHYVDafPfdd7m5ufh1L5HiZVSwjw2FVWJiIooskUj03siRD5OfahxYUKeegFhWDsCKMwxOu0ZvsbKZJ3ZqoHY1LcyXL1oW377HHZ%2BIA4Q3g5DPCFlGyDkkUxAmjsfDX0pA4LOgoGs83mnYPPr7Z0RGPItolBYdntEkKrN548zmTTKaRT9rEpUcHZ4aEZYdGlQcGZbWPPJS00YX%2B3fTntxD64upWemwabUWfXJW1oUbdy5euZ2VUQDhg9vuRh%2BGE1ksCAZxO1VOd73VgSSRi%2BdOXT17ZO%2FmFS%2B1CmfrO4Y0bd%2F663nz9hw6evvUefntRPAYzlUbi9t2qBCLdeAxBKRcyisPldW3aqx9pZ2q1yvyfj0rBvfPHzooeXD%2Fu316nnqp%2Fe6mUb9Eh%2B2KCl0i4G2QCG51iskdOujZsCEJH36k2HuAFhVRrYoawGPA78Nuog49skQNhhNnz%2FfrN3jkiFE3z1%2BzG6AUgAMGGnsc4IB0YC48cgyWCEDhj54SakCcix22HC94DBMrgal3O%2FUOrctR6y5MojOn2Ru3MAslbj7PSvhGIq0Ka6ZZtsBd%2FYy6lOwvW6G0QbatjeaU0SUH6tsNjZX6byQklsfUBwgrAn2O%2BflOFIj6MExLP5%2BWzZt07dZt1eo1ldmZVF1DL19U9BqoF4WZhH4GobhKKi2Jbupat4EW5lKT0m7WWOHECprG06%2BBGYHmG48Egn3Fo8fwxI3AJxa6EvhUgMTAn1JXfvB45qsDFIzIzBc2BPpXymQKnszAC8nxC9khISv55JpPwDNR6AHCzGDIMh%2F%2B3ajo8uYdilt3yO7UMbt%2F36dDh6d9MVG%2BeqPr0g2aU0jrVFSlMxaXbV%2B1NjQ4hCcQMFIJz8%2FntVHvXkmMr9YqDQ7kyyB0FyYY4N1Yl5UXPAYmPG5DMIpw1zx8%2BDACfNHP9Vf6SiDqAI%2FxH%2BoxQJsMHToUgcJ%2F2pHj9%2F%2Fh4EIQUPL666%2F%2F01%2BHkwwiWvR6iKq4jUOAQ4BDgEOAQ4BDgEOAQ4BDgEOAQ%2BBPIgCuAJ9ESeUlNFBSYUNJBbNKVBxbtmyJi4tDeYVXYP134MCB%2Fv37I9GgqqSY6pU0%2FYHt5H7H6eP2EyeyVq8%2BMWnKgnfe7tqiaUhYkH94cNteXRavWFZRXI6kStTJWNJHWqodfhdOW1pWxkcffQQeA1ujRo3AYyCTEaeBk8EPeTecBs4KrfQw0uzbty806nAgPHLyeIPZCMNPC2waUBZj6R08BnwHUAWyPREWuFpQ%2BGNUlNUuWxffbWB8ZNtjIhl4jC8J2cGwXhlyQjQigdFHgj6CSkJyPQYa8AJVIy1UxGsQMPVCnoLPr%2BPxFTxeHY8n5zEKAd8oDtT7RlT4RSX7R8S36WRYt5Hm57kNWjQp2J12g8OhtNpVVuy49A5nTUNDYXlpjbrO7Mb5sJ4eFRpd%2FIPES7GnH8ddi7t44pN3Xm%2FeNDyiSfiYL8Zee%2FJYrtbaNCZaB1uPIvvmneltO%2BT5%2BOolIquM7%2FBjHAF8g4RRCXh6X5E5LEAbGlTpJyvylxWFBBU2CisIC8kPCc7397%2FHMKkCRhkarAoJTvKVnWrXoWDTzxQL37oGdqQ8PIaVuoBWrVZ76NjJbl16jXj9rRvnrli1yKpFLwlkGEh%2FwR9rpgoGgBU3wB%2FDhcFD6w6rx2A5AbiQoAY1sO9UanVVNcUWVSHNfGD6%2BCNDWGMD38dEGBMRWIhMG9Xetm2TS1tgNlQixdTSoNLrNVaYttZp6JlE9bBx10Ki9jPkNiEqPr9a6hPfrOWhnn1XDRw88623f5w9Z8%2FOX7PSs5x6rduiog%2Fv6Ya8Y%2FJrbGUCzURUS0SV%2FlGur7%2BhDx65zfBNwWWxfAX%2BcNqYaJgUOE0QGh4egxXueEw%2BMFs8PAbmIHgMlU5%2B%2BkLJG6P0vpE2YagqJApxt5qQVuaQ9nnRbXdFhKwI9bvSqn3OKwNud%2B21s3%2FXQ%2B%2B99uzzjxRff1U3Y3Ltopman1c6Lp1BmCytKqeKWqpVU4PerWqoKy7auX1b65i2AqmUiEWEz2%2FXufPOXbtU9UqH0egyIV4HviXo1mHnNmY4S9V5lBigNTDt8SJkGDdu3IA7zZ%2BWRvznegzcU2g%2FGTZsGG6x%2FxMSA5eQl5f36aef%2FlN3DlAr48aNg4spLhlTids4BDgEOAQ4BDgEOAQ4BDgEOAQ4BDgE%2FjQCKCtAYmDDN6C2elFSQR8OBgP1lLfmwgFgG%2Fbt24ccExvcNbF6ry2hVVlUXkRry10lBYaCnILkR7%2Fs3jZpwTfj581esXvn%2FfR0E1sBe0pKCBVsLtgv6G3WKnntgvkLoqOjAwMDW7Zsia58eId66zjvOjVb3Xk2OI4iqxHLu1Kp9O133om7n6CzoTGANat0Yw3ejKYHt1tl%2Fn%2Fs3Qd8VFXexvEgSFcUpSMggiCKigUIuq7ttbxr29W1rm3XdV17AQs2EFQUlF6l996l9w4JvYckhPTepvf3d%2BbivFlWEFDRkGd2Nkxm7tx77vdO%2FHzOM%2Bf8j9fudAdMaUsXoYa%2FKJSZmfJVnwXR%2F7Og6TWjL6r3fuUK71cpP71aheSK5fOionKioooqRBWfE8Xj9KioFH6l1OQ55Vgyw82DqChXFA%2FKecud4z%2FnnGD5CsEKVXzlLnScUzf1nPqx5eusanhVxuvvhzbEhgopTMphKUlqFmyl%2B8%2FP5Lz85Rs3TJ47c3vcbnvIRSzAhJkDaZljxo%2F%2F6P13vurywawxQ%2BZOHP7N159179F11pKF6U6GCIR9Cl2h%2FYmuvoPWXNk69qKLUs6rll2lfGHFcrbKUfnlo7Lp75eLKihfLpMGE7BUr5ZatVpCpUqJFSumVKmac94FaZUq5VauZK9a1Vbh3EMXXDi1Zcs93%2FYOJR8xk0p%2BGI%2FBkAhamO2wL1%2B97s477vnH0y%2Fs3rw94KTGBf9jOVAzJIMGk3UczTGYGvNDjsEkGdP5ZKwGRUxZIsbNqjE5CXG7ipN3hTYsTb%2Fv%2FswL6hVUON8dVdkTdZ67fF3vlX8IjvguN2nLppjF29aviV29dtOmTQmJ8aHs%2FFBsvLdLn9VXXje6XNRK8MtVzqp20aLGl8V98LFz1bqcmB2Ze%2BMK0nO9NMzNgrS5oZ1bQ692tNW7PL9cjWDFmp7zGhbWapkbfZ939KRQXnY4FjBzRyg7Si0PJvTgWSLHCIc0VPIgzSBD4LNjZpd4QqzSunj5wWf%2Bdbj5tYmXXL6zRav8Bx8O%2Ff310N87Fr3w%2BrLnnhr%2FxJ8TO38UHDrWP2OGbeMix65Vwe1rQ0vmuqaNzJ02zL5yRih5T8ieHXLmhey5IWdByGsP0WKXY9%2Bhg1%2F06tnsqtbnVK0WdU6FGjUufPbJvyXt3R%2FiOlP60%2BE2E47CAQZ%2Fa5Ebf1x8%2FvlQ87fGXxw1dXnp9P6iTz7H4K%2FvwQcfXLJkyWlnJiVbSIMPHjzYqVMnynv%2B92AMniGHnD179i9yrJLH1WMJSEACEpCABCQgAQlIoEwJmDDAy6Ka%2F7%2FuKo%2FpT3GjSoYVKTAMg64HzyDD6pCM02BYeCDo8wdYCyLV40oO%2BHKDjpwAXbmAx%2Bv3JOZlbTx8aH3iod05WRkuj52iCPTw6VjSNw4P%2Fme6hdPjYZjHu%2B%2B%2By1e3H374IXX%2FOC5HtBYA5THHolsUboifIRmscUCxAYbcp%2BdkFXvphYa7eNTHcPq8GYVpuw%2Ft2bnrSFaajaVEQk4vAUtebsbwsfPufXhs06v71r2kc4NaXZrWmnlp3QN1LjpUseKWqKiNUVFxlSpmnn9harXzkipWzqxSPavyedkVq2VXqJpbvmpe%2BWoF51QrPKdaUfnq9grn2yrULChfp6Bqs31VGs%2BKOm%2FiRU33vPi2f31MqNhJjzjAKqzhvjMxEIt6JqdlTp02%2FaueX2%2FYtJ5%2BuM3nznI69yenvv7mG1c2b3pdy6aP3nvbjNFD9u2MOZx8OKu4iHoEDlOFkn%2F8ofgjuf0Hzby2zeJGDWPq1tx2YaWtVcptqRi1rWq5A7WqxdWrubfWhdsvvmBb7Yu316%2B3qdbFa847b3XVaqurVlldudKOi2rur11rR%2FWqO6pUjG3SeGybNjHffBNKSgxRmMFdjK6HxWBMTGGCn5SMrO5dvxjz3RhHDiVJiQDoQVOTlFVYmLNjxmyEIy0uGXVKKZ9hZmLwqvnT4DXqYjA7xUuK47Tlpvsz4oKLZu279Y69lWumla%2FhLl%2FTcW6d7EoNnTff7xs9MnbjvCGj%2Bw7u22dInwFDhg6bOXt21s49oZSc0Jot8c%2B%2FOKd2%2FQ3nVissVyOlap2tt91VPHlKKCM7xNAUmy9EhU7yCJ836MwN7IwJdf8y68obU6rW9lSpb6%2FeKLFSo51N2tkHjQ4VMCTGBGV8PIkHzJ2xOeH28zP8iTMRDWdniKlUylgI8jq3O2RzFGzdOuLZv%2F3rgmrPV6%2F0j0Y113z2TnDFotCGDf6Na%2BLXfb9p0RTbtk2hw4mhzOSQn0VI8wO5iTGjB372%2BJ%2Beu%2B26t5%2B6N27LklCAyp0FQW9BMAAqCYr5I0nLzRk4YmSzK1uXO7dSVFT5ClHl21517YwRY12UjQUy%2FJkxy5eE%2F9Cs%2BAJX61cru7A%2B%2F0b7tG4nn2MwFIqlUX%2BRahW0mZDzvffea9asWYUKFY4pi8GvtWrVovQHk2V%2B5tmdFoneJAEJSEACEpCABCQgAQmcVQJWQEEHii%2BCI10MHrAWJB0iIgu%2BY924cWNycjKnTbeL0hl0RrLzsvM8eXkh1jbIYypHsSfP5mWZUj%2Fd3MJQiF4fCyuG%2B7vECma%2BB11KVrx0uwLuAD1gE0MwAIOOD2Mt2D%2FxBXvmJ0e0cMk0uNEqWkILCU8Yr56RmeHxeVlWw4zL9%2Fu8xfZd6zd%2F1enj15554em%2FPf38q%2F%2FuM2bIrtSDLI4SKirMmTFv9P8%2B%2FFrVC16oWPmxqpWeOL9K%2F6YN4m%2B5peh%2F7k66oe3Ops2OXHNd4U23Zl7b9sjlVyU3uyrz8mtyW7TJu7xNYbM2RZdda2t6re3Sa%2ByNr3Y0utrW6Jr8Bm0yGrWNqXf1hOqNRjW9dte7n%2Fo3bwsVOlnl1QwCoAdLFhG%2B52blzZk%2Bq9eXPTZvXEew4%2FQ4893uvYmHH3roIVaPoGdbq2L5h%2B%2B6dcn82Q4H9UDNLI1CMgJrUkRqVt6kKaueemrPo39Jf%2BSBw%2Ff8MaZNiyVN62689vLD996W%2FvD9yX9%2BIOnBBw786X%2FXtms%2F7PwLukZFfRYVxU%2Fu8xpcknj7Hal335V65x0Jf%2FnziuefPzRhfCg5KWQrIL3geoYX6jA5BsRun3%2Fn9t1HDiWZrj7jLMz8EZrAgBaPk4oY4diJ%2Fr%2FplwfJBkwGYpaE4ZkfxmMEPSzGQvFMVzAroWDCyC0337b1%2FHoHK9ZMr3BRcuV6By9o5nny5eCShYlJ21ZsWb5yxYp1K9etW7dp645deckpTMEIpSS7h4%2FcfdefYi5unFGxTnLdyzPf7OjbsjGUVxDKd4QKvaFcb6go6He4YjavGvTaC%2BNvuXlx46Zba9RNrVL3cMX6m6LqbGjUNqNbH8%2FeuCCjYrxm7gh1SDkHC5LT4kPH4B%2BCGIZiELuF%2F%2FUF%2FYQelGqhSoar6HD8G%2F94rFK5qHPKRZ1ft%2BLXQz7Oyj0YCBSyEktaICfOmVrkLQhQPcTDARh%2BZEtL3del40uNL65Mida6dar1G%2Fx5Wn6CM1RkC%2FI5cLlMRmToEjIyvuzbr16Ty6LKnXtu%2BUrnRpW%2FvGGTbz7%2BjEEmIVt4PIZZVdjkGNZnm787PuE8th7w2IoWT%2Fu%2FMiefY1DekwVeP%2F%2F88x07dkT%2B9E7juIweYVAHkUiTJk3%2Be40SK9O49957V6xYwamdxv71FglIQAISkIAEJCABCUhAAscI0HWybuGulRmhQYhhZQiU91y9ejVzSfjJYgoTJkygOAYlQPv07TN7yZzDBUmFgUK%2BrHf77UwZYWCHwxewM9XDfHsdKmCVSyIL5izQS%2BabcFfA4fI6AkwtMaUXuHEIjstBeUBqQU%2BKXzk0DWDgh%2FUrP63unmkh4YUZNhDg63ef21WQnjmy76DGNWpViSp%2FzrnnRp1b7vo7b5m2akGBIz%2FkcGQtWv7N3Q%2F%2BMSqqfVRU66iollFRr13eZG%2Fn90PjxgXHjCnu09fZt793wCDb173yun6e%2F0l3W9ceru49vZ%2F19HXt6e%2FSM%2FBJz8DHXwc%2B%2FCrYuUeg85fu9z4reP39I6%2B8s%2FP1jls%2B7JIxc04wMYkaC36KS3AydJWdwaDTLOfhLnDErt04Y%2BKkg3t3U9nSGzCnvO%2Fw4Y8%2F%2FvCmG667%2BrLGLevXanJRja4fvn%2FoUJzN483zBgrD5UPMftjhtm0FUyb5p4wPTRoVGDU4u9cX8R%2B%2Fn%2FbV584Rw%2F3jxvvHTfRPnOocPeHIl9%2BOv%2FvBt%2Bte%2BnKN2m%2FXavBp0xarn%2F67a9DQ0KSpoQkT%2FRMmFsya6d6xPZSTZXIMD6MFzKgFCknQ0aY9LsbNsGgGq72YchI0mmTILPdBoMIjgg7T2zQ5BtEMYzIIjjyYm2cICLi6RWZH5AZmBE5GfNrEEXuf%2BNuRm%2B5IvvHmpOvax7f7Y9zt9%2Ft69qcOpz9Y7GAoCHMmOIgvVGB3m2kFAVeoMDu0LSb1gw8WXXnt6hr1trZplz9yWDD7ME315xblHTySdzA9eWfivh27%2B%2FT7umXN81gEp3vdi6c3brS0Zt351Wp9F1VtdL0rFj73auK8JcFcKqKYaIxRPHzYyDFoF0%2BYaUzmo0eEQUZjzs489FODlc8is0vc7tzsgb2%2FuOWWNte1bfmHe9qNnz06vyiTYRUUWiHMsQVZYsdHisMnkvqcDnfxgV1bX%2F7bYxdWiGI14loXVn3tzX9u3x%2FrCjmL%2FTZHkCVrOStDl55XMHTU2CbNWkaVq1i%2BfEVikiuat%2FhuwKCMhEQzDsTjZuAR2RyfautPAJDMzEwGHfGT2NBKM8yfx%2BneOLOTX6%2BEsRNU7iWCoEQGWaXVqpM%2FMn%2B2BIwjRoy4%2F%2F77GXHx39NJTDnbcuVatWo1YMAAgsqT37O2lIAEJCABCUhAAhKQgAQkcDwBei50fOg9sYGVGNDdoG9lBQjUx2CxEoILan7yvW2jRo34DvfCC6kOWPORxx5ZsXZZYXEuc1PMBARvgOKbXpvXSy%2BNrmN4kD3fktN99JmRGHzLTJhBEYkgVRo4GMfioEQWhBhW341n%2BJVD0zmy8g3SjIyMDMp0MHiDG6UI%2FWbfdAK9VPEoTs%2BaNmLsLde2bVKvYd1LLqnboun%2FPvP4jDVL811F9LyLYndOfPuDJ5o0v%2F2CmtE1L2jfsM47Tz%2B6deVid15mwFZAic4gNSSLixi5ESpgDECBGQnAPbcwlFMYyi40VTe5Z1j3vFBGWujw%2FlDS%2FlDWkVBuasieH3QUH9i%2BffWSFYUUruSsCCMKA%2BaBzZ93JCN%2B7%2F687EyCHAYIuAL%2B9NycvXt3z5s%2BpWeXj%2F%2F9t8evbnLJi88%2BPWnixLmLlixatzm5gMk3zImgZinzHYpDuZmhjKRQ0oFQ2uFQdlYoPdM0rMAWyiow7clzhbKdgfishO%2FXzuzef%2Fhbn37%2F9cAto6bkr40JHckM0anPKQhl54YYLMMJOmwhF0U5WSCDqhAmo6CBdp%2B%2F2Olm5QyTVrj8QatiA6MUwp19rhhPhz8NXDuTZbBd%2BAITEoQzAnsomBdgIIaZj%2BR3%2BfKOONYt9Xz3XfDr3sHe3%2FoH9fYOHeAeMTy4bk0oN51wh88C%2B%2BRo6Tm2Jas3Tvt%2BfnzWYTe1WIuz82dNWffi31fec1fMSy8UbVvtChbY3AWJ%2B%2FYvnDJ38aT54%2FuPGTN8dKcPO9WsXuWSclFvtW8x%2FfH7Ft3%2FP0NbXv7PqKgP6l468flXkhauMkupcIBwjmGijHAbPQGmeZhiLJwIJ%2BbymxyGD0%2BQz6DJ1ChS4Qk6HYf37106b%2FbUMaPnTpuWlphoFlQh2DHxTng8B91ua%2FyKK%2BjLKU7YtO3fDz%2FGaqbVo6LqVK3y7tuv79m%2Fm6EV5h7we%2F1mRAtHd3p86zfGPvHUc5dd3qpuvYaNLm366JNPbNiyyebik12UnH74SEoiH3zwuPFXlpiYuGzZsvnz5y9atIhMgL8%2B66Xj%2FcH%2B5POnlGOQM1Brt2rVqtHR0V999RU1TBj7RHrJX%2BIJDsQhcnNzDx06REnSd955p0WLFqzRfLwQo2HDht26deM0Oa8T7FMvSUACEpCABCQgAQlIQAISOEkBOhdWjGDlCdZja1wEQyP4jnj69OmMwWABhc6dO7NGKvU2iTLOP%2B%2B8h%2F%2F80KoVSz1Ou8ks6CUzraSYCormi%2FAfen%2BhAEuheujtmpH9fB3OxBNWRXWaARV8dW4GY3BQq500g1%2BtG09y4%2Bjp6emxsbHMPWElx1WrVsXFxRXbixn4wQwI0yu1O7PjjyydOe%2BLrt1fe7dTt8H95m1em1iUQ47Cq5RvKNi%2BZ%2B3QkcPe6dS%2F09tje3%2B1cd2KLBdLjASyvU4GFNAd584X6GZ5VMaEEK0cbUr4kdUfDucwZlgCRSMCeaFgfngiiD0QdKSnJ3%2FxefeHH3pk6fxlrny%2Blv%2Bhz0v%2Fz%2B33ut12j8NBIRAzq4bv%2BL0up91RkHtgW2yvTz%2B%2Bqknjh%2B%2B%2F%2F6%2BPPNKy9bV3PPCX2cvW5Dq8JqNhAgdTHkgeWGGkKCvkZGaFiRK4mUkrVr7Aahu57v0xBzYt3bR0xtJ136%2FOO5xdlJoXt3V3yoEEr535DSzM4TIrtzAZgqvDKAx68SwEa6aWsKaqGYXBtAa3k8oeXJ3wtaPYCavEknOYRMD83xzVCPAPIxi8TqYZGYXwk1y%2FAj4mJo9iN2Z1mNyUUPzB4O5dob3bQvGxoSO7Q0f2hTKPmKVS3E5G27BDuz%2B4dV%2FCa%2B9%2FcttfHho2e1JaUWqIcqzZScED24Lb14cOxgZcaQUhW7Itc9369SMGj%2Bj1ac%2Bub3fp33vAWx90rFK9So1KUZ2euyfh%2B9GOZVNnvfL84xee%2F%2FXdf0qa%2Bb0JdviMmc%2BT%2BRgStdBkH4maz%2BfwetzEC%2BZ8A04fw1EYzMMSIQyy8DhshY7iArMwrsvjyM7PSUx1ZheZ%2BiQA8AEmu%2BBqMuyEOw%2F4iJhAI%2BjIzOv23odN69avc%2F55LZo0njhubE5OtpvBFYQn%2FqDT7vLhyWUKhGxFrtiYnX37Duz4Xucve%2FVavHJFjq3QGfTuTNg7dvbEWQvn2J12PuogktRt376d8QzffvvtwIEDV6xYQXJoff7NVT%2Bt2%2FFyDHKG4036IM2oVKkS9TlvvfVWCnXyx75161YiRAIN8kNGiZAu8pNEkWo5KSkpzBEaNmzYv%2F71rzZt2jAM40cLYlgjMerWrfvKK6%2BwN873tM5Gb5KABCQgAQlIQAISkIAEJHCsAAECN57lJx0ouhs8sDpZ%2FKTbQobQt2%2FfpUuX8F1t7969X3rppeeff77zBx%2FMnTE9NzXFFE40Mw98IYfHfDNOzUq6fsVEGRTh5KfP1DY0XZgAwzGcfhdLkVJpkRiCo%2FCdb6QIBo%2B5RVpCX4wOlNXFY0R6r%2FCNoe%2FpGWlu0yt38x04a63yNb%2BroDj%2BUPyu%2BLi4vKwsn4sKlHRGTYeWshV2VyA9uyDuUNaB%2FblHEpzFBcxx8QUDLuZU%2BOm605E0Ex44Z074aDfY6gnTuvDd1I6gaojZwB6kXkOISTN0d10Or33L9thnnn%2B%2BVavWQwYMy2ekhOn8mzuW%2FEvftzjkN5UpmHrBdAOviTYKszO2rFnV4%2BOPrmp66V%2Fuu79D%2Bw58GV6vafNuvfsnZObQgeY7%2FaAZKuAMuYtCbpYcLXZ7WPbUYDG%2Fgc4yUYvP5U%2BOT5k5aeaQvkOGDxw2f9bc%2BAOHtsXEvPrqK%2B93fnf7rq0eZkWYeIEpDC4z8IDhB%2FTfjRedejOph04%2Fe%2BK8sTBthdGUlgiPmzBxDoFF0Glz%2BVmfFBW%2FL7c4f%2B%2Fhg1nF%2BezIfFAolmH3Ovx%2BClzazHqsxUFHlpknkpvhTthxeOfSuD0rd8Us3rN5RV7cwVCene05DuVME%2FNs%2FSdMebPbZyt2xOa6CgLESIGikD8%2F5MsmW3KbWiuOPL8zMy937964WdPm9%2Fy8z4TxU77t3%2BeC2jXOr1Xpk64vpR5aWxi%2FYewXnW67tPanTz%2BRtinGfN4oCspZYEMk4%2FE5nG7mseQXFsUnJaVlZ5Jm4G%2FuxHXmhAN5eTnr1q1ZvGRhakqyyWpcgWARFTPCAzBIikwMFV5%2BhpgGACIzEwv5Pf6ALeBftGXdB726%2F%2F3d1z78utu%2BgwdcRQ53EXNQjKnfwRZsGvZlf3ZvRlp2QmJSUmpadnEh2Zot6F25bf1n%2FXsMHD2syE7lVXPjT4ziEocPH6ZKTHx8PLNLGHTEnyEvIX16tx%2FNMQgxGEPVvHnz6tWr%2F%2BjACWIHbqQZ9erVu%2FLKK2%2B%2F%2FXZq8FJct2vXrmQs%2Ffr140%2Fwk08%2BIZT461%2F%2FevPNNzMG4%2BKLLz5BMMJRCDFefPHFDRs2%2FCKLup6eht4lAQlIQAISkIAEJCABCZyVAnSarPEPfDvMt67kCXSvrM4UHRC6V4yFSExMpH%2FEV7GkGdyIDRz0O63VH6hxyNfdZhVUb8jmMV3LItaboDxk%2BLt%2BXqWKgtfh9NkYyu9hqoO7wONnJgtjFtwMuuAQSUlJ9OPoxHHoyHFpDBP214ZvDMngtmvXrqycLJff7Q%2FQX7X7C%2BhB02GnGKSLLnEB0x2CXlMbki46382TTzjddGtND91MRgnPqzCPw312floPCE%2BoWmBmVYS%2F0TfjHsJ369ejT7IpvXqnM2gv9he76Mf73Wu3bHr0qadaXtl64KBhuUzlMD1pc6cfbIIcszxpMC8UoBgEAYHP5ynMz92wauXA3t983PGdpx5%2B%2BPMuXR%2B8%2F8Fy5Ss2at7qq%2F6DD2eZHIOv9sk7Aqaugyvkt%2Ft9DncA4oCT4RxMXDAdX1rqz8%2FN3xqzdd3adZs3b96zb098UsLsBXNv%2BMONdz5019zV83O8%2BU4TITH4gpMK17c03XXOxAyJMSFGuJ2mrTxtEg3GyoRjHHPiJiohw%2FEQAZmJJwGX3b55W8x7XT6auej7zKJ8FoNx2F3xiUmxB%2FduPLQ7Lj%2BZKMPnYyRFjic5bt2Sqb1GdO81%2FuseI3sMHjtw6%2FIVzH8h52DCBSDUfd2Znb2J3rrTXBU3xVSCxbwSCJGQFFBIk4VViGBoHovAxB1KWbBoVWzsztVr1j753BMPPnPf1OXjM2wHk9K3Dhn6eetWDTq%2B9kLirp3hERSMCmGURaC4yJ6VlVNQWGSzO3bv3Ttp6pTvlyw%2Bkp7GqAwUsDCjJUKhlLSUMePH9vym5y4GkIDA8QgxSI8KvPY8lszxGyPiCFY1Me0LksDYiCjM1QwdCtjWZR76%2FuCWmLT4Qj4OTkal8EEKZxfWsA2uIm%2FnQ8A%2B2XnYOBwfsSv3im3rvhrSe8SUccUMlTn%2BjU%2BvGe9yulHGj%2BYYRArPPvssgyjIIVnv2EotTvCTFILJJixoQunOK6644qqrriK4aNy48UUXXUTWcYI3Rl7iKJTdIMQgqzz%2BueoVCUhAAhKQgAQkIAEJSEACpyNAj4nggqHjjCTnxgx9ulF0prjRJ6Ibwlh3XmXX5AzEDqaTFQg47EUFeZluVzFdW4IF%2BsOmdgXdbsZm2FgPgq%2FtGadBvkG33uPx2ewe4oviQMDucua7nDb2S1LBVJGxY8eyXONbb7312WefTZkyJTExkQZY1T45OsflJ4emMYzQSElNSc%2FOsDltXrvNn19EFUlyDJfHQ185N%2BTLp6gmMyS8ZpwFnWefm5DERZtZeYVKHOG1Kjx%2Bl6MgJ9NVXEg3n2kHXjff2HNGfCtv1h%2BN3MMrkPBtvMk7wkt1MPXE6%2FfxZX%2BxL0ApC%2F%2BhpMQe3%2FT6x79fXrJ6TaHDafVlrS6s1Yt1m1ER9IkZ4WEihOL8vO1bNs%2BdPo1SDN%2FPnr11S8zXX%2FX84613PPXs35etWZ%2FvdNsCARvZjlUAxAzNcFMGhK43vWCn181oFhOvhMeQMCWHcRpeH9DkL75CV%2FGareuefvm5Vz9%2Bc83eDVmBgiIz14L8IzLEgv67SSjMHsLda5O3eLzFhUXOYls4x7CqmbAJjTVjUY6uw%2BINFOTlT54%2B9dr2bTt98tHu%2BLh8uy0uIXHOwgVDJoweMGnkgthVqe4cR5CimIX2jMSNm5aMmz9mwtKJ45ZMnrV0TsKWbcGE%2FFAhq3NQGTSUHV7FhuVsqDIaZHQJeVSQwS2M42E%2BEg0OXwLTAFOPI9%2FmTaSqSF5xXlbeqlUrFqyaf6QgwRcqLMhPmDZ12H0P3DZqzJDc7AzmGAU8fh8jbJzuObPm8BGaOnVaVlZ2XHz8wsWL12xYn5aVyWIm5BgmyjCBSjAtI23MuLHdPu%2B2bed2rhqfLSIHmpSWnh2XlJKeXwg0bSD1cFK01hQLNaVfyM4cwQCjfRhjkxt0mWE2Jv%2Fh8gQ9Do%2BXk4gs%2BGp9FPgQ8GkgjWAMCDczosa998CeOQvmrlm%2Fxs0Un%2BPf%2BPBTfYI%2FyeNvcqJX%2FjvHYDrYM888Q6le%2Fpq2bdv29ttvszrqCYZSRLKI03tAwY2WLVtSN4N1jvjrO1Fb9ZoEJCABCUhAAhKQgAQkIIHTFSDKoI%2FDgqoUxCBeoA%2FFM%2BQVdKl4THZB54gkgd3zk%2BcLCguWr1k%2Bdvq4nUl7coN2W8jH%2FAGHGYFB95kqGdSLYHIEtRqYBWIrLMpyeYtc%2FiKXixEKdoo0eAgiPJ6cnJyRI0e2bt2ab3ipE1i5cuUOHTpQUJSBGVauQn0MtqErxBEpKjhnzuxBgwcN%2Bm7w%2FEXzM44cYZkUsyJouPJGUcBjTeKg%2F08nlF4%2BYz34It5BAU6PKzMnp7iomJVaM9OT58%2BdOWRQ34kTRu3cGZNfkGVzFnqDLqffzjISZC50R%2FlmnvkYzh%2FujnCVSzO2gdiAZIZKIOD4yEg8e%2BIOrNyyKcNu4yh85Wz1iOl8mo4r%2F0eLX8yIDrq7zGnwsEYIi1%2F43G6vi1zFt2f33ukzZi1bsSq%2F2MZytIUuF0VS2Umhx51tL7KZ0pS8md4yC3Oy%2BAa9YgbJYMG2BEleNyUtAhAwhsGdVpwxY8WchTFLU1xZhZTVMCNj6D9GQotjPxbsiM7%2Bju3bDx08yOwacxCzngntJMTgMSkIkZQZm5GdlTNg8OCmV7R84vlnN27fVuCw79q%2Fb86iBSOmjR86bfS8TcviClNyAkWMUbEYbf7iIr8j21%2FEzBGzHmke03FM2kJ8kclYCJZkIYoh5nIx8YeIiEvkdAdNiGE%2BNua44YEkHvN6YTDk4qRJdlxej89Z7Mg9uDd20czxX3R595HH7x80atCeuH2MvjAiLndCfMITTzxRqVJF1veMjYl1ulxMHSJ4orJnOJagMiwfO8Zt%2BPMK8petXDF6zJj9h%2BKI2Kh4YpYJDnjjcjN3pScn2RgZYsaEMI%2BHtO7o1cTL5aZiC08wdoldEvzxoSTb4jCFFG0ptpMzmfEuhCDA8zbrbk6HoUGmZgfna8vKTok7lJmSSohz7CX54Xf%2B7phpwtpA%2FOn98Nyp%2FVsyx7Amdzz33HMrVqzgj4gd8ddEeEjFm5tuuomJISeYY3IaIQYJBvvkr5hJKAcOHLCOeGqt19YSkIAEJCABCUhAAhKQgAR%2BSoB%2BkxVQ0P1hwANjIawQg5%2FWA16lS0WUQQ%2FICjcIOnbt2fVaxzdu%2FtPtI%2BZNPOxibQx%2FdsiZE7LTpzZD%2BPm62V4cYrEMvzs%2FP2Puwplbd29xeItCQSseoGICX5IHUtPSBg8efMMNNzBzn%2FHqDGK%2F7bbbJk6cSFEOWk2IQX2MxMREDs2AkD59%2BrRv3%2F7iOrVrXVIv%2BpabJowe7cjKMV1vUxAjkOdxFATcDh8VPG2uQntOelZ2Tm6Rx5Xtcx9MT926Y2d8XHxOVvaUSRNbXdGi5oU16tau9fZbb%2BzZu5sFSOlOM1clXHEjXMsivICmtYwmX7tzZ0SEOZAJabibahKU8Cy225Iy0g6kHWG4QWRdC9NRtBIMOrNEEibd4C3mfMNphhmFQG7ASp50hemAO5z0rcklKFnqKKCOot1%2BJDNj2cb1M5csPpiaanGHYxMSBoYShAdL0I8O%2Bj1eSkFQCIJKHQx88RG8ZBTnZrvogxMo8SsjY0z%2FnbaYUMW6Wb%2BEf%2Bda79ixffyECUuXLqUB7JlcxgybYN9U4nCwP07T5Bjp6Zk9e%2FdtcnmLh598cu3mzaZmpgfg4nxHQUZxdpYzPz%2FozAs6yCqY2hOuMUpP3qxAynomLIZi8gsGMTDHKJwYUNwz20P6wu%2BUT6GaipOPCkcimTHTf9jeSVUT8gszRoIBQDTKhByG3LvvwO63X%2FxHy4tr1q9Rvfp5lWs3qtvp0492Hjxg93qKbMXTp0%2Fr0CGarvd1bdpQppIClZw08YOpf8KnlhNkSV%2FETSjnSk5J2btvX04BE3BMA5kUlB8KZgdcWX5nQRA9RlKYsStkINzDSRQpBM0j4qIxJpQIMtSHAUWMnzGVN0gvTD0Vdu9hSgxjZzw%2Br93tZ9IT4zTIYggxTALmD9ndgQJbwEal1P%2B%2FLD9cnqP%2F0vcnu9u5c%2BfPzzFIFSh28cILLzC5o%2BTe%2BAwy3oOlRnjJqpjBlqeRWpR8C6M7qLzB3JN%2F%2FOMfLLzyc8aTHAOiXyUgAQlIQAISkIAEJCABCRwjYI2v4Cc3Ojj8pNPHNtavPOBXHjO7hJv1K19%2F79y96x8vvXh1h%2BsHThkZ78jNCvkzQ%2B7skJvlSJgNYUYv0MNzMVmiYP3mNQ8%2F%2FfBr776%2BdstaF%2F1dhyPkoCdL%2F93jcDoPHTpEcMG8EuoHfvTRR8wroSYGB6I3R3xBrkJmQp%2BUihx%2F%2B9vfWCfFdJ3KR1WtUf21l17au3EL81ZonTPAvBIPY0JMSYzc%2FJ1rN82bPnP%2Fvv3OYDDVbVuxdcuY8RPGjRgzbcKUjm91uvD8i8qfc27VStVefunVvbv3s4gFfUoXZSl4EP4WnSyi5N36dt1UDTVLe1APgdQk4PN6DhyKm7Vg3ryVS7PcR8dj8C4DR7JCJ5weMv1wZlPY6LCH62%2FQKSbBCIdBbnrXtJuN6S%2F7PAW52fv37l65fCkDTvr073fPgw9ef8sto6ZMc1ijKtgbd4pP0hSOEf6in7EC1kwJv4fdmn2ZWqTELXSXGUFjxqSYba0owxyHG4374Y7qylUrv%2B7Va%2BKUyTYHwQFlQJl3Q47B20yfPcBQBjOaIZCVVzBi7IRrb2zXqfOH%2B%2BMOmZExZj4ODWL3oJgaHIywoXwEd9IZM5CDD5IJl1jDlFkj5BIB%2BvK0iZIQOaFgMT1%2BuvbkFeRCTMcxZT%2BId%2FjAhHv6PE%2BRWNIB2DhXTpY4yBu0OR0xO2JfeerpyypUqVuuwgWVq5SvVOH2P93bd%2BTwzTt3JKemjB8%2F9sYbb%2BDTcc3VV48cMSInO9tM5SB%2FC89RMkEGN9YxcbkZiHIkOaWwqNjFiB0%2F5VAJr8wMIFPGJZxZhauIoMvpc8Hc3E0GxZsN8dGJR%2FypsOZIRkFOrp0ioXziKZXB0CVyEi9TXw7s3bth1ZqYdRsO7dxdmJJuqriYiIbcJnwnCgtfe%2BuyHPOTZpIysTIIf3THvHSSv%2FL2zp0716hRg2CQkRgUlrH%2Bco95u0kjd%2B0aMWLEI488wnrKjIkiizjV4Rlsz7t4L2U0Hn300eHDh%2B%2FZs4c9H3Ms%2FSoBCUhAAhKQgAQkIAEJSOAXFAh38MwP9knXie9t%2BUlvlRtP8piet%2FWz5IOMzMxRo0d%2F8OlHy2PWpfnsWaEAUUYWowI8hdl5WdSgYF%2BkAztiNj%2F9z2cvaFirYcvLHnj8kZEjhtvTs0N2D4UZrcyEw%2FHtM0U%2B6VLt37%2BfcRc8z4Gso9Mk2kCfjkKj9913nwkxKpxTrlrFc6tVevrJJzYvXxlgcYeQnxyDEQisG0oH2pGevWj8lAFf9YrZvIXYJSfo25uWPGXqtBeefPam6zvcevMdD933yF%2F%2F8tS%2FX3x9zqyFOVmFThu91fDwBTOy4%2Fh3kgG3KVbKCitm9ILfu37z%2Bm8H9h09dXyOs4heMF1g%2Brrmulg5BkM0KG3Jl%2F2kGUxYMaua0qvm63oPU0SKPB4bOQ4FPNzOrVs2dHzj5Yfvv%2Fee2275403RV1%2Fd%2BrwLa1a5qNZr7314YF%2BC6cXTnyfE4AE%2FOSV61Tygza6Aj5SARMHKN8gVTLQQnuJCa36IPY52hmkaj7iH20jHds36dX0G9J86a2aRg6DBDMQw7%2BHOG12u%2BLhD23bs3LZ7b%2ByuPTPmfv9mp%2FcXLFpiszvpv5syI%2BQYVPJgaVfGcnC9wut7cKLkJ6aFRBsU2ODX8AfIpBn04hmnEKAeaCCzqCBu9z5Hana4EiynQyjkJdUi2jGfGYav2PjkQBwkNzITPNibh7VCfKn5OctmzOn1wpv%2FvOvB%2B2%2B9s1atiy%2BsV6dVh7Z3PPinf%2F77X717f%2FOHm2%2FiA9Khfft5c%2BfmZueEm0qZVMatmOiJjzKnlp6avnTJskWLlhTkFxL%2BkCsxisJkFKRMDL4IfxQILDgrm5cBSB6m25gowxTWCE%2FxoSgGE2gYxON2H0qMX7F25bY9Ox0%2BBltwTdiRPzUzbfS4MU88%2Bfj%2F3nv3vXff9ciDD3zx8Se7N2z25BWZgRnkNqYQbvjymM%2FKj9wws%2F40fuS1k3uKi%2Fv6668zv4OAYs2aNT8aYlh74u%2BagRNUsWBYFGMz2rZtS%2FRxvEVUzV%2Fff97Yksob7dq1%2B%2Bc%2F%2F%2Fndd99RdZa%2FZdp%2Fcs3UVhKQgAQkIAEJSEACEpCABH6WQLjHGaBExuHDhxkRceTIEUZBWE8yLsJKFawe1tEinG5PakJS3Nbdhbn5joC%2FMBRk0kiqqyh2766Na9YWpWZQ6KA4JXNwvwG1m1wSVa1SVPXK5c%2Bvdvfd965fsMKXR80JE4%2BYrISv98O1AqydW8%2FQ3%2BdJ6%2Bh0teiIUZyQ6gfnVjw3ivHvlSucW6PqKy%2B9tGfTFr%2BDHCNAr9Me9LjMl%2Bp%2BR3LGvO9G9%2F7s8y0bNzrMop5Bin%2Fu3rWn8%2Bsd%2F9DupmefeW7K1OnLlq%2BMjd2em5tPVMPqG05WBaEHTvcrcre6%2FCV%2B8gpfuNvcTr5zp0tsd9hWrFzWq3fPCVPGFzpZncXkGPwkyjDdON5oOuDhzjyZg6njQY5Bf91rKooyJsHvtfH1fdCXnZMxctiAZg1rV%2BC0oqLOjYoqx1Ks5c%2BtWrPW48%2B%2FuHbNpiD9eu5EShzY4XLbnWY%2Bg8vkCQQhHmY6mKVESTBYGNYbKKanHD6o6bgfTTh48eh5RU6HzX2%2BPfv3zV0wf92WjTYPhmYT2m7iDL8%2FMyNj1uzZQ0eOHDp23MDhI%2FsOHjZt1ryU1Awzd4KOPyEVCQZ5DrOI6JITN4SXG%2BU7eFNOghzDY4Yt8CufCoIL%2Bvim6oXDZGT5Htu2fbumTpu6O2arJ5eZR%2F6AiyqxLOHKlQpQepURIGbFXkpUUGSTPYerc9pcntSAK8FTnJGSnrx22%2BoJM7%2Ft%2BkWzZs1Nr7riOdxr1avz0UedX375pTvuuO3djh337d3jsNmIw4wCkYoZnhJkzZyAz39g74Hhw4YP%2F25ERhrlOrhG4UogfNjMYAqPiSLMu8gxfEU%2BJ%2FOHeEzA4g4yt8gM5%2BBkqfhJxuK0O9esXDl4QP%2FFi773uFnjhOE8ZsZRSmbKiLHDH3zkgehbom9of8Mtt97ywQfv7dga6yqmFCplb83QFJNrsavj3CIf%2B%2BO8%2FtNP8%2BfTo0ePp59%2Bet68eYzN%2BOk3cKUKC2NjY8ePH9%2BtWzeGcNx9993M9mratClhCKuWkFcw8YSfPOaZSy%2B9lFfZhvWXP%2F%2F8c961detW1jk6mQNpGwlIQAISkIAEJCABCUhAAj9fINzxNh1buseJiYmU2Zw2bRo%2FiQ6INejF0RWyUgW2tDajP06XMLy%2BKv3TcD0MBsOHQvGpqdOmzhg%2FeHjGnniez9x%2FuPsn3apffHFUtcpRVatElT%2F3ytbXjxk42p5RxHftTE4x%2BwkviWLNH7F%2BtaIMnudmPeZ5CpAOGDDgrrvuuvzKK5pdc9Uf%2F%2Feu0SNH5B9JofQEY%2FTNvBJWEiFLoLeakrF8zKSBX3wVu2mTM%2BRnERPKM1AjcufazbOmTF25ZkV2Ya6Hb%2BnJUZg7wQOvm8VmzdwBvp43HXn6vOFv3un88iW8dacLHvJTR7SYeh8czucqLMpft2bVoH69J40fY7MV8DbGR9DpDVfVCA%2BdCM8FMXNSGAlg6iqYPjXdZWph0NEPj6ogZ%2FBn5qTOnjb%2BqT%2F%2Fqd0Vza9oULdxrYsuOu%2F8clHnVKhy%2FnP%2FenVz7DZr8AMd6%2Fy8rOSUxOy8NKfXxgq21J5gvVj6zuHExMwEKUjPzE1MdWcWmnkcptdtcgzuR3MM%2FiFk4B7OLBhykFeYn5iSlJaT4WL5lfDT9PV5G4MQklOSFyxdPGnWzFHTpr7z8adPPPv3BYuXFxYUm3U7XO6grTjExAFqaNg9ZpkRYgcGIoSniaDAuXGSrqBZBpdqGIkEAhyVcQgFvlCRPS09Zc2WdfOWLty%2Ba4cjn4qkHI0NQhmh4OFQMI61T9kXyQ9rsYYXMDFGbn9cZka%2FpbM7jhvU57uh62YsiFsbO33MxL899UzrG29ocs2Vl19%2F7X0P3T9y5PAlSxYtXLggJmazg9os4U9PuOJH%2BNyoc4GW13%2F4UOKsaTNnTZ%2BVQ3EVKLi%2BxER2F4sI%2B0lmOA0CCapckIxRk8NMLiGSoaqLGRRjEipOEEOiqWLHjHET33%2F1tWljRvvcLJrDsBInaYjLb0%2FKSFy6ftm4GRNGTh47cfa0Lbu2ObwkF5Rt5fNGYEZQZK7JCW78oXE7wQYnfom%2F1i3hG38%2BJ96y5KscETNCS6qALl%2B%2BfNSoUV988QUrmzBO48knn3z88cefeuopal%2FwDM%2BPHj2ayipMCuM%2FGrzr57S2ZBv0WAISkIAEJCABCUhAAhKQwE8K0AGxMgrCClKLffv2jRkzZtgwvrEevmLFCmZ5RDop4X6hyRV4CzcqGfJlOmGFGQAQ7jXTOUs4fGTa5GmTho%2FJjktmaoAv1zZv1rzrO3SoUa%2FuhQ0bNry85RNPPbdu2TpPId19eoWmp0aCQe2LmTNnfv%2F99yxwQLfIep7DcWMDmseTRBkZ6RlLlizpO7B%2Fv%2B%2BGLl69Ijsr03yZTseQkQiMUWAZTHqY9FYzsjfNWzhhyHe7t28nRjBf7LMRXVKWV6Uggpdii7SUr8NJMZjl4fR4XUyMcDntbqedDqxJM47OLwhvSFzDKdO%2F83uKTAXIIJM5bC67y%2BU4sG%2FPpPFjZ0%2Bb7LTTETdvgyFcacHEFJGwgl4vxzfdas6ESTr%2FH2KYYQssRFKYm7F59fKRA%2Fp8%2FPYbr73wj0cfeqh5s8svbX5Fj2%2F6pKSlh2dWMJDDSYix7%2BDu9OxkE18cXRWVeiBuh7PI6Sj2Oh3ZKalH9h%2FKP5IWKKbcpul0W9NN6IcfHY%2FByA3TKTcdchrjYuFbsySKKbMRrv4Qns5AI80qpi44Ct2uPQkJH3Tpen37DsO%2BG5WTnWvea4IdAoHwui1Ue7BT8IEaF0cPhwJnRZkMBjAQHzHbKCUUsHGx7R5%2FesH%2BDZunTJ44c97shNSkAnuxiU%2BIFwgtQsEMRjKE%2FPEhdx55GFeSnCY8d8bkXU7Pii2bW91z6zn1a1SreeGDt98z8psBwwcMWbhg0cTp07r3%2Bebr%2Fn0WLlmUmZnucjNUxelhlRzTDs7UDC85eufEwxmOrbA48VBCQlyCkxwmHFLZM3O3rl67eN68LRs25KalBRxO6qfy4SUEC884Mn7hyT2mqSb6MJ%2FKEPsZ0rvvkw8%2BOLT3tz4GqFjFSgyNx%2Bl3Fnttufb8tPzM%2FUmHEtOTnQEWlzGVbWmCh0vOSJzjxxTmjyv8YTGHOd0bfy%2BnFGKUPA6fAN5OoMF%2FEPjzZKgGQ7NYQoUbj63IkVcZ9WH9hZZ8rx5LQAISkIAEJCABCUhAAhI4AwKR3ID%2BC90TsguWXmXlU%2FosdGciHS4eWDerSaZTRw%2BPrjLD7PmanoINPhZByCOLOLTvgI3iA3SRvf7M9MxJU6b88%2BWXn33hhS969ly9Zm1hXhHbmx6x6U8HKYvxzDPPtGrV6rrrrmOMOuMurM7RD0djeIApK8GT3KgfmJGVdSQ1pYheMN8Cm6UxzaIaRBnhaMKEMnmp6cvmL1i5dGkGuzLdS6Z3mHqPLFZh%2BuhkFNxM59b8ZN9OJ9VGHQf279u8aVNiQryX8hVsZuZ8mHIN%2FDRzDUxaYmo5Wl1%2Burdut4sg5VBcXFJSIvNSrB1yOAIB%2Bqol7%2BZEOVT4Hj606QeHu9ThnjaOfq%2FbUZyTkZ52JOlIYuKGdev69u37dc9eMVu30xk1%2FXFaS8UGt4t2Uuvhh5452QzDVFI3bFi%2FcuWKHdu2ZqSk2lmE1BleaiScV3AgFtagwAPnwW5Y4YQ7e8nMyOSb9JjYmEPx8RT5pJwnIzHo9nLetNDAhi8P%2Fe5NsbHPPP%2F8Zc2bf9atWzKjX2iNkTZ3tgvnAOGPgUmlzHnxkxkutIx8go8G3XtyJBaCcRUUJu7Y886rr1111ZV%2FffzR%2BQu%2Fz8zOMkN6eBcTNajOauKOYK7fVcz0IIpl2hyMnQjvkbqwzgVLl7aOblv5vCqVq1bpcGO7zh3f7dH9C%2Bq4skxJcnrawYT4hMQEcMJtN6344U6eAW%2F4w%2FbDczzDEBw%2BUeYDHH7D0gUL7%2FzjrVddccXNN900ZODA7LR0LDh2%2BPKHT%2ByHCxr%2ByIb3TY5RVDxkwMCnHntscP%2F%2BfIbMNQoPteB4rOeSmZN1MD4uZlvs1BnTlixfymI0pmSombxkriXCZlfHv5nPiaHUTQISkIAEJCABCUhAAhKQgAR%2BYQF6W6b3Fu5z8ZOBE4VFhXyNawIQemsBU3Bj9%2B7dkyZPnjBhAqsxklpQCdDqMrMNc%2BqnT5%2FeuHFjq3zgvffeu3btWsIKs1t6oOFpLPzKTngLz9B6vssmZrFm%2FdPvNj3mHzbmVWp3MIzkxXDVQSIR62zZgO2TkpJYyzIhIcEaCW%2FtkJccTgczaJ55%2BmmClLfefJPHNMw6Om%2FngQk%2F%2FqvjybMci4axN6v7bx3rNH7ShvC7jvZbiZISExMZGEOOdIK90STG%2F%2Ffs2fOWW265%2Buqr77zzzv79%2B3OO7I0es3WOvJ228dgKgjivosIiCj9SBPLWW2%2FlfB%2B4%2FwFWsz14MA6f%2Fz5HiFavXv3ggw9SEqFTp47QscP%2F3uyYRtIAbtaT5hKGb4QwtI3W%2FvnPf3733Xcpp2BdZauFHMhsZS6umzsNZrURMyYivB%2Be4cJ9%2BumnTz%2FzzFNP%2Fe2NN97o3r17jy97kF7wdj4Mq1avWrBgQUZGunUh%2BGldQV4NH%2FxoY6wmHfMz6XBS584fRKpXPvnEkxs3bLA%2BXdYn5JjtI7%2ByzYb1G7799ttZs2aZVOSHG4fmTOfPn8%2FMC0pf0tTJkydzRiY0CZ8OgOw5sh89kIAEJCABCUhAAhKQgAQkIIHfUICeGn20SL%2BeX%2BniMRydSCE9PT0jI4PBHnRg6cpxs7rYrFQyduxYal9wmzNnDvVFeQunEOn08Ss3tucZbuzfijWsbXgmsjEP6EXS3Z47d%2B727dvZjGesG8dibHxqaiopCtvwZLgJJhjhJZ6ns9mvb1%2FemJKSwjNH3xb%2BJ3zYH%2BkLW6dgNaDk9j%2FzsXWO1imfYFdsBubKlSuHDh3ar18%2FusxEQHl5ebyFhqFkNYyfnA43HnCj9x0fH0%2Fxk4EDBzLqY8SIEeyBsTc%2F2rNmey4Zc3m4QMz9sQo58uQJWnW8l6xD08EnJjp48AARUGQ%2FPKDB1huti0Jj2CDSJJ7kUpLt7Nm9m0CDZT25EY5Z7WGaA2fEp4hfrX3y0zrf4zWm5POs1kFWM2TIED5%2BgwYNImrjw2AdmuNGGlnyLdZjXqJVhw8f5oMdaT8vcWg%2BaYxK2rFjB62NiYmheSU34I0lf%2F3vPesZCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAr%2BggM%2FnO3jw4CHdJPCbCvAhzM7O%2FgU%2F2NqVBCQgAQlIQAISkIAEJCABCZx9AnFxcX369Hn%2F%2Ffc%2F1E0Cv6kAH8LPP%2F987ty5Z99fmc5IAhKQgAQkIAEJSEACEpCABH4RAbfbPWrUqNmzZ3t1k8DvQIBUrXv37hkZGb%2FIx1s7kYAEJCABCUhAAhKQgAQkIIGzTCA3N%2Fftt98uKio6y85Lp1N6BRgdtGLFitLbfrVcAhKQgAQkIAEJSEACEpCABH49gby8vHfffZc049c7hPYsgVMS6N279%2BrVq0%2FpLdpYAhKQgAQkIAEJSEACEpCABMqIgHKMMnKhS9FpKscoRRdLTZWABCQgAQlIQAISkIAEJHCGBZRjnGFwHe4nBZRj%2FCSRNpCABCQgAQlIQAISkIAEJFBmBZRjlNlL%2F7s9ceUYv9tLo4ZJQAISkIAEJCABCUhAAhL4zQWUY%2Fzml0ANOEZAOcYxIPpVAhKQgAQkIAEJSEACEpCABCICyjEiFHrwOxFQjvE7uRBqhgQkIAEJSEACEpCABCQggd%2BhgHKM3%2BFFKeNNUo5Rxj8AOn0JSEACEpCABCQgAQlIQAInEFCOcQIcvfSbCCjH%2BE3YdVAJSEACEpCABCQgAQlIQAKlQkA5Rqm4TGWqkcoxytTl1slKQAISkIAEJCABCUhAAhI4JQHlGKfEpY3PgIByjDOArENIQAISkIAEJCABCUhAAhIopQLKMUrphTuLm60c4yy%2BuDo1CUhAAhKQgAQkIAEJSEACP1NAOcbPBNTbf3EB5Ri%2FOKl2KAEJSEACEpCABCQgAQlI4KwRUI5x1lzKs%2BZElGOcNZdSJyIBCUhAAhKQgAQkIAEJSOAXF1CO8YuTaoc%2FU0A5xs8E1NslIAEJSEACEpCABCQgAQmcxQKnlGP4fD673e5wOILB4DEmbrebl1wu1zHP%2F%2BSvfr%2BfHTqdzmP2yTMFBQU8ae3Z6%2FX%2B5K5OaYNAIGAdlwen9MZT3Tg3N%2FfIkSOZmZmcyKm%2Bt%2BT2VoNp86%2Fd4JIH%2FU0eK8f4Tdh1UAlIQAISkIAEJCABCUhAAqVC4JRyjP3793%2F22WdffPHFwYMHS54dmcOwYcO6du06ZcqUks%2BfzONt27Z169Zt4MCBhYWF1vZZWVljxoz5%2FPPPu3fvnpKSMmHChC5duixcuPBk9na8bUhgioqKSiYw8fHxHPebb74hYTjeu37O8yQwO3bs6Nu3Lywffvjhp59%2B%2BuWXX06aNCk1NfX0dgsFIOyEB6e3h9LyLuUYpeVKqZ0SkIAEJCABCUhAAhKQgATOvMAp5RjZ2dlECq%2B99hr98ZJNJd9455133njjjfXr15d8%2FmQeb9iw4a233iIbyc%2FPZ3uihj59%2BrArnnz%2F%2FfcPHz48ZMiQ119%2FfcaMGSezt%2BNtQ2pBCNC%2Ff3%2FSDGubffv2cYiPP%2F44LS3teO867ec9Hs%2FUqVM7depEy99%2B%2B%2B333nvv3Xff5aSg44inF2UkJia%2B%2BeabOPPgtBtWKt6oHKNUXCY1UgISkIAEJCABCUhAAhKQwG8icEo5Bi0cPXo0ffOvvvqquLg40uC5c%2BfyJEMOImMqIi%2F95AOCi%2BTk5PT0dCaYsDGpAl11%2Bv7sMycnh2eseRnMMfnJXZ1ggwMHDrzyyisffPBBpIXM8uC4HO4Xn7FCM1auXAkIOcm3335LtkNwkZSUtGjRIkaA9OvXj%2BErJ2jq8V5icgosZCNkO8fb5ux4XjnG2XEddRYSkIAEJCABCUhAAhKQgAR%2BDYFTzTF2795Nb5rbnj17rPYQCDAHhG77tGnTrBoXjEbYtGkT80EYSjFr1qzIxA2iD%2Fry33%2F%2FPaEEQzgY1EHfnARj%2Fvz5dPx5F%2F193kKOQW998uTJCxYsmDdvHtuzQeRwHJQdLl%2B%2BnETlu%2B%2B%2BYwPmWVjHZazF5s2bx48fz06YmcK0DisbIa%2BYPn26NcBj2bJlMTExlJggHmG3S5YsiQQybLx3714GfgwdOnTcuHFbtmyJBA5MS6GFRCs0mKEd1qmxZeTUSl6ajIwMBl0wdmLQoEGRnVsbcFArnLF%2BpZwIR8GBIzIlhyEiHCiyK04KJRx4dc6cORs3brRkIjkGG3OOvJHzZfgHbYu8t1Q%2FUI5Rqi%2BfGi8BCUhAAhKQgAQkIAEJSOBXFTjVHINinlZqESmFQbJB7NCxY0dCAJpKRkGxC0IDOvLcmExBp95KIcgTGBHBbBHCDeZZvPrqq%2FTiSR7IQCi7YbPZ%2BPXll19mb9wie%2BABO6E7bzmwzSeffMIzvMv6ya62bt1KNDFgwAD2yZMc1xoOQfLAuzhcZLe8yvZkL4QGbEN7rHITBA4EIwQFkd2yEzrU1jgQAg1mvvBMr169aD%2FTQ9iMXxlfYU2HKXmNKOXBS%2Bz50KFDJZ8%2F5jFxB6M1OLvIEXlMphEpB0qAQ3tosHXDBGR%2BWjkGXKNGjYoosRPOi2IjxxylNP6qHKM0XjW1WQISkIAEJCABCUhAAhKQwJkRONUcg1YxtoFeM114a3USEgb69T169OBXhjTQE%2BdX5pgQOOzcuZNCl3TD%2Bcn0DYZbfPTRRyak6NSJNIBqFYxtIMegM042QmLAWAUGQjDYgxCAEQirVq1i%2BAQvEQsw3oBDswdCDCtAYCTG6tWrqS9KBmJlEXT8yRmWLl3KaBCGKLAfjsI%2BiSyIOPiVOhXsn1iDkQwMdeC4tId9sueZM2dyUjRs5MiR7JbD0QZazlgIggVuTKVhDxya9jDyhOOSKvArp3nMlRo7diwCX3%2F9dWQ4xzEb8CtWNIkjchSOxZkOHz6co7PD2bNnswFFMGgtLezZs%2BfixYtJjfi15HgMBqvwdiqI8l6iJNpptS1SAOS%2FD1panlGOUVqulNopAQlIQAISkIAEJCABCUjgzAucRo5BAkA%2BQC9%2B%2B%2Fbt9MdZQYMONbEDjWepEZbnoENNGkC2wAAMetlWnsCvjEAgN6BvzjaRORRWnU%2FCAVrCHhi5wdsJKyLzLwYPHsz%2BrRyDpIKIgM57ZG4FwzA4qOVGOsFwEdIDpnuwW1pIEEFlDF6lp89OOHpkogcNsHIMJrbQ97fiESaMRFY1ZSIJLWcbxpmQz5Bj8JjNrDKbpC7kJ%2ByfPME6euSnlWOQqJyg8gb7ZOfkEmvXrrXeyHHJWKxzh4LsggaXLEPKNBYaQNbBubMxZUvZgKEvTCfBliocjMdgn9b5RhpTGh8oxyiNV01tloAEJCABCUhAAhKQgAQkcGYETiPHoFNPJ508gUECcXFxVufa6t3zqzVmgO42D7gRJtC5ZmMmWVg5Bn1%2Fa8iBdYLH5BgM4bD68mzMBkQTVJmI5BgUxGBXVMD4URzawFQLRjhYEQFtoG0kGGxM5GLlGFZawjORHIPQg2YTAjDggfEhkT2TeJB7cLg1a9aQSJBj8Jigw9qAERpU7GSf1tSVyLt4QODAOTIi5UerZ1hbsk%2FeS0xB8BJ5L0Q0g2YT5kycOJHDMaoEbWsD4gvOi1cJLjgLEhV4I8485lXoKKMR2WEpfaAco5ReODVbAhKQgAQkIAEJSEACEpDAGRA4jRyDVq1YsYJeNl17xl3Qd2aGCP16nj948CDRAdkFQwsYUUBVT27U0uTGAAYWByEZ4NXY2NjIqZ1SjmGNzaCQRWTURGQ%2FdPOtmSDMcGHmi1WCg7ZZpTmsHIOBHJFyFpEcg%2BEcPKZV5BgEGpEdsrKJlWOQDBCncLIlR1%2BcIMcgOSFVwIE6opG9RR5YwQV1SgEki6DMReQlhlKQS9AMGMlq2IDZIpEcIyEhwcoxGOXCYBUyEA5ByMM8mpLOv8YyspEWnpkHyjHOjLOOIgEJSEACEpCABCQgAQlIoDQKnF6OQWeZTMDq%2B9ObJqawzj07O7tLly6kB1TJiGgwsIF1OviV6Q8kAww5sCqCWhucUo5BTQyGMXTu3Dkye4JAg9yAuST05TluZBQEwQW9ftpm5Ri7du3ijTQ48sZIjsHADxCIBXg76QGRhdUwToo9kEgwTCKSY3AU69UT5Bi81KdPH%2FbGmbITK%2BFh8REcaD%2BjOJjGYh2dnVMAxNohQz6YkMK7mK7ClhQLpcFwRXIJinJwOgQdLOFKe6g7Sq5C0ME6L9YeWME2MhnHeqaU%2FlSOUUovnJotAQlIQAISkIAEJCABCUjgDAicXo7BIAEKXdKtJhlgFESkQgUNtmZV0EOncOW6deuoaEGywYANa1lVK8ewsgXr7E4pxyAJIT%2Bhs89IBipyUFyCo9AMxn5Q25MHFO6gYAUjKDioNSjCOhZjGHiJDSjNwWgNkoT%2FrvPJbgku2CG7ZVoHiQFBwYgRI0gYKANijcc4mRyD82JwiNVOdsgbSXXIKDg0DWDlFOqIosGB2D9H4VWOyJQZNuYZsg72wOgLYhCaxNt5lTZDzY3t2TkbMAzDajD1QtkAAZINioKSgfBqqb4pxyjVl0%2BNl4AEJCABCUhAAhKQgAQk8KsKnF6OQZMoLMkip%2F%2F%2B978JNErO8mBoBEUk6HG%2F8sorvMqN7jblKJlAQQphhQOMjoicFH1wtiGXsMZsMI7ipZdeYm4F5TfZhoEHrHXy4osvEixYb2FpUTIKxioQCPBGjkI2QrkJogmWCGEuBs%2BzB3ZIDsAD61gELyQq7JY2815awogI3k57eGwdiFKi%2FGo1m52QKlCewpqHwkmxPsu%2F%2FvWvyFQRRllQJISGsdBJ5FxKPqDUBqMyyFI4IvvkRtsY9UHOY0UN%2FLSyIOtE%2BEnzWPzFGl8BKWM5OAXexVmwExpAy%2FmViIMDka4wQoMN2DMnws0KPUpmSiXbU4oeK8coRRdLTZWABCQgAQlIQAISkIAEJHCGBU47x6Brz1AKAoTIxIdIy%2BmDM3OEoQuMIuAnSYI1t4Iog1ETvIuDRjamGCbDNqiYwZgHniTNINlgMVZmSfAr0zGYNsJRSlbgZPYK21ABg3qhPLAqglrvZVc8T%2FkOWkXJUN5Y8lgkAIyF4FUyBMpfsDHtiVTa5FjUx2CcA9EEP2l2pDYFD8hPOBY1Nq2W84y1f2t0hPXkMT85axrPrsguGCXCiUeaam3JBuyEKSQckeVRmMByzB4olMHwDF5lhAlnzU%2Fio5IjLmgwcQfO7B9DC%2B2YnZS6X5VjlLpLpgZLQAISkIAEJCABCUhAAhI4YwKnnWOcsRbqQGVNQDlGWbviOl8JSEACEpCABCQgAQlIQAInL6Ac4%2BSttOWZEVCOcWacdRQJSEACEpCABCQgAQlIQAKlUUA5Rmm8amd3m5VjnN3XV2cnAQlIQAISkIAEJCABCUjg5wgox%2Fg5enrvryGgHOPXUNU%2BJSABCUhAAhKQgAQkIAEJnB0CyjHOjut4Np2Fcoyz6WrqXCQgAQlIQAISkIAEJCABCfyyAsoxfllP7e3nCyjH%2BPmG2oMEJCABCUhAAhKQgAQkIIGzVUA5xtl6ZUvveSnHKL3XTi2XgAQkIAEJSEACEpCABCTwawsox%2Fi1hbX%2FUxVQjnGqYtpeAhKQgAQkIAEJSEACEpBA2RFQjlF2rnVpOVPlGKXlSqmdEpCABCQgAQlIQAISkIAEzryAcowzb64jnlhAOcaJffSqBCQgAQlIQAISkIAEJCCBsiygHKMsX%2F3f57krx%2Fh9Xhe1SgISkIAEJCABCUhAAhKQwO9BQDnG7%2BEqqA0lBZRjlNTQYwlIQAISkIAEJCABCUhAAhIoKaAco6SGHv8eBJRj%2FB6ugtogAQlIQAISkIAEJCABCUjg9ymgHOP3eV3KcquUY5Tlq69zl4AEJCABCUhAAhKQgAQkcGIB5Rgn9tGrZ15AOcaZN9cRJSABCUhAAhKQgAQkIAEJlBaB3Nzcd955p7CwsLQ0WO086wXIMVauXHnWn6ZOUAISkIAEJCABCUhAAhKQgAROQ8Bms%2FXq1Wvbtm2n8V69RQK%2FuEBRUdFXX321d%2B%2FeX3zP2qEEJCABCUhAAhKQgAQkIAEJnB0CsbGxXbt27devX3%2FdJPCbCvAh%2FPrrr4cOHXp2%2FGXpLCQgAQlIQAISkIAEJCABCUjgVxLIz89fsmTJct0k8JsK8CHUSIxf6W9cu5WABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAESr9AMBTwlcV70F%2F6r53OQAISkIAEJCABCUhAAhKQgAQkULYEgvZU7%2FRrytx96pW%2BpQ%2BXrSuts5WABCQgAQlIQAISkIAEJCABCZR%2BgaDtiHdsrTJ3H32Bb%2B4tpf%2Fq6QwkIAEJSEACEpCABCQgAQlIQAJlS8DkGOPqlrn7mIt8824tW1daZysBCUhAAhKQgAQkIAEJSEACEij9AsoxSv811BlIQAISkIAEJCABCUhAAhKQgATKioByjLJypXWeEpCABCQgAQlIQAISkIAEJCCB0i%2BgHKP0X0OdgQQkIAEJSEACEpCABCQgAQlIoKwIKMcoK1da5ykBCUhAAhKQgAQkIAEJSEACEij9AsoxSv811BlIQAISkIAEJCABCUhAAhKQgATKioByjLJypXWeEpCABCQgAQlIQAISkIAEJCCB0i%2BgHKP0X0OdgQQkIAEJSEACEpCABCQgAQlIoKwIKMcoK1da5ykBCUhAAhKQgAQkIAEJSEACEij9AsoxSv811BlIQAISkIAEJCABCUhAAhKQgATKioByjLJypXWeEpCABCQgAQlIQAISkIAEJCCB0i%2BgHKP0X0OdgQQkIAEJSEACEpCABCQgAQlIoKwIKMcoK1da5ykBCUhAAhKQgAQkIAEJSEACEij9AsoxSv811BlIQAISkIAEJCABCUhAAhKQgATKisBp5Bi%2B8XWDE%2BuEJoXvE%2Bv4x9f1jjujd%2F%2BEuqGJdWjD6R93zEW%2BebeWlWus85SABCQgAQlIQAISkIAEJCABCZwtAqeaYwQn1HGOqb%2Ble7MJr7Qe%2Fe%2FWaz5pkTusYWjSmcsx%2FBPqJPZtvPqTFrTBM%2FZ0j6sc42z5AOs8JCABCUhAAhKQgAQkIAEJSKBMCZxSjkGIQYbwcNsbateIPr9q9HlVomtWj25%2FefvJr7UMTDzdSOEUx3KEptbu8tdWlSvedG2Tto7R9Xyn%2BPajQziUY5SpT7lOVgISkIAEJCABCUhAAhKQgATOFoGTzzFIDDzj6j9%2F6%2FVVKkaTY7Rr3u72K9s2ujj63HM7dHu0dWjy%2F8%2FyYOIJk03%2Be74Je7Ce52fJ%2FIHHvIU7IUPkgRU4RN5ivcqT5Bhd%2F9qqSqUObS41OcaP7vBoWHGCiEM5xtnyAdZ5SEACEpCABCQgAQlIQAISkECZEjj5HIOSFPu%2BaXpFw%2BgLqkV3fujazCGNi0ZcsvqT5m%2F%2B7%2FWpAxpZqUVgAmUr6tpG1c8Z2qBgeAMe84yVKlBPwz%2B%2BHk9mD22QP7xBYEK9yEuuMfXsoxo4R9fnEK4x9YtH1j%2F6lol1fOPrFY1owN74yd5MXY4SOYZ3XD33GHOsvO8asGVkh8oxytRnWCcrAQlIQAISkIAEJCABCUhAAmVH4BRyjEl1tn15WbO6JscY%2BHzr0PTaoUncTdRgDZYgRsgb3vDbp1vff327Fg3a39SiXY%2FHr84Z1pDnPWPrr%2Fm02Tt%2Fuu4PV7RrVrd9dIv2Xf96bdZQ81JwQt2xr1x5b5t2A%2F%2FehpIXj99047%2FuvNE2sj573tfr0o73XXdH63YtG7S%2Fs3W7z%2F56dXyfJhzXGo9xY7Mbt3S77OlbbrzykvY3Nmv%2F0Z%2Fb5Ay9hOoZPx1iME5D4zHKzkdcZyoBCUhAAhKQgAQkIAEJSEACZ5HAyecY%2FvF1ikZecvc1N1avEt2kFkHENQe%2BaRKaWM9aN4RX87%2B75OF2N%2FBqvQvbX9%2B0Xa3zo5n98dwfryPEsI9u8Kc2baOiOjSuFd2yfvSF1aKrVop%2B4fbrKRkamlTvoz9fc0656D9d3444IiqKghvRrtH1F3%2FQokX99lUqRdc8r8Pl9aMvPr9DVLkOL95xfWh63c8ebVW9Crtq3%2BqS9pUrdqhdo0ONqu2rVOzwxePXBCbWU45xFn08dSoSkIAEJCABCUhAAhKQgAQkIIH%2FEDj5HIN8gMhi42eXXde0PWFFtcrRl9WNfv2eNvF9LjVrsE6u%2B%2BUTVxMvXFq73aL3rygc0XjKG1cSaBAyLO3cPDSt1ux3rujyyNV7ezY93K9pjydbk3LUvaA9v4am1P304WuqV6ZkaPsGF0W%2FdOf1szu2TBnQ%2BOYW7XjyxsvazX6n5b6eTRe%2Bd%2Fmj7W9c%2BXHz0EwzHoMDXVi9PeM0Rr7UaumHze%2B%2Bpm31Ku3vbdO%2BYHhDa4bLT6QZGo%2FxH58C%2FSIBCUhAAhKQgAQkIAEJSEACEigdAqeUYxAOUMLicL8mH%2F3l6msatydnIM1gvZIdPZp6xzV46MYb%2BfXaJu36Pde6%2F%2FNX9X7m6kvrtK94boe%2Bz7YKTa3D2qwpAy5d%2FMFlJA8fPNim4UXtiTKWdW4ZmmpyDN5I7dBhL1wVnFgvNKP2wvebXVC9AxssfK9FaJqZvUIdUQpoMIHFqo9RrQrDMKKHv3hVaHodZpr0f741IzduuKx91pCG%2FgknsXKKcozS8fFUKyUgAQlIQAISkIAEJCABCUhAAv8hcKo5hm88lTxNKEGa0f2xq5ncUa1S9JM33ZAxuMkfWrZjjET9mu3rXNChTo0O%2FGxSi3SiAxUzvOMbEG60adK%2BaqWbLqjWod6F0XUuaF%2BnRjQjN6wcgxSideN2hSOo%2F2l2PvzFVqyK0qph%2B8zB5hlrcIVVhcPKMapW6tCqQVtmsjD6IjSlzqTXL69cMZqBIpmDlWP8x%2FXVLxKQgAQkIAEJSEACEpCABCQggbNJ4JRyjHARjEa2UQ1Dk2ub%2B%2FTaH%2F%2BlDUMpKOm5%2F5vL%2Fqd1W0ZoPHHTjdu%2FbEbFzi3dmm37ollM9%2BaZQxrNfLtljWodLjov%2Br0HrlnVpdnUN6%2B6tDYlL%2F4%2Fx6h0bvQDN9zoHluPhVYZejHulZYU0GDMxu6vL%2BVAPGnyDbP6yX%2Bsu%2BocE95%2BSp2JrynHOJs%2BlToXCUhAAhKQgAQkIAEJSEACEpDAjwucfI7BcAj7qPrP%2FfH6xzvcsK5Ls4zBjQ9%2B2%2BSv0TcQONxzzY220Y1ev%2Bdahkm0bNBu2YeXhybX486CIxNfbUUt0J5PXcOIC8ZXZA1pHJpT%2B6snr6pRNbrkeAxyjMei2x7NMSbV2f7lpQQdbPPsLdenDmwcmli%2FYPglw1%2B8cvLrV1j1Magg2ubSto7RyjF%2B%2FLLqWQlIQAISkIAEJCABCUhAAhKQwFkpcPI5BkUqFr7XnGEVJBL1a0bf3JLlQqIvqN7%2B%2FKod%2BjxzFRUwdn3VtHUjZo5EN7o4%2Bi9tr3%2B47fXN60XXqHbTpm7N5nVqTvJQ6%2Fz2D9143RM3XVv7AgZj%2FMe8EnKMR3%2FIMQhMfBPqffSXa2pU7XB%2B1ei2zdo9Fn3d7Ve1JSRp27ydbUyD7o%2B1Uo5xVn4adVISkIAEJCABCUhAAhKQgAQkIIETC5x8jkFljOKRDfo%2Fd2X75tTBiGbJkvOqRDeuHc2qqfZRDXiVeR9ruzS%2F%2B9q2NaubZVUpcFH3wuinbrph%2FzeNbSMb%2FPt%2Frq9zgXm%2BWuUOrDxyRYN2FMpY%2BJ6pj%2FHxX64555wOD7drZ43HoBoGE1g4VrfHrmlR35QSJTnhcDc0bTfo71cFJ9fr8kircyvcRD2NyHiMCa9eXqF8h6ubRKs%2Bxokvt16VgAQkIAEJSEACEpCABCQgAQmUaoGTzzGIF8xyIZPrZg65JObzS2d3vGJJ5xZxvRsHJtQjdrBKcTJmo3BEw83dms58u9WC91rs%2FrqJe0z94IQ6bOAYXX9HjyZzOl7Bq7aRDQ%2F1vmRnj0b53xGA1E0d2HD7l40S%2Blxi7cT6yVuCE%2Bol9Gm8%2BIMWM95qta7rZRmDL6EEqG9CndSBDdh%2BX69LPGPN0iTsIWdo%2FfAzjVxj6pXcyXEfa72SUv2pVeMlIAEJSEACEpCABCQgAQlIoKwKnFKOYcUC4ZKbjL4wd9YuoQhnybiA%2FMF66eir44%2B%2BajKQ8Fv4aTKK8GOe5L2meie7%2BmFdksje2DNPRt4VWbjk6PYTj4YnbG9WLQk3JvLen3igHKOsfuB13hKQgAQkIAEJSEACEpCABCRQqgVOI8f4iYjgP2ON3%2BnGyjFK9adWjZeABCQgAQlIQAISkIAEJCCBsiqgHKOsXnmdtwQkIAEJSEACEpCABCQgAQlIoPQJKMcofddMLZaABCQgAQlIQAISkIAEJCABCZRVAeUYZfXK67wlIAEJSEACEpCABCQgAQlIQAKlT0A5Rum7ZmqxBCQgAQlIQAISkIAEJCABCUigrAooxyirV17nLQEJSEACEpCABCQgAQlIQAISKH0CyjFK3zVTiyUgAQlIQAISkIAEJCABCUhAAmVVQDlGWb3yOm8JSEACEpCABCQgAQlIQAISkEDpE1COUfqumVosAQlIQAISkIAEJCABCUhAAhIoqwLKMcrqldd5S0ACEpCABCQgAQlIQAISkIAESp%2BAcozSd83UYglIQAISkIAEJCABCUhAAhKQQFkVUI5RVq%2B8zlsCEpCABCQgAQlIQAISkIAEJFD6BJRjlL5rphZLQAISkIAEJCABCUhAAhKQgATKqoByjLJ65XXeEpCABCQgAQlIQAISkIAEJCCB0iegHKP0XTO1WAISkIAEJCABCUhAAhKQgAQkUFYFgsVJ3lHnlbn7iMq%2BWe3K6jXXeUtAAhKQgAQkIAEJSEACEpCABEqrQNCd69%2FwRpm7r3s1sKNHab1marcEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCUhAAhKQgAR%2BawGv13tQNwlIQAISkIAEJCABCUhAAhL4GQK%2FdcdOx5dAGRLw%2BXxJuklAAhKQgAQkIAEJSEACEpDAzxAoQ31InaoEJCABCUhAAhKQgAQkIAEJSEACEpCABCQgAQlIQAISkIAEJCABCfwfe3cBbUeRvX8fd3d3d4K7u7u7u7u7h%2BDuwd3d3YIECwka3J2BkfV%2BhvpR%2F37PvbkEy4TwnMW6U6e6rL%2BnOmv203vvCoEQCIEQCIEQCIEQ6JDAd999%2F847H3zw%2Fsf%2F%2Bte%2FOmyYiyEQAiEQAiEQAiEQAiEQAiEQAiEQAr%2BawL%2F%2B9e9HH37mmqvv6PXqW%2B12fv65HpdefNNHH37a7tX%2BsPLrr7%2B9%2FZYHrr36zuuuueu6a%2B686YZ7H3rg6Z6vvvnvf%2F%2B7H6z22WdenneOtdZfZ48vvvi6H0yXKUIgBEIgBEIgBEIgBEIgBEIgBELgb0Xgxx%2F%2FudmG%2B4441MwXXXBD2xv%2F7rt%2FrLvmroMPNM1ZZ1zZ9mr%2FWdO79wfTTr70CEPMPOxgMw49yPTDDDK9v%2BOOPt%2BWmx7wxmu9%2F%2Bw1P%2FlE9wnGWnCJRTb%2B%2FPOvzPXZZ1%2FcecfDD9z35Lfffv9nT53xQyAEQiAEQiAEQiAEQiAEQiAEQmCAJ0DH2HKT%2FUcdbraLL7qx7c3yYTj5hK7LLrHFo4880%2FZq%2F1kjrGOGqZYba%2BS5l19qy%2FXW3NV%2Fa6y8o5qhB55%2BrdV2%2FvTTL%2F%2FUZT%2F15AuTjLfI0otvVnSMF1%2FoOf2Uyyww9zoff%2FzZnzpvBg%2BBEAiBEAiBEAiBEAiBEAiBEAiBvwOBjnUMBH784cevvvqmJSjj%2B%2B9%2FeLfPWSB%2B%2BOHH99776IMPPmn2%2Btc%2F%2F%2FWP738oWSO%2B%2Furbd3p%2F8Pnn7UsKH3%2F02bvvfGjSdvl%2F%2BOGn7777oQW0e1UlHWP6KZedfKLFevR4s7Z5pttL88y25jijznPbrQ%2FWSgWzWMlnn37RrCzl%2F%2FznPx9%2B%2BImVfPPNt%2FWqSnf3%2FT%2F%2B70ZKPUTW4xBbX6uO8dlnX2r59JMvTDbBonN0Wq332%2B%2F7qntj8E8tVRRMHbxtQZd%2F%2FKOPd9q2fWpCIARCIARCIARCIARCIARCIARCYMAm0LGO4eqJnS9ca9WdH3rgqcLhnz%2F%2B64Zr71552W1GHmbW8Uafb7ON9pVeo4norjsfWWvVncYYac4Jxpx%2Fmy0Okl6jXH3qie7rrL7LQfuffNYZVyw097ojDdVp9plWOe2US7799rva%2FZVXXt91p6OmnHgJg%2FNhOO3kS7%2F55v9dfenFXjtvf%2FjE4y482vCzr7z8ttdfe1dTJ6mDFB1jsgkXffbZV2qlwm47Hj3MYDOcctIlpZKCcdbpVyw4z7ojDtWp03QrHHnoGR99%2BElt3%2B2pF7fd4uCJxl1olGFnW3yhjc4566qyzu%2B%2F%2F4dbEGtDGCmNreHM0y5fdcXtxY%2BoKTrGsktu8frrvffevbPxrXaCsRZYYuGN992rS4kuee7ZV7bf%2BpBJxl14pKFnWWT%2B9c849bJ21Qzqyhab7r%2F26ru89eZ7dWEphEAIhEAIhEAIhEAIhEAIhEAIhMDfmcAv6hibb7zfSEPOcv21d6MkKehJx180xohzjjvqvIvMt%2F58c649wpCdyA533%2FWYq5wNBKeMPfLcY486z8Lzrj%2Fv7GsON%2FiMs8%2B0qtSXrt5956MuCWAZfYQ5KRgLzLXO2KPMM%2Bpws7PiC38hGBpLajHHzKsutsCGk0%2B4mLwWe%2B12HC8ODegDs820yohDzWLYhedbX19SyeWX3FL6Nv9WHeO5nyWUcnWHbQ4ddvAZTzvlUl%2FJI9tve5gcGlNNsuRiC27Ef8Nc6625W0nO2e3pF2eedgX6xryzr7Xo%2FBtQIZSPPOxMziR0jOWW2nLMkeZ64P4ny7Duetedjhx20Bku6frfwJyqY7z22tubbrDPNJMtNdbIc40z6rxSdmy28b7cNro%2F%2F%2BrsM6868jD%2FvRGDTzzOQsMPMdP%2B%2B5zghygD1r89XnljwrEXJOlIr1ErUwiBEAiBEAiBEAiBEAiBEAiBEAiBvzOBX9Qxtt78wDFGmPPG6%2B9BiTLA8J98gsVuvfkBIQ%2B8CI4%2F7jxmuAQUwh%2FeeP2d2WZcZZJxFyF6%2FPDDP7%2F66tv99upCythxu8P%2F%2Fe%2F%2F3HvP4xOMucDYo8wt4cbnn335zdffntTlolGGnXX5pbckDmi%2FwzaHkQv23qPzJx9%2FTjDhETHXrKtrzxWElMEtYeRhZzvq8LNM%2BsOP%2F7zislvJCwvOve5HH7UepFJ0jEknWKRbt5fM6z%2FZSh1fYtmTjrfIY48860auuuI2bhJLLbrpyy%2B99u%2F%2F%2FOf113pTJ7heXHX5ba4ecuApboo3Bbnjnz%2F%2B87ZbHrAMck2PHm%2FAxRVk%2FDHmf%2FBnBxU6xh67HkNtuOySm%2FWtcSWffPLFF19%2B%2FdCDT08%2B4aKUmR6vvP7ll18LK6GHDDfYjHvtflwZ%2FM47Hpl0%2FEWQ4W2ie%2FOD8E033Hf1Fbc3nVKaDVIOgRAIgRAIgRAIgRAIgRAIgRAIgb8bgb7VMW64F5nTT710%2BCFn3mev4yulTz75fJ01dpUI9P33P77o%2FOsIEZwQjMl1gYFPJZhuimXm7LSaLJf33fsEJ4q1V9vlH9%2F%2Fo3R%2FoXuvKSdenHOFRBmv9eo987QrzjTNCm%2B%2F9f9iKM487TKuC1defuuz3V6WZWKe2dd8%2F%2F2PxHEYXFTImqvuNN7o8z%2F80P8vqsXIdAxZPUcfYQ55Pjdef69N1t97kfk2GH3EOUYddraDDzjlxx%2F%2BSR%2FYfJP9LPXyy%2F7rzvHPf%2F7L364X3jDS0J322PVY4%2B%2B9%2B3HcQogwxBaXpLTo0vmCnbY97PXX3ta3L3WMkufz5Zd6TTXJEvPMvsann35uKJ8D9j1xuEFn3GfPzmVwNaefcumuOxzZq2f7597%2B1Cl%2FQiAEQiAEQiAEQiAEQiAEQiAEQiAE%2FkugL3WMm37SMfbds8tIQ83C5K%2FseDtQIagZDPx99ug8wpAzC6BYaJ71eEosNM%2B6QidoFzNOs0LPXm8JjuDVQOX4%2Fh%2F%2Fp2O8%2BEKvaSZdav451%2BG68MhD3QRrrLbS9k3fg%2B%2B%2B%2Ff6jjz6TFJSrA4eHicZZSDSKkct%2FlI1xRpvnumvurIspBTrGjFMvN8ows4rm4Ocwzmjzkixk%2BJToo%2BTM%2FOjDT%2BX81EAYi0X%2BtNT1Zp1hZVNstN6eEoCIGeF2opcGW292gCm4UpTBv%2Fv%2BH79Kx3ih%2B6t0jLlnW6P6jTz84NNTTLiYueaYeTVyyrXX3NVultGWm8rXEAiBEAiBEAiBEAiBEAiBEAiBEAgBBH6VjrHzDkdITXnlFf8Nvmj5cGPYabvD2f7kCL4Qow3%2F3%2F8U%2FDfLdCu98sobRcfgHSGKpPRt6Bif33P3Y1JnbLDO7v%2F4WeVojn%2FuWVe1jjz8HGOMONeEYy1w9ZW3N1sql7gSUSeXXXKLLKNPPP786ivvYNnHHHVOadm79wedpltJag6JPkb%2FaZ2W%2Bt%2FyCHNssPbuZXmPP%2FbcFpvsP%2B5o8%2BoodQZB4%2BYb79Pd1d%2BpYxjkySe6UzAEpxh8mEFnmGma5a%2B56g7uK2V5%2BRsCIRACIRACIRACIRACIRACIRACIdAnAr9Kxzh4%2F5PpCeeedXUdjXzhZNI333j3u%2B%2B%2BF47BH2P%2FvU%2Fo9epbr7z8evlPuITsE7w17rn7cf4YfdIxqA0cJ5ZbcovmcatffPGVbJnSZVx68c3iWQgIcoH2%2BHnknj3eNHgJ36jrUSg6hnNXX%2Fw548QD9z814VgLSub58k81Tm6dc5bVxx9zAb4Qr%2FZ4s6xTUs2er75VTkf96suveW44R9V93X7rgzQHMSmzzrjya73aiSsx4567Hds2P0ZZWIs%2FhngbNyjdB%2Bxvvfnunbc%2FvMn6e4058lwzTrP8qz3eaN5FyiEQAiEQAiEQAiEQAiEQAiEQAiEQAm0JVB3j8stubfdqM8%2FnJV1vomNstdkBJaeE9u%2B%2F97FEnXJcyFzR9YLrORisvtIOJXyjjPZi955SZyjfc9djfdIxBI%2FoLmZksvEXff75%2FzunVZfOx5439WRLXX7pzSU%2FxszTrNBMImFqiTfLLM2%2FRcdonrtKRdlmy4OHHnj6PXc9VgZRXx3CIpPnaT%2Bfwao7dwh5QcW8iDrZaN09ll5ss56vvlmGlY5jxWW3HnHITnff%2Bdi%2F%2Fv3v1VfanudGcc%2F4qcF%2Ftt7iQAlLW%2FJ8NnUMmT1E32j86Sefr7%2F27sssvjnZpAxOCHIcreibu346trVU5m8IhEAIhEAIhEAIhEAIhEAIhEAIhEC7BIqOwZ3gwP1OfvqpF556snv5T%2BwDzwdhFE0d46233ptrltUcJHrMkWeTFDgbOMx0mEFm2GKT%2FYzzzjsfLjjPeiMOPcsu2x%2FxQvee%2FA26Xni9RBPrrrGr80adV9InHYODBBnhiEPP4M7B6eKxR5%2BT7fOSi26cYqLFJ59g0WeefunHH380BSFileW3efSRZ3r3fv%2Fuux5dapFNuFX06vl2y3211TE0ECfivBJnubo7X2%2B56X6LcVDIOWdd9frrvZ0Vsv%2FeXcYbY76zz7zCUjmNDDXQdFtsvJ%2FzWcwljsZdSMfx3HOv6CsNiFNWV11hO%2BqK%2Fw47%2BLSJx11YNE27OgYXDke4SpHBrwMxzhgbr7vXYANNvdF6e4l54f5x%2FbV3dZp2RYOX02mb9%2FL1V98cfeTZB%2B9%2FCj7N%2BpRDIARCIARCIARCIARCIARCIARC4G9LgP4gtGGIgaYlZRAo2OPlv1GGm23D9fZkd2%2B%2B8b7DDz7TddfcVRA5cVWAxtADTzfxOAuNN%2Fp8Qww0zTKLb1ZjIu69%2B7HZZlyZCCD5wyTjLezQVdLBZRffxKXhrjsfcSDIemvuVvNj0DomGXfhOWZarThsfPzRZ5tssM%2Bwg81gARQGk9IHLr7whv%2F8%2B7%2BJI17v9TZHCJqJq6x%2Bq%2BX7cehBpzqGteW3ozyYlCjRrdvL9RI3jP336jLoQFNJwaELf5Ljjz3PcSfDDDqjNVitG1ly4Y2f%2F0mp6P58D6lKBx9oajfIRYQLitwdnY8%2B1yAG7P78q7PPtIqrsmdIH8qvg3vGiEPNcvFF%2F01%2F%2BsTj3eUXXWSBDYo%2Fxrfffm9GuPBcYekt8RTGssQimww18PS6G9xdGJxe4Yeoqy2Fl17sOfao89B27r%2F38ZZL%2BRoCIRACIRACIRACIRACIRACIRACf08CFIarr7r98ENO5w7hb%2F2PRHDFZbfKenHdtXe5xPWi8nGW6NGHnykN5nZbHXLGqZeJCqmXFLhhnHj8hdtsfuCWm%2Bx%2F1GFnvvD8%2F3WUieKow8%2B88vLbhHWU9u%2B991GX4y4487TLv%2Fzi%2F04DYeZfevFNO257GF%2BIg%2FiHPPlCc%2BTPP%2Fvqoguuc3Xzjfbdd4%2Fjb7vlwSIsNNsof%2F7Zlyd36dr5mPM4ZjQvvfXWu8cedc5JXS4ssgkPkIcf7HbgfieKMdl%2B6%2F%2FeyIcffFLbv9P7g1NPvmS7rQ%2FZfKP99tv7BO4fzbm6d%2B958P4ncRGR2lSszaVdbzrikNO7Pf2i7lxWjj3q3PPOuebbb78ro732Wm8wN91wnxOOvxBPlR%2B8%2F%2FFZZ1yx3VYHm3rfPY%2B%2F9eb7a5xOXYCCo1vOOPVSMHOgSRNLyiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiHQLwn85z%2F%2F%2BeKLLz755JPvv%2F%2B%2B7bz%2F%2BMc%2FXPr3v%2F%2Fd9lJ%2FUmPZVvjtt9%2B2rOebb775%2BOOP%2FW2p%2Fz1fcbjqqqv233%2F%2FHj16%2FJ5x0rd%2FIPDjjz%2FaOR999JFN3lzPd9999%2Fnnn%2F%2Fwww%2FNSo%2BJvaS%2BpXGzjS5G8zFys75Z1qblqhoPoJGbn%2BbD%2BK9%2F%2Fauss6Vjy7AtCy5XjWk97V6qw7Z7tTl4yiEQAiEQAiEQAiEQAiEQAiHQXxFgHO2yyy7TTTfdQw891HZh%2B%2B233zTTTHPHHXe0vdSf1Dz44IPTTz%2F9mmuuSbVoLun888%2BfeuqpL7300mbl7ywzZjfaaKOBBx74zjvv%2FJ1Dpfv%2FkIA9f9ttt%2B26666b%2FvTZaaedrrvuuiod%2BHG32mqr008%2FvWng%2B%2BnPO%2B%2B8bbbZplu3bm1XThPQy3NUBjTyfffd11T%2F%2FvnPf77%2B%2BuuXXXaZB%2BrVV19tjvDMM89s1%2Bbz6KOPljbPPfcc3cywm2yyiWHtdiup3c1bht1rr71eeumlWq%2FwzjvvHH300VtssYW%2BFnb%2F%2Ffc3O2pch91tt90M21xtc5yUQyAEQiAEQiAEQiAEQiAEQqB%2FI8CmW2uttdq1zRk%2BLKCRRx65f9YxrG3IIYccaKCBDj300Cbb448%2FXuVpp53WrPydZUAYhsMOO%2Bzdd9%2F9O4dK9%2F8VAQ4V55577gorrLDeeusde%2Byx9skGG2yw%2FPLLn3HGGcXXgqax4oorrrrqqvfcc09dpJ%2B%2Bc%2BfO6h955JFaWQs333zzSiutROPq0qXLMcccQ1VbY401Hn74YQ0oGLfffvsBBxxgwOWWW86kL7zwQu2oQAAx7CqrrKJB%2Fdx7770u9ezZc%2FPNN1d55JFHHnfccWuvvbZH9amnnnKJgmETHnTQQa5avOnoIerL57PPPttjjz3U77vvvjrqtfrqqxMrytU333xzyy23LMO6%2FTLs448%2F%2FnPv%2FG8IhEAIhEAIhEAIhEAIhEAI9NcE6BjrrrvuEEMMcdddd7VdKF%2F6t956iy3WvMTx%2Fuuvv27W1DJLsOVS8y2w6I%2B2ASClr16GreM0C9bQp0uaMQOHG244ksU444xT32KrP%2BGEEwYZZBDGqXJZQ3MltaZZyTb86quvmi%2BmWxascdExHnjgAcNaM3oKbT8uNd%2FmNxu4lw5up9ky5T%2BDAHmBaMAH47XXXvOL%2B7l79erlKyngySefNOP111%2FPxqdLbL%2F99rwayhr89DQKHZt7rFz69NNPdd9www0JFJoZk7a28sorEzSU7QTeFqutthpJgWvE%2Buuv%2F%2BKLLzbv69prr9X41ltvNY6PEBIfz5GhzjnnHHrLFVdc4QG0TsKFBVAelO3MHXfc0TrLsLSIZ599tg7rWbb%2BU045RTONbVd3p6XFaMMtxLBclcqwXDUMe9RRR%2FVpM9dhUwiBEAiBEAiBEAiBEAiBEAiB%2FoFAxzoGo2nZZZd94oknylLffvttJlunTp3EoXjbW94al0tffvmlt8azzjqrS0suuWT1WGCmLbHEEqeeeurWW289wwwzaLD77rt7X1zvnQe%2B99R6GZZp5mVxvcTi8%2F5ar5lnntkb5HfffbdeqoWiY%2FAnIWVYEiGiXGrqGNa%2F%2BOKLn3zyyWxDV%2F31In6ppZbyCls0AXd9b6u9tp533nmFqCyyyCI33HCDV%2BFmNLVVbbzxxmVVOqocaqihrFN3a55nnnkYm00DUMDCoosuOu20084%2B%2B%2Bze4JcEHRIgEEC8dtdxjjnmYNg2pQxr2GeffdikYgTqfaXwZxDwS5EXmO3VOaHMwgfDDineO359v4WP7WTPlB%2FXT98nHcNWsUMEcVS577333qNXCCEhI%2FhxyQjaGMeeJDi06BjCVYgML7%2F8csv92iF2BXnkjTfeKJc8NTvssMO2224r6wWVzLDytJjUQ2qEqmMQLigYasSklI5UkYMPPnidddbRnrTCi8NjZUnlqs1pWKE0LZFZ5Wr%2BhkAIhEAIhEAIhEAIhEAIhED%2FRoB51YE%2FBgONV4OXxZbt3TQbn1zAup9vvvkGH3zw0Ucfndnukve83jW7JJnGAgssoMuYY45ZTMWTTjpJvc8oo4yi16ijjqosBoS1pSOHfC15g8w%2F%2F%2Fw0BJeIAB988IFLrDCjDTbYYLQCM7pE7mgKINr40DF0X%2FCnDzWDYFLqmzqG9evOAKw6hhAAixQ4wNJcaKGFXPWVZDHTTDMpc%2FCYaKKJRhxxRKOOO%2B64apilJX8Cc89Xn8knn5zuoRcOZ599dpn0yiuvHGmkkUYYYYSFF1544okn1kwWApamN%2Bxl5J%2B6%2FldvKW%2FGSy9v4WeccUaLJ%2BmUmvz9kwhATUQSrNGiidGp%2FIgCQMxLx%2BAgIRKE8kbgKnljOtAxSFU8OgxYtrQRnn%2F%2BeQ4YdmBVNlQSEOhaLTqGSvKIOBSOQxZAExO3UpQTYgXVyxpsnkLDXrUqUTB0klLjr0mJck0dw6REuaJa1GYGp8xwJjHIzjvvTI57%2F%2F33y1Ubm6sGwaQpIdaOKYRACIRACIRACIRACIRACIRA%2F0agYx2DF8EwwwxT7DviAzNcVkAvcL0OdnIHkYGlr9y1a1cWPfOQzsCwYs7rxbZipnnHrReXjO7du2vJTCNlzD333Mw0jeecc06GP8vRJcPy5NeYFmFVXhlLfMEHnl3GVCyXLrzwwhaAdIxBBx2UuSfAn%2BYwwQQTlBfTTR3D%2BmkdrMKqY1iqFdINmHWLLbaYiazTLNw5JBawBg4Vjz32mGV4Uc6DYrTRRitpDegY7tR0joHwmtvKxxhjDI19Jb9MMcUU9A0dTdS7d2%2BajJu1HrfGEcUajjjiiFdeeYUJWW1et6OxpAfEFotpubt8%2FWMJ0OIIbnaC7denkcWV8JwRzcHhgY6hcRHW7Kh240paxrGFPCk2%2F9NPP928ZBu31TFoCJQuLk%2FLLLOMLksvvbSIjzPPPNPGI19Q3qgudak8NA455BDPRVNw8Ii16Bi2kwQg4kqqu5TNZmo6BmnR8opTR%2FW%2B8OiRRwzbkoC0ufiUQyAEQiAEQiAEQiAEQiAEQqD%2FIcBi6sAfo%2BgYtAI2Ox%2BD8ccfv7qjM8FEiLCz2IbsO9kvr7nmGmWxJ0x%2BSgVJwYtj5z7QGbwOLrfMD4Ebw5RTTsmW93aY54MkhEVe0EBH76Z52hM96AM8McgIEnQY1uA8H6ynqQDoUnQMxqlBGGskCN4OLD4RAQSHkh%2BjYx2D2uC%2BakyH5AZFqSgLNizRg7NEeS9fgFQLURuuHQQK%2BgxNg7BD4qBglDWLLLAet0PHEBozySSTFIu4jJy%2F%2FZ4ABYCLEYcEYVB9mp2OUdNHkNQoDPYSFeLEE0%2B0z8llNqcaXykbjsXx49ahPE02PMchG57CUOsV2tUxPFYXX3yxfSKjBRnB3rPZ%2BHKQwugY9hL57tfqGOYioVBgPJsip%2BzGErriQbNLrVY9hSQ6RvPXSTkEQiAEQiAEQiAEQiAEQuAvRKBvdAxGFqtHoAQJgmd%2B8%2B6oCmpmm202lj4rvn7Y76JO%2BB5QElR6QVx6eR3MhWOqqaaiY1x99dV6HXjggc0B6QbGNCMfCQJIHVDZmEw8pl%2BzfdUxVLL4iBLGZLj9Kh1jvPHGqy%2BjiR50DO%2BsyyzMT6almhImQ8doOa%2BEiwUdQxoQhq0V1gUrWIkaaUMsjI4x6aSTtoQzNG8k5X5AgCC22WabcbFoG6BUZy86xiWXXKKGEMfkp63R3ERI0THkWiFYyQDDxYgnDzcGY5a%2B9i1PJI4QtnRT3ChX29UxasdS8FekFW%2BQs846yxNHcPBpq2NQyWr7tv4YLnGxMEI5BoWgYdmcPciVFBhCor3tAFkeRGWQ4o8hcqpKlHXwFEIgBEIgBEIgBEIgBEIgBEKgPyTQlzqGt8OTTTaZBBctOoY7UsNIF6bBKULUibSZPgrSCTLbxWuw6AX%2Bl3tv6hhsf%2FqAZm2xeDFNuJBIs4xW%2FhqTHtInHaP4aYgFcFCss0ukbTR4X%2FpjtNUxvAcvq%2FpFHeOwww7jKOJepFukWpiXm0pdsDVLIYJedIy2v3K%2Fr6EJ%2BGU32WQTXkPN2flp8ItwcgcZjUzBH6PoGNrwvaGeMfypH8VTwiBydZYP%2BYsOUIa66aabxG7sueeezfwVdZZ2dQx9PRGewdqM1EBzECpiz%2FAbEcdUBQdxTxJZ8P%2BhAdb27eoYrnpMeBBxCxGKRYQRLUXAsTCxSzYnOa4usiQU5abSwqROkUIIhEAIhEAIhEAIhEAIhEAI9FcEqo7RbpLJEkZR4kpY6PQBLhZl%2FewyeQDY7MQKcf3SXLQkBCjN%2BEW01THElTCjuOhLiel9cZEgtBfcwcZkQnbr1k2aTcOapeKqzWqNQvXHKFfZoQ6koCf40DGkGihteHdUaUJNua%2BSH4MLx6%2FSMSg2EoSWNZhUQkWDuxcZFUzK0iyXmn%2BjYzRp%2FA%2FLrH7BIGSKkp%2B2ruTyyy8XP3LBBReoafpj%2BOoBESoiWKl4NdAEaq9mgQbCbYNEUPWB5lXldnUMGUEdACRmqjYWUSLLqKeGEFEPGSlXP%2FzwQ7IGcYP0Udv3SceoDRT4kAgqobNZgw8HIV9LvhdXbc6WAJZm35RDIARCIARCIARCIARCIARCoH8jUHQMUoNIfyc8UhJ8XnvtNb7rLCnnGjDbeUdY9lFHHcVOZ0bxmWcN3XjjjS6JNPGauCTzZOiVV7qUDQbXXnvtZQQZBtrVMXjjy1GgOwGEM4MBfeW9YAqWpjF5xXPJYNDxhGesPfzww9IOmLQFYIuO4Sr%2FEGemNHUMWQLk25Rp0615%2B8xnQzZOi%2F8NOob7Ei1SzozwapsFKo%2BH41RManAZMKTa8F7e7ZiIXewN%2FksvvSSKoQN%2FDGKIxAWO%2Fkyez5Yf98%2F4SiJjxROy5Fa1zXyY%2Bb6KEPFLmbFFx1Bjr7L0BYzwlGhXxzCmLcFTQsHvbseWj%2F1fb8GWaJvn05NCuDM47w6bnKQgTInMUp44USrKniCjGYpQ5itprino9UnH0Mau8yB7ZPhaUNtoJmUxQleMIwzKsBxCPH2eNUe1GqquNoUQCIEQCIEQCIEQCIEQCIEQ6G8J0DG8R2b1s%2BuZ5JJalI9DOhhBfNGZ7eXcVS%2BaJd7UUnYLmgAnBEeplqNMWEzcKlxixc8111zcG5T5PzC%2BStaI4hcBglfJGkw44YQlxp9JRceQX8KAU089tV6yZ5RkmF5Ml6NLRZfIv2F5AkYY%2By0kLUAvVmTTuBML4IQR9QQW7ekDNBZfzeuEUwkulE1azl115KvGPXr0KCOXQ1rpFeUr85N9qr0X7mrEzij7uFNDGURQST1FhRZkneV2XNXMXyaqSARnyDpOpeZSKIOXvzQQ96gxgaVZn%2FKfQYC1bhfRl2wJoSLcIRR8VVm2ELt%2BqaWWuuiii5qzk8s0Y%2B%2FT05r1ykV%2FkzGDPCL1hG3vU1Jb3H333bWxjXT00UcLPKmOEC5xHzKvYekMAlIIGtxCHEpiTFc5YBADyXeG5emhGb2lJYuF2%2BFfQWMh1tW5FESj7LPPPqbj1GTTeiLMVRrYb7yGHJJiWMoh9w9yZd3%2FzUFSDoEQCIEQCIEQCIEQCIEQCIH%2BkAAdg3nFVmJAMd%2FqxztcJx14S8t8e%2FLJJ8vKuVsIJKFmzDTTTISLpt3NVJcpgiPE9NNPL%2F9hly5dHGiiV3m7XZQQX0kKQvVlzmSjlTE5RRBSOnXqRN9wqWnpM8023HBDEgdnBstoN%2FLFW3Uvl03X1DGU1bDU6CRlll69etE6ZpllFqN5A06mYLo6EdUi9957b7O42dLSzQoiEEpQvjI%2FSyRCeZ1NltFR%2BgLEaBTuVMqO5ots98vedGn22WdnHprXOOWQCKJQzXVQBi9%2Fy%2BGb%2FAF4dDTrU%2F6TCLDoOcDwfCBi%2BNj%2FvlYzn1IhZ0tTgrAMXhZ%2BaAoDVaplVTQHaVhcEmalY%2FlI9anQFD1sJIltaQ68npojqPd0aE%2F68HCZhc5QG3jiuCRxgiK5CJhqO7u9x23j8MMPLzutduTaYd%2Ba7tJLL20b6qKGm4dhSRluv%2B2wdZwUQiAEQiAEQiAEQiAEQiAEQqB%2FI8B8Ywox%2FDv%2BNJfN7uaR3qyp5ZK3sGnX12FLmzKdBtVsLPXc%2B4vuUYeqBZf6NJ02Zfy2A9ZLdRwF49TYjdKx3n5dT8uC9ap8Srm25FvCCG2OX8susXzr17az1Esp%2FA8J2Ax2V8sCbIDyi7fU%2B9pufflxXWr7MVRzkI5HtjmbcSjNjvZS23XWBmXYui1LfVlVbdNuoeNh2%2B2SyhAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgRAIgb8JgZajEH7xrh3c0HGbX2zQ7N6n2Wt9LTR7pRwCHRNw1kzLeSJt29tazd1Vvrb9Wzq2rW%2F2bQ5eWtaadjv2qW%2FtlUIIhEAIhEAIhEAIhEAIhEAIhMCzzz67xRZbXH311S0obrrpps0333yzzTbbdNNNd9lll2OOOebll19uaeOrYxwvuOCCrbbaatVVV9XyrLPO%2BvLLL5vNHCt51VVXbbPNNqutttomm2xy6qmnfvrpp6UBo1Jfg%2Ffs2bPZRfmzzz7bb7%2F9dtppp7feeqt5qVevXttvv%2F1xxx33448%2FNutTDoGOCbz66qvnn3%2F%2BEUcc0blz52uvvfaLL75ot%2F0999zTpUuXRx55pF594YUXTj755FManzPOOOONN97QwKnBV1xxxUknnVQvnnjiiddcc03L5qSc3HzzzYb1rNVhH3rooWZHU5x99tnvv%2F9%2BbZBCCIRACIRACIRACIRACIRACIRAWwLe%2F%2B62224DDTTQ3HPPTTpoNjj88MPVDzLIICOOOOLQQw%2BtPMIIIxx44IGEi9rsww8%2FXGONNVwaZphhJp100pFGGkl5mWWWefvtt0sbksWGG26ocsghh5xkkklGGWUU5UUXXfS1117TgLlH3FBz%2F%2F331zFL4b333pt88sld2nrrrckd9erjjz8%2BxBBDzDvvvD%2F88EOtTCEEOiZANFh33XVXXnllgttGG2207LLLHnzwwZ988klLL1rHxhtvvMQSS1x00UX1EtHDll5yySUX%2F%2Bmz2GKLLb%2F88k888YQGtjdVrV5yfaGFFtp1111bNudTTz219tprL7XUUtSMOizpY%2Bmll659PRSUwFdeeaU2SCEEQiAEQiAEQiAEQiAEQiAEQqAtgd69e0833XTkgmGHHfbOO%2B9sNjj66KPV77XXXmyr7t27e5c9%2FfTTq%2FFGu3jmUyG4UqhZa621nnvuuc8%2F%2F7xHjx6sRTU777yzNj7777%2B%2Fryw4phydhDcFBw81PEB0F2mi%2FaCDDvrggw82p1b2YrpMR0K5%2Fvrr69Unn3ySrrLIIou0mIq1QQoh0ELAxttxxx3tUnIZNwxb69hjj11hhRVuu%2B22ZkvOFfb2iiuu6NIll1xSL3EZojBcdtllD%2F%2F8efTRR4vo98477%2FBZ8hQYuVy0kz0sdn7tzj1pzz33NOxKK6106623lnoNjjzySEsikpSOlBbayNdff107phACIRACIRACIRACIRACIRACIdCWwMUXXzz44IPPMMMMnBy8WW6msCg6xumnn157iSvhIzHuuOOy1FT6O%2BaYY5JBPvjgg9rm9ddfH2%2B88SaaaKJ3331XecIJJ%2BSG0QwbKY4WOqpkzf2ijkH0mH322RmMZYq2Ogb95Nxzz6WcMCeJLS1RLXVhDzzwwFFHHVXHqfUpDPAE7EN74%2Fjjj6%2BOPYQIwsKZZ57ZvHeyBu%2BgddZZx6WqY9iiIj7sUvu52biU%2BW%2B4JDyk7aVSw99JUNUqq6zCH6OpYwi22nfffWkgfQpv6dOAqQ%2BBEAiBEAiBEAiBEAiBEAiBvzMBL6C9ERbrwcbnEk9w4C9RgRQdgxFXaxQOO%2BwwwoIcF8p875WZY80G3CS23XZbcR8vvfTSddddp8EOO%2BzQzF5IKuF4P%2Becc1Ik1HegY0wzzTQjjzzyHHPMYRBdisbSomMIYOGcr8Hwww8%2F3HDDKTBC25qcfD9Yka56t95cbcp%2FBwJ%2Bfe4TTVeHW265RWzIpZdeWm%2F%2FzTff5Cm0xx57kMKEn1Qdg%2BDASWO99da7995777vvPnkzmqrd008%2FLa6KQiLcSWINmTRa3ITU6HvIIYdIqdHUMahtpBVeSdwwjKx7cfCo60khBEIgBEIgBEIgBEIgBEIgBEKgLQGxHoQChhhDT35CZr5Mg7VZuzqGEA%2F%2BG5QKzbg36CLMv3ZpKZQx%2Bc%2B31Nev5u1Ax5hyyimnmmoqHvvTTjut%2FBslt4A1l7gSfb1e50NiDaIGvBkX%2FyKZhq98M1ytsygQTHTn3t8UapoNUh7gCdgDop9oEXyQJMHYbrvt%2BGmUuyZWSGPrQXjmmWfuvvtugoM25RLBQWiVSBO6h78SZchVS3woV2kXdLNydbnlltPRODWN7VdffUXlo2PYdeJHNKtxJWJb7NXS0ciGtZ5mFtAB%2FufIDYZACIRACIRACIRACIRACITAbyBwwAEHyE1x4YUX6vviiy%2BOPfbYMhx%2B8803Zah2dYy77rpLwgpO8qzCkvvCi%2BY%2BTe1FNlWBZdenBr%2BoY0w22WScKxwJIU3oPPPM4511t27dio7BPYN2IciFa4fQkjKFzI2zzDKLqJboFX1i%2Fret5ykhSy21QZJP4oNTRSoKcoTQj7KT77jjjqaOYWvJCCo1qAAr%2FkWS32rJj6IEKBHZCHG8OK688squXbtKImpwHh0lP4YpDFW8PkSXNHUM0VVcjHiACIkicXiUXOWh0Tb1aF1kCiEQAiEQAiEQAiEQAiEQAiHwNyfgrfGss85Ku3j%2B%2Bee%2F%2FfZbEoEADRKBrIOFTLs6xu233z7UUEM5X1Ub3vJkimYCjRaksilqYJyW%2Bvq1b3QMkSOamdFQhJHiQyLPJ1ORyamS90UdkLrCV2TggQfmq18rUwgBBAhftrpktnQG%2Fhjrr7%2B%2BqBD14kS4Rjjht2gIGlTxwVXbTH2NVDKIoCoyiFOJXeXI4VzgKv2Jpdpggw3sQE%2BT9C%2FKlJNy1bnGlAqPj14%2BxnHWTw1RcQbQQQcdRCGpnh6lWf6GQAiEQAiEQAiEQAiEQAiEQAhUArIa8qwQJDLXXHMtuOCCCyywQDk1lZMGNUCzdnUMqRGpBPvtt58GUme0lSnYfXICEENK%2Bk0NSuM6r8GZe%2By1jz%2F%2BWGBIx3El%2FDEkLtDXSSjCTGQH3WeffaTCcEilidiSxt97773r4Aq77767SgkQmpUph0CTAD8KSTA6d%2B4sRcw555zDicLpwxwqfHhf0BPEkkiRUeWLZl85Qgkd%2FCialaVM1rA%2FHTTsEfD4GNbmF6JiWPVmJFbw3KiBJ80ROHsIMOGb0axMOQRCIARCIARCIARCIARCIARCoBAgAnhrLKjE2aa8Mjp16iQcQ4E%2Fxswzz%2FzRRx9pVnSMZvoLb5adoCrEoxxYKcbEKSess2ZuQ%2FKFQI%2BJJ57Y%2B%2BjHHnvMca4yiEoUUMkbZKGFFhpnnHFkA6Bp9KWOobv4l8EGG4zwQqYQ%2F%2BIWqCW%2Byj%2Fg7XYZnzAic6kVOsKyzpjC35yAICPbmPdO5cCJgj8GkUF8h5gOygPfCdFSDitZffXVnbLqo8aJPFw46HXyZtS%2BUn3SMexGGojDWGkUNYNo0TFKAg1BIoY1SxnWSSjls%2BWWW77xxhseDcMKjKrDlgQaN9xwQ61JIQRCIARCIARCIARCIARCIARCoBKQ4VASCeeZeuNMTKAJ%2BCt8g5HF3aL4zMtrQTHo0qULccAl8R3C%2BdUwx4rhxnmeRkFbYNMZweAG8XabPCJEhas8yYIHvgGbR14y%2FagfPEBkUNSr73UMAzIJLcCHjmFVHP4djDLqqKNWb3zv2WUuXXjhha2t3mwpmO61115zIy31%2BTrAE5AAVg5PyWC5AJWbLck8KQmUB48Anx%2FKRvnQJcgULvlKqZCPxR6WFkPslb7ay16rxgh2r1S3GtdQLKIHVY2CIWakd%2B%2FezWGFX%2BnliB%2F1dD8ZOXhrCFEpG5LQR07xZAmbGuB%2FjtxgCIRACIRACIRACIRACIRACPwGAgwoakDLkanGEY4xyCCDeInMvCr%2BGCI7pEZccsklRXPoQjdgFdYZWXNiPQgXTDABHXQGssYYY4zBTCttmHgTTDCBjow46RCNLL2Gk17L4SPFfcJVB7%2FWMUvBgQ6TTjopsaXElZRK5004HFZ7cSXFCYQqwiXDFGYXGiDtJ5GE637LaCaSA8GlMm%2FL1XwdsAkQH4SQ0A0cWGPDOJSH3wUBrellUQnIjyG%2Bo567SouTlUWNTBeXX365w3eU7eQilHH7oZDQLs466ywCiPyf9rkIkTpaLcjzaQE1P4bQEttVYwqJYQVzuXrooYearnZJIQRCIARCIARCIARCIARCIARCoBDg2MDvYooppnj88cdbmIgoWWyxxeaff36uC1JhCA8Zf%2Fzx5QL1V7yJvJ1eNLd0IUFQFUYbbTTRHFwj5Nl48MEHm23MIsPh6KOPrgFnCek4qB%2BlAbVkhx12IJW0XYmJuFUYzfvr5mjnnXeeVTFCi47hnbh33NNNN50AFp8ZZ5yRBaqy2UWZjsH2pIFUQ7KlQb4O2ASEO%2FGIkC1W2AgRg8sECYL7UNu7djArv6NmngpbkUORI0v0JcRJb1sPbLXTZJS1h0UzETScV%2BKMknZ9fvg4mb3qe%2BY1yGGHHSZ6xbAiUExBu2u7ntSEQAiEQAiEQAiEQAiEQAiEQAiws1hMrDPWfVsaXjRLGuC9sEAMzcrH6%2BO24kDtaxxeE95uM83abcZg5KWvAXmkOanGRrYSDvx1tFLQjKjiQIcWq1C9ShElzYnEucgd6tPB62xTCCto9mqZMV8HeAI2m5QUtiIPjT7drPgR%2B6TtRrLr9BWEUpOx1BEIg1JeSAjjkamVLQVb1LAlOKVe8lx41gxr8FqZQgiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAj8tQg4EMT5I4516OBIiL%2FWHWW1IRACIRACIRACIRACIRACIRACIRAC%2F0MCDo686667zjrrrLN%2F%2Bpzz06eUVXbt2tXRq795ec6gXGGFFTp16uQ0yb4fxDmqF1100bXXXtuifjjI1cIefPBBJ1T2%2FWhpGQJNAjbPs88%2Be%2Butt95222233377Y4899sorr%2Fzwww%2FNNs4Xvu%2B%2B%2B15%2B%2BeXmTvv8888fffTR66%2B%2F%2Frrrrnv44Yft0tpF96effvqRRx5xQqvRbrnlFoOX8U1Ryv7ecccdNL3aS0tXHShcaxQcJWwWLZvjNxsoW9Vzzz3Xp1nuvPNOfd2CQcrUCm7T7dQTjT2YDzzwwL333ts8UtY%2FBU899ZQu9eBXNXpZ5JVXXmmQ%2BhS%2F%2FfbbBWAZv3mPanr16tVcMCVTAyO3PaO22UzZA%2B6mXnjhhSb2ljbNr%2B4C9ptuuskv8tBDD3VArNkr5RAIgRAIgRAIgRAIgRAIgb86gR9%2F%2FHGdddYZqA%2BfkUYaqUePHr%2F5Hhkas88%2B%2BxhjjMEa6vtBXnrppZFHHnnIIYe8%2FPLLm70YLJa51VZbNStTDoFfRYCNfPzxxy%2B77LIUthVXXHG55ZZT2HfffZ9%2F%2Fvk6Dhtf%2Fcknn1wNasb1TjvttMwyy6y88sqrrLKKwjbbbMM2L12%2B%2FPLLXXfddb311uvduzcJbqmlljJm%2BZiiFJZffvlVV12VAFK6eDQOOOAA47RsclLD3nvvraWnoK6nbeHEE0%2FU9%2BdJ%2FnsjpWzZa6yxRvfu3Z944onVVlvNpKW%2B3O%2F%2B%2B%2B9ftAgm%2F7bbbrvRRhsRGerg%2Fik47LDDtH%2FyySdVkjjOOOMMN6vGegy1%2BuqrX3zxxUQbymcZsAxeZ%2FfVAiiQdUyFZ555xiBHHHFEi1jUbFPKHnDoTNr2Utua1157za9mVSuttJLxraf5i7Rtn5oQCIEQCIEQCIEQCIEQCIEBhoCXpF7Lnn%2F%2B%2BeyvM888c%2FLJJx9qqKFYWDwxLrzwwiuuuMJr6N98syyjueaaa%2Byxx%2F5VOobGY401Fsli2mmnfeutt%2Brs3tWq3G677WpNCiHwawmQJogATO8TTjiB64JX%2BUxs6sSGG25YpQwv99WcdtppRcegTrD62csnnXQSB4Bu3bqdcsopbOett96aZ4IF0DH23HNPI2hJ9%2BOTUFwUdtttt9KruEaY7r333isLJlOsv%2F76zHAdv%2Frqq3oXdIz99ttvzTXX7OCRsSpLLbPwi9hll13oDFZbZiEyfPrpp48%2F%2FjjZYffdd9dA%2FVVXXVWs%2Fj322MNqOVntuOOOm222WdMbxNN65JFHGqroMzfccANKO%2B%2B889133%2F3iiy8aZPvtt1fjMXSbbtBH5VFHHaXy0EMPLTVWRWGot6PA%2B4W0cvTRR%2F%2BijmFkQPhcNbu3W3aD2GpsdnfKO4X6QbfxizQ9Xtrtm8oQCIEQCIEQCIEQCIEQCIEBiYB0FkssscSYY47ZtKGYDF4iq%2FFe9eqrry5GiogPlYyjG2%2B8sa3DBhvtmmuu8XZVrznmmKNFx3jjjTeYSBpUs7GFoV5Fx6Ba7LDDDjztS4O2OgYrjCXF6b340pdm2nsfzd78%2BOOPRQe4SqgpHvU9e%2FZkt5ra6%2FWWSZlmN998sxt0mzi0XPWVlce%2Ba%2FGZb9ssNf05ASIABYPpLQykLtVPzwrmrvD111%2BrbOoY2pP4uBmwr4usUXqdfvrpSy65JLnPV0JE1THqmAq0BToGQ7tZWcpEQlLJuuuuyxuq%2BD%2BU%2Br7RMVpGo64QH8gFzXqBJJSWU089tVa6NVKGSjv8iy%2B%2B6EDH8OzY7QcffDD9gYJRRzCFNVM2OJPUSsIFNwzPVK1pKfwZOoZ%2FW0zapUsX66zTiYOjbLR4g9SrKYRACIRACIRACIRACIRACAyQBL799ttFF11UGEjTJqJUDD744KOPPjo%2FDcKCN9F8JJiByuUjBuTcc88tJh4FgHEhGqVc0mu44YabcMIJizDi6nnnnUfWKFeHHnroQw45pO1bWo1pKaOMMoqVmJR8UWi36Bi%2BTj311P%2B3iIEGmnHGGYvByKhkYA4zzDDTTDNNvcrq4WQy2mijlZoRRhjBmuuPeMkll1hkuTTEEEOIIGjmDSjNxBoMNthg888%2FPxuwdkzhL0fARi06hh%2B0Lt4m5JVByuBrobLqGMp2Ah8GakOLXkfREnhCADFguzqGeg3oGDWWpE5HYSPQEQSocLQCakPNHfFrdQwd3U51oqhTFB3DApqiHCnGg3D%2F%2Fff3pY4BSFOEcZvSg%2FgHwT8UdaIiKVALa01L4Q%2FXMfxY%2Ft3gbcINozmX344fS%2BfOnZviRrNByiEQAiEQAiEQAiEQAiEQAgMegXZ1DHYKMWGQQQaR6WKLLbbwdZNNNmHyc0rnss5Fnzgw3njjFSuPiUR%2FIF%2FwM2faeIs96KCDTjTRREXH8OqWxDHppJPSEEgHs846q5H5ZrSQ1NggFBVxLlQFkSnvvPOONk0dQ0pA4xBJmJ8c2lmFVuhtLDPQe2epA6xwpplmYrj5FLnDUGuttRZ%2FDK%2Fdhx9%2B%2BOmnn74My2glxbgFEo01zzPPPPoKtGlZlRfTCy%2B8sLfYzZfRLW3ytf8n0K6OYdl%2Beja%2B7aHc1DFkkNhyyy3FlRAf%2BnR3zbiS2qYDHcNjQt%2Fg5kEZIJJ4rGpqzT9Wx2j6Y9AuZN4gTTD%2Ff1HHcBckC3Klx5zbFR%2BqqrTUGyyFfq9jiHQTXOZHaQkh4YjFecxvgXzLIvM1BEIgBEIgBEIgBEIgBEJgQCXQJx2DFuGFb7HfmQn77LPPXnvtVcM9GGIcFbylhcUrZiJAdXVgLnGTGGeccagc3qJ6W8pNwoEjBSD%2F9mGHHVaWgJb3p0XHWHzxxWUjFPBuQNPpQjZRLvkx2GIsLM75ZSj24AwzzEAw4SviLmQLJE1wjy9XGWJUDqIKI7HUeL3uprwoZ%2FJIz8gzhBhSLlnquOOOSxJpq1foXu%2B6NM7fvxyBPukYdhcdo%2BhXTR1DBgzCHRedkiiGe4a9J8LI7vJX2kwD%2Fip%2FDLtdsgjPQomrkueTpiEwqpD8A3UMHgtSZ5AjSA2XXXaZ59QjwJPBxu6b%2FBhW4vniLiLWTPyLmBQ6pIer5Rfv9zqGaDLKj2QdzeQeLavK1xAIgRAIgRAIgRAIgRAIgb8JgT7pGAMPPDCDqAmB%2BXbMMcd4Sb355ps7VpUmwByTNIPHAjFBGorSWM3cc88tkER7L08FenDVcLLDgT99jElAmG%2B%2B%2BbxFbQ5e%2FTHYjMXvQhiIKAC2npXUPJ8c5iW%2B4FzhBBMnLxjZ1CZyF%2BJKyqRlWM0oLSSR8pXhaeUkEdkzGKfcNozP0aKsSmpEwsvEE0%2Fs5MrmqlIeMAh0rGNccMEFbrOpY0icsummm1Ydg1C28cYbUzxkivDXAR%2F24a%2FSMfg22K6kuaKqiU%2BhpB1%2B%2BOHl6x%2BoY%2FA%2BopBYJLcKf5Wl8Sy72hPXQX6Mkuez%2FNxSzXDqIBrQQIh7npHqOlIaRMcYMJ6L3EUIhEAIhEAIhEAIhEAI%2FEUJdKBjsOPKTTEDHWLC0qcM0A2mnHJKAoKQDW75rDmaBlmgxGto781vOa%2BEslH8HPhF0C7Kh2%2FGqKOOyu%2BixTKqOgZ3DoMwLY2%2F0EILCfavOgZzz5KsQWjJZJNNVo5ZmWSSSaqOIVPoK6%2B8UtbswAUt64GtDE9HNNIxuPcXdcWwPy9qaKsS1cKNpBxFUUbI3wGGQJ90DB4LjP2SJbKpY3jpb%2Bf4lF3Kk0EyWMeP8qmgD%2FwGHUN3ggBVTV7Kc845R2QWbwdeSeWg1T9QxxBCQi2555576HWSdlot34zyO%2FJ06ksdo7T3aBM3OGJRM6QVbXol9Xsdo8SVcMmo%2F86URXJ0oc%2F4R8NPPMBs19xICIRACIRACIRACIRACIRAxwT6RsdgO7DxKQaOd2Td0AQcfSgRaPXHcKke6sEoq%2F4YXkNzcphuuulEczguxHtef0kWGgs5aS6sRcfg1OGFONmhHGIiFYbGogBMyh5k%2FZnFSuacc07xIL9Wx2Clyp4x%2Fvjjs%2FXKqizMAggvhm2uKuUBg0C7OgbPH2lgxVCIdXKbTR3DNnCpbSJN%2B0Tghjwwv8ofw0SSVPDlWHvttYWWmNHf4jghXYap7fZfPHe1%2BUN0nOez5sewnzmByClR1DlqDG8oj5XsH3U06oTboX64tTfffJPzBsnF3dUGfFGE2HCIIoPUyn6vY%2Fjnwjr9IjVwrCxGQlHqEGmoKbPUdaYQAiEQAiEQAiEQAiEQAiEwQBLoGx2D7SAKg7FTCfCLEFfidbYaIgPBwdvqctW70ZlnnrnkxzC4QHsJMZrnRDAbm5ZU6dWiY6jkWcHxw8g%2BRcdgoCk7%2B7J08a582mmnJUf8Kh2DdsHkkRTUqsT%2Bl6H8lQCByVa%2F1kLe81YUf91C1THqMSLs4ksvvZS7AqcFu9StVR2j%2FOJyv1DMhH6UU1nLvXsQmNLSTfwqHUNODNoFM1y4ik0rysP%2B9xRsuOGGHCQ8LxZTdIwanNUx6o51jHpeiUU6Kog3hbS3bsosZApnsLrTOj6JQ5iYtDNWRXUksPB5KHEopY0gL%2Bv0AJZUIaWyL3WMY489tk%2BPT62XyBfnml2nLqwUmoqKY2K0NCbZp151yq1nufqcNNu3DJWvIRACIRACIRACIRACIRACAwyBvtEx%2BGPIqCmagwXHB8P72SGHHJKOUbQLyoCjQJxhyvnc0Q%2FSaZbwE9IESs4oESEiBoRJJWfFQQcdNOKIIzLfWFVNhm11DFdZNxwwqo7B5DGyhBvONJGb0XGoLolzEb3iLuTH6Ju4Ei73Rmb4CCrhy8EOsn72nVVx9W%2FJ80khkT7Uy%2FRqOjXXnPJfhQCrWShHCQkhvtmKe%2B65p4gSvgo1EKlFxyAvUB6IAAIrnJTqKFLKBn%2BGZZddllDgxrkDGYSNT52oHEzkqqwUVTBhWfMWMHXN6lka0yI4NXHSkAJXcISULxSG448%2FnrpSPh4ux%2FqYpQ5eC32pY2hvbXQJuTg8I74%2B8sgj%2FEmsWZiYO%2BLgJElvkRGsk74n7MUtqzT1E0884cZlttHALTQlgr7RMTifCMyRNfTnG7rUHRGCeKc4pllsTjkLxkPth6CL%2BoeitlTmsvXaa6%2BBr9L9WjwhxZNoMY4r8sz6vfymoPnHpCT%2FdEdWzmesgkohBEIgBEIgBEIgBEIgBEJggCTQNzqGG5cKQwYJuoEPBUBCDPkl%2BN67xMBhmJAyylVHsjpolaxRdAw22gknnCCfRrnq72KLLVYuNXm2q2Nw7y%2FRJVIOakxk2G233WTbKENN8NPHvFJhuPSrdAyWESVEpo66qjnmmOPJJ59sLkmZ8EKuWWCBBVi1LZfy9S9EoOgY5AW6gb8%2BHCRsWpEU9S5adAz1Iim04YBBhSgfvQhfJUVtX567KhmLxCwtTg5lUruLGd65c2dD0TGsrS7PChnsMtO29VzSt%2B91DI0d3EMocCOUQx1pF3QMNe7IX3dE1ak%2BJwpS04h5%2BfmOV6R7uOWW%2Fd83OgbxpHk75Y4MTr6gb7hanE%2FoGC7h4G%2F9WBg4jz32GAiyjNZoL4IqEUOzsjwFUhKxsfB0p1SmM844ozp7lPr8DYEQCIEQCIEQCIEQCIEQGMAI0Bm8x%2FQSU%2Fh8vTXGlzNJ5Y5oWgSyUni7yurhJ%2B89rwaC0%2BtbWmXuE127dmVWEARoC6yzOqBK71iZGOrbfcXMUHKJ5dLip%2FHee%2B81V2K17E1vjbmFcIm3ErEhCl4luwu%2BFnVSDvw6ivovt%2BCvxmqahiFLyotpgSreOzdvvy6bAWU6N97kUK%2Bm8Fch4Ofzft%2B2lLjSX7vC9mj5TdnXvAWEUTTr7Ssd7UwOPHaCnVY3vK1oY9j2JSyloNCXF4EpirOBSqKHr2S62rFCIxp069bNtuSioEFZnhWWj68uVRO%2B9lIwi62rWVFU6iXCi14W0LwFj5uWhqpPlnvnLuKOaAWUnGbjMhS5gJeIBm4ckLYr91Ty1mg6otQ1lIKnCcz%2Fu5Of%2F8fahG4Zzb8G0GGrsRiWlhsvzT2n7h1ei2kOzjNKXw8yLcVNNR2o%2FBuCZzOPR7NjyiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAv2egDMfHVXgb7%2BfOjOGQP9MwIkhngvnd7Q9OuQPWXYZvO2xI20H19LnT1pG2%2BlSEwIhEAIhEAIhEAIhEAIhEAJ9T4BR43xGxxp%2B%2Fvnnfd%2Fr97S87LLLlllmGQcmtgxSDlK0kpaP0xVdammcryHwGwgwzB0V6rhPR3O27e5kUpeaJ3i2bdNujf2pYz3qt902fVPpgFFHCR977LHNQ4FLR4%2BnKRxO2lZbIDi88cYbLnU8BXnkqquuOuyww5yg2ralYR2y7DhUBSfJXnrppUceeaQaLcvUf8gz6IRZd%2BGYVBOZpe0yUhMCIRACIRACIRACIRACIRACv0iANbHJJpuMMsoo99xzzy82%2FkMaHHrooQMNNNC5557bMhrbba655hpuuOGGHHLIIRqfGWaY4RdttJah8jUE2iXASD%2F11FPXXHPNhx56qG2Drl27rrrqqm0VtrYtW2ruvffe1VZbzZY2vkuffvpp7969v%2F3225Zmv%2FhVl1122WW99dZ7%2B%2B23Wxp7PNdYYw0qR0u9r7169dpwww333Xffb775pu3VWkPHOOaYY9xg9%2B7da2UtfPbZZ6becsstrZww4iF1RxRODU4%2F%2FXTl%2B%2B%2B%2FX%2FmHH34gbnzwwQfa1L59U%2FDvzC233LL11luvvvrq1rDWWmsdcMABPXv27Ju%2BaRMCIRACIRACIRACIRACIRACTQLsi3XWWWewwQa76667mvV%2FXtl7XjrGBRdc0DIF42i66aYbfPDB2WvsqS1%2B%2Bmy%2B%2Beb777%2F%2FJ5980tI4X0PgNxCgM3Tp0mWFFVZ44IEH2na%2F4447tttuu6effrrtpY5rOBhsv%2F32N998c2l24YUXbrrppt26deu4V9urdIw99thj4403Jia0XPV4Lr%2F88kSYlnpfqXx77723S0SGtldrDR3j%2BOOPJyC88MILtbIWTE3l8KwRNLT0kPpnoegY1113HSxF%2FTDXTjvtdNRRR%2F0qlYbTF1eQFVdccf311z%2FzzDOvvvpqbiG%2Bbrvttm%2B%2B%2BWZdQwohEAIhEAIhEAIhEAIhEAIh0DcE6Bjrrrsu94cOdAx2jZew3hG3NZTUqOerz1RR5pmvcZ2XRzo7he1T3lOX%2Bg50jKmnnnqSSSZp%2BzK6Dmi1TDwNeODXSq%2BG%2BaszrMrVjz76SMFKeMJbldk5sdf23llbEs2kuaQ6lELp26xJeYAh4Ec%2F4YQTWNDt6hg2cLHi3a%2ByHWX%2F2Dl2y8cff1w3jDY2vC1XsdjzKnVRsMFOPPHElVdemQODvkYozXQnxxmqbYCGGvW8OIy555579knHIL%2BcdtppddJaMIWQlhZnDDUWacy6bGsrOoZALbfmajO4RjOz66XgRpo6huV5kHVXLyqE78dee%2B3Fe8rdGUcYTr1HS9LYSjxEdXkKpRdNsrqCaHD22Wcvt9xy5513Xl2hlpbUsrAypok0M5fHualqukE1rtbp6g9XGmtQL9WC9nr5h8JN1craUaW7c7X4nLi7cpvNdbp3t9nsXsdJIQRCIARCIARCIARCIARC4M8mwKDoWMd48sknl1hiiZFGGmnEEUecZ555vLOuS%2BKcv9BCC4088siuMkl8ZpttthdffFEDBpF3u%2BOOO644kVFHHZXlyHoqHX9Rx%2BjTK9q7775b4Im5RhhhhE6dOl1yySXFgGIDLrLIIjPOOONiiy022mij8ePwKnz22We3HvNqb4VzzjnnjTfeyPN%2FyimnHH744ccYY4yNNtqobRYClstuu%2B028cQTP%2Fjgg%2FU2UxhgCDBFO9AxrrnmGpvnsccec7833HCDMueNHXfc0QPCeCcC2NuUBE4FQj%2B22morDhjF1H3kkUc01sXG40q09tprC8TQa7%2F99it2tPwVIjVKR54bVJRq%2B%2Btiyxlwgw022GeffWxLvhzt%2BmP0Sccg0%2B2www6WyhK3cooKiUCwWFn20UcfXdJcMLq1EdNhJVbrqokuvvjiIgIQEA488EDBKRQbLYuOUTJpXHTRRW7WvwMCTKxTYAinDquVQ4NiI1SEO0rZIQY56KCDdt55Z0tq7hmPKmcSiXGalcQNlHh6kBzVm%2FeUU06h4ViYv2eccUaVIC6%2F%2FHJU8YfOVYi09Fu4nfpb3HnnnQXp9ddf7%2B4szMiFwMknn0yHKVNDxL3E7bjkXvixyMZTLgl7obT4ff0KrrpBoKAjWaDhNsu%2FbBoTUoDiOUNyKX3zNwRCIARCIARCIARCIARCoF8S6FjHeOqppyaaaCKxHswi%2FyefAjDeeOMVx%2FsePXpMM800IkSoHEwGtr%2Fy2GOP%2Fdxzz7GDWH%2B%2Bzj333IwCeoLyoosuWqyJX9Qx2vXHICwYXOoMNg53dHqFWBgWFlZsjbKSQQYZhDsHv%2FdHH31UY5NOMcUUbB%2F6hjJFhduJZbCzJptsMjVsrmpOFubMHFbSMMMMU2ME%2BuVvkbn%2BbAId6xi2E63gvvvuswxGujK3CloEm9rmX2mlldjvoi1OOumk4447TpINekVxMJAfQ2PW%2BiuvvGJ7b7bZZqussop9qCMj%2FZ133rFj1ZAUmMnMZ33LQyRwg9Ft5IMPPlhgiKdMWfdfpWMYxEqkm7B7Gd1mkUeXhS68xeKXXXZZS%2BKcQHKh4dATSBmHHHIIKYA4Y9lXXHGF%2B%2FVseorJBcS9Fh1DS6siWl577bU8MSzeLZgOKDXGrzFiNBnShMEto%2F6URjv88MPVU0JqpQLPDf%2B8%2BChwBdHLPxTEgfPPP3%2FXXXdVPuKII4qTCfnROi3bA0tLIb%2FQJ8tvQaNwvwYnO%2FgXybB%2BBUuyYPeOth%2FOUCXoxq8vpMXXbbbZhh%2BIfKqaKRedp1wykX8x%2FHD%2BBdOSVOKfiDKmLVHW%2F8QTT%2BhoeS1uJ827SzkEQiAEQiAEQiAEQiAEQuDPI9CBjsEAYR2w%2FeuLVIkQaQVc3%2F1%2FexHupAC2DzPE8hgR00477VhjjcWy47O99NJLM6bKG1VT0Dq4czz%2B%2BONadqBjTD%2F99NQGSgKpgR3hw6mDTWcKVgzhwpvZgkLaQz4VM888M7PLdHQM43sVq6XpnnnmmTHHHJOHRnHtYA0xSayWOcmgMwJLhJcIZ5JmdEAZmT%2B5BALl1Xapyd8BhkDHOobtXeJB3K89z6Tl2FB2gi3B6pe55eGHHy40WLU0AS4cvrLodeTboGyKkhjTblf2pBiE2Wtzlo7quTR07tzZ88VmNwgzuVjEFAl2NzXj1%2BoY%2FAfY%2BAakAfLukL%2BiKAD%2B7r777pwWOIRYCavcTUlVUdxIPKpuiqcBFw4OVJw6lNvqGNQAd1dux8Isj0RAn3F3hvVPhClKiArlxPg33XRTudPyl6bBe4HSUj2ymldLGUCIKBLFN8NopA%2BTFrcomoNh%2FTpl2Z5uo7nlIowUwjCWef0uHnZrLj%2BcFXrqtfcPlGefdkEqqe4ixTmnKFd%2BSh2pE%2BVedHSn%2Fn3zVV8j0HAKVb8aIYUHSNsbSU0IhEAIhEAIhEAIhEAIhEA%2FINCBjsFm4c8gNoThI6bDx4tXJ5ssueSSnMAJC%2FwWalJENhTrjHrAH8OYLCMmlRQB7EGvueedd96hhx66nATRgY5BeaA20CvqcSXcPJiQXND5gSy88MI1EJ4NxeIjqngVzvKyTn2r9zhLh8rhja1VFYZMLV4lXrmWr6SPmWaaiQzSjLXvB7Qzxf%2BWgG3TQVxJi47BVrW7yoIpcsxhn7ph7G0NdNGgqWP46m0%2BDwFamTIruBxBwuz1aPgIKuFx4aW%2F54sgQGSoqgWT%2Fzfkx6B%2BFB2jiHgUCQ4GJrJ4UWB8G8gvHiL3XvNjlJsiC5BTPLYvv%2FwyQe8XdQy9%2BJaw7qmLRQCkFTD83azoDIsnEXCXaokLox5wyuLHYpYyb8tfDykXF4PwzaiXyCb%2BhSFHWDYdA%2Bp6xIw4Ms%2B%2B1RbRQ5fbbrtNgyuvvFKZjqHcDH8rbjZqKB66%2BOeCokIhoYVSSygkIs50pGM0RRiShR%2BOt4z2wHIX8e8Jfxv%2FBKkXmQJFXW0KIRACIRACIRACIRACIRAC%2FZJABzqGsHciBmGh5UOUcNTjggsuSD2obzb9X32e80XHsH6RHVJSNDuKSflFHYNHhxG8ueagTiHxYR8xHJh%2BxA22A0ukwmFZGJ%2FZUnQMx7NWG7PoGAy06lbBmqONCI0v3Rl60TEqyb9P4dfqGDX5ra1FxPB2viZtoF30jY4hhYLIBa4F%2FA2YyT56%2BTCQ5VtgjBu26m%2B2epECqrJRfxor0ZddX2tqoeoYZbfTTzgymcLzSBygBjDAmzqGcu1LIrA2jg2%2FTccwDn3GrVE42fjECq4gVTwss%2FgXRhCKaJSaRqPOruAXIXR4lnmGNAUQqTkoPEQS%2F7AU%2F4eampUI2aJj3Hrrre63qWNQNuos5WrxnPHvlTQgFlzg4KPc1DFKWV9Aio5BNvFV9owyBXp4irUpziF1lhRCIARCIARCIARCIARCIAT6GYEOdAxu57JMTDjhhKync37%2BiFWnBrDOJJ0YZ5xxCBplqcwNFhMVQi%2FCQknI6W0vLYKB4%2F%2F588coxkUH%2FhjOK5l00klLuHqTAFWEDMKuaZpI3mXTMfiK9L2OIZy%2FDBsdo4n371Pu9zoGo1uwBiOdlc0WlnfFR4EMwmGpvNmv2sjv1DE8HTa2HBf0BGKgZ5MyIPMGQa%2BpY9R8lX73s846i0VPNvzNOga3BKoC5UQOCpIIr62W7YS5rJtEmJZLfLp08e%2BJgn8T5Nzo2bNn7csFi5OJSBP%2FQP1OHYM0QYKQhZVgwgGDcGFSLhlW7vYtrG90DD8WVxMqk3uhfsjsWpeaQgiEQAiEQAiEQAiEQAiEQD8mUHWM%2BrqzLsCLSMKCyI5mWv7iT%2B5dpP9XP%2FDAA7PISns10uvRMVhJPMAl5OQ%2BUYeSmm%2BooYbqGx1Dok6R6bVjKXiLKgPG5JNPXt6NqmSSmK4EtkTHaMGVr30iUHWMdu3QtnElv8cfg4JnGTVDRTn7oyzM7uVZ5NHjbyDoowoLQjNkt%2FBk9ckf48wzz2x7a9UfwzMofQc7nT5Qm5EImjqGspCTugyZK4ge5A4BF30fV8K%2Fwi2UQUwq7QYFg4xZc2bW2UtBBIdVWQm1s17yr4SkFjwxVHLn0KAZDMKJQk1JOVLiSuo%2FUL%2Foj0GaaPpjUB4MRQtFVe4daS6suSxDZhJr6Bsdg0ZElTWO2%2BRCQ3upN5JCCIRACIRACIRACIRACIRAPyZQdIxBBx3UEQyMO3aQDxOjuJp7zcrngaUj052UF6wDR68ec8wx7EEBHTJOLLDAAuw1bzblBJBpk4cGo0xYhzQas8wyiwGZb9Iejj766H2TH4Ns0q6OwYjwJtRKmFo0DcIF53BfvUpmT5FZRLj0TVxJ3%2FhjMF29tG1KN%2F34F8l0fx6BqmPISMlxqHzEO3g7zxeC1cwel%2FjCAuR18RL%2Ft%2BkY7G4GL48IG9XzxRODsWzH8jcgA5pUthaeAHZ1CXngMsF3QmOeTuIvOjh3VTJPq%2F2%2FdXfvrizgpalj8KwgjEiTKxkFs11CGA%2BvMYs%2FBsFBKkvTWQlJ0CNsnUQJSkLf5PmERS%2FJPTyGnKz8g4CnSrkmuChQD6S5cFNtfz6BM1Jk4CnlKc1EKl3r5ItCEyg5NkuwBn3APx0QuUoSqalBf62O4R7dFDmUOOMX5AxmZHh9%2BMaQMmgaHnBTi%2B7pSx3DTck3YsFuk39aM8Ct7f2mJgRCIARCIARCIARCIARC4E8lwM5i%2BNAEfPhXlIK%2FTiZlH7E4vMD1leeDo04VKBUMNPaLF838ydXIOzHCCCMo%2BMjGybYyJkvHV0ktnD%2BioLu%2FbDf3cuihhypz7W65L69Z%2BX4Yv%2FnmurZxlcWhowCTkUceWWG22WZj0GlARZlggglIGfUlKWlFM3ZTzY9R1iMIpQyoy1RTTUU2qV1KvRfijlkxeDmMss6ewoBBwL71Vt1hOvYGEax82PJcICgJ%2FDE4%2BTBX3SxNQ7PqIWCfsHmFbNQcLHwMNChHjirrSBsplCiBRiaJkAEFelBIuAQwrj1oJnJJQa4YtjCbnSpoy5EahFHoYmFOABHF0ALcSjRjRP%2Ffon%2F6H43VM9g9pDwr7HZzOZLDYkxBB3BrCnoRZ9w7rdIgjHGVorQ0sx4paMxFamDj0ygoFbQIDylpwr8ALlEntCweLB4QPiS%2BmtEjbEwNMCFKaO%2FGW5ZdvxqKq4nZrcrU7tQd1INajENjtDCfgshoVJ0yvn83oC6KhwFJELQIqyVTlPGF6mhQjlUizrhfH0gNBZFC%2BU0BJ%2ByUn77QdsmS6CTGITcttdRSNYWOn0YuViP4l6fM4h9DNRb22GOPlZr8DYEQCIEQCIEQCIEQCIEQ%2BJ8QYLM4zNTrSx%2FHEJSP1BNMHv%2B%2F3ZIYC4w1VoMAdk7g3pzWdTKanAXg%2FAIhJGyNWWeddfzxx%2FdqWwNWAB94b5ZZRtrw3DYym4thwqTyRrh5NkEZ0Bth5h4zs5qKdaJSYGrJ0sGcZMU4eaH63qv3ovnkk082aWnpEid2VlJ5O2xSlo532UX30EYX7SXrq11KRy35nLDU6uClPn8HDAIsWc4DNiSZgmpRPr6yYe12zgDKBA03yyVAmRtSuXH7hIVLwSPflRouDRqUbayLMvWs2N30BDKFJ0hih3KIJ4cH2957fAkftGye3OH5sjlte7uRqe7wUIuxP1uAyxdhtc1l%2B2oofhHSa3iE5dukH%2BrlORLtZUA7XCxGuV%2BxJOXeCXQSgTL56SeWZ9gykWdZBgligvVoaSValieRgmGumoSTBkjBcCNGLn2JG7xN%2FBPRogq23IJ1erjcpr40H4JnwVWaKVNUSAquWpgF16sW7E6LqKKxGyRH%2BCfFvKUvnhoUQYaOQangA6aNe%2BQn5metQ0Fk2cjggxKvM0j9NG7ZPw5WVf99M7jfRXbQqpZAxJFGrleOKC23lq8hEAIhEAIhEAIhEAIhEAJ%2FCQLe2zqboJ5XwioRPCLqpGNb5i9xa1lkCIRA3xOgAPCykJSDGtD3vf6klkXHqAFBf%2BAsdCr%2BG%2BTZqor8gYNnqBAIgRAIgRAIgRAIgRAIgX5AwOkDYkbGGGMMzhj87WX4HGSQQcS%2F5%2F%2Fk9wP4mSIE%2BgcC%2FFV4PvC5koOipg%2F93y6sxJU083z%2B%2FvUQaqg0vMtEoxR%2Fs98%2FZkYIgRAIgRAIgRAIgRAIgRDo9wR4aPNCn2%2B%2B%2BWTV8HE8K2%2Fzmo%2Bi368nM4ZACPRjAsJPZLSQSlRMSj0EpB%2BvoWU6ITaWVBObtFz9bV%2BlY5XQg4gheKd%2F8Dn5bXeRXiEQAiEQAiEQAiEQAiEQAoWAKHJx63IFJGY8WyIE%2Fm4EpA2RoKNmwuwfbl9CEsltalKLP2RJMnJIwSGS7g8ZLYOEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQH9FQFpO5wnyDy%2BHNrasTcx7y8mkLQ3%2Bt1%2B%2F%2B%2B47y3MUZrufdu%2Fof7vgzP6%2FJWC3OwLVhi8f5T9qPU74tRv%2FqNEyTgiEQAiEQAiEQAiEQAiEQAiEQJ8IMPb33Xdf56U%2B9thjbds4WXWuuea6%2F%2F77217qH2pOOOGEmWaaaeafPrPNNtscc8wxyyyzlK8SkD711FP9wyKzhv6HAB3jkksu2XXXXXfbbbdddtlljz32OOCAAy644IL33nvv9yySiGGQ3XffvVevXr9nnPQNgRAIgRAIgRAIgRAIgRAIgRD4RQJ0jDXXXHOggQZqm%2BGf0eeowcEGG%2By66677xXH%2BJw2OOeaYqaaaauqpp%2FZ36KGHdhdjjz32NNNMo4as8cQTT%2FxPVpVJ%2B1sCtvTxxx%2B%2F7LLLrr%2F%2B%2Bttuu%2B0222xj8%2Fu60047vfPOO7952R6iI488crXVVutPzh79zTeSjiEQAiEQAiEQAiEQAiEQAiHQ%2FxNggjlPcIghhrjrrrvarlby%2F5deeqnlNFXBJnq1bayGndjSuNnMa%2Bs%2BHc5owD5d6qCXoJJPP%2F3UUSmffPLJeuutN%2Bigg15%2F%2FfWff%2F65GvV9OiHRIuuqOl5wB%2FdSR0jhL0TAz82HZ8UVV3SSpkMofJxtweloueWWu%2FTSS%2Fv%2BRmyMlt1l1xmq7R7WsrnfmlP06ZKR%2B7TxjN%2BnS82RUw6BEAiBEAiBEAiBEAiBEAiBAZhAxzrGmWee6c31M888Uwh8%2FPHHBx988MILLzzvvPNusMEGzcANB7CeccYZiy66qEtrrbVW9YW45ZZbvPK%2B%2BOKLRa%2FMP%2F%2F8iyyyyOGHH97MufH000%2BbQi%2FDHnTQQR9%2B%2BGGl%2Feabb26%2F%2FfZ6LbTQQnvttRdpol5qW9hiiy24jjzwwAPlkrMgrXDnnXeuB8K%2B9dZbG220kVACB8V6F%2B8V%2FEknnWQ9xt94441feOGFOiZT8eyzz1588cWtisjz6KOP1ku1YDFG2Gefff7YEx7r%2BCn8GQSqjvHQQw%2FV8R9%2B%2BOFVVlnlxBNPVPPiiy%2FyrLjzzjvLVbrB1Vdffeyxx9o8pcae12Dvvffef%2F%2F9haiUX5%2FUdu2111JIOHWY4oYbbuD1IRrLmFp6ZO6%2B%2B%2B6mxPH8889zJbKlbXj6Yb0kwwbfJ6EuLh1xxBE2XlVLTERpMakBCS%2FNR6%2FeSAohEAIhEAIhEAIhEAIhEAIh8Hcg0LGOwfAXrEGLgILCsMIKK%2Fg60UQTzTDDDAoTTDBBSZ3B%2Ftpxxx3VjD%2F%2B%2BBJWlDZFymDK%2Beoz7LDD6lWiPxiG5SX1PffcYzRXXZpwwgkVTMG5wnSvvPKK2BA100033cQTT6xAD%2FECvd0fhbm3%2Beab0zHuu%2B%2B%2B0oBUQqPQ69577y01F110ka8iZd54441JJplEeZBBBhGEIhRF2Sw9e%2FbUUu5H6oeascYaq9zLOOOM0zR7y2hMUfeib1IiFCB%2Fib9Vx3jkkUfKgmkI5557rl1X%2FDHsn6WXXpoiV64SKA455BD%2BG927d1dDWFhnnXVWX311GTY23HBDASnHHXccBU8zYsXKK6%2F88ssvm0LlUkst5SsfIUKc7rZuzT9jQ5ZBJOhQWGmllegeNrAnkXq2%2FPLLb7LJJi6tuuqqAlVKtJcpOnfuzGnEJqeeGVDHegt%2FCfJZZAiEQAiEQAiEQAiEQAiEQAj8UQQ61jE4OQwzzDC333676YgPrHs1kiJ6O8z6G3jggZdYYgkjXHXVVYMPPjhjjWu9kyDOOussgSrcIVhnp512ml4cG7z11stQI444IhcIEStEiQUWWIC%2BQWFwybBsQ41Zc6zLzTbbTJzI6aef7hIJxWimu%2Fzyy9u98bY6hmbcLYzGD0SZpck8JHRYgImmnHJKy3ALlkHW4BCi5YEHHqiliAP3wrBVT5%2BRv5Fe4dZaTqNgWnoFf9NNN8XPv91fpP%2BsJDIQ1kgHO%2BywA98GH%2F45a6yxxp577lkcgfjzuHrOOeeU9ds2vC804KehzEeCiEGR8Ou%2F%2Ffbb%2BpIdunXrZliXuCER34pUQnPgccGLw%2B698sorKQ%2BnnHKKS9yEtt56a2KaXgZ59dVX5eiw1Tly2G90DwlItbHZKIRWst122ymTR6yBVELi83zRFUkoNrYR%2Bk%2FOWVUIhEAIhEAIhEAIhEAIhEAI%2FHkE%2BkbH4GbPWhdnwTOBpVYWw7zaaqutRGQwwRhZ5A5GPVlAHMfrr78%2B99xzc7T44IMPCBHkCPEppZcDUl2SlpOxxmFjhBFG8NKZClGuCmDxIlsXSTm4OlA%2FWIIGNCzbjbzABqyNm0za1TFYfxbsBJOSu2DyySefc845xYNYMH%2BMTp061fAWQSXjjjuu6bTccsstS2pTMovGFiCqhZ8Jk7M5Y8p%2FRQJVx%2BAsQSUoHyLDoYceWqKWOtAxaGvEChEo9jl1wu1zxbEt33333XKp6hiCSghfxYVDM3FMLgmnooR4lEx36qmnGsHHDqSZqDEvHUMQE4HFbvRUGpMXEOcNDkIeBwOSOLgMqfcw8ht58MEHo6H9FTdh1hwCIRACIRACIRACIRACIfA7CfSNjiGE34vgSSed1PGsJeijTKqvN8JqZp99djEaRImRRx55pJFG4urAvWG00Ubr0aMHUYIswPOhdGG7LbjggkXHuOaaa%2FSSDaDeAjmC1eYjn8CQP32M5mPY4YYbzphEj3Ztt3Z1DC0JLPw9WHxsT%2B4cxTfDm%2FSiY9TUGW6BiKHSgr1h17Lei9l9JcU8%2FvjjdZ0p%2FEUJFB2Dsw0fCboBwY0uJ2TDj86JyC7qQMdwyzwxuAyRHfylS9hXVAX1HoSmPwYdg3BRM67QMQgU2tuQEsXoLirECOWjpdmlxTDIeeedZ21cPiiE1lNDlvhgSL7hEjWDlMd%2FSeqYv%2BhPkGWHQAiEQAiEQAiEQAiEQAiEwO8k0Jc6hgyfzHzxINX2r%2FN6kT3zzDMPNdRQXlXLp1GsM34aAvkpBuJK6BjVUb%2BtjiH%2FQB2qFkR%2FkA4mm2yyn629DY1sTEEBVI7arBba1TFclfSA%2BiFqQGIB6krJ5tGujuHWOJBw4ZAegeOH9%2FXNe2E8VkeUOmkKfzkCdIxyXkkz4Qn%2FHIEeUr%2Fa2%2Bo5adTtWuNKqihh83Tt2lU2GJKC4BHyBb%2Bdtv4YfdIxLrzwQnKE9BrEE2k0fBR8ai7ZJ5980gptdcugwkmdYc2Fs8gssV0uUUKMT%2Burl%2F5yP0QWHAIhEAIhEAIhEAIhEAIhEAK%2FmUDVMWo%2BzOZQJT8GZ3hRJPwoWPpeLpcGbLfzzz%2F%2F5JNPlm5CRs1RRx31ueeea%2FYtZQ36pGOwyzhLyE1RzTHBJgxDIoaQE5fYawzJtmO2remTjuG19bTTTsszhH8FZ5IiwhQdQ7wJUaUM5cU3zURaUUkS2KcSYjhFpe0sqfmrE7DT2uoYfnT7XDwRtxx7koAgQ0u5U%2B0dDkJPkB9DcJOdWTa5ze9B4EpE7yJ9aNY3%2FhieNX4XvC%2FaPeNVzIgoFaFYpuaAQXPjxUFA%2B%2Bijj8hrpi6RLyKzNKNj7L777pb0V%2F9Fsv4QCIEQCIEQCIEQCIEQCIEQ%2BLUEio7BA0EKTQZd%2BTCmWE8uFR2jHJrgjEi%2BDWy3kl2Q7sHDQUQJNYAjvUscGErUCbnAgaQSJHKkl96wrY4hzaasAprpPvroo4tbsWyOFjIZGkeCRGMuueSSIku40FuGq84HkerTO%2Bh2b7BPOgYDsxykYljrKX3pGFNMMYWEGIII1Lgdq9XAYZfaS%2BWhzLefqOIqU9EtWxglpzk1gUWUAbnD1M36lPtnAn7fomOIMyIL%2BFDhBD3RLggRdhqZgmphP9icvnLDEOJBNKAkaElY4LkhGsU9kjJKagtn7hjW7tKs5PnsU1yJPSNwSTJPYxJGDGIK3bt06eJxk2B2scUWU6ZUuGQ6KUA9gB5JeXQl1KWulEePhGIZMmkUXa5%2FBp61hUAIhEAIhEAIhEAIhEAIhMAfToAlxQOB5c6hQgYMwSM%2BzjmV3JK95iQFKSxYWObl2zDjjDNqKVumnJ8SR8jt6cwOl1hhZAeXOD%2BIy3CYqTIHeGZXOTSk5vkkUJQUoMQEHaUpIFZwlmDBzTXXXHrNOuusDj1xiX035phj0kBkrmDEkTu4SbT7IltjYgIVRXe9fG1%2BiCRya7i7muDC1GWFo4wyihtxOzq65fKqXapPATJqaB3LLLOM81iV11577eq8UQZn4SKglzQLzelS7p8JEByIDH5WikTxwaAq2LFkAUKZlQsSIWc5DURUFDVDM8EjRC0%2Ftz0mu4WoEI0PO%2BwwwUqcMahk9C6XZAp1Uiq5wxSiP5Rrnk%2FePrQR8VNkPerHZZddxtHIvGpMQUIR0uLhIqrsvffepnPoqmQaPDGUnQCrC03DRHrxwTC1p9IyPAsu9c%2B0s7YQCIEQCIEQCIEQCIEQCIEQ%2BDMI0DE4Gzj%2FlFxAYagftht7X%2Fw%2BS5%2FjQZna22TWH9Oe1kFbuPnmm%2BuSuHAwsmaaaaYJJ5yQl4Uxy2kgXiWTRIT5l5beNTtKkixQvB1U3njjjTQQYR20BYPzrq9jlmQF9AQCi9AVLeullgJDkv%2F%2Fwgsv3DYeRPYDR5%2BYop5OUvwxxhhjDHaoezE4w7akzijD8iqRwUDgiXsRbMIRhbjRMiN%2FEu%2Ffpd0oLigtV%2FO1%2FyRAZLjiiisoFRQDf2kR%2B%2B23H62guFiUNdvkRAlhJtwhjj76aPuKQ04Jp%2BIydP311ztulZJAfOA4VLYrRwvn82pGjjAFpUK5Zumky9lCgrDoGKbQmIOTBRjEFJJjFE3PJQ%2BFOKztt9%2BeVELNoFRU9cwKKTAm1csCPFYtDkL9J%2FCsKgRCIARCIARCIARCIARCIAT%2BcALMLtZZux9ve1lefCpYXs15hVpwwCAdNCtLmdlF0GhaWHQSI%2FhbGpTp1DS7K8sj2lYrKF0IBX2ari7AsO0uVQPJBKTa4K5fGzMb6TAECqKK9%2B99Gpzu4V5kKqgdWwr4%2BLRU5mv%2FTKDsk%2BZub9nbZfE2JO%2BIkn1CWfvmD%2B2rPWnn1Dutw2qsbLc3u5QR7E%2BXahdfWwaplzxEHofmQ1QvmVQvg9eaFEIgBEIgBEIgBEIgBEIgBEIgBAYMAmzGZ555xuGVzngdZ5xxvGSv91V0jE6dOvVJOaktUwiBEAiBEAiBEAiBEAiBEAiBEAiBEAiBfkOAZ77UFgMPPDBf%2Fear8LfeekukiTAW79z7zUoySwiEQAiEQAiEQAiEQAiEQAiEQAiEQAh0TOCRRx4RTuLQWI79zZYCRq6%2B%2BmopO%2BKf38SScgiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAiEQAj0ewLObnj22WefeOKJDg4H6feryowhEAIhEAIhEAIhEAIhEAIhEAIhEAL9GwHnTl500UW77bbbrrvuKjHmvvvu27lz53vuuafd8yj%2FpMU74HKJJZZwFqoMnH%2FSFBk2BBCQKeW%2B%2B%2B47%2B%2ByzX3jhhSYQBwFfe%2B21Xbt2dappsz7lEAiBEAiBEPj%2F2LvreO2qqnv40g0iXQKilAqIIuVDd3d3d3c30t0N0p3S3dLdIK3YWI%2B%2F%2BLzfl%2Fl51rvf6xwONyF3OPYfF2vv1WOvfXPmWGPOFQSCQBAIAkEgCASBIQ2Bf%2F3rX6uuuqpzPVwjjTTSiCOOKOF3q622%2Bstf%2FvL1jBaPseCCC04%2B%2BeThMb4ewP9je3FsDZpuscUWQ9x1T6v561%2F%2Fus0226yxxhpvvvnmfyw4mXgQCAJBIAgEgSAQBIJAEAgCQWCoQACPsdZaayEujj%2F%2B%2BMcff%2Fyxxx67%2BOKLZ555Zk8uuuiir2cKeIyFFlpoiimmeO%2B9976eHtPLfyYCeAzrfPnll19hhRXOP%2F%2F8dpANHoMYab311nvrrbf%2BM5HJrINAEAgCQSAIBIEgEASCQBAIAkMLAsVjjDzyyPfcc08b8znnnEOVsfvuu3vywQcfnHfeeddccw3h%2FcEHH3zfffd5%2BNFHH5177rkHHHDAoYceeuONNwpwUXWffvrps88%2Bm2j%2FjjvuUPiwww6TYDy2llEW3FhU%2FPnPf857pQzJ4jHoMe6%2F%2F%2F4zzjhD7kknnfTOO%2B%2B0Wi0hgMbll19%2B%2B%2B23%2F%2B%2F%2F%2Fb%2FbwySCwCAi0OUx1llnnSeffLIq9uUxlLSMnc%2BL1rv55pvjbzKICKdYEAgCQSAIBIEgEASCQBAIAkHg341A4zEefPDB1tdRRx2FxzjooIM8QVyMNtpobuvad999X3jhhXnmmcftKKOMMtxww0nssssuIgwofOSRR7qdZJJJECO8VKRHHXXUSy65pFp%2B4403lllmGQ%2Fl%2BiX5EI4DB%2FLnP%2F9ZfAwPJ5hgAs%2BHH354v%2FPOO%2B%2B7777bhlSJhx9%2BWNZ0003329%2F%2Bticrt0HgMxFoPAa3qRVXXHGPPfb44x%2F%2FqFYPj4Elu%2BKKK1ZeeeXllltutdVWW3LJJbfbbruXXnrpM9tPgSAQBIJAEAgCQSAIBIEgEASCQBD4dyNQPMYII4yw%2BeabH%2FPJteeee0488cS4gmeeeUbvDzzwwHjjjYeyIMWnvqCF2GCDDZAJ4oKW7mKWWWZBdNx7770KH3fccbImmmiiffbZh4vKgQceKGuJJZb4%2B9%2F%2FrqONN95Y7vrrr8%2BB5ZZbbvnJT36Cu7jppptYkUIWyJprrrmuvfZaypCFF17Y7VlnndUzfY4nm266KcFG0SY9ubkNAgMjgMewRPmVWHUUQVi1Sy%2B9VBU6n65fCbpslVVW2XLLLa3qV155hfRIFfKkIj0G7iK5QSAIBIEgEASCQBAIAkEgCASBIPBvRQC9sPbaayMNeq7ddtutfDfwGGONNZb4FXWrPK8TDIOEgXEMOeSQQ9TlTuJW8AFpPEONmcPInHPOOemkk%2F7mN7%2BxnT3hhBPOMcccWIvKve222%2FiSUH0ohrggxqD0qCw%2BLNopx5Z6kt8g8OURKB6DysKqFtJzww03tPhffvlltFjxGCLNOqnn8MMPJ8ZAxFWPTcVRZN2XH0ZaCAJBIAgEgSAQBIJAEAgCQSAIBIEvjEDjMew%2Bn3LKKSeffDLfEHE%2B6SgEQtQsi2%2FsscdeffXVW1BEDx1eSZY%2F%2B%2ByzzzrrrJNNNhnOQUXPS4%2BhnRqPE08WWWQRBMWHH35IgMHHZIsttqgsv0QayI23337bNjee5Nvf%2FnaLiaGwNnfddddWOIkg8OURaDxGhXm54YYbcBqYNEFgnGAizmetRmeX%2BBz%2B8Ic%2FtB6FeVFSaJf2JIkgEASCQBAIAkEgCASBIBAEgkAQGCwI4DGcV8K%2Fo7vXzLMDj7H44osLXiFuBh7D2aylx2AJnnnmmaOPPvq3vvWtBRZYYNlll%2F3BD36AcyjuongMUTprLgJfEFqQYdBjkFjopV%2BJRcX5dF5JO3c1PMZgWQzDfKeNx6jVzp2E6xOC4vTTT9922205TOExhF7hu7TDDjt0zx32FSDuTjvttGEeokwwCASBIBAEgkAQCAJBIAgEgSAwhCPQeAw7zm2oTp%2Bceuqpxa9ARDz00ENdHoOyYtppp51xxhmp7lmFqmAwPpPHUAs3MsYYYwiO0Xr5%2BOOP6Tqef%2F55G9917mp4jAZOEv8OBHp4DF1QBFmTK620koAY4rfgMSxLPibSyLc2BofyoOwuu%2Byy9iSJIBAEgkAQCAJBIAgEgSAQBIJAEBgsCDQeQwDPGgD%2FEYoLkT%2BFCBAroEeP8dprr%2FETmX%2F%2B%2BWu3WmABm9d4jFNPPVX1T9Nj0O2%2F%2F%2F77U0011TTTTKOF6uiiiy5Sce%2B998aWDDqPgfRgaQ4WrNLp0I5AXx7DjIT6pLUQxhZ3gcFTxmLGWlx%2F%2FfU1X%2BvTwTokSU899dTQjkDGHwSCQBAIAkEgCASBIBAEgkAQGNoRwGOsueaa%2BARHqa7zyYVScPKpWBbXXHON2YmPQUeB0yi%2FEvSFM1KVZ%2Fftt99%2BlXZ77LHHKuxX%2BsQTTyxYGIALLrig407qBNXDDjtMy0J9Kub8Vp4p448%2FPl0HXoKLijgbTY9x8803a8dxrj3wCgQ6wwwzcAToav57yuQ2CHwaAjgKa88xJdRBrYxVajUuvfTSG220ER7D81dffRWngbhA6FFiOHxHLo5ORJdWK4kgEASCQBAIAkEgCASBIBAEgkAQGCwI4DE222wzfMI444wz5ifXuOOO%2B7Of%2FezKK6%2BswJ7OoKSjcLJD8RgG6dRUET6dxDrccMNNOeWUTDzVHcPKSBRAQJr1V3PBNqA7pp9%2BeueleiIcgZNbhcvAUdB7zDTTTLfeeqvnDEnnWrql6q%2BKnFy0gyep2%2FZrQ9xgkCc5AbNhksSgI1BaCwFhqIy6tZ599llfwdZbb91W4JNPPmlJczaxgJUXxrYb9rNbN%2BkgEASCQBAIAkEgCASBIBAEgkAQ%2BDoRQFZQQdA5vPjJJeE8SuRGGwMuQo4y3fNKROa8%2F%2F77uaLwFsFOKKCWAkIKiHchUmJV55by%2BuuvO9fyn%2F%2F8Z2uQqaiiAyM00lOMl0o9kWUk2A%2BGZ6sooQs75rxUug%2BTDgKDiIDlZIm%2B8cYbPXoe68pKtrS6C9XCtpgfeeQRK7ZnHQ5idykWBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAwRCHglApHvjrC1bkqQ9TAMpjBhYBzSf761786Lqd7ZM%2B%2FYzBOMbb2HJtiEX6u9g3M8AwyR6h8LtxSOAgEgSAQBIJAEAgCQSAIBIFhBgHWkFNTb7311nfffXeYmVRNxNSeeOKJq6666upPrmuuueaee%2B5xkmZjLf7%2B97%2Bvs846884772uvvTaMzT3T%2BUwEsAEPPvhgz6vHKpx99tnbbbeddfKZLQxQAEHhwFZnE7see%2ByxV155BV3WLf%2F73%2F%2F%2BgAMOOPDAA3ued8v0m3YeseGdc845%2FRIgv%2Fvd75xo7AzZfuvmYRAIAkEgCASBIBAEgkAQCAJBYNhAYPfdd%2F%2FGN75xySWXDBvT6c5i%2B%2B23N7Wea%2Fnll2cMKobHmGuuucYbb7y67VZMephH4KabblpuueX22muvv%2F3tb22yyIEjjzxyxRVXfPzxx9vDL5B44403NtxwQ%2B3Xtcwyy2y88cYXX3xx6%2Bujjz7afPPNt9hiC4TG52r%2FySefNDyD7JfHuOKKKxZddNFTTjklao3PhWoKB4EgEASCQBAIAkEgCASBIDB0IbDHHnuw9C%2B99NKha9iDMtodd9zR1FZdddUTTjjh%2BOOPt%2F0955xzejLffPP95je%2F%2Bec%2F%2FznPPPNMPPHEFCmD0lrKDDMI%2FOMf%2F9h%2F%2F%2F3RC2uttVZXeoEcOProo1dZZRV0wZeZbPEY66%2B%2F%2Fi9%2B8QuKoBNPPHGTTTZZaqmlTj31VKtOy3iMrbbaauutt%2F7DH%2F7wuTp66qmnDM8g%2B62FkTvssMMeffTRfnPzMAgEgSAQBIJAEAgCQSAIBIEgMGwg0JfH4GNCnX7XXXe9%2BuqrPXN85513eGfcfffdLLWW9cEHHzAGm0KeCz%2B5vrotyIDEM888c8cdd1Dytw1oW8Y6eumll%2Bgifv3rX%2BvuoYceEjSgNSvB3rQzfvvttxPnK9bNYnI%2B99xz2pRVtmE3t9I77LAD1uKyyy5rWQILrLDCCvXQqPryGIZtJPfee%2B%2Fbb7%2FdalWCs8DDDz%2BsR3MR36Cb%2B%2FHHH%2FMjuPPOO19%2B%2BeXu824adK%2B%2F%2Fnq%2F2%2BjdYkl%2FDQhYruuuuy6Cy2I477zzmnqh8RioAAuAX5IX%2Bt%2F%2F%2Fd81JAvGirXa29u3XN9666228tvIfR1IjJ133rktWr4etB%2B68%2B0oVjzGNttsozUfi45Uac1WO7%2F97W%2BffvppxIWV00ZYPMZRRx3FhcT6d6up1q%2FlbYQ9H1HLTSIIBIEgEASCQBAIAkEgCASBIDBsINDlMdhxrP7JJ5%2Bcpe8aa6yxGF%2BMIzMVVsK28qSTTlpZ4447LnE7nkHW3nvvPeqoo9p3LkCYZj%2F96U%2Bnm266srBwFKuvvnrV8jvzzDOXKaew%2Feixxx6bowdRRBWgile%2B2kFxuG0V7Z43XoWaYtNNNx155JErd%2Bmll0YRVK3ub%2FEYF154YffhSSedpNbhhx%2FOPu3yGAImgMKUq82JJproggsuaLQDtmTuueeurBFGGMH2umlWs7IE2ais0Ucffc899yzEup2ySX%2F2s59NMskkLNPu86QHCwIXXXQRSuHyyy%2B3QpAJllMNo3gMjhs8klZbbTUrU%2FrnP%2F850kABXIT3brVjtKr8DTfcoMyVV17ZM4viMXbaaacupeDVr7zyylrzKWlQv8KzWC260IgsUS%2BKkUOYXH%2F99eutt57nxmkk559%2FfmUhLnxNhmH9y%2BK3Io11rAHceOONWhsmtVU9COc2CASBIBAEgkAQCAJBIAgEgf9kBLo8Bjn9BBNMMNlkkx1zzDEnn3wyzoF5ftppp8GHKGK00UabYYYZ8ADHHnvslFNOOeKII3L5l8UWY9o3a46BP%2Buss37nO99hrCE6WFsaYZQxHnfddddRRhllttlm%2B%2FDDD1UkrZeFA9lggw30UkQBwb8sdRdaaKHhhx9%2Bo402wiew%2BKSXXXZZbAP%2BgTuAivbTbaYLRCDN4iOKULF7FY%2FRE%2FoDM6M8hX%2FTYyBM7Hfr13OMxLnnnovlGH%2F88b%2F1rW8V7YA%2FAYXp22HXI0ZFyc0228wGOtYFJrKEGTnzzDMF3JDFh6VtoNd4qFBYo7PPPvsAgo3uyJP%2B9yFAPoGmwKFZYxawlUOBU93hMax8y8x6E%2FATmeCN8wex5mVZ2Faj1d54DAUsBlEpekbbL4%2BhXwteWAz8nvUgXKe6FTcD86BlDiMVl4Owx6iM8LrrrkOVSLj1UC8W5BprrOGbMgxZQmEgQHw%2BRfF5gtnoWfA9Y8ttEAgCQSAIBIEgEASCQBAIAkFgaEegy2Nw52eGF5NgXtwrxhlnHBu%2BjLgjjjhCFnKj5sti%2BuY3v8kWc2uHGqfR5TF%2B%2FOMfTzPNNHz%2FmX4E%2FAo0zfzaa6%2BN9HCOg4pbbrmltOAV1SYjTpuMOxvWZCGyEBFV0a92RhpppAceeEBdvIFd6arld80116TrkNWeVKLiY%2Byzzz4kE7%2F61a%2B4fhx66KECe5J%2F8A1pPEb5DiiGHmkqC1SG%2BaJQNFVz96SatclOcEKwwSOgss4444zK4ozw3e9%2Bl%2FSCjVxP2q8Yj83%2BbQ%2BT%2BPoR4L5EtHDWWWfpWmgUC%2BmQQw4p55HSYyANbr755hoYwm3bbbflJMJfg6jmy%2FAYfEyQXdY%2F7sunQY9hSbfYLD4fK9%2FROvpFX4jl0s5S4c2EuMC8ycJjoDtwcW0tkRupiCSUGx6j3lp%2Bg0AQCAJBIAgEgSAQBIJAEBi2EejyGCwmTMK3v%2F1tzIO9ZoYbF4ny8XfLj4MMg4V1yy23sNMZ5pVla7hfHsO%2Bc8kSEAU2jhlf9BgoDgSFk16ham%2BaI4aIE4WwXWwkwMILL6zZXXbZZbjhhtNRA58FRynBf4QaBMNgG5qfy3HHHeeXX4knZZm28hLFY8ii5XBpUHrCCScs2oFWpPmV1DiRGKxCNA57c5FFFlHYsNm2%2BuJHgwxpjQsSoiRLk0VMYWJe2BiD4Wsz44wzEnKIXdAKJzHkIIAio5ah5Hn22WeNyu2%2B%2B%2B5LfVF6huIxEAXcN9qYKYVWWmklb%2F%2FL8xi%2BtcZj0GagMlpsDZoQ%2FEnFcrEacWWCydBp0P9YVHgMq8uQiscgGmmCH7E%2BTEd4T4PnVxI9RntxSQSBIBAEgkAQCAJBIAgEgSAwrCLQ5THIHthNQlsw4V1YhYMOOqj8NWxYU9e30BnTTjutQxPKZ38AHgNoNB6kC1pj3eMQEBf4kC6PUWkl8Rjf%2B973EAh65LVBfUFB0Rf2cgxBShQvoWUNjjHGGIbXU7h4jAUXXJCug3SE4oKzQNsBx5Y0HkNFZiN3GK1hcgyYwEO6Dpjg8GLir7zySk%2F7nFw0rlgbiQQ3GXqPLunRUyu3gxEBYTm5chDwcAJCGiAKrA16hoop0XgMgTfbILlpoBEwV1%2BSx7BaEBfUTcJx8Cup80pa2Nsuj%2FHee%2B8hErm3cCGhAKlAGegXQyoew6dnqDVCM1LA2qYvCo%2FR3loSQSAIBIEgEASCQBAIAkEgCAzDCHR5jNrkZXDRSJAl%2FPCHP2SkozIYTZVFD09yrwoeQ1btESMW6DFanE9m2k9%2B8pPyK2FkTTXVVNNPP31JOKg7OPurODCPQekhggEFRQtcAH8PGZLIB4IQLdhGd6gEHwGXBLcRyv%2Be19RvfIxWpvEY5CLSiy%2B%2BONEF81Y4R7cYDLoRfjS4HRYlBoYrSqtL1U9tYqZYF6FBRYwkZanBGAkruO2ztypJDAkIXHPNNUgJAhtCGgkXrQUhBP2PZW%2BR9z13lc5HYZRa8Rhdnw5%2BHDiQQYyPQQFCOOGzwv6RM%2FXLY1hIvjUKEEMi%2BKmlyN9KcJVP02NYveUao2J4jCFhjWUMQSAIBIEgEASCQBAIAkEgCPy7ESgeowTtQgHgH%2Bz5Vqf4AeoClppdY%2BE00RrtxBBRB2UtueSSTD%2FhBegQKpSEitz%2FERe0HAxDsSxQHPxH2izEBBiYx%2BBXQvvBeFTM4Q6tIuU8nYOQHWw93TEDWxbagbq%2B3bZE8Rjswfakm2g8BqEFg3GKKaYQ5LPiJCjmaEtESsUDKQUIpUqrLmCC8J4sU7QMuqNxOAqgU%2FoyKq1iEoMRATQa7RAxhhNz3nzzTT5KLm8fU4fZKPEPHgO5UfFbDJU0yAfCGUR55AM1BZGPhV2zsBQH4DG6567iQPbbbz9OH%2BUqhQT7NB6DuxMxBleXdoqKgSFbujwGKo%2F6osaAS8GzOevEbeJjFCb5DQJBIAgEgSAQBIJAEAgCQWDYRqCrxxDkAXvANGPoCRFgk9rtFltsIYwAK0zaNjH%2FC04TFPJunSwJHCEKpeebbz4qi1%2F%2B8pesKreoDJoEm8V8NJw3Sucgl7cI9oPDSFlzFR%2Bjx68Ej2HDugJmEkiIUEEIgdYQn3OWWWYhuS%2B9B5Jht912s1WNUVliiSV0QQjR86YGkccwSPoKziOcU5i0pUXRnVmUr4qWzYKwBNsDFsFCTWGxxRZjFzN4hUKVy5A0TuEWEUGymr9ADUlJZIhzJVoc0Z6h5vZrQMBxPBQRXDDKH6r1WD4d4q4gB%2Fxa%2F%2Bgp6wqhx5sD%2BeCNI7gwDOrSZmDGUFiicXJRQXr0q8fA%2B3nd3FVoJHgzCWmL9EOSWAn6%2FTQewwIjq0BZ%2BIhwaJa3uDS%2BGkNqPAb1hQgeNBvWG3GUjjypgB7hMdo7TSIIBIEgEASCQBAIAkEgCASBYRgBbACDvU5Q5TbC195tuxzMUfIMBAILrj2XcCpHhYxgs3ezxL1EMtBj2L%2FmlEGtweqvigJZTDrppNKsMJBuuummnrRgnjbHp5566gUWWKBsPUZZC8ehioNcG%2BOBPZhpppmqTb%2FaF%2F6ineDQXhZ5idwmFGnPK0GPgbtwvmpFzGBCYiRam4KdSmuWXam80KBKtlyMSkXAkHv66adXMI3Kxaiwc6tW6%2FGdd95B7FBukLi0h0l8nQhYit6UQ1S5lvT061ASXAFCQIQWITSRdVRDfnEUCAQUBKarqiDxyDmQDLLwG1gRv6Vl6rZpJSMxFHMpIA4tfQUurgk58BhIPAxh47vQJvqqI1NfeOEFuWoZg4v4x%2FdFSWLF%2Bhh16mAdv7K0T6ohBA2m0QCQHmrV2SXd8SQdBIJAEAgCQSAIBIEgEASCQBAYZhDgFWLPlyHGgqtJ2Xe2PW3jGP9AaEEP3yYrQoX9XyeNcvFgDHZDQCBAbD0ffPDB7H0GPnvKpby6LCwxErlp2OkWRIIhxs7Sqa7dqsXGry5YeZq97bbbmnMHe5CNZiR8Oig02kgk2J5U%2FbKcV4Ic0Fo3V9oTz%2FEz7fzKngI230kvuhOxX0%2BAYXbGgKIxNlA0AT%2B6gymqR3A187PadDqJLFoOnIkx93TklhGNe7npppuYon1z8%2BRrQMCisjItxZ53p2ukEz2Dd829yKvkuySuy3333WfxoM6af4eSFpWFYc0gHKxe3kxqtW%2BnzcJpI163LLomPWq824hiloFoKlqg8ahafJGUb4uHboe0yZeiHVn4E5daBq9Ng3zppZcMwydAGdJIMy5d%2BJBPW%2FBteEkEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJB4N%2BNQN%2FDRLo9DpzbLdnSX6BKq5tEEAgCQSAIBIEgEASCQBAIAkEgCASBIDCUIuDERidCOtK07%2FXxxx9%2FVZP61a9%2Btfnmm19xxRX98g%2FOl9xyyy0dK6k7h5w6O7IObB2gd%2BeibrbZZn4HKDMYs4y%2F4eng2na25mAcUrruIvDXv%2F7VC3KeaXs1Vqazgx3m2%2Ff63e9%2B59jcbnXv17moGqmHctVyVKsWusW6aWV8UP1%2BUw6EVb17irG01nTRzvxtTXniuVyHFLeHPQmT6rejKqY7ddvRxj11cxsEgkAQCAJBIAgEgSAQBIJAEBjCEWAW4RAmmWSS8ccff9zO5cmNN944wOARIB999BGTalAMIgzGN77xjZ122qnfBn%2F%2B85%2FLPffcc%2BVeddVVE0000YEHHthvyfbw2GOPVeWoo45qT77mBLOXecv8bIZwdwAPPfTQ9773PROB6AQTTDDVVFNttdVWTz31VLfMAOl%2F%2FvOfgNV%2Bv7TPABWT9ZkI4BNuueWWLbbYYo011lhzzTW32Wabhx9%2BGM4%2BhOOPP96T9f7nWnfddTfYYAN3%2B%2B%2B%2F%2F1%2F%2B8pfWspVvrfpq3nzzTQ%2FfeOMNy3WdddbR4Prrr3%2F66af%2F%2Bc9%2FboUlfCaXXnrp9ttvr7Xrrruum1XpO%2B64Y5NNNrnvvvuq8AknnKDk6quvvvbaa%2B%2B8885PPvlkq%2FLaa6%2Fttddenutro402Ouuss7qMn6m9%2Buqr%2BjIpc2y1WuLFF1885phj9LXddtsZdnueRBAIAkEgCASBIBAEgkAQCAJBYChCgPnGdsMJzDnnnKusssrKn1wrrbTSaqut9sADDwwwEQaUivPMM8%2BgGERXX321Lnbfffd%2BGzzyyCPlXnDBBXLZX1NMMcWJJ57Yb8n2UAFVjjvuuPbka07AbYcddvjhD3%2FYNTPbGO65557RRhttrLHGWmGFFSCK0zBaTBGWppUZIIHx%2BMlPfsLY%2FF%2F%2F638NUCxZXwABrMWqq66KnTjjjDNOOumkIi5eeukl7MTll1%2BOkTjkf65DDz102223XWqppQ4%2F%2FHCvu%2FWFYkIFHHbYYd4OygKxsMwyy6iE3Nhxxx2XXnrpM888U2tVHtdhnXi41lpr7bnnnkVWtKYk0IA61WDpdjSrsC%2FlvPPOk15xxRU33nhj7ISSeC1M4PLLL3%2FEEUfoa%2Butt1522WWxFkV2mdc%2B%2B%2BxjsS255JKeX3%2F99d1epG%2B%2B%2BWbsx3LLLUfIhACkRekpkNsgEASCQBAIAkEgCASBIBAEgsBQgQADjYU18sgj89FgEPVcbQrsNe4nf%2F%2F73%2BsJC46EfrbZZiM5ePbZZ92qWFksOAaXPehmynlePMbee%2B8tLddVheu3y2MYD5EDQUK3wO9%2F%2F3tWXteoLx6DHVoNKtDKG4lGqjDlv2F3JRO67hZutQZuh7GpnabVLx8BtA92gmXqtk2%2FGrz33nvxGIxQXcsyjPPPPx%2BPMc000zz%2F%2FPPdTtFBWm47%2BAob%2Bf333z%2FGGGOorqLGW3mwKNzVBrSsStTcex7mtiFgYRD%2FoOkaR0d0xLQ%2F%2B%2ByzQWfFdi8v4rTTTlMYRdBakEAIYEJQVdLaQVUhOmrFvvfee5QeSAlch1zfy0EHHaR9zEZ9EXrpNiX9%2Buuv03LoSPqZZ55BRBxwwAG1HgwA2YIkueSSS%2BTeeeedmrLma21jSDbddFPSi3Jm8UUYKkpk1113xXXccMMN3Y5eeOEFKg4KE8vVmjTNviPplk86CASBIBAEgkAQCAJBIAgEgSAwxCLQeAzi9n4HKYwACf13vvOdSSeddMYZZ%2BTQwWQ755xzpptuulFGGWXEEUeUZZtYOywjkS4WXHDByT%2B55p133rvuuqvaxGMMP%2FzwiyyyCFNL5tRTT20fnCFWuV0eQws%2F%2FvGPCeYr65FHHmHOf%2Fvb355sssnmmGOOa665pux61txwww1He2%2BLmX7DGKQNVS2%2B%2F0zLRRddlJ7Bc91p8Morr9TUwgsvXL1Tm9Qed%2BuF6SdLRxLk956bpi1v7dgWx9gYgOnT%2FLMir732WtMfZ5xxjGHKKafccMMNu%2FJ%2BdYvHYJN2nW5I%2BvEee%2ByxR3WKjrCJP%2B2003LhwW%2FYdmf8sjGhrc0RRhhh7LHH1uPFF1%2BsvE4vvPDCH%2F3oR94CdQcNQF82QxkeEHQ1Zlpd5LcHASwWeNn%2BjcuyZjiDeCmNo2tVCI3INvbdd9%2Fuy7UqPNFIcXEXXXSRBYNhqFo%2BASvTIn%2F88cc9efTRR60BTzxHHbSWuwkiEEv36aef9tD3wqOkS0FgS3AX2Ay5fn0LFUbGrQZ9jOriKNw%2B99xzKBEdFTPTbcTCMAa0W7E3nzYSjeQKAkEgCASBIBAEgkAQCAJBIAgM%2BQgMzGPYF8YJsL4XWGABhjb1BcudKJ35Nv%2F884855pjM7dlnn90uMHqBlH300UdnfbP%2BmFey2P5PPPEEEPAYJB%2FaYYbLnXnmmaXRGrWV3OUxtCzr4IMPVoshycwfaaSRGIZ2k9EmPDVKMM8u076SSAC7zDPMMIM0%2B5SNxkT9%2Fve%2F73bUUUctmT0KxVCFqsAAsBNnmmkmudos%2B5Rxh8Egn0CJkPTL%2BulPf4pSQEHwKXDr%2Bq%2F%2F%2Bi8zMgDFMC233noruma88caDxqyzziqIQQv5WG%2B88Ri1TV8PWZpiZcw999z69VzEDC3jWOzgIx%2BkzZF1TLUyyyyzuP3mN7%2BJeClXlKOPPhoO3%2F3ud72FyrXt3iVJdKFNtqohYXuqx%2Fz2IIC1sAB4bTTWgkzCi0BYdcNsVi0iDYu%2FcRT1kAcK%2FVLpN5AGp5xyioVUi7wKWMCYh1JrKIblwF%2B5sB9kGz0xUnxfvE5k1frxS2%2FTXUv8R7RQ8WxVtwitojYpdKIV3kNbefsG0OUxaJkQL%2BgaTAh9iO4MzMPWThJBIAgEgSAQBIJAEAgCQSAIBIGhCAE8BvOZ8YuXYOngBFyE7pdddplZOGcEG0DZXia%2F8JWMaywE64%2Fd97Of%2FQxTwa9EI%2FZ8mfP0D82AIjZgjLPgtFN6DAqNV155xS3PETvLiAjCBrddHoOEnsaD%2BN%2FzX%2FziF1poAgYRA9wScsg6%2BeSTpZdYYgl70G6NgY2PvsADGJiwFRgPMRXRGox9dqvC2IlSgDgPBZVh5AYjl48A%2BoVgQzuu%2FfbbT2E6f5MSZwD9Qo%2FB7GW0ljPLLrvsgrThY0LUgVsg1FdSblWv3355jPfff5%2Fu4gc%2F%2BAETUsBG%2FI%2F2IamKYQMHy2FvXWvaJPZgojJpAct2pjkhxqjJqoJFmXDCCbvmc%2FVrat5RM9K7Q0oaAgC0vDFFzdUI64X%2BQiWVvqKhhMjitUHSU1Rbe04e471bb55YBiK0eE38QVoBASvQCLfffrsXZ%2BVIe8uoD0yC78iX5ZtqhaVRE31jWVQBzSov5oZB6gtbqB2LoVXnrIRpsdjaE4m%2BPEZxL744IzEpRKIPxzrvmVq3kaSDQBAIAkEgCASBIBAEgkAQCAJDLAKsZjwGy51i4Vvf%2BlY7sUQsQWNmVk888cTkCpwaqCNYf8Jait6gFmaD6c2%2BLmoCY4CdsN%2Ft1ma0GALYA%2FTIbrvtph22lS7EOWw4IBnIJFAfnhB4yK04n10eAyXi%2BVxzzUX%2FIPIA6kDXZbzTY8iiq68GZXEAMXjRC%2FmVcMeYfvrpK0aBAtpUuKJzuGUSsumoKWyOM%2FGwBygOLZsaeb%2BZom64imiTPMPcS7evIuUGekSWNCsVbngMvgNue65%2BeQxb7eQlHFLMBdVgqE7PNAazM1M8EilLhWJg3poLfYihatk2OiSFZ2TYGqRfm%2FgNsZ6uczsAAugjzMCg8BiYNxRET1xWSwuzwXmnaD1vB033aTyGj4XyAXWApsMvodfwFcQbTX2hOjkHVuGdd97pO2bLQ5RRLEfJLRBu%2Bv1iPIZPBveirkmhRCw%2FHKOBNcqxb%2B95EgSCQBAIAkEgCASBIBAEgkAQGGIRKB6DNIKfvv1Ze%2F0uggFWvDHLteMs5iSrmUyC%2Ba9YaQ8UKB7j5Zdfrtmx8jAVDHCF28X9QW7xGJiNhgPznwqCEefJp%2FEYGrR9XE3RJzhoshz8VSlphIMyq0GDWXzxxVETjcdAFzTzsHxVbGdXYZNiThaPQfnAVaSNtiUUgIYNdERNU%2FILTWAYeAwIaIRhiMfoUfVXF%2F3yGMYjMEipWRTDSOBeWo8SAoFSU8gCDhjZsNgStwzYbrGWHozHtdQ0h7pfghyiI9FFevQYxBIM%2FDYdnAMVBOLIcmoPJbwXhEA7jxgRIV4KHqOiW1RJpBkChDcKrsOCt8IrbItcndapqaWrwbOJCGrx11vudmQAon1qx4HFFc7CekM%2BECNV8JYqTI%2Fhk0TBdev21WOgxQwSnVK0mMKWtIl40rfrblNJB4EgEASCQBAIAkEgCASBIBAEhkAEyh7nPdETB6A7VKYTk3m%2B%2BeYj2GBEO96RQcTloctjsLaQGGQDLCaWFOueqB49wgtDU8VjNA8RTwgPyB4EK5D%2BNB6jxsC0t4Eu0maFp6i4l8VjNEN%2BUHgMhmE12OUxEA74GS4bRiv%2BgIsQxcV%2BxKLQY3yFPMbdd9%2BNEcK3GADbFqFBgME4hTx%2BxnNij355jHJ1Qa0wPLuDbCEfa175%2FUwESBEEGLEmWwwKIpnNN998%2B%2B2378ZNpZARlYIfU9ddqFgLNIhGqiO5DiLBJHS5LLXwD15NRQRVvpEhvhHMmw%2BkmDF8iHgmmLSeYVvMViMODf%2FWeAZ1xUjhA9WNsEGoo4WKKdoa6ctjqKJT4qXGY5iCgeHH4oLUcEsiCASBIBAEgkAQCAJBIAgEgaEFgcZjVGTCnmHT1fNuKONaFgkBnQNXC6EebC7jMRzwUZvL1AsiXopQQctRjfAcIVcoz5HiMYrTqFxbySiR0kgwz6W7fiXsOEYi5xHih2YGaoTzi9AQjLuKj%2FEleQzTISbhV4KiKU%2BBGlsF5zRBcT4%2Fk8d47LHHqlb3t%2FQYtrybIcw4pd8wzdNPP13Jcpkpp5uqyN8By9HlMWy%2BV3XGctFH3S68uO5t0oOCgLfgbB3CnsZFcCyywrFJDU9lqCa8LE4o3TbJaaxGS64EEpVlkXPQ4K9Rt4gCTRHSaNa7s0qxHO3z4RuCkROgg5tJpcUX7epANGLV4TqQGF56tyNZpfTgsVV9WaWoOa1V1Jd66Lcvj%2BELMmVcTevL4b8WJ6eYxpO06kkEgSAQBIJAEAgCQSAIBIEgEASGcASKx6BJYHMJvEAhX5c9XJEPcQss6MUWW0wkB5YXm12EB5ddbLfcIig0HNjKPsJjzDPPPDwjKBnk2qFGOKjb4mPQZohZwX5Xkj7fOR30FbUZ3S%2BPATf6DS2IBWEk7DukCh6Dicda%2FEp4DBvZZlH0Av8Xw8ZmsBPFL2UzshMH4DGMgZhEiA8BQk2%2Fxx7EY%2FCaccqJmcJTYAT2rLlAjNuOqTmBQl0nttC6IIVsrDvJBZgVHwPBAklnxIDdkRbIlqmmmkpgT30REkBDHFS5zeGlrTEuBniSRiW150kUArgFJ4BYQg7sIMDgvoGXcNuNtEkbI3gm9ULPO0VZED%2F0aGDwFcKkoCMc42uJWs%2F4AdxdqTu8fSoaS0tsGS%2Fulltu0TLywdJSEU9lGN1XYzX64hAjVj7Fji%2FFu3ZZYL5Tn6fGtYYG0ZdQom5pdXo0FX15jOL9qEQcZ2zKlgeeUC9O3un2nnQQCAJBIAgEgSAQBIJAEAgCQWCoQIB9RKzOxOYSIlJEuzh9MKn4VpSZz6x2aCkaQTFuIHaKmYQcTFR0ggktATONleQWX%2BEoEBwFO90tU0tJFIS0y3MKhwq4IWQiww1KrHJZDExpYTalnQ4pLWSoCJxuKUCc9KFl4SkqOCEz03MjKZDtobPrUQc2zY3ZsSlTTjlli0tQbEyL82nKHEYULncAbAAlidYwCSqaoATPAmNz7KmJ1%2BEUOsIScMBht5qR23KH4RuCnWBs1kjqlwsJakibYASphGZZwezZKsBEJfX3XBwMkVQlwOWqA1xwHXPMMYeHsurAF7DoyBMjhIaETltr1SZ7FjEiq86a6Y4n6YYAcQJJhgUg%2FIXwFKgqVACioAogsrhHYRt6nDUq2IUViwdoTUmgCIp58Jk4vxVrQd3RpE1WmqNR8SRyHYmCSaCLQEeo6CweJekiuq0hN3wvhiSL%2Fwv5hwtdJnwHnkRfPjG5NBj6woxptq8cCNnFR6kWUmvcrEX8UNeUNWj6hx56KIqsFUgiCASBIBAEgkAQCAJBIAgEgSAwtCDAOGJqORnBnjLjqHtV%2FEDGjkNIWUyMI0YQCb0qNTuBCh2%2BwCayvWuLmURB8AqBDZlLZBj2rzmSiDVh79iJCWxAHdHMMxIxJ6eeemqLUYA0IHovKQJuQckmnsdFHHzwwUy2JZdc0vMm0ScCcVym3xoJE17EDLvYDFLN0vbb9W7cgjYVbnvuxk%2F%2FULvkVR37Ie6EXszF8yIu8BhUH9iP5tjClrTVzm4twT8uQkesVP12oytok4LC3KEKT12bwl133dVwq07JP1Rn20LDeDTrFbQZ2fdnw2q8GaRmASU4KI%2FG0Xu1036NShRWIyxPn%2FY8iR4ECGDQCKJDoMtwPo3EUEyWVUpT1CNysIDxTmr1NOXWOhHhhKYIrWfBkN90y6Ay5AptgS3BQtSrsTJ9SpaoT6Nb2Dqk0KCo8cX5QOpypomKBqak8kQdPjd9yS1KpNuCtKGq0g2jUQWsN2fCmjLa0LcQEqMHt9wGgSAQBIJAEAgCQSAIBIEgMIwhQIHAIut3Uszz0ie03B6DvScX3dHj%2BN8qflpClZ42P63kl3n%2BxXrpO%2F3PNYaB0eg7a08%2BL3qfazz%2FOYUh6eqZb89abbloKLRDjwCm5Up4KZ%2F2gcjVbLcvhBhqqx2%2B021nUNID9zVwC4ZhyQ1cJrlBIAgEgSAQBIJAEAgCQSAIBIEgEASCwFCNwFfLHX21rQ3VwGbwQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASGJQScpOCchXZ92nkNX2zKmv1iFb9Mra%2B8U0c8FD7dIye%2BzAhTd0hAwMmqDgvudyRO9HB67wDnegjU6bjV7nqwQv72t7992ufjvNSeg1y7%2FbZvsD30RGu6aE%2B6Cf3K%2FbRgoTrqOc611VVFxe6wW1YSQSAIBIEgEASCQBAIAkEgCASBoQUB9tchhxyy8MILL7DAAvPNN98iiyyyyiqrHH300b%2F5zW%2B%2B5BQuv%2FzyBRdc8KabbvqS7QxK9WY%2FOstyoYUWOv300wel1iCWOeuss%2BBjLvPPP%2F8yyyyzxRZb3H%2F%2F%2FYNYtxVrI2xPkhhcCLz44otHHXXUzjvvvOuuux5%2F%2FPHvvPNOGwkz37rdZ599dthhh3333ffKK6%2Fsl3%2F45S9%2F6RjWN998U8U%2F%2F%2FnPl1xyyZ577rnTTjvtv%2F%2F%2Bd955Z5cAef3113WxyyeXTl966aXWV0s8%2BeSTKj700EOe%2BB5vueWWAw88cMcdd9xjjz2s5A8%2F%2FLCVRFBcddVVhid3v%2F32u%2B6665RvuY888shhhx1mXno79thjX3755ZZlFtdcc40qKu61117nn3%2F%2BH%2F%2F4x5abRBAIAkEgCASBIBAEgkAQCAJBYChCgB202mqrfeMb3xhnnHGmmGKKySefXNo1%2B%2Byzv%2Frqq19mIugR7Rx33HEDN8JM22abbZiBH3%2F88cAl%2B839xS9%2Bsc466zz66KOVe%2FHFF%2Bt02223%2FbTd6n4bGfjh7rvvrs3RRx8dPuOPP770yCOPzBgcxAFfccUVa6211hegPgYeVXK%2FGAKvvfbaZptttuyyy26%2F%2FfZbb7310ksvzer%2F4IMPtEaeccIJJ3iy4YYbemhdLbXUUmeeeWaXK1DsT3%2F6E5YDB2IBkEyceOKJim200UYIBJ%2FSSiut1Li7t956a6uttsJ96cia1Okmm2zyyiuvdEduoeIc1lxzTYyH9IUXXqi8BWMAG2%2B88ZJLLmml%2Ffa3v1UFPXLBBRfIXX%2F99eUqI41pKYrsrrvuqt4xFT4oszDN6ssgTzrpJINcd911VVRdsxgPmpPuSJIOAkEgCASBIBAEgkAQCAJBIAgMFQiw0RhEI444IgPqvffee%2Ffdd3ECxWww9Lo7y6bzuUQFNnzvu%2B8%2BRt%2FAODAhJ5poollnnbXfje%2BB68pllyEWbr755ippP%2F3BBx8su69b93ONvFtRmiGpC3YffGzB2xCfd955PTnooIMGhS2xt64we7On2dx%2B%2FQh4XyeffDI%2BgeiCFU9KQWyDDcA1Gcxjjz22%2FPLLUzv4Cigf8HjIh7XXXruH0COcoFm6%2FvrrVXn66ad9LBZhVdEC9kOtUjugHfR13nnn6egvf%2FkL2cZyyy1HYtFdNtb%2Fpptuevjhh3v4xhtvqL7ddtuRbRgAig8ZqIXqC9GBiJBrEcp9%2FvnnSYOQFVa79nEXci1%2B35HbM844A5VxyimnGOSvfvUrA7aMq%2BKvf%2F1rSg98C%2F3G1%2F8K0mMQCAJBIAgEgSAQBIJAEAgCQeBLIlA8BoGB%2FdzWFBNpggkmmHPOOZv4%2FPbbb7dlbBt3vfXW60oLbPWeffbZbCJGE6X9Oeecw7B6%2BOGHNUV7v%2FnmmzOsqlmGni1sNqOLwsFWtec2u1dccUVSBzoH9iBjn4H285%2F%2FnLF25JFHrrzyytq0S%2F773%2F%2BeoccGtKes%2Fccff1xdg9T%2BDDPMgCXgFEPRgTOR5eHVV19dnfo1cvbdEkssseqqqzIkW3AAz9mPLEQtG5Jh2LPWe6vYEsVjnHvuue2JwU833XTolxdeeKEe6tqu%2BgorrAAiW%2FM1a1v%2FrMuZZprJCFEf9utr398YLrroIrMzHQVaI619CVNmbHLw%2BWL0TreppBsCXpOlZQk1tynvCFPB4cJLQbtZon5beWyAhdFd8Jg9K9MrLm8UBAie4cYbb6wq6DLOI0iDZ599VlrJNdZYowkwVPER4bW6y4wXiZXAH0oLt912m9ZoitoAfJWW%2FWmnneaJknIvu%2ByyllufHpqCC4nPR3eNIfFE1zgZ80JRquh7bBVxcZ5wS2lPkggCQSAIBIEgEASCQBAIAkEgCAwtCDQe44477mhjtvPLh2K22Wb7wx%2F%2BwByzqzvqJ9c000zDJJ9wwgkFAVCYTUelMNxww3k41VRTjTbaaJVmN8ktHYL9bum3335bfAnFNDvJJJNIIEmYkFT3Y4wxhlquscYai8LBtvUcc8yhQF2LLbaYaAZMObeTTjrp1FNPLfGd73wHicHe%2FPa3v01J4onRqWX%2F%2BtJLL3WLMdCpkdv7RpIMP%2Fzw3%2F3ud0cZZRSJvffeu9wE0A5KInA8n3LKKQ1ArpmqpW73Kh6jJtKeIxmUr4c4h1KwYDaMTbOGyjJFquh3pJFG8kQvM888M2D1vttuu3nCkQdoEjPOOOMzzzzTWq4EVYwsA7N73pOV2y%2BMADINBYGsaC4VxAxUDRQUNAxeDdaoaZCwDRakN4uUaD3iIrSAX6piFhgSD5PQCtBgWK5FfaDpUFt333135VJrKMx1pduFyBiET7%2F73e%2BUIfjBgBlka01QC62hHTzxWZGL1KdXBbBwniAo8I3IQ7qRtnr1hU7h1aUvq06zjbpRFynXw8%2B0HpMIAkEgCASBIBAEgkAQCAJBIAgM4QgUj8HWZg1Ju1jltA2MaL78tnefeuopxAVbmynEXCLI%2F%2BY3v0newBJkvtFR4BYEBGAP3nrrrdNPPz3rHplg1mQVGiGqlyZ1kKY9UIwlyPHf7cEHH0yfwC6beOKJf%2FCDH2jto48%2BYk4Kp6kRXvy2uZmQCAfDo514%2F%2F338SoVdoOiwwAYbkIZoDLwDzQSBm94WjZ%2BnSIHtIwKMDwj5w6APMEnlEq%2FhvSjH%2F2oBq8ixgPZ0tcRpl8eg1GpI4yEjpiuRmhb39RUZzwav1vxE5iQYiMgSVi%2BVP3kK1xgDBg%2FQ4bBej3iiCO0Q0Ni8JpqFz6HGACSqrSHSXxJBLwCgiIvtCkirHbLcssttywmQfuWipWMQEBiUErwQ2mF5VotuAjfgrSvAymhDO%2BSNjCvDPNA7eOJ9Wnd4j0IJ4TWpAPxTXVZEf4j1okYL41%2FaO1I%2BDoQLKoXl1XipVJuVDEKDTxGsRzdigbsMzHOFjemm4s%2F1CbyxNy7z5MOAkEgCASBIBAEgkAQCAJBIAgMFQgwn1lSTGkWPZ8IBEV5aiAWHKNgCtgGuYIZstoUFoDCPi%2F6Ar%2FhoSxC%2BjZTFiIjnfuGJ10eg%2B%2BGknKLJWBJsRwrZAR2gqxCWFEtq4XH%2BK%2F%2F%2Bi%2FciFgB1axhEMDbpEZcoEFYiCQcxlCb2igLmgoGXRWmutdR8RjVKQKhsvxiMBS2%2F85sxGNgGyqAgCyN%2F%2FSnP8XDkI608pXol8dgPOqIk4IyLFNmL2PTbr5db3vx44033s9%2B9rOyfw899FAdlauLMSNecCks5cLT9PWLJsKB9PSb268cAQtPAAqanEZNFI9hSTQeA%2FXE%2B4Orkc%2BBR1JXKqNWnRVSDIC3ecwxx%2BAxumWKx%2BAhYvB8OtAgvId4XWlQAsflYZsX9yLeHxRH7UlL%2BBzKl8rKKZbj1FNPJaIoCqWKWfY4kwru0SoaFVLOc59nXxIME2gK2un6kbW6SQSBIBAEgkAQCAJBIAgEgSAQBIZ8BBqPwfTm9EF6wTz%2F8Y9%2FzHHD4OWyuz3hMcEJgrbBL92CJzfccAP3DSZ54xCUZyJRJvTlMbTGq0KtySabrCIAiHpR4DD%2F9cuWL4qjeAx8Qtef4t5777XvzFnjW9%2F61thjj60dwS7KRqOIQE20AAXFY2AeNM44VfKee%2B6pjvwyUaeddlqhKhikDEyUi4AelUv%2F4NjZQecxzFHjAjxWdSETDcnsaoQEGPPMM08daGJbH4%2FBtFSSFelYWLdKFp44HIiBtLunX23m9ytHADlGjzEwj4GMeu6559AFeAMkg6NJuHvUSEhoBMVt8SswBs5UHUCPQbyBcNMdEYUGeVohEDBgxUtYDEQRTndtpEqbr%2B%2BO%2BgL1of0WIKWCdfToMfAVPSFkfZicWQiWqJtag5XAjWAdfYCWvcH35OY2CASBIBAEgkAQCAJBIAgEgSAwVCDAYqrzSuwjM3xsWDPzhW4oYTyuQC6Dfe6552YxMaxcKAW2G9UBQb7AFF2H%2FU%2FjMUAheAVlwlxzzcWK16BEUSX98hiiaFYgUBVtbY855phoFs4XwnGQQOiUefiZPAYNv466QRr1RWcikgb1RfEYBP%2F1mj4vj1HRP5iZqjMtDQ9o%2FGXMEb2DbKEq6ctjQBi8iBdsBnOy4UkkIDBjjSS%2F%2Fz4EiF7wcrivEv%2FoyBshDbKS0WjohWIY2gAsD68JM1BPOEmRavAWqVuFReDkviEYRavCScSXYtV5%2B1bCBhts0OJdUBNZw%2FyMSvshwIvvCNfR6laCUAdVol9rCa3Xcrkv%2BfS6OgoD86SReErKNR4z6tKA1QLmsA4xodPoy5y0XpIIAkEgCASBIBAEgkAQCAJBIAgM4QgUj8GybvYR5QCFAA08Q4ylxibCBvCbMBEWVjP0JCgNZDGv2hyd%2B9CjxxAWwM6voBCsLQnkA4NdeE8VK7hE8Rj8SjAJ2ik9RuMxiPBFGFC4glooYEud5oGx1uUxmjHY1WMIxaliiUNqhMIFjDvuuCI3Gsnn5TGackNTDFJuOIiLMmDr7NfWkbAGk08%2BOaKmy2OU%2BB9%2FwnNHgJHyROjBswaZ338fAiQQKAtLGn1RvaDLOI%2Fg35j5uDvUQff4GOsKn1Bvli%2BJaBXCctbCq%2BrWG9aCl1Dd%2Bih4jmAnuBpZJJbujjvu2DgTFSmFdGfNe%2FVCo4hT0defqNgJvlFG24WiBtNVX%2Fi4fAhNoWF58xFDlfTlxCx4EV3wZnxVLMJus0kHgSAQBIJAEAgCQSAIBIEgEASGLgQaj1ECDINn5iy%2B%2BOIjjDBC6ecZcbwkxKUkqJDL%2FrIrzdwjd%2BdRggBhmLPcZQn1IE4muQVph9uKj8HUwkUIFoFSaPvaEm4ZXIrZreZXghZgWmrctjglQ%2BMxmH60CtqsKAHGxhDjD9L8ShiGmJPS6rMiu%2FExkCeUG7xFyi8Aq1CUCH8B%2FdqV1s6g6zFIL7RgeOJ1UIPU%2BKGnKTvsbvE5BgAWLYOl6THgAMDKVaCidmByyrxVnj1rSF3rWJsMT3oVjjCquM31lSAAVTwDDYO1ocFaMJiKOmEHV2YxI7hKruB1ezU8Qbg1KcxBCUHR9aLy0JE0FgP6roQTFUITT0Jx4f3iIug3nnjiiRo8dxWLmS%2BJwugLUg3hNaz5yvVrPA43sbaRLeK0uJVblzR2QmvYv5JzWNWaIi8pvQf6hRyI10yFtala5qui32uvvZazibq%2BstZs5bbekwgCQSAIBIEgEASCQBAIAkEgCAwVCLDE68zQikxYY5YWS%2FP73%2F8%2B7gJ1wPhip4tFyYxaeOGFpQV%2FENCSuVfRM5RkGyIfZDHhi8eoAKElY7CJrEHRQRlT7D5Hr7qtcxZKgIGpQDiIuum0CKeKOL3U0RI1GGdEapP4AbsicIcuXOzNUjto3624FkbCsiMmcVtKDzvsJfwQudTI6zhXhEztxeMllBSFoHqhBkG2OFi2ryC%2FdB0TTDABsgUIarkYjDbcq67ZCXBhRhoX6KMKzDrrrGVvolaQQg5gtVfOzlXLTJUhQWmjkqjpVIN%2BhVEl29Ajdqg9TOLLI4AdchQOroCPkiWKphBHpZyYLAzSGjE5BZdwSg6vEGmiI68GF6cwHqzYvDaMoqEUUxEp4UQSrIj1jytQhssV6sN71xdKxJrRXWmHhK6V1XOeiDVvbKRQ2tHvAZ9cFCCoMMvSp8qNRV9Cdhge9kxJPCHKAr1G%2BFGBSXk8uaougsVCcvnGiTF8DmbRmjXg%2BhbadJIIAkEgCASBIBAEgkAQCAJBIAgM%2BQgwjlj9s802m2NJ22gRFGylmWaayWGsHlLUK4MN4NDhJBH7yJw7qjA2g6xCyEqBQMXQ%2BOEPf0gdUTEtUQSzzDJLc0hh3HG1UAwhgI7AP7SDG2wW60sWCwufwJRjc7WoAkxFGgZUiYp%2BWZcLLLAAOX2pLNiVgjEKm4n9YOthYHRaYSuMUGviKKolfgUmgR3XaApHRRhGcSlKslXNixHalzdwtqY2TQ2JIYFOEQOhG2GAlEIZUzBCjAroFv3kwloUejgWmhPMRoUEYa5yLsCZwBP5wwRulEih6pcSQwwNlm8djdGeJ%2FHlEXj44YetZ6%2FAShOEk0yitYnQOPLII%2FEVyAf8hkNtytL3OpT3lpEGrXAlfALWm2AsqlhgaKu2NhQmc7JivUfVUQ10IJY9qYYBeI5%2F6Lb2yiuveG79CAKDpqhLmzvttJMBKInlI93BcohaY3g%2BsaK%2FDBLdoRaFxv%2FU%2B3%2F%2FSxkiAi3vJ133NCvL3Htome5gkg4CQSAIBIEgEASCQBAIAkEgCAyZCNg4Zgqxp3r8GogZPOy60vPWZ0yx2roTYQOyoZhXDCIt2G6mTKgjQvAPWmg2nVoKoBFY8d2H1ZoWcCO0GUw%2F5INEYzmqgN6rgFtmoJbLp8OtBPOTbwudfA27XDaqol%2BtGXnP1nPP8Fq%2FGmkVK1HdacSlVk9uuzUkIzQRT6rxBqm5mDjipTsp3IVR9cRAaK1VI6bTfZL0V4UAYL0Oa6b7RlrjuCN%2BH15oe0JEgfRoHiLteUvQ3qhi6bYnLaEv5Jju2rJ%2F6aWX8B6NQ2slDcbH2O%2FVHWff4Vm0PlW999T1xCJ09Txvt31XextMEkEgCASBIBAEgkAQCAJBIAgEgWEPAewBfQJnCjE8KeRtJYs4wTuj%2FCmGvflmRv%2BZCOD6EAIYp6%2FK6kdz4SLQC%2F%2BZeGbWQSAIBIEgEASCQBAIAkEgCASBwYWALWCe%2B8I4VEQIv6JDPPjgg4NrPOk3CASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCAyPgoATxOUUVEHIwwRwGxiq5QSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAKfFwHnOTr%2F8dBDDz3k%2F38ddthhzz%2F%2F%2FOdtrco7CPWyyy476aST%2Bj2GchDbdFDpMcccY1AHH3zwcccdd8EFFwjEMYh1UywIDICAA1Vvv%2F32c88916JyRnDPWbqWvS%2FizDPPvPzyy5966ikH8vZt6plnnrn00kvfffddWQrU7dlnn33ttddat93yYuHq4rxPrjvuuMPRvd3cSr%2F99tv6coRx3TqY%2BLrrrtPaxRdf%2FPDDD%2Fcca%2BIruOKKK8466yyDfOWVV7qtORP5lltuMS%2BRau66666er%2B%2Fll19u83r22WcdwtKtm3QQCAJBIAgEgSAQBIJAEAgCQWBoQQDnsOqqq7YDR1pi5JFHvvrqq7%2FYLH7729%2FOPPPMo446ajPNvkA7zj0Zc8wx23gkxh133J122snxl4PS2muvvYZIufvuu2OvDQpc%2FzllPvroowMOOGCppZay7FdeeWWJ448%2F%2FuOPP4YARuKaa65ZZZVVlltuuTXWWGPZZZddccUVMQw9x63iPfbdd9%2FNNttMU1ZXVVl%2B%2BeVXX311rW288cZPPvlk4fnHP%2F4REbf00kvrSLNLLrmkir6OHrTPOeccuTgTz5Eea6%2B99jLLLKO1FVZYwRhOO%2B20v%2F3tb1UFE2JghidXmTXXXPOhhx6qrJdeemnrrbc2gNVWW01rEqZZfRnkTTfdpErNy6%2FxXH%2F99fk0el5EboNAEAgCQSAIBIEgEASCQBAYKhDAY7CbRhhhBBbWDTfcYCO4LmaObeIvNgV2n63kE044odlfX6AdO9FOdJ1tttkYkgbDHpx11lmxGezEng30fhu34a7wDjvs0G9uHv5nIsByp6PAABx%2B%2BOH0CWQJlj27Hj8AEE8wAwgKYgaiCNqGDTbYYJ111nnxxRe7cBFs4ARoOTxUTJn111%2F%2F3nvvfeONNy655BLUx957711L1LpFROy%2F%2F%2F4IPbUcUqxrA%2BgSCMQh22%2B%2F%2Fe677%2F7Pf%2F4T7bDFFlv4Hn2DWkblbbPNNhrUuL4%2B%2FPDDrbbaynisbbn4E5zGzjvvTHfxj3%2F8Y7%2F99lMSJYLBMy9j0JfPUMVXX3113XXX3WijjaqiX2N2W3qS7tSSDgJBIAgEgSAQBIJAEAgCQSAIDPkI4DHWWmst6ouylboD5nJy2223UWW8%2F%2F779ZzNRTnPOiOKeOKJJ8raYk9RPlx00UVk7VWMEv6RRx6hhWDN4TTuu%2B%2B%2BX%2F7yl7%2F61a8YWUgJNqNGfve731VhBdhrGmGmdXvHY4w11lh2lpvRx8pbeOGFUS509a0kg%2B4Xv%2FjFiSeeeOGFF0p7zqa788472XF4DFvhBtnGz%2BJze%2FLJJ6vy1ltvtUa6Cfam4fWr%2F%2B8WS3poRICXx6677rreeus17w8uIYQZaA3rEMNAMmF5t6lZsVgOYob2xGrkcoJqKHID9YepQF9UAV8TPgEZUh4fHKM03lRJGAYECD8plEVr0Dq3yG%2B88UZPfDIGcMYZZ7RcXWtfj574Qqk%2BeJRUrpHwt9I%2B1uL1118nscDJNCeUp59%2BWrMoFCITX7EhcfVqzZ5%2B%2BulID99de5JEEAgCQSAIBIEgEASCQBAIAkFgaEGg8RgE7T1jxmPsueee2IBNN91UMblHHXWUWxYTM58uQnqqqabyW9fss89O3K7Y73%2F%2F%2B7nnnnuCCSaw4YvKmGeeeRQYffTR%2Fc4yyyx07xK8%2BKs7JMN3v%2FvdSSaZxK5xdwDFY7C28BLt%2Ba233jr88MPb%2FjY2DxEaU045ZfXud9pppyWzp%2Fafbrrp2sMRRxyxLFNW5GKLLdaeTz311LUL3xqXYAayW5XpUiXdAkkP1QhYbJtssgkNQzmSmAv2jACDbgfHhXyzLIsNq2kSXVgPBAxt1tY29w2MQXERWAVL1FptBbSAbSh3D5IkH0tzM0FuWPw4k%2FqaVLGMjz32WN%2FXO%2B%2B84xbjgTlBrbTWLHgDQLt5gmaRRkq0XOyfvjwxLzExEIYtmkeJRqiY8DNYR0QHErJVPOWUUwz70UcfbU%2BSCAJBIAgEgSAQBIJAEAgCQSAIDC0IFI%2FB2D%2F11FOZUfZ2XTZzS3NONbHQQgvJvfnmm6nuJ598csRFxdvcfPPN2fvTTz89W8%2BuMRG7WzvdqABhAeaff36F33vvPTwGEYWs73%2F%2F%2B1tuuaXohTavRxllFJvFZc1p2S37jsHVBa1fHsM2%2Bve%2B972f%2FvSniBS8xKSTTjr22GPbv37ssceYlsbJrCMLIf9gmepU16T1ahmGLhQwBtvQRx99tGgbiJeeYAXGwK5cYoklmu3ZHVLSQzsCRDhIMOxcI8escLyEVYGg6JmdtbHddtvh64pkqNz7778fNWGBuUUaEAK5rdAWVcDyxjZQBLnFZtBLkAYRI1lRBx10kGXfZc98ZRtuuCE5U%2FFy1UL75ZZ14IEHknAUs%2BHbETEDWdEK%2BHYs%2BL5xbEg1ECyEHAq0wj43FI2vmNyIc8ouu%2BwyiKFmWgtJBIEgEASCQBAIAkEgCASBIBAEhgQEWDdE8kx%2BNv5oo40mOGddDKgaHsNtookmoqMgZuDTUaJ3FpxNZLdNco89EL9isskmw3LY2u7yGAsssMDEE0%2FMlKsG6SVEAcVylPaePaX3psxvmPTLY%2BhFxIxpppnmgw8%2B0JGoAldeeWVVsd384x%2F%2FeIYZZiiHESEOhhtuODvvlct4xJagWVr7%2FAtMWQyE9qQl2qZ2e5LEsIEA7osJj1hoPAb6QgwKUSmao1PNVAFeIdg5C6x5NmEbjjzySCsfQacY1ksZPEZXQdFVTSjAiYmrCEoBuSFBodGlLLiloNfap9EF2SIkw1ALwViMn0SPM4jVq0Bf7dADDzygWdPsukcJd4N7NFohQH3yjz%2F%2BeLe7pINAEAgCQSAIBIEgEASCQBAIAkMLAo3HYNrgLnj314UHaFM44ogjUA0ue9mEDZ4z0FhzeIBuVI0dd9xRGVvAPTwGToPniCiI1SCrkIWFZKCEp9yYY445uIH0jSnaL4%2FBC%2BAnP%2FkJEUiFvDB4ZqM9bqKReeedVzwNHdlx1hETTxciKFan5RGDP7G7bYvcb0UNZWZWgfz%2BJyDAvULEy8%2FkMSxvKiMkBtaiFnyBQ9iDChNcopguxUSoGIDHwBX4ZCg6eIvwN0EjYFFaVApyi3322adcWvqC7zvCWmDq8H6V2zeoRb88BuGHQZJ59JzKiugTHMZIyFHoOohDuixH3wHkSRAIAkEgCASBIBAEgkAQCAJBYMhEABUgzudII43UNz5GG7CQnhNOOKFYoC1UYOMxRCZsxRAgeAyqdcEHunoMaQoKW%2BGtJG087YdDExhiyBAb4hpsuZXol8dgmvEl%2BdnPfoYqYQZWjA5PkCFzzjnnOOOMw%2BukXx6D14mxITpoS8zFRTrCR6YbU7FnALkd9hBARFh1e%2ByxR2MnyDAcAsK1pDlZ4NnIJHAISIYetyPxKOgcfA6FDDYDD7bSSiu1J54LeEsj4WsSQEOYTYxZ81ESGhSPgZqwepWk4tBaHSnSAzWHFGeR8HZBvLQsTAj%2BofudUkPxK8FOtDK%2BMgQjpq4CdLTn3YTPk%2FOUQXa5ym6BpINAEAgCQSAIBIEgEASCQBAIAkMyAsVj4Ci6wQy7A2aOFV1A3oCRqFNFGo%2FR1WNsu%2B22yog62FeP0cNj2AheZJFFxh9%2FfA2OOeaYXUOsdd0vj8HrHx2B91BMLfSL3fAKX2Cciy66qOid%2FfIYJSlhmSrM8cQlwTuAIKT1mMQwjwBtAxcSUS%2Fae68IFbvtthtarKbPBQkPQCZRziMNE%2BY%2FJcNOO%2B3UYoTKItvAJHS5hdNOOw0HgtnAgaAUtOOQlGpE6Ji99tqLIKTUR%2FQVaI0Kjdt6kRBtQxmCip4sLAr%2FFBxLK0yMpK9GR1jSBEjkST3fMiKFd4w13yoK34HH6JdCaWWSCAJBIAgEgSAQBIJAEAgCQSAIDJkINB6jWUM942QriZghOIZtaxwCTb5taHvWbDRHhzQKgmn2gx%2F8oPgKNAWCosX5lO7hMXQh0qbWXD%2F60Y96TlytARSPYbO7xRMQqcPpJDQVZTYKNaC6dqq8rfaZZprpO9%2F5TvEYDnpAqnB1qVyBGd0yOZtRycxEudSpE1Wmfs2ubdZ3nyc9DCCASeA8RQXRInMi4ogcnN%2FhvZsgTxDcAiFE99SSmrjgt%2FgNn0MXB3oki4oqo2JooEcsOZIP9Ag%2FDqzCRhttJF1VLDksiuNRJMg%2FfFBkQobUbZAAg%2FtJv%2FErjM3ngJEr8ZJVesABB7QzXgX68G2i9VrImtasMYuJQSjSnpRopG%2FJViCJIBAEgkAQCAJBIAgEgSAQBILAEItA8RgIgaWXXtresa3quogrMAn8OASjIJywpctQcsAHGgFFYDp1XonQFvgEu9IiVGhEC%2BxB1tx8883HccOONmtLGr3Q9StR3akoCqhiK7xfcPTuSJEpppiC6adZWgt0Ct0ISXyZnPbNOacowNP%2FsMMOE%2FtCa%2FQYdZyK6gJ7CqYhgoEzKO2hE%2BorsPjiiwufaNPcoSfjjTdeC1ZQY2AhMgZNeQBZfr%2BjzcOhBQHMAw0DSY9lfOONNzqGFa1Rrh84BLeECoceeqgsHJ2LCoK8gcQI10Em0bOMuaVQaGhQ3AmUiHXoO7LkLCTMxoUXXuiWG4u%2BLFdyILc0RbJwcUiJ%2BpQadPgNkWmV8VE4LLgG4NdgdGQNc93Culi9PLOOP%2F54Q9UjLk5UUqE8BPTwsVx11VWtLicv%2FJ7QNNQd1j%2FPFGSgrxUh4%2FttBEsbQBJBIAgEgSAQBIJAEAgCQSAIBIEhHwE8hu1jhIA4FfQV7XLLXCK6QB3YAq7tZvaRGBQOM3VciD1rtMAEE0ygioSzS9hlpayw14x2EHKzeAzpGWecsevpDxbGF2MKNVEnVPYF6pFHHsFR4CK0r3ElF1xwQaL6IjGUt5FtP9rI9e5CPjhUBfVRtiHDk8C%2BxsacVN5gbHNzRanyomoQ9vfshru1Ha8vlmPfIeXJMICAV%2BxwHEY9EsDldZMllMIBxeG5iBaYDcKGuvAGSA9iDBTBwQcf3LNgAIKRQ%2FopxukDoXHIIYc0fRHmwSEj1jnNhr4sSOe00mmQGOEfMAmtZAGLcBMXVO%2FG8D%2F9r%2Byzau4nPiK0SesL3VFOVc7x8T3qSEW9tLrGU9%2FXo48%2BavxVsWbUonYMA%2B80UwgCQSAIBIEgEASCQBAIAkHgPwoBFhxHDGaOwx97LlvPnlCzt4AASA8GHYaB%2FUUej%2Btg7wsFcPnllzvqsbljKPbcc88xlDzRfqWbQ0fB63buuecWsZPMo1%2FAmXu6NjCXLmwra7ZvSaafcydtdmtHBABjw7FUMS049MGWdLMW2Y%2FGrzw3k0%2FbjFZdmXYuZ98e82QYQMByooggTugGwaAjEoqz74U9wOAx%2Fy2zfufOkeqxxx7DGFiNPUQHAlBf5BPUGnQRxcJpEOGARmukXDXrQxMTo%2B8AfKHt85HwORiJMKHti8MK%2Blr7VvSkfV%2BYPd40Bum3fdH9TicPg0AQCAJBIAgEgSAQBIJAEAgCwx4CrDNSDTxGhar4XBNEEdDP26dW3eb156qbwkFgsCCAi8CqNTLhS46Bk4jW2oGqX7K1VA8CQSAIBIEgEASCQBAIAkEgCASBz0SAyoI3CgeNT%2FMKGaAFWgg%2BIOqKaNE3muIAFZMVBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBL4AAPbx4m0sssUQ792HQGyGw507Cc58Uf9BrpWQQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEAS%2BJAIV%2F%2FNzNaJKTwyBz1U9hYNAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAn0REGTGgSOOGvkCoqa%2BreVJEAgCQSAIBIEgEASCQBAIAkHgPwQBxpQDHO%2B%2B%2B25niPSdch1P6cTGvllD1BMnZjpx9ZprrrnrrrsSO3SIejVD2mB%2B85vfONu0nUY6GIfnGNY99tjjnHPOcRzwYBxGug4CQSAIBIEgEASCQBAIAkEgCAxdCPzrX%2F9ae%2B21RxtttNtvv71n5LaJ11lnneGGG%2B7SSy%2FtyRqibm%2B55ZYf%2FehHDkCp61vf%2BtZhhx1WR2SyEBmtiBob3585ZpTOyy%2B%2FLHLpxx9%2F%2FJmFU2AoReDiiy9eYYUVbrrppsE%2B%2FhdeeMFIDjroIN%2FgYB9MBhAEgkAQCAJBIAgEgSAQBIJAEBhaEGBDrbXWWiONNFJfHsMUTjvttEUWWeTJJ58cYqeDdphkkknGGGOM7bbb7oILLjjiiCPqUNf999%2FfmP%2F2t78xFaeZZhrCks%2BcAoW%2FU1SmnXZaBuZnFk6BoRSBiy66aJlllrnxxhsH%2B%2FhffPHFVVdd9dBDDw2PMdjfRQYQBIJAEAgCQSAIBIEgEASCwFCEQPEYI488cl8egx7jT3%2F607vvvvuPf%2FyjzYjO4dlnn3388cfff%2F%2F99rASmsIAOE31vffea1l%2F%2FvOf33nnHQeUSOAcnn766b5qhw8%2B%2BECDmu2b9bvf%2FQ6LQlDxab4tBx54IBnG0Ucf3XpkHk444YSTTTaZBl955ZX%2F%2Bq%2F%2FGnvsse%2B%2F%2F36uBC0QAScazbq0XxV1%2Fdprr%2F30pz8dd9xxb731VoVpOfggmEs7XYWoAxqqtHbUKjQ8bwPoSXz00Uf9%2Buz0FMvt14MAHmPZZZft8hjIrtdff50Up%2B9rqlUhq%2BuH4qGSTeHjDGK5VkXXPcStWm%2B99VZfjuLDDz%2BkEbLmn3%2F%2B%2BfAYX89LTy9BIAgEgSAQBIJAEAgCQSAIDEsIDMBjmCaRA7XDHXfcUVOWmH322UccccThhx9%2B4oknJn5o1hyjbOmll6brkMWzQxZ5g1qnn376%2BOOPT%2FLB9WOEEUZAmMw777xN8IAhQUGgHdTSrMZxCNUX85A%2Fy%2Fe%2B9z21ZM0000z33ntvZXV%2Fd9hhBzzGWWed1R6iHfbaa6%2FVV1992223HXXUUbXMNWaUUUbhI8PSNN9TTjll8skn16xr6qmnVhcvQctB1KGkyyDXWGMN%2FIONe8PGb1Tj9913HzS23nprY%2FNELI7ZZput0BhrrLH2228%2FFm4bRiXQIHPPPffMM8%2F89ttv92TldrAg0MNjPPHEExb5yp9cHKx%2B8Ytf1JK2JO65554tt9yysjbYYIMrr7yylvRll11medx22201fgzb3nvvvdVWWxUNYkkrue6666q4yiqrHHDAAXi8Kmllat%2B3IMvvjjvuuNJKK%2F385z%2Fvy3UMFmTSaRAIAkEgCASBIBAEgkAQCAJBYKhAYGAeY5NNNkEF3Hzzzeby6KOPfvvb3x5zzDHZfYcccsiMM87I5D%2FuuONk8dpAQSAxtthiC3YZzw5Z5513nqyTTjqpwlbMN998ai200EJuNatfpiK6A8%2Fwwx%2F%2BkLoe7TD66KMjFqgvVLzqqqvc4jFYgttvv71h8Ph49dVXZXWv66%2B%2FXtZEE0105plndnUgyvzyl7%2FcaKONZCEx0BpnnHEG%2FuH88883gOqR%2BTnBBBN885vffOCBBx566KHNNtsMOWMWrEv0Cx5j%2Fvnn55NC1FE9ioYqkMiGG27o1sPvfve7qI%2BddtrJvGaYYQbNHn%2F88U2qUVUMCW%2BDDAmPUYAM9t8uj0FFY8VaGxbPJZdcgosg1bj22msNkpoC8YWOsIxVsZA4KBV3ceGFFyrWImwQGu28886IDjyGty%2F%2BxvLLL4%2BjuOKKK4488sjlllsOwaWMNlWRpaTYnqeeeioqQ254jMG%2BJDKAIBAEgkAQCAJBIAgEgSAQBIYuBAbmMTbddFNkQmkk7E3jHOgWaoI0FUiAWWedlY3GFkNcoCwqi2x%2ByimnnGeeeWxtEz%2FIomEo5xR2PWZglllmYfQx7aWxAU3wwLjDBuA0%2BLPgECaddFJNVZtUE7LwHj3w2iJHI6Ay5GIhCPUZpM0RxgAWW2yx8cYbr%2BJjmCxWZMkll6QeqXZOPvlkFbXgVq7CGim5CHHFAgssgEjp8hiICyatwvbcVUTpVDtOS%2BG9wtStLft6WL%2BcDlAi3SdJD0YEujwGgQ1iAcFV47HYCC0q8CYSjBoHKVFZDz%2F8MHbL8sNUaAH%2FoEBlWf%2B77LILdsuLJr1Yf%2F310W4O0KlcC8yqsDyqGMnHc889V1n0RdpMfIxCI79BIAgEgSAQBIJAEAgCQSAIBIFBRGBQeAzb0MJizDHHHIQWTSSvIsbg7LPPRk0svPDCDHxsALuPlYdz4AaChcAeoCZ4Xpx77rk1HiJ8%2BoTppptOfIA777wT%2F7D55pu3odofP%2BGEE0TqoP3gqYEkUdH2t2ZZl9pZb731nCrSyleCacnFY80115xqqqlwCy4eLkU%2B6G7RRRfFYzjjUmElafvFLqAwQbAcddRR7ErlHX8pF%2FuhMB5DyAu3A%2FMYLFORNAhUnI1iImxYzIxLFzWq%2FA6ZCHR5DKFUiCI23nhj2gmRW5APCDSXl%2FirX%2F0KyWBxOswX8%2BBh5ZqUBdkvj2FdUexgLeh8cBTcUvAkJ554osJYLwIPS1Q4Fx9OIWOJJj7GkLlIMqogEASCQBAIAkEgCASBIBAEhmQEBoXHQCyw0MWUQEH84Q9%2F6JmOJz%2F%2B8Y8%2F4Q%2F%2Bvx%2FKDTa%2BDW4nnuAfWvwKxAIHE3wIHkP4CxUQFD0NunWUqiAV%2F19zn6S0wwWgaS361kKDkItoX3HGI%2B4FF9HlMVRBaGBdFNAaJ5HqZc8995Q16DwGO1eoDRyLOX4ytG%2FgbQ4%2F%2FPA67LXvwPJkyEGgy2N4iXw9SClIL5ZaaqltttkGoVELDN9VYS48R4sJw3LDDTcUBTEAj4H0sPCceuPXxf0EreFWWAyRbBEjNEuN6cp5JUPOqshIgkAQCAJBIAgEgSAQBIJAEBiKEBhEHsNm9Pe%2F%2F31BMLouEnwoGH22oQknRJkgob%2F66qvFtXAJMsBCRHF4%2BGk8hjMjZBWHUIiJX6FBJiTmBBPCtYQtWQ1qmZEokEXbzlZF%2B2QVuqjYjNWIg1TwKoKLklU4iqLLY6jLO4afy6677ioKh5Mj6EkQEZ%2BmxzCAblAOO%2BzlV1JxPnWnBWwMlYiwHtqx295XLlKjyu8QgkCXx6ghWcAC2Ir0wosK86BAe4noO9KdY445RlALvIRFqIoC0hU0xi1qznJChmhHtBYtHHzwwZQYVouLMEOa5xS9B%2FWFptriwfJFjzGErIoMIwgEgSAQBIJAEAgCQSAIBIGhCIHGY%2FR7GkjFx%2BBXYufatvI444zDn6Jmhzqwv7zEEkvwKyGYb2E0KhfDwL6Tpqvvy2PwK8E2kOuLsLHgggs2FsKZrSJwio5IhE%2F%2BgTbpngDSN1QmUgWFwjmlQoNW12gQ0UTxKnbAtYzHwGmUO4zy2hS7o%2BIuKk%2B%2F0cNjOJGkgnKou%2FjiixuGffNqWZwQEg5uCEzR%2Ffff32El%2FF8qi5XqpFdnvLaW63l%2BhzQEisdAshkYDkpAToutBolt4Ge02267Wb1CcYp6UWFV5Dqid7XVVsO5%2BRBoNpAV1113XdXiciIgRsXHsIB9FHiMyqpfZIiEz0QgWYFiuCDVc%2BvfgSaJj9HFKukgEASCQBAIAkEgCASBIBAEgsBnIoDHYLsRPzDfLr%2F8cpadS%2BALO8s0GCwvBAUvD%2B2Q01MykCg42oNrvyAAGADhBVh2JPeO%2BeAtohbTj9BCJE9qfCxEv3oMPAYfEIQDPxFt7r777hoUSrE8PtiJ2iTy174zI9ARDEyxLBAFWJHujEj0PVfMgSCGzTBELDg8AnMimgdrVBe2zhEdrFfshCc8CBxfwtLUrACPFVKj9BigYIQq7DnugtqkDnXVIJrC%2Fvucc86pL9yOMdQ5LJwO9PjWW28ZGKBU74nzCQGMh%2FaL1ekOPunBgkDxGJao3i1Xb1DoV6%2BbtAY1gaxDLHiJQrJgsQSwtfZwbmqJCGqxWXJEQYqVpEdFh9S4dQQPlszrxnVU7FAfwptvvmmdoDjwGzQeDsGx%2FBw0rNZTTz217777Wpw5r2SwLIN0GgSCQBAIAkEgCASBIBAEgsDQiwDj3UYz87zn4ijhkFPWGYqjjmbgo%2BGQSreYB6yF8riC2stGOzDHaBU8rIgTDu%2FgskG3wMrzkAVXEDHn55prLuxB7XQ7GYSqQQHMg2Y17vTVClCA6GBjyvKwuiOlwCf0QK1BPIMySo4wwggakeACI%2FJnlRS2whPXIossQmJhLkQgbqsKqYY0PQnGQ%2Fk6MKUKa5n56dhWt1puVRxIoSRKhK%2BBh3qsKZtU67S69gtDLUCm4o6250kMLgQocIiIMBgG4BUT%2F%2BAWVl55ZZSaBLEN6YUspAQvIVwcyYQPRJbFX0IdkhtBXSxOtJULa4GOsCowISoq4xQbFfmMqKuiSLAlycCHkH84Lkd3qA%2B5imnKNzi40Ei%2FQSAIBIEgEASCQBAIAkEgCASBoQ4BFASNPTlB93JoyHnnnVdxAwSgqMM%2BTI3BxcfE%2FjXq4Pzzz1egO98HH3zQkSU09oIAlDEo94knntBy4x%2FsdFN9aJwav%2BoyGDVld3u%2F%2FfYTFsN4WpvMzMsuu4xag8JfUM0yBltuS9QWufgGurYbrjUa%2FpZri1xd9iMDtgxGR1HQY%2By0005K2hYXiZQCBMWhikCdZCeyjLCUFQqjaLRsY93cyUt4l1SIA1Xs4O%2Bzzz5kG3L7ZSo0IqwHrUiRM21USQwuBFBnSAwqixqA92Ldet0oLG%2BzHccj18oR2oKDiRXiGxGZto0Zi4UQ85xsA9ll3VoVLcorzxESJtydhafx7qu32omXVLQqOENplrqpheNo7ScRBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQSAIBIEgEASCQBAIAkEgCASBIPAFEHA0qhNXHT3Z73mRTiN1QOqf%2F%2FznL9ByqgSBIBAEgkAQCAJBIAgEgSAQBIJAEAgCQWAQEXj%2F%2Ffcvu%2ByyZ599duDy77zzzvzzz7%2FUUkv1S1bceOONM8444%2BGHHz5wI8kNAoMdgZdffhkd9%2Fvf%2F%2F6FF14YlMH86U9%2F%2BuMf%2F9gt6QnirvtkiE3%2F5je%2F%2BT%2F%2F5%2F8MCcP761%2F%2Fev3117%2F99tsY0X%2FfeN57771GtP7rX%2F%2Fyr1a7bZ16fSjZdvt5E179u%2B%2B%2B22qZl6vdDkrio48%2B6juqVtGwf%2F3rX%2FdbwKuU1Up%2BsQTCeYCK%2F%2Ff%2F%2Fl%2Bz8ztAmb5ZEIB83%2BeeWIH9Pv%2F3PYSS996W%2Fd%2F%2B9rd%2B%2F5%2FVBvDPf%2F7zscceu%2Fvuu%2B%2F5n0v66aefbq8A5nJuuOGGBx98sN%2Fp1Ct76qmnXnvttb7Q%2BdfmxRdffPLJJy281qnhKXzHHXf4X%2Bfjjz%2Fes4SsMQO45ZZbbr311ueff%2F6%2F%2F%2Fu%2FW8WvIWEwhvTLX%2F7yzjvvfOONN%2FrOqO8YegYMkG4ZS%2B6BBx4A4H333ffBBx90s75MGob%2BGf8yLfwn1LX%2BuwtvmJyy%2F6f4n4t%2Fgnw1Fm379k1Wludf8xc0TIKcSQWBIBAEhkwEjj322G984xsrrbTSP%2F7xjwFG%2BNZbb00xxRQzzDBDv%2F9PvPTSSzWyyy67DNBCsoLAYEcAX7f66qvvvPPOO%2B644yqrrMJSGHhI%2Fuq2qh955JFW7M0339xjjz38tiefKzEoRsGgN3jllVdeffXVDKV%2Bm%2FX323777dfMsUFv9t9RkhWDBfXvDCPx39G%2BNhl9Xk3jSe66665VV10Vudr9Jwtt6%2B3ff%2F%2F9X2wMoD7ggAOOO%2B64qs4EPv744zFjg9ia12SQhx56aBl6%2BDEWd09dY1txxRWPPvroHvZMX6effvree%2B%2FdU%2F5z3T7xxBMGXFUY7y%2B99FJ35Vgq55133hegoy%2B%2F%2FHJvFiw9BqwP56ijjvpcI%2FyShaF0yimnQKktA7eM6AGaRWptu%2B22Fme7FllkkWoBIL4v%2F2Isu%2ByyK6ywwjLLLLPRRhvBrdsa02nfffeVu9hii5155pndLCYkZDbeeOMlllhinXXWQVxUrv%2FVnnPOOSuvvPJyyy23%2FPLLa3y33XZD8VVufbb6klUFYDgwFdPt9Eum%2FYu3zz77VO8Gttpqq1111VVdw7Bv%2B4gvCHQHbPX%2B5S9%2FqZLWwCabbCK3ANxggw2%2Bqn8Bbrvttosvvrh6QTGhm7qLue84PRl4Iv1WGaof%2Bgqs%2F0cffdQsPvzwQx%2BCD%2BQzZzSE%2FC%2FjM8fZCqAZl1xyyU033dTnaaWhMloWCtH%2F6P1%2FYWD%2BtpVPIggEgSAQBIYiBPyFT2WBgphooolsGA0wcn9lTT311D%2F84Q%2B7RkEr76%2B1kUYayR9j7UkSQWAIRMDfuttvv%2F0222yDvmNH%2BEt44EGeffbZiy66aLNc%2FBnMWGPvXHTRRc1QGriFnlx%2FePub%2F6GHHhqYNuyp1e8t82GttdZiIiEr%2BrZmqOxlFsRn%2Fm3fb%2BNf%2BUP7zv7ORCwMogzm8w6A3bT22mujp9of4XpkhW299dbdzbhzzz3XH7rw%2F7ztV3k8GMC33HLLkmRcd911jNzXX399EFvztzQQVKkV5Z%2FNYlqYGK0FhM8aa6yx00479Vgc6lqx66677nPPPdcKf66Ef7q32morq0IthAxkNIjWaIi98sor%2FuavAp%2BrZTw27sU67NqJ3gig9t9%2F%2F8%2FV1Jcs7L1gDCD8zDPPaIrhxhgf%2BHWjkjbbbLP111%2FfxH%2F%2ByXXQQQeZkQ%2FHawLR5ptvjvAkUTjjjDPQNV4N9GqcHq633nrmvtdee1144YU6bZ9bMSpLL720b%2FCEE0646aabWi12FoLCWmXRszFZ%2Fdakf1i8CGvVP01qHXHEERgABpru5F577bWt5S8J0QDVvb5TTz1Vd7gsAyPJYBj6rAb4Zk3T%2BA34yCOPVMWAzQvm9sd1hJOxBqznK664Aodmwfskt9hiiy%2BvyvDWvOgLLrhAL%2F448V3oBTs08OyAbLRe0xf713uAxofMrNtvv90Chrzh%2BX%2BZVYeya3xav2P2r9kOO%2ByAvutXetRvlcH%2BkMLNvzMYm2uuucbgX3311SbU8QlbGxtuuGF7MthHmwEEgSAQBILAV4WA%2F82NM844o48%2B%2BnDDDecvuL7N%2Brv95ptv9ncg0WwPj2GzySaIXIy3PydGHHHE4jFsbyns70l%2FLdh8bH%2Bi2wm1Q8pyJFXt6cj%2FOj13%2BR9QN8vfdZrShb%2F3WjvdAtIff%2Fyx%2F0337F32lMltEGgI%2BHum1pJV1zW7WoFu4rTTTmO3Nh5Dlj%2FFF198cXv6Xeu4W2WANMuO8bjwwgv7y%2BrLr1h%2FvB1zzDFrrrlm25Tsdu3zZIAwGb4G86fb7wBp%2FzJ8YRtcs8xw%2F0SA3T87ff8Ul8uQ8V6aVa4K2YyrOySmqG3ugQ3bbvmeNB6M3cpMqyVx8MEH63TQeQzWE3mPrUMGl3GylxmAWugxGYjf%2Bk7Q4tluu%2B2sxrIQewbm1ou%2B9957WYjq9is4YY2yuBnICuuCgal3ll1bIf4lZ2b2%2Bz%2BCvt31PCFK8S989yGstH%2FIIYd0H%2F6708xqKOkXEaEvCpaFFlqoq6fqOwAjxwfCoSfLPw6%2BL0Y927yyrD2vDETlhkkj4X95OBP%2FG%2B2p61YtaNsI7vk%2Fl%2FfOlNYIDqRq%2BZB9p14HosNylYUKaAIM%2BwvIJS%2FlC%2FyD03dU9QTtaZFYUT0FfKGGgdVpm9dYNcZvv%2F%2FCVF3%2Fy8bF%2Be5aa%2BbFdsaEAND%2FuH1uLOjWEZzBMrBCphUeIOGfAp8h7kgZkhgyDy%2F9kksuGaCKBelfS%2Bth11137REODVBrqM4qur62qE488UQsHL6uvdx%2Bp%2BZfSNS966uSzfTby1f%2B0IdfH4iPyKoogqt68bfooEvmvvKBpcEgEASCQBD4NyHgzwx%2FL%2BEfbMGIbjHXXHP5g6r15f8L%2FuIdddRRqTVc0003HbrjRz%2F6Uekx7Ib4a6SyxhhjjOmnn17a32yq%2Bz%2FI8MMPP%2B200yrvYf09aTvpe9%2F7XpWfcMIJTzrppNoQ8b%2Beww47bLzxxqusb37zm%2F7PW%2F8%2F8jeV3cMRRhihsozQllYbXktoSgFS2PbXeMtKIgh8SQTYQT08hj1KAnKO3l%2BgZX88M3noMbp%2F2H%2BBdrpVGI89zvWVi8dgcX8mj8EMIYbvCnG7jX%2FlaexNXwt9UHph%2FfnSvQv7bgw9Wv2mw6%2Fq%2Fl1ipPTwGH1bNtkvw2OwSXUN1fpn0A7%2B5%2BIxjIfDgnEWccH0Q0qwyPqOs%2B8TIQj23HNP3BQKom%2BuJ8gZlt3%2F0959x92SVHXbJ0uUnKMEAckZJWfhQbIEJaOIBJEgGQFJkhEUJahEFZAcBCRIRoICKqAgDwiiBMkgKvq%2BX2a91qfffZ9z5swwMwxw7T%2Fuu3d3ddWqq6q7a%2F16VW3vW2llfM%2B9zjWZgoM5Oob0ug2JeDux5bvRMfaaxG1kz4F1DM8gTtYRe%2BvWBzj%2Box78%2Fu%2F%2FPtnwwDoGSYonTlnSHFy8JRe4Wj3LPJ4WInYKX5F4gj3e9a53eQh6C%2Bxa06DTJYaDp6eKk0coHvY7urLVk51Oi6PAT2KShVajHnjkEfmdaL6YZHPUHo2up60c9qI%2BrHvcxHj9RFr8t%2Bdy93ROBqxwBV1CfU1sWfZs09sWw0PN8%2Fp%2BZUXWU3HCi0ygo60tgNK7o5pgYudOPtuvytIr9lfipLS0iE4%2BOoZ24b26lA78zh1w7%2BsJiaq%2FLe4wbWuObXUO07lHbGI3wB2Rdm%2F%2BuuuKx0CJ4HOo6oT1WJxlQPXdKO26vYtirz1HwR6XMLlG3N13UxbJd%2Bly300%2BnRuBCEQgAkceAU9k6sTlLnc5Y4D73ve%2BxAeR2Ku4kSPOfvazixel5JMRyAUXvehFDboM8MTp%2BXrFK17RQNHbjZOf%2FOS%2BLh3DNv2B6GEA5s2g8Z4EZzjDGWgUxk4W2SBxeNGjLPoGIUW2PDvBt5bgON7xjmd07Zn70Ic%2BVD4Gil6yeKqe5CQnOcc5zrE3wJVOwranPOUpy%2FI2InCoBLgSOpUOuXe0xsXQYyeHvToGMc28EmFCqwive4wnD%2F4Fn%2BH9%2FlwSh8R7cGdW5vvb4CiJbSCncD2WQ7RNfDA6hnf3XHLiANd%2Bn2%2FwZWhYaPS7zyJWcUbUjNk7t2UlmA0XNaePy8Yl3zl0qF%2Bdy09nrRk9PHG3mvFZcJhzD6BjaE0R75NsfzoGp0no%2B44fpFAzR7bv02279XGFpgoH1jEIDpyCnY5Bi6CELJ6KQE%2F4%2FV4CvO9t3IgS1dq5O8Eb60T90OwG%2FqNbt7kME0yuXj6TZkfHWCeujf3pGEoE0HWhnzN4pd%2B7wbtU5dl%2FMDqGd%2FeEoAlv2OYmmGHvrX6b4ADbJmJQuobwwegYogQ9ZbynxtZfcelqMdAOgfftVWWEzTER7%2BGJyQCcueSK0y3NcTAFw%2BoiIypKKTePPwEb5ERHbcxr8WW568WVTof0gJOPQAVdRVlT6EpGInB5%2Brv2HGBj6Q8HSOOQcAhxFzr8qtqkZyEJyIN4iRJanPHWhNnfLUvKHYOJMAymGCwblOLKcifRhWBR9D47%2FErv7irKZV2za7%2FRwjpxq2OsBAezAfs07t7EngWYrFvK3gQguMToNntzcB9%2B7nOfuxNvJr3LwV3a30P1i13vbm56zt5y9%2B7RVchNbiY77YKz9UzWPWerY%2BzN5AB79qmNT3oa1Hve854DnEvnIWTtXXvKHcxOPXknc48hddc33DEOkK1D2s7zl2C1k8OcJX%2B3eoFA5NNnP%2FvZO1m5ubnSd3a6n%2Buoq7fPUR3VfUA33kkMNcHfA3rFSu0k6GsEIhCBCByVBAQQrukkVHphFYboMxDymKBRmHKy7vxGOKc%2B9akvdKELOeSh7NBlLnMZN%2Fwx2KtSwgUxxFejO%2FqD4co8TGUoiv7EJz7xeoXtgXWCE5xAIKjBgCnVEq%2FX0x7BpzjFKbwCcy73yvYa%2B3mLTcowktlB5BnEhdk7rthJ1tcILALGijM7gCJBo9sO5o2prGDgjfaMZg%2BsY3jzqK%2FKyqjb8Gm8m1XKoW6IhdgZ93qH7j2p0eyBz9XhjaVdICJDuAw8r70Dy4PRMQz2EODEebPJO9s7gOdgupDNgyAq7gyYl4XCCbzDlY%2Bwrp0B4UozG0by3ECDTHeA7SHluqsw2GdvKYLElrCDsCKkmZGkrzysmeKxPx2DD86ph2iUh%2F3pGLTTvXHptB3TCrbNMTqGN%2F5MVYUD6Bg0Yb1CZd0Pt5XlenB4xxgc3DM58roQB2pLj3vrbqxR1ojdKXQM5251FTnbv3yfwWjn3Jl1bLfW9WpynzoG341ONRbuU8fgVPJkzaXyoQ%2BQ%2FlZzbOtle17Eu1HP%2FoPRMegMFtWkQm%2BvQa%2F4KQC63MqfkWtyx%2Bz0WFnzMnR%2BzbcSj44xbuPB6BjcIq2ssbiogLug%2BPJ7I5RwJulLOW%2FzPXFMD5HYRyPS510putDQ9oh0UTikImQBV6ttNxbvDpadnq2a2MPRWZSxfcpT5FYJrOezHrXr9J0NgoOoCT3ELctNbI66w5gaszeoHm06z96HJulgIkxWc9DitL4gokNVKadEfUl6GLexncQHFeQbggDLoU4qcZGa14D29oqYpU4MWqagfeoYKCG%2FQ2a%2Brhqto4YW28gKCWbxnDXmWSnXhh4uOkX3QHvttKHWrmL3SX1gVRxhfdhOF44brNjXJY9vz51tDj4ty12U3rW3Xfam18S0AqdsnyBw6fC60wpA2qeO4cLZnye%2BQ8mdeYeG%2B5KRm7rMDXCvYfbw9NXaZbitiG0Xggo6NDG6c66LV5c45O5yTRsex%2Ft7jBoTqo4cfHZuqrJy%2F%2FcswIQe6IYvvGryn7%2FuBsDuTOd0D2GSDr%2FTZ7SFItYjY3JAxpVlv8fu6oTbItqOQAQiEIGjkoBhiWeHCR0TIuvrFa5whTOd6UzGkMwQ%2B33GM57xkpe85HqJ4KWh2IwLXvCCnl8UdeLDGq9KT7te63yOjmEQMtVx4oUvfGEFkSwMtHwMF80fIYN49BAunHi2s51NuLjxoREyJ8XHE1kypZBTnGLAb1TgqToyy1EJqrJ%2B8Ajwm4ymDKqNA426t44h%2F8V%2BsbjzKnyfOoaBotGX0Y5Rk1EN14a7KkNq3v5Y6dheY22H5dwi3gG%2FYzt0pIoYKRkB7owS9f%2FtO2u2GbNRIYQ38A6cwsH0NnC9hmOGHHjH3hRv89%2BaJwHHissg5MAAUizuNshESgNyA0I0Ruiw1s329Nnm%2BRrYOxdJf5fquDelPSwxgkXMYHUlYDPXGHCOAAcBw%2B3o133JnWTbQOtEydwZKDnzetTIXC32ziuhRWidZdv%2BdAyKhBbkkC4xR2PBa6fZbRpryj1IHQNJWDBBj4O89TXoGKi6K6op70bbMU8Xsr28D2Xx2pzrs85lwz51DPfe%2FUXpe3cpc72U9yHPfeoYmhX8KXqvjuF%2BC4IGclfnlOkJOj8gS%2FpYzWHDc0FFeO7cPV8PRsfwuNFq%2BuHCPvnAovfODBoV93TYyhqs4q%2B5BCTmUcrBhbP6%2BWHVMXjZLBf%2BoUcxw%2FtZ2DXT9hpUIgJqR66czuAhZa0MrcZjGh9fXahMGlcncSHoAK6%2Bab51uot02SlgRtf1mNNA7kJeJaxD6uWjkwhd0DqH6vjzarmWzJOVHktGUC47UdIBdq7ryXyff79LHUOLq7KreMdhdyfR2w0YyJjY0nyG2D5tsHN6haxWsBwybhp6BcIzJtmnjkGI0P326jY65E6PpRoxRt9eNmhQ3NDTl7a3oJXAhv3EK2m2k7ZwdiLyKo72mrhhQxcVokPgJVgxXgITYbZ36clcDyQO8LJdX%2FsMT9raMNtzd3Iv3ca96LGi3ZgnYm360l4dQ1t4ZEzwzzZbVVOpNd5zSA5eG7m4tqKNt1HqqBG3z6NtPrYVYZKULj23nTnqRPdDdXTJMHLk2ZEmGOniMj6UrduLozvCwuRAMHcbl4N%2B7hqfgKtVtKeYS28UPJGWev46ZEOXcB%2BW%2F%2FYpZvw5Kr022l56eh0bNIR%2BuDJxaSjdTUktnLXk5ZWgjQhEIAIROCoJcMROdapTCXggUxtNGZKd%2F%2FznF54xvhhxw9ErX%2FnKHu5jlSGldT5Hx%2FCOb1bVWAa%2F4AUv2NExDAbmqPGh%2BSAUCQEbPmavOFdkBdnEeE%2F%2BJPGznOUsEvic85znNE6YQj0NPU0spmE%2Fwy5%2B8YsTOraPm1V6GxE4MIH1nloyvc5g2HhyhAijsm30vgQuBxIfh9f26BhbR8Bgz4jUkMkAz9Cav%2BCogZARDldi645tTTLANvDe7uHpGNYadW9fjbnKZEuU8IOYKzHjXQgGumtYyC1aR71SpIeM1ODaWW91D1XHMEgzVBNs4CXm%2FEjBzip5amo0SNI0cQwlw%2F6tqWOAehnrGtM6anjJKdt7hWIyrp9T6DlA8cfXjUVd3E8EwPgdGX%2BJM1u1h4CjsfZJVTJeoVGlATPDjMCh89p3kZkN9rgXkTsEPNgzOoa7304yPqO2AGTFNkjgFsTFNnxdAc%2FCttlz4HgMHoEamTfHUZ139FsfnDen3WXiQ9UxwOalemlueL99Tal0ZutpY7avsoXXWTrwMl5zc%2Fe2%2FXMdsmHsrVL6hqb0da%2BOgaFOqxQ10kaM0ffWrXuyUu7KU1AEA5gKNbdixxvVTJ4mfCuHnLI%2FHUOGGmv1E50WEKhXKfqGLi2f8d8xl8AlthK4EFSKSmOPkAOevmt2HVUXtRiPTAc%2B1PUx1omzoS9xS1Vz9QSmEouUqO1WXAQjeak4rPVeJBtFTnNoela5P6xqisTg6poLsHr%2BFOer9%2BOubu24bVkFUdUUKlxkZbJj6voKBe%2FVUxgZa3IqWk9WC44b9%2FDgfa65KXkir2uQ2a4vNd0xexW9NnRFNwoGi0Tap8Hy5GZi6Jrdq7w5un0Rz9F2z1lNoBRw3HX1BL3a1706hhYft5QmueyXUn9zfW19UjsF8CCmq9heHw8Ctmn6pZ%2FMIffSdQfT3Oq4FUBg16w6sA15rpf127u0dsFQ5iqlatuGVgRdXfd2b%2BTR6%2Faz6MeyarvhilutSf3Qymu5m0lGtnV5CuCZKuzVMbQOrYAlO5qzryzf2qzFdSH2bEMytKyKuGPsPCyUrsSFnbriceAqGKuY7Z7vcsZhuqX77Rza3l50YxcCRO5aCt3e9jWu3kUf84j0mGD%2Fuu7kQw%2Fx%2BHCW%2BirFtjvw5L%2F%2BelSpiwRrjw2yhuKkV9lt9zM2MAZYAwNYCJvuoq5KcRqeCKtq29zajkAEIhCBo4yAgR99gKrwHfngkA%2BtwH93bw8d7y9Of%2FrTC5lYD03PnRWPIW7QiSZiL2sNtvenYxhnWvOTQOH1hAhwHoSPhyYHUEHz3PQE8eAwdrUWKDOI8x6XHnD%2BGsl4rHsukz5MbOHXrELbiMDBEDDI4fDyhiaxwYmhi9c39vNqbet4RlAGxgYq4jGMKg14DIaln1BS7gnXzEtbPrtxuBGaAfD2nZEhHHfM%2BGoNd7eGuXaMgvT27U6DNCMiHocilDsfwyqZKH3bz71JNHI2OjUUJOWx0BjP3%2FnwCFhl%2BDdpvGsjTRiVsYRiQF5wEW3LXdvGe5wCng5fzPhfpbwnXUdtsJAXQ1FxdXu5LCUmW39TGk6fewUHWS2MLWWI3k5N%2BR1rJMwYbjJxYDXHtsS92wQW5e7MKVjJ3CK0nXJZZaBrHIuDlvVZDapoY1f%2BhTfpTpw31BoLasn%2BF%2FzrtS%2BxSGfge05PcMi5dsqf6yE94PzZUUvmZT0gXIntiHps0x%2FshG6CLrQyT0ozuXPyxdRIj5pXh9Ib%2FHP5MdEf3AZXP3SKlHaqi6L91ZpO3zpB2OotGk5nXli2GxwKCXhYdo6OMRxWGlYZlrNQL3WvVh0ONS%2FPfn%2BVuzqbDS9VWQ6IzuYvXUWNkBmbJeBVeYJwGeQvQzy3CsMUqrF00eW%2FuB5VEyhNsPIRacNXov%2BwgYvHPDnrBtw0H17kMHQN%2BmDi%2FS8HbU5HUi2WjuH3KXbCA1bdbeirGPJKFkB7CAiuoyVeMQwiEhkvbJ3Lfr2IYRPQOPs1hJR0CU3vWtDVV7YeZK5H7wtcMhIIgFn3CheFjkR7WVnpP1IirL8tUKvovRtuFy7AZZ4auVd4Aa3fbqWAvSfu7CFk6d571%2FlU0wObwWCNpVEEWG5Tugq02urqigNTr9jrqrugtvcfdyfVdzFKPxepruhacJsauU9buyW6sa8qGEvoKhLoAEvOddR4Y0eas9NFRJ%2Fxcp8%2FPt3GXzcENxDl6rorWxvuusvrd2sVDOCGSV%2FSOalGTnEhOJ2FOp7SXXRz4axrZ%2B7SitM9mC08Q3vNbYrHravo8C4EFx3FFe1t6dttMJFZewQeyJMlikOJDYp2G3S7npvS6Bj62zqFDiCeyj1hO13OLchtYZts0rvzu5wJF%2Bu%2BpEZSakH9c%2BVpQ6Or0dwVfaUa6c9z%2FaqmU1hlXEfi07v0Eze3uaMuRDbcXkTfOer24qN2bi9zLzLalKEcaOZuKezfcnDjclTTY%2BtEPLURmFsyuoqj6r5uFHCpIHVxbhceDSvGQwdgsDAwd12ZaB3J6DwQuaYUNHfULYG2IxCBCETgKCPAibjUpS4lvsIzwjiQpOBjLOeZZQVOaoPn0aUvfWmxEOvxbYRD2Zh4DAkscEHVXyMxz2KxFoZ%2FquBZQw9ZIwfBe0aSAj%2B2CrbcjHyMGD1wL3CBC3i2Tt3ZI1rDSMBz0H5D2TUoGuHFs%2FIoo1RBPxgE9O1ttzH%2B4dXqeN67GQTyRAxcDX5sjENnw4CHXqH6TjRokcAh4ysbDtk2BuZUGs36yIeH4ujq88NN1zUuco0YL62XdFukRpJym8zl6aNoH1eWcelKyRVihvx9xs5Jtv6qi8%2BcboTJp3NxUSHUzpum%2FekYhmrkQZeYUybz7Ss2xnMiDA6N8XyMDNmpmlu3iMhJRpg03B%2F2yEck%2FPY9mlq4qHmaBthCFHjB0IG2dW1WTfdusAEiRZBHOGvuPD7cUkh5TKjKSsW9muS3ypmREM1f9iykDJsGNVhdzLfktynnxNnjr8%2BknP1O92Zwq2NsmaiCOx6Zixc56CYHkGXi3GlBjoy73Hd6zyH9x5hZ5g5JvMyebTt9ZqdaOGurY%2FDR5OmohtBnhg83lr9j%2BM2t42Fp4nEb6Riy8rZ9h7Pe5YqgR%2FF9tOPQW1WW%2BfajLZaRivZZXyeZmhIlFKGDuXa4ADvFaXo90yNDxzApQxdl1SpucpusxhJF2PDRCedjDxQKQhg6O7%2FD6H8pyUEtRscgVHIMt4%2BeMcajRwJXlssTIs2xVAutqVH4O96PS0xw4MLwoLdrKUwmurQKLldal6Ae6IRIugBdXPJZMfl6LwuhlkxjSbYiBFwvdE4V4Y7JmUncT4nlvL%2BLVzK%2BpwvQx8beZNxt%2FdChMfUg%2F6oyHdUNZOkAehHO2zvDZCV%2FAGH0VY1c1wzeusaTjA6jsVyqywCeowy3e%2BaQMYm7BClDr6CDeWuvHbWyv%2BujVwA%2BKpk7gG35r5xtuGtxePWKrRBBauCNbpNNSnKBHPQr2Spiupy%2FKrKaZs7Si6hYbl9aXP56lzRO9Ne9WiZqZFu5c%2FrkuTKcDbdoHXUKkmCKdpZLDyJfHZWDq3XNyZ3S6TOeIyD7qi6uF2YQBySbO95OFRRhj4tdeh3J19me3Pw15GMMiXjtcUsRt7OGc2u%2FYFomTSuoxaJkjx67ktnQGdz0XOyaTzcQl6U6kqmgv1NHlNw95gbCwpXbFtQcnbJg8Vk5yAcl91VpYN%2BG9OiHAvlccdJroHmwSr9DRrZy89nWhRnq6AmiRvQW9oPsngDRjv1KVzQDlCKNy1a7LKFyS6PtCEQgAhE4UgkYnAif4H%2FtjHOMLUkQ7uFKNxa1Te7w0KRgGw366odFPLA87zwgCBdcOcMJrsFZz3pWR43inLijY9hjOC0xDYS6buDtzRqFxOPYg4D04URPFlI8B9N40leDIqVc5SpXEfVBk3eK1wF0lfkpkx0yXkOY%2FyLnnf19jcAQ4A5sh7teB%2FNDjUm80zFo1JN9jEwMWubjq%2F0jSuh4xjn2OGQY9p2kh3x8dcr6SOMF7o67xF%2FjsPDEuduuoL3N4eWU8Zhs%2F7%2BC%2F%2FefnVzRbXpeg6uSzUr831T%2Fv%2F%2F%2Fa9d34pYpCSMSMmlN1t7mtrYNX40GOblG6a7BbWLenIGo8Z5hm49tpbsw17k26AmKm%2BG3AZ7EuLm3bNPY5kRYtc%2Fp6uVjTEsPWe%2BOdxLvfHVPmCr761zF%2BRySzXf%2BKM6H5dwuiNCexNJsQYEj8TjU7lfSDzvJthCH4T53bnNTEf1nxvxeOLpP7txFjYRlu8UiZ3vkvJrJV3mujzGzsfT%2BipZs7JSAjrF8TKxsa24mSeCvjzQ7fKjT8wqboKdL0JB3IHvJyAAet7suH3aK2zFmDJi%2FqxY2lm3rkBbxCFAEdZrHt7c4Te9Epi47bezNRxo2aCwPBR4unoqQzLVGH%2FDg8JfZ9vjMobHBX8qDurBB5AaXaq%2BDxpvmKc8LaDcHBvDjqEBin7wv9tU1q1l5fEpnA99ZYpqGj26PJJfZNeIJqOfP8hp8cJY4nVdFWJAt%2BzlHbgu6MWGEtZ5lrPK0UoQrWp4iN4g5bGYAX5VbpCz5yNkbaumnUH93pD%2FXuBflHqYCPHYa9FC%2FqqaLxbWwI4CoMqVF07hY1N3NAXkCy44jLH%2BGAci1Z7BhA4OF8VDPtgYLwucIOx1DFwWVRnCF6IV9dkK3ZZfAtlfY9lltamO6xNzMvc1X4g4ThnkLr2MsrU%2BNvPp3p93LxD3ZI2BvP3cf49Vu02PiImKMVpuPs1RZV9QEtAK6nP7JPE3Mzr15Ti0kWB%2BnS6YsCq1njR4rN8lsaJdt6S5MEoFRlrpwn3csYZWsJv%2F566s79gho2pFvPlGv2zz1rrlIZ6cghxn1bdPY5qcTB1R5m78qKHRHtHe5SYnnltLY5m6vS6uC9nUpqbLP%2FhApaCGyMVWTg4c4LVG95ii1be8TxBWhIuQ7QqJTlL5TinPlv4PLHs0nJPie97ynZ9Oy34aPxFpEDze4XfqVmzDJ102VSa7fHWh9jUAEIhCBI5WAgcfIBQSKnYLcnC3v6UdJPMc9DowHqArzEVBBRqBF2O8sYzPJ%2FvfgMcz4sG3E6BBh3PaE5U%2F%2BxnVGTcc%2F%2FvFX%2Botd7GIz99BLLk%2BKtd8Gl2eeUF5gWbJjHTLfxNjV66cdmz1PpfFA2RmS7STr6w8tAQM5fXJbfYMu7raBnPGJEREv3vtBe3jcBrdedbk0ZnxCajM4t8dHKKk0s739a%2BRs5Ll3RO2doPdl85ZHMOrWANv8ICca11H55KZomSvdX%2BLJvH1bpxjBikciPI6d29J3tuXJ%2FeHUG4Ia%2BiplZXKADd6Qcfj2JaaX%2BxwQVrnS%2BSbyVNA2gdy4Y4aL4vxFbRFeQODX7J0wYlRpEGvsakBomobmUEdgD2DPOuRm5V2w%2B5VBPlbrIzcOET3BQJ0Xqb5OYR4D2MlL3QEljTky0ihXXVDyYb%2FEQ96J0%2FRGp3ay0P7JbTLUJWz42OBUutvwDnShraowZmtrHhbXFRY%2BL6HVWQyY4g7JY%2FePZFxaHWyVslJs68JmvRGTxceGBhq8C44NuDQfT5NbzYmYWAJOJTO259qGTtcykp8OLM0UPcbgsCwBZBCtPTaYt2MzI8cN5ElphZ3ifNW3vQrn1FAAeASCt12G8nGi%2FDEf%2BP5qCyH9fFIPHT61gviwfOFZpILr4SGiCDuda2MMs62Jx3dTC%2F74Xht4pnyceaVLB2DPcnk8jzinU4T7hv3Dcxwof3k3HmdzhXpIeUraM%2Fs1xHQzJboA9QReko88ebic0Gk71fEyfQ45URH3uMc9JgZAuXxM6ac1V6ESuy1sK%2BJCU7QHH%2Fdtu%2F9gtrHyYoIs6caykx5ttVOjMdttQdPsvY3oh9xbL74ZPNrXXoP1fJnrNtpajaambonadO%2FDWnvBxVWcXkHVcftCTJvOFTp9w9%2BJk3GD2uquUwvMAbnb3e5mY%2Fa43t2BVWGnmqQtAQmM0UMUoddNKTqPnHcuMdemRnFNud1pKXYyTJ662chlgnkAkYMe5RJzaHvhTLfc%2FnVUp3VDcOErCw0iD1z27%2FWLKYGKFmagFXRmcoG7H8tVU79liazG%2FmHl79wlZKuBdjQZHJityxGCFhPXkb6kW649s6FoVabWuvTYpoJzB0DJXX2bWEdS1ug5sqKKuMTskZLoJyXg1CRyFrxq6tLeuW9s%2BaxtJG0PE1cc%2FY0NLoS9d92tMbalhNdn2xC2tc7gUpGpEQvdq53CpLHfc9ZQkyzJfulntpeORCRhv947A1SBba6RfUpkO8b0NQIRiEAEjkACHiiGgp4Oex9bxhLGBp4vM6alP7hv8xQM54xvbXv22TnGuJm7zzvqyTIj5JmEQgzx9JmH1zLbI9VgwKPN%2BE3RMxSZo2OPMZWPJ9QagTjqHY3nCCdrosr3jqakYaon0ZqZskpsIwJDQC81POZx6PAGjYZDXADdxkhMzzEgFAs0KXXpg%2FSvD4atEY4xubeBBuQG%2Bfxxg1VXhyGWEAjjbQN746XJagZmDHOlcKzmbdocMlQW2GzkaVR2MOVOGgPLvXXZ%2B%2BrWHkKKd%2BgG51unhi7B%2BBmw7a9QF7Ih386Yf5%2BJMQecyzPD%2Fn2mOcBOnpp333gabxNV3G0Mzt1D3DoOcNZBHnKTkRKr7Z3HHkUcuGpaai9hJxoAczHWffIgzfhukuFAqTDM5hZpOANvkg7aarRtU7d3e%2FgmLJ%2Fi2O%2BerPV57geurPQ6w2EC7nY9YsLeqjmkw3OZtew2zVyh0h%2Bqn7I3z33u0QqrsiuBEmdhkxVmoO6gjY%2FjObXazi3CzeGQNTneZmM%2BfFVdceFSCwoed5g3tHN9SaNRZMtjIlNsLXGIT%2Br%2B46LWq9eJCOvhClLo%2Fxb4%2F%2F3fcUgJJu5d3NsR8VbtDmYDdoXuZLhO1EkcHQffLWtr9krjKGnCQEIXOlSDdU4p%2BYn%2BupBXJjsb%2BqpOO71i4ZVmOoO%2BsdrFTttqwbyVCYaa1St1IVLLZm1NynP%2FN3Rx75Weta5Q4THuqFLO6bArd2W1d8ONS%2F93r9j7JmVv4iN2jyeXx4egncmWAWPJ9n4FnWeHBNsLBwSto8rb4ZaWHbFoi05%2FJpUApU%2FqV64Lgz0CI%2BXZU2ANrpS7zzveGKY4BbnPExgPkGwSf2%2F%2FMm9Gv6zdWjIDA3%2B3PW2bYG1LQO4jsO9o%2BytBGxGIQAQiEIEIROC7J8DtNUgjGvAujT1EDQnFN0LzztGg18SlebVqVO8Vv%2FAM7vl3X6hxuBklXpwJVufmcBUN%2B73cFBlr%2FOyr0r1Zm8EkN4c9M7rmUXrdzwzyHT2QguENoFefArzH4%2F5ubOMRcXwMZfkL6uu1LDmFJV4WG8FuczYWZdK8sN7u327z0bzAgne78%2Ftrm%2FdqVC8y3EtbvWK9DOXsaykdRtsd1hpREuS5VaIOaw5HXnrdkq%2Bnz%2BtaQrUFS7sueEnzRvIA5Rqumw7A%2B%2BN6HyDZd3NIn%2FTWm23eMut78zb%2Fu8lwf%2BdqZRECFio8TLLM%2FnL7nuznM2o%2Bbbc3JuEosIe7KrBKY%2B24gUdG0bqBq0nshFuicJeJLJqCOPIuW%2F3ZfUzQkVuZW6vLll6xLAHKIfMd3H655G50c9v3Jl3MwNxRpaHi6hJeymx1gJXJ93CDx01eYJub7f7MIGKwXxQEwdwN2VyqrYwg%2FgE6Mgg9beKgPPUExW3fChGXXN3I6FHK8tcTSjKXobdaU640Qry0O4ljf5Z8X%2BwHRwSaICLPVhVEZoviIKtA7fRw935tK8Me5Lkli0AEIhCBCEQgAgdPQPy%2FsRk1wPDMx4DNx8DPAHgN%2BbifHDrh1sa9B5%2Fz%2FlIaGgkukr9BpmE%2F8cQY21jaX2qGIbdI4OVGCdVgnlkSXANjRVHKtJSZu81OZtNhSBD7K%2Bvg93uryH2bIf2MV20rYu8ypBxemo%2BA%2FwNk7pWW0eDh8PQPkOdRfEgAA9cG9mkgY%2F4xwAZPR2fQTIfVJFh4TKZ7HNYTj5r0ehdnx1WgK37nMrjOdThBhzqSn6vD3PydKVpHoM1cVNNM9H8vgllF1jsCM99m5bWyKJTt%2B%2Bjt0e%2BLba63G4JIs3X7OirNJiC4ox6q9nWEmETjdSVau4CG5ja4487TId1O3SGnM7ubmWexUy5ZwwWuR83t132P%2BmHmxerz3q3bY0Kruxn9duf07%2B1X92HKobuQ58j%2BLBGQI8xV7Vw4NB%2BzyVZ4j1M42nRaV64EQOGgmmtV25Und94zaBABTjanh2yDDQQIQX31q1%2F9MIUFrvyPPhsCWqz5rC95ZeBGrekPR4wNmdo9xJ3k6FOvLIlABCIQgQhE4AeSgEGg2RzeYJq7ZKIHX4ybuRPeYDRoxOvQzv4jBIiBpXhmsc3%2BGgJtQ4Inf%2FvtFCMxse78FNO1uHVG3f4egVqBQbtZLbzFQxSd7yxzisk%2BvSHzTXZ%2BXG8vCm9Ij7wX9HuLOzL2iCcxXKfweMUG%2BxSBkv38RLHrh7VQjoNAjp3VDA5rJkdqei68CU3eRZqYILReZQ%2B1uLk6ADkyro5VOr%2BAo8rBFMV0OJyLlU8bP0gE3Lr1OuE6pj%2FsrZfL1uXGfxdRoH9y%2FPemcS7VxawWObjY997uSM3WPVgy5t4cjuZ7XJ6i%2BNx5xBptQ1bGbBc4RRofTxOLQe1TwXOuO57oFwqPOVCI7VRZEU4kEx3dQlZ27DyYrx61HrjuM%2B42R0FM0cGYVJoIRCACEYhABCLwg0TARF3TPWbi8xFbL%2B%2FjvOPzZo3DuN5L7hRhYO9d1c7Ona9Eoe%2F313M7NTpCvopwsJrcEZLVkZQJr8TcmaPn5Jcjqcpl%2B4NKwMRAPvj%2Blvv4Qa31Ya0XPp4mByNaHtacSx%2BBCEQgAhGIQAQiEIGjDwFvME1LP7A9ZsR8v0%2BXPnAFD99R74WPwBCaw2dDZ0UgAhGIQAQiEIEIRCACEYhABH6oCAjA3juNeoeAH3Q48Dr%2FO%2Bl%2FSL4KzC5c%2BYekratmBCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQicAACFp%2F36%2FZWk1u%2FArBN%2FPGPf%2Fwtb3nLYVqby6p0b3rTmz7xiU9s8zmabO%2BzjlvbDpzAsnvWXbfkuCXLtme1HYEIRCACEYhABCIQgQhEIAIRiIDf2HrmM5%2Fpd8bvete7%2Fsrmc%2Fe7393vUR5RfF7ykpcc%2B9jHvt%2F97rfPDB%2F3uMc56qcwHbWMNmP8LNo%2BU66dfiLQKX4Mfe05%2BmyQIMC0LiK2W6v84qef8sTYz5lt9%2B9sf%2FnLX77CFa5wgQtc4Oip0uxY29cIRCACEYhABCIQgQhEIAIRiMBRScBvrN%2FkJjc5xjGOccxjHvP4xz%2F%2Bj%2Fzv56QnPenLXvayA1jiR7ue9rSn%2FeZv%2FubBLO5Hx1DEfe97331m%2BNjHPtbR5zznOY7%2B4R%2F%2BoW06wIGDFn77t39bMr9puM8Mj4KduPklgoc85CF7f6%2Fwgx%2F8IHpnOctZdg4JSvnxH%2F%2FxE57whISOA1hIx7jsZS%2F7Ez%2FxE%2BkYB6DUoQhEIAIRiEAEIhCBCEQgAhH44STAH7%2F5zW9%2BnOMcR4TD%2B9%2F%2F%2Fr8%2B5CMSw%2F8vfOELB2DixMtc5jKnO93pdrz1fZ4yOsb973%2F%2FfR7d6hgK9QOOh5rn6BhPfvKT95nhUbPz53%2F%2B52kpe6NWkLnFLW5xrGMdi9CxteTFL37xcY973Gtf%2B9rf%2Bta3tvt3tukYl7vc5c53vvOlY%2ByQ6WsEIhCBCEQgAhGIQAQiEIEIRGB0jOMd73j7m%2BwgLuId73iHuAsqhPgHv64IGuf98Y9%2FvJCDk53sZA984ANf97rXffvb37afD06FeNCDHvTrv%2F7r5ol89atfHcJ0DPEed7nLXV796lc%2F%2BMEPFsbw2te%2B1jyLObrVMT760Y%2Ba50JRmUNc%2Fle%2B8pW%2F8Ru%2FoZQ%2F%2BIM%2F%2BLd%2F%2B7fZPzrGwx72MDNQZCjBa17zmrFBoMgfH%2FJ597vf%2FZjHPIbZv%2Fu7v%2Fv5z3%2FeLI8%2F%2FdM%2FZZvEzFDxycpfp7z85S93iGGveMUrZj6Iv4qmRfjlxGc84xkPeMADzH9hnvQf%2BtCHnvjEJ17iEpcwt%2BWe97yncy1qsXKz8cIXvtCh2972tmtqCdvueMc70j2e%2FvSnT0qFUjYYjxVoX%2FnKV2b%2F0jH82KVspRGp8rWvfW2O4v%2BUpzzl7W9%2Fu6%2BMeepTn%2FrWt76VnVMvDWG%2Fn8jUUgx%2BwhOe8LGPfWxOnL%2BWImE5Jg5NXbZHbWuU17%2F%2B9b%2F%2F%2B7%2B%2F2m4nQV8jEIEIRCACEYhABCIQgQhEIALfQwJLx7DS5l4zeLUUgxOd6EQc8Pl77nOfmxM9yoOd87npTW%2FKYed3X%2Bta17LnBCc4AWHExs%2F93M9NUAcdQyjCSU5yEt69ow6JAJHJSBlbHYP64ehDH%2FpQxnzzm9%2F8xV%2F8RQKIxOa82H%2B1q11thBTRIwIeTnGKU8h2DtkggDhLiec617kkPvGJT%2BxEyWxf%2BcpX%2Ftmf%2FVlZTekSc%2Bdn6or0BAdpTKmZxDQT1aEzXOMa17D%2FtKc9LbMdtX2xi12MAYQF2%2Btz1atedcfrt2bpRS5ykTOd6UxjLasICCaVnPOc5xxhQaG3vvWt5cCSYfUzP%2FMzn%2FrUp6RcOoavX%2FziF6klpz71qT%2F96U875PPSl77UWZYQsf385z%2Ff9o%2F%2B6I8iMBDU7na3u51SVGQMZgbV5ZBT%2Fx8ro2o%2Bp0xTokQDmUPrL6VIHaV529vetna2EYEIRCACEYhABCIQgQhEIAIROJoQmEkQ%2FPQJafizQz6vetWrrLfJwo985CNnOMMZrNXwxje%2BkUtOXuDhEis%2B%2FOEPC4Q4%2F%2FnPT0mgPPCUxRtY%2FsJR61h60W%2FPda5zHV8dlQ8dg6RgEoowAL9aIvzg7Gc%2F%2B8lPfvL3vve9jm51DIekFP5hv6gJ2yZiWHFChre5zW1keK973cshMQlECQEhAg8YaVFN7vyVrnSlr3%2F961%2F60pcudKELSXmHO9xBXMe73vWuq1%2F96r4qzoIegiuYdKpTnYpHP%2BKAGAxHuf%2Bq%2FJ73vOfyl788YUFgAynjete7nkNXucpVhCj87d%2F%2BLVnGV9ZaEsTPr9BGcBPswbwVd8G2%2BYh5kNhyH%2FNVob6KSAGKejNHSSsf%2BMAH5EyucZTBmkNgxswroWOoC3vOetazrh9zES4i5UB40YtexABqicxBEJshPMZR8osQGtkOMaEabDBL5bznPa%2FWJMIQVaAARNXIJmPh%2FBUBIvhE%2Fgez7Mn2xLYjEIEIRCACEYhABCIQgQhEIAJHAYHRMTi%2FO59xfq1I6bU%2BHWAmdHByTY64%2Fe1vTy7gj9MN1mqWHHliBT1h%2FGJfn%2FWsZ4kKENugFg7Jn8SxajQTQ2gC9uxPx5iwh7vd7W7KkownfoMb3ICWIo6CjiHDMdIh0y74%2B6c5zWn4%2BwwgvNBJlu%2FvV0Ik%2FuVf%2FuUpnYxAihHkQH%2F4l3%2F5F4npHjbmKMWGjmG2iHgMUoxka1lO8QwCHlR%2FUt7qVrcSTWEhkfm681eog6OkD9DQELKCJIFIMgKF2IxznOMcIljmLHEX1vYU%2BEECwvYgdQzTZOg897jHPSYTTXnNa17TOqJritA73%2FlOMTDW8UCMOgECtWrZKSBEFAfpZu35wd7Q7tZd0V6zDgz4E5Dzg13raheBCEQgAhGIQAQiEIEIROAHjMDoGGIbiAyWbiBEzGfcW%2F719a9%2Fff6v6AU%2Fa8JxXgs12PDzoGc%2B85nFVywmzuIdmwrhZ0MdcuJEDsxsiO3vrpq2wK8Xh%2BBc605ISbWwvY3HkLMQAocufvGLS0lhWAXtrPPJGNKE4BAhFqNjnOc851lzMayWIZOZqyIHVRYIccpTnpJLS6MgTRAryCBXvOIVKTPKkvjGN76xWAg6BqFmzcsQ5OCHSERu8H9lYn1USsVSOZZtszFhFWc84xnVQvwDGqZ4WKbDURNz5EPZmGk1k%2F7e9743IOJAvvGNbxy8jsEAoR2TA8FEY53%2B9Kenz8wezYGJyrL2zne%2Bs1YWQiOMZGrKNjU98K%2FSTD4%2FGH%2BttWIpEk1Ml0Pg0Y9%2B9OhjPxi1qxYRiEAEIhCBCEQgAhGIQAR%2BSAiMPy4CYb3E36m4lRwEJ1zwghfk8%2FqIXjClQpq9OoblLs3XML%2FDbIUb3ehGP%2FmTP8lx5p5LPDrG9ndXzSixSoOQBprA%2FnQMJ5rr4bc%2FTKyY0q2PMatTjo6xfneVMUIRSBNLxyC8zHITMrFWp9NXKIIqM09i7%2BXVhXrAZunnQzkRniEIRAgKHYP%2BYIKGTHwOk44hvUk0yrU86fOe9zwbJrAcks3%2FY5aKuptFso0HMJVGK5gqclh1jKUO8dNNhDF5x1yVKchKp3QMmoxDULPBJBQKz9SUpkG0MZFoEv%2FA%2F9XuZiqJ5KHUWV%2FlDW94w1ZH%2BoGvfhWMQAQiEIEIRCACEYhABCLwg0Fg6Rjcur01Igu8733vM9NBdIHAA6%2BzzUQgUIh5sAjnNh5DGssycKKXe%2BgHRDjmv%2FZrvybb0TH8gsYqwu%2BVWNvhrne9qz370zEsifk3f%2FM3LPzsZz9LcBAIwROfcAh%2BqO3vXscgp9Ax6BWWgxB84iMMgypCTPCxNMd3o2PQE0wVEZ0iBmA7P8WvvVBR4BJBsYCYroKt2SjK3RuP8ZnPfGZSkh1UfKJchMeIxzgYHQNDS4MCTlTRlFNTG2pK4lg2tBGBCEQgAhGIQAQiEIEIRCACETiaE1g6hnU7d0wVLeDHVXnNaxkKv8rxUz%2F1U1xy%2Bgb%2Fl3tubsIESAjbuPCFL%2Bxdv1Us5CNi31nWx1jxGGIzrG7Bg3bUuaIR5GzGh69718d41KMeZT%2FXXhohCrZ9xGYo%2BjKXuQybZ32M71LHoCew59KXvjRVYck4XHvBHiQURh5Ax7DkxS1veUsygpiHMW%2FvXzmY06EKPiJAKD%2BThlRCC%2FJzKqtQYpGYEwESn%2Fvc5xgwOoYFHJwy82XWD9EOq8OkY7BBU%2FpxE00gNmP9tIoVUC0QahmQHcvpG4SjbazIToK%2BRiACEYhABCIQgQhEIAIRiEAEvlcEaALWgeRoEyhudrObWQRjfaxHYUoF%2F5q37oc2iAbzgx18Yf41pcKMDydaJsKvcvCOLaTpq5UHrNgwv%2FTh66ztaZ1PwQC%2BCuEwv0MRtq0XMatrPuYxj%2FHVapwgUDa424985CNtW7pBiIIFH0giFBXTVSSb2RlPfvKTbfvt1OHG9xfe4Nc6Zl6JeRN%2Be3TNK9mZ1qHKFBWJZw0Qv9bKNqUwm0owpZgSQoWgIfiBjzWvxLoWxAc%2FAsLH9xHhwIYb3vCGdA%2B%2B%2Fz5bcJYqVSOBENsEL3jBC0wtsfjGgx%2F84Ic%2F%2FOF%2BApUNfvpEGrEutBqK0Cc%2F%2BUlfH%2FawhymFsoGbtUes5uHrRLm88IUvpBTd5z73mZwZPAuTrnklQmgsxMFCqgvFZhrF3Bww6UuWRbXW6HZ5E%2FkgSffYRqFM5v2NQAQiEIEIRCACEYhABCIQgQgcHQhw6v0EiekPFlLg2m8%2F3H8W%2BpEOMyPGfbaOxHWve93l%2BQrh8LsbVA7LS1I2RGIQE%2FxABufaBBPuMD%2FdX7MnrEsgcoMHbekJP7EhN1EQ4gGGgEki0nPtfX3xi18s5ZOe9CTbvG9Lj%2FrlEaeQAkRNkFPml1P8%2Bgab%2FXjo5MD7tmympS3m90p4%2FWSZ9XslIjokplFMYlU2OYVQMD81ohS5kT5UhOVCPkglqkMWoPAQW%2FzI7Jwo9II8Qr6YdRX8GojlTM2dEVwhHGXS7PzF5FKXupRFRfxq7fYQFciqHUOPggEUmWimmZBEyBHWHZ1fMzGjRCwHqgiwTb2wmrU%2BLEiCFRlkcmawRTAEdSzhRcCJeqmsKksjQyKMn1uVFYHokpe85Cx1sjVM6ZqMvrEiQLZH245ABCIQgQhEIAIRiEAEIhCBCHxvCXDJLQ3B3fb2f%2FuxR2DA2MapN6eD2262BQd8azAv20t%2FP2c5rj1PnNdvjzzpAyIiBEjYEK0hQ38JDpxrPrL1NFY%2BQgW42DPfgR8tpT3rqFkYTpHniq9wSALJHJpkrGKJBAxQHAVjtufolL6khqmyyqrXKkVllSJCg%2BWzUzLhIgxbMy%2BsXOGs7ZwL2wzbi2VlK2xDJmwbPmv%2FbCiUlmKNjhFnZufURRVG1rDTht9MIfvYOTbM755MvWZbsjGYhctgFfRVjbal2yMrDbqSTbnrL6psZvna00YEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRGAvgf%2F5n%2F%2FZu7M9EYhABCIQgQhEIAIRiEAEIhCBCETg6EPgX%2F%2F1X3%2F913%2F9EY94xFe%2B8pUdq7797W8%2F61nP%2BrVf%2BzVpdg4dDb%2F%2B9V%2F%2F9V3veteXv%2Fzlh9u2f%2F%2F3f3%2Fc4x734Ac%2F%2BMtf%2FvIBMnn7299%2Bl7vc5R3veMcB0nQoAhGIQAQiEIEIRCACEYhABCIQgSODwEc%2F%2BtHTnOY05zrXuT73uc%2Ft5P%2F1r3%2F9Kle5yjGOcYy%2F%2FMu%2F3Dl0dPsqmOSe97wnUy93uct99atfPUjzPv7xjz%2FoQQ963vOe91%2F%2F9V9OIdf82I%2F92ElPetK%2F%2F%2Fu%2FP0AOj3zkIxX08Ic%2FXJqvfe1rT37ykx%2FzmMd84QtfOMApHYpABCIQgQhEIAIRiEAEIhCBCETgCCHwsY997KxnPesFLnCBz3%2F%2B83sz%2FKu%2F%2BqtXvvKVAhX2Hjpa7fn0pz997nOfm7xAhfiLv%2FiLg7Ttfe9733GPe9xb3OIWk%2F6%2F%2F%2Fu%2F3%2FSmN73%2B9a%2F%2Fz%2F%2F8zwPk8JnPfOaP%2FuiPlCgNHeOSl7wkFej7ImTlAJXqUAQiEIEIRCACEYhABCIQgQhE4PuCwAF0DFEKr3vd60wt%2BeIXvzh1%2BeY3v%2FmCF7zgAYd8nv%2F853%2FjG99YdfzWt741h0Q4vPrVrx4pQJjEm9%2F85t%2F5nd%2F50Ic%2B9OxnP%2Ft%2B97vfwx72sG10hwQmaNh5n%2Fvc5wlPeAJjVoY2PvKRjwh1cOjxj3%2B82IntoZ1txhz%2F%2BMc%2Fz3nO4%2B%2B97nWvnbU%2BBJa88IUvZPUDH%2FjAP%2F7jP2Y2yeIVr3jFr%2F7qrx7zmMe8xCUuIf%2B%2F%2B7u%2FI9e8%2BMUvltWXvvSlV73qVb%2F3e7%2B31AkZkjie9KQnMeNv%2FuZvnvGMZ6jRu9%2F9bvNxzna2s5361KdW6ze84Q2yZRhuL3vZy8zWURwmoC1r%2F%2Fmf%2FxmN%2B973vmav%2FNmf%2FdnEgayjbUQgAhGIQAQiEIEIRCACEYhABCJwYAIH0DE44Ne85jWPfexj89llYuLJLW95SzEPP%2FIjP0IusPFzP%2FdzMxuF0HG7293OHvuPc5zjEAfufe97%2F8d%2F%2FIez7nSnO9l%2F2tOe1s4TnOAEtk9%2F%2BtO%2F8Y1vdIjX%2F%2FSnP%2F3EJz6xnfP3HOc4xxxy1EoXZz7zmR06yUlO4u%2F5zne%2B97znPfbv%2FZBQbnazm5kdQzMxEUYmEywxKT%2F72c9e73rXkwOzj3e849lQi0984hM%2F8zM%2FY3t9%2FvAP%2F1AtKCFMFXHxkIc8xCE7JxPTRsgd9Ip%2F%2BId%2FIHo4ROUwtWSdbuP2t789uYNIcve7391XZU1xd7jDHYRtyAfGS1%2F60g6pLBo%2BBI2jf6zLEOhvBCIQgQhEIAIRiEAEIhCBCETg6EDgwDrG9a9%2F%2FR%2F90R%2Bd9SKEHPDBb37zmwtI%2BNu%2F%2FVsbvj7qUY%2FivAuosP1Lv%2FRLXHWTNYgJRINZcnOc%2Bgte8IIveclLxFeIUpCSAOKsf%2FzHfzznOc9pPgj9QZyDNTYdusxlLkM%2Fcejsh3ycRXMgHcjwute97j7XvqBvnPKUpyRlCG949KMfTXj5kz%2F5k2FLKpkSb3Ob2zD7Ax%2F4wI1vfGOlCIf48Ic%2FTIuQ7VWvetW3vvWttAtixUUuchFLZBBnPvjBD57oRCeS56gx1BXZUipk%2BMQnPlEOFJhPfvKTIk9U4QxnOMOLXvQiEodCLZdBoPj5n%2F95OSjxpje9qROf85znqO897nEPJ2L1f%2F%2Fv%2F33b29520Yte1Nc%2F%2F%2FM%2FPzp0g2yIQAQiEIEIRCACEYhABCIQgQh8XxA4GB3DWqBiFc573vPy2f%2Fpn%2F5p6sVt5%2FULwyBBXOxiF%2FvxH%2F%2FxtVIo35yHbtYGr390DNM65izFCZwgVgiieP%2F733%2Fyk5%2BcjDCLZFIh7njHO9761rf2yykmX1AD%2FJ2z5POzP%2FuzFBXrdeylauFNiZ%2F73Oc6JE%2FJiBUT5yAY4%2FyHfD71qU%2FNiTQZi4EQUiaxYA8SxxyyQsjoGKaTMO%2Fa1772Wc5yllEn7n%2F%2F%2Bws1IapIuXQM2wIt1EWsyMxAQemKV7zi6U53OtrL5EnNEIUiAkSchoU4TnjCE1pvZA6RPn76p3%2Fachzztb8RiEAEIhCBCEQgAhGIQAQiEIEIHCqBg9ExpBG9IObhyle%2BMgd%2F8iQ78NbJGkILTnWqUzl62cte1s%2BFXP7yl6cG0DFucIMbiKwYHWNFHYhhONOZzsT359dbtkK4gpQ0EDKFlSsm3ELowi%2F8wi%2FYT3C4whWuIE8f4oA9lq3YqdG%2F%2Fdu%2FXfziF7dIhWAPsRN%2BMvXqV7%2B6uSF%2BhlVKERGkg5vc5CYTVmEPs9ngQxuxUocpHkSGWc1j6RhiM6SkoihRKAU7f%2FInf5KG8y%2F%2F8i%2F2b3UMCoxDFJ6RdyyyYS4MIUUF2QyFVUBlcqUrXUnVVJAY4iitQ0DIzNaRYZ8IRCACEYhABCIQgQhEIAIRiEAEDpLAQeoYZmSc7GQnEz%2Fw7W9%2FeydnC2CagsE9587%2FxE%2F8hL%2FiE0gZd7nLXSgAo2O89rWvnbMICKNjzJIRVtQU6nDhC1%2BYs%2B%2FjdDM4FDGTVqgTk6E8zUy51KUuZd3RndLN7KBUiHmgFVzjGtcgYoziYYKJlNQM0oEYj71mO%2Fqud73rADoGUcKEEUoLocbPoNztbncjfTjrADqGcBGSDhrW2RjL%2FYWCAQQW54rBsOSIBCrLMAuBQmR%2FnwhEIAIRiEAEIhCBCEQgAhGIQAQOhsDSMWZyx%2FYU0RSzPoY0lnSgDwgzWH63wAyag18beec732kBzKtd7WrmlfDWfUwMkWx%2BFoT7z2ffq2NIIOyBkiCxWIX3vve9D33oQ61WYdUI%2BZhgYlkJ8zgcmjzpHjJc0SBj50xFkb%2BIjqV4UFFIBII0nGidjVOc4hRmiDh3TpGDqRxvectbRH0cOB5DCIf5KWbBXOc61zH9ZE0A2aeOMfNWTFoxFUXRFhodsxcK9bWOB4wUFTNxVM3Cocc61rEoG1vmbUcgAhGIQAQiEIEIRCACEYhABCJwAAKjY1hDwlISXPv1EXuwdAxrRHD%2FBWOIXljuvCgFcQU3utGNyBFiIRwSmDEFceEtoTnzJvanY1i%2F4mlPexoJQkzCnMXTt86GXzMxR8MUDIfMLmHDHFWuX27d0THMJREyQfogI9A0TA%2Fxl2RhIglJxA%2BbSi%2F%2BQazIss2Sng5RNlSQsECgEPuh1krZmVdij99gJTWwxCIeJrCMJTs6Bm3nXOc61%2BgYqkD08Ists8ap9AzwI62iWSgbQjVElfjp1cln1jX1W67ztb8RiEAEIhCBCEQgAhGIQAQiEIEIHCoBOoZf6ODaX%2Bta1xJ%2BQJfwueENb%2BgnQsQS%2BMVSYgW5QD4ve9nLiBXWf%2FDDJX6WlPNOBBiH%2FQUveIEcSBB%2BbtUvmFjUwsKbj33sY%2BkDVvukA7zmNa8ZS8wrkczPjxIoRC%2FIxJSQO9%2F5zoqbtTKsqkHiIBrQRpxIcJDPr%2FzKr9AiJBZfsa3Rb%2F3Wb0nzgAc8YLvTNnvsJ4MIfhjbmG1JCpb7VVZmv%2FSlL5XM%2BqV%2BFEWcCbGF0CEihc4goGLWx5CAtaI7ZMWGVcQTnvAEe4gw9qgFg3291a1u9axnPYs2oqZMFQQiT2fNr7uyUMpBYY0Rc16oN0wyVYcctHJuIwIRiEAEIhCBCEQgAhGIQAQiEIEDEzDHQUQBX95Cnbzv9TGzg45x29ve1pQN%2Fr5MaALiE%2FxM6vGOd7zjHve4VAU%2FbzrrTvgrWMIqFtQM80HkZpKI4ARncdgJF2agjBniFoRP8O7nqOgIpVvggu5hDQr6yZQlMcP8qogfNHGIfmJtzHe%2F%2B92TyfwVd0F4oUuY27Ldb1vwg2U2rcApE7Y985nPHLNZbvrJMlvwxiMe8QgVVx2relqsQ9yFVThmPU%2F50CUoM5bp8DMoq4inPvWpamT9z9lDyWGDHMR1CAgh3bz4xS%2B24oe5LWI5xGDc6173krPE4j3udKc7mYNjP0usoSHsZACuzNuIQAQiEIEIRCACEYhABCIQgQhE4AAErALB6zeVY%2Bdjmgk338%2BJUh62szmIG2ZJWD%2FT5JGdbOeQlS7WD7BKwHmX86gWvk5xAh6W%2Fy5zM1AsVSHWgm6wzZMmYEEJh0xsmZ8U2R6VlZxN09h7yInsF00xP4DirLGNHGFjm4kShYW8733vEwHCJLntZCi9PVCss4gSct5WH0AaC1DKnWRW8%2FBTKeatrNCOdfok3mvJStBGBCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEvrcE%2Fud%2F%2FocB8%2FeIskRuk%2BH8PaKyPXz5HH0sOXz2d9ZRQ%2BBb3%2FrWi170ok9%2F%2BtNHTXFHdin%2F%2Fd%2F%2F%2FWd%2F9mcf%2FvCHj%2ByCyj8CEYhABCIQgQhEIAIRiMD%2BCPzHf%2FzHP%2F3TP%2F3f%2FXz%2B5V%2F%2Bheeyv3P3t5%2BP%2F%2BxnP%2FsOd7jD%2B9%2F%2F%2Fv2lORz7mfrbv%2F3bd7%2F73T%2F5yU8ejtPXKWqkyp%2F73OfWnsOx8dKXvlQF3%2FnOd%2B6cu8OTA%2FuVr3zluxFeNMFnPvOZ7yaHHQsP8qsS%2F%2FVf%2F%2FXjH%2F%2F45z%2F%2F%2BX2e8oUvfMHRf%2F7nf%2F72t7%2B9zwTt%2FM%2F%2F%2FM%2Ff%2BI3fONGJTvTWt751aJA1%2FvEf%2F%2FFv%2F%2FZv99n9oP7Qhz7093%2F%2F91%2F72tf2Rw%2F2f%2F%2F3f9%2FnUX1PDnsv2C996UuUh%2F1lq3f9zd%2F8zSc%2B8Yn%2F%2Bq%2F%2F2sn2m9%2F85sc%2B9jEnfvWrX51DavTzP%2F%2Fz5zvf%2Bf7u7%2F5uJ3FfIxCBCEQgAhGIQAQiEIEIHDUE%2BC%2FnPve5f%2FSQz0lPetJTn%2FrUpzrVqearv7e%2B9a15XofDktvf%2FvbHOMYxXv3qVx%2BOc%2Fd3Ckv%2Bz%2F%2F5P6z667%2F%2B6%2F2lOZj9vLZznOMc17zmNQ%2FgLR5qPg960INU8HnPe95OSn79Fa94RQzBPMlJTuLv6U53uqtf%2Feqve93rDocWQUm49KUvfdGLXvRTn%2FrUTkEH%2FxU3VnGc93q4B8iEOvErv%2FIr%2BsP1r3%2F9L37xizspebj8WUdvdrObff3rX985%2BsPwVWsSDbQLFPur73Of%2B9zjHve4973vfUd5ePOb33yFK1zhxCc%2B8Y%2F8yI%2Bc9rSnvc997kPjmnNt3O9%2B98PToROe8ITnOc95nLsViDTBy1%2F%2BctfjJS95SfLRTolkyKc%2F%2Fek%2F9VM%2FJQE1Yx2Vwx%2F8wR%2Bc85znPMEJTiDbn%2FiJn9BjVz%2F8t3%2F7tzvf%2Bc6nPOUpj3e847msrnWta33kIx9Z577pTW%2B6xCUuwdqxhzI5J37gAx9w%2BVz%2B8pffn8C1cmgjAhGIQAQiEIEIRCACEYjAkUHA29g73elOvNGb3%2FzmV7nKVfjmJzvZyX72Z3%2BWl3rTm970cY973NYtOngD5Ml1es1rXnPwpxxqSv44n5oDyJM61MQHSOCFOGXgNre5zf7eax%2Fg3HXooQ996HGOc5w%2F%2BZM%2FWXtmQwDG%2Bc9%2Ffoeuc53rYIjqpS51KVRpGn%2F8x3%2B8k%2FhQv%2FJer33ta1%2FjGtfY5%2Bv7Qz19EnBOL3ShC2mRw9SUXGBOMcuPf%2Fzj723Hd7%2F73ac5zWkc5fz%2BcOoY%2BNzlLncB9r3vfe8%2BG4LacN7znlfbjVz2D%2F%2FwD76SNVC9%2F%2F3v70T09CLnCoSgdfhKo3jgAx%2BopVyDFLDXv%2F71jjrdUSEQEvic6Uxn0oFXiYIlfu7nfo5WNkddIEIm1tE3vOENOp5Tfu3Xfu2ud70ree3kJz%2F5O97xDgnYT0hxFkXi13%2F9129yk5vY%2Fpmf%2BRnihqPCQs5%2B9rMf61jH%2BsVf%2FEUn0jpczq997WsnZ5qGQ24Oq6A2IhCBCEQgAhGIQAQiEIEIfE8I0Ac4LIIHvvGNb%2BwY4FUsV%2Fqzn%2F3s3nf6c0jkwDYufXQMjpj0Dm1f6HOgCAiTjyD5nRNXud72OrR1yvanY0zKfTrpfEBCzTjaEshB%2FopW7gqVnxJVWcqtncsSp%2B89dGAdwxtwp0wOqvCHf%2FiHXmpf%2FOIX5ySipPqgKVEFx6RJifBOlSUzKcDHxrJngEu83TlHVU0b%2BYBsjwRK52h7y3%2B9613vy1%2F%2B8raNVJaRe9t6spLDbW972%2FGOObOT4RyS7YMf%2FOA5RGbZ6hj7ZLXIM8Y0GdWZfLZ%2FNQdjWLjdOdtwOWtCF%2BSA2Lbivjq6bTgQnDI1VZajC7KdCGv9vaXI2aHx4ueoUpw45%2FrLvGW5fNRU3Yk84hacuzVpTufmk7Ne%2FOIXz9dnPOMZiN373veelGKKKHIXvvCF5UloojBc5CIXETYziZ%2F2tKdJLFjCVyEfZzvb2YRAUBfPfOYz7%2BgY8lfKZS5zGRIEY25wgxswZjJhpLAosRaveMUrZs%2BznvUs2dI0fDU5S7akFXOsfNVGmltiy1%2F4%2BpCHPETK3%2Fqt35oTX%2FCCF%2BhChE1g7UFJic7FdhL0NwIRiEAEIhCBCEQgAhGIwPeEAB2DP%2BUN8k6o%2FAc%2F%2BEFe0ukP%2BVz1qlfdLgrxtre9jTfnCKfsJ3%2FyJzlB46ZxwfhEd7zjHcV4OPRjP%2FZjv%2FqrvzpeqtPFq%2F%2FCL%2FwCJ%2BsMh3yudKUrbV9qW1XDa%2BUznvGMTpRSzMM40XzJnXiM973vfWaayENK77Kf%2F%2FznjzyCHm%2Fud3%2F3d73Fdkg4%2FT3ucQ8TNO51r3sxzytsEfi3u93txuPjwVl2QyS%2FAIOznOUsFJjx7GTCd374wx%2FOhRTw75A36QL4p2kOrGOc61znWjqG9HgyT%2F5WFRDYz54b3vCGZA0zet7ylrdIILzBPJepCDutDDkYuec805%2F%2B6Z%2FmYk%2B5QFEkpi201F%2F91V%2FNfn%2B9Z7%2Fa1a7m1bwPpGYxqNov%2FdIvcX69PTdBwBt2nqyUQgVU334m%2FfiP%2FziPe6tFTIZbHYPDu51xwHvlgPNzfZaOoXEf8YhHqPiw4hTPMiYqIsaAk%2F6whz3sspe9rOZghgCDNamHDCUwQBFO9FeQw6qs1vzTP%2F1ToNRIF9KprnzlK9%2FqVreajsRCLS60ZhrOUQIO4zn%2BiKGkjrx%2BrCDly5vlxPtmwFnPelZNuUQDFoo0kMYh6QXqTMADene7292UznIxNmO5Ouo20vPihSgc85jH1FEpDGuGyNBjobJ0qrXC56Mf%2FWi4%2FuiP%2FmgSqP4FL3hBeheYVs%2FQu0gHc8hfKgdR4pa3vCUCsnrjG99ItlKuyxCKbTyGbdcgecGaG0RItZ5eLROnuCS175oAot3VnVDpUpKnhTus8bIKdaGxkJ2OXve615WbxTHmKBvMiGHk2uO6oJ%2BY6rJObyMCEYhABCIQgQhEIAIRiMBRT2DpGNt39FaT4HfP21jTT2zwVWeVPz41J1GovKB03p9D4uEn%2BNzSCt%2Fxco9xDA4vd5IPaPvJT36ySgnS4ED5KjF3kn9kmxc%2FzilpQv728MhoHac4xSnoId5lO3FHx%2BDr0QGk5OkrgjPLkmc%2B85nDzRvtYx%2F72DxNL5HNfZDMx0QPTqtqCra3igWPz9dHPvKRDhErqC7cf9v0AQKC19lTC56sjctd7nIO0R%2FGAT%2BwjsF5HJ96jKFjyISFllt80pOeJB8fLvAFLnCBv%2FiLv2CP0u250Y1uxP3nzquy5RGcK3KAB827H3f4Pe95D3eeh2u6Ct%2FZKZqGcyolZ1ZbOFEdb3GLW6i4fLSFdSY5s1BMpI3lSXEmPjiXs%2F%2FLv%2FzLzrL92Mc%2BFoqxdv5SCaZNVZypT3jCE9ZRE2S0NS1I62Crdlx%2BMoJ8VNM0hGlT8x2GlXZ0yMcUGxXEga7ye7%2F3ezKUQK0dInE4kWxlW7kydPTP%2F%2FzPdRKl6wySqbijMp%2FoCzqVPeBwxokGDimRR09rog%2F4yuUnBVDebGtxTMgphJ05SqMYl19Ig1JYRVjDRGKevpADR2984xv76qNQWUGqj73sZS8b%2BQ5Sh1RZVtN7FyKd0wwOaoBeNDtf9apXzZXCeOoEUcVVMPQA3AZ7SE9ugoiksDK0QWtymezoGCuBvqE5tjqGjkFs0aWnFaTUJ%2BlpILBWiAUhghyxcnBh6j%2BiNaRnmGt2qXYjarH%2FL%2F%2FyLye9xOCTB3e6zcqtjQhEIAIRiEAEIhCBCEQgAkcBgb06Bi%2BMq8ulWu%2BRTZHgV5pQz8vjP3J8LDA4URCiCPjLfDdOKJ%2FUWf5OKL44DV4PV5RDNC%2BCeVjzayZcdTEbnD6un3y8bXcit1pKVfaqmuNGr%2FD2nDETjyE%2BxFHaBS%2BSOzwp3%2F72t4tnEIAhJVfxYhe7mAgEb%2BH5WU4UIS9bDr6vTufxcW%2BdaB0Ajjyt4KMf%2FajiuIrjuvJVuZb8biVOeADXj74hzwmBOLCO4c21nNXdRz6kEqD4wjxEYg6zUZrfj1CiKkO6MIrQ4FPTf5TrdBszSwVwsg9nXBDCdIbHP%2F7xThRNMR63ItZijGQQpRAE1B1nXjwFRlkIi4fhwPpKF5LPu971LksxwKWsyXb%2BgqM4J%2FKpvcSnM8x0DEIBCYXj%2F5znPMd%2BnrV8hF5QEsTGDCuTRAROcISniekD7BQMMw71S17yEl9JMYzRFiqI%2BRwSnEDbIU1Yflal6GNazUqVjJGYCKP6BCjBD2Jm6F160QSKqJoaOarDzCGRFRPkQyUwLwYNetdEJiiU5VaoGMKECGFIOKg4XDQrhYoDUSIDdOnHPOYxM5%2BCgMNyCRxSayVaHlN7OWvHnaeN6PBmkSykzCChyFln05HMM9Ks29CmldJKGkI1RK1Mn1z7ITpMOgbRjOo1UslkovrUJ1eTDYqfurjQVv4myLBZPJLqMFV3Gk1SAhcU7Pa4yiY97GQci8Co18qhjQhEIAIRiEAEIhCBCEQgAkcxgb06hhB9fiVvyM9tmPvgnS8HjS%2FPDRdhzpH0elf4%2BtjJexWBz2fncJkdwCdy1hyaV%2BR8W94fd4m%2FSVJYtZOYm8kD5cbKUzT%2B%2BJuTwIQUR7lUnFmuKO9YCL34BDEPvO95NT8pySaTktTA0d6%2BDbeH2XznpWOYgsEbpc84xWSBZQzXUj4iAagx%2FFweOnfYy3Tet9f0FAD2S3wAHYODzF0FTV18ONSK4CnLxIlPfOITfRVLMCXCKBjDpIbtxATakTRssHN0DJaYQSArzWH%2FtAW3ms4gCIEkQjlBY0UFwGIJC0EU1ACKimS0gglyQI9OIiuihxPF3gi5keEcHav8HR1jlAFNoEYziUBKopPIHFjkw7OWAzd%2FLyvnijaRlXiMHRdYYIPWcaIPnUf3kO0rX%2FnKF77wheorW0bqezzurcCie5A4Rg4imjFJ8IkTBQkw5gEPeABov%2FM7v6PJINXZZD7VIXZJvJijSjojZVBLOObgSKzdJx%2BuvXzMhQEENN2GIDD5kGVUiryjF%2Bk8jpKA9qlFPPWpT9VVtithQiTUQc56puo7StCg3U3O66%2BriW2SUW%2FWztk4rDqGoCm9gri0pIbRMWamye%2F%2F%2Fu8rZWvh6Bj6nuK0tSaj8NiwX5iHxEC5A4wxLkDzksTqbFcU2TG4rxGIQAQiEIEIRCACEYhABI5sAnt1DI7bTAnhxWw%2FgtW5b%2FxN3ujykrbmzTqf63cu5tddOWijY3gZPc6gU7iEoz%2FwKHmv%2FH1ixdan5oRy%2BrhdS8fgoDGMTkKX4KevckciEDFCPxEDP%2BsZzlHeLi9sR8fw3pmnr14yX5lsN0grM2Fh1Z0bK55EmgPrGAwWy4GSD29X%2BAqtYHIeI5%2FylKfMVxg5jAIn1G4V%2FahHPUqJAiHoEqNjmBEgYMDb%2F2XJ2rBmAjtxE2GypbFyU%2FToGODbqdbqy5ueHFiIsJ0r%2FWyMjjH6g2kUJkEQTOTv50GxFRoh25kZIRbCKZrPL18sq2xQAEbzoWOYhzIrgUjpVb4Ym9ExfBWrQ6zYnihCRk%2BQvzf%2BIiJWZ5gVL%2BkYhIgJa9meNdvaRWejY4gfWEu5oi2sYmYnKVHkydIxKCcO7c3HJCMdW%2BnQMcZZPi6Q6bpEDHxGx5if%2F5gE6y%2Fz9AHrrqw9dAkFEV4oV3x%2FGpSgINERSwaUkmESMAbkvS1yWHUMcR1kNLEfS8%2BhY5hkJGaJAdZpYc926sroGERFlugqflRlkaFc6XtEJHlOjWhKRBL629b%2BVdk2IhCBCEQgAhGIQAQiEIEIHDUE9uoY9hAreENC603N4IP72DC1YQ6tZR63FpImDlXHMJlCMmdtdQweLtnEGgUTxj95Ko4%2FJXhgq2NwLflWJqqMbz4p5026%2BQ6kBo4zeWRZ5T3yXh1Dhl7fcxvN6Vgp14Y4Af61swSZ8MEpIeZTsORQdQzv2U0K4K5%2BJ9rgG99QysrTBoBKXDqGQAIe4kgEK5n4EGnEiqx4DDoGAzjRMlfNbVuIkFE7zSSoYLn8KysbOzrGHOKQsoH0QQBRFqVlx05faU0kC%2B4tj17mFrSkOfCCxYRwhIW4cNVNW9BYWIlCoSyREYgq7JnlLA6sY7BWQAjNgQ2wEASoOmQK4QraVxSEShG1VhOLAZh4jKVjiBOw3sh3OuUh3RIWkot1IfapY6xWXjqGyA3zR4RqKHSLVG4kOLWmVBw%2BHWPiMbTU0FZTESxkH2RW05B3NOggslOaWY%2BFhKLbrGRr47DqGObXmJ8iYgquyWSaiZQhKzOnXCOCWFb%2B4oV077WHPX62FRaXnv4vNobktSYfTTwG5UpPWDm0EYEIRCACEYhABCIQgQhE4CgmsFfH4K3MVP3tT1WOX2nmgp%2BKMAeEMzh2co6sWSEu3Ya3ukIXDhCPsVfH4P%2FKk5%2FuPa9X6pMnlcMMFP4y%2F5RnPetjcMy9WbZcoakBLFwphVvwSeUzLrCX3csf5D%2FuzCvhwnurzpvjxd%2FznvdcqMkU5krYz3fj1hFk1iHrgcj%2FYHQMyo95FuvE7QYfeatjiDFQZUsleME9yVRTGIAq8%2BuXjuEoJuprJczlS0o%2FwTDK4mb6rEyQpEJYhtFrfeErnHHrnY5SYYFHIsOaDSGyxYly5uRu7Vw6xtR3fglU02Ayv6xBgaFy0DE4vFZ95BRzzFcOFliw58A6huiOmddDKVonWnKBrGEJi2li03NWpax8Yi7DzCsREKItJnhgnTvNffA6BqQ46BgmX6xMbIyMpp8fqo4hrIW%2BtD13tplH1VkRQbKymIbwEl13Jda1hLvMGhS6IvFqInNWp10pZ%2BOw6hiTnjC4ft9kZldRVBRnQV1KoIrbnvxnBdql9mxLn0NEqtEeHZr1MfYXjrU9t%2B0IRCACEYhABCIQgQhEIAJHHoG9OgZn1vKM%2FG7viOkVPCzevZh8AfM8Gv6sQ8Ie%2BNFmQIyzQ6DgOx8OHUPOqjZrCFgZkkDBi%2Fdem5vptS%2Fnndu71vmU0goGUzr3Vkr%2BFzdQ2ICU%2FFCBIjxuYSS8YKERsyjozjqf7OS800P4el5Gq52VFa2bwUHmmPPTSTEi5y1iwBjRC%2BY78M0PUsfYkQVWq%2B3oGAhbDVJF7n73uzMVRss4EDEEpZgWQY6YeSVyY61WkNIvg7DHbA4LZdBqTBLRFsPND5hy%2F70in5kp4%2BZzOYV8IIMDUBZ4lAn9gb8PFEde9IIoi50JAkvH8FKe8XxhYRhOFCPBHbZnq2PMKhOm0lBFKDNMokWgd6g6Br0IbY3FSKqCaT5M1Ry6okphokR8hBb4WPFDm6qIWgDCHvqM%2BRpanM4GLAMIVrM%2Bxt55JctDl37mlSAAsm3AdRXc8Bd%2BYFFT8gLgB9AxmKerEyv8XCl0OudqYhs4UAloMjLxVWI%2FHKMutAvdVdv5vRKTa4SXAEtJMOdFVmIndDZXkzTzmTk7k%2FNh1TGcpVIK1Tf0CiE9LitfxSA5pKYuK7TpWkoBnCxppVyWT3G0F0E7%2BpgL3JXlMlnqogR6hfYlAKrapO9vBCIQgQhEIAIRiEAEIhCBo54AH0roOwdw%2B0aYyzw%2FRultsrf2PD4uqlUFmMdjFdXAM%2BJ4mnRggyM5iyKOx8RZm1pYyNG53EPOEQ9ISi%2BFxwPyd9zz8Xn5p95cS6AUL99tiG1485vfLB%2Fn8r55XrM6Iq%2Ff0hASmIYwa2lKyaGeEsVvjElsc4pkPn46U3E8NS6YJQoFEkhsJUO%2Bud9uYKFlG21YnYBbCoL0zsKEMTbU3d%2F5RdQHPehBti0yMMWtv5gIrmAPP3Tt3G6Ya%2BBEms%2FaKaUIEDuZMRUR5TI%2Fn8GzHh1DK0jP52W2lOwRGsHUUWAc4mNahtQhYgtv1AZ%2Ff17Ec9u1qT1akLPvKz3HV462KnOf1WuEqWWSDTqG2S6S8WR9xY1I4qtzNYQ9VsX0lQggJoSLPXkSnYYVz9fR%2BZmbwThrfjpRD8FZp5IP%2F5pwJKU91ouwwRifiVIgKwkHslOec9S2Esk78rEuqMrao6Fng6rD9YYCAbMn1voYo7CtdT4R0BXRnh8EgdrP18rHWcNfhAyVhgRh3grlZAVR6HiSwTIxDDL01dwQEy5W0AjDfCgtjBHoMg1nD6tEkkivu2o76o1%2BJcoFWIeEnTgkvsVRldVSPhrXRJ5D8vvOH5C1KVD0qLVzbWgOvVpH2ioqSp9C8SGbKAI9gsac5fKcDq8bUIQcpXto9zmq1i4oO30ssiF%2BY5Vlw3K%2BrNUE251tRyACEYhABCIQgQhEIAIROIoJeOVtdjzvbBzVVTpvmp9r1ga3TnjG1qPhh1rS0OKEpipwbcZr45p5ySvlWiBRMmHzMiEdePPuDTs1YJxBf6kiXuxaKmFK5GaygfNrbQRBFyOMOORcL9%2B9lKcVTEr%2BrJfLDNtJOUfNTZAtm73WH9nB72%2ByjZGqKb5ieXxe4rNWsIeZEX6XZPQNmXjLL0LALA8VtHyoN%2B%2ByMllGJv6qBW93ylp%2FsfrN3%2FxNlVWLtXO7QcYx3WOCT9Z%2BBQ1hGo6lKjjjc8j%2BrY5hp1f%2FvHKuNGvve9%2F7eo2%2BMnHIL7ESiEQRiMfYTmxhp0kxMvdDtNJz8AUnmHgiYEB0h1iU5b2u3Oyh0niVv%2Fibn6LKa1lL2hRo1gidWRhMtZaCorHinltBRTVpINoXNyeu9iVVaQ5ppptpDrEK%2Bg97xEL4gLz0KD6%2BPqCy5itZlJLvTwChEoydfG1NqfXVWt3HQ8fBRBXdcibdSEkHYwxpa84SfoC2WRLLoyf4OIUNlkB5%2BMMfrlApdQ%2F9jag1vdoeFwgxxwIsg4sZugeqGkJ4w2S%2B%2FjpEkLH%2BxtqjRezUXU3HuNvd7raEHfuVzkIV99Mw6%2BPrio6Qieq4LqBbZq%2BcbWgOK3OK65gIkHWIzTJnpKpNeNI6ZENrKk43sNKsZlo93yFhLbqipiF3bIVNh%2FRtyhi1Z5%2BWbPNvOwIRiEAEIhCBCEQgAhGIwPeWwCgP%2B7ThAIf2mf5gdh58nntTclq50qsU%2FqPXyhy6tWfvxt5MJg3VYm%2FiI2nPjg28Y%2BtnCvDYie5g0k7KZY9DPuvrdmPvKXvli236w7G9v6IPNau9tjmFhEI%2BWo65t%2F%2BiI4hRa89k69zDXe7WsMOXz%2F7OIiyYrSPaRBzFthTbRzj2nfz3ft2fkZPysNojzIZEs40p2ltieyIQgQhEIAIRiEAEIhCBCETg4Anwc61dQLgQZi9KxBKXpjlYBWJNEDj4rL5XKbmWYicEIViRYztF4ntlz%2FekXJEtGtFUGgIU%2BcJEGHMuZr2O74k9h7VQP65q8oUwiQlZOaynHw3T04tMrnEpmeEi9OhoaGEmRSACEYhABCIQgQhEIAIR%2BD4lICre%2BoSWa%2BAI%2B3gtviZEfF%2FUiMNokgvLLZLwohe96PvC5iPcSJ6ytWQtZHFIGx7DQpSmwBzhpRx5GRKjyGhWulhTWo68so6anM21MW%2FIXKc19emoKbdSIhCBCEQgAhGIQAQiEIEI%2FJAQoGb4cQ0LO6ylEr6PKm7Rhte97nU5jNbTEAMglmbvGhRH%2F9bk%2BIurUYWjv6kHY6H5KeJh9rnQ6MGcXpoIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIhCBCEQgAt8lgf8XwAklVQplbmRzdHJlYW0KZW5kb2JqCjExIDAgb2JqCjw8IC9UeXBlIC9YT2JqZWN0IC9TdWJ0eXBlIC9JbWFnZSAvV2lkdGggMTQzMiAvSGVpZ2h0IDk5OCAvQ29sb3JTcGFjZSAvRGV2aWNlR3JheQovSW50ZXJwb2xhdGUgdHJ1ZSAvQml0c1BlckNvbXBvbmVudCA4IC9MZW5ndGggNjI1NSAvRmlsdGVyIC9GbGF0ZURlY29kZSA%2BPgpzdHJlYW0KeAHt0DEBAAAAwqD%2BqWcJT4hAYcCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMDAa2AtxAdXCmVuZHN0cmVhbQplbmRvYmoKOSAwIG9iago8PCAvVHlwZSAvRXh0R1N0YXRlIC9BQVBMOkFBIGZhbHNlID4%2BCmVuZG9iagoxMCAwIG9iago8PCAvVHlwZSAvRXh0R1N0YXRlIC9BQVBMOkFBIHRydWUgPj4KZW5kb2JqCjEyIDAgb2JqCjw8IC9OIDMgL0FsdGVybmF0ZSAvRGV2aWNlUkdCIC9MZW5ndGggMjYxMiAvRmlsdGVyIC9GbGF0ZURlY29kZSA%2BPgpzdHJlYW0KeAGdlndUU9kWh8%2B9N73QEiIgJfQaegkg0jtIFQRRiUmAUAKGhCZ2RAVGFBEpVmRUwAFHhyJjRRQLg4Ji1wnyEFDGwVFEReXdjGsJ7601896a%2FcdZ39nnt9fZZ%2B9917oAUPyCBMJ0WAGANKFYFO7rwVwSE8vE9wIYEAEOWAHA4WZmBEf4RALU%2FL09mZmoSMaz9u4ugGS72yy%2FUCZz1v9%2FkSI3QyQGAApF1TY8fiYX5QKUU7PFGTL%2FBMr0lSkyhjEyFqEJoqwi48SvbPan5iu7yZiXJuShGlnOGbw0noy7UN6aJeGjjAShXJgl4GejfAdlvVRJmgDl9yjT0%2FicTAAwFJlfzOcmoWyJMkUUGe6J8gIACJTEObxyDov5OWieAHimZ%2BSKBIlJYqYR15hp5ejIZvrxs1P5YjErlMNN4Yh4TM%2F0tAyOMBeAr2%2BWRQElWW2ZaJHtrRzt7VnW5mj5v9nfHn5T%2FT3IevtV8Sbsz55BjJ5Z32zsrC%2B9FgD2JFqbHbO%2BlVUAtG0GQOXhrE%2FvIADyBQC03pzzHoZsXpLE4gwnC4vs7GxzAZ9rLivoN%2Fufgm%2FKv4Y595nL7vtWO6YXP4EjSRUzZUXlpqemS0TMzAwOl89k%2FfcQ%2F%2BPAOWnNycMsnJ%2FAF%2FGF6FVR6JQJhIlou4U8gViQLmQKhH%2FV4X8YNicHGX6daxRodV8AfYU5ULhJB8hvPQBDIwMkbj96An3rWxAxCsi%2BvGitka9zjzJ6%2Fuf6Hwtcim7hTEEiU%2Bb2DI9kciWiLBmj34RswQISkAd0oAo0gS4wAixgDRyAM3AD3iAAhIBIEAOWAy5IAmlABLJBPtgACkEx2AF2g2pwANSBetAEToI2cAZcBFfADXALDIBHQAqGwUswAd6BaQiC8BAVokGqkBakD5lC1hAbWgh5Q0FQOBQDxUOJkBCSQPnQJqgYKoOqoUNQPfQjdBq6CF2D%2BqAH0CA0Bv0BfYQRmALTYQ3YALaA2bA7HAhHwsvgRHgVnAcXwNvhSrgWPg63whfhG%2FAALIVfwpMIQMgIA9FGWAgb8URCkFgkAREha5EipAKpRZqQDqQbuY1IkXHkAwaHoWGYGBbGGeOHWYzhYlZh1mJKMNWYY5hWTBfmNmYQM4H5gqVi1bGmWCesP3YJNhGbjS3EVmCPYFuwl7ED2GHsOxwOx8AZ4hxwfrgYXDJuNa4Etw%2FXjLuA68MN4SbxeLwq3hTvgg%2FBc%2FBifCG%2BCn8cfx7fjx%2FGvyeQCVoEa4IPIZYgJGwkVBAaCOcI%2FYQRwjRRgahPdCKGEHnEXGIpsY7YQbxJHCZOkxRJhiQXUiQpmbSBVElqIl0mPSa9IZPJOmRHchhZQF5PriSfIF8lD5I%2FUJQoJhRPShxFQtlOOUq5QHlAeUOlUg2obtRYqpi6nVpPvUR9Sn0vR5Mzl%2FOX48mtk6uRa5Xrl3slT5TXl3eXXy6fJ18hf0r%2Bpvy4AlHBQMFTgaOwVqFG4bTCPYVJRZqilWKIYppiiWKD4jXFUSW8koGStxJPqUDpsNIlpSEaQtOledK4tE20Otpl2jAdRzek%2B9OT6cX0H%2Bi99AllJWVb5SjlHOUa5bPKUgbCMGD4M1IZpYyTjLuMj%2FM05rnP48%2FbNq9pXv%2B8KZX5Km4qfJUilWaVAZWPqkxVb9UU1Z2qbapP1DBqJmphatlq%2B9Uuq43Pp893ns%2BdXzT%2F5PyH6rC6iXq4%2Bmr1w%2Bo96pMamhq%2BGhkaVRqXNMY1GZpumsma5ZrnNMe0aFoLtQRa5VrntV4wlZnuzFRmJbOLOaGtru2nLdE%2BpN2rPa1jqLNYZ6NOs84TXZIuWzdBt1y3U3dCT0svWC9fr1HvoT5Rn62fpL9Hv1t%2FysDQINpgi0GbwaihiqG%2FYZ5ho%2BFjI6qRq9Eqo1qjO8Y4Y7ZxivE%2B41smsImdSZJJjclNU9jU3lRgus%2B0zwxr5mgmNKs1u8eisNxZWaxG1qA5wzzIfKN5m%2FkrCz2LWIudFt0WXyztLFMt6ywfWSlZBVhttOqw%2BsPaxJprXWN9x4Zq42Ozzqbd5rWtqS3fdr%2FtfTuaXbDdFrtOu8%2F2DvYi%2Byb7MQc9h3iHvQ732HR2KLuEfdUR6%2BjhuM7xjOMHJ3snsdNJp9%2BdWc4pzg3OowsMF%2FAX1C0YctFx4bgccpEuZC6MX3hwodRV25XjWuv6zE3Xjed2xG3E3dg92f24%2BysPSw%2BRR4vHlKeT5xrPC16Il69XkVevt5L3Yu9q76c%2BOj6JPo0%2BE752vqt9L%2Fhh%2FQL9dvrd89fw5%2FrX%2B08EOASsCegKpARGBFYHPgsyCRIFdQTDwQHBu4IfL9JfJFzUFgJC%2FEN2hTwJNQxdFfpzGC4sNKwm7Hm4VXh%2BeHcELWJFREPEu0iPyNLIR4uNFksWd0bJR8VF1UdNRXtFl0VLl1gsWbPkRoxajCCmPRYfGxV7JHZyqffS3UuH4%2BziCuPuLjNclrPs2nK15anLz66QX8FZcSoeGx8d3xD%2FiRPCqeVMrvRfuXflBNeTu4f7kufGK%2BeN8V34ZfyRBJeEsoTRRJfEXYljSa5JFUnjAk9BteB1sl%2FygeSplJCUoykzqdGpzWmEtPi000IlYYqwK10zPSe9L8M0ozBDuspp1e5VE6JA0ZFMKHNZZruYjv5M9UiMJJslg1kLs2qy3mdHZZ%2FKUcwR5vTkmuRuyx3J88n7fjVmNXd1Z752%2Fob8wTXuaw6thdauXNu5Tnddwbrh9b7rj20gbUjZ8MtGy41lG99uit7UUaBRsL5gaLPv5sZCuUJR4b0tzlsObMVsFWzt3WazrWrblyJe0fViy%2BKK4k8l3JLr31l9V%2FndzPaE7b2l9qX7d%2BB2CHfc3em681iZYlle2dCu4F2t5czyovK3u1fsvlZhW3FgD2mPZI%2B0MqiyvUqvakfVp%2Bqk6oEaj5rmvep7t%2B2d2sfb17%2FfbX%2FTAY0DxQc%2BHhQcvH%2FI91BrrUFtxWHc4azDz%2Bui6rq%2FZ39ff0TtSPGRz0eFR6XHwo911TvU1zeoN5Q2wo2SxrHjccdv%2FeD1Q3sTq%2BlQM6O5%2BAQ4ITnx4sf4H%2B%2BeDDzZeYp9qukn%2FZ%2F2ttBailqh1tzWibakNml7THvf6YDTnR3OHS0%2Fm%2F989Iz2mZqzymdLz5HOFZybOZ93fvJCxoXxi4kXhzpXdD66tOTSna6wrt7LgZevXvG5cqnbvfv8VZerZ645XTt9nX297Yb9jdYeu56WX%2Bx%2Baem172296XCz%2FZbjrY6%2BBX3n%2Bl37L972un3ljv%2BdGwOLBvruLr57%2F17cPel93v3RB6kPXj%2FMejj9aP1j7OOiJwpPKp6qP6391fjXZqm99Oyg12DPs4hnj4a4Qy%2F%2FlfmvT8MFz6nPK0a0RupHrUfPjPmM3Xqx9MXwy4yX0%2BOFvyn%2BtveV0auffnf7vWdiycTwa9HrmT9K3qi%2BOfrW9m3nZOjk03dp76anit6rvj%2F2gf2h%2B2P0x5Hp7E%2F4T5WfjT93fAn88ngmbWbm3%2FeE8%2FsKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqClsgL0lDQ0Jhc2VkIDEyIDAgUiBdCmVuZG9iagoyIDAgb2JqCjw8IC9UeXBlIC9QYWdlcyAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvQ291bnQgMSAvS2lkcyBbIDEgMCBSIF0gPj4KZW5kb2JqCjEzIDAgb2JqCjw8IC9UeXBlIC9DYXRhbG9nIC9QYWdlcyAyIDAgUiAvVmVyc2lvbiAvMS40ID4%2BCmVuZG9iago3IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UcnVlVHlwZSAvQmFzZUZvbnQgL0FBQUFBQytDYWxpYnJpIC9Gb250RGVzY3JpcHRvcgoxNCAwIFIgL1RvVW5pY29kZSAxNSAwIFIgL0ZpcnN0Q2hhciAzMyAvTGFzdENoYXIgNzYgL1dpZHRocyBbIDU0MyA0OTggNTI1CjUyNSAzOTEgMzM1IDIyNiA0NzkgNjYyIDU2NyA0MjMgMzQ5IDIyOSAzMDUgNTI3IDc5OSA1MTcgNzE1IDU3OSAyNTIgNTI1IDUyNQoyNjggMzg2IDI1MiA1MjUgNTI1IDQ3MSA0NTIgNTMzIDM5NSA1MjUgNDIwIDYxNSAyMjkgNDMzIDQ4OCA2NDYgMzA2IDUwNyA1MDcKNTA3IDUwNyA1MDcgXSA%2BPgplbmRvYmoKMTUgMCBvYmoKPDwgL0xlbmd0aCA0ODUgL0ZpbHRlciAvRmxhdGVEZWNvZGUgPj4Kc3RyZWFtCngBXZPNitswFEb3fgotp4shiqUkM2AMw5SBLPpD0z6AY8nB0NjGcRZ5%2B57vZjqFLs7i%2BEpX97Ot1ev%2B837oF7f6Po%2FtIS%2Bu64c058t4ndvsjvnUD8W6dKlvl3ezZ%2B25mYoVmw%2B3y5LP%2B6EbXVUVzq1%2BsOWyzDf38JLGY%2F6kZ9%2FmlOd%2BOLmHX68He3K4TtPvfM7D4nxR1y7ljnZfmulrc85uZVsf94l6v9we2fVvxc%2FblB0TsWN9H6kdU75MTZvnZjjlovK%2Brt7e6iIP6b9SDPcdx%2B59abmuK%2BH9pqyLqixR8H67kQYUvN%2BtpREF1KobdGsaVN2h4H3ppU8o0Mr2PqPgfexUbVDg3K30iAKLrVWLAgfZVAkFqs9anFFAbW%2BHAqrOgfACTVLCCQ7SVIFwgs47KeEEU2nIQDiB6qBAQEGrJylZBXutFVmD5Q2NqmQVxLcxyBosL6NSJaugVZSSVaAKGMgqUJuKrMHy7hQwkFUwlV5OIKtgDJ0bySrYq4PIYbC4lZJVoDqXBgaLrUpWnquVAkayChbr%2B0ayCtQ6kzXev69ebCSr8J73iZJVoHqTkawCtVZkjZaXT0OVrIIq75nf9O%2F%2FqD9WN%2BvjJrTXeeYS2PWz%2B6H%2Fvh%2Fyxw2dxkkNjD%2Fx2fi2CmVuZHN0cmVhbQplbmRvYmoKMTQgMCBvYmoKPDwgL1R5cGUgL0ZvbnREZXNjcmlwdG9yIC9Gb250TmFtZSAvQUFBQUFDK0NhbGlicmkgL0ZsYWdzIDQgL0ZvbnRCQm94IFstNTAzIC0zMTMgMTI0MCAxMDI2XQovSXRhbGljQW5nbGUgMCAvQXNjZW50IDk1MiAvRGVzY2VudCAtMjY5IC9DYXBIZWlnaHQgNjMyIC9TdGVtViAwIC9YSGVpZ2h0CjQ2NCAvQXZnV2lkdGggNTIxIC9NYXhXaWR0aCAxMzI4IC9Gb250RmlsZTIgMTYgMCBSID4%2BCmVuZG9iagoxNiAwIG9iago8PCAvTGVuZ3RoMSAyOTA0NCAvTGVuZ3RoIDE2MDgxIC9GaWx0ZXIgL0ZsYXRlRGVjb2RlID4%2BCnN0cmVhbQp4AdV9d3hcxd313Hu396It0kraXa20KqsuWcVFWlvFKm6yLVuyLVuy3Fn3Bi7YdBA4QGgxgQAJGBITvFo3gSlO4oSQxIQklFBCIG8SWpxAQkKV9J2Z2ZFlCG%2F%2B%2BJ7vefLJOjpn5s7M3vubmd%2BUOwvbtmxfSYxkP1FIaf%2F6vk2E%2FdS%2BAdrUv2NbgAVJuJEQ9eOrNq1ez8OFIHNkdeySVTw88WFCqg6tWdm3gofJ5%2BCqNYjgYakSnL1m%2FbaLebiWFnAstrE%2FeX3iHISXrO%2B7OPn55DWEAxv61q%2Fk6TfPp%2BFNW1Ymr0tdKO490ou%2Ff5Vkyqo%2Fz2O84hwP%2F%2FEthCXkKsJVNZgQmdhICbmGEEeVPIHF0Ouaior79HcPL7NO%2FidJ1bHoU%2B%2Ft%2BQUVL9w%2BsOqzT4f36%2F%2Biq0JQjxL4D%2FJpvzX8CiGGez%2F79NN79X9hn5S8yKhoUK9MnSf%2FVP4JqSF%2B%2Bekk%2F47UyK%2BQTvll8Evg3yb5RfALCD8P%2Fg341%2BBfgZ8CPwl%2BAvw46SQq%2BVVSCcwHlDG1AqH7gecBNbkIJUnEiPwSSZF%2FSBqBFcA24FZAjbRP4tr9KFEiAfnKY3qv1BYYkq8Q4nIhLhNivxD7hLhUiL1C7BFitxC7hLhEiIuF2CnEDiG2C7FNiK1CbBZikxAbhdggxHohYkJcJMQ6IdYKsUaI1UKsEmKlECuE6BdiuRB9QvQKsUyIpUL0CLFEiMVCLBKiW4guIRYKsUCITiHmCzFPiLlCdAgxR4jZQswSYqYQM4RoF6JNiFYhWoSYLkSzEE1CNArRIMQ0IaYKERWiXog6IaYIMVmISUJMFKJWiBohqoWoEmKCEJVCVAhRLkSZEKVClAhRLESREIVCRIQoECJfiDwhcoUIC5EjRLYQISGyhAgKERDCL0SmEBlCpAvhEyJNiFQhvEJ4hHAL4RIiRQinEA4h7ELYhLAKYRHCLIRJCKMQBiH0QuiE0AqhEUIthEoIRQhZCEkIkhTSqBAjQgwL8bkQnwnxqRCfCPGxEB8J8S8h%2FinEh0L8Q4i%2FC%2FGBEO8L8Tch%2FirEOSH%2BIsR7QrwrxDtCvC3EW0L8WYg%2FCfFHIf5HiD8I8aYQbwjxeyFeF%2BJ3QrwmxKtCvCLEy0L8VoiXhHhRiBeEeF6I3wjxayF%2BJcRzQvxSiGeFOCvEL4T4uRA%2FE%2BIZIX4qxNNC%2FESIHwtxRogfCfFDIX4gxGkhnhLiSSGeEOJxIU4J8ZgQjwoxJMRJIU4IcVyIY0IcFSIhxKAQcSGOCPGIEN8X4mEhDgvxPSG%2BK8RDQjwoxCEhHhDifiG%2BI8S3hbhPiHuFuEeIbwlxtxB3CfFNIe4U4qAQ3xDiDiFuF%2BI2IW4V4hYhvi7EzULcJMSNQnxNiANC3CDE9UIMCHGdENcKcY0QVwtxlRBXCnGFEJcLcZkQ%2B4XYJ8SlQuwVYo8Qu4XYJcQlQlwsxE4hdgixXYhtQmwVYosQm4XYJMRGITYIsV6ImBAXCbFOiLVCrBFitRCrhFgpxAoh%2BoVYLkSfEL1CLBNiqRA9QiwRYrEQi4ToFqJLiIVCLBCiU4j5QswTYq4Qc4SYLcQsIWYI0S5EmxCtQrQIMV2IZiGahGgUouEonS0PyVcmMuv8mDMnMl2gy3noskTmRIT289A%2BTpcmMk2I3MtDezjt5rSL0yWJjKlIcnEiowG0k9MOTtv5tW08tJXTFh65OZExDRk2cdrIaQNPsp5TjNNFifQmpFzHaS2nNZxWc1qVSG9EkpU8tIJTP6flnPo49XJaxmkpz9fDQ0s4Lea0iFM3py5OCzkt4NTJaT6neZzmcurgNIfTbE6zOM3kNINTO6e2hK8Vz9DKqSXha0NoOqfmhK8doaaEbwaokVMDp2n82lSeL8qpnuer4zSF02SechKniTx7LacaTtWcqjhN4IVVcqrgpZRzKuNUygsr4VTM8xVxKuQU4VTAKZ9THqdcXnSYUw4vM5tTiFMWLzrIKcDz%2BTllcsrglM7JxyktkTYLxkrl5E2kzUbIw8nNI12cUnikk5ODk51fs3Gy8kgLJzMnE79m5GTgpOfXdJy0nDSJ1Dn4dHUitQOk4qTwSJmHJE6EkTTKaYQlkYZ56HNOn3H6lF%2F7hIc%2B5vQRp39x%2BmfCO98%2FJH2Y8M4D%2FYOH%2Fs7pA07v82t%2F46G%2FcjrH6S%2F82nuc3uWR73B6m9NbnP7Mk%2FyJh%2F7IQ%2F%2FDQ3%2Fg9CanN%2Fi133N6nUf%2BjtNrnF7l9ApP8jIP%2FZbTSwnPQjzKiwnPAtALnJ7nkb%2Fh9GtOv%2BL0HE%2FyS07P8siznH7B6eecfsaTPMPppzzyaU4%2F4fRjTmc4%2FYin%2FCEP%2FYDTaU5P8WtPcnqCRz7O6RSnxzg9ymmIpzzJQyc4Hed0jNPRhLseD51IuBeDBjnFOR3h9Ain73N6mNNhTt9LuOH1pe%2FyUh7i9CC%2FdojTA5zu5%2FQdTt%2FmdB%2Bnezndwwv7Fi%2Flbk538Wvf5HQnp4OcvsEz3MFDt3O6jdOt%2FNotvJSvc7qZX7uJ042cvsbpAKcbeMrreWiA03WcruV0DaerE64%2BPPtVCddy0JWcrki4ViF0OafLEq5OhPYnXBhspH0JVxXoUk57efY9PN9uTrsSrhVIcgnPfjGnnZx2cNrOaRunrbzoLTz7Zk6bEq5%2BlLKRF7aBp1zPKcbpIk7rOK3l%2BdZwWs3vbBXPvpLTCp6yn9NyTn2cejkt47SUP3QPv7MlnBbzh17Ei%2B7mH9TFaSG%2F3QX8gzp5KfM5zeM0l1NHIiWKB5uTSKFmnZ1IoR12ViLlCtDMREoRaAZP0s6pLZGCiYTUykMtnKbzyOZEyqW41pRIuQbUmEjZB2pIpOwHTUs4mkFTOUU51XOqSzgwL5Cm8NDkhL0boUmcJibstB%2FVcqpJ2KcjVJ2wd4GqEvZFoAn8WiWnioS9EJHlPGVZwk4frDRhpw6phFMxz17EP6GQU4QXVsApnxeWxymXU5hTTsJOrZTNKcTLzOJlBnlhAV6Kn1Mmz5fBKZ2Tj1Map9SErQdlehO2pSBPwrYM5Obk4pTCycnJwTPYeQYbj7RysnAyczLxlEae0sAj9Zx0nLScNDylmqdU8UiFk8xJ4kSio9blfooRa79%2F2LrC%2Fzn0Z8CnwCeI%2BxhxHwH%2FAv4JfIj4fwB%2Fx7UPEH4f%2BBvwV%2BAc4v8CvIdr7yL8DvA28BbwZ8tq%2F58sa%2Fx%2FBP4H%2BAPwJuLeAP8eeB34HcKvgV8FXgFeBn5rvsj%2FkrnM%2FyL4BXPM%2F7w57P8N8GvoX5kj%2FueAXwLP4vpZxP3CvN7%2Fc%2BifQT8D%2FVPzOv%2FT5rX%2Bn5jX%2BH9sXu0%2Fg7w%2FQnk%2FBH4AREdP4%2B9TwJPAE6bN%2FsdNW%2FynTFv9j5m2%2BR8FhoCTiD8BHMe1Y7h2FHEJYBCIA0eMl%2FgfMe7yf9%2B4x%2F%2Bwca%2F%2FsPFS%2F%2FeA7wIPAQ8Ch4AHjEX%2B%2B8HfAb6NPPeB7zVe5L8H%2BlvQdwN3QX8TZd2Jsg6irG8g7g7gduA24FbgFuDryHczyrvJMMt%2Fo2G2%2F2uG1f4Dhgf8Nxge9F%2Bl5PivVGr8V0g1%2Fss793dednh%2F577OvZ2XHt7badwrGff69rbv3b338N5X90YdGsOezl2duw%2Fv6rykc2fnxYd3dj4mX01WyVdFJ3fuOLy9U7U9Zfu27cqH26XD26XG7VLpdrw42W7bHtiumLZ1buncenhLJ9kyZ8v%2BLfEtqknxLW9skckWyTA0evroFl9mMzi6Z4vZ1ry5c2PnpsMbOzesWt%2B5Dje4tmZ155rDqztX1azoXHl4RWd%2FzfLOvprezmU1PZ1LD%2Fd0LqlZ1Ln48KLO7pquzoVIv6Bmfmfn4fmd82o6Ouce7uicXTOrcxbiZ9a0d8443N7ZVtPS2Xq4pXN6TXNnEx6epNvSA%2BmKjd7ArHTcCfFJ00p9Ud8bvvd9KuKL%2B077FIc1zZ8m51tTpYbZqdLG1H2pN6YqVu8vvXLUm1%2FYbPX80vN7z988KmfUk1%2FcTNw2d8CtuOizuWfOp8921F3fyLlsAntWvzsUbra6JKvL75Kb%2FuaSriaKFJAkItlAig55jkkuf7PyBKLwsoxI0k1kfqR9SEfmtsd1cxbHpWvjOfPo32jHorjm2jjpXLS4a1CSvtY9KMkN8%2BMp7R2LePiqAwdIxrT2eMa8roRy770Z07rb4%2FupjkaZHqWaIEl3ZOnW7VsjXdEpxP6G%2FX274nrK9kubbLVKVuuoVY5acfNWi98i0z%2BjFiVqKatutpr9Zpn%2BGTUr7qgZMdSUuaY585utRr9R7qw3zjbKUWN9Q3PUWFTa%2FKXnPEqfk39yZNvSrRHIbRH2i1C3tJ0G8YMr%2BN26DWH6D4QwoVe%2B%2BocnQ7plW%2FHDiuHFf3WW%2Fw%2BuSP8f3ON%2F%2BS0OEnSRrqmj8pV4l3kFcDlwGbAf2AdcCuwF9gC7gV3AJcDFwE5gB7Ad2AZsBTYDm4CNwAZgPRADLgLWAWuBNcBqYBWwElgB9APLgT6gF1gGLAV6gCXAYmAR0A10AQuBBUAnMB%2BYB8wFOoA5wGxgFjATmAG0A21AK9ACTAeagSagEWgApgFTgShQD9QBU4DJwCRgIlAL1ADVQBUwAagEKoByoAwoBUqAYqAIKAQiQAGQD%2BQBuUAYyAGygRCQBQSBAOAHMoEMIB3wAWlAKuAFPIAbcAEpgBNwAHbABlgBC2AGTIARMAB6QAdoAQ2gBlRTR%2FFXAWRAAghZISFOGgGGgc%2BBz4BPgU%2BAj4GPgH8B%2FwQ%2BBP4B%2FB34AHgf%2BBvwV%2BAc8BfgPeBd4B3gbeAt4M%2FAn4A%2FAv8D%2FAF4E3gD%2BD3wOvA74DXgVeAV4GXgt8BLwIvAC8DzwG%2BAXwO%2FAp4Dfgk8C5wFfgH8HPgZ8AzwU%2BBp4CfAj4EzwI%2BAHwI%2FAE4DTwFPAk8AjwOngMeAR4Eh4CRwAjgOHAOOAglgEIgDR4BHgO8DDwOHge8B3wUeAh4EDgEPAPcD3wG%2BDdwH3AvcA3wLuBu4C%2FgmcCdwEPgGcAdwO3AbcCtwC%2FB14GbgJuBG4GvAAeAG4HpgALgOuBa4BrgauIqsmLpfuhLqCuBy4DJgP7APuBTYC%2BwBdgO7gEuAi4GdwA5gO7AN2ApsATYDm4CNwAZgPRADLgLWAWuBNcBqYBWwElgB9APLgT6gF1gGLAV6gCXAYmAR0A10AQuBBUAnMB%2BYB8wF5gCzgVnADKAdaANagRZgOtAMNAGNQANZ8V%2Fupv%2Fbb6%2F7v%2F0G%2F8vvj9Bp2djEjN6sd9lSHHjSfouQkVvGH4Aic8g6spXsx7%2BryQFyC3mKvEqWkyugDpJ7ySHyXRInPyDPkJcuyPV%2FGRi5RL2emJSTREOchIx%2BOnpu5BAwpLaMi7kFIacqcD5m1Db61y%2FE%2FXXkllHbyJDGQQwsr1n%2BNUr7hzQ8%2BimGXA0xj1bRsHwNtJV90gfab40cGXnwggeYQzrIIrKYLCE9OIXWh%2BdfQdaQtbDMRSRG1pMNLLQB11ZDr0JoGVLBvTB9PtVGsolsJFvINrKd7MC%2FTdBbkyF6bTMLbyc78e9icgnZRXaTPWRv8u9OFrMHV3ax2Itx5VKyDzVzGbmcKcE85gpyJbkKtXYNuZZchxr76tB1Y6kGyPXkBtTz18iN5Kv0gQuu3ERuIjeTr6M93EpuI7eTb6BdfJPc9YXYO1j8neRb5B60GZrjNsTcw9Tt5A7yOPkJOU4eIUfICWbLftiWW0TYZRWz9CbYYA%2Be%2BYpxd8ytuXPMWpfCGvS5B5LPfTHsd%2Fm4HDuSdqTWuwIpqXUGkvVAS9mbjBGWuAlPxvX556Q2os9w4wXPKXL8p1j6xNROd8FewjLUZrcj7s4vxY5PMV7fTu5GD7wPf6lVqfo2NFf3MD0%2B%2Fltjae9l175D7icPoC4eJFQJ5jGHEPcgeQh9%2B3vkMHkY%2F87r8YpffYR8n9VcnAySBDlKjqEmT5CTZIjF%2F2%2FXjsB3fDHP0WRZibFSHiWPkVNoIU%2BS0%2FA0P8Q%2FEfME4p5Kxp5hqXj4h%2BRH5AxLRa%2F%2BEG3raXion5Gfk1%2BQX5IfI%2FQs%2B%2FtThJ4jvya%2FIS9JZqhfkXfwd5g8p%2F4jsZCpWP4%2Fhtq4iyzFv%2F%2BHP%2Bo04iL3jn48unP0Y6WFrJLmYwL5MGrpGLkBOxMbzn%2B05CcG1R9ICjk2%2Bi9lCThv%2BBX1mpFvj%2F4tuujqq7Zt3bJ508YN62MXrVu7ZvWqlSuWL1vas2Txou6uzvnz5nbMmT1r5oz2ttaW6c1NjQ3Tpkbr66ZMnjSxtqa6akJJcVFhXjgnO5Tl96bYbVaz0aDXaTVqlYL5eWFTqLk3EA%2F3xlXhUEtLEQ2H%2BhDRNy6iNx5AVPOFaeIBmq8Ply5IGUXKVV9IGeUpo2MpJVtgMplcVBhoCgXiZxtDgSFpUUcX9IHGUHcgfo7pmUyrwixgRiAYRI5Ak3dNYyAu9Qaa4s071gw09TYWFUqDRkNDqGGloaiQDBqMkEaoeF5o06CUVycxIec1TRyUic5MPzau5DT1rYjP6ehqavQFg90sjjSwsuKahriWlRVYG8c9k%2BsDg4WnB24YspHlvRHTitCKviVdcaUPmQaUpoGBa%2BL2SDw%2F1BjP3%2FVHLwy4Ml4YamyKR0K4sfa5Yx8gxdU5tlBg4J8ENx869xfc9biYvmSMJsf2T0Iv0kccM1Nc6hOa4N5wh3i%2BYJDey%2FVDUbIcgfj%2Bji4eDpDlvgSJlkS643IvvXJaXHF10iv7xZWx7L0hWLYp1NSb%2FN2xxhvfvzxQVIiaZb85cVUOrgfiSrh3ef8ayn0rB0KNeELYkszvikcbIaJ9SWM2DZaWIH1fLx5iLTVDR1e8JLQpnhKaxq2NCBSS07R2XhfLwmOb4ikNcdLbn8wVL2lCXjSRpgFaMfQGaVmhjq5HScXoG4OVAd%2FRClJJuul9xN0NqJRw00DXilVxf69vBdrnqkCXLxiPdsN83aGuld20lkK2eP4b%2BDj8oAJZLjzbF1KLxHjsuDZHF%2BiSfUo3rS1EBJrxJzRtMi7Y4hoepDU6bXKgS%2FIRkQyfkkxB1QXlIKDkNLQgMxhZG1p8QTRu9vO%2F3JKPPwBuI64buycVbkJ9%2Fp7453zlrfHU9IbyA00rG8fd4AWFIsBuMFnav79PmdoiaQzcgo5WZwt9hqJCGTqAy7q4jOdkUbQWvYE4mRPoCq0MdYfQhqJzumjlUFuz%2Bm2fF6Lbq6y2k61k%2FgUhfr2GX4uTYPv8LhGgO0%2Fx5girV1qtLDydhceCLV%2B43Couw%2B%2BQOQMDKwaJkkObsm9QYkLdcH13fHakOxRfHgkF6X0WFQ7qiCk4v7cBvbcZnjPU3BcK2ALNA31Do%2FuXDwxGowObmnrXTES%2FGAi1rhgIzeuajMpljmCvbxe9Fwdpl9rnT0NRMpk2GJKu7RiMStfOW9T1qI2QwLXzuxIy9pp7p3UPZuNa16MBQqIsVqaxNJImCdAALWkuAjqW3vdolJD97KqKRbBw%2F5BEWBxPhDiJ9A%2FJPM7G0g2G2QdF8d2J%2FiEVvxIVJagQp%2BNx%2B3nqvGRqHa7Y6JXHCAYSbP7hnvkP3wmMGtRRXVQfNclmGSalVZJAzGNIq5fIUZNklnyDKBNPgGi8kh7UR32PspJ41GPSfqSkcftRejKZTGiycQXhI%2FmDd4KST9C5qOuoiaB89hcpptEfuBDvGrQxDDRNgRW0%2Fe3pXjPQ2029B3GjreJXikuhOhKXQ3W4Y40pbgitnBY3hqbR%2BHoaX8%2FjNTReG5oWl9wSKnsITnegNwRHjD7Vhdcd3Wj%2BNtq95ZzA0Ojo%2FK7gWd%2B57iD6%2FBJgUVdcH8FAp85pQ7rpFL2Inh7f399H74N0wpdR19Pa343OLgpEkta4HiXokyUgRTPLQ%2FsbMvWjraFBsvz7EYjv7453R%2BiHdq2ldxQI2OKkJTQxrgnzMtVh%2BkEl3QOOUDntuUgaN%2BRcQ0mPeyPzuniMD0F8GEYU%2BkRaE%2B68P4RL%2Fb0BWB1tZB76Mh8sDLQdImYlfL4qvJLB4EteJPSxlByj2RDXF6NA%2FFJtLEaB%2BNV2wyj04VnommQCfLYtbsQdhceZMpkB1sGlVnov%2BL0GN0%2BT%2FoAW0zFE5oYuhu%2BnN80%2BSovLcXNOax9GN57fiJhQjciMsnQ5NIqWcYbHaumTm2B3uISh0QdDl1AXJ36KCkN09KPtj%2FgeRUcl3QNfjIgvjhQV6r4Ya2bRAwM687%2FPwO2lM48xLQUP0k%2BHNTBtcKy9BZroABtqG5RnIQVYYjzQFsKgJudQYKKjoPsEAyu6aSrc8hzmy0JflQhFjCWiwzQrfMA2ic5KaAjXWQgB%2FA7EV18YXDMWbMblZkwGc4oB9htGxVC%2Fv84Xj6Fl4jJLQmskMBCwhSaG6B88qoLeAPSinsa6BZo%2FWh3tNPv7A13L0dhhnubegeYBfEigvw%2FZaBtMflJ8Q%2BSCItEvJPRDGIRaIb5%2FTqC3O9CLqanU0RUM%2BtAbwYFVffFoqI8OBXPw%2BfidgyEJ1DdAmzjpxof64loMTKv6VoaCGHAQ183syuoHn867DfENDIQG4swRNCMxig%2Bj27VSwu%2BmSKhvJZ1C4%2FMCfStZ3mbcLrMOvT9fUwh9eSXultodz4Vvf5Hl9E%2F%2FQAil9fRGYAn7gGMgUDsAF9yD0UMV7l%2FQi6GKjkgBVtV9PoRg11Ya6kZBPKE%2BhybkXYDezfrIYI8253wM7YvxjRGeWMdKxZ3N7YrPEZlYf6KpNkfisqcGF3GncWkuPBvsT%2F0UjKfOaYV5o2h6Ppo7EJcxvPLqYflbaVa4Bl5hPBti2CDCuhgGSTHaiHFoiQ82%2Fcp4orIQgu16orqPhFSNpE%2F1F%2FKw8jZ5WFaBe8jD6gJgBulXZZGHVV3AAMlSXiBLVJXkoLKcLAL3Kp%2BRHnkzyVHOkAk0Hq8GrgIOalaQgzSsqmHpqO6Vf4Z8QdIhP0KCCN%2Bq3E2y1ENkgrKT5Cv5pFvOJqfwYuQOxUMkvNRqwv21AI8AW4CVwFw4D%2FZCGmzCXtVycJAU4hufduIjfpJBMnHdjO82mrCPlY71pIOkEg8xIJWM9CkkjWSTMMnBdyexa0YC%2BNphiHiJFl7YTbLwnltHckkByScRkody6c9T5CkpJqvky5RUJab8RLVZ9an6as10zY%2B1d%2BhadR%2Fqv2NYbvjIpDXdbQ6ZN1jWWk3Ww7ZFdpP9dsdep9WJU0qufe5M943uP3jMnimetd4i70c4gHqVbx4%2BjYxsVX6NHTkFd1BLZpJZ5I74VZGuxzEez8UNTZSOH3c1NuqKtE9KDXiAAPbbdXgV3xC1qmTzybS0%2BtDJCZoDir11SCo6Vq89gDdJ9cOvDz9bMvz6OUdtyTmp5Hdvvv6m7YNn7bUlFW8%2B%2F2YZThakpJlPxpB1QuhkbIKiORBT7PU0f1Qfq4%2FK2gMxFOKtj6Q9G3m2JPJsBMVESsu6JXvQzpBikbXaFE0oq1iekBuuqqgor5MnVIZDWRaZxVVWVdcpFeWZsoKUPKZOpmFJ%2BfXni5TZwxr50lD9ggp1Zpo1xaxRy%2BleR9HkHNu8xTmTizO0ilajqHXavOppWe2xpqxXtPYMlzvDodM5MtyuDLt2%2BFW15dO%2Fqy2fNahin92qaCYtqc9WvmHQySqNZijTm1owKdi6wOq0qYxOm92t0zrsprzGJcNXu9JpGekuFy9reCbMGRr9VHWpOgX1HiavUbs%2FSrJH3z5mskkzQkNJER4aff%2BYETFGIXBm5P1oGo3KsdG%2FZvbXxP5G86QcernQKM3MDoVzPjQZTd6sjJDBLLlVJmKymeQjoadCvwwpIVPI5MiY6%2BhUd5L6%2BnpHbW1JSU%2BP3VNrh7RX2M6V2yvKSqVID99Cx0EDXzQTRZpyPoyNL3N8OV5R0FgxEZSCystxuzWsxnKVoGJRQlnhcFW1xKvJow0pQdV2nWTL8ftznHrVxuE%2Fr1MMzlB6Ro5V0kkJlTk1NzNQkGZR7ZZ%2BL%2F1wittnUSlak16aNPKM3qxXqS0%2BtyphtOgURWc1HhjejR7YN%2Fq%2ByqTORJtm7floOpkUgUWP2qSZ4PePWhn%2F5aiZ8V8xwaXxbx%2BF2SJPyhXom16pBH02LBUmnPNUp6QCMoGUSsWD%2BgVo4M%2Bfo5BK3mS2sb14Bs16MOjFkbujsaAzPCQVHos5501QDUkFR2MT9KVDUnEihpxo1WciFNQkKRbehitZ69S4kq2VtmNXSiZaLG%2B1KpOs1qVEl%2B1uvfTnN86cd%2Fuv9tWsW9Ts06kVlc6os5TP3jx7wYEV1RP6b1o8c2tHpVVr0CgnbV6HJSU%2F1zf%2F%2Fg%2Fuvu%2FzI0tcgQKfxZnmSEl36nNLcpuu%2FsGe3U%2FsmxouCWvsmQQtEd83V90IP%2BCAD%2FsGbYnRjPqg5PTCXk4bjOVMgaWcDpjJ6YWNnKfkcnimNG7RtKRFGSMd%2BF%2FUomBm0bRTsh0e0SuZEpYO35AUHlTPJ%2FXn6scs%2BDw3ZFlpj2%2FQAjOajsUsHWqaMhFDUpitnrkAaqJgVniCvbKqIogera0slkMhOzWV6sYFD7x%2FaOSvnvx8j5Tz0Nt3dxyv3Pi9q48M7vnellr5zoc%2Be2CuP1d1ea5%2F4XfePrj2%2BJVtn9vr9v8ALQVPruzBkxeSR%2BhzD6blJtsJmLUTxngqMHsqdh02yB2S7VG93hlwBvBwaUOSLmreH5ZOh6XnwlI4rEnFcyTMHbmgQQ1%2FXriyns1b8NglrLvZ%2BGOX09YTZgUYY2hxbgW5zTT7sZi5Q0MLSMRQAjMDisBmYrIBXWgN1oKCdmqYcVLZozKYdcO3UMPIq3RmnVqNPyMaKaFD31HpoWfJks5sUE13%2BBw6biSdw5fi8Nl1I%2Bv0tnSnI82mHSnT2X107Ht49FOpCz7LRfqovU7We2Z7jngUkrQamFmNMawGZlZj12E18hjagmH09EmXNNNgm8ucj1QSOd8AjrJIeBLeR7gHt%2FNOIrukLl1KMNWblaLTu4Ke1GCKLk1n0qrVWpNO9YpQyVptx12m8bt8lLh4U8WZNHZ7jHF7YHZ7YNZUXajUY0RvnesakiLJapNKzorb8x21ztXQS2MVIu6SN0veZV2sDpR2GFc%2FfMaTr0vJ8tJblZ6j7qo9xefUw8yPiNv97D69PZ1bFqOBnUwhh6hlj%2BZarSlJqzJGN2SM2wa%2FT3sYC8OqKbQtZmYaiovLaZct9yJtuRcJy21IVU67bDlNYiOZNXMNxdZcVWpWR2onbZZw%2FZ7aeqkk2Qfhr0WzLIH%2F90UtX8jAfDxyiAenI2wu2ntuyO122cWQS3soHXM9UqbiqQjTLpu0jepSsyvNXJ2WGwq5RtYEpqbLsqxz%2Br1ev0NXmDY3I9efYZcmZlSVl3kltEunP9UdcOimp2D4NGaU58pv1O6d1HJ72%2Bf%2F0JppvZu1qu%2FlZRk8%2Bf7hn1b29%2FaUzD48W34SowOatkkLz9Y%2Fek71tjqIuVYuuZt5trQUaqMU6tZSqFtLoW4thdoIZqyI6gOkFO%2FyFJKZND6YtRkwGzDAbMBg15Er8xQGDANJlfIT1nkh2m7UGCTGu7eesfYzaEW%2Fzj8Ws85T05Rwb2xUOO%2Fe6KwmaSrm3caNBqq32255%2Fdavv3B9Y9utr9964%2FMHmo7nLv7Gpk3fWJYfXnTHls13Ls2Tb7%2F788FlCw%2F9696Dnx5ZtuCBf3x3wxPXz5p%2Fw6nVW05fP3P%2BjY8zXz%2F6qfI0PF46Zpn3UIsMZmuSjwpmj8oYpgGz7sGu41E1tBF57BnUgBnUgBk2k1makRHAtYwhuTxB7DlDkuGoRmPC4xmPujpMdHaRnAryJiaaF%2FV6Gpr6eAzJXTT9sRjLgCY2NuujDSp0QbOCf1ONc%2FzK09Gd37%2F4Fr0zmEr7WEGa5CqYuXb9jPzjkxb2FN7zzVmrm7OVW%2Fru2jB5pFj0ONpktJ76JZcsnL2u0jL8Sd70fjoGYjZmhF2qSCN5grWUTFuxvVqHZ6umz1rNnrWaPns1bS3VaC0n86MI5tfbqeGgGCMtYxgQzAwIZv7FDgMm0ottGC1ObIpK0ahnCp77eLDDk5yHUVP1nKsVc4vy55OuEQMGDJYojtKsx2PIGKQ5T8SSWWm3ZEarFR2T9kulWPmS9dyeTIWOD1r0TKfbLVWGc8NhMYoaNSnZmWnBFKNqp6uobv6krcKuGFWdZVPT2rfOyg1NW1IbqCzKS9lm0Y0MN85Jra%2B4%2BaHG%2Fml%2BuGIduh1cTlnlwvrQ8Mtj9n4k169WzDULNjZMXT17YoolMnlW2cj%2FZGcoV81Y69FqRmYEJ82BB8xCDayB184mVzP7Z2RT4%2BdlS2mUw2lSnkcKm6XCVKnQK6XCpMesmP8yQZuhV8RQEXXQqFRvqjec45%2FrVTv4WOOorbc7JDrZpXO3slLS0yP19PRgmus7OZYMxkQ6akg6Vy1WUf9WVcX9WQWbtmI%2Bq5VPqiypuRnuoNdu0ioj3TrJkZeVHnToVdJWSVqr6GBKf7ZZ0WXSuamkUmO%2Bpkqw2SuG28%2BeUtXTeDp7pb1yyeg5pV75GanAxtS%2F2NMHrNP800qmKUa9p9IEF1VpQ4OqpC2v0kafu3JI%2BihqIbm5ViKZCG2hZGJyXAC%2FTee2jJGBMrPVxCFZF02xe35MKm2V8qTTlRKplCori6cWDEm%2BqPW5LCkrS5XxbnHblNdMM1WkhLox1iLtbO6ytEfM2c5ElvbUlvC%2BXI6mubTHFzUbPVKl58cxWl4WK9AdI1lYeKDM4ox3Y8VtpimvxWi53hI6pUvOZWjRkR7WbOkiAYPFBL5YYI6wYgKd5I0t7upUtBpcWj5NdleUV1Ur9bZ0X5rfMunmjulbO4rqtj20do%2B7bFbtlL7WMpMOI4HWN23Bqsq%2Ba%2BeH7z%2FQuGKav3vO1I1TvCYT3I5pUX1zTvOqqTM2teU0V86Z4MsIZehsqdbUjLRQhrOw89L5ZzxF9fnN86Y1oo4Ooo5eUG%2FG%2BnwKOUHr6Hh9vWQIViU7Ofh9anUw6%2Bw0zKxeNSR9HPW5InSgiQSQIkJrMUI9SYTWW2RINkT1xGWomhBUqbFcUJ8It%2FmabTNqIQfVM%2BloQr2oB34hOU6ft3yP7yTPF6YZsYjmWdU0L8aXmWzeSJ2Dh7oGKTlC54r1xvmR2c7XY2LY0drdMG%2BdrLxQ0X9TT6S1uTkX00IXBmKN1hnwpmJUzmtvaclbfv3CvEdclQuigbpoU27jnoa6rupU6a3tp65stocn5m%2BAE1CpMD9T1zD3gD%2FDf8qvCdlmXRHf3nT5iimOgmnlIwfnLZzcv5vOLxfBxgHlGSy3fkotPJiOmeNp6lrBb1Dr0pnkMZiPsIk4LrAJOqwIZuPy%2BQn66Ls0Aybqxqi5xCJZUt%2FyRw3mFn%2F2kCQfc7Yp75XRrxnozS1lhUOSZlAPQw8%2FH6GLuwjaeXLUPgM%2FQafoUZM%2F9a0YL8BJSzgZc7aVKe%2FFaCHHaSF6WgoWeszkyEZXev9%2BqcdWfqEsLPrOL%2FSUgKzWpk5u7yrpu33lhKmbD3ZHOhonePUa2WG25k7unLhzXzDaM7l2QX3EpDVolW%2FbU%2B3m1JwMR3T30e1XPbVrki0ty2txeh25%2FmBe8OQjC6%2FoimRHQjpnBlpuL6x6F85yhbEifpx5F3%2F9JMnoq6U%2BpdaAdllrgylraWuspY2z9hQO%2FBJSwm1eQls0roPZ%2FIAxMrF4pC6hDdjgDDYba3N9Kgv6vjrhbYODUh21zMQWHhova76YZ%2FKpkJhtwnPAcRhERi%2FNeSzmbbPQvFgF0sx0OsCa79ick06SxvuIcrdnbH6JxdP4JXS1cpfWnp5Cd1%2BmH1zcf8PCvPLlNy%2BbfUVUm%2BKnbVh%2FqGFvYz1aLFrw1OCUaHNuqmiwO2cumHnF4PJtp66c3tQgG8V8c7gJbXX5nmjj5SvRdhvKYN0eWPcgfHcEL7jfZdYtKKmqr9pYpThpb3cGYFWnM1hIJ%2BOF1LqF1OyFzIujzXxyvDFyf0SmGxPHqTeoVCWbOpi1aBZGNjB34ypq72Cw8On9qptU8mmV9JxKUqnSS14Lt3nf7bVsssgW%2FbvprDn3JD04W3cy45f%2FLsKbNvW7GAJRAVmqwqdjO1gZ4ZLX4EEs3ndjxGLDN10US7r%2B3RjKopsXdOnJ%2FDZbgUrYhQuOa8FwKeO3NGRXbhWrC61yMDd1OJHZvKkjuqK1xKQ1ahRZ0RqrFmyObnxwy8TJm%2B%2FtX3dbb9Eh5ZKdU5bUZWFFkBtsv3hBsSvNpbWkOsxOq8mY6nXW7Rrate3Ry5oat36zy3n5rcUzVlZTj5GDM4RXqy8mk8m11PYJt426CuYifEmPTJl5Ygg2HwMzF409hk8SpQXYM3su6rDZsaVmOFc1PS18rrQlMMPWQpdH58rpRkXkTMUHdMZwJlJBd3ui9irDuRhSlobPxZJp2RSs%2FIKpPGuJLjZiwVbj5rEY5sToxtZFKvlqzA80Wldmvi%2BnMmB5RmfUqx3WZ3TwtN6AU7fPZlPBc%2B4LtaxvC03LNmHeYHV6LGq9Ue%2Bt6Ji4XGtPc2YHPn%2BPTjHovpDiCmQ70%2BzanqXXLMg3W01OrN0VMmHkFuU65aekDvu8yyQ3a6kuR9F02uun69A4pwdsTmnG9Ir6odGP6foSzPo7%2BI0T9FK9djZk1Gx1SDNm%2B1TWUqVCq6WtFc4BNj0dNUMUVWh9Pm1FkYrWQ7QSDZd00Y%2FoCtiQrasgJ2oE51hLtUpN2yumeW%2B7XL01yjuTWwoC016uaVv8cmA2Wz9hDnaOTg7OvciHvkjFWVoBHkze6PTNjhHNdjaC34j4Q2sml5VranslZnK55r0do4VPVt6J0eJrpr0cq2kLLH45ho9I7qfUoyB0BNtPxkZI1BTWsmygDOdq4KTdnuScWWwnV2OSgn1m%2BpdWn9sTLKcT6bFJSZ3sxLQ614KJNh9Dr3NaLwull%2Ffsn1Xd73N4pla917BpbnHlRYc2rz%2B4vNAWLAuUlZTn%2BLMrl1w2I3%2B6X7LZ7SMjK3tKp5d4Vi4uaynxzFvW8U4g36u%2Fckf7yjqfsi3kz15YMuvieYUZbkdxZqhYNsjBKd2T6jZ1luVEuyuDdTUVqakzCqf0hnN6ps3cNb9IrwuOfLBkdaCmNa97lb%2B6ZXjpxHpZl1qUn%2Bea2pBRWkd70kGsDO%2FF%2FKacr5SP1VdKBc5kTwHzLgTBuhCNoMOrk05uPJlGOogYqV8zUg9nZM7NSK8ZSBSXSGZBKtYumpNFbdnNqTPYoEDnNJjSJLd%2F%2BJSGjQhHC1KLaGLMZsaS0%2B5FfQ%2BbKGJVSAcBO5sbarTjFojJbQY7n6%2B7lHt1Dj5Z8Ra3ltbtaUQwFT1KK%2BYw029qXbR7RjBV9BzZOnNpY3ZX5%2FD1Imb8xKW9dcqq6%2FrofP0q7IN1qEuwDxYkD9KedLI%2BNDu0MaS4qcFgBjCzEws7WZh1E4RZn2LxsJT7FN5f4U0Rt%2BaXN6SSZseu08cnDP4o%2BhG%2BLF13LNXWymz44rlIclBNjqnMow%2Bm0kTHYzwVTPeTsS1DbrekmZz0RQpty2jEUt0XbeMsnDQxQjFmHeVKutMGV6SVSicW5NcCvN1IdWg3LtLBLYE9wY1sT5C1ELgBZgk6c6M77GDelAie6ZjB1sweJPkUdDw6yqJw1xfWdfKev3yfY7d3vqr4Xamfw6g8R8pkns7nsOHD2f512GY0STNyvfTvprlS87g2ze6QtW14MsawOJhNJVlbz8x0o3ozM8sNtMkbaJM30CZvYE3egBHl5JyoXZo5pw4zUfbg42akbJcAYdYEGCN77il8s6qc2DB7bG%2FD1FITNU9tq2suqmktmjHWVbADMH5TqTa5M4AXa8ktAtpz2PdBfYPttPMci7W3TWWlWWIXFse6Ei0Pg%2FmFJqZLrQu605cikpXgSq6H%2BZrBpX6OdzOnLqWwsbh2axMduDxBp9Zd2FBcu22s12kc6R53hk0748bWmu7GUltRR%2Fv07IU7Wv1jVSiHar%2FQ%2F74co1yJ4VFR9Ebdzs7ZaSVT88oaC5zomDOED0Otl5MhVutWXuu06pPujFXBuJpNerExr5ZsAXTJlmmks2Lu1egIxp0c829ouieTjo15KkNRW0FqdquoLjp2jXm2SHLPK1lDvkHu3IxwbmN5aJ0g03%2BqjwvN%2F9XubczQd8z8D%2B7tAmPCiL3Uu9FV2OuwIt01fYbZMb0%2BX8pzSPl2ugMTNklhnRTWSgWKlC9LbCcUhgKzdg1mSzXwhTuldNqaWWKQDOO2YOkMedwW7GP4uiLB%2BwErmbkJ1YntUilhbcPuoJxcCNOVWbLFiyUaGr748bEdVgk7rG10h1UeWwFTw4rF71ftsCqvT9z6%2FS0bH9hQVbv14a3g6kd8detmt65tDPrq181uWdcYkP604dGr26ddemwLuA28p%2FXy5bWVyy6f2XZ5X23l0sup9Q6O3Kq8AOvRfYJBaj26TxCsoi9u6cgAZm6BhtmsFII1OvgODKUuvkXANgu8dKOH7xb82z2CVtvsr9wj%2BN%2B3CJDzP20RfHlUdX31FsHXl%2BY1To1miyEE7S%2FF5XNo82fM7ChaPkC3CCrYFkFzbuOuhrru6jTpnR2PXzHdllUZGqkTOwOqd9Cn8RrXqL%2BkoC7fNePKI9ubLlsx2ZnfUDZyJ45nrtgD6%2FbCunclrXuSe3aY12%2BMUE8coT6ZG4y54whdxxbgCDJriBXJBgpm%2FhzM1rWM4SEq2DrWldNqnBLxq2zFdB2b1lZD17G2mWrMSv%2F9OpZugNFlrMiXRjMei6W12WjWYzGWFx38%2FDJ2rCXa2Q7j2J6%2FRzRN15fXsXo6kfGnaPPbWlpzqUnL%2B29eltfcNL2AnitISbdrv7SWHTkmLCudza8NWcV61p4zKX%2B9MPXIP%2FmClm%2FGYEHLvaj8IGxcwXdij22aIIWtyWYLZq0VzJsvFbRdW2nzdZAoJjuEDoCEdm2ShladE9VH2sJWV6DVRTcD2FAmldCdFbYiZeYbjLCEhtj5lDAZG6XGdV06xf430z3eMDXyg7JGr9N5MrJdqaUTJobGtUY27ORMnVibYQ5mZ5hUiqQsd2fa9Xq9LqV4RvVwXEz3zvvDK6oac62KzmDQW3ywScfoOflZ2KRVsrFWZyppr2%2Bf3b6v%2FUi7emrSBGDWtVkYAwb49FHYg4XhIRmjoU0dkl6L%2BrPLs8tNPtpsfbTZ%2Buhg46MjlY8ONr7H8D1ruMKoAQFiiiLeRJdYYZRXbzpikk3Fv6s2vGefY%2B%2B1b7Ir1fZqu3vyq1N96vw299t80xDWO2dnJzts52zMcUbGvVAoSW7G8D2AnOri38XshvdixG6zB%2Bw4psFKzJ%2F8aoyVqXa%2FLXYTUWyEFUv3A8Y5VpVovfxMTrEmGf7i4QaN%2FGzF0stnlS5sKnUbVBqj1hipX1BT0Fjuy43O6eyI5ubP3T03u2VivkurKApONOizqlpLCqL5rrzo3M550VzJ0hRDe%2FKkpmT7nXg77Qv4HKGqnHBlnj8rUrdg8oS%2B1kKTw2UzWd02e6pN6051O0Ol6bkT8gJZBZPxH7%2BXSHD0b%2FJ61ffJRHIdrc1j%2BcQeKkq6B8aoFTCrTTBzE4xRDUW0oZs85qJzoZYM8zlPSxm6%2BaCW7dOeO0sH%2Fgq%2BzVV%2B9gzbOkTR52JI64l6zOdinhYtzZCIIQdbhKbZzoqBX8XXNV%2FcH5Bd43cR2JqS7irI63W2QH6xp3lFNONSq4O%2B2d8rFjZv0a1vh%2FWt6ume7PQUnVqvVi3OyLJZ9JocvMWRLXyD4EXxqvRFvoUwYuhZpjfo1RYvbHQr3TVUHh%2BbS%2FkxgzLm0vaaS9trLn0rk8vcbC5ttTip8MkJ3vP9yf4AZhYEf8yGOiroGoAmEBHv8wjpk6jeWdSaa1SntmLaqj6%2FdUidgNg5HGvA3OfqkxksNMf4DUOaZ%2FzcVuwXjm0U2tnOVVX1WAR2Ch0ZLk%2BGXTPzdjZp0qbwzRdPSUtp3e4m7Bhi5ejQj01Vd3bOmrz6uuVylpiNDn84e1lDTlenvF3E0JaGt1rKblixUDLTlvYojn1hlKdLD7%2BO%2Fs3xS5lcZEpscQjzsPMJYPGC35n0uY4k433i%2B9FqJKjGfMwu5dqkPLWUlYeIKVlSdpYUpBIneLKDUoDFBqTsgJRrlXYEpSDd7NLbXS3BADwJQm9H9XA8QbpLSUN04wf8ftSEMoJ5rUFjWquRu23UAt0Ei5BID5tzReibs54IfYOWPCdG36RFfMdJULKp2QcZ8UFjZTCHXh%2BB20i6dK3Ex79c6fx7b4%2FTU%2B3kEw1ltyQr8shZlTktLzMzL9WiGnlWpaanAjwZIRwVG1Epn8nYd%2FZ5Mu1a5R6V3mDSfv5d%2BlpNpbMYlIUmh17BWlXGH%2F1wmskk%2F1mPrTNZZ6T1MmH0U%2FWVqJcmqYzXy3Q41ykwAl5U4J1ujVRNOadYCgelcEAK%2B6VwphTOkHLTpTyVlK9IEydJkyZKk4qkyYWSLYADLfiPKLElPmW8aEBEACXYMD6yaMpRvJWcaaXR1qmtLB01e71ttm2jbZ9NZYs63C22itac1ok3FUqF9Foh9fk2p7tldeHOQrkJsZ4ZelodL1Cb95yprz8Lm%2FOaOf9Sk7%2FW5LNiViXRjKmtVpvfRj9KZeKfE2UfNKdQUtiHOPAh4cKqQllGU1Xxj0GNvYDq6okso5%2BUdhav%2FWi3wtbvWNUpuVp2po%2BfAxFDwLhaHCfVV6rUIx8pZk9epr8g1aQ8IctHFHNafqY%2FF6GRT9QqLBw96VkOnfKyjP%2FNh96BPoeTIfJLsvSijJfRaV6cwVTu0aZYz9ezfECvH956vtatKVq9EZWuxfGbNL0elW7GOIKNi2GvCMk6A1pAPnpmO1pACbmft4AymNqOVl9CPVsx9WmTiiW8TX7%2FBGSlV%2FIkvRftpizKLelpTynAZULzTCZSTUiqMkrGALqUkdaz0VhWmt8aMtozWu3JbS%2Fqm%2BhbaLb9zt5AoyLpL%2B1EvqhxfHJUAJsEUV%2FmThGnKM8fojz%2FatrJOg19My0pDTpnrj8z5DKqfvuSyujKwllKu6SXvCMf6SRnbiAjlGJQnX1OZbD7fRk5Dlk%2F8kmhxWlSY1NeK60c%2BSZIUZucFumk9KDFaVYpGoN2ZFCaDVJUxhTryFJYrxtLnZfw3waIkF5uPRss4aZv3cPspEQJ0lTqG%2FWyPseOldjR1BYrRggs49qxu4tN9XIs5M7Cj%2BCREzmpNAn2q1qsapoIy7Z2OjZiO71ceHF6xIq2rmqJKrrxyo66sbMMEpXySxqdRTf8ostHe790YGSfzUnPYMkqI97Q07iR7dIhHHvTNDt9dm16MMvidqfa5HXBHBx202osbnvA4vWk2YZv19p8WGmcGv1IOqDcxlbB5fQZBwmOCO0%2BacgMYZ1vxeuBs%2FVn6WBPB%2FkTNC6KSC%2B6Sf3ZpIMTO8Z0tB5bTyUP%2BEgH9Kl5%2FkAeWqU3L%2BDPS9V%2FMawEAoU%2Bo9FXGMgqolw0nBfkEfgCEPxZWhFsfAfucgN5A2fQ8%2Fk94rTO6RNoeRq9Aj%2BBG4z8APd3VB9FkN4cM%2Bi4fb8NJXWTiynWTy8pbgKod5RG3lIM6iexp%2BdhpdrUpKQExQxCeEtKUIaHu25xjlf7kMqckuFKDTpUGrlHZXZmuvB%2BTaX%2BwGzVqbRmp1mz22zVoxummGn5TdIxuVieglPyAVr%2BMaI1nsOBA8ydzuJTjqmM52L0nACfXeI9E9%2BlZNVc7LCPLHXgR%2Fo2KlMtfZKb6Q%2BHMzX2NNRZC%2BYsT7PzXhHJSEuOpn5h2yJHbFtgOnc6mmOVZ%2FYWSeM2JOjuXgqd36TQY00pXqpOyUV4Gxrg08AAujw9QQxmq0gwm%2BSA36Z2x0iL737gPLuBHiWLEoUu9KN65CgxzDbIhI2%2FCGHDEP%2BdKnoTBioMBF%2BJxgspA46R0XNR4hjZ%2BfMqcBC2N6mPwAsPPsGkU3eMwPwHex80OztdRgv4T6fLMNccO2usUp4uWR%2B%2FbNeDqyKlsfj%2B3eC4xReZPLO0c90Ud%2BbUlS01nVPQUuWB2%2F412Lfwux%2Fde%2BtHjB%2Fuu3NHZ3XqnBsej9388%2F0TsxuWbrmK1u8jeOF0j9pDiiUTq4Xs7EwpO0PKTpdCPik7TcpOlcJeKeyR8tmmkoP6y1JqCzOtkFKJUOOTfDofwRUwMzlj1A%2BYmRzMppP59GCaJdNLM3mN9K8RU6Q3aC2Bnz%2BKMsGnaVHj4k%2FTqQ7CqBzkuBffL3A6hqT6o6G5%2BdjT0%2FLzn%2BX1w%2BhC3OqRs%2FQFIHsRGPkxsz3hk59kBRx1RkO0hOMxFKGhZYiDonBkyY6HVxdBugfPHBi8joa%2FRarOSW6l4jgzvrtwj8Zg1g4v0ZqMGg3O6EqWT%2BnrPkVj1EsFKpPD68BMVPOuzqJXN9IFkNaWhmO6dr3y29sMKnOmx%2B61mTRPKSq80sXb1s9u1KNvSPivmhCcAgji%2Fd%2FPWJ2Y86ukSKaUn0FnNlFqfA81flRy06MpbubF3dSYbjTnExU5%2BEdqkzVS%2Bxj%2BO3FGmBQmNNKJjRG2NtpragOBWrTC4hMVbk3xPBt2p%2FKEHTHsYTVKzz7TaeSbkbN0ZcoaMm3FhB3E8p3kRRTTMvD6h5eiocWcNyVKwPqTFnR%2BbOAWpRPHLxwY0vDXr%2FRbI9R7wAB6q354gsVl1SoGq%2BmzhWtrHekT5lSy40IY%2BFQ4b%2B%2Bd1H3RpKUHeord06%2FeeFauwPcK1G0OHJ7X2jLdKZkej1kyLPn6xcsjkZkTs7LysnSOTBeWmhZXdsg7YcmuprrdNx7Z8qLewU5Mr4RHuh3nCuvI59zqudVSbhXbQFWY1U9wo1cnLQvGNz4wjFbTs9J5qJU81EZeFPbNs8wu31i%2Br1wp%2F%2FdHMB%2FDSVSCHoMCqV%2BhpwmwHQN1ku4jOp1enEQqjJoKJ34YyMJJL3VhB07bG5OOBm9Be7BLQF8oSLYXaQXh50zP888zyauI1pHvGAoqZCXZY1kTP6RnvIwKK01NixtzOyiQbRCgxPOjodgTYF9w4FXFhiJxpt9FDxUk394ptzfvH4xNjs2vsuJ7OtgW1BoKpq9tadjUUZzbsWfBlK5wutefIU%2FRWQ3qFMdIRqi1dOOhjbXSvWu%2BvXGiPdVrMdnTHHacY8dxrkDj6ra6ZfV%2BU1qObA0G9KjN7LyR29TyhL4Bumc7F7V0CH2jlEwjb7F6cuYXSwVqKZ%2FN9Qtwpt8gNdI%2BEqDV1SiV6eCHyvj5111lUm1Za9naMiVSJpUNyYU4zWWxBPCfEqLeHm6I1ccbx2h9TKIuC1nB7%2FNDitsnSVWTmietmqRkY0UxJEeilpIc7Jn9PRDQVn1YMA9G1Q1q%2BZli%2BjqcHk2Cw6HvwiPsEB4C5ewkBzoR60XYjLQGon%2BPoYCCqg9jBfO0tAxsOCRPG9MX3rxK6H7DBds21ePO3omTtmPDRJVyKKW0Y%2Fd3N0U6pham6OGOdMa8KXMr%2Bq7vKpQn3Nobu6U7t3zd%2FVs69i6J5tqPZE3rrZ%2B6ZFJ6as2iae03yI%2FNf%2Fie69dMMtocDn%2BaO82itjqs7ZceWuIvnbTqhnkLvrmjOX%2Fm%2BoH7mvcfiZWWzF4xYdLyxhw67OJHwjdT8G1z%2FGgIvjU4lf40RBr6YmuXb1n7fwCxnfOHCmVuZHN0cmVhbQplbmRvYmoKMTcgMCBvYmoKPDwgL1RpdGxlIChNaWNyb3NvZnQgV29yZCAtIGR0TGljZW5jZS5kb2N4KSAvUHJvZHVjZXIgKG1hY09TIFZlcnNpb24gMTEuNi42IFwoQnVpbGQgMjBHNjI0XCkgUXVhcnR6IFBERkNvbnRleHQpCi9DcmVhdG9yIChXb3JkKSAvQ3JlYXRpb25EYXRlIChEOjIwMjIwOTEyMTI1MDE3WjAwJzAwJykgL01vZERhdGUgKEQ6MjAyMjA5MTIxMjUwMTdaMDAnMDAnKQo%2BPgplbmRvYmoKeHJlZgowIDE4CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDgyMiAwMDAwMCBuIAowMDAwMTg2ODg0IDAwMDAwIG4gCjAwMDAwMDAwMjIgMDAwMDAgbiAKMDAwMDAwMDkyNiAwMDAwMCBuIAowMDAwMTg2ODQ4IDAwMDAwIG4gCjAwMDAwMDAwMDAgMDAwMDAgbiAKMDAwMDE4NzAzMSAwMDAwMCBuIAowMDAwMDAxMTEzIDAwMDAwIG4gCjAwMDAxODQwMjkgMDAwMDAgbiAKMDAwMDE4NDA4MiAwMDAwMCBuIAowMDAwMTc3NTgzIDAwMDAwIG4gCjAwMDAxODQxMzUgMDAwMDAgbiAKMDAwMDE4Njk2NyAwMDAwMCBuIAowMDAwMTg3OTIzIDAwMDAwIG4gCjAwMDAxODczNjUgMDAwMDAgbiAKMDAwMDE4ODE1OSAwMDAwMCBuIAowMDAwMjA0MzMwIDAwMDAwIG4gCnRyYWlsZXIKPDwgL1NpemUgMTggL1Jvb3QgMTMgMCBSIC9JbmZvIDE3IDAgUiAvSUQgWyA8ODQzYTlhMWJkNTcyMDczZjg5MDYwZTJmOGZiNDgxNmY%2BCjw4NDNhOWExYmQ1NzIwNzNmODkwNjBlMmY4ZmI0ODE2Zj4gXSA%2BPgpzdGFydHhyZWYKMjA0NTUyCiUlRU9GCg%3D%3D\"\r\n }\r\n\t\t \r\n ],\r\n \"additionalInformation\": {\r\n \"remarks\": \"\"\r\n }\r\n}\r\n}\r\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/GenerateOrderSSL", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "GenerateOrderSSL" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "164" + }, + { + "key": "Date", + "value": "Tue, 02 Apr 2024 10:52:49 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"meta\": {\n \"ver\": \"1.0\",\n \"ts\": \"2024-04-02T16:22:48+05:30\",\n \"txn\": \"997488680457390000\",\n \"status\": \"0\",\n \"errorCode\": \"EMS-1073\",\n \"errorMessage\": \"Invalid Organization Number.\"\n }\n}" + } + ] + }, + { + "name": "SSL/TLS - EV", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "if (status === \"1\") {", + " pm.test(\"Request Successful\", () => {", + " pm.expect(status).to.equal(\"1\");", + " if (res.orderDetails && res.orderDetails.orderNumber) {", + " pm.collectionVariables.set(\"orderNumber\", res.orderDetails.orderNumber);", + " }", + " if (res.orderDetails && res.orderDetails.requestNumber) {", + " pm.collectionVariables.set(\"requestNumber\", res.orderDetails.requestNumber);", + " }", + " });", + "} else {", + " pm.test(\"Request FAILED: \" + (errMsg || \"Unknown error\"), () => {", + " pm.expect(status).to.equal(\"1\");", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"productCode\": \"{{SSL_EV}}\",\r\n \"accountingModel\": \"\",\r\n \"autoSecureWWW\": \"1\",\r\n \"saveAndHold\": \"0\",\r\n \"emailNotifications\": \"1\",\r\n \"requestorInformation\": {\r\n \"requestorName\": \"{{requestorName}}\",\r\n \"requestorIsdCode\": \"1\",\r\n \"requestorMobileNumber\": \"{{requestorMobileNumber}}\",\r\n \"requestorEmail\": \"{{requestorEmail}}\",\r\n \"requestorDesignation\": \"{{requestorDesignation}}\"\r\n },\r\n \"subscriptionDetails\": {\r\n \"validity\": \"3\",\r\n \"autoRenew\": \"1\",\r\n \"renewCriteria\": \"60\"\r\n },\r\n \"delegationInformation\": {\r\n \"contactName\": \"\",\r\n \"email\": \"\"\r\n },\r\n \"certificateInformation\": {\r\n \"organizationName\": \"eMudhra Inc.\",\r\n \"organizationUnit\": \"Technology\",\r\n \"businessCategory\": \"1\",\r\n \"companyDuns\": \"AXTPN03324214\",\r\n \"streetAddress1\": \"1712 South East Bay Blvd\",\r\n \"streetAddress2\": \" Suite 360\",\r\n \"locality\": \"Provo\",\r\n \"countryCode\": \"US\",\r\n \"state\": \"Utah\",\r\n \"postalCode\": \"84606 \",\r\n \"autoSecureWWW\": \"1\",\r\n \"domainName\": \"emsign.tech\"\r\n \r\n },\r\n \"contractSignerInfo\": { \r\n \"name\": \"Ben Dover\",\r\n \"email\": \"{{requestorEmail}}\",\r\n \"isdCode\": \"1\",\r\n \"mobileNumber\": \"9481081094\",\r\n \"designation\": \"Production Engineer\",\r\n \"employeeID\": \"21023\"\r\n },\r\n \"certificateApproverInfo\": { \r\n \"name\": \"Jenne Flex\",\r\n \"email\": \"{{requestorEmail}}\",\r\n \"isdCode\": \"1\",\r\n \"mobileNumber\": \"9988776655\",\r\n \"designation\": \"System Administrator\",\r\n \"employeeID\": \"\"\r\n },\r\n \"technicalPointOfContact\": {\r\n \"pocFirstName\": \"\",\r\n \"pocLastName\": \"\",\r\n \"pocEmail\": \"\",\r\n \"pocIsdCode\": \"\",\r\n \"pocMobileNumber\": \"\",\r\n \"pocDesignation\": \"\"\r\n },\r\n \"csr\": \"\",\r\n \"numberOfDocuments\": \"\",\r\n \"documentsAttached\": [],\r\n \"additionalInformation\": {\r\n \"remarks\": \"\",\r\n \"tags\": []\r\n }\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}GenerateOrderSSL", + "host": [ + "{{baseURL}}GenerateOrderSSL" + ] + }, + "description": "This API facilitates to generate new order for emSign - SSL / TLS - EV certificates.\n\n**Prerequisites:**\n\neMudhra provides following values for generating SSL EV Orders. It is highly recommended to keep these values configurable, as they change for sandbox environment and Live environment.\n\n- Generate SSL Order Base URL Endpoint\n \n- Product Codes\n \n\n| **Region** | **URL** |\n| --- | --- |\n| US | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n| Global | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n\n**API Endpoint**\n\n| Header | Header |\n| --- | --- |\n| URL | http(s)://(url host port and path)/GenerateOrderSSL |\n| Protocol | HTTP/ HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n\"meta\": {\n \"ver\":\"\",\n \"ts\":\"\",\n \"txn\":\"\",\n \"accountNumber\":\"\",\n \"authKey\":\"\" \n },\n\"orderDetails\": {\n \"productCode\":\"\",\n \"accountingModel\":\"\",\n \"saveAndHold\":\"\",\n \"requestNumber\":\"\",\n \"emailNotifications\":\"\",\n \"groupNumber\":\"\",\n \"requestorInformation\": {\n \"requestorName\": \"\",\n \"requestorIsdCode\": \"\",\n \"requestorMobileNumber\": \"\",\n \"requestorEmail\": \"\",\n \"requestorDesignation\": \"\"\n },\n \"delegationInformation\": {\n \"contactName\": \"\",\n \"email\": \"\"\n },\n \"organizationDetails\": {\n \"preVetting\": \"\",\n \"organizationNumber\": \"\",\n \"representativeNumber\": \"\"\n },\n \"certificateInformation\": {\n \"organizationName\":\"\",\n \"organizationUnit\":\"\",\n \"businessCategory\":\"\",\n \"companyDuns\":\"\",\n \"streetAddress1\":\"\",\n \"streetAddress2\":\"\",\n \"locality\":\"\",\n \"state\":\"\",\n \"countryCode\":\"\",\n \"postalCode\":\"\", \n \"domainName\":\"\",\n \"additionalDomains\": [\n \"\",\n \"\",\n ],\n \"autoSecureWWW\": \"\"\n },\n \"contractSignerInfo\": {\n \"name\":\"\",\n \"email\":\"\",\n \"isdCode\":\"\",\n \"mobileNumber\":\"\",\n \"designation\":\"\",\n \"employeeID\":\"\",\n },\n \"certificateApproverInfo\": {\n \"name\":\"\",\n \"email\":\"\",\n \"isdCode\":\"\",\n \"mobileNumber\":\"\",\n \"designation\":\"\",\n \"employeeID\":\"\",\n },\n \"subscriptionDetails\": {\n \"validity\": \"\", \n \"autoRenew\": \"\", \n \"renewCriteria\": \"\"\n },\n \"csr\":\"\",\n \"numberOfDocuments\":\"\",\n \"documentsAttached\": [\n {\n \"id\":\"\",\n \"fileName\":\"\",\n \"description\":\"\",\n \"base64Value\":\"\"\n },\n {\n \"id\":\"\",\n \"fileName\":\"\",\n \"description\":\"\",\n \"base64Value\":\"\"\n }\n ],\n \"additionalInformation\": {\n \"remarks\": \"\",\n \"recipientEmail\": \"\",\n \"tags\":[\n \"(Tag Name:Tag Value)\",\n \"(Tag Name:Tag Value)\"\n ],\n \"customFields\":[\n {\n \"fieldID\": \"\",\n \"fieldValue\": \"\"\n },\n {\n \"fieldID\": \"\",\n \"fieldValue\": \"\"\n }\n ],\n \"technicalPointOfContact\": {\n \"pocName\":\"\",\n \"pocEmail\":\"\",\n \"pocIsdCode\":\"\",\n \"pocMobileNumber\":\"\",\n \"pocDesignation\":\"\"\n }\n }\n}\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order.
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | 1234567890 (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | 6288275133 (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | 952a95057654aa1c26a0f287b6368579f5f3ec99f213543df5e0f7eb7f798b3d (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| productCode | 850 (mandatory)
Unique Product Code of the respective product. Product Code values are provided by eMudhra. |\n| accountingModel | 1 (optional)
Accounting Model of the Account.
The allowed value for this field is: 1 - Cash Model. The default value is '1' (in case if it is not set). |\n| saveAndHold | 0 (mandatory)
Should be set to any of the numeric values.
1 - Only Request ID will be generated and request will be pending for payment.
0 - Both Request ID & Order ID will be generated. |\n| requestNumber | 5652671652 (mandatory)
Request Number of the existing request which was saved as a draft.
This field is 'mandatory' only for the requests (Request ID is available & Order ID is yet to generate). |\n| emailNotifications | 1 (mandatory)
Can contain one of the numeric values as under. Default is '1'.
0 - Pre-vetting Organization (Organization & Subscriber Agreement info.) re-use consent notification will be sent to the applicant on order initiation, if \"preVetting\" value is set to '1'.
1 - All Order Notifications will be sent to the applicant on order initiation. |\n| groupNumber | 3589463147 (optional)
Group Number of the group. This is auto generated by the system. Groups can be your cost centers, departments, etc. within your Business.
It acts as a feature / tool for managing account user access to organizations, domains and finances. If group number is not given in the request, then system will consider the default group number and amount will be deducted from the account balance. If group is having its own balance, then amount will be deducted from the group balance else the amount will be deducted from the account balance. |\n| requestorInformation | (mandatory)
Specified below. |\n| delegationInformation | (optional)
Specified below. |\n| organizationDetails | (conditional mandatory)
This is mandatory, If prevetting is set to '0'.
Specified below. |\n| certificateInformation | (mandatory)
Specified below. |\n| contractSignerInfo | (conditional mandatory)
This is mandatory, if Prevetting is set to '0'.
Specified below. |\n| certificateApproverInfo | (conditional mandatory)
This is mandatory, if Prevetting is set to '0'.
Specified below. |\n| subscriptionDetails | (conditional mandatory)
Subscription Details of the order. |\n| csr | (optional)
CSR means Certificate Signing Request. CSR file has to be generated, preferably in the web server, or with any standard tools. You should ensure that the corresponding private key resides in same web server / tool, so that you can import the SSL / TLS certificate in the same, once it is issued. Applicable for all SSL / TLS products. |\n| numberOfDocuments | (optional)
Number of Documents attached. |\n| documentsAttached | (optional)
Uploading documents during order creation is recommended for sending us any additional / supporting documents so that they are automatically associated with your certificate order. This additional documentation would help emSign to speed up the certificate validation process. (E.g., incorporation letter, registration document, etc.). Specified below. |\n| additionalInformation | (optional)
Specified below. |\n\n**Requestor Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestorName | John Doe (mandatory)
Name of the requestor. |\n| requestorIsdCode | +1 (mandatory)
ISD Code of the requestor's mobile number. |\n| requestorMobileNumber | 9867542762 (mandatory)
Mobile number of the requestor. |\n| requestorEmail | [johndoe@example.com](https://johndoe@example.com) (mandatory)
Email of the requestor. |\n| requestorDesignation | BA (optional)
Designation of the requestor. |\n\n**Delegation Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| contactName | John Doe (mandatory)
Contact Name of the delegated person. |\n| email | [johndoe@example.com](https://johndoe@example.com) (mandatory)
Email ID of the delegated person. |\n\n**Organization Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| preVetting | 0 (mandatory)
This can be set to any of the numeric values as under. Default is '0'.
0 - No (New Organization)
1 - Yes (Pre-verified EV Organization) |\n| organizationNumber | (conditional mandatory)
Organization Number (Org. ID) of the existing organization associated to the respective emSign account of the certificate requester. This is mandatory, if \"preVetting\" value is set to '1'. |\n| representativeNumber | 4567876 (optional)
Organization Representative Number of the existing Organization representative. It will consider default requestor information of the respective order. (in case if it is not set). |\n\n**Certificate Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| organizationName | eMudhra Limited (mandatory)
Organization Name of Organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| organizationUnit | (optional)
Organization Unit of the respective Organization. |\n| businessCategory | 1 (mandatory)
Business Category of an Organization.
The allowed values for this field are:
1 - Private Organization
2 - Government Entity |\n| companyDuns | 222888778865426726524 (mandatory)
Company Registration Number or Company DUNS Number of an Organization. |\n| streetAddress1 | KIADB (mandatory)
Street Address 1 of an organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| streetAddress2 | (optional)
Street address 2 of Organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| locality | KIADB (mandatory)
Locality of the respective Organization. This field is not required, if \"preVetting\" value is set to '1'. |\n| state | Karnataka (mandatory)
State of an organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| countryCode | IN (mandatory)
Country code of Organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| postalCode | 560076 (mandatory)
Postal code of the respective Organization. This field is not required, if \"preVetting\" value is set to '1'. |\n| domainName | emudhra.com (mandatory)
Domain Name of the website / server. If the pre-verified domain under the pre-vetted organization (if \"preVetting\" value is set to '1') is provided then fresh DCV is not required. |\n| additionalDomainNames | support.emudhra.com (mandatory)
Additional domain names of the website / server. This field is required only for SSL multi-domain / UCC products as specified below.
Applicable for SSL / TLS EV - UCC products. |\n| autoSecureWWW | 1 (mandatory)
This is set to '1' by default.
1 - Enabled (Secure 'www' variant of website)
0 - Disabled (Doesn't secure 'www' variant of website)
If user has chosen to secure website with both www and without www variants, then File-based (HTTP / HTTPs URL) DCV method is not allowed to verify DCV. |\n\n**Contract Signer Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| name | John Doe (mandatory)
Name of the Contract Signer. |\n| email | [johndoe@example.com](https://johndoe@example.com) (mandatory)
Email ID of the Contract Signer. |\n| isdCode | +1 (mandatory)
ISD Code of the Contract Signer's Mobile Number. |\n| mobileNumber | 92673626528 (mandatory)
Mobile Number of the Contract Signer. |\n| designation | PM (mandatory)
Designation of the Contract Signer. |\n| employeeID | 25615 (optional)
Employee ID of the Contract Signer. |\n\n**Certificate Approver Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| name | John Doe (mandatory)
Name of the Certificate Approver. |\n| email | [johndoe@example.com](https://johndoe@example.com) (mandatory)
Email ID of the Certificate Approver. |\n| isdCode | +1 (mandatory)
ISD Code of the Certificate Approver's Mobile Number. |\n| mobileNumber | 8726728726 (mandatory)
Mobile Number of the Certificate Approver. |\n| designation | PO (mandatory)
Designation of the Certificate Approver. |\n| employeeID | 762521(optional)
Employee ID of the Certificate Approver. |\n\n**Subscription Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| validity | 1 (optional)
Validity of the Subscription (1 / 2 / 3 Years).
Default value is '1' Year.
emSign is providing subscription validity upto 3 years where maximum Lifetime of 390 days per certificate is available with free renewals. |\n| autoRenew | 1 (conditional mandatory)
Auto-renew certificates until coverage.
Default value is '1'.
1: Allow Renewal of Certificate
0: Decline Renewal of Certificate |\n| renewCriteria | 30 (conditional mandatory)
Time duration to for renew certificate before expiry.
Default value is '30' Days.
30: Automatically reissue before 30 days of certificate expiry.
60: Automatically reissue before 60 days of certificate expiry. |\n\n**Document Attached**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| id | 1 (mandatory)
ID of the document. |\n| fileName | ID.pdf (mandatory)
File Name of the document. |\n| description | Passport (mandatory)
Description of the document. |\n| base64Value | (mandatory)
Base 64 encoded value of the document. |\n\n**Additional Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| tags | (optional)
Reporting tags are labels with different values, which can be associated with your certificate orders in CERTInext. These tags can then be used to filter reports, giving you a categorized insight into your business.
Tag Name and Tag Value must be colon separated values.
e.g.: Department:Technology
Multiple tag names and tag values can be associated with the order.
e.g.: Department:Technology,
Department:Legal,
Branch Location:India
Specified below. |\n| customFields | (conditional mandatory)
CERTInext allows you to use custom fields in your certificate order form. The Custom Field can be used to simplify record-keeping and makes order management efficient.
To enable Custom Fields feature for your CERTInext account, please contact your Account Manager.
Specified below. |\n| remarks | (optional)
Additional Information related to the order. |\n| recipientEmail | (optional)
Email recipients can be provided for receiving notifications related to Order Confirmation, Revocation and Renewal of certificates. |\n| technicalPointOfContact | (optional)
Technical Point of Contact will be notified for emails which are technical in nature such as Order Confirmation with order status tracking link, CSR & Certificate Download notification based on the email notifications configuration set by your account administrator.
Specified below. |\n\n**Tags**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| Tag Name | Dept (mandatory)
Reporting Tag Name. |\n| Tag Value | Support (mandatory)
Reporting Tag Value. |\n\n**Custom Fields**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| fieldID | 234 (mandatory)
Specify the Custom Field ID. This is a unique field identifier of a custom field.
This is mandatory, if Custom Fields are mandated by your account administrator.
This information will be available within CERTInext online portal. |\n| fieldValue | Dept (mandatory)
Specify the Custom Field value(s). You have to provide the right input based on the custom field type set by your account administrator.
NOTE: The allowed date format for date picker based Custom Field is: YYYY-MM-DD
This is mandatory, if Custom Fields are mandated by your account administrator.
The detailed Custom Field information will be available within CERTInext online portal. |\n\n**Technical Point of Contact**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| pocName | John Doe (mandatory)
Name of the Technical Point of Contact. |\n| pocEmail | [johndoe@example.com](https://johndoe@example.com) (mandatory)
Email ID of the Technical Point of Contact. |\n| pocIsdCode | +1 (optional)
ISD Code of the Technical Point of Contact's Mobile Number. |\n| pocMobileNumber | 8925727228 (optional)
Mobile number of the Technical Point of Contact. |\n| pocDesignation | Manager (optional)
Designation of the Technical Point of Contact. |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n },\n \"orderDetails\": { \n \"requestNumber\":\"\",\n \"orderNumber\": \"\",\n \"trackingUrl\": \"\"\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order.
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | 1234567890 (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | 1 (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestNumber | 4794392161 (conditional mandatory)
Unique Request ID of the respective request. This is 'Mandatory', if 'saveAndHold' is set to '1'. |\n| orderNumber | 2198843858 (conditional mandatory)
Unique Order ID of the respective order.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |\n| trackingUrl | [https://sandbox-emsignsubscriber.emudhra.net/PublicLink/publicLinkVerification.jsp?a=UVMvMzc2RDJjYUhlZFlhLzRJNU5IQT09](https://sandbox-emsignsubscriber.emudhra.net/PublicLink/publicLinkVerification.jsp?a=UVMvMzc2RDJjYUhlZFlhLzRJNU5IQT09) (mandatory)
Order status tracking URL of the respective order.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |" + }, + "response": [ + { + "name": "SSL/TLS - EV", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\":\"4397827229\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"productCode\": \"850\",\r\n \"accountingModel\": \"\",\r\n \"autoSecureWWW\":\"0\",\r\n \"saveAndHold\": \"0\",\r\n \"emailNotifications\": \"1\",\r\n \"requestorInformation\": {\r\n \"requestorName\": \"John Green\",\r\n \"requestorIsdCode\": \"1\",\r\n \"requestorMobileNumber\": \"7094940185\",\r\n \"requestorEmail\": \"john.green@example.com\",\r\n \"requestorDesignation\": \"Manager\"\r\n },\r\n \"subscriptionDetails\": { // Applicable only for SSL DV / OV\r\n \"validity\": \"0\", //1/2/3 (Default Value: 1)\r\n \"autoRenew\": \"\", //1/0 (Default-1)\r\n \"renewCriteria\": \"\" //30/60 (Default 30)\r\n },\r\n \"delegationInformation\": {\r\n \"contactName\": \"\",\r\n \"email\": \"\"\r\n },\r\n \"certificateInformation\": {\r\n \"organizationName\": \"eMudhra Inc.\",\r\n \"organizationUnit\": \"Technology\",\r\n \"businessCategory\": \"1\",\r\n \"companyDuns\": \"AXTPn03324214\",\r\n \"streetAddress1\": \"1712 South East Bay Blvd\",\r\n \"streetAddress2\": \" Suite 360\",\r\n \"locality\": \"Provo\",\r\n \"countryCode\": \"US\",\r\n \"state\":\"Utah\",\r\n \"postalCode\": \"84606 \",\r\n \"domainName\": \"emsign.tech\",\r\n \"additionalDomains\": [\r\n \"\",\r\n \"\"\r\n ]\r\n },\r\n // \"authorizedSignatoryInfo\": {\r\n // \"arName\": \"Viña del Mar Viña Green\",\r\n // \"arEmail\": \"john.green@example.com\",\r\n // \"arIsdCode\": \"1\",\r\n // \"arMobileNumber\": \"7094940185\",\r\n // \"arDesignation\": \"Manager\"\r\n // },\r\n \"contractSignerInfo\": {//Will not be considered if EV Prevetting =1\r\n \"name\": \"Jenne Flex\",\r\n \"email\": \"jenne.flex@example.com\",\r\n \"isdCode\": \"1\",\r\n \"mobileNumber\": \"7094940185\",\r\n \"designation\": \"System Administartor\",\r\n \"employeeID\": \"\"\r\n },\r\n \"certificateApproverInfo\": {//Will not be considered if EV Prevetting =1\r\n \"name\": \"Ben Dover\",\r\n \"email\": \"ben.dover@example.com\",\r\n \"isdCode\": \"1\",\r\n \"mobileNumber\": \"7094940185\",\r\n \"designation\": \"Production Engineer\",\r\n \"employeeID\": \"\"\r\n },\r\n \"technicalPointOfContact\":{\r\n \"pocName\":\"Jenne\",\r\n //\"pocLastName\":\"Flex\",\r\n \"pocEmail\":\"jenne.flex@example.com\",\r\n \"pocIsdCode\":\"+1\",\r\n \"pocMobileNumber\":\"7094940185\",\r\n \"pocDesignation\":\"System Administartor\"\r\n },\r\n \"csr\": \"\",\r\n \"numberOfDocuments\": \"\",\r\n \"documentsAttached\": [\r\n {\r\n \"id\": \"1\",\r\n \"fileName\": \"pdf.pdf\",\r\n \"description\": \"test\",\r\n \"base64Value\": \"\"\r\n }\r\n ],\r\n \"additionalInformation\": {\r\n \"remarks\": \"\",\r\n \"tags\": [\r\n \"AAA:A\"\r\n ]\r\n }\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/GenerateOrderSSL", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "GenerateOrderSSL" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "353" + }, + { + "key": "Date", + "value": "Tue, 02 Apr 2024 10:53:22 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"orderDetails\": {\n \"requestNumber\": \"8161334455\",\n \"orderNumber\": \"1728151195\",\n \"trackingURL\": \"https://sandbox-emsignsubscriber.emudhra.net/PublicLink/publicLinkVerification.jsp?a=UnQ4Z1EyTVFmcXBVMHhhYy9hanFSUT09\"\n },\n \"meta\": {\n \"ver\": \"1.0\",\n \"errorMessage\": \"\",\n \"errorCode\": \"\",\n \"txn\": \"31b38c51b543476a9f87179db9cf4a03\",\n \"ts\": \"2024-04-02T16:23:19+05:30\",\n \"status\": \"1\"\n }\n}" + } + ] + }, + { + "name": "SSL/TLS - EV - UCC", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "if (status === \"1\") {", + " pm.test(\"Request Successful\", () => {", + " pm.expect(status).to.equal(\"1\");", + " if (res.orderDetails && res.orderDetails.orderNumber) {", + " pm.collectionVariables.set(\"orderNumber\", res.orderDetails.orderNumber);", + " }", + " if (res.orderDetails && res.orderDetails.requestNumber) {", + " pm.collectionVariables.set(\"requestNumber\", res.orderDetails.requestNumber);", + " }", + " });", + "} else {", + " pm.test(\"Request FAILED: \" + (errMsg || \"Unknown error\"), () => {", + " pm.expect(status).to.equal(\"1\");", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"productCode\": \"{{SSL_EV_UCC}}\",\r\n \"accountingModel\": \"\",\r\n \"autoSecureWWW\": \"1\",\r\n \"saveAndHold\": \"0\",\r\n \"emailNotifications\": \"1\",\r\n \"requestorInformation\": {\r\n \"requestorName\": \"{{requestorName}}\",\r\n \"requestorIsdCode\": \"1\",\r\n \"requestorMobileNumber\": \"{{requestorMobileNumber}}\",\r\n \"requestorEmail\": \"{{requestorEmail}}\",\r\n \"requestorDesignation\": \"{{requestorDesignation}}\"\r\n },\r\n \"subscriptionDetails\": {\r\n \"validity\": \"3\",\r\n \"autoRenew\": \"1\",\r\n \"renewCriteria\": \"60\"\r\n },\r\n \"delegationInformation\": {\r\n \"contactName\": \"\",\r\n \"email\": \"\"\r\n },\r\n \"certificateInformation\": {\r\n \"organizationName\": \"eMudhra Inc.\",\r\n \"organizationUnit\": \"Technology\",\r\n \"businessCategory\": \"1\",\r\n \"companyDuns\": \"AXTPN03324214\",\r\n \"streetAddress1\": \"1712 South East Bay Blvd\",\r\n \"streetAddress2\": \" Suite 360\",\r\n \"locality\": \"Provo\",\r\n \"countryCode\": \"US\",\r\n \"state\": \"Utah\",\r\n \"postalCode\": \"84606 \",\r\n \"autoSecureWWW\": \"1\",\r\n \"domainName\": \"emsign.tech\",\r\n \"additionalDomains\": [\r\n \"support.emsign.tech\",\r\n \"api.emsign.tech\"\r\n ]\r\n },\r\n \"contractSignerInfo\": { \r\n \"name\": \"Ben Dover\",\r\n \"email\": \"{{requestorEmail}}\",\r\n \"isdCode\": \"1\",\r\n \"mobileNumber\": \"9481081094\",\r\n \"designation\": \"Product Manager\",\r\n \"employeeID\": \"21023\"\r\n },\r\n \"certificateApproverInfo\": { \r\n \"name\": \"Jenne Flex\",\r\n \"email\": \"{{requestorEmail}}\",\r\n \"isdCode\": \"1\",\r\n \"mobileNumber\": \"9988776655\",\r\n \"designation\": \"Product Manager\",\r\n \"employeeID\": \"\"\r\n },\r\n \"technicalPointOfContact\": {\r\n \"pocFirstName\": \"\",\r\n \"pocLastName\": \"\",\r\n \"pocEmail\": \"\",\r\n \"pocIsdCode\": \"\",\r\n \"pocMobileNumber\": \"\",\r\n \"pocDesignation\": \"\"\r\n },\r\n \"csr\": \"\",\r\n \"numberOfDocuments\": \"\",\r\n \"documentsAttached\": [],\r\n \"additionalInformation\": {\r\n \"remarks\": \"\",\r\n \"tags\": []\r\n }\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}GenerateOrderSSL", + "host": [ + "{{baseURL}}GenerateOrderSSL" + ] + }, + "description": "This API facilitates to generate new order for emSign - SSL / TLS - EV - UCC certificates.\n\n**Prerequisites:**\n\neMudhra provides following values for generating SSL EV Orders. It is highly recommended to keep these values configurable, as they change for sandbox environment and Live environment.\n\n- Generate SSL Order Base URL Endpoint\n \n- Product Codes\n \n\n| **Region** | **URL** |\n| --- | --- |\n| US | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n| Global | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n\n**API Endpoint**\n\n| Header | Header |\n| --- | --- |\n| URL | http(s)://(url host port and path)/GenerateOrderSSL |\n| Protocol | HTTP/ HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n\"meta\": {\n \"ver\":\"\",\n \"ts\":\"\",\n \"txn\":\"\",\n \"accountNumber\":\"\",\n \"authKey\":\"\" \n },\n\"orderDetails\": {\n \"productCode\":\"\",\n \"accountingModel\":\"\",\n \"saveAndHold\":\"\",\n \"requestNumber\":\"\",\n \"emailNotifications\":\"\",\n \"groupNumber\":\"\",\n \"requestorInformation\": {\n \"requestorName\": \"\",\n \"requestorIsdCode\": \"\",\n \"requestorMobileNumber\": \"\",\n \"requestorEmail\": \"\",\n \"requestorDesignation\": \"\"\n },\n \"delegationInformation\": {\n \"contactName\": \"\",\n \"email\": \"\"\n },\n \"organizationDetails\": {\n \"preVetting\": \"\",\n \"organizationNumber\": \"\",\n \"representativeNumber\": \"\"\n },\n \"certificateInformation\": {\n \"organizationName\":\"\",\n \"organizationUnit\":\"\",\n \"businessCategory\":\"\",\n \"companyDuns\":\"\",\n \"streetAddress1\":\"\",\n \"streetAddress2\":\"\",\n \"locality\":\"\",\n \"state\":\"\",\n \"countryCode\":\"\",\n \"postalCode\":\"\", \n \"domainName\":\"\",\n \"additionalDomains\": [\n \"\",\n \"\",\n ],\n \"autoSecureWWW\": \"\"\n },\n \"contractSignerInfo\": {\n \"name\":\"\",\n \"email\":\"\",\n \"isdCode\":\"\",\n \"mobileNumber\":\"\",\n \"designation\":\"\",\n \"employeeID\":\"\",\n },\n \"certificateApproverInfo\": {\n \"name\":\"\",\n \"email\":\"\",\n \"isdCode\":\"\",\n \"mobileNumber\":\"\",\n \"designation\":\"\",\n \"employeeID\":\"\",\n },\n \"subscriptionDetails\": {\n \"validity\": \"\", \n \"autoRenew\": \"\", \n \"renewCriteria\": \"\"\n },\n \"csr\":\"\",\n \"numberOfDocuments\":\"\",\n \"documentsAttached\": [\n {\n \"id\":\"\",\n \"fileName\":\"\",\n \"description\":\"\",\n \"base64Value\":\"\"\n },\n {\n \"id\":\"\",\n \"fileName\":\"\",\n \"description\":\"\",\n \"base64Value\":\"\"\n }\n ],\n \"additionalInformation\": {\n \"remarks\": \"\",\n \"recipientEmail\": \"\",\n \"tags\":[\n \"(Tag Name:Tag Value)\",\n \"(Tag Name:Tag Value)\"\n ],\n \"customFields\":[\n {\n \"fieldID\": \"\",\n \"fieldValue\": \"\"\n },\n {\n \"fieldID\": \"\",\n \"fieldValue\": \"\"\n }\n ],\n \"technicalPointOfContact\": {\n \"pocName\":\"\",\n \"pocEmail\":\"\",\n \"pocIsdCode\":\"\",\n \"pocMobileNumber\":\"\",\n \"pocDesignation\":\"\"\n }\n }\n}\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order.
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | 1234567890 (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | 6288275133 (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | 952a95057654aa1c26a0f287b6368579f5f3ec99f213543df5e0f7eb7f798b3d (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| productCode | 850 (mandatory)
Unique Product Code of the respective product. Product Code values are provided by eMudhra. |\n| accountingModel | 1 (optional)
Accounting Model of the Account.
The allowed value for this field is: 1 - Cash Model. The default value is '1' (in case if it is not set). |\n| saveAndHold | 0 (mandatory)
Should be set to any of the numeric values.
1 - Only Request ID will be generated and request will be pending for payment.
0 - Both Request ID & Order ID will be generated. |\n| requestNumber | 5652671652 (mandatory)
Request Number of the existing request which was saved as a draft.
This field is 'mandatory' only for the requests (Request ID is available & Order ID is yet to generate). |\n| emailNotifications | 1 (mandatory)
Can contain one of the numeric values as under. Default is '1'.
0 - Pre-vetting Organization (Organization & Subscriber Agreement info.) re-use consent notification will be sent to the applicant on order initiation, if \"preVetting\" value is set to '1'.
1 - All Order Notifications will be sent to the applicant on order initiation. |\n| groupNumber | 3589463147 (optional)
Group Number of the group. This is auto generated by the system. Groups can be your cost centers, departments, etc. within your Business.
It acts as a feature / tool for managing account user access to organizations, domains and finances. If group number is not given in the request, then system will consider the default group number and amount will be deducted from the account balance. If group is having its own balance, then amount will be deducted from the group balance else the amount will be deducted from the account balance. |\n| requestorInformation | (mandatory)
Specified below. |\n| delegationInformation | (optional)
Specified below. |\n| organizationDetails | (conditional mandatory)
This is mandatory, If prevetting is set to '0'.
Specified below. |\n| certificateInformation | (mandatory)
Specified below. |\n| contractSignerInfo | (conditional mandatory)
This is mandatory, if Prevetting is set to '0'.
Specified below. |\n| certificateApproverInfo | (conditional mandatory)
This is mandatory, if Prevetting is set to '0'.
Specified below. |\n| subscriptionDetails | (conditional mandatory)
Subscription Details of the order. |\n| csr | (optional)
CSR means Certificate Signing Request. CSR file has to be generated, preferably in the web server, or with any standard tools. You should ensure that the corresponding private key resides in same web server / tool, so that you can import the SSL / TLS certificate in the same, once it is issued. Applicable for all SSL / TLS products. |\n| numberOfDocuments | (optional)
Number of Documents attached. |\n| documentsAttached | (optional)
Uploading documents during order creation is recommended for sending us any additional / supporting documents so that they are automatically associated with your certificate order. This additional documentation would help emSign to speed up the certificate validation process. (E.g., incorporation letter, registration document, etc.). Specified below. |\n| additionalInformation | (optional)
Specified below. |\n\n**Requestor Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestorName | John Doe (mandatory)
Name of the requestor. |\n| requestorIsdCode | +1 (mandatory)
ISD Code of the requestor's mobile number. |\n| requestorMobileNumber | 9867542762 (mandatory)
Mobile number of the requestor. |\n| requestorEmail | [johndoe@example.com](https://johndoe@example.com) (mandatory)
Email of the requestor. |\n| requestorDesignation | BA (optional)
Designation of the requestor. |\n\n**Delegation Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| contactName | John Doe(mandatory)
Contact Name of the delegated person. |\n| email | [johndoe@example.com](https://johndoe@example.com) (mandatory)
Email ID of the delegated person. |\n\n**Organization Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| preVetting | 0 (mandatory)
This can be set to any of the numeric values as under. Default is '0'.
0 - No (New Organization)
1 - Yes (Pre-verified EV Organization) |\n| organizationNumber | (conditional mandatory)
Organization Number (Org. ID) of the existing organization associated to the respective emSign account of the certificate requester. This is mandatory, if \"preVetting\" value is set to '1'. |\n| representativeNumber | 4567876 (optional)
Organization Representative Number of the existing Organization representative. It will consider default requestor information of the respective order. (in case if it is not set). |\n\n**Certificate Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| organizationName | eMudhra Limited (mandatory)
Organization Name of Organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| organizationUnit | (optional)
Organization Unit of the respective Organization. |\n| businessCategory | 1 (mandatory)
Business Category of an Organization.
The allowed values for this field are:
1 - Private Organization
2 - Government Entity |\n| companyDuns | 222888778865426726524 (mandatory)
Company Registration Number or Company DUNS Number of an Organization. |\n| streetAddress1 | KIADB (mandatory)
Street Address 1 of an organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| streetAddress2 | (optional)
Street Address 2 of Organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| locality | KIADB (mandatory)
Locality of the respective Organization. |\n| state | Karnataka (mandatory)
State of an organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| countryCode | IN (mandatory)
Country code of Organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| postalCode | 560076 (mandatory)
Postal code of the respective Organization. |\n| domainName | emudhra.com (mandatory)
Domain Name of the website / server. If the pre-verified domain under the pre-vetted organization (if \"preVetting\" value is set to '1') is provided then fresh DCV is not required. |\n| additionalDomainNames | support.emudhra.com (mandatory)
Additional domain names of the website / server. This field is required only for SSL multi-domain / UCC products as specified below.
Applicable for SSL / TLS EV - UCC products. |\n| autoSecureWWW | 1 (mandatory)
This is set to '1' by default.
1 - Enabled (Secure 'www' variant of website)
0 - Disabled (Doesn't secure 'www' variant of website)
If user has chosen to secure website with both www and without www variants, then File-based (HTTP / HTTPs URL) DCV method is not allowed to verify DCV. |\n\n**Contract Signer Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| name | John Doe(mandatory)
Name of the Contract Signer. |\n| email | [johndoe@example.com](https://johndoe@example.com) (mandatory)
Email ID of the Contract Signer. |\n| isdCode | +1 (mandatory)
ISD Code of the Contract Signer's Mobile Number. |\n| mobileNumber | 92673626528 (mandatory)
Mobile Number of the Contract Signer. |\n| designation | PM (mandatory)
Designation of the Contract Signer. |\n| employeeID | 25615 (optional)
Employee ID of the Contract Signer. |\n\n**Certificate Approver Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| name | John Doe (mandatory)
Name of the Certificate Approver. |\n| email | [johndoe@example.com](https://johndoe@example.com) (mandatory)
Email ID of the Certificate Approver. |\n| isdCode | +1 (mandatory)
ISD Code of the Certificate Approver's Mobile Number. |\n| mobileNumber | 8726728726 (mandatory)
Mobile Number of the Certificate Approver. |\n| designation | PO (mandatory)
Designation of the Certificate Approver. |\n| employeeID | 762521(optional)
Employee ID of the Certificate Approver. |\n\n**Subscription Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| validity | 1 (optional)
Validity of the Subscription (1 / 2 / 3 Years).
Default value is '1' Year.
emSign is providing subscription validity upto 3 years where maximum Lifetime of 390 days per certificate is available with free renewals. |\n| autoRenew | 1 (conditional mandatory)
Auto-renew certificates until coverage.
Default value is '1'.
1: Allow Renewal of Certificate
0: Decline Renewal of Certificate |\n| renewCriteria | 30 (conditional mandatory)
Time duration to for renew certificate before expiry.
Default value is '30' Days.
30: Automatically reissue before 30 days of certificate expiry.
60: Automatically reissue before 60 days of certificate expiry. |\n\n**Document Attached**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| id | 1 (mandatory)
ID of the document. |\n| fileName | ID.pdf (mandatory)
File Name of the document. |\n| description | Passport (mandatory)
Description of the document. |\n| base64Value | (mandatory)
Base 64 encoded value of the document. |\n\n**Additional Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| tags | (optional)
Reporting tags are labels with different values, which can be associated with your certificate orders in CERTInext. These tags can then be used to filter reports, giving you a categorized insight into your business.
Tag Name and Tag Value must be colon separated values.
e.g.: Department:Technology
Multiple tag names and tag values can be associated with the order.
e.g.: Department:Technology,
Department:Legal,
Branch Location:India
Specified below. |\n| customFields | (conditional mandatory)
CERTInext allows you to use custom fields in your certificate order form. The Custom Field can be used to simplify record-keeping and makes order management efficient.
To enable Custom Fields feature for your CERTInext account, please contact your Account Manager.
Specified below. |\n| remarks | (optional)
Additional Information related to the order. |\n| recipientEmail | (optional)
Email recipients can be provided for receiving notifications related to Order Confirmation, Revocation and Renewal of certificates. |\n| technicalPointOfContact | (optional)
Technical Point of Contact will be notified for emails which are technical in nature such as Order Confirmation with order status tracking link, CSR & Certificate Download notification based on the email notifications configuration set by your account administrator.
Specified below. |\n\n**Tags**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| Tag Name | Dept (mandatory)
Reporting Tag Name. |\n| Tag Value | Support (mandatory)
Reporting Tag Value. |\n\n**Custom Fields**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| fieldID | 234 (mandatory)
Specify the Custom Field ID. This is a unique field identifier of a custom field.
This is mandatory, if Custom Fields are mandated by your account administrator.
This information will be available within CERTInext online portal. |\n| fieldValue | Dept (mandatory)
Specify the Custom Field value(s). You have to provide the right input based on the custom field type set by your account administrator.
NOTE: The allowed date format for date picker based Custom Field is: YYYY-MM-DD
This is mandatory, if Custom Fields are mandated by your account administrator.
The detailed Custom Field information will be available within CERTInext online portal. |\n\n**Technical Point of Contact**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| pocName | John Doe(mandatory)
Name of the Technical Point of Contact. |\n| pocEmail | [johndoe@example.com](https://johndoe@example.com) (mandatory)
Email ID of the Technical Point of Contact. |\n| pocIsdCode | +1 (optional)
ISD Code of the Technical Point of Contact's Mobile Number. |\n| pocMobileNumber | 8925727228 (optional)
Mobile number of the Technical Point of Contact. |\n| pocDesignation | Manager (optional)
Designation of the Technical Point of Contact. |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n },\n \"orderDetails\": { \n \"requestNumber\":\"\",\n \"orderNumber\": \"\",\n \"trackingUrl\": \"\"\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order.
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | 1234567890 (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | 1 (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestNumber | 4794392161 (conditional mandatory)
Unique Request ID of the respective request. This is 'Mandatory', if 'saveAndHold' is set to '1'. |\n| orderNumber | 2198843858 (conditional mandatory)
Unique Order ID of the respective order.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |\n| trackingUrl | [https://sandbox-emsignsubscriber.emudhra.net/PublicLink/publicLinkVerification.jsp?a=UVMvMzc2RDJjYUhlZFlhLzRJNU5IQT09](https://sandbox-emsignsubscriber.emudhra.net/PublicLink/publicLinkVerification.jsp?a=UVMvMzc2RDJjYUhlZFlhLzRJNU5IQT09) (mandatory)
Order status tracking URL of the respective order.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |" + }, + "response": [ + { + "name": "SSL/TLS - EV - UCC", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n\"meta\": {\r\n \"ver\":\"1.0\",\r\n \"ts\":\"{{ts}}\",\r\n \"txn\":\"{{txn}}\",\r\n \"accountNumber\":\"4397827229\",\r\n \"authKey\":\"{{hash}}\" \r\n },\r\n\"orderDetails\": {\r\n \"productCode\":\"851\",\r\n \"accountingModel\":\"2\",\r\n \"saveAndHold\":\"0\",\r\n \"emailNotifications\":\"0\",\r\n \"requestorInformation\": {\r\n \"requestorName\": \"Viña del Mar Viña Green\",\r\n \"requestorIsdCode\": \"1\",\r\n \"requestorMobileNumber\": \"7094940185\",\r\n \"requestorEmail\": \"john.green@example.com\",\r\n \"requestorDesignation\": \"Manager\"\r\n },\r\n \"subscriptionDetails\": { // Applicable only for SSL DV / OV\r\n \"validity\": \"3\", //1/2/3 (Default Value: 1)\r\n \"autoRenew\": \"1\", //1/0 (Default-1)\r\n \"renewCriteria\": \"60\" //30/60 (Default 30)\r\n },\r\n \"delegationInformation\": {\r\n \"contactName\": \"\",\r\n \"email\": \"\"\r\n },\r\n \"certificateInformation\": {\r\n \"organizationName\":\"eMudhra Inc.\",\r\n \"organizationUnit\":\"Technology\",\r\n \"businessCategory\":\"2\",\r\n \"companyDuns\":\"575764645375765\",\r\n \"streetAddress1\":\"1712 South East Bay Blvd\",\r\n \"streetAddress2\":\" Suite 360\",\r\n \"locality\":\"Provo\",\r\n \"state\":\"Utah\",\r\n \"countryCode\":\"US\",\r\n \"postalCode\":\"84606 \",\r\n \"autoSecureWWW\":\"1\",\r\n \"domainName\":\"www.emudhra.com\",\r\n \"additionalDomains\":[\r\n \"www.emSign.com\",\r\n \"www.emconnect.com\"\r\n ]\r\n },\r\n \"authorizedSignatoryInfo\": {\r\n \"arName\":\"Viña del Mar Viña Green\",\r\n \"arEmail\":\"john.green@example.com\",\r\n \"arIsdCode\":\"1\",\r\n \"arMobileNumber\":\"7094940185\",\r\n \"arDesignation\":\"Manager\"\r\n },\r\n \"technicalPointOfContact\":{\r\n \"pocFirstName\":\"Jenne\",\r\n \"pocLastName\":\"Flex\",\r\n \"pocEmail\":\"jenne.flex@example.com\",\r\n \"pocIsdCode\":\"+1\",\r\n \"pocMobileNumber\":\"7094940185\",\r\n \"pocDesignation\":\"System Administartor\"\r\n },\r\n \"csr\":\"\",\r\n \"numberOfDocuments\":\"\",\r\n \"documentsAttached\":[\r\n {\r\n \"id\":\"1\",\r\n \"fileName\":\"tuv.pdf\",\r\n \"description\":\"DTC Licence\",\r\n \"base64Value\":\"\"\r\n }\r\n\t\t \r\n ],\r\n \"additionalInformation\": {\r\n \"remarks\": \"SSL EV UCC MultiDomain, SSL EV UCC MultiDomain\"\r\n }\r\n}\r\n}\r\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/GenerateOrderSSL", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "GenerateOrderSSL" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "179" + }, + { + "key": "Date", + "value": "Tue, 02 Apr 2024 10:53:38 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"meta\": {\n \"ver\": \"1.0\",\n \"ts\": \"2024-04-02T16:23:36+05:30\",\n \"txn\": \"523481818168486850\",\n \"status\": \"0\",\n \"errorCode\": \"EMS-1220\",\n \"errorMessage\": \"'contractSignerInfo' block cannot be empty.\"\n }\n}" + } + ] + } + ], + "description": "This section includes following APIs.\n\n- SSL/TLS - DV\n- SSL/TLS - DV - UCC\n- SSL/TLS - DV - Wildcard\n- SSL/TLS - DV - Wildcard - UCC\n- SSL/TLS - OV\n- SSL/TLS - OV - UCC\n- SSL/TLS - OV - Wildcard\n- SSL/TLS - OV - Wildcard - UCC\n- SSL/TLS - EV\n- SSL/TLS - EV - UCC" + }, + { + "name": "Generate SMIME", + "item": [ + { + "name": "SMIME - Simple MV-S", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "if (status === \"1\") {", + " pm.test(\"Request Successful\", () => {", + " pm.expect(status).to.equal(\"1\");", + " if (res.orderDetails && res.orderDetails.orderNumber) {", + " pm.collectionVariables.set(\"orderNumber\", res.orderDetails.orderNumber);", + " }", + " if (res.orderDetails && res.orderDetails.requestNumber) {", + " pm.collectionVariables.set(\"requestNumber\", res.orderDetails.requestNumber);", + " }", + " });", + "} else {", + " pm.test(\"Request FAILED: \" + (errMsg || \"Unknown error\"), () => {", + " pm.expect(status).to.equal(\"1\");", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"productCode\": \"{{SMIME_Simple}}\",\r\n \"accountingModel\": \"2\",\r\n \"saveAndHold\": \"0\",\r\n \"emailNotifications\": \"1\",\r\n \"requestorInformation\": {\r\n \"requestorName\": \"{{requestorName}}\",\r\n \"requestorIsdCode\": \"1\",\r\n \"requestorMobileNumber\": \"{{requestorMobileNumber}}\",\r\n \"requestorEmail\": \"{{requestorEmail}}\",\r\n \"requestorDesignation\": \"{{requestorDesignation}}\"\r\n },\r\n \"delegationInformation\": {\r\n \"contactName\": \"\",\r\n \"email\": \"\"\r\n },\r\n \"organizationDetails\": {\r\n \"preVetting\": \"0\",\r\n \"organizationNumber\": \"\"\r\n },\r\n \"csr\": \"\",\r\n \"certificateInformation\": {\r\n \"email\": \"{{requestorEmail}}\"\r\n },\r\n \"additionalInformation\": {\r\n \"remarks\": \"\",\r\n \"tags\": []\r\n },\r\n \"agreementDetails\": {\r\n \"signerIP\": \"{{signerIP}}\",\r\n \"signerPlace\": \"{{signerPlace}}\",\r\n \"signerName\": \"{{requestorName}}\",\r\n \"acceptAgreement\": \"1\"\r\n }\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}GenerateOrderSMIME", + "host": [ + "{{baseURL}}GenerateOrderSMIME" + ] + }, + "description": "### Generate S/MIME - Simple Order\n\nThis API facilitates to generate new order for emSign S/MIME - Simple certificate.\n\n| **Region** | **URL** |\n| --- | --- |\n| US | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n| Global | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n\n**API Endpoint**\n\n| Header | Header |\n| --- | --- |\n| URL | http(s)://(url host port and path)/GenerateOrderSMIME |\n| Protocol | HTTP/ HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n \"meta\": {\n \"ver\":\"\",\n \"ts\":\"\",\n \"txn\":\"\",\n \"accountNumber\":\"\",\n \"authKey\":\"\"\n },\n \"orderDetails\": {\n \"productCode\":\"\",\n \"accountingModel\":\"\",\n \"saveAndHold\":\"\",\n \"requestNumber\":\"\",\n \"emailNotifications\":\"\",\n \"groupNumber\": \"\",\n \"requestorInformation\": {\n \"requestorName\":\"\",\n \"requestorIsdCode\":\"\",\n \"requestorMobileNumber\":\"\",\n \"requestorEmail\":\"\",\n \"requestorDesignation\":\"\"\n },\n \"delegationInformation\": {\n \"contactName\":\"\",\n \"email\":\"\"\n },\n \"organizationDetails\": {\n \"preVetting\":\"\",\n \"organizationNumber\":\"\"\n },\n \"certificateInformation\": {\n \"email\":\"\"\n },\n \"agreementDetails\": {\n \"signerIP\": \"\",\n \"signerPlace\": \"\",\n \"signerName\": \"\",\n \"acceptAgreement\": \"\"\n },\n \"additionalInformation\": {\n \"remarks\":\"\",\n \"tags\":[\n \"(Tag Name:Tag Value)\",\n \"(Tag Name:Tag Value)\"\n ],\n \"customFields\":[\n {\n \"fieldID\": \"\",\n \"fieldValue\": \"\"\n },\n {\n \"fieldID\": \"\",\n \"fieldValue\": \"\"\n }\n ]\n }\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order. Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | (mandatory)
Version should be set to \"1.0\". |\n| ts | (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| productCode | (mandatory)
Unique Product Code of the respective product. Product Code values are provided by eMudhra. |\n| accountingModel | (optional)
Accounting Model of the Account.
The allowed value for this field is: 1 - Cash Model.
The default value is '1' (in case if it is not set). |\n| saveAndHold | (mandatory)
Should be set to any of the numeric values.
1 - Only Request ID will be generated and request will be pending for payment.
0 - Both Request ID & Order ID will be generated. |\n| requestNumber | (conditional mandatory)
Request Number of the existing request which was saved as a draft. This field is 'mandatory' only for the requests (Request ID is available & Order ID is yet to generate). |\n| emailNotifications | (optional)
Can contain one of the numeric values as under.
Default is '1'.
0 - Subscriber Agreement, Email Verification link & Certificate Download PIN email notification will be sent to the applicant on order initiation.
1 - All Order Notifications will be sent to the applicant on order initiation. |\n| groupNumber | (optional)
Group Number of the group. This is auto generated by the system. Groups can be your cost centers, departments, etc. within your Business. It acts as a feature / tool for managing account user access to organizations, domains and finances. If group number is not given in the request, then system will consider the default group number and amount will be deducted from the account balance. If group is having its own balance, then amount will be deducted from the group balance else the amount will be deducted from the account balance. |\n| requestorInformation | (mandatory)
Specified below. |\n| delegationInformation | (optional)
Specified below. |\n| organizationDetails | (optional)
Specified below. |\n| certificateInformation | (mandatory)
Specified below. |\n| agreementDetails | (mandatory)
Signer details of the respective order to complete the Subscriber Agreement automatically. Specified Below. |\n| additionalInformation | (optional)
Specified below. |\n\n**Requestor Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestorName | (mandatory)
Name of the requestor. |\n| requestorIsdCode | (mandatory)
ISD Code of the requestor. |\n| requestorMobileNumber | (mandatory)
Mobile number of the requestor. |\n| requestorEmail | (mandatory)
Email of the requestor. |\n| requestorDesignation | (optional)
Designation of the requestor. |\n\n**Delegation Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| contactName | (mandatory)
Contact Name of the delegated person. |\n| email | (mandatory)
Email ID of the delegated person. |\n\n**Organization Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| preVetting | 0 (optional)
This can be set to any of the numeric values as under. Default is '0'.

0 - No (Fresh Email Verification without re-use)
1 - Yes (Pre-verified Domain of pre-vetted SMIME Organization). |\n| organizationNumber | (conditional mandatory)
Organization Number (Org. ID) of the existing SMIME organization associated to the respective emSign account.
This is mandatory, if 'preVetting' value is set to '1'. |\n\n**Certificate Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| email | (mandatory)
Email address of an individual. |\n\n**Document Attached**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| id | (mandatory)
ID of the document. |\n| fileName | (mandatory)
File Name of the document. |\n| description | (mandatory)
Description of the document. |\n| base64Value | (mandatory)
Base 64 encoded value of the document. |\n\n**Subscriber Agreement Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| acceptAgreement | 1 (mandatory)
This can be set to any of the numeric values as under.
1 - Accept Subscriber Agreement
0 - Reject Subscriber Agreement |\n| signerName | Sipra (conditional mandatory)
Name of the Signer.
It is mandatory, if 'acceptAgreement' is 1. |\n| signerPlace | GOA (conditional mandatory)
Place of the Signer.
It is mandatory, if 'acceptAgreement' is 1. |\n| signerIP | 10.24.108.199.182 (conditional mandatory)
IP Address of the Signer.
It is mandatory, if 'acceptAgreement' is 1. |\n\n**Additional Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| remarks | (optional)
Additional Information related to the order. |\n| tags | (optional)
Reporting tags are labels with different values, which can be associated with your certificate orders in CERTInext. These tags can then be used to filter reports, giving you a categorized insight into your business.Tag Name and Tag Value must be colon separated values. e.g.: Department:Technology. Multiple Tag Names and Tag Values can be associated with the order.e.g.: Department:Technology, Department:Legal, Branch Location:India. Specified below. |\n| customFields | (conditional mandatory)
CERTInext allows you to use custom fields in your certificate order form. The Custom Field can be used to simplify record-keeping and makes order management efficient. To enable Custom Fields feature for your CERTInext account, please contact your Account Manager. Specified below. |\n\n**Tags**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| Tag Name | (mandatory)
Reporting Tag Name. |\n| Tag Value | (mandatory)
Reporting Tag Value. |\n\n**Custom Fields**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| fieldID | (conditional mandatory)
Specify the Custom Field ID. This is a unique field identifier of a custom field.
This is mandatory, if Custom Fields are mandated by your account administrator. This information will be available within CERTInext online portal. |\n| fieldValue | (conditional mandatory)
Specify the Custom Field value(s). You have to provide the right input based on the custom field type set by your account administrator.
NOTE: The allowed date format for date picker based Custom Field is: YYYY-MM-DD. This is mandatory, if Custom Fields are mandated by your account administrator. The detailed Custom Field information will be available within CERTInext online portal. |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n },\n \"orderDetails\": {\n \"requestNumber\": \"\",\n \"orderNumber\": \"\",\n \"trackingUrl\": \"\"\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | (mandatory)
Version should be set to \"1.0\". |\n| ts | (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success.
Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success.
Mandatory in case of failure. |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestNumber | (conditional mandatory)
Unique Request ID of the respective request.
This is 'Mandatory', if 'saveAndHold' is set to '1'. |\n| orderNumber | (conditional mandatory)
Unique Order ID of the respective order.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |\n| trackingUrl | (mandatory)
Order status tracking URL of the respective order.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |" + }, + "response": [ + { + "name": "SMIME - Simple MV-S", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{authKey}}\"\r\n },\r\n \"orderDetails\": {\r\n \"productCode\": \"{{emSign - SMIME - Simple MV-S 1 Year}}\",\r\n \"accountingModel\": \"2\",\r\n \"saveAndHold\": \"0\",\r\n \"emailNotifications\": \"1\",\r\n \"requestorInformation\": {\r\n \"requestorName\": \"John Green\",\r\n \"requestorIsdCode\": \"1\",\r\n \"requestorMobileNumber\": \"9481081094\",\r\n \"requestorEmail\": \"john.green@example.com\",\r\n \"requestorDesignation\": \"Manager\"\r\n },\r\n \"delegationInformation\": {\r\n \"contactName\": \"\",\r\n \"email\": \"\"\r\n },\r\n \"organizationDetails\": {\r\n \"preVetting\": \"0\",\r\n \"organizationNumber\": \"\"\r\n },\r\n \"csr\": \"\",\r\n \"certificateInformation\": {\r\n \"email\": \"john.green@example.com\"\r\n },\r\n \"additionalInformation\": {\r\n \"remarks\": \"API Test\",\r\n \"tags\": []\r\n },\r\n \"agreementDetails\": {\r\n \"signerIP\": \"103.156.134.109\",\r\n \"signerPlace\": \"Provo\",\r\n \"signerName\": \"John\",\r\n \"acceptAgreement\": \"1\"\r\n }\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}GenerateOrderSMIME", + "host": [ + "{{baseUrl}}GenerateOrderSMIME" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "339" + }, + { + "key": "Date", + "value": "Mon, 22 Apr 2024 08:59:42 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"orderDetails\": {\n \"requestNumber\": \"8748732776\",\n \"orderNumber\": \"7781212655\",\n \"trackingURL\": \"https://sandbox-emsignsubscriber.emudhra.net/PublicLink/publicLinkVerification.jsp?a=cnBBNnh2OEoyd1o1cGRPSTU4Zzhodz09\"\n },\n \"meta\": {\n \"ver\": \"1.0\",\n \"errorMessage\": \"\",\n \"errorCode\": \"\",\n \"txn\": \"429385593801453060\",\n \"ts\": \"2024-04-22T14:29:41+05:30\",\n \"status\": \"1\"\n }\n}" + } + ] + } + ] + }, + { + "name": "Generate Signature", + "item": [ + { + "name": "Natural Person", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "if (status === \"1\") {", + " pm.test(\"Request Successful\", () => {", + " pm.expect(status).to.equal(\"1\");", + " if (res.orderDetails && res.orderDetails.orderNumber) {", + " pm.collectionVariables.set(\"orderNumber\", res.orderDetails.orderNumber);", + " }", + " if (res.orderDetails && res.orderDetails.requestNumber) {", + " pm.collectionVariables.set(\"requestNumber\", res.orderDetails.requestNumber);", + " }", + " });", + "} else {", + " pm.test(\"Request FAILED: \" + (errMsg || \"Unknown error\"), () => {", + " pm.expect(status).to.equal(\"1\");", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"productCode\": \"{{DocSigner_NaturalPerson_1Year}}\",\r\n \"accountingModel\": \"2\",\r\n \"saveAndHold\": \"0\",\r\n \"emailNotifications\": \"1\",\r\n \"requestorInformation\": {\r\n \"requestorName\": \"{{requestorName}}\",\r\n \"requestorIsdCode\": \"1\",\r\n \"requestorMobileNumber\": \"{{requestorMobileNumber}}\",\r\n \"requestorEmail\": \"{{requestorEmail}}\",\r\n \"requestorDesignation\": \"{{requestorDesignation}}\"\r\n },\r\n \"delegationInformation\": {\r\n \"contactName\": \"\",\r\n \"email\": \"\"\r\n },\r\n \"certificateInformation\": {\r\n \"firstName\": \"John\",\r\n \"lastName\": \"Green\",\r\n \"identityDocumentType\": \"RUT\",\r\n \"identificationNumber\": \"13227635-8\",\r\n \"email\": \"{{requestorEmail}}\",\r\n \"streetAddress1\": \"1712 South East Bay Blvd\",\r\n \"streetAddress2\": \"\",\r\n \"countryCode\": \"US\",\r\n \"state\": \"Utah\",\r\n \"locality\": \"Provo\",\r\n \"postalCode\": \"84606\"\r\n },\r\n \"numberOfDocuments\": \"\",\r\n \"documentsAttached\": [\r\n {\r\n \"id\": \"\",\r\n \"fileName\": \"\",\r\n \"description\": \"\",\r\n \"base64Value\": \"\"\r\n }\r\n ],\r\n \"additionalInformation\": {\r\n \"remarks\": \"Test ,User\",\r\n \"Recipient Email IDs\": \"\",\r\n \"tags\": [],\r\n \"customFields\": []\r\n }\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}GenerateOrderSignature", + "host": [ + "{{baseURL}}GenerateOrderSignature" + ] + }, + "description": "This API facilitates to generate new order for Document Signer Personal (Natural Person) certificates.\n\n| **Region** | **URL** |\n| --- | --- |\n| US | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n| Global | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n\n**API Endpoint**\n\n| Header | Header |\n| --- | --- |\n| URL | http(s)://(url host port and path)/GenerateOrderSignature |\n| Protocol | HTTP/ HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n \"meta\": {\n \"ver\":\"\",\n \"ts\":\"\",\n \"txn\":\"\",\n \"accountNumber\":\"\",\n \"authKey\":\"\" \n },\n \"orderDetails\": {\n \"productCode\":\"\",\n \"accountingModel\":\"\",\n \"saveAndHold\":\"\",\n \"requestNumber\":\"\",\n \"emailNotifications\": \"\",\n \"groupNumber\": \"\",\n \"requestorInformation\": {\n \"requestorName\":\"\",\n \"requestorIsdCode\":\"\",\n \"requestorMobileNumber\":\"\",\n \"requestorEmail\":\"\",\n \"requestorDesignation\":\"\"\n },\n \"delegationInformation\": {\n \"contactName\":\"\",\n \"email\":\"\"\n },\n \"certificateInformation\": {\n \"firstName\":\"\",\n \"lastName\":\"\",\n \"identityDocumentType\":\"\",\n \"identificationNumber\":\"\",\n \"email\": \"\",\n \"streetAddress1\":\"\",\n \"streetAddress2\":\"\",\n \"locality\":\"\",\n \"state\": \"\",\n \"countryCode\":\"\",\n \"postalCode\":\"\"\n },\n \"numberOfDocuments\":\"\",\n \"documentsAttached\": [\n {\n \"id\": \"\",\n \"fileName\":\"\",\n \"description\": \"\",\n \"base64Value\": \"\"\n },\n {\n \"id\": \"\",\n \"fileName\": \"\",\n \"description\": \"\",\n \"base64Value\": \"\"\n },\n ],\n \"additionalInformation\": {\n \"remarks\":\"\",\n \"recipientEmail\": \"\",\n \"tags\":[\n \"(Tag Name:Tag Value)\",\n \"(Tag Name:Tag Value)\"\n ],\n \"customFields\":[\n {\n \"fieldID\": \"\",\n \"fieldValue\": \"\"\n },\n {\n \"fieldID\": \"\",\n \"fieldValue\": \"\"\n }\n ]\n }\n}\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order. Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | 1234567890 (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | 6288275133 (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | 952a95057654aa1c26a0f287b6368579f5f3ec99f213543df5e0f7eb7f798b3d (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| productCode | 676 (mandatory)
Unique Product Code of the respective product. Product Code values are provided by eMudhra. |\n| accountingModel | 1 (optional)
Accounting Model of the Account.
The allowed value for this field is: 1 - Cash Model. The default value is '1' (in case if it is not set). |\n| saveAndHold | 1 (mandatory)
Should be set to any of the numeric values.
1 - Only Request ID will be generated and request will be pending for payment.
0 - Both Request ID & Order ID will be generated. |\n| requestNumber | (conditional mandatory)
Request Number of the existing request which was saved as a draft. This field is 'mandatory' only for the requests (Request ID is available & Order ID is yet to generate). |\n| emailNotifications | 0 (optional)
Can contain one of the numeric values as under. Default is '1'.
0 - Subscriber Agreement, Email Verification link & Certificate Download PIN email notification will be sent to the applicant on order initiation.
1 - All Order Notifications will be sent to the applicant on order initiation. |\n| groupNumber | 6765423415 (optional)
Group Number of the group. This is auto generated by the system. Groups can be your cost centers, departments, etc. within your Business. It acts as a feature / tool for managing account user access to organizations, domains and finances. If group number is not given in the request, then system will consider the default group number and amount will be deducted from the account balance. If group is having its own balance, then amount will be deducted from the group balance else the amount will be deducted from the account balance. |\n| requestorInformation | (mandatory)
Specified below. |\n| delegationInformation | (optional)
Specified below. |\n| certificateInformation | (mandatory)
Specified below. |\n| numberOfDocuments | (optional)
Number of Documents attached. |\n| documentsAttached | (optional)
Uploading documents during order creation is recommended for sending us any additional / supporting documents so that they are automatically associated with your certificate order. This additional documentation would help emSign to speed up the certificate validation process. (E.g., incorporation letter, registration document, etc.). Specified below. |\n| additionalInformation | (optional)
Specified below. |\n\n**Requestor Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestorName | John Doe (mandatory)
Name of the requestor. |\n| requestorIsdCode | +1 (mandatory)
ISD Code of the requestor. |\n| requestorMobileNumber | 87899256252 (mandatory)
Mobile number of the requestor. |\n| requestorEmail | [johndoe@example.com](https://johndoe@example.com) (mandatory)
Email of the requestor. |\n| requestorDesignation | Manager (optional)
Designation of the requestor. |\n\n**Delegation Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| contactName | John Doe (mandatory)
Contact Name of the delegated person. |\n| email | [johndoe@example.com](https://johndoe@example.com) (mandatory)
Email ID of the delegated person. |\n\n**Certificate Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| firstName | John (mandatory)
First Name of an individual. |\n| lastName | Doe (mandatory)
Last Name of an individual. |\n| identityDocumentType | ID Proof (optional) Description of the Identity Document Type. |\n| identificationNumber | 456789876 (optional)
Identification Number of an individual. |\n| email | [johndoe@example.com](https://johndoe@example.com) (mandatory)
Email ID of an individual. |\n| streetAddress1 | KIADB (mandatory)
Street Address 1 of an individual. |\n| streetAddress2 | (optional)
Street Address 2 of an individual. |\n| locality | KIADB (mandatory)
Locality of an individual. |\n| state | Karnataka (mandatory)
State Name of an individual. |\n| countryCode | IN (mandatory)
Country Code of the Country. Please refer the shared list below for your reference. |\n| postalCode | 785562 (mandatory)
Postal Code of an individual. |\n\n**Document Attached**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| id | 1 (mandatory)
ID of the document. |\n| fileName | PAN.pdf (mandatory)
File Name of the document. |\n| description | ID Proof (mandatory)
Description of the document. |\n| base64Value | (mandatory)
Base 64 encoded value of the document. |\n\n**Additional Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| remarks | (optional)
Additional Information related to the order. |\n| recipientEmail | (optional)
Email recipients can be provided for receiving notifications related to Order Confirmation, Revocation and Renewal of certificates. |\n| tags | (optional)
Reporting tags are labels with different values, which can be associated with your certificate orders in CERTInext. These tags can then be used to filter reports, giving you a categorized insight into your business.Tag Name and Tag Value must be colon separated values.
e.g.: Department:Technology.
Multiple Tag Names and Tag Values can be associated with the order. e.g.: Department:Technology, Department:Legal, Branch Location:India.
Specified below. |\n| customFields | (conditional mandatory)
CERTInext allows you to use custom fields in your certificate order form. The Custom Field can be used to simplify record-keeping and makes order management efficient. To enable Custom Fields feature for your CERTInext account, please contact your Account Manager. Specified below. |\n\n**Tags**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| Tag Name | Dept (mandatory)
Reporting Tag Name. |\n| Tag Value | Technology (mandatory)
Reporting Tag Value. |\n\n**Custom Fields**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| fieldID | 567 (conditional mandatory)
Specify the Custom Field ID. This is a unique field identifier of a custom field. This is mandatory, if Custom Fields are mandated by your account administrator. This information will be available within CERTInext online portal. |\n| fieldValue | 2023-98-08 (conditional mandatory)
Specify the Custom Field value(s). You have to provide the right input based on the custom field type set by your account administrator.
**NOTE**:The allowed date format for date picker based Custom Field is: YYYY-MM-DD. This is mandatory, if Custom Fields are mandated by your account administrator. The detailed Custom Field information will be available within CERTInext online portal. |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n },\n \"orderDetails\": {\n \"requestNumber\": \"\",\n \"orderNumber\": \"\",\n \"trackingUrl\": \"\"\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | 1234567890 (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | 1 (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestNumber | 5678654562 (conditional mandatory)
Unique Request ID of the respective request. This is 'Mandatory', if 'saveAndHold' is set to '1'. |\n| orderNumber | 1234567890 (conditional mandatory)
Unique Order ID of the respective order.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |\n| trackingUrl | [https://sandbox-emsignsubscriber.emudhra.net/PublicLink/publicLinkVerification.jsp?a=UVMvMzc2RDJjYUhlZFlhLzRJNU5IQT09](https://sandbox-emsignsubscriber.emudhra.net/PublicLink/publicLinkVerification.jsp?a=UVMvMzc2RDJjYUhlZFlhLzRJNU5IQT09) (mandatory)
Order status tracking URL of the respective order.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |" + }, + "response": [ + { + "name": "Natural Person", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n\"meta\": {\r\n \"ver\":\"1.0\",\r\n \"ts\":\"{{ts}}\",\r\n \"txn\":\"{{txn}}\",\r\n \"accountNumber\":\"4397827229\",\r\n \"authKey\":\"{{hash}}\" \r\n },\r\n \"orderDetails\":{\r\n \"productCode\":\"819\",\r\n \"accountingModel\":\"10\",\r\n \"saveAndHold\":\"0\",\r\n \"emailNotifications\":\"0\",\r\n \"submitAgreementByEmail\": \"0\", \r\n \"requestorInformation\": {\r\n \"requestorName\":\"PAOLLA ANDREA DELPIANO\",\r\n \"requestorIsdCode\":\"56\",\r\n \"requestorMobileNumber\":\"984271446\",\r\n \"requestorEmail\":\"abcd@hotmail.com\",\r\n \"requestorDesignation\":\"\"\r\n },\r\n// \"subscriptionDetails\": { // Applicable only for SSL DV / OV\r\n// \"validity\": \"0\", //1/2/3 (Default Value: 1)\r\n// \"autoRenew\": \"\", //1/0 (Default-1)\r\n// \"renewCriteria\": \"\" //30/60 (Default 30)\r\n// },\r\n \"delegationInformation\": {\r\n \"contactName\":\"\",\r\n \"email\":\"\"\r\n },\r\n \"certificateInformation\": {\r\n \"firstName\": \"PAOLLA ANDREA\",\r\n \"lastName\": \"DELPIANO ZAVALA\",\r\n \"identityDocumentType\": \"RUT\",\r\n \"identificationNumber\": \"13227635-8\",\r\n \"organizationName\": \"\",\r\n \"organizationIdentificationNumber\": \"\",\r\n \"organizationUnit\": \"\",\r\n \"email\": \"abcd@hotmail.com\",\r\n \"streetAddress1\": \"GRACIELA LETELIER DE IBAAEZ PARCELA 27B\",\r\n \"streetAddress2\": \"\",\r\n \"countryCode\": \"IN\", \r\n \"state\": \"Karnataka\", \r\n \"locality\": \"Bangalore\",\r\n \"postalCode\": \"546103\",\r\n \"designation\": \"Testing\"\r\n },\r\n \"numberOfDocuments\":\"\",\r\n \"documentsAttached\":[\r\n {\r\n \"id\":\"\",\r\n \"fileName\":\"\",\r\n \"description\":\"\",\r\n \"base64Value\":\"\"\r\n }\r\n\t\t \r\n ],\r\n \r\n \"additionalInformation\": {\r\n \"remarks\": \"Test ,User\",\r\n \"Recipient Email IDs\" :\"xyz@emudhra.com\",\r\n \"tags\":[\r\n \"AABH:A6756747\"\r\n ]\r\n // \"customFields\": [\r\n // {\r\n // \"fieldID\": \"145\",\r\n // \"fieldValue\": \"xyz@emudhra.com\"\r\n // },\r\n // {\r\n // \"fieldID\": \"764\",\r\n // \"fieldValue\": \"Male\"\r\n // },\r\n // {\r\n // \"fieldID\": \"519\",\r\n // \"fieldValue\": \"1996-29-09\"\r\n // },\r\n // {\r\n // \"fieldID\": \"616\",\r\n // \"fieldValue\": \"HCL\"\r\n // },\r\n // {\r\n // \"fieldID\": \"861\",\r\n // \"fieldValue\": \"56785\"\r\n // },\r\n // {\r\n // \"fieldID\": \"827\",\r\n // \"fieldValue\": \"AXTPN9856D\"\r\n // }\r\n // ] \r\n \r\n }\r\n}\r\n}\r\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/GenerateOrderSignature", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "GenerateOrderSignature" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "353" + }, + { + "key": "Date", + "value": "Tue, 02 Apr 2024 10:54:29 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"orderDetails\": {\n \"requestNumber\": \"4346643251\",\n \"orderNumber\": \"5484879223\",\n \"trackingURL\": \"https://sandbox-emsignsubscriber.emudhra.net/PublicLink/publicLinkVerification.jsp?a=MXNUTFVHbHM0ZEdpMW5UMDRTNVNhQT09\"\n },\n \"meta\": {\n \"ver\": \"1.0\",\n \"errorMessage\": \"\",\n \"errorCode\": \"\",\n \"txn\": \"0588c58827ae4ccca0afc04472f7bc3e\",\n \"ts\": \"2024-04-02T16:24:27+05:30\",\n \"status\": \"1\"\n }\n}" + } + ] + }, + { + "name": "Legal Person", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "if (status === \"1\") {", + " pm.test(\"Request Successful\", () => {", + " pm.expect(status).to.equal(\"1\");", + " if (res.orderDetails && res.orderDetails.orderNumber) {", + " pm.collectionVariables.set(\"orderNumber\", res.orderDetails.orderNumber);", + " }", + " if (res.orderDetails && res.orderDetails.requestNumber) {", + " pm.collectionVariables.set(\"requestNumber\", res.orderDetails.requestNumber);", + " }", + " });", + "} else {", + " pm.test(\"Request FAILED: \" + (errMsg || \"Unknown error\"), () => {", + " pm.expect(status).to.equal(\"1\");", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"productCode\": \"{{DocSigner_LegalPerson_1Year}}\",\r\n \"accountingModel\": \"2\",\r\n \"saveAndHold\": \"0\",\r\n \"emailNotifications\": \"1\",\r\n \"requestorInformation\": {\r\n \"requestorName\": \"{{requestorName}}\",\r\n \"requestorIsdCode\": \"1\",\r\n \"requestorMobileNumber\": \"{{requestorMobileNumber}}\",\r\n \"requestorEmail\": \"{{requestorEmail}}\",\r\n \"requestorDesignation\": \"{{requestorDesignation}}\"\r\n },\r\n \"delegationInformation\": {\r\n \"contactName\": \"\",\r\n \"email\": \"\"\r\n },\r\n \"organizationDetails\": {\r\n \"preVetting\": \"\",\r\n \"organizationNumber\": \"\",\r\n \"prevettingToken\": \"\",\r\n \"representativeNumber\": \"\"\r\n },\r\n \"certificateInformation\": {\r\n \"firstName\": \"John\",\r\n \"lastName\": \"Green\",\r\n \"identityDocumentType\": \"testing\",\r\n \"identificationNumber\": \"AXTPN9856D\",\r\n \"organizationName\": \"eMudhra Inc.\",\r\n \"organizationIdentificationNumber\": \"one-K\",\r\n \"organizationUnit\": \"Technology\",\r\n \"email\": \"{{requestorEmail}}\",\r\n \"streetAddress1\": \"1712 South East Bay Blvd\",\r\n \"streetAddress2\": \"\",\r\n \"countryCode\": \"US\",\r\n \"state\": \"Utah\",\r\n \"locality\": \"Provo\",\r\n \"postalCode\": \"84606\"\r\n },\r\n \"numberOfDocuments\": \"\",\r\n \"documentsAttached\": [\r\n {\r\n \"id\": \"\",\r\n \"fileName\": \"\",\r\n \"description\": \"\",\r\n \"base64Value\": \"\"\r\n }\r\n ],\r\n \"additionalInformation\": {\r\n \"remarks\": \"Test ,User\",\r\n \"Recipient Email IDs\": \"\",\r\n \"tags\": [],\r\n \"customFields\": []\r\n }\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}GenerateOrderSignature", + "host": [ + "{{baseURL}}GenerateOrderSignature" + ] + }, + "description": "This API facilitates to generate new order for Document Signer Professional (Legal Person) certificates.\n\n| **Region** | **URL** |\n| --- | --- |\n| US | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n| Global | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n\n**API Endpoint**\n\n| Header | Header |\n| --- | --- |\n| URL | http(s)://(url host port and path)/GenerateOrderSignature |\n| Protocol | HTTP/ HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n \"meta\":{\n \"ver\":\"\",\n \"ts\":\"\",\n \"txn\":\"\",\n \"accountNumber\":\"\",\n \"authKey\":\"\"\n },\n \"orderDetails\":{\n \"productCode\":\"\",\n \"accountingModel\":\"\",\n \"saveAndHold\":\"\",\n \"requestNumber\":\"\",\n \"emailNotifications\":\"\",\n \"groupNumber\":\"\",\n \"requestorInformation\":{\n \"requestorName\":\"\",\n \"requestorIsdCode\":\"\",\n \"requestorMobileNumber\":\"\",\n \"requestorEmail\":\"\",\n \"requestorDesignation\":\"\"\n },\n \"delegationInformation\":{\n \"contactName\":\"\",\n \"email\":\"\"\n },\n \"organizationDetails\":{\n \"preVetting\":\"\",\n \"organizationNumber\":\"\",\n \"prevettingToken\": \"\",\n \"representativeNumber\":\"\"\n },\n \"certificateInformation\":{\n \"firstName\":\"\",\n \"lastName\":\"\",\n \"designation\":\"\",\n \"identityDocumentType\":\"\",\n \"identificationNumber\":\"\",\n \"organizationName\":\"\",\n \"organizationIdentificationNumber\":\"\",\n \"organizationUnit\":\"\",\n \"email\":\"\",\n \"streetAddress1\":\"\",\n \"streetAddress2\":\"\",\n \"locality\":\"\",\n \"state\":\"\",\n \"countryCode\":\"\",\n \"postalCode\":\"\"\n },\n \"numberOfDocuments\":\"\",\n \"documentsAttached\":[\n {\n \"id\":\"\",\n \"fileName\":\"\",\n \"description\":\"\",\n \"base64Value\":\"\"\n },\n {\n \"id\":\"\",\n \"fileName\":\"\",\n \"description\":\"\",\n \"base64Value\":\"\"\n }\n ],\n \"additionalInformation\":{\n \"remarks\":\"\",\n \"recipientEmail\": \"\",\n \"tags\":[\n \"(Tag Name:Tag Value)\",\n \"(Tag Name:Tag Value)\"\n ],\n \"customFields\":[\n {\n \"fieldID\":\"\",\n \"fieldValue\":\"\"\n },\n {\n \"fieldID\":\"\",\n \"fieldValue\":\"\"\n }\n ]\n }\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order. Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | 1234567890 (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | 6288275133 (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | 952a95057654aa1c26a0f287b6368579f5f3ec99f213543df5e0f7eb7f798b3d (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| productCode | 786 (mandatory)
Unique Product Code of the respective product. Product Code values are provided by eMudhra. |\n| accountingModel | 1 (optional)
Accounting Model of the Account. The allowed value for this field is: 1 - Cash Model. The default value is '1' (in case if it is not set). |\n| saveAndHold | 0 (mandatory)
Should be set to any of the numeric values. 1 - Only Request ID will be generated and request will be pending for payment. 0 - Both Request ID & Order ID will be generated. |\n| requestNumber | (conditional mandatory)
Request Number of the existing request which was saved as a draft. This field is 'mandatory' only for the requests (Request ID is available & Order ID is yet to generate). |\n| emailNotifications | (optional)
Can contain one of the numeric values as under. Default is '1'. 0 - Subscriber Agreement, Email Verification link & Certificate Download PIN email notification will be sent to the applicant on order initiation. 1 - All Order Notifications will be sent to the applicant on order initiation. |\n| groupNumber | (optional)
Group Number of the group. This is auto generated by the system. Groups can be your cost centers, departments, etc. within your Business. It acts as a feature / tool for managing account user access to organizations, domains and finances. If group number is not given in the request, then system will consider the default group number and amount will be deducted from the account balance. If group is having its own balance, then amount will be deducted from the group balance else the amount will be deducted from the account balance. |\n| requestorInformation | (mandatory)
Specified below. |\n| delegationInformation | (optional)
Specified below. |\n| organizationDetails | (optional)
Specified below. |\n| certificateInformation | (mandatory)
Specified below. |\n| numberOfDocuments | (optional)
Number of Documents attached. |\n| documentsAttached | (optional)
Uploading documents during order creation is recommended for sending us any additional / supporting documents so that they are automatically associated with your certificate order. This additional documentation would help emSign to speed up the certificate validation process. (E.g., incorporation letter, registration document, etc.). Specified below. |\n| additionalInformation | (optional)
Specified below. |\n\n**Requestor Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestorName | John Doe (mandatory)
Name of the requestor. |\n| requestorIsdCode | +1 (mandatory)
ISD Code of the requestor. |\n| requestorMobileNumber | 9856245226 (mandatory)
Mobile number of the requestor. |\n| requestorEmail | [johndoe@example.com](https://johndoe@example.com) (mandatory)
Email of the requestor. |\n| requestorDesignation | (optional)
Designation of the requestor. |\n\n**Delegation Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| contactName | John Doe (mandatory)
Contact Name of the delegated person. |\n| email | [johndoe@example.com](https://johndoe@example.com) (mandatory)
Email ID of the delegated person. |\n\n**Organization Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| preVetting | 0 (optional)
This can be set to any of the numeric values as under. Default is '0'. 0 - No (New Organization), 1 - Yes (Existing Organization) |\n| organizationNumber | 4567654 (conditional mandatory)
Organization Number (Org.ID) of the existing organization associated to the respective emSign account.This is mandatory, if \"preVetting\" value is set to '1'. |\n| prevettingToken | (optional)
Please specify the unique pre-vetted token value associated to the respective organization. Pre-vetting Organization re-use consent email notification will not be sent on order initiation, if the organization re-use token is submitted with your certificate order.
To get the prevetting token, please contact your Account administartor.
See [Organization Details API](https://dev.emsign.com/organization-detail) |\n| representativeNumber | (optional)
Organization Representative Number of the existing Organization representative. It will consider default representative details of the respective organization (in case if it is not set). |\n\n**Certificate Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| firstName | Yashwanth (mandatory)
First Name of an individual. |\n| lastName | TM (mandatory)
Last Name of an individual. |\n| designation | (optional)
Designation of an individual. |\n| identityDocumentType | (optional)
Description of the Identity Document Type. |\n| identificationNumber | (optional)
Identification Number of an individual. |\n| organizationName | eMudhra (mandatory)
Name of the Organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| organizationIdentificationNumber | (optional)
Organization Identification Number. |\n| organizationUnit | (optional)
Organization Unit of the respective Organization. |\n| email | [johndoe@example.com](https://johndoe@example.com) (mandatory)
Email ID of an individual. |\n| streetAddress1 | KIADB (mandatory)
Street Address 1 of an organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| streetAddress2 | (optional)
Street Address 2 of an organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| locality | GOA (mandatory)
Locality of an organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| state | Karnataka (mandatory)
State Name of an organization.
This field is not required, if \"preVetting\" value is set to '1'. |\n| countryCode | IN (mandatory)
Country Code of the Country. Please refer the shared list below for your reference. |\n| postalCode | 786672 (mandatory)
Postal Code of an organization.This field is not required, if \"preVetting\" value is set to '1'. |\n\n**Document Attached**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| id | 1 (mandatory)
ID of the document. |\n| fileName | ID.pdf (mandatory)
File Name of the document. |\n| description | ID Proof (mandatory)
Description of the document. |\n| base64Value | (mandatory)
Base 64 encoded value of the document. |\n\n**Additional Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| remarks | (optional)
Additional Information related to the order. |\n| recipientEmail | (optional)
Email recipients can be provided for receiving notifications related to Order Confirmation, Revocation and Renewal of certificates. |\n| tags | (optional)
Reporting tags are labels with different values, which can be associated with your certificate orders in CERTInext. These tags can then be used to filter reports, giving you a categorized insight into your business.Tag Name and Tag Value must be colon separated values.
e.g.: Department:Technology.
Multiple Tag Names and Tag Values can be associated with the order.
e.g.: Department:Technology, Department:Legal, Branch Location:India. Specified below. |\n| customFields | (conditional mandatory)
CERTInext allows you to use custom fields in your certificate order form. The Custom Field can be used to simplify record-keeping and makes order management efficient. To enable Custom Fields feature for your CERTInext account, please contact your Account Manager. Specified below. |\n\n**Tags**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| Tag Name | (mandatory)
Reporting Tag Name. |\n| Tag Value | (mandatory)
Reporting Tag Value. |\n\n**Custom Fields**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| fieldID | (conditional mandatory)
Specify the Custom Field ID. This is a unique field identifier of a custom field. This is mandatory, if Custom Fields are mandated by your account administrator. This information will be available within CERTInext online portal. |\n| fieldValue | (conditional mandatory)
Specify the Custom Field value(s). You have to provide the right input based on the custom field type set by your account administrator.NOTE: The allowed date format for date picker based Custom Field is: YYYY-MM-DD. This is mandatory, if Custom Fields are mandated by your account administrator. The detailed Custom Field information will be available within CERTInext online portal. |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n },\n \"orderDetails\": {\n \"requestNumber\": \"\",\n \"orderNumber\": \"\",\n \"trackingUrl\": \"\"\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | 1234567890 (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | 1 (mandatory)
1 - Success, 0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success.
Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success.
Mandatory in case of failure. |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestNumber | 1234567890 (conditional mandatory)
Unique Request ID of the respective request. This is 'Mandatory', if 'saveAndHold' is set to '1'. |\n| orderNumber | 9876543210 (conditional mandatory)
Unique Order ID of the respective order. This is 'Optional' in case of failure status (0).This is 'Mandatory' in case of success status (1). |\n| trackingUrl | (mandatory)
Order status tracking URL of the respective order. This is 'Optional' in case of failure status (0). This is 'Mandatory' in case of success status (1). |" + }, + "response": [ + { + "name": "Legal Person", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n\"meta\": {\r\n \"ver\":\"1.0\",\r\n \"ts\":\"{{ts}}\",\r\n \"txn\":\"{{txn}}\",\r\n \"accountNumber\":\"4397827229\",\r\n \"authKey\":\"{{hash}}\" \r\n },\r\n \"orderDetails\": {\r\n \"productCode\":\"822\",\r\n \"accountingModel\":\"2\",\r\n \"saveAndHold\":\"0\",\r\n \"emailNotifications\":\"1\",\r\n //\"submitAgreementByEmail\": \"0\",\r\n \"requestorInformation\": {\r\n \"requestorName\":\"John\",\r\n \"requestorIsdCode\":\"1\",\r\n \"requestorMobileNumber\":\"7094940185\",\r\n \"requestorEmail\":\"john.green@example.com\",\r\n \"requestorDesignation\":\"Manager\"\r\n },\r\n \"subscriptionDetails\": { // Applicable only for SSL DV / OV\r\n \"validity\": \"0\", //1/2/3 (Default Value: 1)\r\n \"autoRenew\": \"\", //1/0 (Default-1)\r\n \"renewCriteria\": \"\" //30/60 (Default 30)\r\n },\r\n \"delegationInformation\": {\r\n \"contactName\":\"Ben Dover\",\r\n \"email\":\"ben.dover@example.com\"\r\n },\r\n \"organizationDetails\": {\r\n \"preVetting\": \"0\",\r\n \"organizationNumber\": \"8447416\",\r\n \"prevettingToken\": \"8D93A1D55160401EB991DB99530A698F\",\r\n \"representativeNumber\": \"3226512684\"\r\n },\r\n \"certificateInformation\": {\r\n \"firstName\": \"John\",\r\n \"lastName\": \"Green\",\r\n \"identityDocumentType\": \"testing\",\r\n \"identificationNumber\": \"AXTPN9856D\",\r\n \"organizationName\": \"eMudhra Inc.\",\r\n \"organizationIdentificationNumber\": \"one-K\",\r\n \"organizationUnit\": \"Technology\",\r\n \"email\": \"john.green@example.com\",\r\n \"streetAddress1\": \"1712 South East Bay Blvd\",\r\n \"streetAddress2\": \"\",\r\n \"countryCode\": \"US\", \r\n \"state\": \"Utah\", \r\n \"locality\": \"Provo\",\r\n \"postalCode\": \"84606 \",\r\n \"designation\": \"Manager\"\r\n },\r\n \"numberOfDocuments\":\"\",\r\n \"documentsAttached\":[\r\n {\r\n \"id\":\"\",\r\n \"fileName\":\"\",\r\n \"description\":\"\",\r\n \"base64Value\":\"\"\r\n }\r\n\t\t \r\n ],\r\n \"additionalInformation\": {\r\n \"remarks\": \"Test ,User , name. abcd@example.com , wxyz@example.com\"\r\n }\r\n}\r\n}\r\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/GenerateOrderSignature", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "GenerateOrderSignature" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "353" + }, + { + "key": "Date", + "value": "Tue, 02 Apr 2024 10:56:12 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"orderDetails\": {\n \"requestNumber\": \"8832684853\",\n \"orderNumber\": \"2146729573\",\n \"trackingURL\": \"https://sandbox-emsignsubscriber.emudhra.net/PublicLink/publicLinkVerification.jsp?a=MXNUTFVHbHM0ZEVvUzFHYWJ4c0IrZz09\"\n },\n \"meta\": {\n \"ver\": \"1.0\",\n \"errorMessage\": \"\",\n \"errorCode\": \"\",\n \"txn\": \"d52902b6901e4d139ca78fb516dbdb34\",\n \"ts\": \"2024-04-02T16:26:11+05:30\",\n \"status\": \"1\"\n }\n}" + } + ] + }, + { + "name": "Legal Entity", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "if (status === \"1\") {", + " pm.test(\"Request Successful\", () => {", + " pm.expect(status).to.equal(\"1\");", + " if (res.orderDetails && res.orderDetails.orderNumber) {", + " pm.collectionVariables.set(\"orderNumber\", res.orderDetails.orderNumber);", + " }", + " if (res.orderDetails && res.orderDetails.requestNumber) {", + " pm.collectionVariables.set(\"requestNumber\", res.orderDetails.requestNumber);", + " }", + " });", + "} else {", + " pm.test(\"Request FAILED: \" + (errMsg || \"Unknown error\"), () => {", + " pm.expect(status).to.equal(\"1\");", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"productCode\": \"{{DocSigner_LegalEntity_1Year}}\",\r\n \"accountingModel\": \"1\",\r\n \"saveAndHold\": \"0\",\r\n \"emailNotifications\": \"1\",\r\n \"requestorInformation\": {\r\n \"requestorName\": \"{{requestorName}}\",\r\n \"requestorIsdCode\": \"1\",\r\n \"requestorMobileNumber\": \"{{requestorMobileNumber}}\",\r\n \"requestorEmail\": \"{{requestorEmail}}\",\r\n \"requestorDesignation\": \"{{requestorDesignation}}\"\r\n },\r\n \"delegationInformation\": {\r\n \"contactName\": \"\",\r\n \"email\": \"\"\r\n },\r\n \"organizationDetails\": {\r\n \"preVetting\": \"\",\r\n \"organizationNumber\": \"\",\r\n \"prevettingToken\": \"\",\r\n \"representativeNumber\": \"\"\r\n },\r\n \"certificateInformation\": {\r\n \"organizationName\": \"1712 South East Bay Blvd\",\r\n \"registeredId\": \"56753878\",\r\n \"organizationUnit\": \"Technology\",\r\n \"email\": \"{{requestorEmail}}\",\r\n \"streetAddress1\": \"1712 South East Bay Blvd\",\r\n \"streetAddress2\": \" Suite 360\",\r\n \"countryCode\": \"US\",\r\n \"state\": \"Utah\",\r\n \"locality\": \"Provo\",\r\n \"postalCode\": \"84606 \"\r\n },\r\n \"numberOfDocuments\": \"\",\r\n \"documentsAttached\": [\r\n {\r\n \"id\": \"\",\r\n \"fileName\": \"\",\r\n \"description\": \"\",\r\n \"base64Value\": \"\"\r\n }\r\n ],\r\n \"additionalInformation\": {\r\n \"remarks\": \"Test ,User\",\r\n \"Recipient Email IDs\": \"\",\r\n \"tags\": [],\r\n \"customFields\": []\r\n }\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}GenerateOrderSignature", + "host": [ + "{{baseURL}}GenerateOrderSignature" + ] + }, + "description": "This API facilitates to generate new order for Document Signer Corporate (Legal Entity) certificates.\n\n| **Region** | **URL** |\n| --- | --- |\n| US | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n| Global | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n\n**API Endpoint**\n\n| Header | Header |\n| --- | --- |\n| URL | http(s)://(url host port and path)/GenerateOrderSignature |\n| Protocol | HTTP/ HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n \"meta\":{\n \"ver\":\"\",\n \"ts\":\"\",\n \"txn\":\"\",\n \"accountNumber\":\"\",\n \"authKey\":\"\"\n },\n \"orderDetails\":{\n \"productCode\":\"\",\n \"accountingModel\":\"\",\n \"saveAndHold\":\"\",\n \"requestNumber\":\"\",\n \"emailNotifications\":\"\",\n \"groupNumber\":\"\",\n \"requestorInformation\":{\n \"requestorName\":\"\",\n \"requestorIsdCode\":\"\",\n \"requestorMobileNumber\":\"\",\n \"requestorEmail\":\"\",\n \"requestorDesignation\":\"\"\n },\n \"delegationInformation\":{\n \"contactName\":\"\",\n \"email\":\"\"\n },\n \"organizationDetails\":{\n \"preVetting\":\"\",\n \"organizationNumber\":\"\",\n \"prevettingToken\": \"\",\n \"representativeNumber\":\"\"\n },\n \"certificateInformation\":{\n \"organizationName\":\"\",\n \"registeredId\":\"\",\n \"organizationUnit\":\"\",\n \"email\":\"\",\n \"streetAddress1\":\"\",\n \"streetAddress2\":\"\",\n \"locality\":\"\",\n \"state\":\"\",\n \"countryCode\":\"\",\n \"postalCode\":\"\"\n }\n },\n \"numberOfDocuments\":\"\",\n \"documentsAttached\":[\n {\n \"id\":\"\",\n \"fileName\":\"\",\n \"description\":\"\",\n \"base64Value\":\"\"\n },\n {\n \"id\":\"\",\n \"fileName\":\"\",\n \"description\":\"\",\n \"base64Value\":\"\"\n }\n ],\n \"additionalInformation\":{\n \"remarks\":\"\",\n \"recipientEmail\": \"\",\n \"tags\":[\n \"(Tag Name:Tag Value)\",\n \"(Tag Name:Tag Value)\"\n ],\n \"customFields\":[\n {\n \"fieldID\":\"\",\n \"fieldValue\":\"\"\n },\n {\n \"fieldID\":\"\",\n \"fieldValue\":\"\"\n }\n ]\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order.
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | (mandatory)
Version should be set to \"1.0\". |\n| ts | (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| productCode | (mandatory)
Unique Product Code of the respective product. Product Code values are provided by eMudhra. |\n| accountingModel | (optional)
Accounting Model of the Account.
The allowed value for this field is: 1 - Cash Model. The default value is '1' (in case if it is not set). |\n| saveAndHold | (mandatory)
Should be set to any of the numeric values.
1 - Only Request ID will be generated and request will be pending for payment.
0 - Both Request ID & Order ID will be generated. |\n| requestNumber | (conditional mandatory)
Request Number of the existing request which was saved as a draft. This field is 'mandatory' only for the requests (Request ID is available & Order ID is yet to generate). |\n| emailNotifications | (optional)
Can contain one of the numeric values as under. Default is '1'.
0 - Subscriber Agreement, Email Verification link & Certificate Download PIN email notification will be sent to the applicant on order initiation.
1 - All Order Notifications will be sent to the applicant on order initiation. |\n| groupNumber | (optional)
Group Number of the group. This is auto generated by the system. Groups can be your cost centers, departments, etc. within your Business. It acts as a feature / tool for managing account user access to organizations, domains and finances. If group number is not given in the request, then system will consider the default group number and amount will be deducted from the account balance. If group is having its own balance, then amount will be deducted from the group balance else the amount will be deducted from the account balance. |\n| requestorInformation | (mandatory)
Specified below. |\n| delegationInformation | (optional)
Specified below. |\n| organizationDetails | (optional)
Specified below. |\n| certificateInformation | (mandatory)
Specified below. |\n| numberOfDocuments | (optional)
Number of Documents attached. |\n| documentsAttached | (optional)
Uploading documents during order creation is recommended for sending us any additional / supporting documents so that they are automatically associated with your certificate order. This additional documentation would help emSign to speed up the certificate validation process. (E.g., incorporation letter, registration document, etc.). Specified below. |\n| additionalInformation | (optional)
Specified below. |\n\n**Requestor Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestorName | (mandatory)
Name of the requestor. |\n| requestorIsdCode | (mandatory)
ISD Code of the requestor. |\n| requestorMobileNumber | (mandatory)
Mobile number of the requestor. |\n| requestorEmail | (mandatory)
Email of the requestor. |\n| requestorDesignation | (optional)
Designation of the requestor. |\n\n**Delegation Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| contactName | (mandatory)
Contact Name of the delegated person. |\n| email | (mandatory)
Email ID of the delegated person. |\n\n**Organization Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| preVetting | (optional)
This can be set to any of the numeric values as under. Default is '0'.
0 - No (New Organization)
1 - Yes (Existing Organization) |\n| organizationNumber | (conditional mandatory)
Organization Number (Org.ID) of the existing organization associated to the respective emSign account.This is mandatory, if \"preVetting\" value is set to '1'. |\n| prevettingToken | (optional)
Please specify the unique pre-vetted token value associated to the respective organization. Pre-vetting Organization re-use consent email notification will not be sent on order initiation, if the organization re-use token is submitted with your certificate order.
To get the prevetting token, please contact your Account administrator.
See [Organization Details API](https://dev.emsign.com/organization-detail) |\n| representativeNumber | (optional)
Organization Representative Number of the existing Organization representative. It will consider default representative details of the respective organization (in case if it is not set). |\n\n**Certificate Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| organizationName | (mandatory)
Organization Name of Organization. This field is not required, if \"preVetting\" value is set to '1'. |\n| registeredId | (mandatory)
Registered ID / Registration Number of an Organization. |\n| organizationUnit | (mandatory)
Organization Unit of the respective Organization. |\n| email | (mandatory)
Email ID of an organization. |\n| streetAddress1 | (mandatory)
Street Address 1 of an organization. This field is not required, if \"preVetting\" value is set to '1'. |\n| streetAddress2 | (optional)
Street Address 2 of an organization.This field is not required, if \"preVetting\" value is set to '1'. |\n| locality | (mandatory)
Locality of an organization.This field is not required, if \"preVetting\" value is set to '1'. |\n| state | (mandatory)
State Name of an organization. This field is not required, if \"preVetting\" value is set to '1'. |\n| countryCode | (mandatory)
Country Code of the Country. Please refer the shared list below for your reference. |\n| postalCode | (mandatory)
Postal Code of an individual. |\n\n**Document Attached**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| id | (mandatory)
ID of the document. |\n| fileName | (mandatory)
File Name of the document. |\n| description | (mandatory)
Description of the document. |\n| base64Value | (mandatory)
Base 64 encoded value of the document. |\n\n**Additional Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| remarks | (optional)
Additional Information related to the order. |\n| recipientEmail | (optional)
Email recipients can be provided for receiving notifications related to Order Confirmation, Revocation and Renewal of certificates. |\n| tags | (optional)
Reporting tags are labels with different values, which can be associated with your certificate orders in CERTInext. These tags can then be used to filter reports, giving you a categorized insight into your business.Tag Name and Tag Value must be colon separated values. e.g.: Department:Technology. Multiple Tag Names and Tag Values can be associated with the order. e.g.: Department:Technology, Department:Legal, Branch Location:India. Specified below. |\n| customFields | (conditional mandatory)
CERTInext allows you to use custom fields in your certificate order form. The Custom Field can be used to simplify record-keeping and makes order management efficient. To enable Custom Fields feature for your CERTInext account, please contact your Account Manager. Specified below. |\n\n**Tags**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| Tag Name | (mandatory)
Reporting Tag Name. |\n| Tag Value | (mandatory)
Reporting Tag Value. |\n\n**Custom Fields**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| fieldID | (conditional mandatory)
Specify the Custom Field ID. This is a unique field identifier of a custom field. This is mandatory, if Custom Fields are mandated by your account administrator. This information will be available within CERTInext online portal. |\n| fieldValue | (conditional mandatory)
Specify the Custom Field value(s). You have to provide the right input based on the custom field type set by your account administrator.NOTE: The allowed date format for date picker based Custom Field is: YYYY-MM-DD. This is mandatory, if Custom Fields are mandated by your account administrator. The detailed Custom Field information will be available within CERTInext online portal. |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n },\n \"orderDetails\": {\n \"requestNumber\": \"\",\n \"orderNumber\": \"\",\n \"trackingUrl\": \"\"\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | (mandatory)
Version should be set to \"1.0\". |\n| ts | (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestNumber | (conditional mandatory)
Unique Request ID of the respective request. This is 'Mandatory', if 'saveAndHold' is set to '1'. |\n| orderNumber | (conditional mandatory)
Unique Order ID of the respective order.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |\n| trackingUrl | (mandatory)
Order status tracking URL of the respective order.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |" + }, + "response": [ + { + "name": "Legal Entity", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\":\"1.0\",\r\n \"ts\":\"{{ts}}\",\r\n \"txn\":\"{{txn}}\",\r\n \"accountNumber\":\"4397827229\",\r\n \"authKey\":\"{{hash}}\" \r\n },\r\n\"orderDetails\": {\r\n \"productCode\":\"825\",\r\n \"accountingModel\":\"1\",\r\n \"saveAndHold\":\"0\",\r\n \"emailNotifications\":\"1\",\r\n //\"submitAgreementByEmail\": \"1\",\r\n \"requestorInformation\": {\r\n \"requestorName\": \"Viña del Mar Viña Green\",\r\n \"requestorIsdCode\": \"1\",\r\n \"requestorMobileNumber\": \"7094940185\",\r\n \"requestorEmail\": \"john.green@example.com\",\r\n \"requestorDesignation\": \"Manager\"\r\n },\r\n \"subscriptionDetails\": { // Applicable only for SSL DV / OV\r\n \"validity\": \"0\", //1/2/3 (Default Value: 1)\r\n \"autoRenew\": \"\", //1/0 (Default-1)\r\n \"renewCriteria\": \"\" //30/60 (Default 30)\r\n },\r\n \"delegationInformation\": {\r\n \"contactName\": \"Jenne Flex\",\r\n \"email\": \"jenne.flex@example.com\"\r\n },\r\n \"organizationDetails\": {\r\n \"preVetting\": \"0\",\r\n \"organizationNumber\": \"8447416\",\r\n \"prevettingToken\": \"8D93A1D55160401EB991DB99530A698F\",\r\n \"representativeNumber\": \"3226512684\"\r\n },\r\n \"certificateInformation\": {\r\n \"organizationName\": \"eMudhra Inc.\",\r\n \"registeredId\": \"56753878\",\r\n \"organizationUnit\": \"Technology\",\r\n \"email\": \"john.green@example.com\",\r\n \"streetAddress1\": \"1712 South East Bay Blvd\",\r\n \"streetAddress2\": \" Suite 360\",\r\n \"countryCode\": \"US\", \r\n \"designation\":\"Manager\",\r\n \"state\": \"Utah\", \r\n \"locality\": \"Provo\",\r\n \"postalCode\": \"84606 \"\r\n },\r\n \"numberOfDocuments\":\"\",\r\n \"documentsAttached\":[\r\n {\r\n \"id\":\"\",\r\n \"fileName\":\"\",\r\n \"description\":\"\",\r\n \"base64Value\":\"\"\r\n }\r\n\t\t \r\n ],\r\n \"additionalInformation\": {\r\n \"remarks\": \"\",\r\n \"customFields\": [\r\n {\r\n \"fieldID\": \"145\",\r\n \"fieldValue\": \"wxyz@emudhra.com\"\r\n },\r\n {\r\n \"fieldID\": \"764\",\r\n \"fieldValue\": \"Male\"\r\n },\r\n {\r\n \"fieldID\": \"519\",\r\n \"fieldValue\": \"1996-29-09\"\r\n },\r\n {\r\n \"fieldID\": \"616\",\r\n \"fieldValue\": \"HCL\"\r\n },\r\n {\r\n \"fieldID\": \"861\",\r\n \"fieldValue\": \"56785\"\r\n },\r\n {\r\n \"fieldID\": \"827\",\r\n \"fieldValue\": \"AXTPN9856D\"\r\n }\r\n ]\r\n }\r\n}\r\n}\r\n\r\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/GenerateOrderSignature", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "GenerateOrderSignature" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "353" + }, + { + "key": "Date", + "value": "Tue, 02 Apr 2024 10:56:26 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"orderDetails\": {\n \"requestNumber\": \"8119791967\",\n \"orderNumber\": \"3778916934\",\n \"trackingURL\": \"https://sandbox-emsignsubscriber.emudhra.net/PublicLink/publicLinkVerification.jsp?a=MXNUTFVHbHM0ZEdMT3IwZnE0MUt5UT09\"\n },\n \"meta\": {\n \"ver\": \"1.0\",\n \"errorMessage\": \"\",\n \"errorCode\": \"\",\n \"txn\": \"971eed85fa154a8c8503c045563fa87b\",\n \"ts\": \"2024-04-02T16:26:25+05:30\",\n \"status\": \"1\"\n }\n}" + } + ] + } + ], + "description": "This section includes following APIs.\n\n- Document Signer Personal (Natural Person)\n \n- Document Signer Professional (Legal Person) \n \n- Document Signer Corporate (Legal Entity)" + }, + { + "name": "Generate Private PKI", + "item": [ + { + "name": "emSign Intranet SSL", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "if (status === \"1\") {", + " pm.test(\"Request Successful\", () => {", + " pm.expect(status).to.equal(\"1\");", + " if (res.orderDetails && res.orderDetails.orderNumber) {", + " pm.collectionVariables.set(\"orderNumber\", res.orderDetails.orderNumber);", + " }", + " if (res.orderDetails && res.orderDetails.requestNumber) {", + " pm.collectionVariables.set(\"requestNumber\", res.orderDetails.requestNumber);", + " }", + " });", + "} else {", + " pm.test(\"Request FAILED: \" + (errMsg || \"Unknown error\"), () => {", + " pm.expect(status).to.equal(\"1\");", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"productCode\": \"{{PrivatePKI_IntranetSSL}}\",\r\n \"requestorInformation\": {\r\n \"requestorName\": \"{{requestorName}}\",\r\n \"requestorIsdCode\": \"91\",\r\n \"requestorMobileNumber\": \"{{requestorMobileNumber}}\",\r\n \"requestorEmail\": \"{{requestorEmail}}\",\r\n \"requestorDesignation\": \"{{requestorDesignation}}\"\r\n },\r\n \"certificateInformation\": {\r\n \"domainName\": \"{{domainName}}\",\r\n \"organizationName\": \"eMudhra Limited\",\r\n \"organizationUnit\": \"CA\",\r\n \"state\": \"Karnataka\",\r\n \"countryCode\": \"IN\",\r\n \"dnsType\": \"1\",\r\n \"additionalDomains\": [\r\n \"staging.emudhra.net\"\r\n ]\r\n },\r\n \"additionalInformation\": {\r\n \"remarks\": \"Test Private PKI API\",\r\n \"tags\": []\r\n },\r\n \"csr\": \"-----BEGIN CERTIFICATE REQUEST----- MIICjjCCAXYCAQAwFTETMBEGA1UEAxMKZW1zaWduLmNvbTCCASIwDQYJKoZIhvcN AQEBBQADggEPADCCAQoCggEBAKaITvqB6veCwJW4/O65wFeEgKM0J+YkSj+cBNHe sEnMhAS4lc1dAbM1+gZDwoQbC7bm+1cB+FEWd33GBLkoQ2Niqbl/LBRGKbT9h1kZ Hoe+GWAgE85ReHaVti4ERa7fsKnNcS3v72cM/iChSGzIIfk+wIK2JMvA4DOfHfR4 4browQ1xCNJYWM/fws947sj8VAuCRAPhAEfoINQ4LuZxI2W3TzXxHkYj0nFcFkts T4j0kzRtv/iW7I3ntIAFXZ4cyI0EhYtGVL5MefuzdqKr/ffMO0kJCbxQyTZSxYtd GPud8DB2hAbb/kTVJAYlOKz1llO/bfzqytHjz2adZN8Q8OsCAwEAAaA0MDIGCSqG SIb3DQEJDjElMCMwDgYDVR0PAQH/BAQDAgWgMBEGCWCGSAGG+EIBAQQEAwIGQDAN BgkqhkiG9w0BAQsFAAOCAQEAYjPBveJ5d1xeXPOIwQlFcdgdoAiGAs+ng9k6hd0i Dxj12/uX9952GXPYc9Osm1UU/nd1byaEskDFdxW759jXPEsAfmVUt/yPFf3hkHUu wfpHzKZebXDKP0yGFzTseohCLAJn6+RJDznWycEi4ES5o/LDlJ/0S4owPUeVfvYT NMRc0Fe3oT1XQU6ljACwBKT5eHfEPK9jseROo30OvbcVLgg3XACZcdctowin3N31 oC9VxvalkSRNTKfx8hrejE42pboWi/XZLNyy3r/XRVVt6Lbr+B54vbfMg/Marepg dDyl28MzqJRon1JU5bjFTryZiYNYtzH6EX2NcWnWI9rPHA== -----END CERTIFICATE REQUEST-----\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}GenerateOrderPrivatePKI", + "host": [ + "{{baseURL}}GenerateOrderPrivatePKI" + ] + }, + "description": "This API facilitates to generate Private PKI Orders. \n[See Private PKI Product Creation ](https://docs.emsign.com/emsign-certhub/account-management/create-private-product)  | [See Intranet SSL Order API](https://documenter.getpostman.com/view/32880058/2sA35LUJq5#91fd7bfd-ef47-4360-877d-d3a01f81e576)\n\n**Prerequisites**\n\neMudhra provides Generate Private PKI Order Base URL Endpoint values for generating Private PKI Orders. It is highly recommended to keep these values configurable, as they change for sandbox environment and Live environment.\n\n| **Region** | **URL** |\n| --- | --- |\n| US | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n| Global | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n\n**API Endpoint**\n\n| Header | Header |\n| --- | --- |\n| URL | http(s)://(url host port and path)/GenerateOrderPrivatePKI |\n| Protocol | HTTP/ HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n \"meta\":{\n \"ver\":\"\",\n \"ts\":\"\",\n \"txn\":\"\",\n \"accountNumber\":\"\",\n \"authKey\":\"\"\n },\n \"orderDetails\":{\n \"productCode\":\"\",\n \"accountingModel\":\"\",\n \"emailNotifications\":\"\",\n \"groupNumber \": \"\",\n \"saveAndHold\":\"\",\n \"requestNumber\":\"\",\n \"requestorInformation\":{\n \"requestorName\":\"\",\n \"requestorIsdCode\":\"\",\n \"requestorMobileNumber\":\"\",\n \"requestorEmail\":\"\",\n \"requestorDesignation\":\"\"\n },\n \"certificateInformation\":{\n \"firstName\":\"\",\n \"lastName\":\"\",\n \"identityDocumentType\":\"\",\n \"identificationNumber\":\"\",\n \"organizationName\":\"\",\n \"organizationIdentificationNumber\":\"\",\n \"organizationUnit\":\"\",\n \"registeredId\":\"\",\n \"businessCategory\":\"\",\n \"companyDuns\":\"\",\n \"email\":\"\",\n \"isdCode\":\"\",\n \"mobileNumber\":\"\",\n \"streetAddress1\":\"\",\n \"streetAddress2\":\"\",\n \"locality\":\"\",\n \"state\":\"\",\n \"countryCode\":\"\",\n \"postalCode\":\"\",\n \"dnsType\":\"\",\n \"domainName\":\"\",\n \"additionalDomains\":[\n \"\",\n \"\"\n ]\n },\n \"csr\":\"\",\n \"additionalInformation\":{\n \"tags\":[\n \"(Tag Name:Tag Value)\",\n \"(Tag Name:Tag Value)\"\n ],\n \"remarks\":\"\"\n }\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order.
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | (mandatory)
Version should be set to \"1.0\". |\n| ts | (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| productCode | (mandatory)
Unique Product Code of the respective product. Product Code values are provided by eMudhra. |\n| accountingModel | (optional)
Accounting Model of the Account.
The allowed value for this field is: 1 - Cash Model. The default value is '1' (in case if it is not set). |\n| emailNotifications | (optional)
Can contain one of the numeric values as under. Default is '1'.
0 - Pre-vetting Organization (Organization & Subscriber Agreement info.) re-use consent notification will be sent to the applicant on order initiation, if \"preVetting\" value is set to '1'.
1 - All Order Notifications will be sent to the applicant on order initiation. |\n| groupNumber | (optional)
Group Number of the group. This is auto generated by the system. Groups can be your cost centers, departments, etc. within your Business.
It acts as a feature / tool for managing account user access to organizations, domains and finances. If group number is not given in the request, then system will consider the default group number and amount will be deducted from the account balance. If group is having its own balance, then amount will be deducted from the group balance else the amount will be deducted from the account balance. |\n| saveAndHold | (optional)
Should be set to any of the numeric values. Default value is '0'.
1 - Only Request ID will be generated and request will be pending for payment.
0 - Both Request ID & Order ID will be generated. |\n| requestNumber | (optional)
Request Number of the certificate order. |\n| requestorInformation | (mandatory)
Specified below. |\n| certificateInformation | (mandatory)
Specified below. |\n| csr | (mandatory)
CSR means Certificate Signing Request.
[Procedure to generate CSR](https://docs.emsign.com/emsign-certhub/certificate-utility-tools/emudhra-certificate-utility-tool) |\n| additionalInformation | (optional)
Specified below. |\n\n**Requestor Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestorName | (mandatory)
Name of the requestor / Org. Representative. |\n| requestorIsdCode | (mandatory)
ISD Code of the requestor's / Org. Representative's mobile number. |\n| requestorMobileNumber | (mandatory)
Mobile number of the requestor / Org. Representative. |\n| requestorEmail | (mandatory)
Email of the requestor / Org. Representative. |\n| requestorDesignation | (optional)
Designation of the requestor / Org. Representative. |\n\n**Certificate Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| firstName | (conditional mandatory)
First Name of an individual. |\n| lastName | (conditional mandatory)
Last Name of an individual. |\n| identityDocumentType | (optional)
Description of the Identity Document Type. |\n| identificationNumber | (optional)
Identification Number of an individual. |\n| organizationName | (conditional mandatory)
Organization Unit of the respective Organization. |\n| organizationIdentificationNumber | (optional)
Organization Identification Number. |\n| organizationUnit | Optional |\n| registeredId | (conditional mandatory)
Registered ID of the respective Organization. |\n| businessCategory | (conditional mandatory)
Business Category of an Organization.
The allowed values for this field are:
1 - Private Organization
2 - Government Entity |\n| companyDuns | (conditional mandatory)
Company Registration Number or Company DUNS Number of an Organization. |\n| 1 email | (conditional mandatory)
Email ID of an individual. |\n| isdCode | (conditional mandatory)
ISD Code of mobile number of an Individual. |\n| mobileNumber | (conditional mandatory)
Mobile number of an Individual. |\n| streetAddress1 | (conditional mandatory)
Street Address 1 of an organization. |\n| streetAddress2 | (optional)
Street Address 2 of an organization. |\n| locality | (conditional mandatory)
Locality of an organization. |\n| | state |\n| countryCode | (conditional mandatory)
Country Code of the Country. |\n| postalCode | (conditional mandatory)
Postal Code of an organization. |\n| dnsType | (optional)
Default value is '1'.
1: Internal Domain Name
2: External Domain Name |\n| domainName | (mandatory)
Domain Name of the website / server. |\n| additionalDomainNames | (mandatory)
Additional domain names of the website / server. |\n\n**Additional Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| tags | (optional)
Reporting tags are labels with different values, which can be associated with your certificate orders in CERTInext. These tags can then be used to filter reports, giving you a categorized insight into your business.
Tag Name and Tag Value must be colon separated values.
e.g.: Department:Technology
Multiple tag names and tag values can be associated with the order.
e.g.: Department:Technology,
Department:Legal,
Branch Location:India
Specified below. |\n| remarks | (optional)
Additional Information related to the order. |\n\n**Tags**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| Tag Name | (mandatory)
Reporting Tag Name. |\n| Tag Value | (mandatory)
Reporting Tag Value. |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n },\n \"orderDetails\": {\n \"orderNumber\": \"\",\n \"requestNumber\": \"\",\n \"trackingURL\": \"\",\n \"certificateDetails\": {\n \"rootCertificate\": \"\",\n \"caCertificate\": \"\",\n \"subCACertificate\": \"\",\n \"endEntityCertificate\": \"\",\n \"certificateSerialNumber\": \"\", \n \"expiryDate\": \"\"\n }\n },\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order.
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | (mandatory)
Version should be set to \"1.0\". |\n| ts | (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestNumber | (conditional mandatory)
Unique Request ID of the respective request. |\n| orderNumber | (conditional mandatory)
Unique Order ID of the respective order.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |\n| trackingUrl | (mandatory)
Order status tracking URL of the respective order. If saveAndHold is set to '1', will not be displayed in the response.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |\n| certificateDetails | (mandatory)
If dnsType is selected as '2' and Domain is yet to be verified, certificateDetails will not be displayed in the response. Specified below. |\n\n**Certificate Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| rootCertificate | (mandatory)
Root CA Certificate of emSign. |\n| caCertificate | (mandatory)
Intermediate / Issuer CA Certificate. |\n| subCACertificate | (optional)
Subordinate CA Certificate. |\n| endEntityCertificate | (mandatory)
End Entity Certificate. |\n| certificateSerialNumber | (mandatory)
Serial Number of the Certificate. |\n| expiryDate | (mandatory)
Expiry Date of the respective End Entity Certificate in UTC format. |\n\n" + }, + "response": [ + { + "name": "GenerateOrderPrivatePKI", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"4397827229\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n // \"autoSecureWWW\": \"0\",\r\n \"productCode\": \"188\",\r\n // \"emailNotifications\": \"\",\r\n //\"groupNumber\": \"\",\r\n //\"accountingModel\": \"\",\r\n // \"saveAndHold\": \"\", // 1-Generate RequestNumber | 0-Generate Order Number\r\n // \"requestNumber\": \"\",\r\n \"requestorInformation\": {\r\n \"requestorName\": \"Abcd\",\r\n \"requestorIsdCode\": \"+91\",\r\n \"requestorMobileNumber\": \"7094940185\",\r\n \"requestorEmail\": \"abcd@emudhra.com\",\r\n \"requestorDesignation\": \"test\" \r\n }, \r\n \"subscriptionDetails\": { // Applicable only for SSL DV / OV\r\n \"validity\": \"0\", //1/2/3 (Default Value: 1)\r\n \"autoRenew\": \"\", //1/0 (Default-1)\r\n \"renewCriteria\": \"\" //30/60 (Default 30)\r\n },\r\n // \"agreementDetails\": {\r\n // \"acceptAgreement\": \"1\", // 1-Accept | 0-Reject\r\n // \"signerIP\": \"\",\r\n // \"signerPlace\": \"\",\r\n // \"signerName\": \"\"\r\n // },\r\n // \"organizationDetails\": {\r\n // \"preVetting\": \"1\", //0-New(No) | 1-Existing (Yes)\r\n // \"organizationNumber\": \"12344\", //Mandatory in case of 1\r\n // \"prevettingToken\": \"12344\", \r\n // \"representativeNumber\": \"12344\" //If Empty, requestorInfo will be considered as OrgRep | Initially, only one Representative. So if Pre-vetting 1 requestor info will not be considered\r\n // },\r\n \"certificateInformation\": {\r\n // \"firstName\": \"Nandhu\",\r\n //\"lastName\": \"Nandhu\",\r\n \"countryCode\": \"IN\", //Country Code\r\n \"state\": \"Tamil Nadu\", //String value\r\n // \"locality\": \"Mysore\",\r\n // \"email\": \"wxyz@emudhra.com\",\r\n \"organizationName\": \"emudhra\",\r\n \"organizationUnit\": \"test\",\r\n // \"postalCode\": \"570004\",\r\n // \"streetAddress1\": \"test\",\r\n // \"streetAddress2\": \"test2\",\r\n // \"isdCode\": \"103\",\r\n // \"mobileNumber\": \"8050903086\",\r\n // \"registeredId\": \"12345\",\r\n // \"identificationNumber\": \"A0RPYX29234\",\r\n // \"identityDocumentType\": \"Passport\",\r\n // \"organizationIdentificationNumber\": \"ASDDASRPYX29234\",\r\n // \"companyDuns\": \"123344456\",\r\n // \"businessCategory\": \"1\", //1/2/3\r\n \"domainName\": \"empower.com\",\r\n \"additionalDomains\": [\r\n \"staging.emudhra.net\"\r\n ]\r\n },\r\n // \"delegationInformation\": {\r\n // \"contactName\": \"Abcd\",\r\n // \"email\": \"abcd@emudhra.com\"\r\n // },\r\n // \"authorizedSignatoryInfo\": {\r\n // \"arName\": \"Abcd\",\r\n // \"arEmail\": \"abcd@emudhra.com\",\r\n // \"arIsdCode\": \"103\",\r\n // \"arMobileNumber\": \"8050903086\",\r\n // \"arDesignation\": \"Dev\"\r\n // },\r\n // \"technicalPointOfContact\": {\r\n // \"pocName\": \"Wxyz\",\r\n // \"pocEmail\": \"wxyz@emudhra.com\",\r\n // \"pocIsdCode\": \"103\",\r\n // \"pocMobileNumber\": \"8050903086\",\r\n // \"pocDesignation\": \"Dev\"\r\n // }\r\n \"additionalInformation\": {\r\n \"remarks\": \"\",\r\n \"tags\": [\r\n \r\n ] \r\n // \"customFields\": [\r\n // {\r\n // \"fieldID\": \"\",\r\n // \"fieldValue\": \"\"\r\n // },\r\n // {\r\n // \"fieldID\": \"\",\r\n // \"fieldValue\": \"\"\r\n // }\r\n // ]\r\n }, \r\n \"csr\": \"-----BEGIN CERTIFICATE REQUEST----- MIICmjCCAYICAQAwVTELMAkGA1UEBhMCSU4xCTAHBgNVBAgTADEJMAcGA1UEBxMA MQkwBwYDVQQKEwAxDzANBgkqhkiG9w0BCQETADEUMBIGA1UEAxMLZW11ZGhyYS5j b20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDgHHshLzP3CXFqxrTK 58PfWzHfRJJDvgZrmVjj1gqxX0RWgC+Scm66ZN/cTf82Y1AoLaCwSBoWtqV0Brlc KpJN6qQeawVB9t438X2gFGwzCYHOM1GXaslW3b1TK5oXJ4QkX0j32kUst4cYqHd6 Gs3WEqOBi4pBdRqS/0zEspq400pL6cUin2QNGCed46S8IdmCTV9mUsROK17PEkgr ufT+i5AixLoSC09zoQpIrewF5B7CSANAOW1XNKNQYA64vsNNHmOJHlwEKBd/zmMY te7wsgS3S+BRdJgfniAcntnIEW5aFLHgFn1n1YE5DACkEZ7qy3YR68kDdproTscx 2SlfAgMBAAGgADANBgkqhkiG9w0BAQsFAAOCAQEAHnLo3GPyIgH/yTm8afbpoN1X MzbmpGygbAbQblT/FuFUilevonI68J9DTrgclVDfp3i16EL2VZFFrawzVdof0g/Z xzoolJPL4Y+Whz0M7NuEgcB3gYimZ7QV/db00Vg6C+jLJ5TT01KTDlW2H+la9f23 bEroOY+kXKF6AN8omSVReQXOAGobJw53KIzWxWfn78DgHdtTfenvdpxkYw6aQptv +wklMdLoRkk+zw5g1c9ZzZaIOsONmDuBevEb/vjcG5ucIHlxb8f6k3rzsoP2W70T Q6FDuIDxBBP6qvdUaGLmqtys+QSBP0YKBtSLn+M5W4n+wi+1ZPwf/XkhO83MXQ== -----END CERTIFICATE REQUEST-----\"\r\n // \"numberOfDocuments\": \"\",\r\n // \"documentsAttached\": [\r\n // {\r\n // \"id\": \"\",\r\n // \"fileName\": \"\",\r\n // \"description\": \"\",\r\n // \"base64Value\": \"\"\r\n // },\r\n // {\r\n // \"id\": \"\",\r\n // \"fileName\": \"\",\r\n // \"description\": \"\",\r\n // \"base64Value\": \"\"\r\n // }\r\n // ]\r\n }\r\n }\r\n\r\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/GenerateOrderPrivatePKI", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "GenerateOrderPrivatePKI" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "171" + }, + { + "key": "Date", + "value": "Tue, 02 Apr 2024 10:56:44 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"meta\": {\n \"ver\": \"1.0\",\n \"ts\": \"2024-04-02T16:26:42+05:30\",\n \"txn\": \"c519a2c63fe14a95b0f4a377391cb898\",\n \"status\": \"0\",\n \"errorCode\": \"EMS-915\",\n \"errorMessage\": \"Invalid Certificate ID\"\n }\n}" + } + ] + }, + { + "name": "IGTF Host Certificate", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "if (status === \"1\") {", + " pm.test(\"Request Successful\", () => {", + " pm.expect(status).to.equal(\"1\");", + " if (res.orderDetails && res.orderDetails.orderNumber) {", + " pm.collectionVariables.set(\"orderNumber\", res.orderDetails.orderNumber);", + " }", + " if (res.orderDetails && res.orderDetails.requestNumber) {", + " pm.collectionVariables.set(\"requestNumber\", res.orderDetails.requestNumber);", + " }", + " });", + "} else {", + " pm.test(\"Request FAILED: \" + (errMsg || \"Unknown error\"), () => {", + " pm.expect(status).to.equal(\"1\");", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"productCode\": \"{{PrivatePKI_IGTF}}\",\r\n \"requestorInformation\": {\r\n \"requestorName\": \"{{requestorName}}\",\r\n \"requestorIsdCode\": \"91\",\r\n \"requestorMobileNumber\": \"{{requestorMobileNumber}}\",\r\n \"requestorEmail\": \"{{requestorEmail}}\",\r\n \"requestorDesignation\": \"{{requestorDesignation}}\"\r\n },\r\n \"certificateInformation\": {\r\n \"domainName\": \"{{domainName}}\",\r\n \"organizationName\": \"eMudhra Limited\",\r\n \"state\": \"Karnataka\",\r\n \"countryCode\": \"IN\",\r\n \"dnsType\": \"1\"\r\n },\r\n \"additionalInformation\": {\r\n \"remarks\": \"Test Private PKI API\",\r\n \"tags\": []\r\n },\r\n \"csr\": \"-----BEGIN CERTIFICATE REQUEST----- MIIClzCCAX8CAQAwHjEcMBoGA1UEAxMTc3RhZ2luZy5lbXVkaHJhLm5ldDCCASIw DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANe34K110Exm6rZMPxI82BZ9SYTi 8YHdkdJw41mXrJvGmJqUaUklu9zWURF/1SufwAC5wumH8j2/T334FRb/v4TzZngX Xo4F+gY19wYYzn/Bns5nkC/Ch5BdObSsm9G9wFffCV2rgeCIr/XameesYo51Ht3u SutTrsq9skD12ruBp1LRACPHYa+Up3yQCPiycj6ulJYfImMwt9zsA3tOVfv/u/2I rQyBbkB3C2BPkgsQyAjR2SKHwLwnSSqkQxgjRwco54p06TyRFoF0toJGnK0+lsz3 7QY047iJ64HzNBhRsXnelMWItJItPlAbs6dq1jgZPu4T8HRmKWoI2M9CPTcCAwEA AaA0MDIGCSqGSIb3DQEJDjElMCMwDgYDVR0PAQH/BAQDAgWgMBEGCWCGSAGG+EIB AQQEAwIGQDANBgkqhkiG9w0BAQsFAAOCAQEADfNPKe5Xvj2g0Z5epgjKkYr2XEoI 1g659iLuHnotzmTYS2LXVJzcsg0m6tRKrxprtRAlGMJbNYcYVDlLotZf9tF4MMPv z416dUc7bqnbgzH8G/ub7oP5N+kdH7fYuTE9FfGby06i0Us91ojUR0s2cCQAwwtm vjyDa0HgBr+AJ3NTsR/cdeu5+F7V4vDMG21EaOz58X8lAIas5zpZEaU6jFEFLoQM RSwbauTG+Ba3bjq5OspWGRjqVcoh90uI1YjezHVeFD/j/HrdZORLbSF18Cj5vW+7 XxM3MpD0YHbOYWBzpLxvoN3RGnTG/6QU/dglaZsHfxMlTUb+di5F0hjl3A== -----END CERTIFICATE REQUEST-----\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}GenerateOrderPrivatePKI", + "host": [ + "{{baseURL}}GenerateOrderPrivatePKI" + ] + }, + "description": "\nThis API facilitates to generate Private PKI Orders. \n[See Private PKI Product Creation ](https://docs.emsign.com/emsign-certhub/account-management/create-private-product) | [See Intranet SSL Order API](https://documenter.getpostman.com/view/32880058/2sA35LUJq5#91fd7bfd-ef47-4360-877d-d3a01f81e576)\n\n**Prerequisites**\n\neMudhra provides Generate Private PKI Order Base URL Endpoint values for generating Private PKI Orders. It is highly recommended to keep these values configurable, as they change for sandbox environment and Live environment.\n\n| **Region** | **URL** |\n| --- | --- |\n| US | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n| Global | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n\n**API Endpoint**\n\n| Header | Header |\n| --- | --- |\n| URL | http(s)://(url host port and path)/GenerateOrderPrivatePKI |\n| Protocol | HTTP/ HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n \"meta\":{\n \"ver\":\"\",\n \"ts\":\"\",\n \"txn\":\"\",\n \"accountNumber\":\"\",\n \"authKey\":\"\"\n },\n \"orderDetails\":{\n \"productCode\":\"\",\n \"accountingModel\":\"\",\n \"emailNotifications\":\"\",\n \"groupNumber \": \"\",\n \"saveAndHold\":\"\",\n \"requestNumber\":\"\",\n \"requestorInformation\":{\n \"requestorName\":\"\",\n \"requestorIsdCode\":\"\",\n \"requestorMobileNumber\":\"\",\n \"requestorEmail\":\"\",\n \"requestorDesignation\":\"\"\n },\n \"certificateInformation\":{\n \"firstName\":\"\",\n \"lastName\":\"\",\n \"identityDocumentType\":\"\",\n \"identificationNumber\":\"\",\n \"organizationName\":\"\",\n \"organizationIdentificationNumber\":\"\",\n \"organizationUnit\":\"\",\n \"registeredId\":\"\",\n \"businessCategory\":\"\",\n \"companyDuns\":\"\",\n \"email\":\"\",\n \"isdCode\":\"\",\n \"mobileNumber\":\"\",\n \"streetAddress1\":\"\",\n \"streetAddress2\":\"\",\n \"locality\":\"\",\n \"state\":\"\",\n \"countryCode\":\"\",\n \"postalCode\":\"\",\n \"dnsType\":\"\",\n \"domainName\":\"\",\n \"additionalDomains\":[\n \"\",\n \"\"\n ]\n },\n \"csr\":\"\",\n \"additionalInformation\":{\n \"tags\":[\n \"(Tag Name:Tag Value)\",\n \"(Tag Name:Tag Value)\"\n ],\n \"remarks\":\"\"\n }\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order.
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | (mandatory)
Version should be set to \"1.0\". |\n| ts | (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| productCode | (mandatory)
Unique Product Code of the respective product. Product Code values are provided by eMudhra. |\n| accountingModel | (optional)
Accounting Model of the Account.
The allowed value for this field is: 1 - Cash Model. The default value is '1' (in case if it is not set). |\n| emailNotifications | (optional)
Can contain one of the numeric values as under. Default is '1'.
0 - Pre-vetting Organization (Organization & Subscriber Agreement info.) re-use consent notification will be sent to the applicant on order initiation, if \"preVetting\" value is set to '1'.
1 - All Order Notifications will be sent to the applicant on order initiation. |\n| groupNumber | (optional)
Group Number of the group. This is auto generated by the system. Groups can be your cost centers, departments, etc. within your Business.
It acts as a feature / tool for managing account user access to organizations, domains and finances. If group number is not given in the request, then system will consider the default group number and amount will be deducted from the account balance. If group is having its own balance, then amount will be deducted from the group balance else the amount will be deducted from the account balance. |\n| saveAndHold | (optional)
Should be set to any of the numeric values. Default value is '0'.
1 - Only Request ID will be generated and request will be pending for payment.
0 - Both Request ID & Order ID will be generated. |\n| requestNumber | (optional)
Request Number of the certificate order. |\n| requestorInformation | (mandatory)
Specified below. |\n| certificateInformation | (mandatory)
Specified below. |\n| csr | (mandatory)
CSR means Certificate Signing Request.
[Procedure to generate CSR](https://docs.emsign.com/emsign-certhub/certificate-utility-tools/emudhra-certificate-utility-tool) |\n| additionalInformation | (optional)
Specified below. |\n\n**Requestor Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestorName | (mandatory)
Name of the requestor / Org. Representative. |\n| requestorIsdCode | (mandatory)
ISD Code of the requestor's / Org. Representative's mobile number. |\n| requestorMobileNumber | (mandatory)
Mobile number of the requestor / Org. Representative. |\n| requestorEmail | (mandatory)
Email of the requestor / Org. Representative. |\n| requestorDesignation | (optional)
Designation of the requestor / Org. Representative. |\n\n**Certificate Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| firstName | (conditional mandatory)
First Name of an individual. |\n| lastName | (conditional mandatory)
Last Name of an individual. |\n| identityDocumentType | (optional)
Description of the Identity Document Type. |\n| identificationNumber | (optional)
Identification Number of an individual. |\n| organizationName | (conditional mandatory)
Organization Unit of the respective Organization. |\n| organizationIdentificationNumber | (optional)
Organization Identification Number. |\n| organizationUnit | (optional) |\n| registeredId | (conditional mandatory)
Registered ID of the respective Organization. |\n| businessCategory | (conditional mandatory)
Business Category of an Organization.
The allowed values for this field are:
1 - Private Organization
2 - Government Entity |\n| companyDuns | (conditional mandatory)
Company Registration Number or Company DUNS Number of an Organization. |\n| 1 email | (conditional mandatory)
Email ID of an individual. |\n| isdCode | (conditional mandatory)
ISD Code of mobile number of an Individual. |\n| mobileNumber | (conditional mandatory)
Mobile number of an Individual. |\n| streetAddress1 | (conditional mandatory)
Street Address 1 of an organization. |\n| streetAddress2 | (optional)
Street Address 2 of an organization. |\n| locality | (conditional mandatory)
Locality of an organization. |\n| state | (conditional mandatory)
State of an organization. |\n| countryCode | (conditional mandatory)
Country Code of the Country. |\n| postalCode | (conditional mandatory)
Postal Code of an organization. |\n| dnsType | (optional)
Default value is '1'.
1: Internal Domain Name
2: External Domain Name |\n| domainName | (mandatory)
Domain Name of the website / server. |\n| additionalDomainNames | (mandatory)
Additional domain names of the website / server. |\n\n**Additional Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| tags | (optional)
Reporting tags are labels with different values, which can be associated with your certificate orders in CERTInext. These tags can then be used to filter reports, giving you a categorized insight into your business.
Tag Name and Tag Value must be colon separated values.
e.g.: Department:Technology
Multiple tag names and tag values can be associated with the order.
e.g.: Department:Technology,
Department:Legal,
Branch Location:India
Specified below. |\n| remarks | (optional)
Additional Information related to the order. |\n\n**Tags**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| Tag Name | (mandatory)
Reporting Tag Name. |\n| Tag Value | (mandatory)
Reporting Tag Value. |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n },\n \"orderDetails\": {\n \"orderNumber\": \"\",\n \"requestNumber\": \"\",\n \"trackingURL\": \"\",\n \"certificateDetails\": {\n \"rootCertificate\": \"\",\n \"caCertificate\": \"\",\n \"subCACertificate\": \"\",\n \"endEntityCertificate\": \"\",\n \"certificateSerialNumber\": \"\", \n \"expiryDate\": \"\"\n }\n },\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order.
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | (mandatory)
Version should be set to \"1.0\". |\n| ts | (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestNumber | (conditional mandatory)
Unique Request ID of the respective request. |\n| orderNumber | (conditional mandatory)
Unique Order ID of the respective order.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |\n| trackingUrl | (mandatory)
Order status tracking URL of the respective order. If saveAndHold is set to '1', will not be displayed in the response.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |\n| certificateDetails | (mandatory)
If dnsType is selected as '2' and Domain is yet to be verified, certificateDetails will not be displayed in the response. Specified below. |\n\n**Certificate Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| rootCertificate | (mandatory)
Root CA Certificate of emSign. |\n| caCertificate | (mandatory)
Intermediate / Issuer CA Certificate. |\n| subCACertificate | (optional)
Subordinate CA Certificate. |\n| endEntityCertificate | (mandatory)
End Entity Certificate. |\n| certificateSerialNumber | (mandatory)
Serial Number of the Certificate. |\n| expiryDate | (mandatory)
Expiry Date of the respective End Entity Certificate in UTC format. |\n\n" + }, + "response": [ + { + "name": "GenerateOrderPrivatePKI", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"4397827229\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n // \"autoSecureWWW\": \"0\",\r\n \"productCode\": \"188\",\r\n // \"emailNotifications\": \"\",\r\n //\"groupNumber\": \"\",\r\n //\"accountingModel\": \"\",\r\n // \"saveAndHold\": \"\", // 1-Generate RequestNumber | 0-Generate Order Number\r\n // \"requestNumber\": \"\",\r\n \"requestorInformation\": {\r\n \"requestorName\": \"Abcd\",\r\n \"requestorIsdCode\": \"+91\",\r\n \"requestorMobileNumber\": \"7094940185\",\r\n \"requestorEmail\": \"abcd@emudhra.com\",\r\n \"requestorDesignation\": \"test\" \r\n }, \r\n \"subscriptionDetails\": { // Applicable only for SSL DV / OV\r\n \"validity\": \"0\", //1/2/3 (Default Value: 1)\r\n \"autoRenew\": \"\", //1/0 (Default-1)\r\n \"renewCriteria\": \"\" //30/60 (Default 30)\r\n },\r\n // \"agreementDetails\": {\r\n // \"acceptAgreement\": \"1\", // 1-Accept | 0-Reject\r\n // \"signerIP\": \"\",\r\n // \"signerPlace\": \"\",\r\n // \"signerName\": \"\"\r\n // },\r\n // \"organizationDetails\": {\r\n // \"preVetting\": \"1\", //0-New(No) | 1-Existing (Yes)\r\n // \"organizationNumber\": \"12344\", //Mandatory in case of 1\r\n // \"prevettingToken\": \"12344\", \r\n // \"representativeNumber\": \"12344\" //If Empty, requestorInfo will be considered as OrgRep | Initially, only one Representative. So if Pre-vetting 1 requestor info will not be considered\r\n // },\r\n \"certificateInformation\": {\r\n // \"firstName\": \"Nandhu\",\r\n //\"lastName\": \"Nandhu\",\r\n \"countryCode\": \"IN\", //Country Code\r\n \"state\": \"Tamil Nadu\", //String value\r\n // \"locality\": \"Mysore\",\r\n // \"email\": \"abcd@emudhra.com\",\r\n \"organizationName\": \"emudhra\",\r\n \"organizationUnit\": \"test\",\r\n // \"postalCode\": \"570004\",\r\n // \"streetAddress1\": \"test\",\r\n // \"streetAddress2\": \"test2\",\r\n // \"isdCode\": \"103\",\r\n // \"mobileNumber\": \"8050903086\",\r\n // \"registeredId\": \"12345\",\r\n // \"identificationNumber\": \"A0RPYX29234\",\r\n // \"identityDocumentType\": \"Passport\",\r\n // \"organizationIdentificationNumber\": \"ASDDASRPYX29234\",\r\n // \"companyDuns\": \"123344456\",\r\n // \"businessCategory\": \"1\", //1/2/3\r\n \"domainName\": \"empower.com\",\r\n \"additionalDomains\": [\r\n \"staging.emudhra.net\"\r\n ]\r\n },\r\n // \"delegationInformation\": {\r\n // \"contactName\": \"Abcd\",\r\n // \"email\": \"abcd@emudhra.com\"\r\n // },\r\n // \"authorizedSignatoryInfo\": {\r\n // \"arName\": \"Wxyz\",\r\n // \"arEmail\": \"wxyz@emudhra.com\",\r\n // \"arIsdCode\": \"103\",\r\n // \"arMobileNumber\": \"8050903086\",\r\n // \"arDesignation\": \"Dev\"\r\n // },\r\n // \"technicalPointOfContact\": {\r\n // \"pocName\": \"Dcef\",\r\n // \"pocEmail\": \"dcef@emudhra.com\",\r\n // \"pocIsdCode\": \"103\",\r\n // \"pocMobileNumber\": \"8050903086\",\r\n // \"pocDesignation\": \"Dev\"\r\n // }\r\n \"additionalInformation\": {\r\n \"remarks\": \"\",\r\n \"tags\": [\r\n \r\n ] \r\n // \"customFields\": [\r\n // {\r\n // \"fieldID\": \"\",\r\n // \"fieldValue\": \"\"\r\n // },\r\n // {\r\n // \"fieldID\": \"\",\r\n // \"fieldValue\": \"\"\r\n // }\r\n // ]\r\n }, \r\n \"csr\": \"-----BEGIN CERTIFICATE REQUEST----- MIICmjCCAYICAQAwVTELMAkGA1UEBhMCSU4xCTAHBgNVBAgTADEJMAcGA1UEBxMA MQkwBwYDVQQKEwAxDzANBgkqhkiG9w0BCQETADEUMBIGA1UEAxMLZW11ZGhyYS5j b20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDgHHshLzP3CXFqxrTK 58PfWzHfRJJDvgZrmVjj1gqxX0RWgC+Scm66ZN/cTf82Y1AoLaCwSBoWtqV0Brlc KpJN6qQeawVB9t438X2gFGwzCYHOM1GXaslW3b1TK5oXJ4QkX0j32kUst4cYqHd6 Gs3WEqOBi4pBdRqS/0zEspq400pL6cUin2QNGCed46S8IdmCTV9mUsROK17PEkgr ufT+i5AixLoSC09zoQpIrewF5B7CSANAOW1XNKNQYA64vsNNHmOJHlwEKBd/zmMY te7wsgS3S+BRdJgfniAcntnIEW5aFLHgFn1n1YE5DACkEZ7qy3YR68kDdproTscx 2SlfAgMBAAGgADANBgkqhkiG9w0BAQsFAAOCAQEAHnLo3GPyIgH/yTm8afbpoN1X MzbmpGygbAbQblT/FuFUilevonI68J9DTrgclVDfp3i16EL2VZFFrawzVdof0g/Z xzoolJPL4Y+Whz0M7NuEgcB3gYimZ7QV/db00Vg6C+jLJ5TT01KTDlW2H+la9f23 bEroOY+kXKF6AN8omSVReQXOAGobJw53KIzWxWfn78DgHdtTfenvdpxkYw6aQptv +wklMdLoRkk+zw5g1c9ZzZaIOsONmDuBevEb/vjcG5ucIHlxb8f6k3rzsoP2W70T Q6FDuIDxBBP6qvdUaGLmqtys+QSBP0YKBtSLn+M5W4n+wi+1ZPwf/XkhO83MXQ== -----END CERTIFICATE REQUEST-----\"\r\n // \"numberOfDocuments\": \"\",\r\n // \"documentsAttached\": [\r\n // {\r\n // \"id\": \"\",\r\n // \"fileName\": \"\",\r\n // \"description\": \"\",\r\n // \"base64Value\": \"\"\r\n // },\r\n // {\r\n // \"id\": \"\",\r\n // \"fileName\": \"\",\r\n // \"description\": \"\",\r\n // \"base64Value\": \"\"\r\n // }\r\n // ]\r\n }\r\n }\r\n\r\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/GenerateOrderPrivatePKI", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "GenerateOrderPrivatePKI" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "171" + }, + { + "key": "Date", + "value": "Tue, 02 Apr 2024 10:56:44 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"meta\": {\n \"ver\": \"1.0\",\n \"ts\": \"2024-04-02T16:26:42+05:30\",\n \"txn\": \"c519a2c63fe14a95b0f4a377391cb898\",\n \"status\": \"0\",\n \"errorCode\": \"EMS-915\",\n \"errorMessage\": \"Invalid Certificate ID\"\n }\n}" + } + ] + } + ], + "description": "This section includes following API.\n\n- Generate Private PKI" + }, + { + "name": "Track Order", + "item": [ + { + "name": "TrackOrder SSL", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "pm.test('Status code is 200', () => {", + " pm.response.to.have.status(200);", + "});", + "", + "if (status === '1') {", + " pm.test('Request Successful', () => {", + " pm.expect(status).to.equal('1');", + " });", + "} else {", + " pm.test('Request FAILED: ' + (errMsg || 'Unknown error'), () => {", + " pm.expect(status).to.equal('1');", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"orderNumber\": \"{{orderNumber}}\"\r\n }\r\n}\r\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}TrackOrder", + "host": [ + "{{baseURL}}TrackOrder" + ] + }, + "description": "This API facilitates user to track the order status for emSign SSL certificate orders.\n\n| **Region** | **URL** |\n| --- | --- |\n| US | [https://sandbox-us-subscriber.emsign.com/](https://sandbox-us-subscriber.emsign.com/) |\n| Global | [https://sandbox-emsignsubscriber.emudhra.net/](https://sandbox-emsignsubscriber.emudhra.net/) |\n\n**API Endpoint**\n\n| Category | Specification |\n| --- | --- |\n| URL | http(s)://(url host port and path)/TrackOrder |\n| Protocol | HTTP / HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"accountNumber \": \"\",\n \"authKey\": \"\"\n },\n \"orderDetails\": {\n \"orderNumber\": \"\" \n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective emSign Certificate Order. Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | 1234567890 (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | 6288275133 (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | 952a95057654aa1c26a0f287b6368579f5f3ec99f213543df5e0f7eb7f798b3d (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| orderNumber | 8276384767 (mandatory)
Unique Order ID of the respective emSign Certificate Order. |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n },\n \"orderDetails\": {\n \" trackingUrl\": \"\",\n \"orderStatusId\": \"\",\n \"orderStatus\": \"\",\n \"certificateStatusId\": \"\",\n \"certificateStatus\": \"\",\n \"certificateExpiryDate\": \"\",\n \"requestorInformation\": {\n \"requestorName\": \"\",\n \"requestorIsdCode\": \"\",\n \"requestorMobileNumber\": \"\",\n \"requestorEmail\": \"\",\n \"requestorDesignation\": \"\"\n },\n \"subscriberAgreement\": {\n \"signerName\": \"\",\n \"signedDate\": \"\", \n \"signedPlace\": \"\",\n \"status\": \"\", \n \"consentStatus\": \"\", \n \"consentSentTo\": \"\"\n },\n \"domainVerification\": {\n \"\": {\n \"dcvMethod\": \"\",\n \"dcvStatus\": \"\", \n \"status\": \"\", \n \"verifiedDate\": \"\",\n \"caaStatus\": \"\"\n },\n \"status\": \"\", \n },\n \"csr\": \"\",\n \"interimDvIssued\": \"\",\n \"organizationVerification\": {\n \"organizationName\": \"\",\n \"status\": \"\", \n \"consentStatus\": \"\", \n \"consentSentTo\": \"\"\n },\n \"revocationDetails\": {\n \"revokeRequestStatusId\": \"\",\n \"revokeReasonId\": \"\",\n \"revokeProcessedDate\": \"\", \n \"revokeRequestStatus\": \"\",\n }, \n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective emSign Certificate Order. Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | 1234567890 (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | 1 (mandatory)
1 - Success, 0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. Error codes are specified under Error Codes and Messages. |\n| errorMessage | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. Error messages are specified under Error Codes and Messages. |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| trackingUrl | [https://sandbox-emsignsubscriber.emudhra.net/PublicLink/trackOrderPrivatePKI.jsp?a=REw3a2FPUU55YzV3Nys4WWMzOEJYQT09](https://sandbox-emsignsubscriber.emudhra.net/PublicLink/trackOrderPrivatePKI.jsp?a=REw3a2FPUU55YzV3Nys4WWMzOEJYQT09) (conditional mandatory)
URL for tracking the order status. This is 'Optional' in case of failure status (0). This is 'Mandatory' in case of success status (1). |\n| orderStatusId | 1 (conditional mandatory)
Status ID of the order.
1 - Order Placed
2 - Order Accepted
3 - Order In-Progress
4 - Order Rejected
5 - Order Cancelled
6 - Order Fulfilled
7 - On Hold (Saved to Draft)
8 - Order Pending for Approval
9 - Order Pending for Account Administrator Approval.

This is 'Optional' in case of failure status (0). This is 'Mandatory' in case of success status (1). |\n| orderStatus | Order Placed (conditional mandatory)
Status of the order.
1 - Order Placed
2 - Order Accepted
3 - Order In-Progress
4 - Order Rejected
5 - Order Cancelled
6 - Order Fulfilled
7 - On Hold (Saved to Draft)
8 - Order Pending for Approval
9 - Order Pending for Account Administrator Approval.

This is 'Optional' in case of failure status (0). This is 'Mandatory' in case of success status (1). |\n| certificateStatusId | 1 (conditional mandatory)
Optional in case of failure. Mandatory in case of success.
1 - Setup Pending
2 - Pending for Approver
3 - Under Discrepancy
4 - Approved
5 - Rejected
6 - Pending Second Approver
7 - Approved by Second Approver
8 - Rejected by Second Approver
9 - Certificate Downloaded
12 - Certificate Expired
13 - Rejected due to Order Cancellation
14 - Auto Rejected
15 - Order Auto Approved (Certificate Download Instructions email sent)
16 - Pending LRA
17 - Approved LRA
18 - Rejected LRA
19 - Invalid Configuration
20 - Certificate Generated
21 - Download Rejected
22 - Certificate Revoked
23 - Rekey Approved
24 - Pending for Approver (Auto-approval)
25 - Organization Authorization Pending LRA
26 - Organization Authorized LRA
27 - Organization Authorization Rejected LRA |\n| certificateStatus | Setup Pending (conditional mandatory)
Optional in case of failure. Mandatory in case of success.
1 - Setup Pending
2 - Pending for Approver
3 - Under Discrepancy
4 - Approved
5 - Rejected
6 - Pending Second Approver
7 - Approved by Second Approver
8 - Rejected by Second Approver
9 - Certificate Downloaded
12 - Certificate Expired
13 - Rejected due to Order Cancellation
14 - Auto Rejected
15 - Order Auto Approved (Certificate Download Instructions email sent)
16 - Pending LRA
17 - Approved LRA
18 - Rejected LRA
19 - Invalid Configuration
20 - Certificate Generated
21 - Download Rejected
22 - Certificate Revoked
23 - Rekey Approved
24 - Pending for Approver (Auto-approval)
25 - Organization Authorization Pending LRA
26 - Organization Authorized LRA
27 - Organization Authorization Rejected LRA |\n| certificateExpiryDate | (conditional mandatory)
Expiry Date of the respective End Entity Certificate in UTC format. This is 'Mandatory', if the certificate is downloaded. |\n| requestorInformation | (mandatory)
Specified below. |\n| subscriberAgreement | (mandatory)
Specified Below. |\n| domainVerification | (mandatory)
Specified Below. |\n| csr | (mandatory)
CSR means Certificate Signing Request. Specified Below. |\n| interimDvIssued | (mandatory)
Status of the interim DV certificate.

Interim DV Certificate can be used for your temporary replacement so that you need not wait to get final certificate. This is mandatory for OV and EV certificate orders.

1 - Interim DV Certificate is issued and ready for download.
0 – Interim DV Certificate is yet to be issued. |\n| organizationVerification | (conditional mandatory)
Specified Below.

Applicable for the products below.
1\\. SSL/TLS OV Products
2\\. SSL/TLS EV Products |\n| revocationDetails | (conditional mandatory)
Specified Below.

This is ‘Mandatory’, if the certificate is revoked. |\n\n**Requestor Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestorName | John Doe (mandatory)
Name of the requestor. |\n| requestorIsdCode | +1 (mandatory)
ISD Code of the requestor. |\n| requestorMobileNumber | 8280098898 (mandatory)
Mobile number of the requestor. |\n| requestorEmail | [johndoe@example.com](https://johndoe@example.com) (mandatory)
Email of the requestor. |\n| requestorDesignation | CFO (optional)
Designation of the requestor. |\n\n**Subscriber Agreement**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| signerName | John Doe (mandatory)
Name of the Signer. |\n| signedDate | 2024-01-02 10:05:13 (mandatory)
The date when the subscriber agreement is signed. |\n| signedPlace | GOA (mandatory)
The Place where the subscriber Agreement is signed. |\n| status | 1 (mandatory)
Status of Subscriber Agreement. 0: Pending, 1: Completed, 2: Rejected |\n| consentStatus | (conditional mandatory)
Status of the consent to re-use the subscriber agreement of the pre-vetting organization. 0: Pending, 1: Accepted, 2: Declined. This is 'Mandatory', if \"preVetting\" value is set to \"1\" and \"requestorInformation\" provided in the order requires the consent from primary certificate requester of the respective organization. That means, Primary certificate requester of the organization should provide the consent to re-use (decline) the organization's subscriber agreement over email link. |\n| consentSentTo | (conditional mandatory)
Primary Certificate Requester Email ID of the respective pre-vetting organization to which the consent will be sent. |\n\n**Domain Verification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| dcvMethod | Email based verification (mandatory) |\n| verifiedDate | 2023-12-21 04:56:55 (mandatory)
The date when the domain is successfully validated. |\n| dcvStatus | 1 (mandatory)
Status of DCV (Domain Control Validation) of the respective domain.

0: Pending
1: Validated
2: Rejected |\n| caaStatus | 1 (mandatory)
CAA (Certification Authority Authorization) status of the respective domain.

1: emSign Authorized or No CAA record present
2: Authorization Required
3: Authorization Pending |\n| status | 1 (mandatory)
Status of the Domain.

1: Active
2: Inactive
3: Expired |\n\n**Domain Status**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| status | 1 (mandatory)

Domain status of the respetive order.
0- Pending
1- Completed
2- Rejected |\n\n**CSR**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| csr | 1 (mandatory)
Status of the CSR.
0 - Pending, 1 - Submitted |\n\n**Organization Verification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| organizationName | eMudhra Limited (mandatory)
Name of an individual given in the certificate information. |\n| status | (mandatory)
Status of Organization Verification Process. 0: Pending, 1: Completed, 2: Rejected |\n| consentStatus | (conditional mandatory)
Status of the consent. 0: Pending, 1: Accepted, 2: Declined. This is 'Mandatory', if \"preVetting\" value is set to \"1\" and \"requestorInformation\" provided in the order requires the consent from primary certificate requester of the respective organization. That means, Primary certificate requester of the organization should provide the consent to re-use (decline) the organization information over email link. |\n| consentSentTo | (conditional mandatory)
Primary Certificate Requester Email ID of the respective pre-vetting organization to which the consent will be sent. |\n\n**Revocation Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| revokeRequestStatusId | 1 (mandatory)
Status ID of the certificate revocation request.
1 - Revoke Request Initiated
2 - Certificate Revoked
3 - Revoke Request Rejected
4 - Revoke Request Initiated and pending LRA (Trusted Agent)
5 - Certificate Revoked by LRA (Trusted Agent)
6 - Revoke Request Rejected by LRA (Trusted Agent) |\n| revokeReasonId | 1 (mandatory)
Revocation reason ID of the certificate revocation request.
1 - KeyCompromise
3 - AffiliationChanged
4 - Superseded
5 - cessationOfOperation
9 - privilegeWithdrawn |\n| revokeProcessedDate | 2024-02-02 10:05:13 (conditional mandatory)
The date of certificate revocation. This is 'Mandatory', if the certificate is revoked. |\n| revokeRequestStatus | Certificate Revoked (mandatory)
Status of the certificate revocation request. e.g.: Certificate Revoked |" + }, + "response": [ + { + "name": "TrackOrder SSL", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\":{\r\n \"ver\":\"1.0\",\r\n \"ts\":\"{{ts}}\",\r\n \"txn\":\"{{txn}}\",\r\n \"accountNumber\":\"4391755311\",\r\n \"authKey\":\"{{hash}}\" \r\n },\r\n \"orderDetails\": {\r\n \"orderNumber\":\"9574794842\"\r\n }\r\n}\r\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/TrackOrder", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "TrackOrder" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "171" + }, + { + "key": "Date", + "value": "Tue, 02 Apr 2024 10:57:39 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"meta\": {\n \"ver\": \"1.0\",\n \"ts\": \"2024-04-02T16:27:37+05:30\",\n \"txn\": \"967911a5663f4bf29245ff0f11c83599\",\n \"status\": \"0\",\n \"errorCode\": \"EMS-913\",\n \"errorMessage\": \"Invalid Account Number\"\n }\n}" + } + ] + }, + { + "name": "TrackOrder SMIME", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "pm.test('Status code is 200', () => {", + " pm.response.to.have.status(200);", + "});", + "", + "if (status === '1') {", + " pm.test('Request Successful', () => {", + " pm.expect(status).to.equal('1');", + " });", + "} else {", + " pm.test('Request FAILED: ' + (errMsg || 'Unknown error'), () => {", + " pm.expect(status).to.equal('1');", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"orderNumber\": \"{{orderNumber}}\"\r\n }\r\n}\r\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}TrackOrder", + "host": [ + "{{baseURL}}TrackOrder" + ] + }, + "description": "This API facilitates user to track the order status for emSign S/MIME - Simple certificate orders.\n\n| **Region** | **URL** |\n| --- | --- |\n| US | [https://sandbox-us-subscriber.emsign.com/](https://sandbox-us-subscriber.emsign.com/) |\n| Global | [https://sandbox-emsignsubscriber.emudhra.net/](https://sandbox-emsignsubscriber.emudhra.net/) |\n\n**API Endpoint**\n\n| Category | Specification |\n| --- | --- |\n| URL | http(s)://(url host port and path)/TrackOrder |\n| Protocol | HTTP / HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"accountNumber \": \"\",\n \"authKey\": \"\"\n },\n \"orderDetails\": {\n \"orderNumber\": \"\" \n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective emSign Certificate Order. Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | 1234567890 (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | 6288275133 (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | 952a95057654aa1c26a0f287b6368579f5f3ec99f213543df5e0f7eb7f798b3d (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| orderNumber | 8276384767 (mandatory)
Unique Order ID of the respective emSign Certificate Order. |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n },\n \"orderDetails\": {\n \" trackingUrl\": \"\",\n \"orderStatusId\": \"\",\n \"certificateStatusId\": \"\",\n \"certificateExpiryDate\": \"\",\n \"requestorInformation\": {\n \"requestorName\": \"\",\n \"requestorIsdCode\": \"\",\n \"requestorMobileNumber\": \"\",\n \"requestorEmail\": \"\",\n \"requestorDesignation\": \"\"\n },\n \"subscriberAgreement\": {\n \"signerName\": \"\",\n \"signedDate\": \"\", \n \"signedPlace\": \"\",\n \"status\": \"\", \n \"consentStatus\": \"\", \n \"consentSentTo\": \"\"\n },\n \"emailVerification\": {\n \"emailId\": \"\",\n \"verifiedDate\": \"\",\n \"status\": \"\" \n },\n \"csr\": \"\",\n \"individualVerification\": {\n \"name\": \"\",\n \"verifiedDate\": \"\",\n \"status\": \"\" \n },\n \"faceToFaceVerification\": {\n \"name\": \"\",\n \"verifiedDate\": \"\",\n \"status\": \"\" \n },\n \"organizationVerification\": {\n \"organizationName\": \"\",\n \"status\": \"\", \n \"consentStatus\": \"\", \n \"consentSentTo\": \"\"\n },\n \"revocationDetails\": {\n \"revokeRequestStatusId\": \"\",\n \"revokeReasonId\": \"\",\n \"revokeProcessedDate\": \"\", \n \"revokeRequestStatus\": \"\",\n }, \n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective emSign Certificate Order. Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | 1234567890 (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | 1 (mandatory)
1 - Success, 0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. Error codes are specified under Error Codes and Messages. |\n| errorMessage | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. Error messages are specified under Error Codes and Messages. |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| trackingUrl | [https://sandbox-emsignsubscriber.emudhra.net/PublicLink/trackOrderPrivatePKI.jsp?a=REw3a2FPUU55YzV3Nys4WWMzOEJYQT09](https://sandbox-emsignsubscriber.emudhra.net/PublicLink/trackOrderPrivatePKI.jsp?a=REw3a2FPUU55YzV3Nys4WWMzOEJYQT09) (conditional mandatory)
URL for tracking the order status. This is 'Optional' in case of failure status (0). This is 'Mandatory' in case of success status (1). |\n| orderStatusId | 1 (conditional mandatory)
Status ID of the order. 1 - Order Placed, 2 - Order Accepted, 3 - Order In-Progress, 4 - Order Rejected, 5 - Order Cancelled, 6 - Order Fulfilled, 7 - On Hold (Saved to Draft), 8 - Order Pending for Approval, 9 - Order Pending for Account Administrator Approval. This is 'Optional' in case of failure status (0). This is 'Mandatory' in case of success status (1). |\n| certificateStatusId | 1 (conditional mandatory)
Optional in case of failure. Mandatory in case of success. 1 - Setup Pending, 2 - Pending for Approver, 3 - Under Discrepancy, 4 - Approved, 5 - Rejected, 6 - Pending Second Approver, 7 - Approved by Second Approver, 8 - Rejected by Second Approver, 9 - Certificate Downloaded, 12 - Certificate Expired, 13 - Rejected due to Order Cancellation, 14 - Auto Rejected, 15 - Order Auto Approved (Certificate Download Instructions email sent), 16 - Pending LRA, 17 - Approved LRA, 18 - Rejected LRA, 19 - Invalid Configuration, 20 - Certificate Generated, 21 - Download Rejected, 22 - Certificate Revoked, 23 - Rekey Approved, 24 - Pending for Approver (Auto-approval), 25 - Organization Authorization Pending LRA, 26 - Organization Authorized LRA, 27 - Organization Authorization Rejected LRA |\n| certificateExpiryDate | (conditional mandatory)
Expiry Date of the respective End Entity Certificate in UTC format. This is 'Mandatory', if the certificate is downloaded. |\n| requestorInformation | (mandatory)
Specified below. |\n| subscriberAgreement | (mandatory)
Specified Below. |\n| emailVerification | (mandatory)
Specified Below. |\n| csr | (mandatory)
CSR means Certificate Signing Request. Specified Below. |\n| individualVerification | (conditional mandatory) |\n| faceToFaceVerification | (conditional mandatory)
Specified Below. |\n| organizationVerification | (conditional mandatory)
Specified Below. |\n| revocationDetails | (conditional mandatory)
Specified Below. |\n\n**Requestor Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestorName | John Doe (mandatory)
Name of the requestor. |\n| requestorIsdCode | +1 (mandatory)
ISD Code of the requestor. |\n| requestorMobileNumber | 8280098898 (mandatory)
Mobile number of the requestor. |\n| requestorEmail | [johndoe@example.com](https://johndoe@example.com) (mandatory)
Email of the requestor. |\n| requestorDesignation | CFO (optional)
Designation of the requestor. |\n\n**Subscriber Agreement**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| signerName | John Doe (mandatory)
Name of the Signer. |\n| signedDate | 2024-01-02 10:05:13 (mandatory)
The date when the subscriber agreement is signed. |\n| signedPlace | GOA (mandatory)
The Place where the subscriber Agreement is signed. |\n| status | 1 (mandatory)
Status of Subscriber Agreement. 0: Pending, 1: Completed, 2: Rejected |\n| consentStatus | (conditional mandatory)
Status of the consent to re-use the subscriber agreement of the pre-vetting organization. 0: Pending, 1: Accepted, 2: Declined. This is 'Mandatory', if \"preVetting\" value is set to \"1\" and \"requestorInformation\" provided in the order requires the consent from primary certificate requester of the respective organization. That means, Primary certificate requester of the organization should provide the consent to re-use (decline) the organization's subscriber agreement over email link. |\n| consentSentTo | (conditional mandatory)
Primary Certificate Requester Email ID of the respective pre-vetting organization to which the consent will be sent. |\n\n**Email Verification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| emailID | [johndoe@example.com](https://johndoe@example.com) (mandatory)
Email ID of an individual given in the certificate information. |\n| verifiedDate | 2024-01-02 10:05:13 (mandatory)
The date when the email is successfully verified. |\n| status | 1 (mandatory)
Status of the email verification. 0: Pending, 1: Completed, 2: Rejected |\n\n**CSR**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| csr | 1 (mandatory)
Status of the CSR. 0 - Pending, 1 - Submitted |\n\n**Individual Verification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| name | Tejaswini (mandatory)
Name of an individual given in the certificate information. |\n| verifiedDate | 2024-01-02 10:05:13 (mandatory)
The date when the individual verification is successfully completed. |\n| status | 1 (mandatory)
Status of the individual verification. 0: Pending, 1: Completed, 2: Rejected |\n\n**Face-to-Face Verification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| name | Vivek M (mandatory)
Name of an individual given in the certificate information. |\n| verifiedDate | 2024-01-02 10:05:13 (mandatory)
The date when the Face-to-Face verification is successfully completed. |\n| status | 1 (mandatory)
Status of the Face-to-Face verification. 0: Pending, 1: Completed, 2: Rejected |\n\n**Organization Verification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| organizationName | eMudhra Limited (mandatory)
Name of an individual given in the certificate information. |\n| status | (mandatory)
Status of Organization Verification Process. 0: Pending, 1: Completed, 2: Rejected |\n| consentStatus | (conditional mandatory)
Status of the consent. 0: Pending, 1: Accepted, 2: Declined. This is 'Mandatory', if \"preVetting\" value is set to \"1\" and \"requestorInformation\" provided in the order requires the consent from primary certificate requester of the respective organization. That means, Primary certificate requester of the organization should provide the consent to re-use (decline) the organization information over email link. |\n| consentSentTo | (conditional mandatory)
Primary Certificate Requester Email ID of the respective pre-vetting organization to which the consent will be sent. |\n\n**Revocation Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| revokeRequestStatusId | 1 (mandatory)
Status ID of the certificate revocation request. 1 - Revoke Request Initiated, 2 - Certificate Revoked, 3 - Revoke Request Rejected, 4 - Revoke Request Initiated and pending LRA (Trusted Agent), 5 - Certificate Revoked by LRA (Trusted Agent), 6 - Revoke Request Rejected by LRA (Trusted Agent) |\n| revokeReasonId | 1 (mandatory)
Revocation reason ID of the certificate revocation request. 1 - KeyCompromise, 3 - AffiliationChanged, 4 - Superseded, 5 - cessationOfOperation, 9 - privilegeWithdrawn |\n| revokeProcessedDate | 2024-02-02 10:05:13 (conditional mandatory)
The date of certificate revocation. This is 'Mandatory', if the certificate is revoked. |\n| revokeRequestStatus | Certificate Revoked (mandatory)
Status of the certificate revocation request. e.g.: Certificate Revoked |" + }, + "response": [ + { + "name": "TrackOrder SMIME", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\":{\r\n \"ver\":\"1.0\",\r\n \"ts\":\"{{ts}}\",\r\n \"txn\":\"{{txn}}\",\r\n \"accountNumber\":\"4391755311\",\r\n \"authKey\":\"{{hash}}\" \r\n },\r\n \"orderDetails\": {\r\n \"orderNumber\":\"9574794842\"\r\n }\r\n}\r\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/TrackOrder", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "TrackOrder" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "171" + }, + { + "key": "Date", + "value": "Tue, 02 Apr 2024 10:58:12 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"meta\": {\n \"ver\": \"1.0\",\n \"ts\": \"2024-04-02T16:28:11+05:30\",\n \"txn\": \"425c5ec0745946d3bbde94536903d09d\",\n \"status\": \"0\",\n \"errorCode\": \"EMS-913\",\n \"errorMessage\": \"Invalid Account Number\"\n }\n}" + } + ] + }, + { + "name": "TrackOrder Signature", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "pm.test('Status code is 200', () => {", + " pm.response.to.have.status(200);", + "});", + "", + "if (status === '1') {", + " pm.test('Request Successful', () => {", + " pm.expect(status).to.equal('1');", + " });", + "} else {", + " pm.test('Request FAILED: ' + (errMsg || 'Unknown error'), () => {", + " pm.expect(status).to.equal('1');", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"orderNumber\": \"{{orderNumber}}\"\r\n }\r\n}\r\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}TrackOrder", + "host": [ + "{{baseURL}}TrackOrder" + ] + }, + "description": "This API facilitates user to track the order status for all Signature certificate orders.\n\n- Document Signer Personal (Natural Person)\n \n- Document Signer Professional (Legal Person)\n \n- Document Signer Corporate (Legal Entity)\n \n\n| **Region** | **URL** |\n| --- | --- |\n| US | [https://sandbox-us-subscriber.emsign.com/](https://sandbox-us-subscriber.emsign.com/) |\n| Global | [https://sandbox-emsignsubscriber.emudhra.net/](https://sandbox-emsignsubscriber.emudhra.net/) |\n\n**API Endpoint**\n\n| Category | Specification |\n| --- | --- |\n| URL | http(s)://(url host port and path)/TrackOrder |\n| Protocol | HTTP / HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"accountNumber \": \"\",\n \"authKey\": \"\"\n },\n \"orderDetails\": {\n \"orderNumber\": \"\" \n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective emSign Certificate Order. Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | (mandatory)
Version should be set to \"1.0\". |\n| ts | (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| orderNumber | (mandatory)
Unique Order ID of the respective emSign Certificate Order. |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n },\n \"orderDetails\": {\n \"trackingUrl\": \"\",\n \"orderStatusId\": \"\",\n \"certificateStatusId\": \"\",\n \"certificateExpiryDate\": \"\",\n \"requestorInformation\": {\n \"requestorName\": \"\",\n \"requestorIsdCode\": \"\",\n \"requestorMobileNumber\": \"\",\n \"requestorEmail\": \"\",\n \"requestorDesignation\": \"\"\n },\n \"subscriberAgreement\": {\n \"signerName\": \"\",\n \"signedDate\": \"\", \n \"signedPlace\": \"\",\n \"status\": \"\", \n \"consentStatus\": \"\", \n \"consentSentTo\": \"\"\n },\n \"emailVerification\": {\n \"emailId\": \"\",\n \"verifiedDate\": \"\",\n \"status\": \"\" \n },\n \"individualVerification\": {\n \"name\": \"\",\n \"verifiedDate\": \"\",\n \"status\": \"\" \n },\n \"faceToFaceVerification\": {\n \"name\": \"\",\n \"verifiedDate\": \"\",\n \"status\": \"\" \n },\n \"organizationVerification\": {\n \"organizationName\": \"\",\n \"status\": \"\", \n \"consentStatus\": \"\", \n \"consentSentTo\": \"\"\n },\n \"revocationDetails\": {\n \"revokeRequestStatusId\": \"\",\n \"revokeReasonId\": \"\",\n \"revokeProcessedDate\": \"\", \n \"revokeRequestStatus\": \"\"\n }, \n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective emSign Certificate Order. Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | (mandatory)
Version should be set to \"1.0\". |\n| ts | (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success.
Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success.
Mandatory in case of failure. |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| trackingUrl | (conditional mandatory)
URL for tracking the order status. This is 'Optional' in case of failure status (0). This is 'Mandatory' in case of success status (1). |\n| orderStatusId | (conditional mandatory)
Status ID of the order.
1 - Order Placed
2 - Order Accepted
3 - Order In-Progress
4 - Order Rejected
5 - Order Cancelled
6 - Order Fulfilled
7 - On Hold (Saved to Draft)
8 - Order Pending for Approval
9 - Order Pending for Account Administrator Approval. This is 'Optional' in case of failure status (0). This is 'Mandatory' in case of success status (1). |\n| certificateStatusId | (conditional mandatory)
Optional in case of failure. Mandatory in case of success.
1 - Setup Pending
2 - Pending for Approver
3 - Under Discrepancy
4 - Approved
5 - Rejected
6 - Pending Second Approver
7 - Approved by Second Approver
8 - Rejected by Second Approver
9 - Certificate Downloaded
12 - Certificate Expired
13 - Rejected due to Order Cancellation
14 - Auto Rejected
15 - Order Auto Approved (Certificate Download Instructions email sent)
16 - Pending LRA
17 - Approved LRA
18 - Rejected LRA
19 - Invalid Configuration
20 - Certificate Generated
21 - Download Rejected
22 - Certificate Revoked
23 - Rekey Approved
24 - Pending for Approver (Auto-approval)
25 - Organization Authorization Pending LRA
26 - Organization Authorized LRA
27 - Organization Authorization Rejected LRA |\n| certificateExpiryDate | (conditional mandatory)
Expiry Date of the respective End Entity Certificate in UTC format. This is 'Mandatory', if the certificate is downloaded. |\n| requestorInformation | (mandatory)
Specified below. |\n| subscriberAgreement | (mandatory)
Specified Below. |\n| emailVerification | (mandatory)
Specified Below. |\n| individualVerification | (conditional mandatory)
Applicable for Document Signer Personal (Natural Person) & Document Signer Professional (Legal Person).
Specified Below. |\n| faceToFaceVerification | (conditional mandatory)
Specified Below. |\n| organizationVerification | (conditional mandatory)
Applicable for Document Signer Personal (Natural Person) & Document Signer Professional (Legal Person) Products.
Specified Below. |\n| revocationDetails | (conditional mandatory)
This is 'Mandatory', if the certificate is revoked. Specified Below. |\n\n**Requestor Information**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestorName | John Doe (mandatory)
Name of the requestor. |\n| requestorIsdCode | +1 (mandatory)
ISD Code of the requestor. |\n| requestorMobileNumber | 8280098898 (mandatory)
Mobile number of the requestor. |\n| requestorEmail | [johndoe@example.com](https://johndoe@example.com) (mandatory)
Email of the requestor. |\n| requestorDesignation | CFO (optional)
Designation of the requestor. |\n\n**Subscriber Agreement**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| signerName | (mandatory)
Name of the Signer. |\n| signedDate | (mandatory)
The date when the subscriber agreement is signed. |\n| signedPlace | (mandatory)
The Place where the subscriber Agreement is signed. |\n| status | (mandatory)
Status of Subscriber Agreement.
0: Pending
1: Completed
2: Rejected |\n| consentStatus | (conditional mandatory)
Status of the consent to re-use the subscriber agreement of the pre-vetting organization.
0: Pending
1: Accepted
2: Declined.
This is 'Mandatory', if \"preVetting\" value is set to \"1\" and \"requestorInformation\" provided in the order requires the consent from primary certificate requester of the respective organization. That means, Primary certificate requester of the organization should provide the consent to re-use (decline) the organization's subscriber agreement over email link. |\n| consentSentTo | (conditional mandatory)
Primary Certificate Requester Email ID of the respective pre-vetting organization to which the consent will be sent. |\n\n**Email Verification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| emailID | (mandatory)
Email ID of an individual given in the certificate information. |\n| verifiedDate | (mandatory)
The date when the email is successfully verified. |\n| status | (mandatory)
Status of the email verification.
0: Pending
1: Completed
2: Rejected |\n\n**Individual Verification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| name | (mandatory)
Name of an individual given in the certificate information. |\n| verifiedDate | (mandatory)
The date when the individual verification is successfully completed. |\n| status | (mandatory)
Status of the individual verification.
0: Pending
1: Completed
2: Rejected |\n\n**Face-to-Face Verification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| name | (mandatory)
Name of an individual given in the certificate information. |\n| verifiedDate | (mandatory)
The date when the Face-to-Face verification is successfully completed. |\n| status | (mandatory)
Status of the Face-to-Face verification.
0: Pending
1: Completed
2: Rejected |\n\n**Organization Verification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| organizationName | (mandatory)
Name of an individual given in the certificate information. |\n| status | (mandatory)
Status of Organization Verification Process.
0: Pending
1: Completed
2: Rejected |\n| consentStatus | (conditional mandatory)
Status of the consent.
0: Pending
1: Accepted
2: Declined.
This is 'Mandatory', if \"preVetting\" value is set to \"1\" and \"requestorInformation\" provided in the order requires the consent from primary certificate requester of the respective organization. That means, Primary certificate requester of the organization should provide the consent to re-use (decline) the organization information over email link. |\n| consentSentTo | (conditional mandatory)
Primary Certificate Requester Email ID of the respective pre-vetting organization to which the consent will be sent. |\n\n**Revocation Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| revokeRequestStatusId | (mandatory)
Status ID of the certificate revocation request.
1 - Revoke Request Initiated
2 - Certificate Revoked
3 - Revoke Request Rejected
4 - Revoke Request Initiated and pending LRA (Trusted Agent)
5 - Certificate Revoked by LRA (Trusted Agent)
6 - Revoke Request Rejected by LRA (Trusted Agent) |\n| revokeReasonId | (mandatory)
Revocation reason ID of the certificate revocation request.
1 - KeyCompromise
3 - AffiliationChanged
4 - Superseded
5 - cessationOfOperation
9 - privilegeWithdrawn |\n| revokeProcessedDate | (conditional mandatory)
The date of certificate revocation.
This is 'Mandatory', if the certificate is revoked. |\n| revokeRequestStatus | (mandatory)
Status of the certificate revocation request. e.g.: Certificate Revoked |" + }, + "response": [ + { + "name": "TrackOrder Signature", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\":{\r\n \"ver\":\"1.0\",\r\n \"ts\":\"{{ts}}\",\r\n \"txn\":\"{{txn}}\",\r\n \"accountNumber\":\"4391755311\",\r\n \"authKey\":\"{{hash}}\" \r\n },\r\n \"orderDetails\": {\r\n \"orderNumber\":\"9574794842\"\r\n }\r\n}\r\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/TrackOrder", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "TrackOrder" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "171" + }, + { + "key": "Date", + "value": "Tue, 02 Apr 2024 10:58:40 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"meta\": {\n \"ver\": \"1.0\",\n \"ts\": \"2024-04-02T16:28:38+05:30\",\n \"txn\": \"373f23114b6640769c1693ff0de42f71\",\n \"status\": \"0\",\n \"errorCode\": \"EMS-913\",\n \"errorMessage\": \"Invalid Account Number\"\n }\n}" + } + ] + }, + { + "name": "TrackOrder Private PKI", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "pm.test('Status code is 200', () => {", + " pm.response.to.have.status(200);", + "});", + "", + "if (status === '1') {", + " pm.test('Request Successful', () => {", + " pm.expect(status).to.equal('1');", + " });", + "} else {", + " pm.test('Request FAILED: ' + (errMsg || 'Unknown error'), () => {", + " pm.expect(status).to.equal('1');", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"orderNumber\": \"{{orderNumber}}\"\r\n }\r\n}\r\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/TrackOrder", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "TrackOrder" + ] + }, + "description": "This API facilitates to track the order status for Private PKI orders.\n\n| **Region** | **URL** |\n| --- | --- |\n| US | [https://sandbox-us-subscriber.emsign.com/](https://sandbox-us-subscriber.emsign.com/) |\n| Global | [https://sandbox-emsignsubscriber.emudhra.net/](https://sandbox-emsignsubscriber.emudhra.net/) |\n\n**API Endpoint**\n\n| Category | Specification |\n| --- | --- |\n| URL | http(s)://(url host port and path)/TrackOrder |\n| Protocol | HTTP / HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"accountNumber \": \"\",\n \"authKey\": \"\"\n },\n \"orderDetails\": {\n \"orderNumber\": \"\" \n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective emSign Certificate Order.
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | 1234567890 (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | 6288275133 (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | 952a95057654aa1c26a0f287b6368579f5f3ec99f213543df5e0f7eb7f798b3d (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| orderNumber | (mandatory)
Unique Order ID of the respective emSign Certificate Order. |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n },\n \"orderDetails\": {\n \" trackingUrl\": \"\",\n \"orderStatusId\": \"\",\n \"certificateStatusId\": \"\",\n \"certificateExpiryDate\": \"\",\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective emSign Certificate Order.
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | 1234567890 (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | 1 (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success.
Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success.
Mandatory in case of failure. |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| trackingUrl | [https://server1.qa.emudhra.net/emSign-Subscriber/PublicLink/publicLinkVerification.jsp?a=b01LeTN2eWVnbFJyWk1jOXpscDl2dz09](https://server1.qa.emudhra.net/emSign-Subscriber/PublicLink/publicLinkVerification.jsp?a=b01LeTN2eWVnbFJyWk1jOXpscDl2dz09) (conditional mandatory)
URL for tracking the order status.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |\n| orderStatusId | Status ID of the order.
1 - Order Placed
2 - Order Accepted
3 - Order In-Progress
4 - Order Rejected
5 - Order Cancelled
6 - Order Fulfilled
7 - On Hold (Saved to Draft)
8 - Order Pending for Approval
9 - Order Pending for Account Administrator Approval.
This is 'Optional' in case of failure status (0).
This is 'Mandatory' in case of success status (1). |\n| certificateStatusId | (conditional mandatory)
Optional in case of failure.
Mandatory in case of success.
1 - Setup Pending
2 - Pending for Approver
3 - Under Discrepancy
4 - Approved
5 - Rejected
6 - Pending Second Approver
7 - Approved by Second Approver
8 - Rejected by Second Approver
9 - Certificate Downloaded
12 - Certificate Expired
13 - Rejected due to Order Cancellation
14 - Auto Rejected
15 - Order Auto Approved (Certificate Download Instructions email sent)
16 - Pending LRA
17 - Approved LRA
18 - Rejected LRA
19 - Invalid Configuration
20 - Certificate Generated
21 - Download Rejected
22 - Certificate Revoked
23 - Rekey Approved
24 - Pending for Approver (Auto-approval)
25 - Organization Authorization Pending LRA
26 - Organization Authorized LRA
27 - Organization Authorization Rejected LRA |\n| certificateExpiryDate | (conditional mandatory)
Expiry Date of the respective End Entity Certificate in UTC format. This is 'Mandatory', if the certificate is downloaded. |\n\n" + }, + "response": [ + { + "name": "TrackOrder Private PKI", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\":{\r\n \"ver\":\"1.0\",\r\n \"ts\":\"{{ts}}\",\r\n \"txn\":\"{{txn}}\",\r\n \"accountNumber\":\"4391755311\",\r\n \"authKey\":\"{{hash}}\" \r\n },\r\n \"orderDetails\": {\r\n \"orderNumber\":\"9574794842\"\r\n }\r\n}\r\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/TrackOrder", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "TrackOrder" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "171" + }, + { + "key": "Date", + "value": "Tue, 02 Apr 2024 10:58:57 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"meta\": {\n \"ver\": \"1.0\",\n \"ts\": \"2024-04-02T16:28:56+05:30\",\n \"txn\": \"79279ab782b94c74bec8752b24345b52\",\n \"status\": \"0\",\n \"errorCode\": \"EMS-913\",\n \"errorMessage\": \"Invalid Account Number\"\n }\n}" + } + ] + } + ] + }, + { + "name": "DCV", + "item": [ + { + "name": "GetDcv", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "pm.test('Status code is 200', () => {", + " pm.response.to.have.status(200);", + "});", + "", + "if (status === '1') {", + " pm.test('Request Successful', () => {", + " pm.expect(status).to.equal('1');", + " });", + "} else {", + " pm.test('Request FAILED: ' + (errMsg || 'Unknown error'), () => {", + " pm.expect(status).to.equal('1');", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"requestorEmail\": \"{{requestorEmail}}\",\r\n \"orderNumber\": \"{{orderNumber}}\",\r\n \"domainName\": \"{{domainName}}\",\r\n \"dcvMethod\": \"3\"\r\n\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}GetDcv", + "host": [ + "{{baseURL}}GetDcv" + ] + }, + "description": "This API facilitates to get DCV (Domain Control Validation) details for all emSign SSL/TLS & external DNS type Private PKI certificate orders.\n\n| **Region** | **URL** |\n| --- | --- |\n| US | [https://sandbox-us-subscriber.emsign.com/](https://sandbox-us-subscriber.emsign.com/) |\n| Global | [https://sandbox-emsignsubscriber.emudhra.net/](https://sandbox-emsignsubscriber.emudhra.net/) |\n\n**API Endpoint**\n\n| Category | Specification |\n| --- | --- |\n| URL | http(s)://(url host port and path)/GetDcv |\n| Protocol | HTTP / HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"accountNumber \": \"\",\n \"authKey\": \"\"\n },\n \"dcvDetails\": {\n \"requestorEmail\": \"\",\n \"orderNumber\": \"\",\n \"domainName\": \"\",\n \"dcvMethod\": \"\"\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| dcvDetails | (mandatory)
Domain Control Validation details of the respective emSign Certificate Order. Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | (mandatory)
Version should be set to \"1.0\". |\n| ts | (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**DCV Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestorEmail | (mandatory)
Registered Email ID of the certificate requestor associated with the respective emSign Certificate Order. |\n| orderNumber | (mandatory)
Order Number of the respective Certificate Order. |\n| domainName | (mandatory)
Domain Name of the respective Certificate Order. |\n| dcvMethod | (mandatory)
This must contain the 'dcvMethod' value as provided in 'GetDcv' API request JSON.
1: DNS TXT Record based verification
2: HTTP URL / HTTPS URL based verification
3: Email based verification |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n },\n \"dcvDetails\": {\n \"token\": \"\",\n \"fileName\": \"\",\n \"fileContent\": \"\",\n \"dcvEmails\": [\n ]\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| dcvDetails | (mandatory)
Domain Control Validation details of the respective emSign Certificate Order. Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | (mandatory)
Version should be set to \"1.0\". |\n| ts | (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success.
Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success.
Mandatory in case of failure. |\n\n**DCV Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| token | (mandatory)
A unique authorization token (Target Address Value) provided to verify the domain.
This is 'mandatory', if \"dcvMethod\" is set to '1'. |\n| fileName | (conditional mandatory)
File Name of the .txt file which should be created and uploaded in the following directory of the domain. Directory: https://(domain)/.well-known/pki-validation/(fileName).txt.
NOTE: Don't modify the name or the contents of the file. This is 'mandatory', if \"dcvMethod\" is set to '2'. |\n| fileContent | (conditional mandatory)
File Content of the .txt file which should be created and uploaded in the respective directory of the domain.
NOTE: Don't modify the name or the contents of the file. This is 'mandatory', if \"dcvMethod\" is set to '2'. |\n| dcvEmails | (mandatory)
List of CA/B forum approved contact email addresses:
admin@(domain)
administrator@(domain)
webmaster@(domain)
hostmaster@(domain)
postmaster@(domain)
This is 'mandatory', if \"dcvMethod\" is set to '3'. |\n\n" + }, + "response": [ + { + "name": "GetDcv", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\":\"1.0\",\r\n \"ts\":\"{{ts}}\",\r\n \"txn\":\"{{txn}}\",\r\n \"accountNumber\":\"2251641725\",\r\n \"authKey\":\"{{hash}}\" \r\n },\r\n \"orderDetails\": {\r\n \"requestorEmail\": \"nandhakumar.n@emudhra.com\",\r\n \"orderNumber\": \"9319971887\",\r\n \"domainName\":\"emsigndev.emudhra.net\",\r\n \"dcvMethod\": \"1\" \r\n\r\n //1 TXT \r\n //2 HTTPS\r\n //3 Email base\r\n }\r\n}\r\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/GetDcv", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "GetDcv" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "171" + }, + { + "key": "Date", + "value": "Tue, 02 Apr 2024 11:04:21 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"meta\": {\n \"ver\": \"1.0\",\n \"ts\": \"2024-04-02T16:34:19+05:30\",\n \"txn\": \"747a5358e2024475b941b44be424e3b4\",\n \"status\": \"0\",\n \"errorCode\": \"EMS-913\",\n \"errorMessage\": \"Invalid Account Number\"\n }\n}" + } + ] + }, + { + "name": "VerifyDcv", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "pm.test('Status code is 200', () => {", + " pm.response.to.have.status(200);", + "});", + "", + "if (status === '1') {", + " pm.test('Request Successful', () => {", + " pm.expect(status).to.equal('1');", + " });", + "} else {", + " pm.test('Request FAILED: ' + (errMsg || 'Unknown error'), () => {", + " pm.expect(status).to.equal('1');", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"dcvDetails\": {\r\n \"requestorEmail\": \"{{requestorEmail}}\",\r\n \"orderNumber\": \"{{orderNumber}}\",\r\n \"domainName\": \"{{domainName}}\",\r\n \"dcvMethod\": \"3\",\r\n \"dcvEmail\": \"{{requestorEmail}}\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}VerifyDcv", + "host": [ + "{{baseURL}}VerifyDcv" + ] + }, + "description": "This API facilitates to verify DCV (Domain Control Validation) for emSign SSL - DV / OV / EV & external DNS type Private PKI certificate orders.\n\n| **Region** | **URL** |\n| --- | --- |\n| US | [https://sandbox-us-subscriber.emsign.com/](https://sandbox-us-subscriber.emsign.com/) |\n| Global | [https://sandbox-emsignsubscriber.emudhra.net/](https://sandbox-emsignsubscriber.emudhra.net/) |\n\n**API Endpoint**\n\n| Category | Specification |\n| --- | --- |\n| URL | http(s)://(url host port and path)/VerifyDcv |\n| Protocol | HTTP / HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"accountNumber \": \"\",\n \"authKey\": \"\"\n },\n \"dcvDetails\": {\n \"requestorEmail\": \"\",\n \"orderNumber\": \"\",\n \"domainName\": \"\",\n \"dcvMethod\": \"\", \n \"dcvEmail\": \"\" \n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| dcvDetails | (mandatory)
Domain Control Validation details of the respective emSign Certificate Order.
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | 1234567890 (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | 6288275133 (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | 952a95057654aa1c26a0f287b6368579f5f3ec99f213543df5e0f7eb7f798b3d (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**DCV Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestorEmail | (mandatory)
Registered Email ID of the certificate requestor associated with the respective emSign Certificate Order. |\n| orderNumber | 787624625 (mandatory)
Order Number of the respective Certificate Order. |\n| domainName | emsign.com (mandatory)
Domain Name of the respective Certificate Order. |\n| dcvMethod | 3 (mandatory)
This must contain the 'dcvMethod' value as provided in 'GetDcv' API request JSON.
1: DNS TXT Record based verification
2: HTTP URL / HTTPS URL based verification
3: Email based verification |\n| dcvEmail | [johndoe@example.com](https://johndoe@example.com) (conditional mandatory)
This value should contain any of the CA/B forum approved contact email address as received in 'GetDcv' API response JSON. An email with verification code will be sent to the provided contact email address to verify the domain.
This is 'mandatory', if \"dcvMethod\" is set to \"3\". |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | 1234567890 (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | 1 (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |" + }, + "response": [ + { + "name": "VerifyDcv", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{authKey}}\"\r\n },\r\n \"dcvDetails\": {\r\n \"requestorEmail\": \"anand.s@emudhra.com\",\r\n \"orderNumber\": \"5917192968\",\r\n \"domainName\": \"emsigndev.emudhra.net\",\r\n \"dcvMethod\": \"3\",\r\n \"dcvEmail\": \"nandhakumar.n@emudhra.com\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}VerifyDcv", + "host": [ + "{{baseUrl}}VerifyDcv" + ] + } + }, + "_postman_previewlanguage": "Text", + "header": [], + "cookie": [], + "body": "" + } + ] + } + ] + }, + { + "name": "Submit Document & CSR", + "item": [ + { + "name": "SubmitCSR", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "pm.test('Status code is 200', () => {", + " pm.response.to.have.status(200);", + "});", + "", + "if (status === '1') {", + " pm.test('Request Successful', () => {", + " pm.expect(status).to.equal('1');", + " });", + "} else {", + " pm.test('Request FAILED: ' + (errMsg || 'Unknown error'), () => {", + " pm.expect(status).to.equal('1');", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"requestorEmail\": \"{{requestorEmail}}\",\r\n \"orderNumber\": \"{{orderNumber}}\",\r\n \"csr\": \"-----BEGIN CERTIFICATE REQUEST----- MIICmTCCAYECAQAwIDEeMBwGA1UEAxMVZW1zaWduZGV2LmVtdWRocmEubmV0MIIB IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApk8Q3msMEU9qk4nuVIQHbG5Z yS/zYJYMwXstqzehYVtRBL2Ff179f6iOdU3c8h80u2bKN/lTAzacTuLdApgWhcbi gChS9PERnZRhID4aVU62opKVSyqKnqx1cllD04QSw/Uz/pyaurQeII8vOTJ4g/kR aEOY9+4iwXoKOuxMWZtdQIUhNCa7D3iUBXhAY0EXx1speqKhmT9oXsbWkk8TLKi4 Rr1vQu0pT9BvxDgl/fotyNiva43Fo2437J7qag+Itc1pno7tmvKKYJ3XCfX8VdoG bvLOZR/QbbmdNLnc52V8UFjsoV5ko/IxDI0g91/HpgiaveG1L67k0BFK5TpBNwID AQABoDQwMgYJKoZIhvcNAQkOMSUwIzAOBgNVHQ8BAf8EBAMCBaAwEQYJYIZIAYb4 QgEBBAQDAgZAMA0GCSqGSIb3DQEBCwUAA4IBAQCCDQy3OB3FDaN2UQCkalE24t6a JSt/qi135sGYo6LWrxRhPBSZ1Tob6C+4SvrRmB24mIu+/0xSVKRdJZ9tC3ZcDnN+ C7V3byNq8bskvQrvwTIjjpcCpMiawD2cDY3sv0MKOcO7l7RB97D+VV+yOLBpsirW UcQCkW2oMFUWJaJsduzOgc7iSuwk7TLKMz4D0zgzSOIWgvV1KrqomEsD+rQVUiBu ib+kpt96ZgMf+Yf3S6/L52KkUDS4+1dUyqH0nP3FTiQirkdUhtiIARFHnk8EtbJ3 rGzxd/Wdi7wUMpt9qP27dczJ93TAUdcGIjtfuPqsMj+KMn5oxNk2PIVHH7kV -----END CERTIFICATE REQUEST-----\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}SubmitCSR", + "host": [ + "{{baseURL}}SubmitCSR" + ] + }, + "description": "This API facilitates to submit CSR (attested or normal) for the below certificates.\n\n- All emSign SSL Certificates\n \n- All emSign S/MIME Certificate\n \n- All signature Certificates\n \n\n[Procedure to generate attested CSR](https://docs.emsign.com/emsign-certhub/certificate-utility-tools/emudhra-emsign-click-tool/procedure-for-attested-csr-generation) \n[Procedure to generate normal CSR](https://docs.emsign.com/emsign-certhub/certificate-utility-tools/emudhra-certificate-utility-tool)\n\n| **Region** | **URL** |\n| --- | --- |\n| US | [https://sandbox-us-subscriber.emsign.com/](https://sandbox-us-subscriber.emsign.com/) |\n| Global | [https://sandbox-emsignsubscriber.emudhra.net/](https://sandbox-emsignsubscriber.emudhra.net/) |\n\n**API Endpoint**\n\n| Category | Specification |\n| --- | --- |\n| URL | http(s)://(url host port and path)/SubmitCSR |\n| Protocol | HTTP/ HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"accountNumber\": \"\",\n \"authKey\": \"\" \n },\n \"orderDetails\": {\n \"requestorEmail\": \"\",\n \"orderNumber\": \"\",\n \"csr\": \"\"\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order. Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | (mandatory)
Version should be set to \"1.0\". |\n| ts | (mandatory)
Time stamp of request in ISO 8601 format.
Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestorEmail | (mandatory)
Registered Email ID of the certificate requestor associated with the respective emSign Certificate Order. |\n| orderNumber | (mandatory)
Order Number of the respective Certificate Order. |\n| csr | (mandatory)
CSR means Certificate Signing Request. Please note, Domain Name value provided in order details must match with Common Name (CN) value provided in the CSR.**
NOTE:** Submitted CSR must be \"Attested CSR\" for Signature Certificates. To generate \"Attested CSR\", please utilize [emSign Click Tool ](https://docs.emsign.com/emsign-certhub/certificate-utility-tools/emudhra-emsign-click-tool) 
Submitted CSR can be \"Normal CSR\" for TLS and S/MIME Certificates.
[Procedure to generate attested CSR](https://docs.emsign.com/emsign-certhub/certificate-utility-tools/emudhra-emsign-click-tool/procedure-for-attested-csr-generation)
[Procedure to generate normal CSR](https://docs.emsign.com/emsign-certhub/certificate-utility-tools/emudhra-certificate-utility-tool) |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | (mandatory)
Version should be set to \"1.0\". |\n| ts | (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success.
Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success.
Mandatory in case of failure. |" + }, + "response": [ + { + "name": "SubmitCSR", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{authKey}}\"\r\n },\r\n \"orderDetails\": {\r\n \"requestorEmail\": \"abcd@emudhra.com\",\r\n \"orderNumber\": \"5917192968\",\r\n \"csr\": \"-----BEGIN CERTIFICATE REQUEST----- MIICmTCCAYECAQAwIDEeMBwGA1UEAxMVZW1zaWduZGV2LmVtdWRocmEubmV0MIIB IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApk8Q3msMEU9qk4nuVIQHbG5Z yS/zYJYMwXstqzehYVtRBL2Ff179f6iOdU3c8h80u2bKN/lTAzacTuLdApgWhcbi gChS9PERnZRhID4aVU62opKVSyqKnqx1cllD04QSw/Uz/pyaurQeII8vOTJ4g/kR aEOY9+4iwXoKOuxMWZtdQIUhNCa7D3iUBXhAY0EXx1speqKhmT9oXsbWkk8TLKi4 Rr1vQu0pT9BvxDgl/fotyNiva43Fo2437J7qag+Itc1pno7tmvKKYJ3XCfX8VdoG bvLOZR/QbbmdNLnc52V8UFjsoV5ko/IxDI0g91/HpgiaveG1L67k0BFK5TpBNwID AQABoDQwMgYJKoZIhvcNAQkOMSUwIzAOBgNVHQ8BAf8EBAMCBaAwEQYJYIZIAYb4 QgEBBAQDAgZAMA0GCSqGSIb3DQEBCwUAA4IBAQCCDQy3OB3FDaN2UQCkalE24t6a JSt/qi135sGYo6LWrxRhPBSZ1Tob6C+4SvrRmB24mIu+/0xSVKRdJZ9tC3ZcDnN+ C7V3byNq8bskvQrvwTIjjpcCpMiawD2cDY3sv0MKOcO7l7RB97D+VV+yOLBpsirW UcQCkW2oMFUWJaJsduzOgc7iSuwk7TLKMz4D0zgzSOIWgvV1KrqomEsD+rQVUiBu ib+kpt96ZgMf+Yf3S6/L52KkUDS4+1dUyqH0nP3FTiQirkdUhtiIARFHnk8EtbJ3 rGzxd/Wdi7wUMpt9qP27dczJ93TAUdcGIjtfuPqsMj+KMn5oxNk2PIVHH7kV -----END CERTIFICATE REQUEST-----\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}SubmitCSR", + "host": [ + "{{baseUrl}}SubmitCSR" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "128" + }, + { + "key": "Date", + "value": "Thu, 04 Apr 2024 06:20:43 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"meta\": {\n \"ver\": \"1.0\",\n \"ts\": \"2024-04-04T11:50:41+05:30\",\n \"txn\": \"829117045589503600\",\n \"status\": \"1\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n }\n}" + } + ] + }, + { + "name": "SubmitDocument", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "pm.test('Status code is 200', () => {", + " pm.response.to.have.status(200);", + "});", + "", + "if (status === '1') {", + " pm.test('Request Successful', () => {", + " pm.expect(status).to.equal('1');", + " });", + "} else {", + " pm.test('Request FAILED: ' + (errMsg || 'Unknown error'), () => {", + " pm.expect(status).to.equal('1');", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"documentDetails\":{\r\n \"requestorEmail\": \"{{requestorEmail}}\",\r\n \"orderNumber\": \"{{orderNumber}}\", \r\n \"numberOfDocuments\":\"1\",\r\n \"documentsAttached\":[\r\n {\r\n \"id\":\"1\",\r\n \"fileName\":\"Registation.pdf\",\r\n \"description\":\"Registation Copy\",\r\n \"base64Value\":\"JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9Db2xvclNwYWNlL0RldmljZUdyYXkvU3VidHlwZS9JbWFnZS9IZWlnaHQgNDUvRmlsdGVyL0ZsYXRlRGVjb2RlL1R5cGUvWE9iamVjdC9XaWR0aCAxNjYvTGVuZ3RoIDI5ODcvQml0c1BlckNvbXBvbmVudCA4Pj5zdHJlYW0KeJzNWXlc1NUWPzMMgyA7ImCJGwSipJaJgiLgguwuESIipam4r6UmKWaK4nONNBWJegoEkii4FaaCSmFAogI%2BFUhAQWVTdhjuO%2Bf%2BZmQoPq%2F8PD7S%2BWPOudu539%2B5Z7m%2F3wD8mcQ9h%2FstmGyu0sHQP4ZUbUMvn1xhISJZrG1kYqje1Yj%2BTGK3xKarfqooqVpN%2FSQ84WJqcuyuADNRV%2BNqRyPiWIEvGc902cn8ViYnWe7OAV2NrI3U1z5lYUYoDD5U1MKqclLOpt1rEoDmenY1OAX1Oc3qPsSYMYt8Wn5qsY2xjqaGlt7g%2BSktBLN8WlfDE8j2Fit1A9BZdueifw%2BlfrH3HYJZZNN5W702eeqU6VYAdt6TvR0lL7PS4xErcQAYtOMb2z8OmaUSzHjtTkO5hPxoFUizkZ3ReImFU6pYuQuAsY99B4MDbhJMt84CCbtQW5MnvPYU%2BfaXyMtTKljDDOTa3Tsc9qKH%2F7pb52AEOEEeNAJsyOPn%2F%2F1lTqWMBSkamiP8Vgd%2FNGOIatu4%2BCTqe2DcSSC1f0ZtGabgT7lu%2FN9eZnWXsSg1LkpH7M9vaJa1yprrMhfpvpgxjY5c4Qw9Jqzdti3Y10xMDaPxzi6jJDBgTsiOlWbYNgvcFhpoioKKnfME1zdoylsuE1yHUmHQdwsO3eAElv9BZael8BmySivoM2tr6HILHO7n7OzSD4YGb3fFhrqVz%2FptISGbFtjJj1c3mbHbfbg4MKKRtVGqlQLl60XY9Oditzk5wmj1Rsr%2Fi2WMnZcEkouxfAvRiick3B6ELk7SalrwCwoRKLhk8HWhnnhy7BBADLKr%2FQNLqLMYTbCHsVb%2FYY8ZO4wpO7pegeL0a3zfHTjqyyWP31k7yhkoR6mViK3lJGkepiQfnfQM2UZsb0ces0c%2BP%2BaAXNgN8A55nRdZlzLZJwABdcJYQ0EN%2Fq4BEaFPiZWb5QxAPHqVP1qsoC%2BMJOgPE49dbEa%2Bi%2Fad9JyxSJ613q3iC6ouRCWUCGu%2Fl6cf1b3YWEnSThQSsGDORxtW9gWIY6ylvj4%2BrJTPb4yPeET8hBp4E6ChuGIsDtXPhLfJto1Ry77im9T4ggmdD6uPjuQ8R0vtGmOFqRWRkR8CrLv3UJYxWgS6kTh0nYyTho%2FAU%2Faoh3yn797RV9cyO8BLDpNXRvFWlHE1jMO97pAjmKDOlvdA7zIOPJsngnfpqZs%2F0IA5%2BNDsuCoEISsgB52F1iodC0eZ%2FEGXkVAyCkaWU6i7SMCbjPybiil5UvFUUBWDyLDfQJv%2BtPFq7MtEvhDh7KUOgx84rl1CMVA5wVsHJW0oKWEeRB4OEqnIMAn9ZDFY5WFHNEURGfM7nN2PYmMfwNfIfjLAkTUo5PUywQhlydTmS3JMwK9O8AQwo3R8FuxJw3aloNawmpNQIThDL0wLj4ZQ53wO65wiK7rWUjNTiDDpl4zVogl7oOHZk%2BycnFt56N2yeeCIYdO8FGcMasCROSgMpwNcBUAFK6Kb%2FMF%2BAS8ycTDpMidQKSLYgImo2gE7rAuxIwx8UWOZg9zD%2BngFJ5aymiICsQdgNrJjPNJvEKpqR8WTGHHXfGAoRM9pxrL0MPTQUM15v2RkZGReT09PcwBfnFOJRQvcaTYtfhfh1PmAXj62P8W29jkU4mAp%2FrbMIl2jK1GMBIhAdotSlSO6RMsqWEVG0eHbGX5ehq6bum%2Bq%2BS3sXAi6p9DHaRdhG3aqzeA5Sih7PcC4xeN8C%2FcudxXp6uhoamrr6IhgHfmSJc5YTmE3QO52xbZgTUGyBNv2dGg7uIO1fkC6aEnrBhBdQv4jpeT3Uah1hTBkscLJhaOY5SRFZ6B8NAnskeXxQw7nKE%2FMCpzPae4SnvluavF141CkXNsnCxUKeZPf30VfkUFoDuWgbLqcUja4aSo4zBkD6P8jhfYCWCgjEOg%2FbpgQWR2GOJnpW1ISTOfxhuh7ZKFc9Rj02MYAkj6luQNhg%2BDqmHuzhVTS0CRQo3D%2FPSxEz27G0nqRQMeU1htEM7KuJe%2FXA90kcmUaSKaD0JZH3SUJeFFstGYmPShEP612htEUG63n139dVokHXDEcRhZjx2ZcqU7Z5r7UEB25aRHfbQalCzwH8WLKrHeMdCmRvMd9upp1QE9G8mUG%2BUIZAbChvF9yNgOfoREvCv0pEg6R495ngk8YX0UhCvU9FTT8PA2nlKH7xcpVRlGKemgI09HYrXNxac9r9FwwpABTGvc9cKBIrDp6IKeQylOKngWCbRxEI448pJuKlOhBbryd4KILWlm2mSA6Z1TT2TU9SR6NrTcLaqsqyADmRdWV6OYYsDcaqp%2BtR2EtpkPZkyidsWU1z9PRJ8x%2FQus2P9om2sgqn%2F8ggqW11bUF48iLcmorn4eBXXHt83tCrZOGEBhZ%2BWkzh%2BeV9cl6U7CV35NGfAg%2FuzJ0sLWC3rTUkseR1q9CYuOk7bFy85Z1s4dzV9B3d3f1pDqr7%2Brm5mlCwiRPN49%2BKIgdP92yEsuHsae7xyiaq%2BGzccuiwSAeOsXFcziAhZebx0QKac1JHi5eFtDT3cN9nHDlAbH9x1uC57%2BlAgburl5OamT9dB79M3k9jenwfr9Wxq7qdjTwiugbRHaBm8yZ3wVS1NrGJAsjZnLQ1vfZM%2FlF3Xru%2BwGvmuZankVklzlKS7rksIp%2BL0AaxzSxGgcU1NDvP5d3ukZFhL9iOhzjdBGR3eCHqXqdR%2BCLOjr6N3JgZ5SwapxUmFgs7QISU%2F5%2FxBMhLw6YQhdR4Rb1XU9ljCXjK%2B%2F4CpZu2smO9pJ0nrAI6YbXAkwxZ1bMXryPv4Kzh5hshtxl2Yq7MHSfEzQvaLnB%2F9Ko36Nd02ntxmntv4eZB9DZ9V%2FS629CnOiqQmkfr82Cwid%2FSOmFE%2FCalcVuWr5Y0SM0T3bloIBDrHhFFT51yX1iQIKT8hYqWYWnqj4SZsjf9tZl6%2BPvmHjFpyepMFGxQiwRPvcp2pLkA%2BJ1PEnKp4y7r4yx9RiiG5TFLpsrbQqflPal3UzjksIw%2FntGT%2BibQHdi%2FZ0pl8Yg10xkW5VRmpbPg7BiyqM99v92RI9AJZwkEB8fwF%2FDb93gGJUC0ZqMxME0f%2Ba5S5OQWcXe2MTXf%2FTr8ad%2B4Mx9cYxcZ%2B%2BQwkYZR9jy%2BKg93kFG3GWR7c83OpWfn1fdUbqT2tba%2BFWS0%2FrUBG1Ey4NGYo6F8nT30iGwuoJm7M%2Bd%2Fjvd1HXzaH%2FNFLpn6F8PmlH%2BJgrzymal0FVaPb58KWZv7eTE2Y%2FID11L%2FCNbh4ExN9%2BxF1p1HFfuDD%2Byd60H3W4goKpyYftPlxrpu3nHtnv8w0ngbdh3TRMF8%2BOFsa8j17r0Rbv5m%2B6B5OwVvIQYpOUeuU2vd5bl9MZmUryMjmbnr7kraNqx6iNZdBswKviMmkPK0yLuUPGMvAJrCvuAZDehrPaADsg6SnbcUsljiGyqhWtbRhxne7J9SnahC2l8vnJLzVTa%2FdmhdjouPvWPapiMguqF5FXfkU0Dfqf3mXFVDtwMLIEXvr35ixKoY1Q9XQ%2Bhf0HE5i%2FpOnmgcFrucfR4O57M03v%2FCeMb%2FypOm4au93a7D6w2MTyUpAe9eHNicvRxfrlfdDFpMxWH7hHxyqokIUmpZwQT2H2fvI4qmc9OCowJ%2B8lHYXC88Omsb%2FiFL%2Bj07A%2FxM4T3zp33Iz4w4fwpH%2BSiMB4qse3LtMR2%2B4U4X1TUe9M8HeUBVSGUxd0ERxAbaouFaDQUvjpANxNl24u7qXdX3Az0X%2BdMjYe6VK5HMVlNWC5VVA8j4RUBtHoK%2BgcKXyoSjNp0iyxmLvexRm26QVlLO%2B974P9F3vzM2U33FxcNkYYUH0A6aEdZlkMXAmtPi4UvI7ITXqaKKiE1GhZ4ofVxyD%2FEkJxmF8kT%2Bd1%2Fb%2FjQ23N64KYj6Q3s8ZFhXQ2sPdkmyZRqDv%2B9v9Xmn%2FUnD5L65LgaJZx3D3mbiv961asndQu%2FHSevZWZeObVn7ggjtb9e8Crpv8blqS0KZW5kc3RyZWFtCmVuZG9iagoyIDAgb2JqCjw8L0ZpbHRlci9GbGF0ZURlY29kZS9BbHRlcm5hdGUvRGV2aWNlUkdCL0xlbmd0aCAyNTk1L04gMz4%2Bc3RyZWFtCnicnZZ3VFTXFofPvXd6oc0wFClD770NIL03qdJEYZgZYCgDDjM0sSGiAhFFRAQVQYIiBoyGIrEiioWAYMEekCCgxGAUUVF5M7JWdOXlvZeX3x9nfWufvfc9Z%2B991roAkLz9ubx0WAqANJ6AH%2BLlSo%2BMiqZj%2BwEM8AADzABgsjIzAkI9w4BIPh5u9EyRE%2FgiCIA3d8QrADeNvIPodPD%2FSZqVwReI0gSJ2ILNyWSJuFDEqdmCDLF9RsTU%2BBQxwygx80UHFLG8mBMX2fCzzyI7i5mdxmOLWHzmDHYaW8w9It6aJeSIGPEXcVEWl5Mt4lsi1kwVpnFF%2FFYcm8ZhZgKAIontAg4rScSmIibxw0LcRLwUABwp8SuO%2F4oFnByB%2BFJu6Rm5fG5ikoCuy9Kjm9naMujenOxUjkBgFMRkpTD5bLpbeloGk5cLwOKdP0tGXFu6qMjWZrbW1kbmxmZfFeq%2Fbv5NiXu7SK%2BCP%2FcMovV9sf2VX3o9AIxZUW12fLHF7wWgYzMA8ve%2F2DQPAiAp6lv7wFf3oYnnJUkgyLAzMcnOzjbmcljG4oL%2Bof%2Fp8Df01feMxen%2BKA%2FdnZPAFKYK6OK6sdJT04V8emYGk8WhG%2F15iP9x4F%2BfwzCEk8Dhc3iiiHDRlHF5iaJ289hcATedR%2Bfy%2FlMT%2F2HYn7Q41yJRGj4BaqwxkBqgAuTXPoCiEAESc0C0A%2F3RN398OBC%2FvAjVicW5%2Fyzo37PCZeIlk5v4Oc4tJIzOEvKzFvfEzxKgAQFIAipQACpAA%2BgCI2AObIA9cAYewBcEgjAQBVYBFkgCaYAPskE%2B2AiKQAnYAXaDalALGkATaAEnQAc4DS6Ay%2BA6uAFugwdgBIyD52AGvAHzEARhITJEgRQgVUgLMoDMIQbkCHlA%2FlAIFAXFQYkQDxJC%2BdAmqAQqh6qhOqgJ%2Bh46BV2ArkKD0D1oFJqCfofewwhMgqmwMqwNm8AM2AX2g8PglXAivBrOgwvh7XAVXA8fg9vhC%2FB1%2BDY8Aj%2BHZxGAEBEaooYYIQzEDQlEopEEhI%2BsQ4qRSqQeaUG6kF7kJjKCTCPvUBgUBUVHGaHsUd6o5SgWajVqHaoUVY06gmpH9aBuokZRM6hPaDJaCW2AtkP7oCPRiehsdBG6Et2IbkNfQt9Gj6PfYDAYGkYHY4PxxkRhkjFrMKWY%2FZhWzHnMIGYMM4vFYhWwBlgHbCCWiRVgi7B7scew57BD2HHsWxwRp4ozx3nionE8XAGuEncUdxY3hJvAzeOl8Fp4O3wgno3PxZfhG%2FBd%2BAH8OH6eIE3QITgQwgjJhI2EKkIL4RLhIeEVkUhUJ9oSg4lc4gZiFfE48QpxlPiOJEPSJ7mRYkhC0nbSYdJ50j3SKzKZrE12JkeTBeTt5CbyRfJj8lsJioSxhI8EW2K9RI1Eu8SQxAtJvKSWpIvkKsk8yUrJk5IDktNSeCltKTcpptQ6qRqpU1LDUrPSFGkz6UDpNOlS6aPSV6UnZbAy2jIeMmyZQplDMhdlxigIRYPiRmFRNlEaKJco41QMVYfqQ02mllC%2Fo%2FZTZ2RlZC1lw2VzZGtkz8iO0BCaNs2Hlkoro52g3aG9l1OWc5HjyG2Ta5EbkpuTXyLvLM%2BRL5Zvlb8t%2F16BruChkKKwU6FD4ZEiSlFfMVgxW%2FGA4iXF6SXUJfZLWEuKl5xYcl8JVtJXClFao3RIqU9pVllF2Us5Q3mv8kXlaRWairNKskqFylmVKVWKqqMqV7VC9ZzqM7os3YWeSq%2Bi99Bn1JTUvNWEanVq%2FWrz6jrqy9UL1FvVH2kQNBgaCRoVGt0aM5qqmgGa%2BZrNmve18FoMrSStPVq9WnPaOtoR2lu0O7QndeR1fHTydJp1HuqSdZ10V%2BvW697Sw%2Bgx9FL09uvd0If1rfST9Gv0BwxgA2sDrsF%2Bg0FDtKGtIc%2Bw3nDYiGTkYpRl1Gw0akwz9jcuMO4wfmGiaRJtstOk1%2BSTqZVpqmmD6QMzGTNfswKzLrPfzfXNWeY15rcsyBaeFustOi1eWhpYciwPWN61olgFWG2x6rb6aG1jzbdusZ6y0bSJs9lnM8ygMoIYpYwrtmhbV9v1tqdt39lZ2wnsTtj9Zm9kn2J%2F1H5yqc5SztKGpWMO6g5MhzqHEUe6Y5zjQccRJzUnplO90xNnDWe2c6PzhIueS7LLMZcXrqaufNc21zk3O7e1bufdEXcv92L3fg8Zj%2BUe1R6PPdU9Ez2bPWe8rLzWeJ33Rnv7ee%2F0HvZR9mH5NPnM%2BNr4rvXt8SP5hfpV%2Bz3x1%2Ffn%2B3cFwAG%2BAbsCHi7TWsZb1hEIAn0CdwU%2BCtIJWh30YzAmOCi4JvhpiFlIfkhvKCU0NvRo6Jsw17CysAfLdZcLl3eHS4bHhDeFz0W4R5RHjESaRK6NvB6lGMWN6ozGRodHN0bPrvBYsXvFeIxVTFHMnZU6K3NWXl2luCp11ZlYyVhm7Mk4dFxE3NG4D8xAZj1zNt4nfl%2F8DMuNtYf1nO3MrmBPcRw45ZyJBIeE8oTJRIfEXYlTSU5JlUnTXDduNfdlsndybfJcSmDK4ZSF1IjU1jRcWlzaKZ4ML4XXk66SnpM%2BmGGQUZQxstpu9e7VM3w%2FfmMmlLkys1NAFf1M9Ql1hZuFo1mOWTVZb7PDs0%2FmSOfwcvpy9XO35U7keeZ9uwa1hrWmO18tf2P%2B6FqXtXXroHXx67rXa6wvXD%2B%2BwWvDkY2EjSkbfyowLSgveL0pYlNXoXLhhsKxzV6bm4skivhFw1vst9RuRW3lbu3fZrFt77ZPxeziayWmJZUlH0pZpde%2BMfum6puF7Qnb%2B8usyw7swOzg7biz02nnkXLp8rzysV0Bu9or6BXFFa93x%2B6%2BWmlZWbuHsEe4Z6TKv6pzr%2BbeHXs%2FVCdV365xrWndp7Rv2765%2Fez9QwecD7TUKteW1L4%2FyD14t86rrr1eu77yEOZQ1qGnDeENvd8yvm1qVGwsafx4mHd45EjIkZ4mm6amo0pHy5rhZmHz1LGYYze%2Bc%2F%2Bus8Wopa6V1lpyHBwXHn%2F2fdz3d074neg%2ByTjZ8oPWD%2FvaKG3F7VB7bvtMR1LHSGdU5%2BAp31PdXfZdbT8a%2F3j4tNrpmjOyZ8rOEs4Wnl04l3du9nzG%2BekLiRfGumO7H1yMvHirJ7in%2F5LfpSuXPS9f7HXpPXfF4crpq3ZXT11jXOu4bn29vc%2Bqr%2B0nq5%2Fa%2Bq372wdsBjpv2N7oGlw6eHbIaejCTfebl2%2F53Lp%2Be9ntwTvL79wdjhkeucu%2BO3kv9d7L%2B1n35x9seIh%2BWPxI6lHlY6XH9T%2Fr%2Fdw6Yj1yZtR9tO9J6JMHY6yx579k%2FvJhvPAp%2BWnlhOpE06T55Okpz6kbz1Y8G3%2Be8Xx%2BuuhX6V%2F3vdB98cNvzr%2F1zUTOjL%2Fkv1z4vfSVwqvDry1fd88GzT5%2Bk%2FZmfq74rcLbI%2B8Y73rfR7yfmM%2F%2BgP1Q9VHvY9cnv08PF9IWFv4FA5jz%2FAplbmRzdHJlYW0KZW5kb2JqCjMgMCBvYmoKPDwvQ29sb3JTcGFjZVsvSUNDQmFzZWQgMiAwIFJdL1N1YnR5cGUvSW1hZ2UvSGVpZ2h0IDQ1L0ZpbHRlci9GbGF0ZURlY29kZS9UeXBlL1hPYmplY3QvV2lkdGggMTY2L1NNYXNrIDEgMCBSL0xlbmd0aCAzMDYyL0JpdHNQZXJDb21wb25lbnQgOD4%2Bc3RyZWFtCnic7VzpUxvnGf8P%2BqnTzjSd2GA7R5PYxIBAIIEFQiRODLFxAhgfnFohpBU6d4UQCOwYMBACRhKgA90nlwHjKyFOMk7aNM1M%2B6kzbfKhnfZT02RS27Gdgz6vhKQViCtoLIZo5jc78rvP%2B1y%2F93je3cWKIyOKOEGerxfnasnC0bajJg95dTtwk9e%2BbD50V5T6P9H%2BJOKLeBA9IsnV4VmXgevzJRar%2BMqE%2BlqS8R2LbdItYeia6dreCmfP63ZD49Ts%2BXe8yoVt0p1kfGcyLsvTC2nDvRWOEe7EpPr6VPsNf%2Bt2p3aS8R3LuDhHe6HUYhHNADuTbdfjRXQYLvL6l81pScZ3AuNSpl79sskhm3Mp5v2qa3FZw1dhwU%2FM%2FLf5YJLxBDPOggpNC3S7FfO%2BlqtUrr3kwoT83Wgs%2BhU3PeT8T2Dcrnzvn2LWfdGTCU%2FOrsQWNm6mTvOKydey4G1ZCGy1837FrSnZ7WnZbR9xfbCrb%2BjiW2G83dVr0JivSO7Ara3yHmC8IMl4whlvzh42C6Yn2697iQUgelby8UjnyJsD8p4%2BdeeguNqdWevKDuOcJ0NkLunuV9lafROKdzZPt5ecNyt%2F%2Fw8xJ8l4whkX0%2FTG2rkZ8ra7ZVala9BcFgjHjwWIptc5c3g2Ns9WSAGbaz8CvIuNZU7VzKR8cdMTfPFjmfIb0dPJTTyxjBPZZpwvFo2XyAzlQGKdM7femQucriJ6Bdj1TqbIXErqq2Fl2AzjFuWdv4nLHuG%2FTnhmdis2R%2Fe45Gwb5s%2FFHPlcO8K6LK8EDAwYHi26umnZ%2B%2BvT7SYXnOTNv0uOP8CfSHhmdis2ZjxvjKCbRXI%2B5mNsieho0vMlxpO2Vr%2BXWOfwPj%2Bu%2FOgPMtl3%2BC8TnpZdjA3ozh8BunGCh03k8KzrL%2BAboMaVpRkSQr23VunuIm9MEv4vxK9%2BK%2FptwtOyi7ER46M4icF6vk26g9NcZCod6zDCmW6tQ9kH8vM%2F4r%2F4RnQg4WnZxdhgB2cYENf2gm3SHaziqt0ZcJSbkr23FuPvyvse4b9KeE52N9avzwUXzvAcrHjQjQDn9N7ezslYjMOSPktYvxb9LnkoSxjj%2BaMKtpY%2FXMpzHnk8jF8ljA9Fv0l4QnY9YtPN0pOHHUJNDeb%2F6fX5VhmfJ0zJmi1RjBMMo%2FR4D5rgrrhN8CTjOwQxGScz7KJmnDuTtf0SPbpyy%2Bzpa4tZuSUZTyDjRK5RWn6hcZwTx5oNUO9gKHW1buWcl4jxqUyS8a3iHh4%2FxnPM4hoVd5IeR7oBDQ7mmwOKaWnsB62oclOYvhc88UC4b%2FOx3A3g8WQ4XoZi6rm7diwxG78V7vuP5MB9fB%2BkKx6Mm8Tn1OipSzwnOFNqKJ%2BS3V77ifrNeYXzry2pf1E991XzgSV%2Byr01goUYl5pSf2hKXWpKWeLtfRQIOZyx%2B%2Fj%2BJXQLAX7fpSYT3%2F8DdEFIpfZCCcT3LQUVNqUEe1ETfg91TAUZqqGAGyk%2FBjy5t4ojUPKdIJUqDzIg%2FGO0VyDwMBDOUuNeiOU7Yerd6C5ggmp32dXGlK%2FFB1SGvA86Dn5BPAO5eriKdySP7%2F8%2B4BtV5w%2BC1MfCeAFs4j39bYGvYmIzPilb7OnTVHlYr3uLTQNZH2kOPuKnoDzAlQrunn9Lnrp1Ie1256E7mkOftT4PLZCrZWB77gn2fdCRBrfe70y7C8nEKHe5ez5TPf%2Bh5tDtzrTPFc9GOmJ7vhTtX%2BxMu9N%2BEPAIjFLt8vbeb0r5k3qloa9wMHQIsHg%2B7QGiLIXa5QE%2F5dPoLiBz68KLn8ufXWqIculf0qffA9Oag5%2B0vRDlLW%2Fvt02pf1Y9t8R9kmr3a3z%2FJ20Hu%2FSMs66iM64ioaUAUvGF4hnUl5ooHuTqyT%2Bqn38Y8Cfc%2FVP1C4%2BBcczOEplKAxM89hN12Nm7%2B9V1LthH2I22wmpnUaWH4%2BzN9F3KcPdmUnGlK71Xm1vme6nKzanwcLj2wivd6f6ejCCmutNNb2XBmDnl5gRHzhTl7kxXOmYrrPAUn%2FQWtxry5y4eDrdfHqKDTugFdl19ND%2FFLghY%2B7ME1oK5rihVukF60I1KT7G9j%2Ba9lEntAi18a%2BGVUJeJ7gxbH%2B2E7yUVxS5g9uLhLh0D2ivdHIh6KtSOuiC7tCZrwSzF7nRXun6QDvK1DpQoHnp2XQhudIwywVbYAQ%2BkricDWvi2QmcfbSLcvTu93s5%2BPIzj5pJ13ppNyhebxl%2FG7FEnwSCnb3iLqQhmpjEkA%2FECTVScdi3fhStMgUrKLfgN8sFbkLFKSvtZV0TnaRenwh2xC7%2FPuDgNdvapaFXULlUuTrkn4uSpUJfKKMc4MAaodoN6wuFggZCjYwGH17PLs0XCKadkqTyQqFMoXnZVdPcGe2Fsxqtb0cuyeM7x19ZifEL%2BrkbXyPUweO58BNcRdEYIAAvCXoAFEoLFa5exFyybWPt9QdgiFnImgvXlw41h%2BbCV1S3xAxbTh9XOOFixTmcmSVUHz1EQL8caHHk2td9Lxv77hWliUTlUxx0r4o%2B8gqB%2Fla8tieByKc%2FMwbxMzJ1PRdC3FY0IoUdGmPNIlHyIKcyTx7MX8rWlAPiB%2FrlOGl35jaOv8IdLl53RwbUUc7KQwmUrrIiV0ONo8AEQEA74b0VWQDISkQ0Nuc06T3nKvbpLcADHVLLsCYq3IBKCtiT2ExiatVkojsvCXuuiy8YqXcpZTyzGvcoFu3S2vcgipxuIXCNCjpnMtoShTHNLatRNA2WCnsoILlbxrGxwT3CpIqq9%2B1TTwElYnbBJOsQo6KoKyZ9uNLwMjTBymt46KZLzSZoFAD%2BQ%2FGrSrYU8J4s7RYfhp2DryExb0BlY%2FdDXAuo68Ad9EQRWdMfAGWSiq4p%2F%2BTVkYiKnafCEsPMcCJNZVmWapxkXgxWhppqko9CUL7pEYhzzMDflPKjVliC1PgY4v7JLbwU2kcuzFEGAFCVlSAnIgycDJwV95SIFX3nIA84Eo4j9lDULZYM7nb3NZ27V7kzZWLmbvOpbY0mf0dy89Iajma5DL25CIPLGqCCzxwmalYBrCGS6oxlT4EouDA9qO4jJC7XC1nphW62spI%2FMsC%2FLH3ZITnVAI07wEGs5ZgVLD0Dd6WZhe82KDwBgpjSOHQX98qNvI%2BshT5B7LPQNGKQIVzWAQumJHjIdWQFbslf7oUXYWicvGgZPUBqZAf%2BzLJBPMIRaQE%2F%2BKAwhmFBoeK9wnh1y%2Ftiy86Bc9tol5DmJLTtP7ZJrxFX14gYSAqQoGQYfQB5UQTZQ6uhmgmmIRBGTcaZBdqyfrz%2B2%2FqK3LtAzVelYOdC61icQPtWCSTCt4hhkefr1X9OvftGDosiKNVzzRgmaBSWcaUC0huUhvRD76i6QgVwTpDTykQ%2Bsola29EQ3edgZpST6xSIiEawwQgJwhayCCWhcI6tRGQ4Mm42dD6sNDJuYc3N5AFMiWpanWUBhOAObSamccxlWrZ%2F0trSg2p0hHXsDFWaI7tiHsgn19cGzHhF9eGt0xx2Q0hwTDqT7GLDlNVqLYFqhKblhlnYdYKeDjYO3lWmO2Vh1TrT7i41lsJL7FDeCdPtaFgBO2bxTHgFs4oPnPJJcbcIjRcsv3Qy7Ic%2BdJz1%2BCRaEhLuUQDT1v475mKgCtK5JdL2TAah20wSWoxJjmb3Vh%2BgmboRW72s28ay%2BwS9l6BIezlogssZh75NWnP%2BZ0x2oVfSCjnP8oeNo0aOUN402doODWePKhkndqsXUWh4xemas0zAj%2FdBLLngQ0E49o7ll5E8107U7mW4EFvoqILLx%2FZwBtWW6Q148BAdM7kwWOpUEUONPJ%2FRne3s1PX1tk%2FJF9CeH0vepy%2FjshVvj%2BMzAabcsD%2F23IfL8RAeSxOYRKOSgem%2FmkuJaFQA%2F19IqaXO2T8CMjnzhQCB4Wxam22%2B4FPMXT1hbi4149vCWS%2FEkdgKAdIYBnfjo6AwrzTD2H%2FdPq295lVCALRdmwf9AAK7nSy0qjhHPSnK9qyBl6oaqvW7yqksx55DOeZQLb59xQ6OSPSZh6KTMJNe7E1CPwfEKrvJ8vSwPiNYpkvv1rkaAaD1ck4VZEkkkkUQSSTx%2B%2FB%2F6GslsCmVuZHN0cmVhbQplbmRvYmoKNiAwIG9iago8PC9Db2xvclNwYWNlL0RldmljZUdyYXkvU3VidHlwZS9JbWFnZS9IZWlnaHQgMTEzL0ZpbHRlci9GbGF0ZURlY29kZS9UeXBlL1hPYmplY3QvV2lkdGggMTEzL0xlbmd0aCAzNS9CaXRzUGVyQ29tcG9uZW50IDg%2BPnN0cmVhbQp4nO3BMQEAAADCoP6pZwwfoAAAAAAAAAAAAAAAALgaliex%2FwplbmRzdHJlYW0KZW5kb2JqCjcgMCBvYmoKPDwvQ29sb3JTcGFjZS9EZXZpY2VSR0IvU3VidHlwZS9JbWFnZS9IZWlnaHQgMTEzL0ZpbHRlci9GbGF0ZURlY29kZS9UeXBlL1hPYmplY3QvV2lkdGggMTEzL1NNYXNrIDYgMCBSL0xlbmd0aCAyNjg3L0JpdHNQZXJDb21wb25lbnQgOD4%2Bc3RyZWFtCnic7ZLBEuM6DgPz%2Fz%2FtvU150A1ImbfH6JCyZRIEmnme3%2Fmd3%2Fmd3%2Fl%2Fno%2BdP%2FdaH11xHzV8oAInvi%2F%2F%2FL4fjgrNDFOEYPjclBrSAY1leklXCrbZYwqNEONao7qNWa1YywYlPTQWvx9blopvq62mDRphWXODroXSEW1TCvwrpFrQFOKGqQeQYwvvB7d23pqqfOPzn5HS26Vt%2BmeXSjVl3dSu334CCD9xEON8Tkgb5PeliozitzF%2B0qSjOJA2wppLdZTbnri5jYyc%2B%2FttlL49QVh3pyPiRj9xd00nat5fOSu%2BxnM4Z1jO%2Bgd0kV1tbxr8pM5vmFNnVzYa6jbmxgO71D81FemDPbb4wyQbaWNsjQQYvAHUgsEtHEb2tiPW7zMy3tePobTddI4PGlM%2FDYAD%2BOXQ%2BxOzHhAmkzjRRcNKIHqHmhY3%2F8P2Ds6H3U4mhBPpQqoBURuqH7MGBPUfvYr3KfBplXlpT8lvqoSgCjcENhz13JhoY6PXsDTsnxNb1ryljjwpMm4a8E3j6OSxjeglZVuN8mzAlczzJdJWST9a%2Fykn%2FNNn6Lfi4USXNXToOdSUW1ufkmzG2kZUnNPbaM3O7TQpGhvo7pnsPWoiroOHsnqvKTilYWzeWgRFd98y3BKpKrdLZXXDje0RpNkbEZptwlfnsZTmdiC9nK7bHMbiaNlxNYqomaGBvf2bCHS7V3kkECJj%2B81SC%2Fh%2BVT86KFxFnPEQCpolEjV0A0J8Iv8tGCQ3zCG7U7eWQUktfcqhDa0hRr6y7GNLaZbU%2Fwa7Lb2HhnL86oNGo4E9ZevcCLawx5pWNnj%2BaYywcTOwN4fbQ%2FwHNNqNTquhzg3A5rBFeMoJJ3p2imOjriAKmpoWHxk2nZjCROGnheI4pdqmaDTSGA5Z1oy1aIObGtZP474NZf0wz0NL4YSvbRBtMBc9H3XiHP0PP0pYBXWtm%2BfYYDNMSpGRlJSA2mgY2RVf%2BemmctfsXO00PioelrSltT%2B2l6MgI7NsoKYOixurMYuaup3GnzoDGtd083qP8f3Qsuy5Qy3aG5B2OWzr6HDYyNPPUU0JBN6m02aNMh0xxI9zFamG4nSa4VHysYIxa2CPdiVJexu1cmg8icWBFm%2FhihMHgWGGI1RkfB363OmoV%2BejS5XHOSrrawR%2FDLi6Us78e7SvOlTv6fMGr7Zoomce%2FRscd8osxDLI0AP5UHm4GtMblmZjpzvyvPdDzZFIg7Mg7FGwzW3M1X%2F7ZTF5tlkbpuo3hzqu9R7JB3C2tMZjNCJqEzVv3DRilwaor5pBVcse4xlBNIvOoo7aoGwEORoesjcwVaqBVSbHoa0ysDCsKofhbXUY5r0yKdjqOTpXY7T97o1n9c8dcbObw%2FhKb7qvx9BRh3yamZ1ReTZEyrZ5IC5lq0m1cWQca9LXnbrhVZ6Kd3jj5fAfNmhJDY%2BC9rVluQmiQ48%2BN1INRQMhqOkuxWme0AaQGDHUhu3WrkjD281pq4%2BFths60UWrjs46xufX5iQ%2BMTV3pPa2VLPXeulcuUW0RqzRVqpaH5%2F2K10xoz4oh400QikxqimicX8zjlSZq6WgPabQQXsof0m4IW1qzYbKMvuA1mLqzRjB12ZY87Zom%2BFebksXD29lmmz3%2BjD8R0xGfk9RLKrfKgcBzbVJKqU2vUk1A0OhCd54aBnfN8zV6nViC3LPhFkUtQ7iOi75hGaLT9sUUXuaRfmrecoe72%2FYNre8pOHobeIauXlgo6JQgFo2%2FOyMwWGQ1Aj6KR54P3ojC9ekGI%2FoNGYbOtRudPS5GQvZFjCGKiKtaSccthqd%2FgD7NnMDM4KrE9pQQfJp4xrkj2E8ioy98GszTBT%2FAHlzaPYa0h1cfQ59voamhmpd6q35HMS2pfYp2G7ZsNHSKS4dRzNquEntVTaFttZhXmtGTM2yqT4Ayz%2FDcBj%2FijFLaYTOzqLelMmN4SHFxuOudS4f1Eyg3vzHshSUBh%2FQhit2xVdt4eFoMv%2BKVZTRMOs5Tkkqme15QG7%2BVWpY1cghMrreTrQ4NqVT9L6tuOU9euAzg49o%2FI0aTmeQyzNAPQU4Aw4y98tqO2K9emAEnfIWHEhZrBM31bEOncJe0lCqUX8s5mv8avyB8fJefR5hNvNKuKGgMeorcLXRLI1iTqHhr5wEhHbTzvgbNOXmXy1xylsh9BkzvqpgmxuDaH4zHPU3YBuuTVKZaNhGoN3ECnQjKq4RuC%2F6V58jwkZ6pMHDdPyqBJqx9%2FSGmibpUz0zi474ahGbZ2vnjhpqtaRTWNCwa%2FZGY3xSAox8E7Z9VXFGbnM3w9auTLa34Udt06TWt4ktrHKOT5sqexuTJqUeaLV5VksPFtR4tmJNFGVkdQNkQGCiKGspxjOVqdk80OSAQ4fbW9yQhra0yuNpImG7hXqMJ6E18xrn3UWq%2BulYE0N1hBY0OMevn78PTW6rx9H6B9gTNRfrB1LWjNE7b8tFD0qSUlGjjfqrykOK0DQdC1qW46eRVCs3VbpSeo%2BtSXtbPQ03dE1HAQ4%2FLNZQG52C4mF7g9AMMDKDv1vU6r0Hjmuz1E%2Bjp%2BOOufRwZRqzve50dKhzH8BUAirYFq06R3v3zL%2BiyrBNTQ20FRBglO3iG%2F8jSxsaBjRg0zlaampRGTWU0qQc1HSIkQTit90PJmOWctMR47QtUHM4PEIYPDn3uCCGal366d58%2B7SRqjJf2dKmh2HtCnv81VBquCmMWc3Y2BrhNDLae5zOKZ%2B%2FT8u%2BAeqDborxGwr1PEZQROuVpI5QQc31ALjy5ILoSr82nQGEYUcQ1oykWzkOt3Nc2dGtfhrGvsWuje%2FKNlRX3Ew2HS5FT%2FPGXOqq7bf9tZoOycQ9ixu3AfYGVzNJqRu29KYpKKuVPJqu8VRZxUKrCmR%2FJQRt%2BeoMbgGqjVCMWj8ib1ka5rIaxhgRobRgRGgMm8nmYWd%2FbBHqn2AViw5lQLbTz5ilxbuyHV0rzV86fMA%2FulooJdagjchsj4d2r34ainu80R5HPQykNwbGoIivI3jPFpL%2FlBXEwwdsL2FqexOkckCgQyVGdKoTgsPtSPcU7LyPWXqvNTr38%2Ffimnn1GSYfAKdz1lzGb%2BQfI6wP5KNA2M7fwfNIaaBrG9yjNUtbzU0xt0YzOoL1bY%2FaO9JtLLqmwZmG45kixDLsKduWQke3%2BCrL%2BsGzEWsFcW4o0Xm41SyjTEENHb42BcYJ89TXowsdLQTYWFGfD0M5gDSwTUezHP8JEWrH2adFbhOZizYGN7ZofdwcBY9WtSaKB0Auax%2FyPE5kVxwa01%2B%2BNimtbxyoQA6Ms9XuefLQTIw7pm6gaP6DxYXzoxP%2Bqg5fldLwM7aj6FrqpsPRLFMsmkUXoVY3q6Ez7lWn%2FQ6eVKMx6tD50zFqr7a3dYx21mhqncXGI%2B2xzQHweBM%2BaVtPFGyrUakxtexoQxVo4zLIt0gf2yArP30LLQsNjOkq0nr1q97oOH1oNP4ZKWti1luZ%2FO%2Bzt7ltQZzV3I5ZN2RUubVEPYcOk4PbdhWV3GPUtEV8lZezQlNXQ80jTHWuf4zWNdKpsQaQl7o%2BvYmvTZmCTWSLtwi%2F8zu%2F8zu%2F89%2FP%2FwBxZFZVCmVuZHN0cmVhbQplbmRvYmoKOCAwIG9iago8PC9GaWx0ZXIvRmxhdGVEZWNvZGUvTGVuZ3RoIDIxOTE%2BPnN0cmVhbQp4nMVYS3vbuhHd%2B1fMpm3yfTIN4sGHd7IkO0ocSbHo3N7bdEGJtM1GEn0pKrnur%2B8AfIkgaCurZhHTmHMGgzMDYOA%2Fz66CM%2BaARxwIojMC54Jy%2BTUJzr6c%2FXlGLFdQn4P%2BM3s8s6klQDABjFieD9SBLD57eI3BmcV8RbEdYvn2KRxKuMUUhzsW4ydRBLEcXnBsyz4pNOrblsd%2FbTmMUsstYhO2xU%2FjuNyiVHFQAnGaatxykcJdsLlruR7%2BOJlUa2DTtzmukElR%2BSGOJRqSrAQKH7E8bs5k1D%2BL5Dty9S4BmzGJW7Zswi1sTtfE7V4TwaLwMGZybKK%2BK21Urlyi7NZ0HMF9NuFVXrfVNyI3mmfhSHs5C2I3ZW2r4TquDsvzVKztGRuP0kbdzhKlSa6edOPkqGcVp4SVcRTDtjZaRHcMbk%2BjVXKtlnkP1uEbt1ttNe%2BsZu3GTVSZe%2FZLbTZujSZu8y7Q7VrB1%2Bae2m5rRqrAGTFoRmrN2uZSM1JppllLzUgVmGYuNeuZutKMVJpp5lIzUmqmx11o0jN1pQmpNWnbi%2BJTIcnAbEMdcTWuNLENdSTNhSa2qY6UuUiWqY76pi41keZCE9tUR9KsNNHjVpr0TV1qotZV1kn3lJIb8hVN0PqaJjL0VzSR5lc06Zm60kTWyiuaoPkVTXqmrjSR6zJpUh5euKLt0UGmnUfcIdUh1rZyx6vN5beBjdt%2B2zjqsJW58aSzVWi1ow5bmRtPG31llDQrQ2x1LNNmRUejxzKoYc0ZObJTUtNIEyIlHRbzj0Ig9WTFsN2MaizXqVkSWrHUsN2MaixxxEJoxRINS41qLNqsS0IrFq3XVYy2EsOaVUvg5niUCV5%2Bd6Y6krCgNdjtEeZVHtGms%2FtY1KMmVjFsG0cbonSGzRNuGAfklc9xmIIrGKy3cJFsH20Yp%2FBFNuG2AtiVPUDnioS9%2BHn9hV35EdSWq3aZAl9c4%2B%2BIeTh7F38%2BRE9ZCLfJNsnj6H3wHyMP70%2FFo%2BC3aOPkMcnDDYzC7fNhL9ltHl4XyHu32KQ5zFIL79GFDUMIdxEU31cwSrNdnA1g%2BWIhwhcLZXWtjjPHd5Szq026%2Fi69UXsgFZBwzOwizPIBXIW7x3CTZjFMAzn0Haa76LDPswSjHGZxOOj69dzCL3xCxuYlhK%2FJZhM%2BxgP4GG5C%2BJCuNsmx51ma5U8GP9hnKD%2BxBB6yA9xnq3A3gE9htgvz8Hs4wOaL2ty3Spkx19gQKhe2POiFikZU%2BWadfDcQmXLFVSmvvtqpo4JZDjh4xrVzPvnnYn4XAExnX%2BfT0cSQczULr3Ne8qZ3MzgH2%2FPjyGUrQVbRKuJe7IZECO64a86dh7XthBFxVg888sTKcaIVZaHvrYSPNURDR7C%2B6VgdZlFiV5iEOIIgvWwprbDUVkrPMPVP4ffDNsy6GKwviRmluzxc57CIs326u4TrTfKMCcm7BMLaNb6qEj6A202cRCHAv27jDD%2F%2BPegugvq%2BaRXT3Y80WcuauQRJatxTH%2FNAGXHxQOq645z2OxuHeay7s9n5x8PmnBJqULgMznbb%2FkaHLIt36xfd1%2F1y3JMl4WshjeNN8iPOehIlvLcTJdxfTJRwtERBnSkwpEoeAEnYp0lnQVeHlzj7xx7mWRRnsOukzRMUc8N8x%2Bt16WoyF65k0qJfyprtFq8Yv3LnKc5ygwdlF41Hg3wt6uhxvF9nyXOepDtIH%2BAmTaP9xTLOfmAh7Q1LIL6FbY0QtjbpcHTxYTkzEISwCO0SvhzCXZ7kLyaVXDzmu4z7%2BecumDHfwiLqgO9QTQPap%2BqVIkgbfZOl%2B9btxDzfYg6qVdTecJsedrlhF2JCqdf1N71ZBqY961iOfE27p8Qq8NKwu%2BC%2BUKhQxUDr2i%2FgtiEMV71AdWS8XSaPOzzAx%2Bn6sI13OcjfsTDPoTAd61P6sIvrO0ifD8%2BGkOQKWHcm3%2FeY7xvwjq9K5YQ1MEIsPK060FlqqFnGPFWzOpgyiwtTkXCLOyfDOd7Xgnfh5G%2BGnFLH8j0D1rC5HdmI%2BxqwL5snACnx5IvxBCA2wPLxeALSF%2Bod%2BTaS4cLdk2ZnrqdelyesHNPqnoZ0ffXofBtZqO5qey5IsYftlb5Cvyn9CcBS%2BhOQpfRvIyvpT0AWhd9B9hV%2Bqf%2Fbjsuy7yBNZc%2FVs5u2b8ji0INkBz%2FTLNrrt2TwE4%2BrFwiesjgGbE5Uy3%2BNXfgLXGP3AaPJLFjCfLcx3DbcZio2fUp1JcBXbNRNZ7NwjaQepSgr%2Fhzgt%2FsSQzBSKL9BVn5VAUIQ%2FmUIxRXHvitGr7TMaQMNZeAJy3e7yCKIqsf88boyOrlHGdWwcarJuAhf1AV0F2ND%2BH0P%2F99%2FWEjw9jNYLYRoGS46u%2Bn48rWuUFGxM9Eb5%2FUmzELZl132cYoHZTPdbzFEihZD%2FhTm%2BF%2Byx01TJGz%2FlP7c41AM2EUfMJPPmRzGnk%2BOPWdpdFjn%2B4t92fqhJ9kYrrBzl7tJ%2BusLw9Ee%2F%2BEGnWNnnqwPGMseVDzZIVaO1im%2BKda51eeMa86%2BvVveLxa3v8PnyXAWwPX8Dor36UU5HsxhOfkD7mf4oEej%2FB5Pvk5u54vJnYR33geMFV0LDO%2BDD%2FO76XIyBgkeBtP5bImOxkgEuJrPxtLh7SQIcGB%2BXViC4afp7AZ%2BmyL3Pug6p8UfIWAx%2FP0zHjuSJ7tB%2BPbu9j6A2dyC4Zj6hOHLjvpM%2FjEmko0%2FUPeCsAvZ6MO399%2Fe98lja2Uysf4%2BnxjExFI8hldqDg%2F5U5ol%2F8UJZUsX5mlmOBOZY2FnR33WnkvbBAMYTWf6UXzr4uLIpyElxFvcjgi%2Bmh1vUGDq7T3s0IbD4egPxrh7o2FRuu4k1G%2FwhBCNsswNz9%2F6byyYiFEaxZfUL0Suz2gu5DVJPa5dPXeGYHGN3PYwV64n7kv9%2FgcU3sSiCmVuZHN0cmVhbQplbmRvYmoKMTAgMCBvYmoKPDwvQ29udGVudHMgOCAwIFIvVHlwZS9QYWdlL1Jlc291cmNlczw8L0ZvbnQ8PC9GMSA0IDAgUi9GMiA1IDAgUj4%2BL1hPYmplY3Q8PC9pbWczIDcgMCBSL2ltZzIgNiAwIFIvaW1nMSAzIDAgUi9pbWcwIDEgMCBSPj4%2BPi9QYXJlbnQgOSAwIFIvTWVkaWFCb3hbMCAwIDU5NSA4NDJdPj4KZW5kb2JqCjQgMCBvYmoKPDwvU3VidHlwZS9UeXBlMS9UeXBlL0ZvbnQvQmFzZUZvbnQvSGVsdmV0aWNhLUJvbGQvRW5jb2RpbmcvV2luQW5zaUVuY29kaW5nPj4KZW5kb2JqCjUgMCBvYmoKPDwvU3VidHlwZS9UeXBlMS9UeXBlL0ZvbnQvQmFzZUZvbnQvSGVsdmV0aWNhL0VuY29kaW5nL1dpbkFuc2lFbmNvZGluZz4%2BCmVuZG9iago5IDAgb2JqCjw8L0tpZHNbMTAgMCBSXS9UeXBlL1BhZ2VzL0NvdW50IDE%2BPgplbmRvYmoKMTEgMCBvYmoKPDwvVHlwZS9DYXRhbG9nL1BhZ2VzIDkgMCBSPj4KZW5kb2JqCjEyIDAgb2JqCjw8L01vZERhdGUoRDoyMDIzMDcxMzEwMjgyOSswNSczMCcpL0NyZWF0aW9uRGF0ZShEOjIwMjMwNzEzMTAyODI5KzA1JzMwJykvUHJvZHVjZXIoaVRleHSuIDUuNS41IKkyMDAwLTIwMTQgaVRleHQgR3JvdXAgTlYgXChBR1BMLXZlcnNpb25cKSk%2BPgplbmRvYmoKeHJlZgowIDEzCjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAxNSAwMDAwMCBuIAowMDAwMDAzMTU4IDAwMDAwIG4gCjAwMDAwMDU4NDUgMDAwMDAgbiAKMDAwMDAxNDU2MyAwMDAwMCBuIAowMDAwMDE0NjU2IDAwMDAwIG4gCjAwMDAwMDkwODEgMDAwMDAgbiAKMDAwMDAwOTI3MSAwMDAwMCBuIAowMDAwMDEyMTI2IDAwMDAwIG4gCjAwMDAwMTQ3NDQgMDAwMDAgbiAKMDAwMDAxNDM4NSAwMDAwMCBuIAowMDAwMDE0Nzk2IDAwMDAwIG4gCjAwMDAwMTQ4NDIgMDAwMDAgbiAKdHJhaWxlcgo8PC9JbmZvIDEyIDAgUi9JRCBbPDU3MmZjMDMzYzg1NDkzYmY3ZDMyMGNmZWYxMjM4ZmI3Pjw1NzJmYzAzM2M4NTQ5M2JmN2QzMjBjZmVmMTIzOGZiNz5dL1Jvb3QgMTEgMCBSL1NpemUgMTM%2BPgolaVRleHQtNS41LjUKc3RhcnR4cmVmCjE1MDAwCiUlRU9GCg%3D%3D\"\r\n }\r\n \r\n ]\r\n\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}SubmitDocument", + "host": [ + "{{baseURL}}SubmitDocument" + ] + }, + "description": "This API facilitates to submit document(s) for the below certificates orders.\n\n- emSign SSL/TLS - OV & EV Certificates\n \n- emSign Signature - Natural Person, Legal Person, Legal Entity Certificates\n \n\n| **Region** | **URL** |\n| --- | --- |\n| US | [https://sandbox-us-subscriber.emsign.com/](https://sandbox-us-subscriber.emsign.com/) |\n| Global | [https://sandbox-emsignsubscriber.emudhra.net/](https://sandbox-emsignsubscriber.emudhra.net/) |\n\n**API Endpoint**\n\n| Category | Specification |\n| --- | --- |\n| URL | http(s)://(url host port and path)/SubmitDocument |\n| Protocol | HTTP/ HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"accountNumber\": \"\",\n \"authKey\": \"\"\n },\n \"documentDetails\": {\n \"requestorEmail\": \"\",\n \"orderNumber\": \"\",\n \"numberOfDocuments\": \"\",\n \"documentsAttached\": [\n {\n \"id\": \"\",\n \"fileName\": \"\",\n \"description\": \"\",\n \"base64Value\": \"\"\n },\n ]\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| documentDetails | (mandatory)
Document details of the respective order. Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | (mandatory)
Version should be set to \"1.0\". |\n| ts | (mandatory)
Time stamp of request in ISO 8601 format.
Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Document Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestorEmail | (mandatory)
Registered Email ID of the certificate requestor associated with the respective emSign Certificate Order. |\n| orderNumber | (mandatory)
Order Number of the respective Certificate Order. |\n| numberOfDocuments | (optional)
Number of Documents attached. |\n| documentsAttached | (optional)
Specified below. Uploading documents during order creation is recommended for sending us any additional / supporting documents so that they are automatically associated with your certificate order. This additional documentation would help emSign to speed up the certificate validation process. (E.g., incorporation letter, registration document, etc.). |\n\n**Documents Attached**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| id | (mandatory)
ID of the document. |\n| fileName | (mandatory)
File Name of the document. |\n| description | (mandatory)
Description of the document. |\n| base64Value | (mandatory)
URL encoded base64 data of pdf document. |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | (mandatory)
Version should be set to \"1.0\". |\n| ts | (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success.
Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success.
Mandatory in case of failure. |" + }, + "response": [ + { + "name": "SubmitDocument", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\":{\r\n \"ver\":\"1.0\",\r\n \"ts\":\"{{ts}}\",\r\n \"txn\":\"{{txn}}\",\r\n \"accountNumber\":\"4391755311\",\r\n \"authKey\":\"{{hash}}\"\r\n },\r\n \"documentDetails\":{\r\n \"requestorEmail\":\"abcd@emudhra.com\",\r\n \"orderNumber\":\"9592742855\", \r\n \"numberOfDocuments\":\"1\",\r\n \"documentsAttached\":[\r\n {\r\n \"id\":\"000001\",\r\n \"fileName\":\"xyz.pdf\",\r\n \"description\":\"DTC Licence1\",\r\n \"base64Value\":\"JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9Db2xvclNwYWNlL0RldmljZUdyYXkvU3VidHlwZS9JbWFnZS9IZWlnaHQgNDUvRmlsdGVyL0ZsYXRlRGVjb2RlL1R5cGUvWE9iamVjdC9XaWR0aCAxNjYvTGVuZ3RoIDI5ODcvQml0c1BlckNvbXBvbmVudCA4Pj5zdHJlYW0KeJzNWXlc1NUWPzMMgyA7ImCJGwSipJaJgiLgguwuESIipam4r6UmKWaK4nONNBWJegoEkii4FaaCSmFAogI%2BFUhAQWVTdhjuO%2Bf%2BZmQoPq%2F8PD7S%2BWPOudu539%2B5Z7m%2F3wD8mcQ9h%2FstmGyu0sHQP4ZUbUMvn1xhISJZrG1kYqje1Yj%2BTGK3xKarfqooqVpN%2FSQ84WJqcuyuADNRV%2BNqRyPiWIEvGc902cn8ViYnWe7OAV2NrI3U1z5lYUYoDD5U1MKqclLOpt1rEoDmenY1OAX1Oc3qPsSYMYt8Wn5qsY2xjqaGlt7g%2BSktBLN8WlfDE8j2Fit1A9BZdueifw%2BlfrH3HYJZZNN5W702eeqU6VYAdt6TvR0lL7PS4xErcQAYtOMb2z8OmaUSzHjtTkO5hPxoFUizkZ3ReImFU6pYuQuAsY99B4MDbhJMt84CCbtQW5MnvPYU%2BfaXyMtTKljDDOTa3Tsc9qKH%2F7pb52AEOEEeNAJsyOPn%2F%2F1lTqWMBSkamiP8Vgd%2FNGOIatu4%2BCTqe2DcSSC1f0ZtGabgT7lu%2FN9eZnWXsSg1LkpH7M9vaJa1yprrMhfpvpgxjY5c4Qw9Jqzdti3Y10xMDaPxzi6jJDBgTsiOlWbYNgvcFhpoioKKnfME1zdoylsuE1yHUmHQdwsO3eAElv9BZael8BmySivoM2tr6HILHO7n7OzSD4YGb3fFhrqVz%2FptISGbFtjJj1c3mbHbfbg4MKKRtVGqlQLl60XY9Oditzk5wmj1Rsr%2Fi2WMnZcEkouxfAvRiick3B6ELk7SalrwCwoRKLhk8HWhnnhy7BBADLKr%2FQNLqLMYTbCHsVb%2FYY8ZO4wpO7pegeL0a3zfHTjqyyWP31k7yhkoR6mViK3lJGkepiQfnfQM2UZsb0ces0c%2BP%2BaAXNgN8A55nRdZlzLZJwABdcJYQ0EN%2Fq4BEaFPiZWb5QxAPHqVP1qsoC%2BMJOgPE49dbEa%2Bi%2Fad9JyxSJ613q3iC6ouRCWUCGu%2Fl6cf1b3YWEnSThQSsGDORxtW9gWIY6ylvj4%2BrJTPb4yPeET8hBp4E6ChuGIsDtXPhLfJto1Ry77im9T4ggmdD6uPjuQ8R0vtGmOFqRWRkR8CrLv3UJYxWgS6kTh0nYyTho%2FAU%2Faoh3yn797RV9cyO8BLDpNXRvFWlHE1jMO97pAjmKDOlvdA7zIOPJsngnfpqZs%2F0IA5%2BNDsuCoEISsgB52F1iodC0eZ%2FEGXkVAyCkaWU6i7SMCbjPybiil5UvFUUBWDyLDfQJv%2BtPFq7MtEvhDh7KUOgx84rl1CMVA5wVsHJW0oKWEeRB4OEqnIMAn9ZDFY5WFHNEURGfM7nN2PYmMfwNfIfjLAkTUo5PUywQhlydTmS3JMwK9O8AQwo3R8FuxJw3aloNawmpNQIThDL0wLj4ZQ53wO65wiK7rWUjNTiDDpl4zVogl7oOHZk%2BycnFt56N2yeeCIYdO8FGcMasCROSgMpwNcBUAFK6Kb%2FMF%2BAS8ycTDpMidQKSLYgImo2gE7rAuxIwx8UWOZg9zD%2BngFJ5aymiICsQdgNrJjPNJvEKpqR8WTGHHXfGAoRM9pxrL0MPTQUM15v2RkZGReT09PcwBfnFOJRQvcaTYtfhfh1PmAXj62P8W29jkU4mAp%2FrbMIl2jK1GMBIhAdotSlSO6RMsqWEVG0eHbGX5ehq6bum%2Bq%2BS3sXAi6p9DHaRdhG3aqzeA5Sih7PcC4xeN8C%2FcudxXp6uhoamrr6IhgHfmSJc5YTmE3QO52xbZgTUGyBNv2dGg7uIO1fkC6aEnrBhBdQv4jpeT3Uah1hTBkscLJhaOY5SRFZ6B8NAnskeXxQw7nKE%2FMCpzPae4SnvluavF141CkXNsnCxUKeZPf30VfkUFoDuWgbLqcUja4aSo4zBkD6P8jhfYCWCgjEOg%2FbpgQWR2GOJnpW1ISTOfxhuh7ZKFc9Rj02MYAkj6luQNhg%2BDqmHuzhVTS0CRQo3D%2FPSxEz27G0nqRQMeU1htEM7KuJe%2FXA90kcmUaSKaD0JZH3SUJeFFstGYmPShEP612htEUG63n139dVokHXDEcRhZjx2ZcqU7Z5r7UEB25aRHfbQalCzwH8WLKrHeMdCmRvMd9upp1QE9G8mUG%2BUIZAbChvF9yNgOfoREvCv0pEg6R495ngk8YX0UhCvU9FTT8PA2nlKH7xcpVRlGKemgI09HYrXNxac9r9FwwpABTGvc9cKBIrDp6IKeQylOKngWCbRxEI448pJuKlOhBbryd4KILWlm2mSA6Z1TT2TU9SR6NrTcLaqsqyADmRdWV6OYYsDcaqp%2BtR2EtpkPZkyidsWU1z9PRJ8x%2FQus2P9om2sgqn%2F8ggqW11bUF48iLcmorn4eBXXHt83tCrZOGEBhZ%2BWkzh%2BeV9cl6U7CV35NGfAg%2FuzJ0sLWC3rTUkseR1q9CYuOk7bFy85Z1s4dzV9B3d3f1pDqr7%2Brm5mlCwiRPN49%2BKIgdP92yEsuHsae7xyiaq%2BGzccuiwSAeOsXFcziAhZebx0QKac1JHi5eFtDT3cN9nHDlAbH9x1uC57%2BlAgburl5OamT9dB79M3k9jenwfr9Wxq7qdjTwiugbRHaBm8yZ3wVS1NrGJAsjZnLQ1vfZM%2FlF3Xru%2BwGvmuZankVklzlKS7rksIp%2BL0AaxzSxGgcU1NDvP5d3ukZFhL9iOhzjdBGR3eCHqXqdR%2BCLOjr6N3JgZ5SwapxUmFgs7QISU%2F5%2FxBMhLw6YQhdR4Rb1XU9ljCXjK%2B%2F4CpZu2smO9pJ0nrAI6YbXAkwxZ1bMXryPv4Kzh5hshtxl2Yq7MHSfEzQvaLnB%2F9Ko36Nd02ntxmntv4eZB9DZ9V%2FS629CnOiqQmkfr82Cwid%2FSOmFE%2FCalcVuWr5Y0SM0T3bloIBDrHhFFT51yX1iQIKT8hYqWYWnqj4SZsjf9tZl6%2BPvmHjFpyepMFGxQiwRPvcp2pLkA%2BJ1PEnKp4y7r4yx9RiiG5TFLpsrbQqflPal3UzjksIw%2FntGT%2BibQHdi%2FZ0pl8Yg10xkW5VRmpbPg7BiyqM99v92RI9AJZwkEB8fwF%2FDb93gGJUC0ZqMxME0f%2Ba5S5OQWcXe2MTXf%2FTr8ad%2B4Mx9cYxcZ%2B%2BQwkYZR9jy%2BKg93kFG3GWR7c83OpWfn1fdUbqT2tba%2BFWS0%2FrUBG1Ey4NGYo6F8nT30iGwuoJm7M%2Bd%2Fjvd1HXzaH%2FNFLpn6F8PmlH%2BJgrzymal0FVaPb58KWZv7eTE2Y%2FID11L%2FCNbh4ExN9%2BxF1p1HFfuDD%2Byd60H3W4goKpyYftPlxrpu3nHtnv8w0ngbdh3TRMF8%2BOFsa8j17r0Rbv5m%2B6B5OwVvIQYpOUeuU2vd5bl9MZmUryMjmbnr7kraNqx6iNZdBswKviMmkPK0yLuUPGMvAJrCvuAZDehrPaADsg6SnbcUsljiGyqhWtbRhxne7J9SnahC2l8vnJLzVTa%2FdmhdjouPvWPapiMguqF5FXfkU0Dfqf3mXFVDtwMLIEXvr35ixKoY1Q9XQ%2Bhf0HE5i%2FpOnmgcFrucfR4O57M03v%2FCeMb%2FypOm4au93a7D6w2MTyUpAe9eHNicvRxfrlfdDFpMxWH7hHxyqokIUmpZwQT2H2fvI4qmc9OCowJ%2B8lHYXC88Omsb%2FiFL%2Bj07A%2FxM4T3zp33Iz4w4fwpH%2BSiMB4qse3LtMR2%2B4U4X1TUe9M8HeUBVSGUxd0ERxAbaouFaDQUvjpANxNl24u7qXdX3Az0X%2BdMjYe6VK5HMVlNWC5VVA8j4RUBtHoK%2BgcKXyoSjNp0iyxmLvexRm26QVlLO%2B974P9F3vzM2U33FxcNkYYUH0A6aEdZlkMXAmtPi4UvI7ITXqaKKiE1GhZ4ofVxyD%2FEkJxmF8kT%2Bd1%2Fb%2FjQ23N64KYj6Q3s8ZFhXQ2sPdkmyZRqDv%2B9v9Xmn%2FUnD5L65LgaJZx3D3mbiv961asndQu%2FHSevZWZeObVn7ggjtb9e8Crpv8blqS0KZW5kc3RyZWFtCmVuZG9iagoyIDAgb2JqCjw8L0ZpbHRlci9GbGF0ZURlY29kZS9BbHRlcm5hdGUvRGV2aWNlUkdCL0xlbmd0aCAyNTk1L04gMz4%2Bc3RyZWFtCnicnZZ3VFTXFofPvXd6oc0wFClD770NIL03qdJEYZgZYCgDDjM0sSGiAhFFRAQVQYIiBoyGIrEiioWAYMEekCCgxGAUUVF5M7JWdOXlvZeX3x9nfWufvfc9Z%2B991roAkLz9ubx0WAqANJ6AH%2BLlSo%2BMiqZj%2BwEM8AADzABgsjIzAkI9w4BIPh5u9EyRE%2FgiCIA3d8QrADeNvIPodPD%2FSZqVwReI0gSJ2ILNyWSJuFDEqdmCDLF9RsTU%2BBQxwygx80UHFLG8mBMX2fCzzyI7i5mdxmOLWHzmDHYaW8w9It6aJeSIGPEXcVEWl5Mt4lsi1kwVpnFF%2FFYcm8ZhZgKAIontAg4rScSmIibxw0LcRLwUABwp8SuO%2F4oFnByB%2BFJu6Rm5fG5ikoCuy9Kjm9naMujenOxUjkBgFMRkpTD5bLpbeloGk5cLwOKdP0tGXFu6qMjWZrbW1kbmxmZfFeq%2Fbv5NiXu7SK%2BCP%2FcMovV9sf2VX3o9AIxZUW12fLHF7wWgYzMA8ve%2F2DQPAiAp6lv7wFf3oYnnJUkgyLAzMcnOzjbmcljG4oL%2Bof%2Fp8Df01feMxen%2BKA%2FdnZPAFKYK6OK6sdJT04V8emYGk8WhG%2F15iP9x4F%2BfwzCEk8Dhc3iiiHDRlHF5iaJ289hcATedR%2Bfy%2FlMT%2F2HYn7Q41yJRGj4BaqwxkBqgAuTXPoCiEAESc0C0A%2F3RN398OBC%2FvAjVicW5%2Fyzo37PCZeIlk5v4Oc4tJIzOEvKzFvfEzxKgAQFIAipQACpAA%2BgCI2AObIA9cAYewBcEgjAQBVYBFkgCaYAPskE%2B2AiKQAnYAXaDalALGkATaAEnQAc4DS6Ay%2BA6uAFugwdgBIyD52AGvAHzEARhITJEgRQgVUgLMoDMIQbkCHlA%2FlAIFAXFQYkQDxJC%2BdAmqAQqh6qhOqgJ%2Bh46BV2ArkKD0D1oFJqCfofewwhMgqmwMqwNm8AM2AX2g8PglXAivBrOgwvh7XAVXA8fg9vhC%2FB1%2BDY8Aj%2BHZxGAEBEaooYYIQzEDQlEopEEhI%2BsQ4qRSqQeaUG6kF7kJjKCTCPvUBgUBUVHGaHsUd6o5SgWajVqHaoUVY06gmpH9aBuokZRM6hPaDJaCW2AtkP7oCPRiehsdBG6Et2IbkNfQt9Gj6PfYDAYGkYHY4PxxkRhkjFrMKWY%2FZhWzHnMIGYMM4vFYhWwBlgHbCCWiRVgi7B7scew57BD2HHsWxwRp4ozx3nionE8XAGuEncUdxY3hJvAzeOl8Fp4O3wgno3PxZfhG%2FBd%2BAH8OH6eIE3QITgQwgjJhI2EKkIL4RLhIeEVkUhUJ9oSg4lc4gZiFfE48QpxlPiOJEPSJ7mRYkhC0nbSYdJ50j3SKzKZrE12JkeTBeTt5CbyRfJj8lsJioSxhI8EW2K9RI1Eu8SQxAtJvKSWpIvkKsk8yUrJk5IDktNSeCltKTcpptQ6qRqpU1LDUrPSFGkz6UDpNOlS6aPSV6UnZbAy2jIeMmyZQplDMhdlxigIRYPiRmFRNlEaKJco41QMVYfqQ02mllC%2Fo%2FZTZ2RlZC1lw2VzZGtkz8iO0BCaNs2Hlkoro52g3aG9l1OWc5HjyG2Ta5EbkpuTXyLvLM%2BRL5Zvlb8t%2F16BruChkKKwU6FD4ZEiSlFfMVgxW%2FGA4iXF6SXUJfZLWEuKl5xYcl8JVtJXClFao3RIqU9pVllF2Us5Q3mv8kXlaRWairNKskqFylmVKVWKqqMqV7VC9ZzqM7os3YWeSq%2Bi99Bn1JTUvNWEanVq%2FWrz6jrqy9UL1FvVH2kQNBgaCRoVGt0aM5qqmgGa%2BZrNmve18FoMrSStPVq9WnPaOtoR2lu0O7QndeR1fHTydJp1HuqSdZ10V%2BvW697Sw%2Bgx9FL09uvd0If1rfST9Gv0BwxgA2sDrsF%2Bg0FDtKGtIc%2Bw3nDYiGTkYpRl1Gw0akwz9jcuMO4wfmGiaRJtstOk1%2BSTqZVpqmmD6QMzGTNfswKzLrPfzfXNWeY15rcsyBaeFustOi1eWhpYciwPWN61olgFWG2x6rb6aG1jzbdusZ6y0bSJs9lnM8ygMoIYpYwrtmhbV9v1tqdt39lZ2wnsTtj9Zm9kn2J%2F1H5yqc5SztKGpWMO6g5MhzqHEUe6Y5zjQccRJzUnplO90xNnDWe2c6PzhIueS7LLMZcXrqaufNc21zk3O7e1bufdEXcv92L3fg8Zj%2BUe1R6PPdU9Ez2bPWe8rLzWeJ33Rnv7ee%2F0HvZR9mH5NPnM%2BNr4rvXt8SP5hfpV%2Bz3x1%2Ffn%2B3cFwAG%2BAbsCHi7TWsZb1hEIAn0CdwU%2BCtIJWh30YzAmOCi4JvhpiFlIfkhvKCU0NvRo6Jsw17CysAfLdZcLl3eHS4bHhDeFz0W4R5RHjESaRK6NvB6lGMWN6ozGRodHN0bPrvBYsXvFeIxVTFHMnZU6K3NWXl2luCp11ZlYyVhm7Mk4dFxE3NG4D8xAZj1zNt4nfl%2F8DMuNtYf1nO3MrmBPcRw45ZyJBIeE8oTJRIfEXYlTSU5JlUnTXDduNfdlsndybfJcSmDK4ZSF1IjU1jRcWlzaKZ4ML4XXk66SnpM%2BmGGQUZQxstpu9e7VM3w%2FfmMmlLkys1NAFf1M9Ql1hZuFo1mOWTVZb7PDs0%2FmSOfwcvpy9XO35U7keeZ9uwa1hrWmO18tf2P%2B6FqXtXXroHXx67rXa6wvXD%2B%2BwWvDkY2EjSkbfyowLSgveL0pYlNXoXLhhsKxzV6bm4skivhFw1vst9RuRW3lbu3fZrFt77ZPxeziayWmJZUlH0pZpde%2BMfum6puF7Qnb%2B8usyw7swOzg7biz02nnkXLp8rzysV0Bu9or6BXFFa93x%2B6%2BWmlZWbuHsEe4Z6TKv6pzr%2BbeHXs%2FVCdV365xrWndp7Rv2765%2Fez9QwecD7TUKteW1L4%2FyD14t86rrr1eu77yEOZQ1qGnDeENvd8yvm1qVGwsafx4mHd45EjIkZ4mm6amo0pHy5rhZmHz1LGYYze%2Bc%2F%2Bus8Wopa6V1lpyHBwXHn%2F2fdz3d074neg%2ByTjZ8oPWD%2FvaKG3F7VB7bvtMR1LHSGdU5%2BAp31PdXfZdbT8a%2F3j4tNrpmjOyZ8rOEs4Wnl04l3du9nzG%2BekLiRfGumO7H1yMvHirJ7in%2F5LfpSuXPS9f7HXpPXfF4crpq3ZXT11jXOu4bn29vc%2Bqr%2B0nq5%2Fa%2Bq372wdsBjpv2N7oGlw6eHbIaejCTfebl2%2F53Lp%2Be9ntwTvL79wdjhkeucu%2BO3kv9d7L%2B1n35x9seIh%2BWPxI6lHlY6XH9T%2Fr%2Fdw6Yj1yZtR9tO9J6JMHY6yx579k%2FvJhvPAp%2BWnlhOpE06T55Okpz6kbz1Y8G3%2Be8Xx%2BuuhX6V%2F3vdB98cNvzr%2F1zUTOjL%2Fkv1z4vfSVwqvDry1fd88GzT5%2Bk%2FZmfq74rcLbI%2B8Y73rfR7yfmM%2F%2BgP1Q9VHvY9cnv08PF9IWFv4FA5jz%2FAplbmRzdHJlYW0KZW5kb2JqCjMgMCBvYmoKPDwvQ29sb3JTcGFjZVsvSUNDQmFzZWQgMiAwIFJdL1N1YnR5cGUvSW1hZ2UvSGVpZ2h0IDQ1L0ZpbHRlci9GbGF0ZURlY29kZS9UeXBlL1hPYmplY3QvV2lkdGggMTY2L1NNYXNrIDEgMCBSL0xlbmd0aCAzMDYyL0JpdHNQZXJDb21wb25lbnQgOD4%2Bc3RyZWFtCnic7VzpUxvnGf8P%2BqnTzjSd2GA7R5PYxIBAIIEFQiRODLFxAhgfnFohpBU6d4UQCOwYMBACRhKgA90nlwHjKyFOMk7aNM1M%2B6kzbfKhnfZT02RS27Gdgz6vhKQViCtoLIZo5jc78rvP%2B1y%2F93je3cWKIyOKOEGerxfnasnC0bajJg95dTtwk9e%2BbD50V5T6P9H%2BJOKLeBA9IsnV4VmXgevzJRar%2BMqE%2BlqS8R2LbdItYeia6dreCmfP63ZD49Ts%2BXe8yoVt0p1kfGcyLsvTC2nDvRWOEe7EpPr6VPsNf%2Bt2p3aS8R3LuDhHe6HUYhHNADuTbdfjRXQYLvL6l81pScZ3AuNSpl79sskhm3Mp5v2qa3FZw1dhwU%2FM%2FLf5YJLxBDPOggpNC3S7FfO%2BlqtUrr3kwoT83Wgs%2BhU3PeT8T2Dcrnzvn2LWfdGTCU%2FOrsQWNm6mTvOKydey4G1ZCGy1837FrSnZ7WnZbR9xfbCrb%2BjiW2G83dVr0JivSO7Ara3yHmC8IMl4whlvzh42C6Yn2697iQUgelby8UjnyJsD8p4%2BdeeguNqdWevKDuOcJ0NkLunuV9lafROKdzZPt5ecNyt%2F%2Fw8xJ8l4whkX0%2FTG2rkZ8ra7ZVala9BcFgjHjwWIptc5c3g2Ns9WSAGbaz8CvIuNZU7VzKR8cdMTfPFjmfIb0dPJTTyxjBPZZpwvFo2XyAzlQGKdM7femQucriJ6Bdj1TqbIXErqq2Fl2AzjFuWdv4nLHuG%2FTnhmdis2R%2Fe45Gwb5s%2FFHPlcO8K6LK8EDAwYHi26umnZ%2B%2BvT7SYXnOTNv0uOP8CfSHhmdis2ZjxvjKCbRXI%2B5mNsieho0vMlxpO2Vr%2BXWOfwPj%2Bu%2FOgPMtl3%2BC8TnpZdjA3ozh8BunGCh03k8KzrL%2BAboMaVpRkSQr23VunuIm9MEv4vxK9%2BK%2FptwtOyi7ER46M4icF6vk26g9NcZCod6zDCmW6tQ9kH8vM%2F4r%2F4RnQg4WnZxdhgB2cYENf2gm3SHaziqt0ZcJSbkr23FuPvyvse4b9KeE52N9avzwUXzvAcrHjQjQDn9N7ezslYjMOSPktYvxb9LnkoSxjj%2BaMKtpY%2FXMpzHnk8jF8ljA9Fv0l4QnY9YtPN0pOHHUJNDeb%2F6fX5VhmfJ0zJmi1RjBMMo%2FR4D5rgrrhN8CTjOwQxGScz7KJmnDuTtf0SPbpyy%2Bzpa4tZuSUZTyDjRK5RWn6hcZwTx5oNUO9gKHW1buWcl4jxqUyS8a3iHh4%2FxnPM4hoVd5IeR7oBDQ7mmwOKaWnsB62oclOYvhc88UC4b%2FOx3A3g8WQ4XoZi6rm7diwxG78V7vuP5MB9fB%2BkKx6Mm8Tn1OipSzwnOFNqKJ%2BS3V77ifrNeYXzry2pf1E991XzgSV%2Byr01goUYl5pSf2hKXWpKWeLtfRQIOZyx%2B%2Fj%2BJXQLAX7fpSYT3%2F8DdEFIpfZCCcT3LQUVNqUEe1ETfg91TAUZqqGAGyk%2FBjy5t4ojUPKdIJUqDzIg%2FGO0VyDwMBDOUuNeiOU7Yerd6C5ggmp32dXGlK%2FFB1SGvA86Dn5BPAO5eriKdySP7%2F8%2B4BtV5w%2BC1MfCeAFs4j39bYGvYmIzPilb7OnTVHlYr3uLTQNZH2kOPuKnoDzAlQrunn9Lnrp1Ie1256E7mkOftT4PLZCrZWB77gn2fdCRBrfe70y7C8nEKHe5ez5TPf%2Bh5tDtzrTPFc9GOmJ7vhTtX%2BxMu9N%2BEPAIjFLt8vbeb0r5k3qloa9wMHQIsHg%2B7QGiLIXa5QE%2F5dPoLiBz68KLn8ufXWqIculf0qffA9Oag5%2B0vRDlLW%2Fvt02pf1Y9t8R9kmr3a3z%2FJ20Hu%2FSMs66iM64ioaUAUvGF4hnUl5ooHuTqyT%2Bqn38Y8Cfc%2FVP1C4%2BBcczOEplKAxM89hN12Nm7%2B9V1LthH2I22wmpnUaWH4%2BzN9F3KcPdmUnGlK71Xm1vme6nKzanwcLj2wivd6f6ejCCmutNNb2XBmDnl5gRHzhTl7kxXOmYrrPAUn%2FQWtxry5y4eDrdfHqKDTugFdl19ND%2FFLghY%2B7ME1oK5rihVukF60I1KT7G9j%2Ba9lEntAi18a%2BGVUJeJ7gxbH%2B2E7yUVxS5g9uLhLh0D2ivdHIh6KtSOuiC7tCZrwSzF7nRXun6QDvK1DpQoHnp2XQhudIwywVbYAQ%2BkricDWvi2QmcfbSLcvTu93s5%2BPIzj5pJ13ppNyhebxl%2FG7FEnwSCnb3iLqQhmpjEkA%2FECTVScdi3fhStMgUrKLfgN8sFbkLFKSvtZV0TnaRenwh2xC7%2FPuDgNdvapaFXULlUuTrkn4uSpUJfKKMc4MAaodoN6wuFggZCjYwGH17PLs0XCKadkqTyQqFMoXnZVdPcGe2Fsxqtb0cuyeM7x19ZifEL%2BrkbXyPUweO58BNcRdEYIAAvCXoAFEoLFa5exFyybWPt9QdgiFnImgvXlw41h%2BbCV1S3xAxbTh9XOOFixTmcmSVUHz1EQL8caHHk2td9Lxv77hWliUTlUxx0r4o%2B8gqB%2Fla8tieByKc%2FMwbxMzJ1PRdC3FY0IoUdGmPNIlHyIKcyTx7MX8rWlAPiB%2FrlOGl35jaOv8IdLl53RwbUUc7KQwmUrrIiV0ONo8AEQEA74b0VWQDISkQ0Nuc06T3nKvbpLcADHVLLsCYq3IBKCtiT2ExiatVkojsvCXuuiy8YqXcpZTyzGvcoFu3S2vcgipxuIXCNCjpnMtoShTHNLatRNA2WCnsoILlbxrGxwT3CpIqq9%2B1TTwElYnbBJOsQo6KoKyZ9uNLwMjTBymt46KZLzSZoFAD%2BQ%2FGrSrYU8J4s7RYfhp2DryExb0BlY%2FdDXAuo68Ad9EQRWdMfAGWSiq4p%2F%2BTVkYiKnafCEsPMcCJNZVmWapxkXgxWhppqko9CUL7pEYhzzMDflPKjVliC1PgY4v7JLbwU2kcuzFEGAFCVlSAnIgycDJwV95SIFX3nIA84Eo4j9lDULZYM7nb3NZ27V7kzZWLmbvOpbY0mf0dy89Iajma5DL25CIPLGqCCzxwmalYBrCGS6oxlT4EouDA9qO4jJC7XC1nphW62spI%2FMsC%2FLH3ZITnVAI07wEGs5ZgVLD0Dd6WZhe82KDwBgpjSOHQX98qNvI%2BshT5B7LPQNGKQIVzWAQumJHjIdWQFbslf7oUXYWicvGgZPUBqZAf%2BzLJBPMIRaQE%2F%2BKAwhmFBoeK9wnh1y%2Ftiy86Bc9tol5DmJLTtP7ZJrxFX14gYSAqQoGQYfQB5UQTZQ6uhmgmmIRBGTcaZBdqyfrz%2B2%2FqK3LtAzVelYOdC61icQPtWCSTCt4hhkefr1X9OvftGDosiKNVzzRgmaBSWcaUC0huUhvRD76i6QgVwTpDTykQ%2Bsola29EQ3edgZpST6xSIiEawwQgJwhayCCWhcI6tRGQ4Mm42dD6sNDJuYc3N5AFMiWpanWUBhOAObSamccxlWrZ%2F0trSg2p0hHXsDFWaI7tiHsgn19cGzHhF9eGt0xx2Q0hwTDqT7GLDlNVqLYFqhKblhlnYdYKeDjYO3lWmO2Vh1TrT7i41lsJL7FDeCdPtaFgBO2bxTHgFs4oPnPJJcbcIjRcsv3Qy7Ic%2BdJz1%2BCRaEhLuUQDT1v475mKgCtK5JdL2TAah20wSWoxJjmb3Vh%2BgmboRW72s28ay%2BwS9l6BIezlogssZh75NWnP%2BZ0x2oVfSCjnP8oeNo0aOUN402doODWePKhkndqsXUWh4xemas0zAj%2FdBLLngQ0E49o7ll5E8107U7mW4EFvoqILLx%2FZwBtWW6Q148BAdM7kwWOpUEUONPJ%2FRne3s1PX1tk%2FJF9CeH0vepy%2FjshVvj%2BMzAabcsD%2F23IfL8RAeSxOYRKOSgem%2FmkuJaFQA%2F19IqaXO2T8CMjnzhQCB4Wxam22%2B4FPMXT1hbi4149vCWS%2FEkdgKAdIYBnfjo6AwrzTD2H%2FdPq295lVCALRdmwf9AAK7nSy0qjhHPSnK9qyBl6oaqvW7yqksx55DOeZQLb59xQ6OSPSZh6KTMJNe7E1CPwfEKrvJ8vSwPiNYpkvv1rkaAaD1ck4VZEkkkkUQSSTx%2B%2FB%2F6GslsCmVuZHN0cmVhbQplbmRvYmoKNiAwIG9iago8PC9Db2xvclNwYWNlL0RldmljZUdyYXkvU3VidHlwZS9JbWFnZS9IZWlnaHQgMTEzL0ZpbHRlci9GbGF0ZURlY29kZS9UeXBlL1hPYmplY3QvV2lkdGggMTEzL0xlbmd0aCAzNS9CaXRzUGVyQ29tcG9uZW50IDg%2BPnN0cmVhbQp4nO3BMQEAAADCoP6pZwwfoAAAAAAAAAAAAAAAALgaliex%2FwplbmRzdHJlYW0KZW5kb2JqCjcgMCBvYmoKPDwvQ29sb3JTcGFjZS9EZXZpY2VSR0IvU3VidHlwZS9JbWFnZS9IZWlnaHQgMTEzL0ZpbHRlci9GbGF0ZURlY29kZS9UeXBlL1hPYmplY3QvV2lkdGggMTEzL1NNYXNrIDYgMCBSL0xlbmd0aCAyNjg3L0JpdHNQZXJDb21wb25lbnQgOD4%2Bc3RyZWFtCnic7ZLBEuM6DgPz%2Fz%2FtvU150A1ImbfH6JCyZRIEmnme3%2Fmd3%2Fmd3%2Fl%2Fno%2BdP%2FdaH11xHzV8oAInvi%2F%2F%2FL4fjgrNDFOEYPjclBrSAY1leklXCrbZYwqNEONao7qNWa1YywYlPTQWvx9blopvq62mDRphWXODroXSEW1TCvwrpFrQFOKGqQeQYwvvB7d23pqqfOPzn5HS26Vt%2BmeXSjVl3dSu334CCD9xEON8Tkgb5PeliozitzF%2B0qSjOJA2wppLdZTbnri5jYyc%2B%2FttlL49QVh3pyPiRj9xd00nat5fOSu%2BxnM4Z1jO%2Bgd0kV1tbxr8pM5vmFNnVzYa6jbmxgO71D81FemDPbb4wyQbaWNsjQQYvAHUgsEtHEb2tiPW7zMy3tePobTddI4PGlM%2FDYAD%2BOXQ%2BxOzHhAmkzjRRcNKIHqHmhY3%2F8P2Ds6H3U4mhBPpQqoBURuqH7MGBPUfvYr3KfBplXlpT8lvqoSgCjcENhz13JhoY6PXsDTsnxNb1ryljjwpMm4a8E3j6OSxjeglZVuN8mzAlczzJdJWST9a%2Fykn%2FNNn6Lfi4USXNXToOdSUW1ufkmzG2kZUnNPbaM3O7TQpGhvo7pnsPWoiroOHsnqvKTilYWzeWgRFd98y3BKpKrdLZXXDje0RpNkbEZptwlfnsZTmdiC9nK7bHMbiaNlxNYqomaGBvf2bCHS7V3kkECJj%2B81SC%2Fh%2BVT86KFxFnPEQCpolEjV0A0J8Iv8tGCQ3zCG7U7eWQUktfcqhDa0hRr6y7GNLaZbU%2Fwa7Lb2HhnL86oNGo4E9ZevcCLawx5pWNnj%2BaYywcTOwN4fbQ%2FwHNNqNTquhzg3A5rBFeMoJJ3p2imOjriAKmpoWHxk2nZjCROGnheI4pdqmaDTSGA5Z1oy1aIObGtZP474NZf0wz0NL4YSvbRBtMBc9H3XiHP0PP0pYBXWtm%2BfYYDNMSpGRlJSA2mgY2RVf%2BemmctfsXO00PioelrSltT%2B2l6MgI7NsoKYOixurMYuaup3GnzoDGtd083qP8f3Qsuy5Qy3aG5B2OWzr6HDYyNPPUU0JBN6m02aNMh0xxI9zFamG4nSa4VHysYIxa2CPdiVJexu1cmg8icWBFm%2FhihMHgWGGI1RkfB363OmoV%2BejS5XHOSrrawR%2FDLi6Us78e7SvOlTv6fMGr7Zoomce%2FRscd8osxDLI0AP5UHm4GtMblmZjpzvyvPdDzZFIg7Mg7FGwzW3M1X%2F7ZTF5tlkbpuo3hzqu9R7JB3C2tMZjNCJqEzVv3DRilwaor5pBVcse4xlBNIvOoo7aoGwEORoesjcwVaqBVSbHoa0ysDCsKofhbXUY5r0yKdjqOTpXY7T97o1n9c8dcbObw%2FhKb7qvx9BRh3yamZ1ReTZEyrZ5IC5lq0m1cWQca9LXnbrhVZ6Kd3jj5fAfNmhJDY%2BC9rVluQmiQ48%2BN1INRQMhqOkuxWme0AaQGDHUhu3WrkjD281pq4%2BFths60UWrjs46xufX5iQ%2BMTV3pPa2VLPXeulcuUW0RqzRVqpaH5%2F2K10xoz4oh400QikxqimicX8zjlSZq6WgPabQQXsof0m4IW1qzYbKMvuA1mLqzRjB12ZY87Zom%2BFebksXD29lmmz3%2BjD8R0xGfk9RLKrfKgcBzbVJKqU2vUk1A0OhCd54aBnfN8zV6nViC3LPhFkUtQ7iOi75hGaLT9sUUXuaRfmrecoe72%2FYNre8pOHobeIauXlgo6JQgFo2%2FOyMwWGQ1Aj6KR54P3ojC9ekGI%2FoNGYbOtRudPS5GQvZFjCGKiKtaSccthqd%2FgD7NnMDM4KrE9pQQfJp4xrkj2E8ioy98GszTBT%2FAHlzaPYa0h1cfQ59voamhmpd6q35HMS2pfYp2G7ZsNHSKS4dRzNquEntVTaFttZhXmtGTM2yqT4Ayz%2FDcBj%2FijFLaYTOzqLelMmN4SHFxuOudS4f1Eyg3vzHshSUBh%2FQhit2xVdt4eFoMv%2BKVZTRMOs5Tkkqme15QG7%2BVWpY1cghMrreTrQ4NqVT9L6tuOU9euAzg49o%2FI0aTmeQyzNAPQU4Aw4y98tqO2K9emAEnfIWHEhZrBM31bEOncJe0lCqUX8s5mv8avyB8fJefR5hNvNKuKGgMeorcLXRLI1iTqHhr5wEhHbTzvgbNOXmXy1xylsh9BkzvqpgmxuDaH4zHPU3YBuuTVKZaNhGoN3ECnQjKq4RuC%2F6V58jwkZ6pMHDdPyqBJqx9%2FSGmibpUz0zi474ahGbZ2vnjhpqtaRTWNCwa%2FZGY3xSAox8E7Z9VXFGbnM3w9auTLa34Udt06TWt4ktrHKOT5sqexuTJqUeaLV5VksPFtR4tmJNFGVkdQNkQGCiKGspxjOVqdk80OSAQ4fbW9yQhra0yuNpImG7hXqMJ6E18xrn3UWq%2BulYE0N1hBY0OMevn78PTW6rx9H6B9gTNRfrB1LWjNE7b8tFD0qSUlGjjfqrykOK0DQdC1qW46eRVCs3VbpSeo%2BtSXtbPQ03dE1HAQ4%2FLNZQG52C4mF7g9AMMDKDv1vU6r0Hjmuz1E%2Bjp%2BOOufRwZRqzve50dKhzH8BUAirYFq06R3v3zL%2BiyrBNTQ20FRBglO3iG%2F8jSxsaBjRg0zlaampRGTWU0qQc1HSIkQTit90PJmOWctMR47QtUHM4PEIYPDn3uCCGal366d58%2B7SRqjJf2dKmh2HtCnv81VBquCmMWc3Y2BrhNDLae5zOKZ%2B%2FT8u%2BAeqDborxGwr1PEZQROuVpI5QQc31ALjy5ILoSr82nQGEYUcQ1oykWzkOt3Nc2dGtfhrGvsWuje%2FKNlRX3Ew2HS5FT%2FPGXOqq7bf9tZoOycQ9ixu3AfYGVzNJqRu29KYpKKuVPJqu8VRZxUKrCmR%2FJQRt%2BeoMbgGqjVCMWj8ib1ka5rIaxhgRobRgRGgMm8nmYWd%2FbBHqn2AViw5lQLbTz5ilxbuyHV0rzV86fMA%2FulooJdagjchsj4d2r34ainu80R5HPQykNwbGoIivI3jPFpL%2FlBXEwwdsL2FqexOkckCgQyVGdKoTgsPtSPcU7LyPWXqvNTr38%2Ffimnn1GSYfAKdz1lzGb%2BQfI6wP5KNA2M7fwfNIaaBrG9yjNUtbzU0xt0YzOoL1bY%2FaO9JtLLqmwZmG45kixDLsKduWQke3%2BCrL%2BsGzEWsFcW4o0Xm41SyjTEENHb42BcYJ89TXowsdLQTYWFGfD0M5gDSwTUezHP8JEWrH2adFbhOZizYGN7ZofdwcBY9WtSaKB0Auax%2FyPE5kVxwa01%2B%2BNimtbxyoQA6Ms9XuefLQTIw7pm6gaP6DxYXzoxP%2Bqg5fldLwM7aj6FrqpsPRLFMsmkUXoVY3q6Ez7lWn%2FQ6eVKMx6tD50zFqr7a3dYx21mhqncXGI%2B2xzQHweBM%2BaVtPFGyrUakxtexoQxVo4zLIt0gf2yArP30LLQsNjOkq0nr1q97oOH1oNP4ZKWti1luZ%2FO%2Bzt7ltQZzV3I5ZN2RUubVEPYcOk4PbdhWV3GPUtEV8lZezQlNXQ80jTHWuf4zWNdKpsQaQl7o%2BvYmvTZmCTWSLtwi%2F8zu%2F8zu%2F89%2FP%2FwBxZFZVCmVuZHN0cmVhbQplbmRvYmoKOCAwIG9iago8PC9GaWx0ZXIvRmxhdGVEZWNvZGUvTGVuZ3RoIDIxOTE%2BPnN0cmVhbQp4nMVYS3vbuhHd%2B1fMpm3yfTIN4sGHd7IkO0ocSbHo3N7bdEGJtM1GEn0pKrnur%2B8AfIkgaCurZhHTmHMGgzMDYOA%2Fz66CM%2BaARxwIojMC54Jy%2BTUJzr6c%2FXlGLFdQn4P%2BM3s8s6klQDABjFieD9SBLD57eI3BmcV8RbEdYvn2KRxKuMUUhzsW4ydRBLEcXnBsyz4pNOrblsd%2FbTmMUsstYhO2xU%2FjuNyiVHFQAnGaatxykcJdsLlruR7%2BOJlUa2DTtzmukElR%2BSGOJRqSrAQKH7E8bs5k1D%2BL5Dty9S4BmzGJW7Zswi1sTtfE7V4TwaLwMGZybKK%2BK21Urlyi7NZ0HMF9NuFVXrfVNyI3mmfhSHs5C2I3ZW2r4TquDsvzVKztGRuP0kbdzhKlSa6edOPkqGcVp4SVcRTDtjZaRHcMbk%2BjVXKtlnkP1uEbt1ttNe%2BsZu3GTVSZe%2FZLbTZujSZu8y7Q7VrB1%2Bae2m5rRqrAGTFoRmrN2uZSM1JppllLzUgVmGYuNeuZutKMVJpp5lIzUmqmx11o0jN1pQmpNWnbi%2BJTIcnAbEMdcTWuNLENdSTNhSa2qY6UuUiWqY76pi41keZCE9tUR9KsNNHjVpr0TV1qotZV1kn3lJIb8hVN0PqaJjL0VzSR5lc06Zm60kTWyiuaoPkVTXqmrjSR6zJpUh5euKLt0UGmnUfcIdUh1rZyx6vN5beBjdt%2B2zjqsJW58aSzVWi1ow5bmRtPG31llDQrQ2x1LNNmRUejxzKoYc0ZObJTUtNIEyIlHRbzj0Ig9WTFsN2MaizXqVkSWrHUsN2MaixxxEJoxRINS41qLNqsS0IrFq3XVYy2EsOaVUvg5niUCV5%2Bd6Y6krCgNdjtEeZVHtGms%2FtY1KMmVjFsG0cbonSGzRNuGAfklc9xmIIrGKy3cJFsH20Yp%2FBFNuG2AtiVPUDnioS9%2BHn9hV35EdSWq3aZAl9c4%2B%2BIeTh7F38%2BRE9ZCLfJNsnj6H3wHyMP70%2FFo%2BC3aOPkMcnDDYzC7fNhL9ltHl4XyHu32KQ5zFIL79GFDUMIdxEU31cwSrNdnA1g%2BWIhwhcLZXWtjjPHd5Szq026%2Fi69UXsgFZBwzOwizPIBXIW7x3CTZjFMAzn0Haa76LDPswSjHGZxOOj69dzCL3xCxuYlhK%2FJZhM%2BxgP4GG5C%2BJCuNsmx51ma5U8GP9hnKD%2BxBB6yA9xnq3A3gE9htgvz8Hs4wOaL2ty3Spkx19gQKhe2POiFikZU%2BWadfDcQmXLFVSmvvtqpo4JZDjh4xrVzPvnnYn4XAExnX%2BfT0cSQczULr3Ne8qZ3MzgH2%2FPjyGUrQVbRKuJe7IZECO64a86dh7XthBFxVg888sTKcaIVZaHvrYSPNURDR7C%2B6VgdZlFiV5iEOIIgvWwprbDUVkrPMPVP4ffDNsy6GKwviRmluzxc57CIs326u4TrTfKMCcm7BMLaNb6qEj6A202cRCHAv27jDD%2F%2BPegugvq%2BaRXT3Y80WcuauQRJatxTH%2FNAGXHxQOq645z2OxuHeay7s9n5x8PmnBJqULgMznbb%2FkaHLIt36xfd1%2F1y3JMl4WshjeNN8iPOehIlvLcTJdxfTJRwtERBnSkwpEoeAEnYp0lnQVeHlzj7xx7mWRRnsOukzRMUc8N8x%2Bt16WoyF65k0qJfyprtFq8Yv3LnKc5ygwdlF41Hg3wt6uhxvF9nyXOepDtIH%2BAmTaP9xTLOfmAh7Q1LIL6FbY0QtjbpcHTxYTkzEISwCO0SvhzCXZ7kLyaVXDzmu4z7%2BecumDHfwiLqgO9QTQPap%2BqVIkgbfZOl%2B9btxDzfYg6qVdTecJsedrlhF2JCqdf1N71ZBqY961iOfE27p8Qq8NKwu%2BC%2BUKhQxUDr2i%2FgtiEMV71AdWS8XSaPOzzAx%2Bn6sI13OcjfsTDPoTAd61P6sIvrO0ifD8%2BGkOQKWHcm3%2FeY7xvwjq9K5YQ1MEIsPK060FlqqFnGPFWzOpgyiwtTkXCLOyfDOd7Xgnfh5G%2BGnFLH8j0D1rC5HdmI%2BxqwL5snACnx5IvxBCA2wPLxeALSF%2Bod%2BTaS4cLdk2ZnrqdelyesHNPqnoZ0ffXofBtZqO5qey5IsYftlb5Cvyn9CcBS%2BhOQpfRvIyvpT0AWhd9B9hV%2Bqf%2Fbjsuy7yBNZc%2FVs5u2b8ji0INkBz%2FTLNrrt2TwE4%2BrFwiesjgGbE5Uy3%2BNXfgLXGP3AaPJLFjCfLcx3DbcZio2fUp1JcBXbNRNZ7NwjaQepSgr%2Fhzgt%2FsSQzBSKL9BVn5VAUIQ%2FmUIxRXHvitGr7TMaQMNZeAJy3e7yCKIqsf88boyOrlHGdWwcarJuAhf1AV0F2ND%2BH0P%2F99%2FWEjw9jNYLYRoGS46u%2Bn48rWuUFGxM9Eb5%2FUmzELZl132cYoHZTPdbzFEihZD%2FhTm%2BF%2Byx01TJGz%2FlP7c41AM2EUfMJPPmRzGnk%2BOPWdpdFjn%2B4t92fqhJ9kYrrBzl7tJ%2BusLw9Ee%2F%2BEGnWNnnqwPGMseVDzZIVaO1im%2BKda51eeMa86%2BvVveLxa3v8PnyXAWwPX8Dor36UU5HsxhOfkD7mf4oEej%2FB5Pvk5u54vJnYR33geMFV0LDO%2BDD%2FO76XIyBgkeBtP5bImOxkgEuJrPxtLh7SQIcGB%2BXViC4afp7AZ%2BmyL3Pug6p8UfIWAx%2FP0zHjuSJ7tB%2BPbu9j6A2dyC4Zj6hOHLjvpM%2FjEmko0%2FUPeCsAvZ6MO399%2Fe98lja2Uysf4%2BnxjExFI8hldqDg%2F5U5ol%2F8UJZUsX5mlmOBOZY2FnR33WnkvbBAMYTWf6UXzr4uLIpyElxFvcjgi%2Bmh1vUGDq7T3s0IbD4egPxrh7o2FRuu4k1G%2FwhBCNsswNz9%2F6byyYiFEaxZfUL0Suz2gu5DVJPa5dPXeGYHGN3PYwV64n7kv9%2FgcU3sSiCmVuZHN0cmVhbQplbmRvYmoKMTAgMCBvYmoKPDwvQ29udGVudHMgOCAwIFIvVHlwZS9QYWdlL1Jlc291cmNlczw8L0ZvbnQ8PC9GMSA0IDAgUi9GMiA1IDAgUj4%2BL1hPYmplY3Q8PC9pbWczIDcgMCBSL2ltZzIgNiAwIFIvaW1nMSAzIDAgUi9pbWcwIDEgMCBSPj4%2BPi9QYXJlbnQgOSAwIFIvTWVkaWFCb3hbMCAwIDU5NSA4NDJdPj4KZW5kb2JqCjQgMCBvYmoKPDwvU3VidHlwZS9UeXBlMS9UeXBlL0ZvbnQvQmFzZUZvbnQvSGVsdmV0aWNhLUJvbGQvRW5jb2RpbmcvV2luQW5zaUVuY29kaW5nPj4KZW5kb2JqCjUgMCBvYmoKPDwvU3VidHlwZS9UeXBlMS9UeXBlL0ZvbnQvQmFzZUZvbnQvSGVsdmV0aWNhL0VuY29kaW5nL1dpbkFuc2lFbmNvZGluZz4%2BCmVuZG9iago5IDAgb2JqCjw8L0tpZHNbMTAgMCBSXS9UeXBlL1BhZ2VzL0NvdW50IDE%2BPgplbmRvYmoKMTEgMCBvYmoKPDwvVHlwZS9DYXRhbG9nL1BhZ2VzIDkgMCBSPj4KZW5kb2JqCjEyIDAgb2JqCjw8L01vZERhdGUoRDoyMDIzMDcxMzEwMjgyOSswNSczMCcpL0NyZWF0aW9uRGF0ZShEOjIwMjMwNzEzMTAyODI5KzA1JzMwJykvUHJvZHVjZXIoaVRleHSuIDUuNS41IKkyMDAwLTIwMTQgaVRleHQgR3JvdXAgTlYgXChBR1BMLXZlcnNpb25cKSk%2BPgplbmRvYmoKeHJlZgowIDEzCjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAxNSAwMDAwMCBuIAowMDAwMDAzMTU4IDAwMDAwIG4gCjAwMDAwMDU4NDUgMDAwMDAgbiAKMDAwMDAxNDU2MyAwMDAwMCBuIAowMDAwMDE0NjU2IDAwMDAwIG4gCjAwMDAwMDkwODEgMDAwMDAgbiAKMDAwMDAwOTI3MSAwMDAwMCBuIAowMDAwMDEyMTI2IDAwMDAwIG4gCjAwMDAwMTQ3NDQgMDAwMDAgbiAKMDAwMDAxNDM4NSAwMDAwMCBuIAowMDAwMDE0Nzk2IDAwMDAwIG4gCjAwMDAwMTQ4NDIgMDAwMDAgbiAKdHJhaWxlcgo8PC9JbmZvIDEyIDAgUi9JRCBbPDU3MmZjMDMzYzg1NDkzYmY3ZDMyMGNmZWYxMjM4ZmI3Pjw1NzJmYzAzM2M4NTQ5M2JmN2QzMjBjZmVmMTIzOGZiNz5dL1Jvb3QgMTEgMCBSL1NpemUgMTM%2BPgolaVRleHQtNS41LjUKc3RhcnR4cmVmCjE1MDAwCiUlRU9GCg%3D%3D\"\r\n }\r\n \r\n ]\r\n\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/SubmitDocument", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "SubmitDocument" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "171" + }, + { + "key": "Date", + "value": "Tue, 02 Apr 2024 11:05:07 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"meta\": {\n \"ver\": \"1.0\",\n \"ts\": \"2024-04-02T16:35:05+05:30\",\n \"txn\": \"c7a05252a91e45bba1c77e1553a5d5f0\",\n \"status\": \"0\",\n \"errorCode\": \"EMS-913\",\n \"errorMessage\": \"Invalid Account Number\"\n }\n}" + } + ] + } + ] + }, + { + "name": "Cancel Order", + "item": [ + { + "name": "RejectOrder", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "pm.test('Status code is 200', () => {", + " pm.response.to.have.status(200);", + "});", + "", + "if (status === '1') {", + " pm.test('Request Successful', () => {", + " pm.expect(status).to.equal('1');", + " });", + "} else {", + " pm.test('Request FAILED: ' + (errMsg || 'Unknown error'), () => {", + " pm.expect(status).to.equal('1');", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"orderNumber\": \"{{orderNumber}}\",\r\n \"rejectRemarks\": \"Test @123\" \r\n }\r\n}\r\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}RejectOrder", + "host": [ + "{{baseURL}}RejectOrder" + ] + }, + "description": "\nThis API allows cancellation of below certificate orders.\n\n- emSign SSL - DV / OV / EV\n \n- emSign S/MIME - Simple\n \n- Signature - Natural Person / Legal Person / Legal Entity\n \n\n| **Region** | **URL** |\n| --- | --- |\n| US | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n| Global | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n\n**API Endpoint**\n\n| Category | Specification |\n| --- | --- |\n| URL | http(s)://(url host port and path)/RejectOrder |\n| Protocol | HTTP / HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"accountNumber\": \"\",\n \"authKey\": \"\"\n },\n \"orderDetails\": {\n \"orderNumber\": \"\",\n \"rejectRemarks\": \"\" \n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order. Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | 1234567890 (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | 6288275133 (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | 952a95057654aa1c26a0f287b6368579f5f3ec99f213543df5e0f7eb7f798b3d (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| orderNumber | 7867866567 (mandatory)
Unique Order ID of the respective order. |\n| rejectRemarks | Invalid Document (mandatory)
Rejection reason for the request. |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | 1234567890 (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | 1 (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |" + }, + "response": [ + { + "name": "RejectOrder", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n\"meta\": {\r\n \"ver\":\"1.0\",\r\n \"ts\":\"{{ts}}\",\r\n \"txn\":\"{{txn}}\",\r\n \"accountNumber\":\"4397827229\",\r\n \"authKey\":\"{{hash}}\" \r\n },\r\n \"orderDetails\": {\r\n \"orderNumber\": \"6571855486\",\r\n \"rejectRemarks\": \"Test @123\" \r\n }\r\n}\r\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/RejectOrder", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "RejectOrder" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "181" + }, + { + "key": "Date", + "value": "Tue, 02 Apr 2024 11:05:44 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"meta\": {\n \"ver\": \"1.0\",\n \"ts\": \"2024-04-02T16:35:42+05:30\",\n \"txn\": \"ba0c26750a914f92bc17d79bdbddcf9c\",\n \"status\": \"0\",\n \"errorCode\": \"EMS-985\",\n \"errorMessage\": \"This Order was already cancelled\"\n }\n}" + } + ] + } + ] + }, + { + "name": "RejectRequest", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "pm.test('Status code is 200', () => {", + " pm.response.to.have.status(200);", + "});", + "", + "if (status === '1') {", + " pm.test('Request Successful', () => {", + " pm.expect(status).to.equal('1');", + " });", + "} else {", + " pm.test('Request FAILED: ' + (errMsg || 'Unknown error'), () => {", + " pm.expect(status).to.equal('1');", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"requestNumber\": \"{{requestNumber}}\",\r\n \"rejectRemarks\": \"\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}RejectRequest", + "host": [ + "{{baseURL}}RejectRequest" + ] + } + }, + "response": [] + }, + { + "name": "AgreementAcceptance", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "pm.test('Status code is 200', () => {", + " pm.response.to.have.status(200);", + "});", + "", + "if (status === '1') {", + " pm.test('Request Successful', () => {", + " pm.expect(status).to.equal('1');", + " });", + "} else {", + " pm.test('Request FAILED: ' + (errMsg || 'Unknown error'), () => {", + " pm.expect(status).to.equal('1');", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"agreementDetails\": {\r\n \"requestorEmail\": \"{{requestorEmail}}\",\r\n \"orderNumber\": \"{{orderNumber}}\",\r\n \"acceptAgreement\": \"1\",\r\n \"signerName\": \"{{requestorName}}\",\r\n \"signerPlace\": \"{{signerPlace}}\",\r\n \"signerIP\": \"{{signerIP}}\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}AgreementAcceptance", + "host": [ + "{{baseURL}}AgreementAcceptance" + ] + } + }, + "response": [] + } + ], + "description": "This section includes following APIs.\n\n- Generate SSL Order\n- Generate SMIME Order\n- Generate Signature Order\n \n- Generate Private PKI\n \n- Generate Intranet SSL\n \n- Track Order\n- Get & Verify DCV\n- Submit Document & CSR\n- Cancel Order" + }, + { + "name": "Certificates", + "item": [ + { + "name": "Download Certificate", + "item": [ + { + "name": "Download SSL", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "pm.test('Status code is 200', () => {", + " pm.response.to.have.status(200);", + "});", + "", + "if (status === '1') {", + " pm.test('Request Successful', () => {", + " pm.expect(status).to.equal('1');", + " });", + "} else {", + " pm.test('Request FAILED: ' + (errMsg || 'Unknown error'), () => {", + " pm.expect(status).to.equal('1');", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"requestorEmail\": \"{{requestorEmail}}\",\r\n \"orderNumber\": \"{{orderNumber}}\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}GetCertificate", + "host": [ + "{{baseURL}}GetCertificate" + ] + }, + "description": "[Download certificate via CERTInext ](https://docs.emsign.com/emsign-certhub/certificate-management/download-certificate) This API facilitates to get certificate or download certificate for all emSign SSL - DV / OV / EV certificate orders.\n\n**API Endpoint**\n\n| Category | Specification |\n| --- | --- |\n| URL | http(s)://(url host port and path)/GetCertificate |\n| Protocol | HTTP / HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"accountNumber\": \"\",\n \"authKey\": \"\"\n },\n \"orderDetails\": {\n \"requestorEmail\": \"\",\n \"orderNumber\": \"\", \n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order. Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | 1234567890 (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | 6288275133 (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | 952a95057654aa1c26a0f287b6368579f5f3ec99f213543df5e0f7eb7f798b3d (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestorEmail | [yashwnath.t@gmail.com](https://mailto:yashwnath.t@gmail.com) (mandatory)
Registered Email ID of the certificate requestor associated with the respective emSign Certificate Order. |\n| orderNumber | 9867647 (mandatory)
Unique Order ID of the respective order. |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n },\n \"certificateDetails\": {\n \"rootCertificate\": \"\",\n \"caCertificate\": \"\",\n \"subCACertificate\": \"\",\n \"endEntityCertificate\": \"\",\n \"certificateSerialNumber\": \"\",\n \"expiryDate\": \"\",\n \"interimDv\": \"\"\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| certificateDetails | (mandatory)
Compressed ZIP folder contains Root Certificate, CA Certificate, Sub CA Certificate & End Entity Certificate. If \"interimDv\" is 1 (True) then the certificate details responded are related to DV certificate.
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | 1234567890 (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | 1 (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success.
Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success.
Mandatory in case of failure. |\n\n**Certificate Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| rootCertificate | eMudhra Sandbox Private Root CA G1 (mandatory)
Root CA Certificate of emSign. |\n| caCertificate | Private (mandatory)
Intermediate / Issuer CA Certificate. |\n| subCACertificate | emSign Sandbox Issuing CA - G1 (optional)
Subordinate CA Certificate. |\n| endEntityCertificate | (mandatory)
End Entity Certificate. |\n| certificateSerialNumber | 11551504729657302953 (mandatory)
Serial Number of the Certificate. |\n| expiryDate | 2024-11-06 04:58:31 (mandatory)
Expiry Date of the respective End Entity Certificate in UTC format. |\n| interimDv | 1 (conditional mandatory)
Status of the interim DV certificate. Interim DV Certificate can be used for your temporary replacement so that you need not wait to get final certificate.
This is mandatory for OV and EV certificate orders.
1 - Certificate issued is an Interim DV Certificate for your temporary replacement
0 - Final Certificate (OV / EV). |" + }, + "response": [ + { + "name": "Download TLS", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\":\"2395971468\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"requestorEmail\": \"nandhakumar.n@emudhra.com\",\r\n \"orderNumber\":\"1694552317\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/GetCertificate", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "GetCertificate" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "305" + }, + { + "key": "Date", + "value": "Tue, 02 Apr 2024 11:06:14 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"certificateDetails\": {\n \"rootCertificate\": \"\",\n \"caCertificate\": \"\",\n \"endEntityCertificate\": \"\",\n \"expiryDate\": \"\",\n \"ceritficateSerialNumber\": \"\"\n },\n \"meta\": {\n \"ver\": \"1.0\",\n \"ts\": \"2024-04-02T16:36:12+05:30\",\n \"txn\": \"b51c508582ae4f2280a7bd6a2a68b471\",\n \"status\": \"0\",\n \"errorCode\": \"EMS-913\",\n \"errorMessage\": \"Invalid Account Number\"\n }\n}" + } + ] + }, + { + "name": "Download SMIME", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "pm.test('Status code is 200', () => {", + " pm.response.to.have.status(200);", + "});", + "", + "if (status === '1') {", + " pm.test('Request Successful', () => {", + " pm.expect(status).to.equal('1');", + " });", + "} else {", + " pm.test('Request FAILED: ' + (errMsg || 'Unknown error'), () => {", + " pm.expect(status).to.equal('1');", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"requestorEmail\": \"{{requestorEmail}}\",\r\n \"orderNumber\": \"{{orderNumber}}\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}GetCertificate", + "host": [ + "{{baseURL}}GetCertificate" + ] + }, + "description": "This API facilitates to download emSign S/MIME - Simple certificates. This API is applicable only if the certificate issued was based on the CSR submitted by the certificate requestor i.e., CSR based certificate issuance.\n\n**NOTE:** This API is not applicable for the certificates issued for the purpose of qualified hardware or PFX / P12 certificates.\n\n**API Endpoint**\n\n| Category | Specification |\n| --- | --- |\n| URL | http(s)://(url host port and path)/GetCertificate |\n| Protocol | HTTP / HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"accountNumber\": \"\",\n \"authKey\": \"\"\n },\n \"orderDetails\": {\n \"requestorEmail\": \"\",\n \"orderNumber\": \"\", \n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order. Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | (mandatory)
Version should be set to \"1.0\". |\n| ts | (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestorEmail | (mandatory)
Registered Email ID of the certificate requestor associated with the respective emSign Certificate Order. |\n| orderNumber | (mandatory)
Unique Order ID of the respective order. |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n },\n \"certificateDetails\": {\n \"rootCertificate\": \"\",\n \"caCertificate\": \"\",\n \"subCACertificate\": \"\",\n \"endEntityCertificate\": \"\",\n \"certificateSerialNumber\": \"\",\n \"expiryDate\": \"\"\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| certificateDetails | (mandatory)
Compressed ZIP folder contains Root Certificate, CA Certificate, SubCA Certificate & End Entity Certificate. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | (mandatory)
Version should be set to \"1.0\". |\n| ts | (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success.
Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success.
Mandatory in case of failure. |\n\n**Certificate Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| rootCertificate | (mandatory)
Root CA Certificate of emSign. |\n| caCertificate | (mandatory)
Intermediate / Issuer CA Certificate. |\n| subCACertificate | (optional) Subordinate CA Certificate. |\n| endEntityCertificate | (mandatory)
End Entity Certificate. |\n| certificateSerialNumber | (mandatory)
Serial Number of the Certificate. |\n| expiryDate | (mandatory)
Expiry Date of the respective End Entity Certificate in UTC format. |" + }, + "response": [ + { + "name": "Download SMIME", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\":\"2395971468\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"requestorEmail\": \"nandhakumar.n@emudhra.com\",\r\n \"orderNumber\":\"1694552317\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/GetCertificate", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "GetCertificate" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "305" + }, + { + "key": "Date", + "value": "Tue, 02 Apr 2024 11:06:31 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"certificateDetails\": {\n \"rootCertificate\": \"\",\n \"caCertificate\": \"\",\n \"endEntityCertificate\": \"\",\n \"expiryDate\": \"\",\n \"ceritficateSerialNumber\": \"\"\n },\n \"meta\": {\n \"ver\": \"1.0\",\n \"ts\": \"2024-04-02T16:36:29+05:30\",\n \"txn\": \"05b00221ddec4f64899317b12e161c66\",\n \"status\": \"0\",\n \"errorCode\": \"EMS-913\",\n \"errorMessage\": \"Invalid Account Number\"\n }\n}" + } + ] + }, + { + "name": "Download Document Signer", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "pm.test('Status code is 200', () => {", + " pm.response.to.have.status(200);", + "});", + "", + "if (status === '1') {", + " pm.test('Request Successful', () => {", + " pm.expect(status).to.equal('1');", + " });", + "} else {", + " pm.test('Request FAILED: ' + (errMsg || 'Unknown error'), () => {", + " pm.expect(status).to.equal('1');", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"requestorEmail\": \"{{requestorEmail}}\",\r\n \"orderNumber\": \"{{orderNumber}}\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}GetCertificate", + "host": [ + "{{baseURL}}GetCertificate" + ] + }, + "description": "This API facilitates to download Document Signer Certificates. This API is applicable only if the certificate issued was based on the attested CSR submitted by the certificate requestor i.e., attested CSR based certificate issuance.\n\n**API Endpoint**\n\n| Header | Header |\n| --- | --- |\n| URL | http(s)://(url host port and path)/GetCertificate |\n| Protocol | HTTP / HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"accountNumber\": \"\",\n \"authKey\": \"\"\n },\n \"orderDetails\": {\n \"requestorEmail\": \"\",\n \"orderNumber\": \"\" \n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order. Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | (mandatory)
Version should be set to \"1.0\". |\n| ts | (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestorEmail | (mandatory)
Registered Email ID of the certificate requestor associated with the respective emSign Certificate Order. |\n| orderNumber | (mandatory)
Unique Order ID of the respective order. |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n },\n \"certificateDetails\": {\n \"rootCertificate\": \"\",\n \"caCertificate\": \"\",\n \"subCACertificate\": \"\",\n \"endEntityCertificate\": \"\",\n \"expiryDate\": \"\"\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| certificateDetails | (mandatory)
Compressed ZIP folder contains Root Certificate, CA Certificate, Sub CA Certificate & End Entity Certificate.
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | (mandatory)
Version should be set to \"1.0\". |\n| ts | (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success.
Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success.
Mandatory in case of failure. |\n\n**Certificate Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| rootCertificate | (mandatory)
Root CA Certificate of emSign. |\n| caCertificate | (mandatory)
Intermediate / Issuer CA Certificate. |\n| subCACertificate | (optional)
Subordinate CA Certificate. |\n| endEntityCertificate | (mandatory)
End Entity Certificate. |\n| expiryDate | (mandatory)
Expiry Date of the respective End Entity Certificate in UTC format. |\n\n" + }, + "response": [ + { + "name": "Download Private PKI", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\":\"2395971468\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"requestorEmail\": \"nandhakumar.n@emudhra.com\",\r\n \"orderNumber\":\"1694552317\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/GetCertificate", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "GetCertificate" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "305" + }, + { + "key": "Date", + "value": "Tue, 02 Apr 2024 11:06:57 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"certificateDetails\": {\n \"rootCertificate\": \"\",\n \"caCertificate\": \"\",\n \"endEntityCertificate\": \"\",\n \"expiryDate\": \"\",\n \"ceritficateSerialNumber\": \"\"\n },\n \"meta\": {\n \"ver\": \"1.0\",\n \"ts\": \"2024-04-02T16:36:56+05:30\",\n \"txn\": \"f264f80cba33443e99d6fa64d8ed405a\",\n \"status\": \"0\",\n \"errorCode\": \"EMS-913\",\n \"errorMessage\": \"Invalid Account Number\"\n }\n}" + } + ] + }, + { + "name": "Download Private PKI", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "pm.test('Status code is 200', () => {", + " pm.response.to.have.status(200);", + "});", + "", + "if (status === '1') {", + " pm.test('Request Successful', () => {", + " pm.expect(status).to.equal('1');", + " });", + "} else {", + " pm.test('Request FAILED: ' + (errMsg || 'Unknown error'), () => {", + " pm.expect(status).to.equal('1');", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"requestorEmail\": \"{{requestorEmail}}\",\r\n \"orderNumber\": \"{{orderNumber}}\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}GetCertificate", + "host": [ + "{{baseURL}}GetCertificate" + ] + }, + "description": "This API facilitates to get certificate or download certificate for all Private PKI Products.\n\n**API Endpoint**\n\n| Header | Header |\n| --- | --- |\n| URL | http(s)://(url host port and path)/GetCertificate |\n| Protocol | HTTP / HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"accountNumber\": \"\",\n \"authKey\": \"\"\n },\n \"orderDetails\": {\n \"requestorEmail\": \"\",\n \"orderNumber\": \"\", \n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order. Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | (mandatory)
Version should be set to \"1.0\". |\n| ts | (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| requestorEmail | (mandatory)
Registered Email ID of the certificate requestor associated with the respective emSign Certificate Order. |\n| orderNumber | (mandatory)
Unique Order ID of the respective order. |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n },\n \"certificateDetails\": {\n \"rootCertificate\": \"\",\n \"caCertificate\": \"\",\n \"subCACertificate\": \"\",\n \"endEntityCertificate\": \"\",\n \"expiryDate\": \"\"\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| certificateDetails | (mandatory)
Compressed ZIP folder contains Root Certificate, CA Certificate, Sub CA Certificate & End Entity Certificate.
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | (mandatory)
Version should be set to \"1.0\". |\n| ts | (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success.
Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success.
Mandatory in case of failure. |\n\n**Certificate Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| rootCertificate | (mandatory)
Root CA Certificate of emSign. |\n| caCertificate | (mandatory)
Intermediate / Issuer CA Certificate. |\n| subCACertificate | (optional)
Subordinate CA Certificate. |\n| endEntityCertificate | (mandatory)
End Entity Certificate. |\n| expiryDate | (mandatory)
Expiry Date of the respective End Entity Certificate in UTC format. |\n\n" + }, + "response": [ + { + "name": "Download Private PKI", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\":\"2395971468\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"orderDetails\": {\r\n \"requestorEmail\": \"nandhakumar.n@emudhra.com\",\r\n \"orderNumber\":\"1694552317\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/GetCertificate", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "GetCertificate" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "305" + }, + { + "key": "Date", + "value": "Tue, 02 Apr 2024 11:06:57 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"certificateDetails\": {\n \"rootCertificate\": \"\",\n \"caCertificate\": \"\",\n \"endEntityCertificate\": \"\",\n \"expiryDate\": \"\",\n \"ceritficateSerialNumber\": \"\"\n },\n \"meta\": {\n \"ver\": \"1.0\",\n \"ts\": \"2024-04-02T16:36:56+05:30\",\n \"txn\": \"f264f80cba33443e99d6fa64d8ed405a\",\n \"status\": \"0\",\n \"errorCode\": \"EMS-913\",\n \"errorMessage\": \"Invalid Account Number\"\n }\n}" + } + ] + } + ] + }, + { + "name": "Revoke Certificate", + "item": [ + { + "name": "RevokeOrder", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "pm.test('Status code is 200', () => {", + " pm.response.to.have.status(200);", + "});", + "", + "if (status === '1') {", + " pm.test('Request Successful', () => {", + " pm.expect(status).to.equal('1');", + " });", + "} else {", + " pm.test('Request FAILED: ' + (errMsg || 'Unknown error'), () => {", + " pm.expect(status).to.equal('1');", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"revocationDetails\": {\r\n \"orderNumber\": \"{{orderNumber}}\",\r\n \"requestorEmail\": \"{{requestorEmail}}\",\r\n \"revokeReasonId\": \"1\", \r\n \"revokeRemarks\": \"Lost Private Key\" \r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}RevokeOrder", + "host": [ + "{{baseURL}}RevokeOrder" + ] + }, + "description": "[Revoke Certificate via CERTInext](https://docs.emsign.com/emsign-certhub/certificate-management/revoke-an-issued-certificate) - This API facilitates to revoke order for below certificate orders.\n\n- emSign SSL - DV / OV / EV\n \n- emSign S/MIME - Simple\n \n- Signature - Natural Person / Legal Person / Legal Entity\n \n- Private PKI\n \n\n| **Region** | **URL** |\n| --- | --- |\n| US | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n| Global | [https://sandbox-us.certinext.io/](https://sandbox-us.certinext.io/) |\n\n**API Endpoint**\n\n| Category | Specification |\n| --- | --- |\n| URL | http(s)://(url host port and path)/RevokeOrder |\n| Protocol | HTTP / HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"accountNumber\": \"\",\n \"authKey\": \"\"\n },\n \"revocationDetails\": {\n \"orderNumber\": \"\",\n \"requestorEmail\": \"\",\n \"revokeReasonId\": \"\", \n \"revokeRemarks\": \"\" \n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Order details of the respective order. Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | (mandatory)
Version should be set to \"1.0\". |\n| ts | (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Revocation Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| orderNumber | (mandatory)
Unique Order ID of the respective order. |\n| requestorEmail | (mandatory)
Registered Email ID of the certificate requestor associated with the respective emSign Certificate Order. |\n| revokeReasonId | (mandatory)
Revocation reason for the request.
1 - KeyCompromise
3 - AffiliationChanged
4 - Superseded
5 - cessationOfOperation
9 - privilegeWithdrawn |\n| revokeRemarks | (mandatory)
Revocation reason for the request. |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | (mandatory)
Version should be set to \"1.0\". |\n| ts | (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success.
Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success.
Mandatory in case of failure. |" + }, + "response": [ + { + "name": "RevokeOrder", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n\"meta\": {\r\n \"ver\":\"1.0\",\r\n \"ts\":\"{{ts}}\",\r\n \"txn\":\"{{txn}}\",\r\n \"accountNumber\":\"4391755311\",\r\n \"authKey\":\"{{hash}}\" \r\n },\r\n \"revocationDetails\": {\r\n \"orderNumber\": \"5585846517\",\r\n \"requestorEmail\": \"nandhakumar.n@emudhra.com\",\r\n \"revokeReasonId\": \"1\", \r\n \"revokeRemarks\": \"test\" \r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/RevokeOrder", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "RevokeOrder" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "171" + }, + { + "key": "Date", + "value": "Tue, 02 Apr 2024 11:07:16 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"meta\": {\n \"ver\": \"1.0\",\n \"ts\": \"2024-04-02T16:37:14+05:30\",\n \"txn\": \"778cabeb4e644ba6b487995c4d64e7a9\",\n \"status\": \"0\",\n \"errorCode\": \"EMS-913\",\n \"errorMessage\": \"Invalid Account Number\"\n }\n}" + } + ] + } + ] + } + ], + "description": "This section includes following APIs.\n\n- Download SSL\n \n- Download SMIME\n \n- Download Signature\n \n- Download Private PKI\n \n- Revoke Certificate" + }, + { + "name": "Accounts", + "item": [ + { + "name": "ValidateCredentials", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "pm.test('Status code is 200', () => {", + " pm.response.to.have.status(200);", + "});", + "", + "if (status === '1') {", + " pm.test('Request Successful', () => {", + " pm.expect(status).to.equal('1');", + " });", + "} else {", + " pm.test('Request FAILED: ' + (errMsg || 'Unknown error'), () => {", + " pm.expect(status).to.equal('1');", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}ValidateCredentials", + "host": [ + "{{baseURL}}ValidateCredentials" + ] + }, + "description": "This API allows you to validate the API access credentials of your user account.\n\n**API Endpoint**\n\n| Category | Specification |\n| --- | --- |\n| URL | http(s)://(url host port and path)/ValidateCredentials |\n| Protocol | HTTP / HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"accountNumber\": \"\",\n \"authKey\": \"\"\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | 1234567890 (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | 6288275133 (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | 952a95057654aa1c26a0f287b6368579f5f3ec99f213543df5e0f7eb7f798b3d (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | 1234567890 (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | 1 (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |" + }, + "response": [ + { + "name": "ValidateCredentials", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n\"meta\": {\r\n \"ver\":\"1.0\",\r\n \"ts\":\"{{ts}}\",\r\n \"txn\":\"{{txn}}\",\r\n \"accountNumber\":\"9288433941\",\r\n \"authKey\":\"{{hash}}\" \r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/ValidateCredentials", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "ValidateCredentials" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "164" + }, + { + "key": "Date", + "value": "Tue, 02 Apr 2024 11:07:45 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"meta\": {\n \"responseTs\": \"2024-04-02T16:37:45+05:30\",\n \"errorMessage\": \"\",\n \"errorCode\": \"\",\n \"responseTxnId\": \"491ca52d11324e5589718d0914dc625f\",\n \"version\": \"1.0\",\n \"status\": \"1\"\n }\n}" + } + ] + }, + { + "name": "GetGroupDetails", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "pm.test('Status code is 200', () => {", + " pm.response.to.have.status(200);", + "});", + "", + "if (status === '1') {", + " pm.test('Request Successful', () => {", + " pm.expect(status).to.equal('1');", + " });", + "} else {", + " pm.test('Request FAILED: ' + (errMsg || 'Unknown error'), () => {", + " pm.expect(status).to.equal('1');", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}GetGroupDetails", + "host": [ + "{{baseURL}}GetGroupDetails" + ] + }, + "description": "This API allows you to get group details associated with the respective account user. Groups can be your cost centers, departments, etc. within your Business. It acts as a feature / tool for managing account user access to organizations, domains and use its finances.\n\n**API Endpoint**\n\n| Category | Specification |\n| --- | --- |\n| URL | http(s)://(url host port and path)/GetGroupDetails |\n| Protocol | HTTP / HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"accountNumber\": \"\",\n \"authKey\": \"\"\n },\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | 928726526 (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | 6288275133 (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | 952a95057654aa1c26a0f287b6368579f5f3ec99f213543df5e0f7eb7f798b3d (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\":{\n \"ver\":\"\",\n \"ts\":\"\",\n \"txn\":\"\",\n \"status\":\"\",\n \"errorCode\":\"\",\n \"errorMessage\":\"\"\n },\n \"accountDetails\":{\n \"fullName\":\"\",\n \"email\":\"\",\n \"isdCode\":\"\",\n \"mobileNumber\":\"\",\n \"designation\":\"\",\n \"roleId\":\"\",\n \"groups\":[\n {\n \"groupNumber\":\"\",\n \"groupName\":\"\",\n \"isDefaultGroup\":\"\",\n \"finance\":\"\",\n \"accountBalance\":\"\", \n \"groupBalance\":\"\",\n \"organizationDetails\":[\n {\n \"organizationNumber\":\"\",\n \"organizationName\":\"\",\n \"validationFor\":\"\" \n }\n ]\n }\n ]\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| accountDetails | (mandatory)
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | 1234567890 (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | 1 (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory) |\n| errorMessage | (conditional mandatory) |\n\n**Account Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| fullName | Sipra Acharya (mandatory)
Name of the account user. |\n| email | [sipra.acharya@gmail.com](https://mailto:sipra.acharya@gmail.com)(mandatory)
Email ID of the account user. |\n| isdCode | +91 (mandatory)
ISD code of the account user's mobile number. |\n| mobileNumber | 9898098667 (mandatory)
Mobile Number of the account user. |\n| designation | Business Analyst (mandatory)
Designation of the account user. |\n| roleId | 1 (mandatory)
Role of the account user.
1 - Administrator
3 - Basic User
4 - Finance Manager
5 - Manager
6 - Standard User |\n| groups | Technology (mandatory)
Specified below. |\n\n**Groups**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| groupNumber | 8194218742 (mandatory)
Group Number of the group. This is auto generated by the system. Groups can be your cost centers, departments, etc. within your Business. It acts as a feature / tool for managing account user access to organizations, domains and finances.
If group number is not given in the request, then system will consider the default group number and amount will be deducted from the account balance. If group is having its own balance, then amount will be deducted from the group balance else the amount will be deducted from the account balance. |\n| groupName | eMudhra (mandatory)
Group Name of the group. |\n| isDefaultGroup | 1 (mandatory)
0 - No
1 - Yes |\n| finance | 1 (mandatory)
Finance / Billing Method of the respective group as set by your CERTInext account administrator. The allowed values for this field are:
1 - If set to 'Deduct from Account Balance'.
2 - If set to 'Deduct from Group Balance'. |\n| accountBalance | 5635363 (conditional mandatory) |\n| groupBalance | (conditional mandatory) |\n| organizationDetails | (mandatory)
Specified below. |\n\n**Organization Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| organizationNumber | 1283685 (mandatory)
Organization Number (Org. ID) of Organization. |\n| organizationName | eMudhra (mandatory)
Organization Name of Organization. |\n| validationFor | 1 (mandatory)
1 - OV (SSL OV / Document Signer OV)
2 - EV & OV
3 - S/MIME OV |" + }, + "response": [ + { + "name": "GetGroupDetails", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\":\"7793288417\",\r\n \"authKey\":\"{{hash}}\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/GetGroupDetails", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "GetGroupDetails" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "1015" + }, + { + "key": "Date", + "value": "Tue, 02 Apr 2024 11:08:01 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"meta\": {\n \"ver\": \"1.0\",\n \"errorMessage\": \"\",\n \"errorCode\": \"\",\n \"txn\": \"6914909823303007\",\n \"status\": \"1\",\n \"ts\": \"2024-04-02T16:38:01+05:30\"\n },\n \"accountDetails\": {\n \"roleId\": \"1\",\n \"mobileNumber\": \"7094940185\",\n \"fullName\": \"Nandhakumar\",\n \"groups\": [\n {\n \"isDefaultGroup\": \"1\",\n \"groupName\": \"fastrack\",\n \"groupBalance\": \"\",\n \"accountBalance\": \"8133.00\",\n \"organizationDetails\": [\n {\n \"organizationName\": \"fastrack\",\n \"organizationNumber\": \"3325789\",\n \"validationFor\": \"1\"\n },\n {\n \"organizationName\": \"emudha with BO\",\n \"organizationNumber\": \"5919142\",\n \"validationFor\": \"2\"\n },\n {\n \"organizationName\": \"Demo Testing in UAT changes\",\n \"organizationNumber\": \"3326758\",\n \"validationFor\": \"2\"\n },\n {\n \"organizationName\": \"Verified Method\",\n \"organizationNumber\": \"6593882\",\n \"validationFor\": \"2\"\n },\n {\n \"organizationName\": \"New order with verified method\",\n \"organizationNumber\": \"4265327\",\n \"validationFor\": \"2\"\n },\n {\n \"organizationName\": \"Verified data user\",\n \"organizationNumber\": \"4929168\",\n \"validationFor\": \"2\"\n }\n ],\n \"groupNumber\": \"6787449116\",\n \"finance\": \"1\"\n }\n ],\n \"isdCode\": \"91\",\n \"designation\": \"Testing\",\n \"email\": \"nandhakumar.n@emudhra.com\"\n }\n}" + } + ] + }, + { + "name": "GetOrganizationDetails", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "pm.test('Status code is 200', () => {", + " pm.response.to.have.status(200);", + "});", + "", + "if (status === '1') {", + " pm.test('Request Successful', () => {", + " pm.expect(status).to.equal('1');", + " });", + "} else {", + " pm.test('Request FAILED: ' + (errMsg || 'Unknown error'), () => {", + " pm.expect(status).to.equal('1');", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"organizationDetails\": {\r\n \"organizationNumber\": \"9841296\",\r\n \"groupNumber\": \"5439812761\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}GetOrganizationDetails", + "host": [ + "{{baseURL}}GetOrganizationDetails" + ] + }, + "description": "This API allows you to get detailed organization information and its status.\n\n**API Endpoint**\n\n| Category | Specification |\n| --- | --- |\n| URL | http(s)://(url host port and path)/GetOrganizationDetails |\n| Protocol | HTTP / HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"accountNumber\": \"\",\n \"authKey\": \"\"\n },\n \"organizationDetails\": {\n \"organizationNumber\": \"\",\n \"groupNumber\": \"\"\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| organizationDetails | (mandatory)
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | 1234567890 (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | 6288275133 (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | 952a95057654aa1c26a0f287b6368579f5f3ec99f213543df5e0f7eb7f798b3d (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Organization Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| organizationNumber | 4567876567 (mandatory)
Organization Number (Org. ID) of Organization. |\n| groupNumber | 6567874361 (mandatory)
Group Number of the group. This is auto generated by the system. Groups can be your cost centers, departments, etc. within your Business. It acts as a feature / tool for managing account user access to organizations, domains and finances.
This is set to Default Group Number (in case if it is not set) and amount will be deducted from the account balance. If group is having its own balance, then amount will be deducted from the group balance else the amount will be deducted from the account balance. |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n },\n \"organizationDetails\": {\n \"organizationNumber\": \"\",\n \"prevettingToken\": \"\",\n \"organizationName\": \"\",\n \"organizationCountryCode\": \"\",\n \"organizationStateName\": \"\",\n \"organizationStateId\": \"\",\n \"organizationStateCode\": \"\",\n \"organizationLocality\": \"\",\n \"organizationStreetAddress1\": \"\",\n \"organizationStreetAddress2\": \"\",\n \"organizationPostalCode\": \"\",\n \"organizationStatusId\": \"\",\n \"validationStatusId\": \"\",\n \"validationFor\": \"\",\n \"businessCategoryId\": \"\",\n \"domains\": [\n \"(Domain Name)\",\n ],\n \"orgRepresentatives\": [{\n \"representativeNumber\": \"\",\n \"name\": \"\",\n \"emailId\": \"\",\n \"isdCode\": \"\",\n \"mobileNumber\": \"\",\n \"designation\": \"\"\n },\n ],\n \"subscriberAgreement\": {\n \"signerName\": \"\",\n \"signedDate\": \"\", \n \"signedPlace\": \"\",\n \"status\": \"\"\n },\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| organizationDetails | (mandatory)
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | 1234567890 (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | 1 (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n\n**Organization Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| organizationNumber | 4567654567777 (mandatory)
Organization Number (Org. ID) of Organization. |\n| prevettingToken | 0947CBA3C2DF42B0B303590541C8E5B1 (optional)
A unique token value of the respective pre-vetted organization. This helps to re-use the organization information skipping pre-vetting consent email notification for every order.
Token is accessible only by Account Administrators. Please contact your account administrator for any assistance. |\n| organizationName | ABC Limited (mandatory)
Organization Name of the Organization. |\n| organizationStreetAddress1 | Goa (mandatory)
Street address 1 of the Organization. |\n| organizationStreetAddress2 | (optional)
Street address 2 of the Organization. |\n| organizationLocality | Goa (mandatory)
Locality of the Organization. |\n| organizationStateName | Karnataka (mandatory)
State of the Organization. |\n| organizationStateId | 1760 (mandatory)
State ID of the State. |\n| organizationStateCode | KA (mandatory)
State code of the State. |\n| organizationCountryCode | IN (mandatory)
Country code of the Organization. |\n| organizationPostalCode | 767767(mandatory)
Postal code of the Organization. |\n| organizationStatusId | 1 (mandatory)
Status of the Organization.
1 - Active
2 - Inactive
3 - Expired |\n| validationStatusId | 0 (mandatory)
Validation status of the Organization.
0 - Pending for approval
1 - Validated
2 - Rejected
3 - Cancelled
4 - Expired
5 - Under Discrepancy |\n| validationFor | 1 (mandatory)
1 - OV (SSL OV / Document Signer OV)
2 - EV & OV
3 - S/MIME OV |\n| businessCategoryId | (conditional mandatory)
Business Category of an Organization.
The allowed values for this field are:
1 - Private Organization
2 - Government Entity |\n| domains | (conditional mandatory)
Validated domains associated with the Organization. |\n| orgRepresentatives | (conditional mandatory)
Specified Below. |\n| subscriberAgreement | (mandatory)
Specified Below. |\n\n**Org. Representatives**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| representativeNumber | 56789876 (mandatory)
A unique number of the organization representative. |\n| name | Yashwanth TM (mandatory)
Name of the organization representative. |\n| emailId | [yashwnath.tm@gmail.com](https://mailto:yashwnath.tm@gmail.com)(mandatory)
Email address of the organization representative. |\n| isdCode | +91 (mandatory)
ISD Code of the organization representative. |\n| mobileNumber | 8745817662 (mandatory)
Mobile Number of the organization representative. |\n| designation | PM (optional)
Designation of the organization representative. |\n\n**Subscriber Agreement**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| signerName | Sipra (mandatory)
Name of the Signer. |\n| signedDate | 2024-01-02 10:05:13 (conditional mandatory)
The date when the subscriber agreement is signed. |\n| signedPlace | GOA (conditional mandatory)
The Place where the subscriber Agreement is signed. |\n| status | 1 (mandatory)
Status of Subscriber Agreement.
0: Pending
1: Completed
2: Rejected |" + }, + "response": [ + { + "name": "GetOrganizationDetails", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{authKey}}\"\r\n },\r\n \"organizationDetails\": {\r\n \"organizationNumber\": \"9841296\",\r\n \"groupNumber\": \"5439812761\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}GetOrganizationDetails", + "host": [ + "{{baseUrl}}GetOrganizationDetails" + ] + } + }, + "_postman_previewlanguage": "Text", + "header": [], + "cookie": [], + "body": "" + } + ] + }, + { + "name": "GetDomainDetails", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "pm.test('Status code is 200', () => {", + " pm.response.to.have.status(200);", + "});", + "", + "if (status === '1') {", + " pm.test('Request Successful', () => {", + " pm.expect(status).to.equal('1');", + " });", + "} else {", + " pm.test('Request FAILED: ' + (errMsg || 'Unknown error'), () => {", + " pm.expect(status).to.equal('1');", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}GetDomainDetails", + "host": [ + "{{baseURL}}GetDomainDetails" + ] + }, + "description": "This API allows you to get the list of domains available in your account and it's details such as DCV Method, DCV Status, Created Date, Expiry Date & Organization details.\n\n**API Endpoint**\n\n| Header | Header |\n| --- | --- |\n| URL | http(s)://(url host port and path)/GetDomainDetails |\n| Protocol | HTTP/ HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n \"meta\":{\n \"ver\":\"\",\n \"ts\":\"\",\n \"txn\":\"\",\n \"accountNumber\":\"\",\n \"authKey\":\"\"\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | 1234567890 (mandatory)
A unique transaction ID of the request. This should be unique for the given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | 6288275133 (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | 952a95057654aa1c26a0f287b6368579f5f3ec99f213543df5e0f7eb7f798b3d (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"status\": \"\",\n \"errorCode\": \"\",\n \"errorMessage\": \"\"\n },\n \"domainDetails\": {\n \"domainName\": \"\",\n \"organizationName\": \"\",\n \"organizationNumber\": \"\",\n \"dcvMethod\": \"\",\n \"dcvStatus\": \"\", \n \"createdDate\": \"\",\n \"expiryDate\": \"\",\n \"status\": \"\"\n },\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| domainDetails | (mandatory)
Domain details of the respective order.
Specified below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30(mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | 1234567890 (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | 1 (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success. Mandatory in case of failure. |\n\n**Domain Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| domainName | emSign.com (mandatory)
Domain associated with the Organization. |\n| organizationName | ABC Limited (mandatory)
Organization Name of the Organization. |\n| organizationNumber | 456787654555(mandatory)
Organization Number (Org. ID) of Organization. |\n| dcvMethod | 3 (conditional mandatory)
Domain Control Validation Method for proving domain ownership, if chosen by the user.
1 - DNS TXT Record
2 - HTTP/HTTPS or File-based
3 - Constructed Email (e.g.: [webmaster@example.com](https://mailto:webmaster@example.com)) |\n| dcvStatus | 0 (mandatory)
Validation status of the Domain.
0 - Pending (either DCV or CAA Validation)
1 - Completed
2 - Rejected |\n| createdDate | 2024-01-02 10:05:13 (mandatory)
Created date of the domain. e.g.; 2024-01-02 10:05:13 |\n| expiryDate | (conditional mandatory)
Expiry date of the domain. e.g.; 2025-05-17 00:00:00 |\n| status | 2 (mandatory)
Current status of the domain.
1 - Active
2 - Inactive
3 - Expired |\n\n" + }, + "response": [ + { + "name": "GetDomainDetails", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\":{\r\n \"ver\":\"1.0\",\r\n \"ts\":\"{{ts}}fhgfhg\",\r\n \"txn\":\"{{txn}}\",\r\n \"accountNumber\":\"7453557959\",\r\n \"authKey\":\"{{hash}}\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/GetDomainDetails", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "GetDomainDetails" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "177" + }, + { + "key": "Date", + "value": "Tue, 02 Apr 2024 11:08:41 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"meta\": {\n \"ver\": \"1.0\",\n \"ts\": \"2024-04-02T16:38:39+05:30fhgfhg\",\n \"txn\": \"5521a88c65dd4613b1758929056906f5\",\n \"status\": \"0\",\n \"errorCode\": \"EMS-913\",\n \"errorMessage\": \"Invalid Account Number\"\n }\n}" + } + ] + }, + { + "name": "GetProductDetails", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "pm.test('Status code is 200', () => {", + " pm.response.to.have.status(200);", + "});", + "", + "if (status === '1') {", + " pm.test('Request Successful', () => {", + " pm.expect(status).to.equal('1');", + " });", + "} else {", + " pm.test('Request FAILED: ' + (errMsg || 'Unknown error'), () => {", + " pm.expect(status).to.equal('1');", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"productDetails\": {\r\n \"groupNumber\": \"{{groupNumber}}\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}GetProductDetails", + "host": [ + "{{baseURL}}GetProductDetails" + ] + } + }, + "response": [] + }, + { + "name": "GetFieldDetails", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "pm.test('Status code is 200', () => {", + " pm.response.to.have.status(200);", + "});", + "", + "if (status === '1') {", + " pm.test('Request Successful', () => {", + " pm.expect(status).to.equal('1');", + " });", + "} else {", + " pm.test('Request FAILED: ' + (errMsg || 'Unknown error'), () => {", + " pm.expect(status).to.equal('1');", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"productDetails\": {\r\n \"groupNumber\": \"{{groupNumber}}\",\r\n \"categoryID\": \"\",\r\n \"productCode\": \"\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}GetFieldDetails", + "host": [ + "{{baseURL}}GetFieldDetails" + ] + } + }, + "response": [] + }, + { + "name": "GetGroupDetailsV2", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "pm.test('Status code is 200', () => {", + " pm.response.to.have.status(200);", + "});", + "", + "if (status === '1') {", + " pm.test('Request Successful', () => {", + " pm.expect(status).to.equal('1');", + " });", + "} else {", + " pm.test('Request FAILED: ' + (errMsg || 'Unknown error'), () => {", + " pm.expect(status).to.equal('1');", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"groupDetails\": {\r\n \"groupNumber\": \"{{groupNumber}}\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}GetGroupDetailsV2", + "host": [ + "{{baseURL}}GetGroupDetailsV2" + ] + } + }, + "response": [] + } + ], + "description": "This section includes following APIs.\n\n- Validate API Key\n- Account Details\n- Organization Details\n- Domain Details" + }, + { + "name": "Reporting", + "item": [ + { + "name": "GetOrderReport", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "pm.test('Status code is 200', () => {", + " pm.response.to.have.status(200);", + "});", + "", + "if (status === '1') {", + " pm.test('Request Successful', () => {", + " pm.expect(status).to.equal('1');", + " });", + "} else {", + " pm.test('Request FAILED: ' + (errMsg || 'Unknown error'), () => {", + " pm.expect(status).to.equal('1');", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n\"meta\": {\r\n \"ver\":\"1.0\",\r\n \"ts\":\"{{ts}}\",\r\n \"txn\":\"{{txn}}\",\r\n \"accountNumber\":\"6238572834\",\r\n \"authKey\":\"{{hash}}\" \r\n }, \r\n \"searchCriteria\": {\r\n \"productCode\": \"\", \r\n \"groupNumber\": \"\",\r\n \"orderDateFrom\": \"\", \r\n \"orderDateTill\": \"\",\r\n \"organizationName\": \"\", \r\n \"domainName\": \"\",\r\n \"orderNumber\": \"\", \r\n \"orderStatusId\": \"\", \r\n \"certExpiryDateFrom\": \"\",\r\n \"certExpiryDateTill\": \"\",\r\n \"requestorEmailId\": \"\", \r\n \"pageNumber\": \"\", \r\n \"pageSize\": \"\" ,\r\n \"tagSearchCriteria\": \"\",\r\n \"certificateChain\": \"\", // 1-true else false\r\n \"certificateTrustType\": \"\", // 1-public , 2-private\r\n \"issuerCA\": \"\", // partial search\r\n \"tags\":[\r\n \r\n ]\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://emsign-server1.qa.emudhra.net/emSignHub-API/getOrderReport", + "protocol": "https", + "host": [ + "emsign-server1", + "qa", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "getOrderReport" + ] + }, + "description": "This API facilitates to get order report for below certificate orders.\n\n- emSign SSL - DV / OV / EV\n \n- emSign S/MIME - Simple\n \n- Document Signer Personal (Natural Person) / Document Signer Professional (Legal Person) / Document Signer Corporate (Legal Entity)\n \n\n**API Endpoint**\n\n| Category | Specification |\n| --- | --- |\n| URL | http(s)://(url host port and path)/GetOrderReport |\n| Protocol | HTTP / HTTPS |\n| Method | POST |\n| Content Type | JSON |\n| Post data | A well-formed JSON, as per the specifications provided in this document. |\n\n**Request JSON format**\n\nBelow is the JSON structure in which the request has to be formed. It must be well formed with correct syntax.\n\n```\n{\n \"meta\": {\n \"ver\": \"\",\n \"ts\": \"\",\n \"txn\": \"\",\n \"accountNumber\": \"\",\n \"authKey\": \"\"\n },\n \"searchCriteria\": {\n \"productCode\": \"\", \n \"groupNumber\": \"\",\n \"orderDateFrom\": \"\", \n \"orderDateTill\": \"\", \n \"organizationName\": \"\", \n \"domainName\": \"\", \n \"requestNumber\": \"\", \n \"orderNumber\": \"\", \n \"orderStatusId\": \"\", \n \"certExpiryDateFrom\": \"\",\n \"certExpiryDateTill\": \"\",\n \"requestorEmailId\": \"\", \n \"pageNumber\": \"\", \n \"pageSize\": \"\", \n \"tagSearchCriteria\": \"\",\n \"certificateChain\": \"\", \n \"certificateTrustType\": \"\", \n \"issuerCA\": \"\", \n \"tags\":[\n \"(Tag Name:Tag Value)\",\n \"(Tag Name:Tag Value)\"\n ]\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| searchCriteria | (mandatory)
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of request in ISO 8601 format. Example: 2023-01-31T14:19:28+05:30. Use Time zone to indicate the requesting server time. Else, it considers as Indian Time Zone. |\n| txn | 1234567890 (mandatory)
A unique transaction ID of the request. This should be unique for given Account. This should be an alphanumeric value between 10 to 50 characters. |\n| accountNumber | 6288275133 (mandatory)
Unique Account Number of CERTInext Account. This information will be available within CERTInext online portal. |\n| authKey | 952a95057654aa1c26a0f287b6368579f5f3ec99f213543df5e0f7eb7f798b3d (mandatory)
This should be the SHA 256 hash value of Account User Access Key Value along with Time Stamp and Transaction ID. Account User's REST API Access Key can be generated within CERTInext online portal. Example: SHA256 (accessKey + requestTs + requestTxnId). |\n\n**Search Criteria**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| productCode | 844 (optional)
Unique Product Code of the respective product. Product Code values are provided by eMudhra. For multiple Product Codes, please provide comma separated values. |\n| groupNumber | 8194218742 (optional)
Group Number of the group. This is auto generated by the system. Groups can be your cost centers, departments, etc. within your Business. It acts as a feature / tool for managing account user access to organizations, domains and finances. If group number is not given in the request, then system will consider the default group number and amount will be deducted from the account balance. If group is having its own balance, then amount will be deducted from the group balance else the amount will be deducted from the account balance. |\n| orderDateFrom | 2025-03-15T13:24:11+05:30 (optional) Specify the order from date in ISO 8601 format in UTC time zone. All the certificates are fetched from the order date specified to till date. It shall be considered with the time zone representation. The allowed date formats for this field are: YYYY-MM-DD / YYYY-MM-DDTHH:MM / YYYY-MM-DDTHH:MM:SS / YYYY-MM-DDTHH:MM:SSZ. e.g.: 2022-12-15T13:24:11+05:30. This is mandatory, if 'orderDateTill' is provided. |\n| orderDateTill | 2025-03-15T13:24:11+05:30 (optional) Specify the order till date in ISO 8601 format in UTC time zone. All the certificates are fetched till the specified order date mentioned. It shall be considered with the time zone representation. The allowed date formats for this field are: YYYY-MM-DD / YYYY-MM-DDTHH:MM / YYYY-MM-DDTHH:MM:SS / YYYY-MM-DDTHH:MM:SSZ. e.g.: 2022-12-15T13:24:11+05:30. This is mandatory, if 'orderDateFrom' is provided. |\n| organizationName | eMudhra (optional)
Exact Name of the Organization. This is Case insensitive.
For multiple organizations, please provide comma separated values. |\n| domainName | emsign.com (optional)
Exact Name of the Domain. This is Case insensitive.
For multiple domains, please provide comma separated values. |\n| requestNumber | 873837633 (optional)
Request Number of the respective Certificate Order.
For multiple requests, please provide comma separated values. |\n| orderNumber | 8276384767 (optional)
Order Number of the respective Certificate Order.
For multiple orders, please provide comma separated values. |\n| orderStatusId | 1 (optional)
Status ID of the order.
1 - Order Placed
2 - Order Accepted
3 - Order In-Progress
4 - Order Rejected
5 - Order Cancelled
6 - Order Fulfilled
7 - On Hold (Saved to Draft)
8 - Order Pending for Approval
9 - Order Pending for Account Administrator Approval.
For multiple status, please provide comma separated values. |\n| certExpiryDateFrom | 2025-03-15T13:24:11+05:30 (optional) Specify the certificate expiry from date in ISO 8601 format in UTC time zone. All the expired certificates are fetched till the specified date mentioned. It shall be considered with the time zone representation. The allowed date formats for this field are: YYYY-MM-DD / YYYY-MM-DDTHH:MM / YYYY-MM-DDTHH:MM:SS / YYYY-MM-DDTHH:MM:SSZ. e.g.: 2022-12-15T13:24:11+05:30. This is mandatory, if 'certExpiryDateTill' is provided. |\n| certExpiryDateTill | 2025-03-15T13:24:11+05:30 (optional) Specify the certificate expiry till date in ISO 8601 format in UTC time zone. All the expired certificates are fetched from the date specified to till date. It shall be considered with the time zone representation. The allowed date formats for this field are: YYYY-MM-DD / YYYY-MM-DDTHH:MM / YYYY-MM-DDTHH:MM:SS / YYYY-MM-DDTHH:MM:SSZ. e.g.: 2022-12-15T13:24:11+05:30. This is mandatory, if 'certExpiryDateFrom' is provided. |\n| requestorEmailId | [support@emudhra.com ](https://support@emudhra.com) (optional)
Registered Email ID of the certificate requestor associated with the respective emSign Certificate Order. For multiple requestors, please provide comma separated values. |\n| pageNumber | 1 (optional)
Page number of the report.
The default value is 1 (in case if it is not set). |\n| pageSize | 50 (optional)
Page size of the report.
The only allowed value is 100. |\n| tagSearchCriteria | (optional)
Specify the tag search criteria if multiple tags are provided.
The allowed values are: 1 - OR Condition & 2 - AND Condition.
The default value is 1 (in case if it is not set). |\n| certificateChain | (optional)
The allowed values are 0 and 1.
If \"0\" is passed, the response will not have the Certificate chain.
If \"1\" is passed, the response will include the Certificate chain. |\n| certificateTrustType | (optional)
\"1\" = Public, the response will return only Public trust certificates.
\"2\" = Private, the response will return only Private trust certificates.
If the field is left empty, the response will return both Public and Private trust certificates. |\n| issuerCA | (optional)
Issuer CA based search.
e.g; issuerCA: \"IGFT\" |\n| tags | (optional)
Reporting tags are labels with different values, which can be associated with your certificate orders in CERTInext. These tags can then be used to filter reports, giving you a categorized insight into your business. Tag Name and Tag Value must be colon separated values.
e.g.: Department:Technology. Multiple tag names and tag values can be associated with the order.
e.g.: (Department:Technology, Department:Legal, Branch Location:India).
Specified below. |\n\n**Tags**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| Tag Name | Dept (mandatory)
Reporting Tag Name. |\n| Tag Value | Technology (mandatory)
Reporting Tag Value. |\n\n**Response JSON format**\n\nBelow is the JSON structure in which the response is provided. The correlation can be made against the transaction ID (txn) attribute present in the request.\n\n```\n{\n \"meta\":{\n \"ver\":\"\",\n \"ts\":\"\",\n \"txn\":\"\",\n \"status\":\"\",\n \"errorCode\":\"\",\n \"errorMessage\":\"\"\n },\n \"orderDetails\":{\n \"pageSize\":\"\",\n \"currentPage\":\"\",\n \"totalNoOfResults\":\"\",\n \"noOfPages\":\"\",\n \"ordersArray\":[\n {\n \"orderNumber\":\"\",\n \"requestNumber\":\"\",\n \"productCode\":\"\",\n \"groupNumber\":\"\",\n \"certificateStatusId\":\"\",\n \"certificateStatus\":\"\",\n \"orderStatusId\":\"\",\n \"orderStatus\":\"\",\n \"orderDate\":\"\",\n \"organizationName\":\"\",\n \"domainName\":\"\",\n \"countryName\":\"\",\n \"certificateSerialNumber\":\"\",\n \"certificateExpiryDate\":\"\",\n \"certificateChain\": \"\", \n \"certificateTrustType\": \"\", \n \"issuerCA\": \"\", \n \"tags\":[\n \"(Tag Name:Tag Value)\",\n \"(Tag Name:Tag Value)\"\n ],\n \"customFields\":[\n {\n \"fieldID\":\"\",\n \"fieldName\":\"\",\n \"fieldValue\":\"\"\n },\n {\n \"fieldID\":\"\",\n \"fieldName\":\"\",\n \"fieldValue\":\"\"\n }\n ]\n },\n {\n \"orderNumber\":\"\",\n \"requestNumber\":\"\",\n \"productCode\":\"\",\n \"groupNumber\":\"\",\n \"certificateStatusId\":\"\",\n \"certificateStatus\":\"\",\n \"orderStatusId\":\"\",\n \"orderStatus\":\"\",\n \"orderDate\":\"\",\n \"organizationName\":\"\",\n \"domainName\":\"\",\n \"countryName\":\"\",\n \"certificateSerialNumber\":\"\",\n \"certificateExpiryDate\":\"\",\n \"certificateChain\": \"\", \n \"certificateTrustType\": \"\", \n \"issuerCA\": \"\", \n \"tags\":[\n \"(Tag Name:Tag Value)\",\n \"(Tag Name:Tag Value)\"\n ],\n \"customFields\":[\n {\n \"fieldID\":\"\",\n \"fieldName\":\"\",\n \"fieldValue\":\"\"\n },\n {\n \"fieldID\":\"\",\n \"fieldName\":\"\",\n \"fieldValue\":\"\"\n }\n ]\n }\n ]\n }\n}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| meta | (mandatory)
Specified Below. |\n| orderDetails | (mandatory)
Specified Below. |\n\n**Meta**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| ver | 1.0 (mandatory)
Version should be set to \"1.0\". |\n| ts | 2023-01-31T14:19:28+05:30 (mandatory)
Time stamp of response in ISO 8601 format. It shall be considered with the time zone representation. |\n| txn | 1234567890 (mandatory)
This value will contain the transaction ID as received in request JSON, and shall be used to co-relate the request and responses. |\n| status | 1 (mandatory)
1 - Success
0 - Failure |\n| errorCode | (conditional mandatory)
Optional in case of success.
Mandatory in case of failure. |\n| errorMessage | (conditional mandatory)
Optional in case of success.
Mandatory in case of failure. |\n\n**Order Details**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| pageSize | 1 (mandatory)
Total number of results in the current page. The default size is 100. |\n| currentPage | 1 (mandatory)
Numeric value of the current page. This will contain the 'pageNumber' value as received in the request. |\n| totalNoOfResults | 1000 (mandatory)
Total number of results available. |\n| noOfPages | 100 (mandatory)
Total number of pages available. |\n| ordersArray | (mandatory)
Specified below. |\n\n**Orders Arrays**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| orderNumber | 8276384767 (mandatory)
Order Number of the respective Certificate Order. |\n| requestNumber | 6765456787 (mandatory)
Request Number of the respective Certificate Order. |\n| productCode | 844 (mandatory)
Unique Product Code of the respective product. Product Code values are provided by eMudhra. |\n| groupNumber | 8194218742 (optional)
Group Number of the group. This is auto generated by the system. Groups can be your cost centers, departments, etc. within your Business. It acts as a feature / tool for managing account user access to organizations, domains and finances. If group number is not given in the request, then system will consider the default group number and amount will be deducted from the account balance. If group is having its own balance, then amount will be deducted from the group balance else the amount will be deducted from the account balance. |\n| certificateStatusId | 1 (conditional mandatory)
Optional in case of failure. Mandatory in case of success.
1 - Setup Pending
2 - Pending for Approver
3 - Under Discrepancy
4 - Approved
5 - Rejected
6 - Pending Second Approver
7 - Approved by Second Approver
8 - Rejected by Second Approver
9 - Certificate Downloaded
12 - Certificate Expired
13 - Rejected due to Order Cancellation
14 - Auto Rejected
15 - Order Auto Approved (Certificate Download Instructions email sent)
16 - Pending LRA
17 - Approved LRA
18 - Rejected LRA
19 - Invalid Configuration
20 - Certificate Generated
21 - Download Rejected
22 - Certificate Revoked
23 - Rekey Approved
24 - Pending for Approver (Auto-approval)
25 - Organization Authorization Pending LRA
26 - Organization Authorized LRA
27 - Organization Authorization Rejected LRA |\n| orderStatusId | 1 (mandatory)
1 - Order Placed
2 - Order Accepted
3 - Order In-Progress
4 - Order Rejected
5 - Order Cancelled
6 - Order Fulfilled
7 - On Hold (Saved to Draft)
8 - Order Pending for Approval
9 - Order Pending for Account Administrator Approval. |\n| orderDate | 2024-03-11 09:48:28 (mandatory)
Order Date of the respective order. |\n| organizationName | eMudhra (conditional mandatory)
Name of the organization. This is mandatory for emSign SSL - OV & EV products. |\n| domainName | emsign.com (conditional mandatory)
Domain Name of the website / server. |\n| countryName | IN (conditional mandatory)
Country of an organization. This is mandatory, if Country value is provided in the request. |\n| certificateSerialNumber | 11551504729657302953 (conditional mandatory)
Serial Number of the Certificate, if certificate issued / ready for download. |\n| certificateExpiryDate | 2024-03-11 09:48:28 (conditional mandatory)
Expiry Date of the respective End Entity Certificate in UTC format. if certificate issued / ready for download. e.g.; 2024-11-06 04:58:31 |\n| certificateChain | (optional)
If \"0\" is passed, the response will not have the Certificate chain.
If \"1\" is passed, the response will include the Certificate chain. |\n| certificateTrustType | (optional)
The allowed values are 1 and 2.
\"1\" = Public, the response will return only Public trust certificates.
\"2\" = Private, the response will return only Private trust certificates.
If the field is left empty, the response will return both Public and Private trust certificates. |\n| issuerCA | |\n| tagName:tagValue | Department:Technology (optional)
Tag Name and Tag Value of the Certificate. e.g.: Department:Technology. Multiple tag names and tag values can be associated with the order. e.g.:Department:Technology, Department:Legal, Branch Location:India |\n| fieldName:value | BusinessUnit:IJH43 (optional)
Custom field name and Custom Field value of the certificate. e.g.: Server IP Address:192.168.12.3. Multiple Custom field names and Custom Field values can be associated with the order. e.g.: Server IP Address:192.168.12.3, BusinessUnit:IJH43 |" + }, + "response": [ + { + "name": "GetOrderReport", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\":{\r\n \"ver\":\"1.0\",\r\n \"ts\":\"{{ts}}\",\r\n \"txn\":\"{{txn}}\",\r\n \"accountNumber\":\"4391755311\",\r\n \"authKey\":\"{{hash}}\"\r\n },\r\n \"searchCriteria\":{\r\n \"productCode\":\"\",\r\n \"groupNumber\":\"\",\r\n \"orderDateFrom\":\"\",\r\n \"orderDateTill\":\"\",\r\n \"organizationName\":\"\",\r\n \"domainName\":\"\",\r\n \"orderNumber\":\"2586552952\",\r\n \"orderStatusId\":\"\",\r\n \"certExpiryDateFrom\":\"\",\r\n \"certExpiryDateTill\":\"\",\r\n \"requestorEmailId\":\"\",\r\n \"pageNumber\":\"\",\r\n \"pageSize\":\"\",\r\n \"tagSearchCriteria\":\"\",\r\n \"tags\":[\r\n \"\"\r\n ],\r\n \"customFields\":[\r\n {\r\n \"fieldID\":\"\",\r\n \"fieldValue\":\"\"\r\n }\r\n ]\r\n }\r\n}\r\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://sandbox-emsignapi.emudhra.net/emSignHub-API/getOrderReport", + "protocol": "https", + "host": [ + "sandbox-emsignapi", + "emudhra", + "net" + ], + "path": [ + "emSignHub-API", + "getOrderReport" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Method" + }, + { + "key": "Vary", + "value": "Access-Control-Request-Headers" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000 ; includeSubDomains" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json;charset=UTF-8" + }, + { + "key": "Content-Length", + "value": "171" + }, + { + "key": "Date", + "value": "Tue, 02 Apr 2024 11:11:35 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"meta\": {\n \"ver\": \"1.0\",\n \"ts\": \"2024-04-02T16:41:33+05:30\",\n \"txn\": \"0d09b66e13274aff94e3ce88928f6b47\",\n \"status\": \"0\",\n \"errorCode\": \"EMS-913\",\n \"errorMessage\": \"Invalid Account Number\"\n }\n}" + } + ] + }, + { + "name": "GetLedgerStatementDetails", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "const res = pm.response.json();", + "const status = res.meta && res.meta.status;", + "const errMsg = res.meta && res.meta.errorMessage;", + "", + "pm.test('Status code is 200', () => {", + " pm.response.to.have.status(200);", + "});", + "", + "if (status === '1') {", + " pm.test('Request Successful', () => {", + " pm.expect(status).to.equal('1');", + " });", + "} else {", + " pm.test('Request FAILED: ' + (errMsg || 'Unknown error'), () => {", + " pm.expect(status).to.equal('1');", + " });", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"meta\": {\r\n \"ver\": \"1.0\",\r\n \"ts\": \"{{ts}}\",\r\n \"txn\": \"{{txn}}\",\r\n \"accountNumber\": \"{{accountNumber}}\",\r\n \"authKey\": \"{{hash}}\"\r\n },\r\n \"statementDetails\": {\r\n \"groupNumber\": \"{{groupNumber}}\",\r\n \"pageNumber\": \"1\",\r\n \"pageSize\": \"10\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}GetLedgerStatementDetails", + "host": [ + "{{baseURL}}GetLedgerStatementDetails" + ] + } + }, + "response": [] + } + ], + "description": "This section includes following APIs.\n\n- Orders Report" + }, + { + "name": "Error Codes & Messages", + "item": [], + "description": "| Error Code | Error Message |\n| --- | --- |\n| EMS-901 | Invalid Request. |\n| EMS-902 | Input JSON parsing error. |\n| EMS-904 | clientAuthCode cannot be empty. |\n| EMS-905 | clientAccessKey cannot be empty. |\n| EMS-906 | Invalid clientAuthCode. |\n| EMS-907 | Invalid clientAccessKey. |\n| EMS-908 | Transaction ID cannot be empty. |\n| EMS-909 | Invalid Transaction ID. |\n| EMS-910 | Invalid Request TS. |\n| EMS-911 | Invalid Version. |\n| EMS-912 | Customer ID cannot be empty. |\n| EMS-913 | Invalid Account Number. |\n| EMS-914 | Certificate ID cannot be empty. |\n| EMS-915 | Invalid Certificate ID. |\n| EMS-916 | Requestor Information cannot be empty. |\n| EMS-917 | Certificate Information cannot be empty. |\n| EMS-918 | Additional Information cannot be empty. |\n| EMS-919 | Authorized Signatory Information cannot be empty. |\n| EMS-920 | Requestor Name cannot be empty. |\n| EMS-921 | Invalid Requestor ISD Code. |\n| EMS-922 | Requestor Mobile Number cannot be empty. |\n| EMS-923 | Invalid Requestor Mobile Number. |\n| EMS-924 | Requestor Email ID cannot be empty. |\n| EMS-925 | Invalid Requestor Email ID. |\n| EMS-926 | Requestor Designation cannot be empty. |\n| EMS-927 | Please enter valid remarks. |\n| EMS-928 | Authorized Name cannot be empty. |\n| EMS-930 | Authorized Email ID cannot be empty. |\n| EMS-931 | Invalid Authorized Email ID. |\n| EMS-932 | Invalid Authorized ISD Code. |\n| EMS-933 | Authorized Mobile Number cannot be empty. |\n| EMS-934 | Invalid Authorized Mobile Number. |\n| EMS-935 | Authorized Designation cannot be empty. |\n| EMS-936 | Invalid Certificate Information Details. |\n| EMS-937 | DB Connectivity Error. |\n| EMS-938 | API cannot be empty. |\n| EMS-939 | Unknown error. |\n| EMS-942 | Order ID cannot be empty. |\n| EMS-943 | Invalid Order ID. |\n| EMS-944 | Download PIN cannot be empty. |\n| EMS-945 | Invalid Order ID or Download PIN. |\n| EMS-946 | Certificate Request still being processed. |\n| EMS-947 | Duplicate request Txn. |\n| EMS-948 | Certificate Installed cannot be empty. |\n| EMS-949 | Invalid Certificate Installed. |\n| EMS-950 | Certificate Download Type cannot be empty. |\n| EMS-951 | Invalid Certificate Download Type. |\n| EMS-952 | Invalid CSR. |\n| EMS-953 | Token Name cannot be empty. |\n| EMS-954 | Token Serial Number cannot be empty. |\n| EMS-955 | Certificate Password cannot be empty. |\n| EMS-956 | Invalid Request for this API. |\n| EMS-957 | Invalid AuthCode. |\n| EMS-959 | Invalid Key Generation Type. |\n| EMS-961 | Auth Packet cannot be empty. |\n| EMS-962 | Certificate Already Generated. |\n| EMS-963 | AuthCode Expired. |\n| EMS-964 | Rejection Remarks cannot be empty. |\n| EMS-965 | PFX File already Downloaded. |\n| EMS-966 | The Order has been Rejected. |\n| EMS-967 | Issue generating certificate. |\n| EMS-968 | Connectivity Error. |\n| EMS-969 | Revoke Reason ID cannot be empty. |\n| EMS-970 | Invalid Revoke Reason ID. |\n| EMS-971 | Revoke Remarks cannot be empty. |\n| EMS-972 | Revoke request already Initiated. |\n| EMS-973 | Certificate already Revoked. |\n| EMS-974 | Certificate Serial Number cannot be empty. |\n| EMS-975 | Invalid Certificate Serial Number. |\n| EMS-976 | Internal Server Error. |\n| EMS-977 | Value exceeds Max length. |\n| EMS-978 | Invalid Requestor Designation. |\n| EMS-979 | Invalid Authorized Signatory Name. |\n| EMS-981 | Invalid Authorized Signatory Designation. |\n| EMS-982 | Certificate is expired. |\n| EMS-983 | Invalid Requestor Name. |\n| EMS-984 | Cancellation Remarks cannot be empty. |\n| EMS-985 | This Order was already cancelled. |\n| EMS-986 | This Order is Rejected. |\n| EMS-987 | Certificate already generated. |\n| EMS-988 | Re-Key request already Initiated. |\n| EMS-989 | Re-Key request is not Initiated. |\n| EMS-990 | Issue Revoking the Certificate. |\n| EMS-991 | Document count does not match. |\n| EMS-992 | File size cannot exceed 3 MB. |\n| EMS-993 | File name cannot be empty. |\n| EMS-994 | Only PDF files are accepted. |\n| EMS-995 | Invalid Document. |\n| EMS-996 | Encrypted Documents are not allowed. |\n| EMS-997 | Issue saving the document. |\n| EMS-998 | Document description cannot exceed 25 characters. |\n| EMS-999 | Attached documents exceeds the maximum documents allowed. |\n| EMS-1000 | Document description cannot be empty. |\n| EMS-1001 | Issue decoding the document. |\n| EMS-1002 | OS cannot be empty. |\n| EMS-1003 | Invalid OS. |\n| EMS-1004 | Request Number cannot be empty. |\n| EMS-1005 | Invalid Request Number. |\n| EMS-1006 | CSR cannot be empty. |\n| EMS-1007 | Delegation Contact Name cannot be empty. |\n| EMS-1008 | Invalid Delegation Contact Name. |\n| EMS-1009 | Delegation Contact Name should be less than\"**\" characters. |\n| EMS-1010 | Delegation Email ID cannot be empty. |\n| EMS-1011 | Invalid Delegation Email ID. |\n| EMS-1012 | Delegation Email ID should be less than \"**\" characters. |\n| EMS-1013 | Name cannot be empty. |\n| EMS-1014 | Logo cannot be empty. |\n| EMS-1015 | Email ID cannot be empty. |\n| EMS-1016 | Invalid Email ID. |\n| EMS-1017 | ISD Code cannot be empty. |\n| EMS-1018 | Invalid ISD Code. |\n| EMS-1019 | Mobile Number cannot be empty. |\n| EMS-1020 | Invalid Mobile Number. |\n| EMS-1021 | Invalid Mobile Number. |\n| EMS-1022 | Invalid Landline Number. |\n| EMS-1023 | Address cannot be empty. |\n| EMS-1024 | Country cannot be empty. |\n| EMS-1025 | Invalid Country. |\n| EMS-1026 | State cannot be empty. |\n| EMS-1027 | Invalid State. |\n| EMS-1028 | Locality cannot be empty. |\n| EMS-1029 | \"Locality \"**\" characters. |\n| EMS-1030 | Postal Code cannot be empty. |\n| EMS-1031 | Invalid Postal Code. |\n| EMS-1032 | Unique ID cannot be empty. |\n| EMS-1033 | Invalid Unique ID. |\n| EMS-1034 | Customer already exists. |\n| EMS-1035 | Time Zone cannot be empty. |\n| EMS-1036 | Invalid Time Zone. |\n| EMS-1037 | Invalid Time Zone. |\n| EMS-1038 | Invalid Customer Type. |\n| EMS-1039 | Order Number cannot be empty. |\n| EMS-1040 | Invalid Order Number. |\n| EMS-1042 | Order not in status. |\n| EMS-1047 | Invalid Email Address. |\n| EMS-1048 | Technical Contact Email Address should be less than \"**\" characters. |\n| EMS-1049 | Invalid Technical Mobile Number. |\n| EMS-1050 | Invalid Technical Contact Designation. |\n| EMS-1051 | Technical Contact Designation should be less than \"**\" characters. |\n| EMS-1052 | Invalid CSR. |\n| EMS-1053 | Key Size is too small. |\n| EMS-1054 | The CSR uses an unsupported algorithm! |\n| EMS-1055 | Invalid Key. |\n| EMS-1056 | Domain Name is not matching. |\n| EMS-1057 | Invalid Domain Name. |\n| EMS-1058 | Wild Card Domains are not allowed. |\n| EMS-1059 | Only Wild Card Domains are allowed. |\n| EMS-1060 | Following Domain Names specified in this Order exists in
Public Suffix List (PSL). |\n| EMS-1061 | No requests were found with this email. |\n| EMS-1062 | Invalid Technical Contact ISD Code. |\n| EMS-1063 | Invalid App Code. |\n| EMS-1064 | App Code cannot be empty. |\n| EMS-1065 | Invalid Auth Key. |\n| EMS-1066 | Auth Key cannot be empty. |\n| EMS-1067 | PFX based certificate orders are not allowed. |\n| EMS-1068 | CSR is already submitted. |\n| EMS-1069 | CSR with ECC Keys are not allowed. Please submit CSR with
any of the following RSA keys: RSA-2048 or RSA-4096. |\n| EMS-1070 | Number Of Documents cannot be empty. |\n| EMS-1071 | Base64 file value cannot be empty. |\n| EMS-1072 | File upload limit reached. |\n| EMS-1073 | Invalid Organization Number. |\n| EMS-1074 | Organization Profile Number cannot be empty. |\n| EMS-1075 | Invalid Pre-Vetting value. |\n| EMS-1076 | We were unable to verify your domain. |\n| EMS-1077 | No valid TXT record found for the domain. If the TXT record is already updated,
please wait until the DNS is updated with the TXT record. |\n| EMS-1078 | No valid record found for the domain. |\n| EMS-1079 | CAA record validation failed due to invalid CAA records. Expected Value is either no
CAA records or CAA records with \"emsign.com\".
Please update the CAA records to match the expected value and retry. |\n| EMS-1080 | Domain is already verified. |\n| EMS-1081 | Invalid Document ID. |\n| EMS-1082 | Agreement already signed. |\n| EMS-1083 | Invalid Signer Name. |\n| EMS-1084 | agreementDetails block cannot be empty. |\n| EMS-1085 | acceptAgreement cannot be empty. |\n| EMS-1086 | Signed Place cannot be empty. |\n| EMS-1087 | Signer IP cannot be empty. |\n| EMS-1088 | Signer Name cannot be empty. |\n| EMS-1089 | Invalid Signer Place. |\n| EMS-1090 | Unauthorized Access. |\n| EMS-1091 | Invalid Signer IP. |\n| EMS-1092 | Invalid value for acceptAgreement. |\n| EMS-1093 | Invalid Verified IP. |\n| EMS-1094 | Invalid Verified Date. |\n| EMS-1095 | Originator ID cannot be empty. |\n| EMS-1096 | Request cannot be processed. |\n| EMS-1097 | Invalid Originator ID. |\n| EMS-1098 | Certificate Public Key does not match with CSR Public Key. |\n| EMS-1099 | Document ID order should be maintained. |\n| EMS-1100 | Key ID cannot be empty. |\n| EMS-1101 | MacKey ID cannot be empty. |\n| EMS-1102 | Email ID doesnot Exists. |\n| EMS-1102 | Agreement details cannot be empty. |\n| EMS-1103 | Domain verification is pending. |\n| EMS-1104 | CSR is not submitted. |\n| EMS-1105 | Invalid value for the key \"autoSecureWWW\". |\n| EMS-1106 | You have chosen to secure both www and without www for this SSL order.
File-based (HTTP / HTTPs URL) DCV method does not secure www automatically.
Please try with alternate DCV method. |\n| EMS-1107 | You have chosen to secure both www and without www for this SSL order.
File-based (HTTP / HTTPs URL) DCV method does not secure www automatically.
Please try with alternate DCV method. |\n| EMS-1108 | Invalid Order from Date. |\n| EMS-1109 | Invalid Order till Date. |\n| EMS-1110 | Order from Date cannot be greater than Order till Date. |\n| EMS-1111 | Invalid Certificate Expiry from Date. |\n| EMS-1112 | Invalid Certificate Expiry till Date. |\n| EMS-1113 | Certificate Expiry from Date cannot be greater than Certificate Expiry till Date. |\n| EMS-1114 | Invalid Page Number. |\n| EMS-1115 | Invalid Page Size. |\n| EMS-1116 | Invalid Order Number. |\n| EMS-1117 | Invalid Status ID. |\n| EMS-1118 | Dates cannot be greater than Current Date. |\n| EMS-1119 | \"searchCriteria\" block cannot be empty. |\n| EMS-1120 | Order from date cannot be empty. |\n| EMS-1121 | Order till date cannot be empty. |\n| EMS-1122 | Certificate Expiry from date cannot be empty. |\n| EMS-1123 | Certificate Expiry till date cannot be empty. |\n| EMS-1124 | Invalid Product Code value. |\n| EMS-1125 | Invalid Organization Name. |\n| EMS-1126 | Invalid Tag Search Criteria. |\n| EMS-1127 | Please enter Tag Details. |\n| EMS-1128 | Please enter valid Tag Details. |\n| EMS-1129 | Tag Name cannot exceed 30 characters. |\n| EMS-1130 | Tag Value cannot exceed 30 characters. |\n| EMS-1131 | Version cannot be empty. |\n| EMS-1133 | Request Ts cannot be empty. |\n| EMS-1134 | Organization Number cannot be empty. |\n| EMS-1135 | Invalid Organization Number. |\n| EMS-1136 | Group Number cannot be empty. |\n| EMS-1137 | Invalid Group Number. |\n| EMS-1138 | You have exceeded the maximum threshold limit set for your group.
Please contact your Account Administrator / Finance Manager. |\n| EMS-1139 | Insufficient Credits. Please contact your Account Administrator / Finance Manager. |\n| EMS-1140 | Account balance end date is expired. |\n| EMS-1141 | Exceeded Negative Credit Limit. You will be able to pay for this order,
once sufficient credits are available.
Please contact your Account Administrator / Finance Manager. |\n| EMS-1142 | Account User do not have access to use this Group. |\n| EMS-1143 | Account User do not have access to create new organization. |\n| EMS-1144 | Please enter valid Tag Details. |\n| EMS-1145 | Tag Name should be of minimum 3 characters. |\n| EMS-1146 | Tag Name cannot exceed 30 characters. |\n| EMS-1147 | Please enter valid Tag Name. |\n| EMS-1148 | Tag Value cannot exceed 30 characters. |\n| EMS-1149 | Please enter valid Tag Value. |\n| EMS-1150 | Tag Name and Value Pairs cannot be same. |\n| EMS-1151 | Tag Name (##appendString##) has been deactivated. |\n| EMS-1152 | Tag Value (##appendString##) has been deactivated. |\n| EMS-1153 | Please fill the billing details. |\n| EMS-1154 | Inactive Account User. |\n| EMS-1155 | Your account has been deactivated. Please contact your Account Manager. |\n| EMS-1156 | Your account has been expired. For any assistance, please contact your Account Manager. |\n| EMS-1157 | Domain name cannot be empty. |\n| EMS-1158 | DCV method cannot be empty. |\n| EMS-1159 | Invalid DCV method. |\n| EMS-1160 | Document ID cannot be empty. |\n| EMS-1161 | Organization provided in this order is not allowed to use due to specific
organization restrictions set for this Group (##groupNumber##).
Please contact your account administrator. |\n| EMS-1162 | Invalid Product Code. |\n| EMS-1163 | Invalid Number of Documents. |\n| EMS-1164 | Domain Name is not matching. |\n| EMS-1165 | Invalid request status. |\n| EMS-1166 | DCV Email cannot be empty. |\n| EMS-1167 | Invalid DCV Email. |\n| EMS-1168 | Requestor ISD Code cannot be empty. |\n| EMS-1169 | Request is already processed. |\n| EMS-1170 | One or more domain names provided in this order are not allowed to use due to specific domain
restrictions set for (##groupName##) Group.
Please contact your account administrator. |\n| EMS-1171 | Additional Domain Names name cannot be empty. |\n| EMS-1172 | Number of Additional Domain Names cannot exceed ##NumberOfDomains##. |\n| EMS-1173 | Invalid Additional Domain Names. |\n| EMS-1175 | Wild Card Additional Domain Names are not allowed. |\n| EMS-1176 | Only Wild Card Additional Domain Names are allowed. |\n| EMS-1177 | Domain name and Additional Domain Names (##AdditionalDomainName##) cannot be same. |\n| EMS-1178 | Additional Domain Names (##AdditionalDomainName##) cannot be duplicate. |\n| EMS-1179 | Additional Domain Names cannot be empty. |\n| EMS-1180 | ##fieldName## cannot be empty. |\n| EMS-1181 | Invalid ##fieldName##. |\n| EMS-1182 | country cannot be empty. |\n| EMS-1184 | First Name should be alphabets and special characters. |\n| EMS-1185 | Last Name should be alphabets and special characters. |\n| EMS-1186 | Locality should be alphabets and special characters. |\n| EMS-1187 | Identity Document Type only allows alphabets and special characters. |\n| EMS-1188 | ##fieldName## should be less than ##MaxLength## characters. |\n| EMS-1189 | Invalid Certificate ISD Code. |\n| EMS-1190 | Invalid Certificate Mobile Number. |\n| EMS-1191 | Invalid Certificate Email ID. |\n| EMS-1192 | Invalid Business Category. |\n| EMS-1193 | Designation must contain alphabets. |\n| EMS-1194 | Designation should be of minimum 3 and should be less than 64 characters. |\n| EMS-1195 | Product Code for this account is not mapped. |\n| EMS-1196 | Permission is restricted for this account to complete subscriber agreement. |\n| EMS-1197 | Order does not belong to corresponding account number. |\n| EMS-1200 | Account Status should be active. |\n| EMS-1203 | Invalid Field ID - ##fieldID##. |\n| EMS-1205 | Please enter ##fieldName##. |\n| EMS-1206 | ##fieldName## should not exceed more than ##maxlength## characters. |\n| EMS-1207 | Please enter valid ##fieldName##. |\n| EMS-1208 | Please select ##fieldName##. |\n| EMS-1209 | Please select valid ##fieldName##. |\n| EMS-1210 | Custom Fields for this account are not mapped. |\n| EMS-1211 | Custom Fields cannot be empty. |\n| EMS-1212 | One or more mandatory field IDs are not present. |\n| EMS-1213 | Invalid Revoke Remarks. |\n| EMS-1214 | Please enter valid Organization Consent Token. |\n| EMS-1215 | Please contact the account administrator to process the order. |\n| EMS-1216 | Please enter valid Validity in Subscription Details. |\n| EMS-1217 | Please enter valid Auto Renew in Subscription Details. |\n| EMS-1218 | Please enter valid Renew Criteria in Subscription Details. |\n| EMS-1220 | Contract Signer Information block cannot be empty. |\n| EMS-1221 | Certificate ApproverI nformation block cannot be empty. |\n| EMS-1222 | Contract Signer Email ID cannot be empty |\n| EMS-1223 | Invalid Contract Signer Email ID. |\n| EMS-1224 | Invalid Contract Signer ISD Code. |\n| EMS-1225 | Contract Signer Mobile Number cannot be empty. |\n| EMS-1226 | Invalid Contract Signer Mobile Number. |\n| EMS-1227 | Contract Signer Designation cannot be empty. |\n| EMS-1228 | Invalid Contract Signer Name. |\n| EMS-1229 | Invalid Contract Signer Designation. |\n| EMS-1230 | Contract Signer Name cannot be empty. |\n| EMS-1231 | Certificate Approver Email ID cannot be empty. |\n| EMS-1232 | Invalid Certificate Approver Email ID. |\n| EMS-1233 | Invalid Certificate Approver ISD Code. |\n| EMS-1234 | Certificate Approver Mobile Number cannot be empty. |\n| EMS-1235 | Invalid Certificate Approver Mobile Number. |\n| EMS-1236 | Certificate Approver Designation cannot be empty. |\n| EMS-1237 | Invalid Certificate Approver Name. |\n| EMS-1238 | Invalid Certificate Approver Designation. |\n| EMS-1239 | Certificate Approver Name cannot be empty. |" + }, + { + "name": "Country Codes", + "item": [], + "description": "| Country Name | Country Code |\n| --- | --- |\n| Afghanistan | AF |\n| Aland Islands | AX |\n| Albania | AL |\n| Algeria | DZ |\n| American Samoa | AS |\n| Andorra | AD |\n| Angola | AO |\n| Anguilla | AI |\n| Antarctica | AQ |\n| Antigua and Barbuda | AG |\n| Argentina | AR |\n| Armenia | AM |\n| Aruba | AW |\n| Australia | AU |\n| Austria | AT |\n| Azerbaijan | AZ |\n| Bahamas | BS |\n| Bahrain | BH |\n| Bangladesh | BD |\n| Barbados | BB |\n| Belarus | BY |\n| Belgium | BE |\n| Belize | BZ |\n| Benin | BJ |\n| Bermuda | BM |\n| Bhutan | BT |\n| Bolivia | BO |\n| Bonaire, Sint Eustatius and Saba | BQ |\n| Bosnia and Herzegovina | BA |\n| Botswana | BW |\n| Bouvet Island | BV |\n| Brazil | BR |\n| British Indian Ocean Territory | IO |\n| Brunei Darussalam | BN |\n| Bulgaria | BG |\n| Burkina Faso | BF |\n| Burundi | BI |\n| Cabo Verde | CV |\n| Cambodia | KH |\n| Cameroon | CM |\n| Canada | CA |\n| Cayman Islands | KY |\n| Central African Republic | CF |\n| Chad | TD |\n| Chile | CL |\n| China | CN |\n| Christmas Island | CX |\n| Cocos (Keeling) Islands | CC |\n| Colombia | CO |\n| Comoros | KM |\n| Congo (the Democratic Republic of the) | CD |\n| Congo | CG |\n| Cook Islands | CK |\n| Costa Rica | CR |\n| Cote dIvoire | CI |\n| Croatia | HR |\n| Cuba | CU |\n| Curaçao | CW |\n| Cyprus | CY |\n| Czechia | CZ |\n| Denmark | DK |\n| Djibouti | DJ |\n| Dominica | DM |\n| Dominican Republic | DO |\n| Ecuador | EC |\n| Egypt | EG |\n| El Salvador | SV |\n| Equatorial Guinea | GQ |\n| Eritrea | ER |\n| Estonia | EE |\n| Ethiopia | ET |\n| Falkland Islands \\[Malvinas\\] | FK |\n| Faroe Islands | FO |\n| Fiji | FJ |\n| Finland | FI |\n| France | FR |\n| French Guiana | GF |\n| French Polynesia | PF |\n| French Southern Territories | TF |\n| Gabon | GA |\n| Gambia | GM |\n| Georgia | GE |\n| Germany | DE |\n| Ghana | GH |\n| Gibraltar | GI |\n| Greece | GR |\n| Greenland | GL |\n| Grenada | GD |\n| Guadeloupe | GP |\n| Guam | GU |\n| Guatemala | GT |\n| Guernsey | GG |\n| Guinea | GN |\n| Guinea-Bissau | GW |\n| Guyana | GY |\n| Haiti | HT |\n| Heard Island and McDonald Islands | HM |\n| Holy See | VA |\n| Honduras | HN |\n| Hong Kong | HK |\n| Hungary | HU |\n| Iceland | IS |\n| India | IN |\n| Indonesia | ID |\n| Iran (Islamic Republic of) | IR |\n| Iraq | IQ |\n| Ireland | IE |\n| Isle of Man | IM |\n| Israel | IL |\n| Italy | IT |\n| Jamaica | JM |\n| Japan | JP |\n| Jersey | JE |\n| Jordan | JO |\n| Kazakhstan | KZ |\n| Kenya | KE |\n| Kiribati | KI |\n| Korea (the Democratic Peoples Republic of) | KP |\n| Korea (the Republic of) | KR |\n| Kuwait | KW |\n| Kyrgyzstan | KG |\n| Laos | LA |\n| Latvia | LV |\n| Lebanon | LB |\n| Lesotho | LS |\n| Liberia | LR |\n| Libya | LY |\n| Liechtenstein | LI |\n| Lithuania | LT |\n| Luxembourg | LU |\n| Macao | MO |\n| Macedonia (the former Yugoslav Republic of) | MK |\n| Madagascar | MG |\n| Malawi | MW |\n| Malaysia | MY |\n| Maldives | MV |\n| Mali | ML |\n| Malta | MT |\n| Marshall Islands | MH |\n| Martinique | MQ |\n| Mauritania | MR |\n| Mauritius | MU |\n| Mayotte | YT |\n| Mexico | MX |\n| Micronesia | FM |\n| Moldova | MD |\n| Monaco | MC |\n| Mongolia | MN |\n| Montenegro | ME |\n| Montserrat | MS |\n| Morocco | MA |\n| Mozambique | MZ |\n| Myanmar | MM |\n| Namibia | NA |\n| Nauru | NR |\n| Nepal | NP |\n| Netherlands | NL |\n| New Caledonia | NC |\n| New Zealand | NZ |\n| Nicaragua | NI |\n| Niger | NE |\n| Nigeria | NG |\n| Niue | NU |\n| Norfolk Island | NF |\n| Northern Mariana Islands | MP |\n| Norway | NO |\n| Oman | OM |\n| Pakistan | PK |\n| Palau | PW |\n| Palestine | PS |\n| Panama | PA |\n| Papua New Guinea | PG |\n| Paraguay | PY |\n| Peru | PE |\n| Philippines | PH |\n| Pitcairn | PN |\n| Poland | PL |\n| Portugal | PT |\n| Puerto Rico | PR |\n| Qatar | QA |\n| Réunion | RE |\n| Romania | RO |\n| Russian Federation | RU |\n| Rwanda | RW |\n| Saint Barthelemy | BL |\n| Saint Helena, Ascension and Tristan da Cunha | SH |\n| Saint Kitts and Nevis | KN |\n| Saint Lucia | LC |\n| Saint Martin (French part) | MF |\n| Saint Pierre and Miquelon | PM |\n| Saint Vincent and the Grenadines | VC |\n| Samoa | WS |\n| San Marino | SM |\n| Sao Tome and Principe | ST |\n| Saudi Arabia | SA |\n| Senegal | SN |\n| Serbia | RS |\n| Seychelles | SC |\n| Sierra Leone | SL |\n| Singapore | SG |\n| Sint Maarten (Dutch part) | SX |\n| Slovakia | SK |\n| Slovenia | SI |\n| Solomon Islands | SB |\n| Somalia | SO |\n| South Africa | ZA |\n| South Georgia and the South Sandwich Islands | GS |\n| South Sudan | SS |\n| Spain | ES |\n| Sri Lanka | LK |\n| Sudan | SD |\n| Suriname | SR |\n| Svalbard and Jan Mayen | SJ |\n| Swaziland | SZ |\n| Sweden | SE |\n| Switzerland | CH |\n| Syrian Arab Republic | SY |\n| Taiwan (Province of China) | TW |\n| Tajikistan | TJ |\n| Tanzania, United Republic of | TZ |\n| Thailand | TH |\n| Timor-Leste | TL |\n| Togo | TG |\n| Tokelau | TK |\n| Tonga | TO |\n| Trinidad and Tobago | TT |\n| Tunisia | TN |\n| Turkey | TR |\n| Turkmenistan | TM |\n| Turks and Caicos Islands | TC |\n| Tuvalu | TV |\n| Uganda | UG |\n| Ukraine | UA |\n| United Arab Emirates | AE |\n| United Kingdom of Great Britain and Northern Ireland | GB |\n| United States Minor Outlying Islands | UM |\n| United States of America (USA) | US |\n| Uruguay | UY |\n| Uzbekistan | UZ |\n| Vanuatu | VU |\n| Venezuela (Bolivarian Republic of) | VE |\n| Vietnam | VN |\n| Virgin Islands (British) | VG |\n| Virgin Islands (U.S.) | VI |\n| Wallis and Futuna | WF |\n| Western Sahara | EH |\n| Yemen | YE |\n| Zambia | ZM |\n| Zimbabwe | ZW |\n| North Macedonia | MK |" + }, + { + "name": "Product Codes", + "item": [ + { + "name": "Production", + "item": [], + "description": "| Product Name | Product Code |\n| --- | --- |\n| DV SSL Certificate 1 Year | 838 |\n| DV SSL Certificate Wildcard 1 Year | 839 |\n| DV SSL Certificate UCC 1 Year | 840 |\n| DV SSL Certificate Wildcard UCC 1 Year | 841 |\n| OV SSL Certificate 1 Year | 842 |\n| OV SSL Certificate Wildcard 1 Year | 843 |\n| OV SSL Certificate UCC 1 Year | 844 |\n| OV SSL Certificate Wildcard UCC 1 Year | 845 |\n| EV SSL Certificate 1 Year | 846 |\n| EV SSL Certificate UCC 1 Year | 847 |\n| emSign - SMIME - Simple MV-S 1 Year | 894 |\n| emSign Natural Person Certificate - NonRepudiation 3 Years | 827 |\n| emSign Natural Person Certificate - NonRepudiation 2 Years | 826 |\n| emSign Natural Person Certificate - NonRepudiation 1 Year | 825 |\n| emSign Legal Person Certificate - NonRepudiation 3 Years | 824 |\n| emSign Legal Person Certificate - NonRepudiation 2 Years | 823 |\n| emSign Legal Person Certificate - NonRepudiation 1 Year | 822 |\n| emSign Legal Entity Certificate - NonRepudiation 3 Years | 821 |\n| emSign Legal Entity Certificate - NonRepudiation 2 Years | 820 |\n| emSign Legal Entity Certificate - NonRepudiation 1 Year | 819 |\n| IGTF Host Certificate 1 Year | 104 |\n| emSign Intranet SSL 1 Year | 100 |" + }, + { + "name": "Sandbox", + "item": [], + "description": "| Product Name | Product Code |\n| --- | --- |\n| DV SSL Certificate 1 Year | 842 |\n| DV SSL Certificate Wildcard 1 Year | 843 |\n| DV SSL Certificate UCC 1 Year | 844 |\n| DV SSL Certificate Wildcard UCC 1 Year | 845 |\n| OV SSL Certificate 1 Year | 846 |\n| OV SSL Certificate Wildcard 1 Year | 847 |\n| OV SSL Certificate UCC 1 Year | 848 |\n| OV SSL Certificate Wildcard UCC 1 Year | 849 |\n| EV SSL Certificate 1 Year | 850 |\n| EV SSL Certificate UCC 1 Year | 851 |\n| emSign - SMIME - Simple MV-S 1 Year | 914 |\n| emSign Natural Person Certificate - NonRepudiation 3 Years | 827 |\n| emSign Natural Person Certificate - NonRepudiation 2 Years | 826 |\n| emSign Natural Person Certificate - NonRepudiation 1 Year | 825 |\n| emSign Legal Person Certificate - NonRepudiation 3 Years | 824 |\n| emSign Legal Person Certificate - NonRepudiation 2 Years | 823 |\n| emSign Legal Person Certificate - NonRepudiation 1 Year | 822 |\n| emSign Legal Entity Certificate - NonRepudiation 3 Years | 821 |\n| emSign Legal Entity Certificate - NonRepudiation 2 Years | 820 |\n| emSign Legal Entity Certificate - NonRepudiation 1 Year | 819 |\n| IGTF Host Certificate 1 Year | 108 |\n| emSign Intranet SSL 1 Year | 104 |" + } + ] + } + ], + "description": "RESTful services designed for scale, flexibility & ease of integration. CERTInext Interface-less APIs enables access to key functionalities offered on CERTInext product that can be consumed by your application. Our API's are built on REST and therefore interoperable with any existing web application framework that supports REST based API calls." + }, + { + "name": "ACME APIs", + "item": [ + { + "name": "How to get started", + "item": [], + "description": "ACME EAB Credentials can be generated via CERTInext.\n\nFollow the steps mentioned below to generate it.\n\n**Steps**\n\n- Login to CERTInext portal\n- Click on Integration\n- Click on ACME APIs\n- Click on '+' (Generate New ACME EAB Credentials) on the top right of the page. Fill all the Details.\n \n\n| Field Name | Field Specifications |\n| --- | --- |\n| Description | Mandatory |\n| User | Mandatory |\n| Groups | Mandatory |\n| Product | Mandatory |\n| Tags | Optional |\n\n- Click on Generate EAB Credentials to generate ACME EAB Credentials.\n \n\n**NOTE**: Multiple users (and/or) groups cannot be mapped for a pair of Key ID & Mac Key. Anyhow, multiple tags can be mapped for it.\n\n\"\"\n\n- Once EAB Credentials generated, it will be displayed in CERTInext as below.\n \n\n\"\"\n\n- On click of 'View' of EAB Credentials, User can view and copy the external account binding credentials (EAB Credentials). EAB Credentials consists of Key ID & Mac Key. Refer below.\n \n\n\"\"" + }, + { + "name": "Generation of emSign TLS DV using ACME Certbot", + "item": [], + "description": "**Installation of Certbot application** \nInstall certbot application in your system.\n\n**Certbot Command**\n\n```\ncertbot\n-d {{yourdomain.com}} \n--manual --preferred-challenges dns certonly \n--server {{https://sandbox-us-api.certinext.io/emSignACME-API/directory}}\n--eab-kid={{xxxxxxxxxxxxxxxxxxxx}} --eab-hmac-key={{xxxxxxxxxxxxxxxxxxxx}} \n--config-dir {{\"C:/Users/xxxxx/Documents/certobot uat/conf\"}} \n--work-dir {{\"C:/Users/xxxxx/Documents/certobot uat/work\" }}\n--logs-dir {{\"C:/Users/xxxxx/Documents/certobot uat/logs\" }}\n--key-type {{\"rsa\"}} \n--rsa-key-size {{2048}}\n\n ```\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| Domain Name | (mandatory) Domain Name of the website / server. |\n| ACME URL | (mandatory) ACME URL will be provided by eMudhra.
ACME URL: https://(url host port and path)/emSignACME-API/directory |\n| eabkid | (mandatory) User can create Key ID from CERTInext account.
Path: Login CERTInext portal > Integration > ACME APIs > New ACME EAB Credentials > Fill the details > Generate EAB Credentials. |\n| eabhmackey | User can create Mac Key from CERTInext account.
**Path**: Login CERTInext portal > Integration > ACME APIs > New ACME EAB Credentials > Fill the details > Generate EAB Credentials. |\n| Config dir | Configuration file path of the cerbot application. |\n| Work dir | Specify a path for input data. |\n| Logs dir | Specify a path for input data. |\n| Key Type | RSA Algorithm. |\n| RSA Key size | RSA Algorithm with 2048. |" + }, + { + "name": "Step-by-Step guide to get DV SSL using Certbot", + "item": [], + "description": "Refer the steps as shown below.\n\nStep 1: **(Enter your Mac Key and Key ID in the script. Click Enter.)**\n\n\n\nStep 2: **(Enter your email Id and click Enter.)**\n\n\n\nStep 3: **(Accept the terms and conditions by click on 'Y'.)**\n\n\n\nStep 4: **(To send the issued certificate to your shared email Id by click on 'Y'.)**\n\n\n\nStep 5:\n\n\n\nStep 6: **(Your Certificate issued successfully)**\n\n" + }, + { + "name": "Generation of emSign TLS DV in WinACME", + "item": [], + "description": "**Installation of WinACME application** \nInstall the WinACME application\n\n**Specification**\n\n| Name of the Attribute | Specification |\n| --- | --- |\n| Domain Name | (mandatory) Domain Name of the website / server. |\n| WinACME URL | WinACME URL will be provided by eMudhra. https:///emSignACME-API/acme/directory |\n| eabkid | (mandatory) User can create Key ID from CERTInext account.
**Path**: Login CERTInext portal > Integration > ACME APIs > New ACME EAB Credentials > Fill the details > Generate EAB Credentials. |\n| eabhmackey | User can create Mac Key from CERTInext account.
**Path**: Login CERTInext portal > Integration > ACME APIs > New ACME EAB Credentials > Fill the details > Generate EAB Credentials. |\n| Config dir | Configuration file path of the WinACME application. |\n| Work dir | Specify a path for input data. |\n| Logs dir | Specify a path for input data. |\n| Key Type | RSA Algorithm. |\n| RSA Key size | RSA Algorithm with 2048. |\n\n
  • In settings.json file update the following parameters before placing order:-
  • \n\n\"DefaultBaseUri\": \"https:///emsignACME-API/acme/directory\", \n\"DefaultBaseUriTest\": \"https:///emsignACME- API/acme/directory\", \n\"DefaultBaseUriImport\": \"https:///emsignACME-API/acme/directory\",\n\n\"RSAKeyBits\": 2048,\n\nPlease find the steps as shown below:\n\nStep 1: **(Configuration setup)**\n\n\n\nStep 2: **(Enter Domain Name)**\n\n\n\nStep 3: **(Select Domain Control Validation (DCV) method)**\n\n\n\nStep 4: **(Subscriber Agreement)**\n\n\n\nStep 5: **(EAB Credential Authentication)**\n\n\n\nStep 6: **(Domain Verification)**\n\n\n\nStep 7: **(Download Certificate)**\n\n" + }, + { + "name": "Generation of emSign TLS OV Cert. in certbot", + "item": [], + "description": "Refer the steps as shown below.\n\nStep 1: **(Enter your Mac Key and Key ID in the predefined script & Click 'Enter'.)**\n\n\n\nStep 2: **(Enter your email address and click 'Enter'.)**\n\n\n\nStep 3: **(Enter 'Y' to accept the terms and conditions)**\n\n\n\nStep 4: **(Enter 'Y' to send the issued certificate to your shared email ID.)**\n\n\n\nStep 5: **(Your Certificate issued successfully)**\n\n" + }, + { + "name": "Benefits of ACME", + "item": [], + "description": "The Automatic Certificate Management Environment (ACME) protocol offers several benefits for managing and securing web communications, particularly through the use of SSL/TLS certificates. Here are some key advantages:\n\n- **Automation**: ACME automates the process of obtaining, validating, and renewing SSL/TLS certificates. This automation reduces the administrative burden on website owners, making it easier to maintain secure connections.\n \n- **Enhanced Security:** By automating the certificate management process, ACME encourages more frequent certificate renewal. Shorter certificate lifetimes mean that compromised certificates are valid for a shorter duration, enhancing overall security.\n \n- **Cost-Effective:** ACME is a cost-effective solution for availing Free SSL/TLS certificates, especially for individuals and organizations with budget constraints.\n \n- **Improved HTTPS Adoption**: The simplified process of obtaining and renewing certificates encourages website owners to implement HTTPS, leading to a more secure web environment. This is crucial for protecting user data and ensuring the integrity of online communications.\n \n- **Flexible Validation Methods:** ACME supports multiple methods for domain validation, including HTTP-based validation (placing a file on the web server), DNS-based validation, and TLS-based validation. This flexibility allows website owners to choose the most suitable method for their infrastructure.\n \n- **Open Standards:** ACME is designed as an open protocol, encouraging interoperability and standardization. This openness promotes a consistent and widely adopted approach to certificate management.\n \n- **Reduced Downtime:** Automated certificate renewal reduces the risk of certificate expiration, minimizing the potential for service disruptions due to expired certificates. This is especially critical for maintaining the availability and reliability of websites.\n \n- **Wildcard Certificate Support:** ACME supports the issuance of wildcard certificates, allowing website owners to secure not only a specific subdomain but all its subdomains with a single certificate.\n \n- **Ease of Implementation:** ACME's well-defined protocol and the availability of client libraries make it relatively easy for system administrators and developers to integrate ACME into their infrastructure.\n \n\nOverall, the ACME protocol, has played a significant role in simplifying the process of securing web communications and promoting a more secure online environment." + } + ], + "description": "ACME stands for Automated Certificate Management Environment. emSign's ACME service is meticulously crafted to simplify the automation of SSL/TLS processes, mitigating the complexity and effort associated with managing numerous certificates within an enterprise. With organizations juggling a multitude of certificates, each demanding significant time and effort, ACME proves invaluable by completely automating the essential procedures needed to oversee SSL/TLS certificates across all endpoints in your organization. It provides an easy-to-use method of automating interactions between emSign CA and a web server.\n\n**NOTE:** Unlike REST APIs for which you are using Postman, for using our ACME service you have to use any type of ACME client that supports EAB authorization. There are many ACME clients such as win-acme, certbot, etc. available in this space. Most of these tools are free to use and created to simplify use of the ACME protocol." + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "requestTxnId = Math.floor((Math.random() * (1000000000000000000) + 1));", + "pm.variables.set(\"txn\", requestTxnId);", + "", + "const moment = require('moment');", + "let requestTs = moment().format('YYYY-MM-DDTHH:mm:ss+05:30');", + "pm.variables.set(\"ts\", requestTs);", + "", + "let AccessKey = pm.collectionVariables.get('AccessKey') || pm.environment.get('AccessKey');", + "let accessKeyStr = AccessKey + requestTs + requestTxnId;", + "let clientAccessKey = CryptoJS.SHA256(accessKeyStr).toString(CryptoJS.enc.HEX);", + "pm.variables.set(\"hash\", clientAccessKey);", + "", + "// Log the request to Postman Console for debugging", + "console.log(\"--- REQUEST [\" + pm.info.requestName + \"] ---\");", + "console.log(\"URL: \" + pm.request.url.toString());", + "if (pm.request.body && pm.request.body.raw) {", + " try {", + " console.log(\"Body:\\n\" + JSON.stringify(JSON.parse(pm.request.body.raw), null, 2));", + " } catch(e) {", + " console.log(\"Body (raw):\\n\" + pm.request.body.raw);", + " }", + "}" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "try {", + " const res = pm.response.json();", + " if (res.orderDetails && res.orderDetails.orderNumber) {", + " pm.collectionVariables.set(\"orderNumber\", res.orderDetails.orderNumber);", + " }", + " if (res.orderDetails && res.orderDetails.requestNumber) {", + " pm.collectionVariables.set(\"requestNumber\", res.orderDetails.requestNumber);", + " }", + "} catch(e) {", + " // Non-order endpoints - skip variable extraction", + "}" + ] + } + } + ], + "variable": [ + { + "key": "accountNumber", + "value": "", + "type": "string" + }, + { + "key": "AccessKey", + "value": "", + "type": "string" + }, + { + "key": "baseURL", + "value": "", + "type": "string" + }, + { + "key": "requestorEmail", + "value": "", + "type": "string" + }, + { + "key": "requestorName", + "value": "", + "type": "string" + }, + { + "key": "requestorMobileNumber", + "value": "", + "type": "string" + }, + { + "key": "requestorDesignation", + "value": "", + "type": "string" + }, + { + "key": "domainName", + "value": "", + "type": "string" + }, + { + "key": "wildcardDomainName", + "value": "", + "type": "string" + }, + { + "key": "organizationNumber", + "value": "", + "type": "string" + }, + { + "key": "groupNumber", + "value": "", + "type": "string" + }, + { + "key": "SSL_DV", + "value": "", + "type": "string" + }, + { + "key": "SSL_DV_Wildcard", + "value": "", + "type": "string" + }, + { + "key": "SSL_DV_UCC", + "value": "", + "type": "string" + }, + { + "key": "SSL_DV_Wildcard_UCC", + "value": "", + "type": "string" + }, + { + "key": "SSL_OV", + "value": "", + "type": "string" + }, + { + "key": "SSL_OV_Wildcard", + "value": "", + "type": "string" + }, + { + "key": "SSL_OV_UCC", + "value": "", + "type": "string" + }, + { + "key": "SSL_OV_Wildcard_UCC", + "value": "", + "type": "string" + }, + { + "key": "SSL_EV", + "value": "", + "type": "string" + }, + { + "key": "SSL_EV_UCC", + "value": "", + "type": "string" + }, + { + "key": "SMIME_Simple", + "value": "", + "type": "string" + }, + { + "key": "DocSigner_NaturalPerson_1Year", + "value": "", + "type": "string" + }, + { + "key": "DocSigner_NaturalPerson_2Year", + "value": "", + "type": "string" + }, + { + "key": "DocSigner_NaturalPerson_3Year", + "value": "", + "type": "string" + }, + { + "key": "DocSigner_LegalPerson_1Year", + "value": "", + "type": "string" + }, + { + "key": "DocSigner_LegalPerson_2Year", + "value": "", + "type": "string" + }, + { + "key": "DocSigner_LegalPerson_3Year", + "value": "", + "type": "string" + }, + { + "key": "DocSigner_LegalEntity_1Year", + "value": "", + "type": "string" + }, + { + "key": "DocSigner_LegalEntity_2Year", + "value": "", + "type": "string" + }, + { + "key": "DocSigner_LegalEntity_3Year", + "value": "", + "type": "string" + }, + { + "key": "PrivatePKI_IntranetSSL", + "value": "", + "type": "string" + }, + { + "key": "PrivatePKI_IGTF", + "value": "", + "type": "string" + }, + { + "key": "signerIP", + "value": "", + "type": "string" + }, + { + "key": "signerPlace", + "value": "", + "type": "string" + }, + { + "key": "orderNumber", + "value": "", + "type": "string" + }, + { + "key": "requestNumber", + "value": "", + "type": "string" + } + ] +} \ No newline at end of file diff --git a/docsource/architecture.md b/docsource/architecture.md index 93ac459..b46eabb 100644 --- a/docsource/architecture.md +++ b/docsource/architecture.md @@ -26,9 +26,17 @@ This document describes how the CERTInext AnyCA Gateway REST plugin integrates w ┌────────────────────────────▼────────────────────────────┐ │ CERTInext REST API (eMudhra) │ │ │ -│ ValidateCredentials GenerateOrderSSL TrackOrder │ -│ GetCertificate RevokeOrder GetOrderReport │ -│ GetProductDetails SubmitCSR │ +│ V1 (HMAC) ValidateCredentials · GenerateOrderSSL │ +│ TrackOrder · GetCertificate · GetOrderReport │ +│ RevokeOrder · GetProductDetails · SubmitCSR │ +│ │ +│ V2 (OAuth2 Bearer) POST /oauth/token │ +│ POST /ssl-certificates │ +│ GET /ssl-certificates/{id} │ +│ GET /ssl-certificates/{id}/dcv │ +│ POST /ssl-certificates/{id}/dcv/verify │ +│ GET /ssl-certificates/{id}/certificate │ +│ POST /ssl-certificates/{id}/revoke │ └─────────────────────────────────────────────────────────┘ ``` @@ -44,6 +52,8 @@ A unique transaction ID (`requestTxnId`) is generated for each request. The time An OAuth client-credentials mode is also available as an alternative. When OAuth is configured, the plugin exchanges a client ID and secret for a short-lived bearer token and automatically refreshes it before expiry. +When `UseV2Api` is enabled, the plugin uses a dedicated OAuth2 `client_credentials` flow, reusing the connector's `OAuthClientId`/`OAuthClientSecret` fields regardless of the V1 `AuthMode` setting. The plugin posts `client_id` and `client_secret` (form-encoded) to `{ApiUrl}/oauth/token` (the same `ApiUrl` field, which becomes the V2 host in this mode), caches the resulting bearer token for its 1-hour lifetime, and automatically refreshes it 60 seconds before expiry. + ## Certificate Identifiers CERTInext assigns two different reference numbers to each order. Understanding the difference matters when tracing certificates across systems: @@ -113,6 +123,8 @@ sequenceDiagram **Expired certificates:** The `IgnoreExpired` connector setting controls whether expired certificates are included in synchronization. When enabled, expired certificates are silently skipped and will not appear in the Keyfactor Command inventory. +**DCV-during-sync:** on a DCV-enabled build, each sync pass also drives DNS-01 validation forward for pending DV orders that are still waiting on it, bounded by `DcvSyncMaxOrderAgeHours` (skip orders older than this) and `DcvSyncMaxPerPass` (cap how many are attempted per pass), so a large backlog of stalled pending orders can't slow down every sync. + --- ## Certificate Enrollment @@ -134,13 +146,27 @@ sequenceDiagram Plugin->>API: Place certificate order\n(CSR, domain, organization details,\nsubscriber agreement, requestor info) API-->>Plugin: Order accepted — order number assigned + opt DNS-01 DCV build, DCV enabled, and this order requires it + Plugin->>Plugin: Publish DNS TXT challenge\nvia the configured DNS provider plugin + Plugin->>API: Ask CERTInext to verify the record + API-->>Plugin: Domain validated (or still pending —\nfalls through to the pending path below) + end + Plugin->>API: Check order status API-->>Plugin: Order status and certificate details alt Certificate issued immediately Plugin-->>CMD: Certificate ready — PEM returned - else Certificate pending approval - Plugin-->>CMD: Pending — Command will pick it up\nduring the next synchronization + else Certificate pending or not yet downloadable + loop Synchronous certificate pickup\n(PickupRetries × PickupDelay, default 3 × 5 s; ceiling 180 s) + Plugin->>API: Poll order status\nand attempt certificate download + API-->>Plugin: Status / certificate PEM + end + alt Certificate became available during pickup + Plugin-->>CMD: Certificate ready — PEM returned + else Still not available + Plugin-->>CMD: Pending — Command will pick it up\nduring the next synchronization + end else Order rejected by CERTInext Plugin-->>CMD: Enrollment failed — see gateway logs end @@ -148,12 +174,18 @@ sequenceDiagram Plugin->>Plugin: Record enrollment outcome in audit log\n(order number, serial number, status) ``` +**DCV:** on a DCV-enabled build, DNS-01 validation runs inline for DV orders that require it, bounded by `DcvTimeoutMinutes`. When DCV isn't enabled, isn't built into this host, or the order doesn't require it, this step is skipped entirely and the order proceeds straight to the pending/pickup path like any other asynchronously-issued order. + +**Synchronous certificate pickup:** after placing an order (or after DCV completes), the plugin polls CERTInext a bounded number of times — `PickupRetries` attempts spaced `PickupDelay` seconds apart, with a hard ceiling of 180 seconds — before returning a pending disposition to Command. This lets fast-issuing DV certificates (and pre-approved renewals) come back in the same enrollment call. OV and EV orders undergo human review over minutes to hours and almost always exhaust this window; they are picked up by the next synchronization run. + ### Renewal When Command initiates a renewal, the plugin checks whether the existing certificate is within the configured renewal window. If it is, the prior order record is used as context for the new request. If it is outside the window (or the prior certificate cannot be located), the plugin falls back to issuing a new certificate. > **Note:** CERTInext does not have a dedicated certificate renewal endpoint. Both renewal and reissuance paths submit a new `GenerateOrderSSL` order. The distinction affects how Keyfactor Command tracks the certificate record, not what is sent to CERTInext. +> **Note:** If the prior-order lookup itself throws (rather than cleanly returning "not found" — e.g. a transient database error), the plugin falls back to issuing a new certificate rather than failing the enrollment. + ```mermaid flowchart TD A([Renewal requested]) --> B{Prior certificate\nserial number\nprovided?} @@ -169,6 +201,107 @@ flowchart TD C --> I ``` +### V2 API Path (UseV2Api = true) + +When `UseV2Api` is enabled, Ping, Enroll, GetSingleRecord, Revoke, and Synchronize all route through the V2 REST API — Synchronize calls V2 `/reports/orders` rather than the V1 `GetOrderReport` endpoint, and V1 credentials are not required in this mode. + +#### DCV required (DV SSL) + +```mermaid +sequenceDiagram + participant CMD as Keyfactor Command + participant Plugin as CERTInext Plugin + participant API as CERTInext API (V2) + participant DNS as DNS Provider + + CMD->>Plugin: Request new certificate\n(CSR, subject, SANs, product code, requester details) + Plugin->>Plugin: Record enrollment intent in audit log + + Plugin->>API: POST /oauth/token\n(client_credentials grant) + API-->>Plugin: Bearer token (1-hour TTL) + + Plugin->>API: POST /ssl-certificates\n(X-Product-Code header · Idempotency-Key · JSON body) + API-->>Plugin: 201 Created — orderId assigned\nstatus: pending-dcv + + Plugin->>API: GET /ssl-certificates/{orderId}/dcv + API-->>Plugin: DCV challenge\n(fileNameContent = TXT value,\ndcvMethod = "2" for DNS-TXT) + + Plugin->>DNS: Publish TXT record\n_emudhra-challenge.{domain} → fileNameContent + Plugin->>Plugin: Wait for DNS propagation + + Plugin->>API: POST /ssl-certificates/{orderId}/dcv/verify\n(domain, method: "dns-txt") + API-->>Plugin: { "overallStatus": "VERIFIED" }\n(multi-perspective check) + + Plugin->>DNS: Remove TXT record + + loop Poll until status leaves pending-dcv\n(bounded by DcvTimeoutMinutes) + Plugin->>API: GET /ssl-certificates/{orderId} + API-->>Plugin: Current status + end + + loop Synchronous certificate pickup\n(PickupRetries × PickupDelay, ceiling 180 s) + Plugin->>API: GET /ssl-certificates/{orderId}\nGET /ssl-certificates/{orderId}/certificate + API-->>Plugin: Status · certificatePem · chainPem[] + end + + alt Certificate issued + Plugin->>Plugin: Assemble full chain\n(leaf + intermediates from chainPem[]) + Plugin-->>CMD: Certificate ready — PEM chain returned + else Still pending + Plugin-->>CMD: Pending — picked up by next sync + else Order rejected + Plugin-->>CMD: Enrollment failed — see gateway logs + end + + Plugin->>Plugin: Record enrollment outcome in audit log +``` + +#### No DCV required (OV/EV/Private PKI) + +```mermaid +sequenceDiagram + participant CMD as Keyfactor Command + participant Plugin as CERTInext Plugin + participant API as CERTInext API (V2) + + CMD->>Plugin: Request new certificate + Plugin->>Plugin: Record enrollment intent in audit log + + Plugin->>API: POST /oauth/token + API-->>Plugin: Bearer token + + Plugin->>API: POST /ssl-certificates\n(or /private-pki-certificates · /signature-certificates) + API-->>Plugin: 201 Created — orderId assigned\nstatus: pending-csr or pending-agreement + + loop Synchronous certificate pickup\n(PickupRetries × PickupDelay, ceiling 180 s) + Plugin->>API: GET /ssl-certificates/{orderId} + API-->>Plugin: Current status + end + + alt Certificate issued + Plugin->>API: GET /ssl-certificates/{orderId}/certificate + API-->>Plugin: certificatePem · chainPem[] + Plugin->>Plugin: Assemble full chain + Plugin-->>CMD: Certificate ready — PEM chain returned + else Still pending (OV/EV human review) + Plugin-->>CMD: Pending — picked up by next sync + else Order rejected + Plugin-->>CMD: Enrollment failed + end + + Plugin->>Plugin: Record enrollment outcome in audit log +``` + +**Token caching:** the Bearer token is cached for its 1-hour lifetime and shared across all V2 calls in the same gateway process. A new token is fetched automatically 60 seconds before expiry. + +**Idempotency:** V2 order-create and revoke calls carry a fresh `Idempotency-Key` UUID, but the plugin can't rely on it to prevent duplicates. The V2 spec describes the header as "Parsed today; enforced in a future release", and a new key is generated on every call, so a retry never reuses one. In a live sandbox check (2026-09-25), two order-create calls sent with the same key created two separate orders. The second call returned a generic HTTP 500 even though its order had been created. The plugin never retries an order-create call, and the AnyCA Gateway doesn't retry a timed-out `Enroll`. The only revoke retry is the one-time reason fallback described under [Revocation](#revocation), which is sent after CERTInext has already rejected the first call. If an operator resubmits after an order-create error, check the CERTInext portal for an order that was created anyway. + +**Full certificate chain:** the V2 `/certificate` endpoint returns the leaf certificate in `certificatePem` and any intermediate certificates in `chainPem[]`. The plugin concatenates these into a single PEM before returning to Command. + +**Order IDs:** the plugin stores the V2 `orderId` unchanged as the `CARequestID`. The V2 spec's examples show `ord_`-prefixed IDs, but orders placed through V2 on the sandbox so far have returned numeric order numbers in the same format as V1 (e.g. `6625262451`), and V1 order numbers resolve through V2 Track Order unchanged. + +**Synchronize uses V2 reports:** with `UseV2Api = true`, Synchronize pages through V2 `/reports/orders` and downloads the certificate body for each issued order. An incremental sync starts `V2SyncLookbackHours` (default 72) before the last sync time. + --- ## Revocation @@ -206,6 +339,29 @@ sequenceDiagram **Audit trail:** The revocation intent is written to the gateway log *before* the API call is made. This ensures that the intent is captured even if the API call subsequently fails, satisfying SOX audit requirements. +**Reason code fallback (V2 only):** the V2 spec documents 8 RFC 5280 reason values for the SSL/TLS +revoke endpoint. `aa-compromise` is listed only for the Document Signer and Private PKI endpoints, +which document 9. In live sandbox testing on SSL/TLS orders, independent of this plugin, only 5 values +succeeded: `key-compromise`, `affiliation-changed`, `superseded`, `cessation-of-operation`, and +`privilege-withdrawn`. Three documented values, `unspecified`, `ca-compromise`, and +`certificate-hold`, returned a 422 "Invalid Revoke Reason ID". So did the undocumented `aa-compromise`. +Revoke reasons for Private PKI and Document Signer orders haven't been tested live. + +Rather than surface any of these four rejections to the caller, the plugin retries each once with a +close accepted substitute: `unspecified` (CRL reason 0, Command's default when no explicit reason is +given — by far the most common revoke case) and `certificate-hold` (CRL reason 6) retry as +`cessation-of-operation`; `ca-compromise` (CRL reason 2) and `aa-compromise` (CRL reason 10) retry as +`key-compromise`. `cessation-of-operation` was chosen over `key-compromise` for the first pair +because neither `unspecified` nor `certificate-hold` implies an actual key compromise, and +`key-compromise` carries the spec's own BR 4.9.1.1 24-hour CRL-turnaround obligation, which would +misrepresent the revoke. Only these four specific rejections trigger a retry; any other revoke +failure is surfaced as-is. See `issues/0026` for the full reason-value test matrix and the open +question to CERTInext support about whether the documented reason enum is intentional. + +**Note field quirk:** CERTInext's revoke `note` (audit remarks) field rejects a semicolon (`;`) with a +separate 422, "Invalid Revoke Remarks." — confirmed live that comma, period, slash, and parentheses are +all accepted; only `;` triggers it. The retry note above avoids semicolons for this reason. + --- ## Connector Validation @@ -233,6 +389,8 @@ flowchart TD The table below maps each Keyfactor Command operation to the CERTInext API endpoint it calls. +**V1 endpoints (default)** + | Operation | CERTInext API endpoint | |---|---| | Test connection / verify credentials | `POST ValidateCredentials` | @@ -243,3 +401,20 @@ The table below maps each Keyfactor Command operation to the CERTInext API endpo | Synchronize inventory | `POST GetOrderReport` (paginated) | | List available product codes | `POST GetProductDetails` | | Attach CSR to draft order | `POST SubmitCSR` | + +**V2 endpoints (UseV2Api = true)** + +| Operation | V2 endpoint | +|---|---| +| Obtain Bearer token | `POST /oauth/token` | +| Test connection | `GET /api/certinext/v2/auth/me` | +| Issue / renew certificate | `POST /api/certinext/v2/{family}-certificates` | +| Check order status | `GET /api/certinext/v2/{family}-certificates/{orderId}` | +| Get DCV challenge (DV SSL) | `GET /api/certinext/v2/ssl-certificates/{orderId}/dcv` | +| Verify DCV | `POST /api/certinext/v2/ssl-certificates/{orderId}/dcv/verify` | +| Download certificate | `GET /api/certinext/v2/{family}-certificates/{orderId}/certificate` | +| Revoke certificate | `POST /api/certinext/v2/{family}-certificates/{orderId}/revoke` | +| List available products | `GET /api/certinext/v2/catalog/products` | +| Synchronize inventory | `GET /api/certinext/v2/reports/orders` (paginated) | + +`{family}` is `ssl-certificates`, `private-pki-certificates`, or `signature-certificates`. diff --git a/docsource/configuration.md b/docsource/configuration.md index eedb73b..6f04d47 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -7,8 +7,9 @@ The CERTInext AnyCA Gateway REST plugin extends the certificate lifecycle capabi * Expired certificates can optionally be excluded from synchronization using the `IgnoreExpired` configuration flag. * Certificate Enrollment for profiles configured in CERTInext: * New certificate enrollment (new keys and certificate). - * Certificate renewal via the CERTInext renew API when the prior certificate is within the configured renewal window. + * Certificate renewal — submits a new `GenerateOrderSSL` order when the prior certificate is within the configured renewal window (CERTInext has no dedicated renewal endpoint; the renewal-window check governs how Command tracks old→new, not which API is called). * Certificate reissuance (new keys with the same or updated subject/SANs) when outside the renewal window or no prior certificate is found. + * Synchronous certificate pickup — a fast-issuing order (DV, or already-approved) can return the certificate in the same enrollment call instead of always waiting for the next sync, via `PickupRetries`/`PickupDelay`. * Certificate Revocation: * Request revocation of a previously issued certificate using any RFC 5280 CRL reason code. * Supported authentication modes for calls to the CERTInext API: @@ -17,8 +18,8 @@ The CERTInext AnyCA Gateway REST plugin extends the certificate lifecycle capabi ## Requirements -* Keyfactor Command 10.x or later -* AnyCA Gateway REST framework version 24.2.0 or later +* Keyfactor Command 25.5.x or later +* AnyCA Gateway REST framework version 25.5.0 or later * A CERTInext account with API access enabled and at least one certificate product configured * Network connectivity from the AnyCA Gateway host to the CERTInext API endpoint for your region (see table below) * The AnyCA Gateway host must trust the TLS certificate presented by the CERTInext API endpoint @@ -91,28 +92,64 @@ Before enrolling certificates, the Keyfactor Command server must trust the CERTI ## CA Configuration -The following fields are presented in the Keyfactor Command Management Portal when creating or editing the CERTInext CA connector. All fields marked **Required** must be provided before the connector can be saved in an enabled state. +The following fields are presented in the Keyfactor Command Management Portal when creating or editing the CERTInext CA connector. + +> Note: the connector's own save-time validation only enforces `ApiUrl`, `AccountNumber`, and the credential fields for the selected `AuthMode`. Other fields marked **Required** below are required by CERTInext for a successful order — the connector will save without them, but enrollment will fail or the order will be parked pending until they're set. | Field | Required / Optional | Description | Where to find it | Example | |---|---|---|---|---| -| `ApiUrl` | Required | CERTInext API base URL for your environment. Must include the `/emSignHub-API/` path segment. No trailing slash is required but is accepted. | See the environments table above. | `https://api.certinext.io/emSignHub-API` | -| `AccountNumber` | Required | Your CERTInext account number (numeric string). Included in the `meta` block of every API request. | Portal → click your name or avatar → **Account Settings** or **My Profile**. | `4461259728` | +| `ApiUrl` | Required | CERTInext API base URL. In V1 mode (default), must include the `/emSignHub-API/` path segment. When `UseV2Api` is `true` (see [V2 API](#v2-api-preview) below), this is instead the bare V2 host with no trailing slash or path suffix — the two APIs are hosted differently, so this value changes when `UseV2Api` is toggled. Must use `https` — the OAuth client secret (V2) or API key (V1) is sent to this URL on every request, and `http` would transmit it in cleartext. `http` is rejected at connection-validation time except for a loopback host (`localhost`/`127.0.0.1`/`::1`), which is allowed for local test servers only. | See the environments table above. | `https://api.certinext.io/emSignHub-API/` | +| `AccountNumber` | Required | Your CERTInext account number (numeric string). Included in the `meta` block of every API request. | Portal → click your name or avatar → **Account Settings** or **My Profile**. | `1234567890` | | `AuthMode` | Required | Authentication mode. `AccessKey` uses HMAC signing (recommended). `OAuth` uses a bearer token. | N/A — choose based on the credential type you created. | `AccessKey` | | `ApiKey` | Conditional | The REST API Access Key generated in the CERTInext portal. Used to compute `authKey = SHA256(accessKey + ts + txn)`. The raw key is never transmitted. Required when `AuthMode` is `AccessKey`. This field is masked in the UI. | Portal → **Integrations → APIs** → generate or view the credential row. | *(generated, masked in UI)* | | `OAuthTokenUrl` | Conditional | OAuth token endpoint URL. Required when `AuthMode` is `OAuth`. | Provided by eMudhra for your account. | `https://auth.certinext.io/oauth/token` | -| `OAuthClientId` | Conditional | OAuth client ID. Required when `AuthMode` is `OAuth`. | Portal → **Integrations → APIs** → the OAuth credential row. | `keyfactor-gateway` | -| `OAuthClientSecret` | Conditional | OAuth client secret. Required when `AuthMode` is `OAuth`. This field is masked in the UI. | Generated at OAuth credential creation time. | *(generated, masked in UI)* | +| `OAuthClientId` | Conditional | OAuth client ID. Required when `AuthMode` is `OAuth` (V1). Also required — and reused as-is — when `UseV2Api` is `true`; V2 does not have its own separate client ID field. | Portal → **Integrations → APIs** → the OAuth credential row. | `keyfactor-gateway` | +| `OAuthClientSecret` | Conditional | OAuth client secret. Required when `AuthMode` is `OAuth` (V1). Also required — and reused as-is — when `UseV2Api` is `true`. This field is masked in the UI. | Generated at OAuth credential creation time. | *(generated, masked in UI)* | | `RequestorName` | Required | Default name of the person or service submitting certificate orders. Sent in the `requestorInformation` block of every order request. | Use the name of the team or automation account responsible for these certificates. | `PKI Automation` | | `RequestorEmail` | Required | Default email address for the requestor. Must be a valid email address associated with your CERTInext account. Sent in the `requestorInformation` block of every order request. | Use a monitored team inbox or the account holder's email. | `pki-admin@example.com` | | `RequestorIsdCode` | Optional | International dialing code for the requestor phone number (digits only, no `+` prefix). Default: `1` (United States). | N/A — use the country code for your requestor. | `1` | | `RequestorMobileNumber` | Optional | Requestor mobile number (digits only, no country code). Included in the `requestorInformation` block. | N/A | `5551234567` | -| `SignerPlace` | Required | City or location of the person accepting the subscriber agreement on behalf of your organization. Required by CERTInext for all orders. | Use the physical city where the signer is located. | `Austin` | +| `RequestorDesignation` | Optional | Job title / role of the requestor (e.g. `IT Administrator`). Sent in the `requestorInformation` block (V1) and the `requestor.designation` field (V2 mode). Free text with no CA-side enum. Left blank by default, in which case the field is omitted from the order entirely rather than sent with a default value. | N/A | `IT Administrator` | +| `SignerPlace` | Required | City or location of the person accepting the subscriber agreement on behalf of your organization. Required by CERTInext for all orders. When `UseV2Api` is `true` the connector can't be saved with this blank, because the V2 Subscriber Agreement sent with every SSL order requires it (a template's `SignerPlace` enrollment parameter still overrides it). | Use the physical city where the signer is located. | `Austin` | | `SignerIp` | Required | Public IP address of the host accepting the subscriber agreement. Required by CERTInext for all orders. | Use the outbound IP of the AnyCA Gateway host, or the IP of the workstation from which the agreement was accepted. | `203.0.113.10` | -| `GroupNumber` | Optional | CERTInext group (delegation) number. When set, it is passed in the `productDetails.groupNumber` field of `GetProductDetails` requests. Some sandbox accounts return an empty product list from `GetProductDetails` unless this field is included. Available in the CERTInext portal under **Delegation → Groups**. | Portal → **Delegation → Groups**. | `2171775848` | -| `DefaultProductCode` | Optional | Default numeric product code to use when no product code is set on the certificate template. If omitted and the template also has no product code, enrollment will fail. Product codes are provisioned per account by eMudhra — contact your eMudhra account representative to obtain the numeric codes available to your account. | Call `GetProductDetails` against your account/environment (see product code table below). | `842` | +| `GroupNumber` | Optional | CERTInext group (delegation) number. When set, it is passed in the `productDetails.groupNumber` field of `GetProductDetails` requests *and* in `delegationInformation.groupNumber` on every SSL order. Some sandbox accounts return an empty product list from `GetProductDetails` unless this field is included. Available in the CERTInext portal under **Delegation → Groups**. | Portal → **Delegation → Groups**. | `2345678901` | +| `OrganizationNumber` | Optional, strongly recommended for OV/EV and faster DV | Numeric CERTInext organization number for a pre-vetted organization. When set, every SSL order is submitted with `organizationDetails.preVetting="1"` and this number, telling CERTInext to skip its manual organization-vetting queue. Without it, orders may sit in `Pending System RA` for extended manual review (observed: tens of hours). | Portal → **Organizations → Pre-vetted Organizations**. | `1234567` | +| `TechnicalContactName` / `TechnicalContactEmail` / `TechnicalContactIsdCode` / `TechnicalContactMobileNumber` | Optional | Populate `technicalPointOfContact` on every SSL order. Each defaults to the corresponding `Requestor*` field when blank. Some product configurations require a technical point of contact to be present; omitting it can cause CERTInext to park orders awaiting manual completion of the field. | N/A | *(defaults to Requestor fields)* | +| `AccountingModel` | Optional | CERTInext billing model sent in `orderDetails.accountingModel`. `2` = credit-based (most accounts). `1` = cash model. Default: `2`. | N/A | `2` | +| `EmailNotifications` | Optional | Whether CERTInext sends lifecycle-event emails to the requestor. `1` = full notification set (V1 sends it as-is; V2 maps it to `all`). `0` = silent on both V1 and V2 (V2 confirmed live 2026-09-28). Blank/unset stays silent on V1 (sent as `0`) but is omitted on V2, so the CA's own default (`all`, not silent) applies instead. Any other value fails V2 enrollment before any CA call. Default: `0` — V2 orders are now silent by default, matching V1 (previously V2 always sent `all`). | N/A | `0` | +| `SubscriptionValidityYears` | Optional | Connector-level default validity in years for SSL orders (`1`, `2`, or `3`). Overridden per template by the `ValidityYears` enrollment parameter. Default: `1`. | N/A | `1` | +| `SubscriptionAutoRenew` | Optional | Whether CERTInext should auto-renew certificates issued through this connector. `0` = disabled (recommended — renewal is driven by Keyfactor Command), `1` = enabled. Default: `0`. | N/A | `0` | +| `SubscriptionRenewCriteriaDays` | Optional | Days before expiry at which CERTInext auto-renews. Only honored when `SubscriptionAutoRenew` is `1`. Default: `30`. | N/A | `30` | +| `AutoSecureWww` | Optional | If `1`, CERTInext automatically adds the `www.` variant of the primary domain as an additional SAN. Default: `0`. | N/A | `0` | +| `SubmitNonDnsSans` | Optional | If `true` (default), SANs that aren't DNS names (IP address, email, URI) are submitted to CERTInext instead of silently dropped. CERTInext can't validate them, so such an order won't issue until they're removed. Set to `false` to restore the pre-1.0.1 behavior of submitting DNS names only. Default: `true`. | N/A | `true` | +| `DefaultProductCode` | Optional, but effectively required if you use renewals (V1), or ProductId-only templates against an ambiguous V2 catalog | **V1 mode:** used for renewals only — CERTInext's `TrackOrder` doesn't return the prior order's product code, so the renewal path sends this value verbatim, ignoring the template's `ProductCode`/`ProfileId`. If left blank, renewals go out with an empty product code. Has no effect on new V1 enrollments. **V2 mode:** also used to disambiguate a template that sets only `ProductId` (no explicit `ProductCode`) when the live V2 catalog has more than one product sharing the product's expected assurance level — if this value doesn't match one of the candidate codes, that enrollment (and template save-time validation) fails with an error listing them. See [issue tracking the V1 renewal behavior](https://github.com/Keyfactor/certinext-caplugin/issues/26). | Call `GetProductDetails` against your account/environment (see product code table below). | `842` | | `IgnoreExpired` | Optional | If `true`, expired certificates are skipped during synchronization and are not imported into Keyfactor Command. Default: `false`. | N/A | `false` | | `PageSize` | Optional | Number of orders to retrieve per page during synchronization. Default: `100`. Maximum: `500`. Reduce this value if synchronization requests time out. | N/A | `100` | | `Enabled` | Optional | Enables or disables the CA connector. Setting this to `false` allows the connector record to be created before all credentials are available, without triggering a live connectivity test. Default: `true`. | N/A | `true` | +| `LogSensitiveRequestData` | Optional | **Diagnostic escape hatch — off by default.** When `true`, this writes requestor personal data (name, email, phone, and other organization contact details) and full CA request/response payloads to the gateway logs. It's meant for temporary use while verifying a new deployment (confirming exactly what was sent to the CA and that the order succeeded) — turn it back off once verification is complete. When `false` (default), personal-data fields are redacted (email is masked but keeps its domain, e.g. `j***@example.com`) and the enrollment log line omits the requester name entirely. Email SAN values (rfc822Name) in log lines are masked the same way; DNS, IP and URI SANs are always logged in full. Credentials (API keys, OAuth secrets, tokens) are always redacted regardless of this setting. Default: `false`. | N/A | `false` | +| `PickupRetries` | Optional | Number of times `Enroll` polls CERTInext for the certificate after a successful order submission, before returning pending and leaving pickup to the next sync. Set to `0` to disable the wait entirely. OV/EV orders validate asynchronously (minutes to hours) and typically exhaust this wait regardless of the value. Default: `5`. | N/A | `5` | +| `PickupDelay` | Optional | Seconds between certificate-pickup retries. The total pickup budget is a fixed 5-second initial delay + (`PickupRetries` × `PickupDelay`), hard-capped at 180 seconds regardless of how the two values are set. Aim for well under ~90s total so the call doesn't run long enough to trip Command's own enrollment timeout. Default: `10` (a ~55s ceiling with default `PickupRetries`). | N/A | `10` | + +> **Pickup timing detail:** after a successful order placement, the plugin waits a fixed 5-second initial delay before the first poll attempt, then polls CERTInext every `PickupDelay` seconds up to `PickupRetries` times. Each poll calls `GetCertificate` to check whether the certificate has been issued. The total time budget is: **5s + (PickupRetries × PickupDelay) + API round-trip time per poll (~1s each)**. With defaults this is approximately 5 + (5 × 10) + 5 = **~60 seconds**. +> +> **Tuning for faster pickup:** if the CERTInext API typically issues certificates within a few seconds of order placement (as observed with DV and auto-approved orders), you can reduce per-enrollment wait time by lowering `PickupDelay` and raising `PickupRetries` to compensate — this polls more frequently without changing the total budget. For example: +> +> | Configuration | PickupRetries | PickupDelay | Total budget | Poll cadence | +> |---------------|:---:|:---:|---|---| +> | Default | `5` | `10` | ~55s | Every 10s | +> | Faster polling | `10` | `5` | ~55s | Every 5s | +> | Aggressive | `50` | `1` | ~55s | Every 1s | +> | Minimal wait | `0` | — | 0s | No polling; defers to sync | +> +> The 5-second initial delay before the first poll is not configurable. The 180-second hard ceiling applies regardless of configuration. +| `DcvEnabled` | Optional | When `true`, the gateway performs DNS-based Domain Control Validation (DCV) during enrollment for orders that require it. Requires a DNS provider plugin (e.g. `azure-azuredns-dnsplugin`) to be deployed on the gateway. Default: `false`. | N/A | `false` | +| `DcvTxtRecordTemplate` | Optional | Format string for the DNS TXT record hostname published during DCV. `{0}` is replaced with the domain being validated. Default: `_emsign-validation.{0}`. | N/A | `_emsign-validation.{0}` | +| `DcvPropagationDelaySeconds` | Optional | Seconds to wait after publishing the DNS TXT record before asking CERTInext to verify it. Increase for zones with slow propagation. Applies only to the `Enroll()`-time DCV path — DCV driven during sync uses its own fixed 3-second delay. Default: `30`. | N/A | `30` | +| `DcvTimeoutMinutes` | Optional | Maximum minutes to wait for the entire DCV flow (DNS publish + propagation + verify) before cancelling the enrollment. Can also be set via the `CERTINEXT_DCV_TIMEOUT_MINUTES` environment variable; the environment variable takes precedence when both are set. Default: `10`. | N/A | `10` | +| `DcvWaitForChallengeSeconds` | Optional | How long `Enroll()` waits for CERTInext to expose the DCV challenge after order placement, before giving up and deferring to the next sync. Set to `0` to disable the wait. Can also be set via `CERTINEXT_DCV_WAIT_FOR_CHALLENGE_SECONDS`. Default: `60`. | N/A | `60` | +| `DcvWaitForIssuanceSeconds` | Optional | How long `Enroll()` waits for CERTInext to finish generating the certificate after DCV verifies. Set to `0` to disable the wait. Can also be set via `CERTINEXT_DCV_WAIT_FOR_ISSUANCE_SECONDS`. Default: `60`. | N/A | `60` | +| `DcvSyncMaxOrderAgeHours` | Optional | During synchronization, only pending DV orders younger than this many hours are driven through DCV, so a large backlog of old/abandoned pending orders doesn't slow down every sync pass. Set to `0` to disable the age filter. Default: `24`. | N/A | `24` | +| `DcvSyncMaxPerPass` | Optional | Maximum number of pending DV orders driven through DCV in a single sync pass. Set to `0` to disable the cap. Default: `50`. | N/A | `50` | > Note: `AccountNumber` and group-level identifiers are distinct values. The `AccountNumber` is your top-level user account identifier. CERTInext groups (cost centers or departments) each have their own `groupNumber`, which is passed per-order and is separate from any organization number displayed on the Organizations page. @@ -126,15 +163,15 @@ In the Keyfactor Command Management Portal, navigate to **Certificate Templates* | Parameter | Required / Optional | Type | Description | Example / Default | |---|---|---|---|---| -| `ProductCode` | Optional | String | Override the numeric CERTInext product code for this template. Product codes are provisioned per account by eMudhra — obtain the correct code from `GetProductDetails` for your account. Set this explicitly when targeting the sandbox environment or when the connector `DefaultProductCode` should not apply to this template. | `842` (sandbox DV SSL, account-specific) | +| `ProductCode` | Optional | String | Override the numeric CERTInext product code for this template. Product codes are provisioned per account by eMudhra — obtain the correct code from `GetProductDetails` for your account. If omitted, the built-in default code for the selected product name is used (see [Product Codes](#product-codes)). Set this explicitly when targeting the sandbox environment or a non-standard code. | DV SSL: `842` (sandbox) or `838` (production) | | `ProfileId` | Deprecated | String | Legacy alias for `ProductCode`. Accepted for backward compatibility — if `ProductCode` is not set, `ProfileId` is used in its place. New templates should use `ProductCode`. | `838` | | `ValidityYears` | Optional | Number | Subscription validity period in years: `1`, `2`, or `3`. Default: `1`. CERTInext certificates are issued within a subscription term at up to 390 days per certificate, with free renewals within the term. | `1` | | `ValidityDays` | Deprecated | Number | Legacy validity field. If set, the value is divided by 365 and rounded up to derive a year count. New templates should use `ValidityYears`. | `365` | -| `AutoApprove` | Optional | Boolean | If `true`, the gateway will attempt automatic approval of certificates returned in a pending-approval state. Only set this if your CERTInext product is configured with automatic approval. Default: `false`. | `false` | +| `AutoApprove` | Optional | Boolean | **Currently has no effect** — reserved for future use. The plugin does not call any approval endpoint against CERTInext regardless of this setting. See [issue tracking this](https://github.com/Keyfactor/certinext-caplugin/issues/25). | `false` | | `RequesterName` | Optional | String | Per-template override for the requestor name. When set, overrides the connector-level `RequestorName` for orders using this template. | `Keyfactor Automation` | | `RequesterEmail` | Optional | String | Per-template override for the requestor email address. When set, overrides the connector-level `RequestorEmail` for orders using this template. | `pki-admin@example.com` | | `RenewalWindowDays` | Optional | Number | Number of days before certificate expiration within which a renewal is attempted instead of a reissue. Default: `90`. | `90` | -| `KeyType` | Optional | String | Key algorithm to request at enrollment time. Valid values depend on what the target product supports. If omitted, the product default is used. | `RSA2048`, `RSA4096`, `EC256`, `EC384` | +| `KeyType` | Optional | String | Key algorithm to request at enrollment time. The key type is carried by the submitted CSR. CERTInext accepts **RSA 2048 / 3072 / 4096 and ECC P-256 / P-384** only — larger RSA, ECC P-521, and the Ed25519/Ed448 curves are rejected by the CA (`Invalid key size`). If omitted, the product default is used. | `RSA2048`, `RSA3072`, `RSA4096`, `EC256`, `EC384` | | `DomainName` | Optional | String | Primary domain name for SSL/TLS orders. If omitted, the gateway derives the domain from the CSR `CN` field. | `example.com` | | `SignerName` | Optional | String | Per-template override for the subscriber agreement signer name. When omitted, defaults to the connector-level `RequestorName`. | `Jane Smith` | | `SignerPlace` | Optional | String | Per-template override for the subscriber agreement signer location. When omitted, defaults to the connector-level `SignerPlace`. | `Austin` | @@ -154,53 +191,64 @@ To retrieve the exact codes available to your account, call the `GetProductDetai ### SSL/TLS -The product codes in this table were observed on the US sandbox account (`accountNumber=9374221333`) in April 2026. Your account will likely have different codes. Always call `GetProductDetails` to confirm the codes provisioned for your account. +The product codes in this table were observed on: +- the US sandbox environment (`sandbox-us-api.certinext.io`) in April–May 2026 +- the Production India environment (`api.certinext.io`) via the live draft-order coverage matrix in [development.md](development.md) -| Product | Sandbox Code (account 9374221333, April 2026) | Required fields beyond base (`domainName`, `csr`, `requestorInformation`, `subscriptionDetails`, `agreementDetails`) | -|---|---|---| -| DV (Domain Validated) | `842` | None. `domainName` is derived from the CSR CN if omitted on the template. | -| DV Wildcard | `843` | CSR CN must use wildcard format (e.g. `*.example.com`). `domainName` in the order must also use the wildcard format. | -| DV UCC (Multi-domain) | `844` | `certificateInformation.additionalDomains` — array of additional SAN values beyond the primary `domainName`. | -| DV Wildcard UCC (Multi-domain Wildcard) | `845` | Combines wildcard and multi-domain requirements. CSR CN and `domainName` must use wildcard format; `certificateInformation.additionalDomains` required. | -| OV (Organization Validated) | `846` | `organizationDetails.organizationNumber` (your CERTInext org ID); `certificateInformation.locality`, `postalCode`, and full organization address fields (`streetAddress`, `city`, `state`, `country`). | -| OV Wildcard | `847` | Same as OV (846). CSR CN and `domainName` must use wildcard format. | -| OV UCC (Multi-domain) | `848` | Same as OV (846) plus `certificateInformation.additionalDomains`. | -| OV Wildcard UCC (Multi-domain Wildcard) | `849` | Combines OV, wildcard, and multi-domain requirements. Same as OV (846) plus wildcard CN/domainName and `certificateInformation.additionalDomains`. | -| EV (Extended Validation) | `850` | All OV fields plus: `contractSignerInfo` object (`name`, `email`, `isdCode`, `mobileNumber`, `designation`, `employeeID`); `certificateApproverInfo` object (same fields); `certificateInformation.companyRegistrationNumber`; `streetAddress2` must be non-empty. | -| EV UCC (Multi-domain EV) | `851` | Same as EV (850) plus `certificateInformation.additionalDomains`. | +**Your account may still have different codes.** Always call `GetProductDetails` against your target environment before going live. + +| Product | Sandbox Code | Production Code | Required fields beyond base (`domainName`, `csr`, `requestorInformation`, `subscriptionDetails`, `agreementDetails`) | +|---|---|---|---| +| DV (Domain Validated) | `842` | `838` | None. `domainName` is derived from the CSR CN if omitted on the template. | +| DV Wildcard | `843` | `839` | CSR CN must use wildcard format (e.g. `*.example.com`). `domainName` in the order must also use the wildcard format. | +| DV UCC (Multi-domain) | `844` | `840` | `certificateInformation.additionalDomains` — array of additional SAN values beyond the primary `domainName`. | +| DV Wildcard UCC (Multi-domain Wildcard) | `845` | `841` | Combines wildcard and multi-domain requirements. CSR CN and `domainName` must use wildcard format; `certificateInformation.additionalDomains` required. | +| OV (Organization Validated) | `846` | `842` | `organizationDetails.organizationNumber` (your CERTInext org ID); `certificateInformation.locality`, `postalCode`, and full organization address fields (`streetAddress`, `city`, `state`, `country`). | +| OV Wildcard | `847` | `843` | Same as OV. CSR CN and `domainName` must use wildcard format. | +| OV UCC (Multi-domain) | `848` | `844` | Same as OV plus `certificateInformation.additionalDomains`. | +| OV Wildcard UCC (Multi-domain Wildcard) | `849` | `845` | Combines OV, wildcard, and multi-domain requirements. Same as OV plus wildcard CN/domainName and `certificateInformation.additionalDomains`. | +| EV (Extended Validation) | `850` | `846` | All OV fields plus: `contractSignerInfo` object (`name`, `email`, `isdCode`, `mobileNumber`, `designation`, `employeeID`); `certificateApproverInfo` object (same fields); `certificateInformation.companyRegistrationNumber`; `streetAddress2` must be non-empty. | +| EV UCC (Multi-domain EV) | `851` | `847` | Same as EV plus `certificateInformation.additionalDomains`. | + +> Note: SSL/TLS codes appear to be offset by 4 between the US sandbox and Production India in the snapshots we've observed — but treat that as a coincidence, not a guarantee. eMudhra controls the per-account mapping and may use different numeric codes for any new account. Always confirm via `GetProductDetails`. > Note: The CERTInext portal may display additional short-validity products (e.g. **DV SSL Certificate 1 Month**, **DV SSL Certificate Wildcard 1 Month**) that do not appear in the `GetProductDetails` API response and have no published product code. These products are not accessible via the API and are therefore **not supported by this plugin**. Contact eMudhra to determine whether API ordering is available for these products on your account. ### Private PKI -| Product | Example Code | Availability | -|---|---|---| -| Sandbox emSign Intranet SSL 1 year | `149` (sandbox account 9374221333, April 2026) | Requires special provisioning by eMudhra. Not available on standard production accounts. | -| emSign Intranet SSL 1 year (production) | `100` | Requires special provisioning by eMudhra. Not orderable on standard accounts. | -| IGTF Host 1 year | `104` | Requires special provisioning by eMudhra. Not orderable on standard accounts. | +| Product | Sandbox Code | Production Code | Availability | +|---|---|---|---| +| emSign Intranet SSL 1 year | `149` | `100` | Requires special provisioning by eMudhra. Not orderable on standard accounts. | +| IGTF Host 1 year | (not observed) | `104` | Requires special provisioning by eMudhra. Not orderable on standard accounts. | -> Note: Private PKI products are not available for ordering on standard CERTInext accounts. Attempting to place an order will return EMS-1162 (product not provisioned). The sandbox Private PKI code (`149` on account 9374221333) also returns EMS-1162 because the product is not provisioned even though it appears in the `GetProductDetails` list. Contact eMudhra to have these products enabled on your account. +> Note: Private PKI products need a separate entitlement. On an account without it, placing an order returns EMS-1162 (product not provisioned). Contact eMudhra to have these products enabled. An earlier V1 note recorded the sandbox code `149` returning EMS-1162. More recently, a read-only V2 catalog check on the plugin's sandbox account (2026-09-25) listed `149` ("Sandbox emSign Intranet SSL 1 Year", `productTypeID` `39`) as active. No order has been placed against it, so it's unconfirmed whether a V2 order for it is accepted. Check your own account's catalog rather than relying on either observation. ### S/MIME and Document Signing -| Product | Product Code | Availability | +The same numeric product codes have been observed for S/MIME and document-signing products on both the US sandbox and Production India in the snapshots we have. **Treat that as an empirical observation, not a contract** — eMudhra is free to assign different codes per account. Always confirm via `GetProductDetails`. + +| Product | Sandbox / Production Code | Availability | |---|---|---| | S/MIME | `894` | Requires a separate S/MIME entitlement on the account. Not available on standard SSL accounts. | -| Natural Person Doc Signer (tier 1) | `825` | Requires document signing entitlement. Not orderable on standard accounts. | -| Natural Person Doc Signer (tier 2) | `826` | Requires document signing entitlement. Not orderable on standard accounts. | -| Natural Person Doc Signer (tier 3) | `827` | Requires document signing entitlement. Not orderable on standard accounts. | -| Legal Person Doc Signer (tier 1) | `822` | Requires document signing entitlement. Not orderable on standard accounts. | -| Legal Person Doc Signer (tier 2) | `823` | Requires document signing entitlement. Not orderable on standard accounts. | -| Legal Person Doc Signer (tier 3) | `824` | Requires document signing entitlement. Not orderable on standard accounts. | -| Legal Entity Doc Signer (tier 1) | `819` | Requires document signing entitlement. Not orderable on standard accounts. | -| Legal Entity Doc Signer (tier 2) | `820` | Requires document signing entitlement. Not orderable on standard accounts. | -| Legal Entity Doc Signer (tier 3) | `821` | Requires document signing entitlement. Not orderable on standard accounts. | +| Document Signer | `819`–`827` | Requires document signing entitlement. Not orderable on standard accounts. See the code-to-product table below. | + +The two CERTInext references disagree on which Document Signer code is which product. The V2 API +spec's Product Codes table lists `819`–`821` as Natural Person and `825`–`827` as Legal Entity. The +earlier V1 Postman collection this table was first built from listed them the other way round. Both +list `822`–`824` as Legal Person. Neither mapping has been checked against a live catalog, so confirm +the product name for each code in your account's catalog before you use one. + +| Code | V2 API spec | Earlier V1 Postman collection | +|---|---|---| +| `819` / `820` / `821` | Natural Person, 1 / 2 / 3 year | Legal Entity, 1 / 2 / 3 year | +| `822` / `823` / `824` | Legal Person, 1 / 2 / 3 year | Legal Person, 1 / 2 / 3 year | +| `825` / `826` / `827` | Legal Entity, 1 / 2 / 3 year | Natural Person, 1 / 2 / 3 year | > Note: S/MIME (894) and document signing products (819–827) require a separate entitlement that is not included in a standard SSL/TLS account. Contact eMudhra to request access. To retrieve the full list of product codes available to your account, call the `GetProductDetails` endpoint against your target environment. The sandbox and production APIs each return their own set of codes. -> Note: SSL/TLS products (codes 838–846) are supported on standard accounts. Private PKI (100, 104), S/MIME (894), and document-signing products (819–827) require special provisioning by eMudhra and are not available on standard SSL/TLS accounts — ordering them returns EMS-1162. +> Note: SSL/TLS products are supported on standard accounts — see the SSL/TLS table above for the exact sandbox/production code pair for each product. Private PKI (Production `100`, `104` / Sandbox `149`), S/MIME (`894`), and document-signing products (`819`–`827`) require special provisioning by eMudhra and are not available on standard SSL/TLS accounts — ordering them returns EMS-1162. ## Mechanics @@ -215,13 +263,22 @@ authKey = SHA256(accessKey + requestTs + requestTxnId) Where `requestTs` is the ISO 8601 timestamp and `requestTxnId` is a unique transaction UUID generated per request. The raw access key is never transmitted — only the derived hash is sent. This computation happens automatically on every outbound call. When `AuthMode` is `OAuth`, the gateway obtains a bearer token via the configured client credentials flow and injects it into the `meta` block instead. +### HTTP Timeout + +Every CERTInext API call (enroll, sync, revoke) shares one HTTP client with a fixed 120-second +request timeout. This is hardcoded and is not exposed as a connector setting or environment +variable — it cannot be changed without modifying the plugin. If a call doesn't return within 120 +seconds, the plugin aborts it and the operation fails; a non-idempotent call (e.g. order placement) +is not retried afterward, since CERTInext may have already created the order — see +[Synchronization](#synchronization) to reconcile such orders on a later pass. + ### Enrollment Decision Logic When the gateway calls `Enroll`, the plugin selects between three paths based on the enrollment type and the age of the prior certificate: 1. **New enrollment** — no prior certificate exists. A new `GenerateOrderSSL` request is submitted. -2. **Renewal** — a prior certificate exists and its expiry is within the `RenewalWindowDays` threshold (default: 90 days). The plugin calls the CERTInext renew API, which reuses the existing subscription term. -3. **Reissue** — a prior certificate exists but is outside the renewal window. A new order is placed with the updated CSR/subject, replacing the prior certificate under a new subscription. +2. **Renewal** — a prior certificate exists and its expiry is within the `RenewalWindowDays` threshold (default: 90 days). A new `GenerateOrderSSL` order is submitted within the configured renewal window (CERTInext has no dedicated renewal endpoint; the renewal-window check governs how Command tracks old→new, not which API is called). +3. **Reissue** — a prior certificate exists but is outside the renewal window. A new `GenerateOrderSSL` order is placed with the updated CSR/subject, replacing the prior certificate under a new subscription. The `RenewalWindowDays` template parameter controls the renewal/reissue boundary per certificate template. @@ -233,9 +290,10 @@ The `GenerateOrderSSL` API requires an `additionalInformation.remarks` field in CERTInext orders pass through several internal status stages before a certificate is issued. The plugin maps these to Keyfactor enrollment statuses as follows: -- **Issued** (status 9, 20) → certificate returned immediately. -- **Pending approval** (status 2, 8, 15, 24) → enrollment returns a pending status to Command. If `AutoApprove` is enabled on the template, the plugin attempts automatic approval before returning. -- **Rejected / cancelled** (status 4, 5, 13, 14) → enrollment fails with an error. +- **Issued** (status `7`, `9`, `12`, `15`, `20`, `23`) → certificate returned immediately (status `12`, expired, is retained in inventory as issued rather than treated as a failure). +- **Pending approval** (status `1`, `2`, `4`, `6`, `16`, `17`, `24`) → enrollment returns a pending status to Command. `Enroll()` polls briefly for the certificate (see `PickupRetries`/`PickupDelay`) before falling back to pending. +- **Revoked** (status `22`) → certificate marked revoked. +- **Rejected / cancelled** (status `3`, `5`, `8`, `13`, `14`, `18`, `19`, `21`, or any unrecognized code) → enrollment fails with an error. The gateway polls the `TrackOrder` endpoint during sync to pick up certificates that were approved after the initial enrollment call. @@ -255,4 +313,94 @@ When an enrollment request arrives, the numeric CERTInext product code is resolv If none of these yield a code, enrollment fails with a validation error. -{% include 'architecture.md' %} +## V2 API (Preview) + +The plugin includes an opt-in CERTInext V2 REST API code path that uses modern OAuth2 `client_credentials` authentication and a new order-centric resource model. V2 is disabled by default; V1 remains the active path unless `UseV2Api` is explicitly set to `true`. When enabled, V2 is fully self-contained: Enroll, GetSingleRecord, Revoke, and Synchronize all route through the V2 API, and V1 credentials (`ApiKey`, `AccountNumber`, `AuthMode`) are not required. + +### V2 CA Connector Fields + +V2 mode reuses the connector's `ApiUrl`, `OAuthClientId`, and `OAuthClientSecret` fields (documented above) rather than separate V2-only credentials — `ApiUrl` becomes the V2 host and `OAuthClientId`/`OAuthClientSecret` authenticate against it, regardless of `AuthMode`. Only the fields below are specific to V2 mode: + +| Field | Required / Optional | Description | Example | +|---|---|---|---| +| `UseV2Api` | Optional | Enable the V2 API code path for enrollment, revocation, status checks, and synchronization. Default: `false`. | `false` | +| `V2SyncLookbackHours` | Optional | V2 mode only. During an incremental Synchronize, the plugin queries `from` = (last sync time minus this many hours) rather than the exact last-sync time, since it's not confirmed whether the API's `from`/`to` filter brackets order-placement date or issuance date — a lookback window keeps an order created before last sync but issued afterward (e.g. a slow DCV order) from being missed. Default: `72`. | `72` | + +#### V2 OAuth2 Setup + +1. Log in to the CERTInext portal for your environment. +2. Navigate to **Integrations → APIs**. +3. Click **+ Create API Credentials**, set **API Type** to `REST`, and select the **OAuth** auth type (not `Access Key`). The V2 spec requires the key to be generated in OAuth mode. A key that wasn't gets HTTP 403 `unauthorized_client` at token time. +4. Note the client ID and client secret. Enter them in `OAuthClientId` and `OAuthClientSecret`. The V2 spec's token example uses the account number as `client_id`, but the plugin never substitutes `AccountNumber` for it, so set `OAuthClientId` explicitly. See [Step 1 of the migration guide](#step-1--create-a-v2-oauth2-credential) for what hasn't been verified about reusing V1 OAuth keys. +5. Set `UseV2Api` to `true` and set `ApiUrl` to the V2 base URL (no trailing path suffix), e.g. `https://sandbox-us-api.certinext.io`. +6. V1-only fields (`ApiKey`, `AccountNumber`, `AuthMode`) are not required in this mode and can be left blank. + +#### V2 Token Caching + +The plugin obtains a V2 bearer token via the standard OAuth2 `client_credentials` grant (`grant_type=client_credentials`, form-encoded) against `{ApiUrl}/oauth/token`. Tokens are cached in memory and reused until 60 seconds before expiry (minimum 30-second cache). Token refresh is thread-safe. + +### V2 Certificate Template Fields + +When `UseV2Api` is `true`, two additional enrollment parameters become relevant: + +| Parameter | Required / Optional | Type | Description | Example / Default | +|---|---|---|---|---| +| `ProductFamily` | Optional | String | CERTInext V2 product family. Supported for enrollment: `ssl` (SSL/TLS) and `private-pki` (Private PKI — see [V2 Private PKI Orders](#v2-private-pki-orders)). `signature` (Document Signer) is accepted by the parameter, but Document Signer enrollment is not yet supported: a `signature` enrollment fails before any order is placed. Default: `ssl`. | `ssl` | +| `ProductVariant` | Optional | String | Product variant within the family. `ssl`: `dv`, `ov`, or `ev`. If omitted, the plugin derives it from the selected product (e.g. an OV product sends `ov`, an EV product sends `ev`) rather than always defaulting to `dv`; an explicit override that contradicts the product's derived variant fails enrollment with an actionable error instead of being sent as-is. `private-pki`: `intranet-ssl` or `igtf-host` — required, with no default. | `dv` | + +`ProductCode` continues to carry the numeric product code and is sent in the `X-Product-Code` header on V2 order placement. + +### V2 Product Code Resolution + +When `UseV2Api` is `true`, the numeric product code sent to CERTInext is resolved as follows: + +1. **Explicit `ProductCode` (or the deprecated `ProfileId` alias) on the template** — sent as-is in the `X-Product-Code` header, after template save-time validation confirms it exists in the live V2 catalog. +2. **No explicit code set** — the plugin maps the template's selected product to the catalog's expected `productTypeID` and looks for catalog entries sharing it: + - **Exactly one match** — used automatically. + - **No match** — enrollment (and template save-time validation) fails; the account may not be entitled to the product. + - **More than one match** — the live catalog can carry several entries at the same assurance level (e.g. two DV SSL entries with different billing terms). The connector's `DefaultProductCode` must name one of them, or enrollment fails with an error listing every candidate code and name. Set `ProductCode` explicitly on the template, or set `DefaultProductCode` on the connector, to disambiguate. + +This differs from V1, where `DefaultProductCode` only affects renewals (see the [`DefaultProductCode` field](#ca-configuration) above) — in V2 mode it also disambiguates new enrollments and template validation for a `ProductId`-only template. + +### V2 Private PKI Orders + +With `ProductFamily=private-pki`, the plugin places the order against CERTInext's Private PKI endpoint using the Private PKI request body, which differs from the SSL/TLS one: + +- **Product code is required.** Set `ProductCode` explicitly to your account's Private PKI catalog code. Private PKI codes vary per customer catalog, so the plugin can't look one up from the product selected on the template. Template validation checks that the code exists in the V2 catalog and is a Private PKI product (catalog `productTypeID` `39`). +- **Variant is required.** Set `ProductVariant` to `intranet-ssl` or `igtf-host`. +- **Hostname.** The order's primary `hostname` comes from `DomainName`, or from the CSR's CN when `DomainName` isn't set. +- **SANs, including IP addresses.** Additional SANs are sent in the order's `additionalHosts` field, which accepts DNS names and IPv4/IPv6 addresses. SANs come from the gateway's SAN list; the plugin falls back to the SANs in the CSR only when the gateway supplies none. Email and URI SANs can't be expressed in `additionalHosts`, so they're left off the order and a warning is written to the gateway log. `SubmitNonDnsSans` isn't consulted for Private PKI orders. +- **No DCV, organization, or subscriber agreement.** Private PKI orders have none of these steps, so DCV is never attempted for them, and `OrganizationNumber`, `AutoSecureWww`, `SignerName`, `SignerPlace`, and `SignerIp` aren't used. +- **Shared fields.** The requestor, technical contact, subscription, email-notification, and group settings are sent exactly as they are for SSL/TLS orders. + +### V2 Order Lifecycle + +V2 orders are identified by the `orderId` the V2 order placement endpoint returns, which the plugin stores unchanged as the `CARequestID` and uses for all later tracking, certificate download, and revocation calls. The V2 spec's examples show `ord_`-prefixed IDs, but orders placed through V2 on the sandbox so far have returned numeric order numbers in the same format as V1 (e.g. `6625262451`). Treat the ID as an opaque string. + +V2 status strings map to Keyfactor enrollment statuses as follows: + +| V2 Status | Keyfactor Status | Notes | +|---|---|---| +| `issued` | Issued | Certificate is immediately downloaded and returned to Command. | +| `pending-dcv` | Pending External Validation | Order is awaiting domain control validation. | +| `pending-csr` | Pending External Validation | Order is awaiting CSR submission or processing. | +| `pending-agreement` | Pending External Validation | Order requires subscriber agreement acceptance. | +| `pending-organization-verification` | Pending External Validation | OV/EV order is awaiting organization verification. | +| `pending-documents` | Pending External Validation | Order is awaiting supporting document submission. | +| `pending-approval` | Pending External Validation | Order is awaiting final CA/LRA approval before issuance. | +| `revoked` | Revoked | Order has been revoked. | +| `cancelled` | Failed | Order was cancelled; a new enrollment is required. | +| `rejected` | Failed | Order was rejected by the CA/LRA; a new enrollment is required. | +| `expired` | Issued | An expired-but-not-revoked order is reported as issued (GENERATED), matching V1's convention — it remains visible in Command's inventory rather than disappearing as a failure. | + +Any V2 status not in this table (e.g. a value CERTInext adds in the future) also maps to Failed, but the +plugin logs a warning distinguishing "unmapped status" from the statuses above that are deliberately +mapped to Failed — see the gateway trace log if certificates unexpectedly show as failed. + +V2 has no *renew* endpoint. CERTInext does document a `/reissue` endpoint (`mode: rekey|update-sans`, with optional `revokePrevious`/`revokeReason`), but the plugin does not use it by design — all three enrollment types (New, Reissue, RenewOrReissue) place a fresh V2 order, and the prior order/certificate is left issued rather than auto-revoked. + +### V2 Revocation Reason Handling + +CERTInext's V2 revoke endpoint accepts only a subset of its own documented reason enum. When Command's revoke reason maps to one CERTInext rejects, the plugin substitutes an accepted reason and retries once, rather than failing the revoke outright: CA-compromise and AA-compromise are retried as key-compromise; unspecified (Command's default when no reason is given) and certificate-hold are retried as cessation-of-operation. See [Revocation Reason Codes](#revocation-reason-codes) in the migration guide below for the full accepted/rejected matrix. + +{% include 'migration-v1-to-v2.md' %} diff --git a/docsource/development.md b/docsource/development.md index a01f6de..2f7ab02 100644 --- a/docsource/development.md +++ b/docsource/development.md @@ -40,6 +40,24 @@ CERTINEXT_SIGNER_IP= | Coverage report (browser) | `make coverage-report` | Same as `coverage`, then opens HTML report in the default browser | | Clean | `make clean` | `dotnet clean` and wipe coverage output directories | +### Build variants — `DcvSupport` (DCV vs no-DCV) + +The plugin builds against two `Keyfactor.AnyGateway.IAnyCAPlugin` contracts from a single +codebase, selected by the `DcvSupport` MSBuild property. The plugin's `AnyCAPluginCertificate` +records must match the gateway host's IAnyCAPlugin version to persist, so the build must target +the host (see issue 0003). + +| Build | Command | IAnyCAPlugin | DCV | Target gateway host | +|---|---|---|---|---| +| **No-DCV (default)** | `make build` / `dotnet build` | `3.2.0` (stable) | fenced out (`#if SUPPORTS_DCV`) | AnyCA Gateway **25.5.x** (IAnyCAPlugin 3.2.0) | +| **DCV** | `dotnet build -p:DcvSupport=true` | `3.3.0-PRERELEASE` | enabled | AnyCA Gateway **26.x** (IAnyCAPlugin ≥ 3.3) | + +The **default is the no-DCV / 3.2.0 build** — it is the GA artifact that loads and persists on the +current GA gateway (25.5.x) and depends only on a stable package, so it is what CI ships. Build the +DCV variant explicitly with `-p:DcvSupport=true` for 26.x hosts. The one property drives the package +version, the `SUPPORTS_DCV` compile constant, and DCV test-file inclusion across all three projects, +so the two host targets are a build flag rather than a maintained fork. + ## API Smoke-Test Targets All API targets source `~/.env_certinext`, compute the HMAC `authKey` (`SHA256(accessKey + ts + txn)`), and call the live CERTInext API via `curl`. All JSON responses are piped through `jq`. @@ -62,6 +80,9 @@ make orders # lists recent orders — useful to find an ORDER_NUMBER to test | Place a draft order | `make generate-order DOMAIN=example.com [CSR_FILE=req.pem] [VALIDITY=1] [SAVE_AND_HOLD=1]` | `GenerateOrderSSL` — places a new order; `SAVE_AND_HOLD=1` (default) creates a draft | | Revoke an order | `make revoke-order ORDER_NUMBER=NNNNN [REASON_ID=1]` | `RevokeOrder` — revokes an issued certificate | | Attach a CSR to a draft | `make submit-csr ORDER_NUMBER=NNNNN CSR_FILE=req.pem` | `SubmitCSR` — attaches a CSR to a saveAndHold draft order | +| Discover product codes | `make probe-products` | Places `saveAndHold=1` draft orders for all known SSL/TLS product codes and reports which ones the account accepts | +| Cancel one pending order | `scripts/reject-order.sh ORDER_NUMBER=NNNNN` | Shell script — cancels a single pending order (not a `make` target) | +| Cancel all pending orders | `scripts/reject-all-pending.sh` | Shell script — dry-run by default; set `REJECT_ALL_PENDING=1` to fire (not a `make` target) | | Show API target help | `make api-help` | Prints usage for all API targets | > Note: `TrackOrder` and `GetCertificate` require a formal `orderNumber`, which is only assigned after a draft order is submitted and approved. Draft orders (created with `saveAndHold:"1"`) have a `requestNumber` but no `orderNumber` until that point. @@ -93,26 +114,12 @@ See `CERTInext.IntegrationTests/INTEGRATION_TESTING.md` for a full description o ## Product Integration Test Coverage -The table below records live draft-order results against the Production — India instance. Orders were placed with `saveAndHold:"1"` so no billing, DCV, or CA issuance was triggered. Tests are in `CERTInext.IntegrationTests/DraftOrderTests.cs`. +`DraftOrderTests.cs` (and `TrackOrderTests.cs`) previously recorded live draft-order results here, but both were removed: they asserted specific `requestNumber` values hardcoded from one developer's account, which don't exist on any other account and so failed everywhere else. Their intent — verifying draft-order and track-order semantics — is now covered by `LifecycleTests`, which creates its own order and asserts on it without relying on account-specific identifiers. -| Product | Code | Test Status | requestNumber | Notes | -|---|---|---|---|---| -| DV SSL | `838` | ✓ Tested | 4572531551 | Base domain; no extra fields required beyond base set | -| DV SSL Wildcard | `839` | ✓ Tested | 9149755266 | CSR CN must be `*.domain`; `domainName` must also use wildcard format | -| DV SSL UCC | `840` | ✓ Tested | 1611445122 | `certificateInformation.additionalDomains` array required | -| DV SSL Wildcard UCC | `841` | ✗ Blocked | — | EMS-918: "Additional Information cannot be empty" — required fields for this product not yet identified | -| OV SSL | `842` | ✓ Tested | 5546366498 | Requires `locality` and `postalCode` in `certificateInformation` | -| OV SSL Wildcard | `843` | ✗ Not tested | — | Draft order not yet placed | -| OV SSL UCC | `844` | ✗ Not tested | — | Draft order not yet placed | -| OV SSL Wildcard UCC | `845` | ✗ Blocked | — | EMS-918: "Additional Information cannot be empty" — required fields for this product not yet identified | -| EV SSL | `846` | ✓ Tested | 3932332114 | Requires `contractSignerInfo`, `certificateApproverInfo`, non-empty `streetAddress2`, `companyRegistrationNumber` | -| EV SSL UCC | `847` | ✗ Blocked | — | EMS-918: "Additional Information cannot be empty" — required fields for this product not yet identified | -| DV SSL 1 Month | N/A | ✗ Not supported | — | Visible in portal but not returned by `GetProductDetails` API; no product code available. Not supported by plugin. | -| DV SSL Wildcard 1 Month | N/A | ✗ Not supported | — | Visible in portal but not returned by `GetProductDetails` API; no product code available. Not supported by plugin. | -| emSign Intranet SSL | `100` | ✗ Not tested | — | EMS-1162: not provisioned on this account type | -| IGTF Host | `104` | ✗ Not tested | — | EMS-1162: not provisioned on this account type | -| S/MIME | `894` | ✗ Not tested | — | EMS-1162: not provisioned on this account type | -| Natural Person Doc Signer | `825` | ✗ Not tested | — | EMS-1162: not provisioned on this account type | -| Legal Entity Doc Signer | `819` | ✗ Not tested | — | EMS-1162: not provisioned on this account type | - -Products returning EMS-1162 require special provisioning by eMudhra that is not included on a standard SSL/TLS account. The plugin code supports submitting orders for any product code; whether the order is accepted depends on what is provisioned for your account. +Product codes are provisioned per account by eMudhra and are not portable across accounts (see the [Product Codes](configuration.md#product-codes) section in configuration.md). To discover which codes and required fields apply to *your* account: + +```bash +make probe-products +``` + +This places `saveAndHold=1` draft orders for all known SSL/TLS product codes and reports which return a `requestNumber` (valid/provisioned) versus an error (invalid or not provisioned). See `CERTInext.IntegrationTests/TESTING.md` for the current, account-specific findings and expected test results. diff --git a/docsource/migration-v1-to-v2.md b/docsource/migration-v1-to-v2.md new file mode 100644 index 0000000..42b012b --- /dev/null +++ b/docsource/migration-v1-to-v2.md @@ -0,0 +1,227 @@ +## Migrating from V1 to V2 + +The CERTInext V2 REST API is an opt-in, order-centric API with modern OAuth2 authentication. It is +controlled entirely by the `UseV2Api` connector flag: `false` (default) keeps the connector on the +V1 API documented above; `true` switches **all** operations — Enroll, GetSingleRecord, Revoke, and +Synchronize — to V2. The two APIs cannot be mixed on a single connector. + +> **V2 is labeled Preview.** It has real functional gaps relative to V1 (see +> [Known Gaps](#known-gaps) below) — most notably that per-SAN DCV on multi-domain (UCC) orders +> hasn't been confirmed by CERTInext or verified end to end (see below), and that Document Signer +> (`ProductFamily=signature`) enrollment isn't supported yet. Read this whole document — especially +> that section — before migrating a production connector. + +### Before You Begin: Confirm V2 Will Work for Your Templates + +**V2 supports multi-domain (UCC) certificates**, including **DV UCC, DV Wildcard UCC, OV UCC, OV +Wildcard UCC, and EV UCC**. The plugin detects a UCC product from the live Catalog's `productTypeID` +and sends the extra SAN domains in the order's `additionalDomains` field. Per the CERTInext V2 spec, +a UCC order's SANs are taken from the order, not the CSR. `additionalDomains` takes DNS names only, +so any non-DNS SAN (IP, email, URI) is left off a UCC order and a warning is written to the gateway +log. For a non-UCC SSL product, a CSR that carries DNS SANs beyond the primary domain and its `www.` +variant is rejected with a `FAILED` result before any order is placed; UCC products are exempt from +that check. + +**UCC DCV: the plugin runs DCV for each SAN, but the behavior isn't confirmed yet.** For a UCC order, +the plugin's DNS-01 DCV flow publishes, verifies, and cleans up a TXT record for each domain that +CERTInext reports as not yet validated, not just the primary domain. CERTInext hasn't yet confirmed +how per-SAN DCV is meant to work on V2, and this path hasn't been verified end to end against a live +UCC order. Test each UCC template on the sandbox before you rely on it in production, and keep it on +a V1 connector if you need that guarantee today. + +### Step 1 — Create a V2 OAuth2 Credential + +V2 authenticates with an OAuth2 `client_credentials` token, and the CERTInext V2 spec requires the +API key to be generated in **OAuth mode**. The credential goes in the connector's +`OAuthClientId`/`OAuthClientSecret` fields: + +1. Log in to the CERTInext portal for your environment. +2. Navigate to **Integrations → APIs**. +3. Click **+ Create API Credentials**. +4. Set **API Type** to `REST` and select the **OAuth** auth type, not `Access Key`. +5. Complete the form and click **Generate**. +6. Note the client ID and client secret right away. + +A V1 `Access Key` credential won't work against V2. If the key wasn't generated in OAuth mode, the +token request fails with HTTP 403 `unauthorized_client`, and the plugin reports that the key wasn't +generated in OAuth mode. A wrong client ID or secret fails with HTTP 401 `invalid_client` instead. +Nobody has checked whether a key that was already created in OAuth mode for V1's `AuthMode: OAuth` +also works on V2. If you reuse one and get the 403, create a new OAuth-mode key. + +The V2 spec's token example sends the account number as `client_id`. It hasn't been verified whether +the portal ever shows a client ID that differs from the account number. The plugin never substitutes +`AccountNumber` for the client ID, so always set `OAuthClientId` explicitly, even if the value +matches your account number. + +### Step 2 — Update the CA Connector + +You can update the existing CA connector in place, or (recommended for a first migration) create a +second connector pointed at the same CERTInext account with `UseV2Api=true`, so you can validate V2 +behavior without disrupting V1 traffic. + +| V1 field | What happens when you set `UseV2Api = true` | +|---|---| +| `ApiUrl` | **Must change format.** V1 requires the `/emSignHub-API/` path segment (e.g. `https://us-api.certinext.io/emSignHub-API/`); V2 is the bare host with no trailing slash or path suffix (e.g. `https://us-api.certinext.io`). Using the V1-style URL under V2 (or vice versa) will fail every call. In both modes, `ApiUrl` must use `https` — `http` is rejected at connection-validation time except for a loopback host, which stays allowed for local test servers. | +| `AccountNumber` | Not required, and not read by any V2 code path. V2 authenticates with `OAuthClientId`; the plugin doesn't reuse `AccountNumber` as the OAuth `client_id` (see Step 1). | +| `AuthMode` | Not required. V2 always authenticates via OAuth2 `client_credentials`, regardless of this setting. | +| `ApiKey` | Not required. V2 never computes an `authKey`. | +| `OAuthClientId` / `OAuthClientSecret` | **Reused, but repointed.** Set them to the OAuth-mode credential from Step 1. It's unverified whether a V1 `AuthMode: OAuth` key also works on V2 (see Step 1). | +| `OAuthTokenUrl` | Not used. V2 always requests a token from `{ApiUrl}/oauth/token`; the token URL is derived, not configured. | +| `GroupNumber` | **Honored.** Sent as `groupNumber` on V2 order create (SSL/TLS and Private PKI) and as a `groupNumber` query parameter on the catalog and orders-report calls. Omitted when blank, so the account's default group applies. It hasn't been verified live whether the catalog and report filters actually narrow results on a multi-group account. | +| `OrganizationNumber` | **Required for OV/EV, otherwise unused.** V2 OV/EV orders now send `organization.organizationNumber` (with `preVetted=true`) from this setting — CERTInext hard-rejects an OV/EV order with no organization data (HTTP 422 `EMS-1180`), so `OrganizationNumber` must be set on the connector before enrolling OV/EV certificates via V2. DV orders never send an organization block, so this setting has no effect for DV. | +| `AccountingModel` | Not used by V2 order placement. | +| `EmailNotifications` | **Honored, with one default-value difference from V1.** `1` maps to `emailNotifications: "all"`; `0` maps to `"0"` (confirmed live 2026-09-28 to suppress order-creation emails, same as V1). Blank/unset is omitted on V2 (the CA's own default of `"all"` applies) rather than sent as `"0"` the way V1's own fallback does — set `EmailNotifications=0` explicitly if you want V2 orders silent. Any other value fails the V2 enrollment before any CA call. | +| `SubscriptionAutoRenew` / `SubscriptionRenewCriteriaDays` | Honored. `SubscriptionAutoRenew=1` sets `subscription.autoRenew=true`; `SubscriptionRenewCriteriaDays` sets `subscription.renewBeforeDays` (blank omits the field, so the CA's documented default of 30 applies). An unparseable or negative `SubscriptionRenewCriteriaDays` fails the enrollment before any CA call. | +| `DefaultProductCode` | Not used for V2 renewals (see [Renewals](#renewals-and-reissuance) below) — V2 has no separate renewal call to fall back to a default code for. | +| `TechnicalContactName` / `Email` / `IsdCode` / `MobileNumber` | **Honored.** Sent as the order's `technicalPointOfContact` block on V2 SSL/TLS and Private PKI orders. Each blank field falls back to the matching `Requestor*` value, the same as V1. `designation` is always sent as `Technical Contact`. | +| `IgnoreExpired` | **Honored during V2 Synchronize.** When `true`, a report row whose `certificateExpiryDate` parses and is in the past is skipped. A row with a missing or unparseable expiry date is kept. | +| `SubmitNonDnsSans` | **SSL family (`ProductFamily=ssl`):** not consulted. A non-UCC order carries only the primary domain (plus `www.` when `AutoSecureWww` is set). A UCC order's `additionalDomains` takes DNS names only, so non-DNS SANs are left off the order with a warning in the gateway log (see [Before You Begin](#before-you-begin-confirm-v2-will-work-for-your-templates)). **Private PKI family (`ProductFamily=private-pki`):** not consulted — the order's `additionalHosts` field accepts DNS names and IPv4/IPv6 addresses natively, so IP-address SANs are always submitted; email and URI SANs cannot be expressed there and are left off the order with a warning in the gateway log. | +| `PageSize` | Still used, now against V2's `/reports/orders` paging. | +| `RequestorName` / `RequestorEmail` / `RequestorMobileNumber` / `RequestorDesignation` | Still used — carried into the V2 order's `requestor` block. `RequestorDesignation` is omitted from the order when blank (the default) rather than sent with any value. | +| `SignerPlace` / `SignerIp` | Still used — carried into the V2 order's `agreement` block. | +| `SubscriptionValidityYears` | Still used as the fallback validity when the template's `ValidityYears` parameter is not set. | +| `AutoSecureWww` | Still used — controls whether V2 adds the `www.` variant. | + +New fields, `UseV2Api` and `V2SyncLookbackHours`, are documented in [V2 API (Preview)](#v2-api-preview) +above. + +### Step 3 — Update Certificate Templates + +For each template you're migrating: + +1. **If the template sets only `ProductId` (no explicit `ProductCode`), check whether the live V2 + catalog has more than one product at that assurance level.** The plugin resolves the numeric code + automatically from the catalog when exactly one entry matches; when the catalog has several (e.g. + two DV SSL entries with different billing terms), you must either set `ProductCode` explicitly on + the template or set the connector's `DefaultProductCode` to one of the candidates — otherwise every + enrollment against that template fails with an error listing the candidate codes. See + [V2 Product Code Resolution](configuration.md#v2-product-code-resolution) for the full resolution + order. +2. Add `ProductFamily` (default `ssl`) if not already present — this is a V2-only parameter with no + V1 equivalent. `ProductVariant` (`dv`/`ov`/`ev`) is optional for `ssl`: if left unset, the plugin + derives it from the selected product (an OV product sends `ov`, an EV product sends `ev`) instead + of defaulting to `dv`; set it explicitly only to override. For a Private PKI template, set + `ProductFamily=private-pki`, `ProductVariant` to `intranet-ssl` or `igtf-host` (required, no + default), and an explicit `ProductCode` (see [V2 Private PKI Orders](#v2-private-pki-orders)). + `ProductFamily=signature` (Document Signer) enrollment is not yet supported. +3. Re-verify `ProductCode` (if set explicitly) against the V2 catalog. V1 and V2 product codes are not + guaranteed to be the same numeric values on your account — call `GetProductDetailsV2Async` (or the + equivalent live probe) rather than assuming the V1 code carries over. Template validation + (`ValidateProductInfo`) automatically checks `ProductCode` against the V2 catalog once + `UseV2Api=true`, so an incorrect code will be caught at template save time, not silently at + enrollment. +4. If the template enrolls for a UCC (multi-domain) product, test it on the sandbox first. The plugin + runs DCV for each SAN, but CERTInext hasn't confirmed that behavior yet (see + [Before You Begin](#before-you-begin-confirm-v2-will-work-for-your-templates)). +5. If the product is OV or EV (whether `ProductVariant` is set explicitly or left to be derived), set + `OrganizationNumber` on the CA connector (a pre-vetted organization number from CERTInext's + Accounts → List Organizations). It is mandatory for OV/EV under V2 — enrollment fails fast with a + clear error if it's missing, rather than reaching the CA + and getting back an opaque 422. + +### Step 4 — Test Before Cutting Over + +Run a full enroll → sync → revoke cycle against the sandbox environment with `UseV2Api=true` before +pointing a production template at the V2 connector. At minimum, confirm: + +- A new enrollment issues (or parks pending DCV/approval as expected) and is retrievable via + `GetSingleRecord`. +- A full and an incremental `Synchronize` both pick up the order. +- `Revoke` succeeds for the Command revoke reasons you actually use. + +## Behavioral Differences After Migrating + +- **Order identifiers.** The V2 spec's examples show `ord_`-prefixed order IDs (e.g. + `ord_8K9mQ2vR8nP4bL`), but the orders placed through V2 against the sandbox so far have come back + with numeric order numbers in the same format as V1 (e.g. `6625262451`). V1-placed order numbers + also resolve through V2 Track Order and appear under the same number in the V2 orders report, so + existing `CARequestID` values carry over. The plugin stores whatever `orderId` CERTInext returns as + the `CARequestID`. Treat it as an opaque string in any external tooling rather than assuming either + format. +- **Status vocabulary.** V2 reports order status as strings (`issued`, `pending-dcv`, `pending-csr`, + `pending-agreement`, `pending-organization-verification`, `pending-documents`, `pending-approval`, + `revoked`, `cancelled`, `rejected`, `expired`) rather than V1's numeric CERTInext status codes. The + plugin maps both to the same Keyfactor `EndEntityStatus` values, so this is transparent to Command, + but it changes what you'll see in gateway trace logs. +- **Synchronization source.** V2 sync reads CERTInext's `/reports/orders` endpoint instead of V1's + `GetOrderReport`. Incremental sync queries a window starting `V2SyncLookbackHours` (default 72) + before the last sync time rather than the exact last-sync timestamp, because it isn't confirmed + whether the API's date filter brackets order-placement or issuance date — this trades a small + amount of redundant re-processing for not missing a slow-issuing order. + +### Renewals and Reissuance + +CERTInext V2 has no *renew* endpoint, but it does document a `/reissue` endpoint (`mode: +rekey|update-sans`, with optional `revokePrevious`/`revokeReason`). The plugin intentionally does not +use it. +**Every** Command `Renew`, `Reissue`, and `RenewOrReissue` enrollment instead places a brand-new V2 +order — the same call path as a new enrollment — rather than reusing V1's renewal-window logic or the +`/reissue` endpoint. The prior order and certificate are left issued, not auto-revoked; Command links +the old and new certificates via history only. If your CERTInext account is on a credit-based billing +model, **each renewal under V2 consumes a new credit**, unlike V1 where a renewal inside the +`RenewalWindowDays` window is billed as part of the existing subscription term. Factor this into your +migration decision if you rely on CERTInext's free-renewal-within-subscription behavior. + +### Revocation Reason Codes + +V1 sends CERTInext a numeric `revokeReasonId`; V2 sends a kebab-case string reason. The plugin +handles this translation automatically. On SSL/TLS orders, only `key-compromise` (1), +`affiliation-changed` (3), `superseded` (4), `cessation-of-operation` (5), and `privilege-withdrawn` +(9) were accepted in live sandbox testing. `unspecified` (0, Command's default when no reason is +given), `ca-compromise` (2), `certificate-hold` (6), and `aa-compromise` (10) are all rejected live +with `"Invalid Revoke Reason ID"` — `ca-compromise` and `certificate-hold` are listed in the spec for +SSL/TLS, `aa-compromise` isn't listed for SSL/TLS at all, but the live sandbox rejects all four the +same way. Rather than fail the revoke, the plugin retries each once with a close accepted +substitute: `ca-compromise` and `aa-compromise` retry as `key-compromise`; `unspecified` and +`certificate-hold` retry as `cessation-of-operation` (chosen over `key-compromise` for those two +because neither implies an actual key compromise, and `key-compromise` carries the spec's own BR +§4.9.1.1 24-hour CRL-turnaround obligation that would misrepresent the revoke). No customer action is +needed for any of these four cases. Any other revoke failure is surfaced as-is, without a retry. +Revoke reasons for Private PKI orders haven't been tested live. Separately, a revoke note containing +a semicolon (`;`) is rejected with `"Invalid Revoke Remarks"`. The plugin's own generated notes avoid +semicolons, but a future customer-supplied note would need to avoid them too. + +## Known Gaps + +As of this writing, the following V2 limitations are known and unresolved. None of them are +show-stoppers for a single-domain, credit-tolerant deployment, but you should decide with these in +mind rather than discover them after cutting over: + +- **UCC per-SAN DCV is unconfirmed.** The plugin runs DCV for each SAN on a UCC order, but CERTInext + hasn't confirmed the per-SAN DCV behavior and the path hasn't been verified end to end. See + [Before You Begin](#before-you-begin-confirm-v2-will-work-for-your-templates) above. +- **Document Signer (`ProductFamily=signature`) enrollment isn't supported yet.** It fails before any + order is placed. +- **Every renewal/reissue places a new order and consumes a new credit** — see + [Renewals and Reissuance](#renewals-and-reissuance) above. +- **`OrganizationNumber` is now required to enroll OV/EV via V2, but only unlocks acceptance, not + V1's pre-vetting speed benefit.** V2 OV/EV orders send `organization.organizationNumber` with + `preVetted=true` (mirroring V1's `organizationDetails.preVetting`), which is what makes CERTInext + accept the order at all — omitting it gets a hard 422 rejection. Whether this also grants V1's + observed vetting-queue speedup has not been independently confirmed for V2; if your account was + relying on `OrganizationNumber` to fast-path DV issuance under V1, note that DV orders under V2 + never send an organization block, so that specific benefit does not carry over. +- **`AccountingModel` has no V2 effect.** V2 order create has no equivalent field. `GroupNumber`, the + technical-contact fields, `EmailNotifications`, `SubscriptionAutoRenew`/`RenewCriteriaDays`, and + `IgnoreExpired` are all honored on V2 (see the table above). +- **V2 `TrackOrder` responses omit `_links`** — a spec-shape discrepancy observed live; no functional + impact has been identified so far, but it means any future feature that expects those links (e.g. + a direct download link) can't rely on them yet. + +## Rolling Back to V1 + +Rolling back is just setting `UseV2Api` back to `false` on the connector — the V1 credential fields +(`ApiKey`/`AccountNumber`/`AuthMode` or V1 `OAuth`) are unaffected by having been unused while V2 was +active, as long as you didn't overwrite them in Step 2. + +**Rollback caveat (unverified):** `Enroll`, `GetSingleRecord`, `Revoke`, and `Synchronize` choose V1 +or V2 purely from the connector's current `UseV2Api` flag, not from the stored `CARequestID`. What +has been observed on the sandbox runs in one direction only: V1-placed order numbers resolve through +V2 Track Order, and V2-placed orders so far have numeric order numbers in the same format as V1. The +reverse hasn't been tested. Nobody has checked whether V1 Track Order or `GetOrderReport` can see an +order that was placed through V2. Until that's confirmed, treat rolling back a connector that has +already issued V2 certificates as untested. Before you rely on it, check on the sandbox that sync, +revoke, and renewal still work for those certificates after switching back. Certificates enrolled +before the switch to V2 are V1 orders and are unaffected. + +{% include 'architecture.md' %} diff --git a/docsource/overview.md b/docsource/overview.md new file mode 100644 index 0000000..f11d3d3 --- /dev/null +++ b/docsource/overview.md @@ -0,0 +1,92 @@ +## Overview + +The CERTInext AnyCA Gateway REST plugin extends the certificate lifecycle capabilities of the CERTInext platform (by eMudhra) to Keyfactor Command via the Keyfactor AnyCA Gateway REST. See [configuration.md](configuration.md) for full installation and configuration details, [architecture.md](architecture.md) for design notes, and [development.md](development.md) for local development. + +## CERTInext CA Certificates + +Before the gateway can register a CA backed by this plugin, the Keyfactor Command server (and the AnyCA Gateway REST host) must trust the CERTInext issuing CA chain. Download the root and any intermediate CA certificates from the CERTInext portal for the environment you are targeting: + +| Environment | Portal Sign-in URL | +|---|---| +| Sandbox | https://sandbox-us.certinext.io/ | +| Production — India (Global) | https://in.certinext.io/ | +| Production — US | https://us.certinext.io/ | + +After signing in, navigate to the certificate-authority / chain download page in the portal, export each CA in the chain as PEM or DER, and import them into the appropriate Windows certificate stores on the gateway host (Trusted Root for the root CA, Intermediate Certification Authorities for any subordinates). See [configuration.md](configuration.md#gateway-registration) and the [README](../README.md#configuration) for the full Gateway Registration walkthrough. + +## Troubleshooting + +### `"Inactive Account User."` returned from `GenerateOrderSSL` + +**Symptom** + +Enrollments fail with the gateway exception: + +``` +CERTInext order failed: Inactive Account User.. See gateway logs for details. +``` + +The same access key / account works perfectly fine before and after the failing window — a `Ping` (`ValidateCredentials`) call seconds earlier returns success, and the next individual enrollment after a brief pause also succeeds. + +**Root cause** + +The CERTInext sandbox at `https://sandbox-us-api.certinext.io/emSignHub-API` applies a **burst rate limit** on order placement and surfaces rate‑limit rejection through the **generic** error string `"Inactive Account User."` — the same string the API uses for genuinely inactive accounts. There is currently no distinguishing `errorCode`, `Retry-After` header, or structured field to tell the two conditions apart from the meta block alone. + +Empirically the limit kicks in at roughly **16+ enrollments submitted within 10 seconds** on the US sandbox. Sustained submission velocity well below that runs cleanly. + +**Confirmation steps** + +1. Run a single `Ping` against the same `ApiUrl` / `AccessKey`. If it succeeds, the account is active; the prior failure was almost certainly a rate-limit hit. +2. Check the gateway warning log for the `LogApiFailure` line emitted just before the throw (see issue [#8](https://github.com/Keyfactor/certinext-caplugin/issues/8) and the `LogApiFailure` helper in `CERTInextClient.cs`). The full raw response body is included there — if CERTInext ever surfaces a distinguishing code or message for rate-limit (as opposed to account-state), it will appear in that line. +3. Wait 30–60 seconds, then retry the failed enrollment(s). A successful retry confirms it was rate-limit. + +**Mitigation** + +- **Reduce submission velocity**: throttle order placements to roughly one per 1–2 seconds. The plugin does not yet have a built-in client-side throttle; pacing must come from the caller (e.g. Keyfactor Command's enrollment scheduling, or a workflow that places certs in batches). +- **For high-volume migration scenarios**: split the workload into batches of ~10 orders separated by a short pause, rather than firing everything at once. +- **No client-side automatic retry on this error**: a defensive retry inside `PlaceOrderAsync` would paper over the misleading error string and burn the operator's order quota on retries. We document the gotcha instead. + +### Enrollment returns immediately with `Status=90 (EXTERNALVALIDATION)` + +**Symptom** + +Enrollment completes successfully but the cert is not yet issued — Command shows the request in pending status. A subsequent `Synchronize` picks it up. + +**Root cause** + +This is the expected return shape on two paths: + +1. The plugin was loaded on an older gateway host (pre-IAnyCAPlugin v3.3) that does not inject `IDomainValidatorFactory`. DCV cannot run, so any product that requires DNS validation completes only after CERTInext-side validation finishes. +2. The plugin's bounded `Enroll()` budget (`DcvWaitForChallengeSeconds` + `DcvWaitForIssuanceSeconds`, defaults 60s each) elapsed before CERTInext finished asynchronous issuance. + +**Mitigation** + +The next gateway sync cycle will pick the cert up and transition it to `GENERATED`. The plugin's sync-driven DCV retry is single-shot per record, so even with hundreds of pending orders the sync completes in seconds, not minutes — see [configuration.md](configuration.md) for the `DcvWaitForChallengeSeconds`/`DcvWaitForIssuanceSeconds` knobs if you want to tune the Enroll-time budget. + +### `EMS-956 "Invalid Request for this API"` from `GetDcv` + +**Symptom** + +The plugin's DCV machinery starts but the first `GetDcv` call returns this error. Plugin gracefully defers DCV to the next sync cycle (single warning log line, no exception thrown). + +**Root cause** + +CERTInext exposes the `domainVerification` slot in `TrackOrder` **before** the `GetDcv` endpoint will accept calls for that order — there's an internal gating window. The plugin's `IsDcvNotYetReady` predicate explicitly recognizes this and treats it as "DCV not ready yet, retry on the next sync". + +**Mitigation** + +No action needed. Plugin's sync-driven DCV retry handles this transparently — the order will be picked up on a subsequent sync cycle once the CA-side gate clears (observed window: seconds to a few hours, environment-dependent). + +### Plugin fails to load with `Could not load type 'Keyfactor.AnyGateway.Extensions.IDomainValidatorFactory'` + +**Symptom** + +Gateway returns HTTP 500 on CA registration or first enrollment with the body `{"ErrorCode":"0x80131509"}`. Pod logs show `TypeLoadException` for `Keyfactor.AnyGateway.Extensions.IDomainValidatorFactory`. + +**Root cause** + +Older gateway image whose bundled `Keyfactor.AnyGateway.IAnyCAPlugin` assembly is v3.2 or earlier (the `IDomainValidatorFactory` interface is v3.3+). This was fully addressed by the issue [#7](https://github.com/Keyfactor/certinext-caplugin/issues/7) fix in v1.0 — both the constructor-signature surface AND the field-type surface are now safe to load on v3.2 hosts. + +**Mitigation** + +Deploy the default (no-DCV) build for AnyCA Gateway 25.5.x; do not deploy the DCV build on a 25.5.x host. The **default build (no-DCV, IAnyCAPlugin 3.2.0)** is the one that loads *and* persists records on AnyCA Gateway 25.5.x, and it is what the released artifact ships. The DCV-capable build (IAnyCAPlugin 3.3.0-PRERELEASE, `dotnet build -p:DcvSupport=true`) is for AnyCA Gateway 26.x; loading it on a 25.5.x host triggers the type-load error above and, even when it loads, its records do not persist on a 3.2 host. See the `DcvSupport` build variants in the developer guide. diff --git a/integration-manifest.json b/integration-manifest.json index 2056b50..6c13acd 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -2,12 +2,12 @@ "$schema": "https://keyfactor.github.io/v2/integration-manifest-schema.json", "integration_type": "anyca-plugin", "name": "CERTInext AnyCA REST Gateway Plugin", - "status": "prototype", - "support_level": "kf-community", + "status": "production", + "support_level": "kf-supported", "link_github": true, "update_catalog": true, "description": "AnyCA REST Gateway plugin for CERTInext (eMudhra) certificate lifecycle management platform", - "gateway_framework": "24.2.0", + "gateway_framework": "25.5.0", "release_dir": "CERTInext/bin/Release", "release_project": "CERTInext/CERTInext.csproj", "about": { @@ -27,7 +27,7 @@ "ca_plugin_config": [ { "name": "ApiUrl", - "description": "REQUIRED: CERTInext API base URL. Sandbox (US): https://sandbox-us-api.certinext.io/emSignHub-API/ \u2014 Production (US): https://us-api.certinext.io/ \u2014 Production (Global/India): https://api.certinext.io/" + "description": "REQUIRED: CERTInext API base URL. Its meaning follows UseV2Api. V1 (default): Sandbox (US): https://sandbox-us-api.certinext.io/emSignHub-API/ \u2014 Production (US): https://us-api.certinext.io/emSignHub-API/ \u2014 Production (Global/India): https://api.certinext.io/emSignHub-API/. V2 (UseV2Api=true): the bare V2 host, e.g. https://sandbox-us-api.certinext.io, no trailing slash or path suffix \u2014 V1 and V2 are hosted differently, so this value changes when UseV2Api is toggled." }, { "name": "AccountNumber", @@ -35,7 +35,27 @@ }, { "name": "GroupNumber", - "description": "OPTIONAL: CERTInext group (delegation) number. When set, it is included in GetProductDetails requests so the full product list is returned. Some sandbox accounts require this to avoid receiving an empty product list. Available in the CERTInext portal under Delegation \u2192 Groups." + "description": "OPTIONAL: CERTInext group (delegation) number. When set, it is included in GetProductDetails requests AND in the `delegationInformation.groupNumber` field of every SSL order so the order is routed to the correct account group. Some accounts will queue orders for additional review when this field is omitted. Available in the CERTInext portal under Delegation \u2192 Groups." + }, + { + "name": "OrganizationNumber", + "description": "STRONGLY RECOMMENDED for OV/EV and faster DV issuance: numeric CERTInext organization number for a pre-vetted organization (e.g. your company's pre-vetted entry). When set, every SSL order is submitted with `organizationDetails.preVetting=\"1\"` and the configured `organizationNumber`, telling CERTInext to skip the manual organization-vetting queue. Without this value, orders are placed without any organizationDetails block and CERTInext may park them in `Pending System RA` for extended manual review (observed: tens of hours). Available in the CERTInext portal under Organizations \u2192 Pre-vetted Organizations." + }, + { + "name": "TechnicalContactName", + "description": "OPTIONAL: Name sent in the `technicalPointOfContact.tpcName` field of every SSL order. Defaults to the configured RequestorName when blank. Some product configurations require a TPoC to be present; omitting it can cause CERTInext to park orders awaiting manual completion of the field." + }, + { + "name": "TechnicalContactEmail", + "description": "OPTIONAL: Email sent in the `technicalPointOfContact.tpcEmail` field of every SSL order. Defaults to the configured RequestorEmail when blank." + }, + { + "name": "TechnicalContactIsdCode", + "description": "OPTIONAL: International dialing code for the TPoC phone number. Defaults to the configured RequestorIsdCode when blank." + }, + { + "name": "TechnicalContactMobileNumber", + "description": "OPTIONAL: Mobile number for the TPoC (digits only). Defaults to the configured RequestorMobileNumber when blank." }, { "name": "AuthMode", @@ -51,11 +71,11 @@ }, { "name": "OAuthClientId", - "description": "OAuth client ID. Required when AuthMode is 'OAuth'." + "description": "OAuth client ID. Required when AuthMode is 'OAuth' (V1). Also required, and reused, when UseV2Api is true — V2 authenticates with these same OAuthClientId/OAuthClientSecret fields via client_credentials against {ApiUrl}/oauth/token, rather than separate V2-only credentials." }, { "name": "OAuthClientSecret", - "description": "OAuth client secret. Required when AuthMode is 'OAuth'." + "description": "OAuth client secret. Required when AuthMode is 'OAuth' (V1). Also required, and reused, when UseV2Api is true (see OAuthClientId)." }, { "name": "RequestorName", @@ -73,9 +93,13 @@ "name": "RequestorMobileNumber", "description": "Requestor mobile number (digits only, no country code)." }, + { + "name": "RequestorDesignation", + "description": "OPTIONAL: Job title / role of the requestor (e.g. 'IT Administrator'). Sent in V2 orders' `requestor.designation` field. Free text with no CA-side enum. Left blank by default, in which case the field is omitted entirely from the order rather than sent with a default value." + }, { "name": "SignerPlace", - "description": "City or location of the subscriber agreement signer. Required by CERTInext for all orders." + "description": "City or location of the subscriber agreement signer (e.g. 'San Francisco, CA'). REQUIRED when UseV2Api is on: the V2 Subscriber Agreement sent with every SSL order requires it, so the connector cannot be saved with it blank. A per-template SignerPlace enrollment parameter overrides it." }, { "name": "SignerIp", @@ -85,6 +109,30 @@ "name": "DefaultProductCode", "description": "OPTIONAL: Default numeric product code used when not specified at template level. Product codes are provided by eMudhra (e.g. the SSL DV 1-year code for your account). Retrieve available codes from Integrations \u2192 APIs \u2192 GetProductDetails." }, + { + "name": "AccountingModel", + "description": "OPTIONAL: CERTInext billing model sent in `orderDetails.accountingModel`. \"2\" = credit-based (most accounts, default). \"1\" = cash model." + }, + { + "name": "EmailNotifications", + "description": "OPTIONAL: Whether CERTInext sends lifecycle-event emails to the requestor. \"1\" = full notification set (V1 sends it as-is; V2 maps it to \"all\"). \"0\" = silent on both V1 and V2 (V2 confirmed live 2026-09-28). Blank/unset stays silent on V1 (sent as \"0\") but is omitted on V2, so the CA's own default (\"all\", not silent) applies instead. Any other value fails V2 enrollment before any CA call. Default: \"0\" — V2 orders are now silent by default, matching V1 (previously V2 always sent \"all\")." + }, + { + "name": "SubscriptionValidityYears", + "description": "OPTIONAL: Default validity in years for SSL orders. \"1\", \"2\", or \"3\". Override per template via the ValidityYears product parameter. Default: \"1\"." + }, + { + "name": "SubscriptionAutoRenew", + "description": "OPTIONAL: Whether CERTInext should auto-renew certificates issued through this connector. \"0\" = disabled (recommended \u2014 renewal is driven by Keyfactor Command), \"1\" = enabled. Default: \"0\"." + }, + { + "name": "SubscriptionRenewCriteriaDays", + "description": "OPTIONAL: Days before expiry at which CERTInext auto-renews (only honored when SubscriptionAutoRenew = \"1\"). Typical values: \"30\" or \"60\". Default: \"30\"." + }, + { + "name": "AutoSecureWww", + "description": "OPTIONAL: If \"1\", CERTInext automatically adds the `www.` variant of the primary domain as an additional SAN. \"0\" = use only the CN/SANs supplied with the CSR. Default: \"0\"." + }, { "name": "IgnoreExpired", "description": "If true, expired certificates will be skipped during synchronization. Default: false." @@ -95,7 +143,51 @@ }, { "name": "Enabled", - "description": "Enables or disables the CA connector. Set to false to save the connector record before credentials are available without triggering a live connectivity test. Default: true." + "description": "Enables or disables the CA connector. Set to false to create the connector record before credentials are available. Default: true." + }, + { + "name": "LogSensitiveRequestData", + "description": "OPTIONAL diagnostic escape hatch. When true, enabling it writes requestor personal data (name, email, phone, and other organization contact details) and full CA request/response payloads to the gateway logs. Meant for temporary use while verifying a new deployment — confirming exactly what was sent to the CA and that the order succeeded — and should be turned back off once verification is complete. When false (default), personal-data fields are redacted (email is masked but keeps its domain, e.g. 'j***@example.com') and the enrollment log line omits the requester name entirely. Email SAN values (rfc822Name) in log lines are masked the same way; DNS, IP and URI SANs are always logged in full. Credentials (API keys, OAuth secrets, tokens) are always redacted regardless of this setting. Default: false." + }, + { + "name": "DcvEnabled", + "description": "OPTIONAL: When true, the gateway will perform DNS-based Domain Control Validation (DCV) during enrollment for orders that require it, using the configured DNS provider plugin. Requires a DNS provider plugin (e.g. azure-azuredns-dnsplugin) to be deployed on the gateway. Default: false." + }, + { + "name": "DcvTxtRecordTemplate", + "description": "OPTIONAL: Format string for the DNS TXT record hostname used during DCV. {0} is replaced with the domain name being validated. Default: _emsign-validation.{0}" + }, + { + "name": "DcvPropagationDelaySeconds", + "description": "OPTIONAL: Seconds to wait after publishing the DNS TXT record before asking CERTInext to verify it. Increase for zones with slow propagation. Default: 30." + }, + { + "name": "DcvTimeoutMinutes", + "description": "OPTIONAL: Maximum minutes to wait for the entire DCV flow (DNS publish + propagation + verify) before timing out the enrollment. Can also be set via the CERTINEXT_DCV_TIMEOUT_MINUTES environment variable; the env var takes precedence when both are set. Default: 10." + }, + { + "name": "DcvWaitForChallengeSeconds", + "description": "OPTIONAL: How long (seconds) the plugin will wait inside Enroll() for CERTInext to expose the DCV challenge (i.e. populate `domainVerification` in TrackOrder). Under concurrent load CERTInext sometimes takes a few seconds after GenerateOrderSSL before the slot appears. Without this wait, the plugin's initial TrackOrder check sees null and skips DCV \u2014 the order then has to wait for the next gateway sync cycle to be picked up. Setting to 0 disables the wait (single-check behaviour). Can also be set via the CERTINEXT_DCV_WAIT_FOR_CHALLENGE_SECONDS environment variable; the env var takes precedence when both are set. Default: 60." + }, + { + "name": "DcvWaitForIssuanceSeconds", + "description": "OPTIONAL: How long (seconds) the plugin will wait inside Enroll() after DCV verifies for CERTInext to finish generating the certificate. CERTInext issuance is async \u2014 DCV may be verified but the cert PEM isn't yet available for download. Without this wait, Enroll() returns a pending result and the issued cert is picked up by the next sync cycle. Setting to 0 disables the wait (single-fetch behaviour). Can also be set via the CERTINEXT_DCV_WAIT_FOR_ISSUANCE_SECONDS environment variable; the env var takes precedence when both are set. Default: 60." + }, + { + "name": "DcvSyncMaxOrderAgeHours", + "description": "OPTIONAL: During synchronization, only pending DV orders younger than this many hours are eligible to be driven through DCV. This keeps a sync pass fast when there is a large backlog of old, never-completing pending orders (e.g. abandoned orders or domains outside the configured DNS provider's zone): they age out and are simply reported as pending rather than retried every pass. Recently-placed orders (the ones that legitimately deferred DCV) are always within the window and complete via the normal scan cadence. Set to 0 to disable the age filter (attempt DCV for all pending). Default: 24." + }, + { + "name": "DcvSyncMaxPerPass", + "description": "OPTIONAL: Maximum number of pending DV orders the plugin will attempt to drive through DCV in a single synchronization pass. Bounds the per-pass cost regardless of backlog size; remaining pending orders are reported as-is and picked up on a later pass (the per-minute incremental scan keeps recent orders moving). Set to 0 to disable the cap. Default: 50." + }, + { + "name": "UseV2Api", + "description": "OPTIONAL: When true, the plugin routes Enroll / GetSingleRecord / Revoke / Synchronize through the CERTInext V2 REST API (/api/certinext/v2/), including V2 /reports/orders for Synchronize. Requires ApiUrl (the V2 base URL in this mode) plus OAuthClientId and OAuthClientSecret. V1 credentials (ApiKey/AccountNumber/AuthMode) are not required when this is true. Default: false (V1 API)." + }, + { + "name": "V2SyncLookbackHours", + "description": "OPTIONAL (V2 mode only): during an incremental Synchronize, the plugin queries V2 /reports/orders with a 'from' date of (lastSync minus this many hours) rather than exactly lastSync, since live probing could not confirm whether the API's from/to filter brackets order-placement date or issuance date. A lookback window ensures an order created before lastSync but issued afterward (e.g. a slow DCV order) still surfaces on the next incremental pass. Ignored when UseV2Api is false. Default: 72." } ], "enrollment_config": [ @@ -137,7 +229,7 @@ }, { "name": "DomainName", - "description": "OPTIONAL: Primary domain for SSL/TLS orders. Derived from the CSR CN if omitted." + "description": "OPTIONAL: Primary domain for SSL/TLS orders (for V2 private-pki orders, the primary hostname). Derived from the CSR CN if omitted." }, { "name": "SignerName", @@ -154,4 +246,4 @@ ] } } -} \ No newline at end of file +} diff --git a/scripts/get-dcv.sh b/scripts/get-dcv.sh new file mode 100755 index 0000000..8b317d9 --- /dev/null +++ b/scripts/get-dcv.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Required env vars: ORDER_NUMBER, DOMAIN_NAME +# Optional: DCV_METHOD (default: 1 = DNS TXT record) +set -euo pipefail +. ~/.env_certinext +. "$(dirname "$0")/lib/certinext-auth.sh" + +ORDER_NUMBER="${ORDER_NUMBER:?Usage: ORDER_NUMBER= DOMAIN_NAME= [DCV_METHOD=1] scripts/get-dcv.sh}" +DOMAIN_NAME="${DOMAIN_NAME:?DOMAIN_NAME is required}" +DCV_METHOD="${DCV_METHOD:-1}" + +read -r ts txn authKey <<< "$(certinext_meta)" + +# SOC2 CC6.1: do NOT echo authKey — it is a valid single-use request authenticator. +echo "GetDcv orderNumber=$ORDER_NUMBER domainName=$DOMAIN_NAME dcvMethod=$DCV_METHOD ts=$ts txn=$txn" + +# SOX CC6.6: use jq --arg to safely interpolate all user-supplied values into the JSON body, +# preventing shell injection via specially crafted ORDER_NUMBER or DOMAIN_NAME values. +jq -n \ + --arg ver "1.0" \ + --arg ts "$ts" \ + --arg txn "$txn" \ + --arg acct "$CERTINEXT_ACCOUNT_NUMBER" \ + --arg authKey "$authKey" \ + --arg email "$CERTINEXT_REQUESTOR_EMAIL" \ + --arg order "$ORDER_NUMBER" \ + --arg domain "$DOMAIN_NAME" \ + --arg method "$DCV_METHOD" \ + '{ + meta: {ver: $ver, ts: $ts, txn: $txn, accountNumber: $acct, authKey: $authKey}, + dcvDetails: {requestorEmail: $email, orderNumber: $order, domainName: $domain, dcvMethod: $method} + }' \ +| curl -s -X POST "$CERTINEXT_API_URL/GetDcv" \ + -H "Content-Type: application/json" \ + --data-binary @- \ +| jq . diff --git a/scripts/lib/certinext-v2-auth.sh b/scripts/lib/certinext-v2-auth.sh new file mode 100755 index 0000000..38a33ce --- /dev/null +++ b/scripts/lib/certinext-v2-auth.sh @@ -0,0 +1,294 @@ +#!/usr/bin/env bash +# Shared helpers for the CERTInext V2 dev scripts in scripts/v2/. +# +# Source this from a scripts/v2/*.sh script; do not execute it directly: +# . "$(dirname "$0")/../lib/certinext-v2-auth.sh" +# +# Auth model (mirrors CERTInextClient.GetOrRefreshV2TokenAsync): +# POST {CERTINEXT_API_URL}/oauth/token +# Content-Type: application/x-www-form-urlencoded +# grant_type=client_credentials&client_id=...&client_secret=... +# -> {"access_token": "...", "token_type": "...", "expires_in": N} +# CERTINEXT_API_URL is the single V2 base URL (e.g. https://sandbox-us.certinext.io, +# no /emSignHub-API suffix). There is no V1 SHA256 authKey step any more. +# +# Credentials come from the V2 env file, default ~/.env_certinext_v2, overridable with +# CERTINEXT_V2_ENV_FILE. The file is PARSED (KEY=VALUE, '#' comments, optional +# surrounding quotes), never sourced, so it cannot run code or leak variables into the +# caller's shell. Like V2EnvHelper.LoadEnvFile in the integration tests, a value in the +# file wins over a same-named process env var (a shell that sourced the V1 +# ~/.env_certinext has a V1 CERTINEXT_API_URL exported). Process env is the fallback for +# keys the file doesn't define. +# +# Keys used: CERTINEXT_API_URL, CERTINEXT_CLIENT_ID, CERTINEXT_CLIENT_SECRET (required); +# CERTINEXT_REQUESTOR_NAME, CERTINEXT_REQUESTOR_EMAIL, CERTINEXT_REQUESTOR_MOBILE, +# CERTINEXT_SIGNER_IP (optional, order-body scripts only). +# +# Secret handling: the client secret and bearer token live only in unexported shell +# variables of the running script. They are handed to curl through process substitution +# (/dev/fd pipes), so they never appear in argv (ps), on disk, or on stdout/stderr. +# Do not add `set -x` to any script that sources this file. + +if [ -z "${BASH_VERSION:-}" ]; then + echo "ERROR: certinext-v2-auth.sh must be sourced from bash." >&2 + exit 1 +fi + +for _v2_tool in curl jq; do + if ! command -v "$_v2_tool" >/dev/null 2>&1; then + echo "ERROR: '$_v2_tool' is required but not installed." >&2 + exit 1 + fi +done +unset _v2_tool + +V2_ENV_FILE="${CERTINEXT_V2_ENV_FILE:-$HOME/.env_certinext_v2}" +V2_MUTATE=0 +V2_TOKEN="" + +# _v2_trim — strip leading/trailing whitespace (bash 3.2 compatible). +_v2_trim() { + local s=$1 + s="${s#"${s%%[![:space:]]*}"}" + s="${s%"${s##*[![:space:]]}"}" + printf '%s' "$s" +} + +# _v2_file_value — print KEY's value from $V2_ENV_FILE; return 1 if not defined. +# Last definition wins. Lines without '=' and '#' comments are ignored. +_v2_file_value() { + local want=$1 line key val found=1 out="" + [ -r "$V2_ENV_FILE" ] || return 1 + while IFS= read -r line || [ -n "$line" ]; do + line="${line%$'\r'}" + line=$(_v2_trim "$line") + case "$line" in ''|'#'*) continue ;; esac + case "$line" in *=*) ;; *) continue ;; esac + key=$(_v2_trim "${line%%=*}") + key="${key#export }" + key=$(_v2_trim "$key") + [ "$key" = "$want" ] || continue + val=$(_v2_trim "${line#*=}") + if [ "${#val}" -ge 2 ]; then + case "$val" in + \"*\") val="${val#\"}"; val="${val%\"}" ;; + \'*\') val="${val#\'}"; val="${val%\'}" ;; + esac + fi + out=$val + found=0 + done < "$V2_ENV_FILE" + [ "$found" -eq 0 ] && printf '%s' "$out" + return "$found" +} + +# v2_cfg [default] — value from the env file, else process env, else default. +v2_cfg() { + local key=$1 def=${2:-} val + if val=$(_v2_file_value "$key") && [ -n "$val" ]; then + printf '%s' "$val" + return 0 + fi + val="${!key:-}" + if [ -n "$val" ]; then printf '%s' "$val"; else printf '%s' "$def"; fi +} + +# v2_require — like v2_cfg but exits with a clear message when the key is missing. +v2_require() { + local key=$1 val + val=$(v2_cfg "$key") + if [ -z "$val" ]; then + echo "ERROR: required key $key is not set in $V2_ENV_FILE (or the environment)." >&2 + echo " Set CERTINEXT_V2_ENV_FILE to use a different env file." >&2 + exit 1 + fi + printf '%s' "$val" +} + +# v2_base_url — validated CERTINEXT_API_URL without a trailing slash. +# Refuses plain http except for localhost stubs, so the client secret is never sent in clear. +v2_base_url() { + local url + url=$(v2_require CERTINEXT_API_URL) || exit 1 + url="${url%/}" + case "$url" in + https://*) ;; + http://localhost|http://localhost:*|http://localhost/*|http://127.0.0.1|http://127.0.0.1:*|http://127.0.0.1/*) ;; + *) + echo "ERROR: CERTINEXT_API_URL must be an https:// URL (plain http is only allowed for localhost stubs)." >&2 + exit 1 ;; + esac + case "$url" in + *emSignHub-API*) + echo "ERROR: CERTINEXT_API_URL looks like a V1 URL (contains /emSignHub-API)." >&2 + echo " V2 needs the base URL only, e.g. https://sandbox-us.certinext.io" >&2 + exit 1 ;; + esac + printf '%s' "$url" +} + +# v2_parse_args "$@" — read-only scripts: accepts only -h/--help. +v2_parse_args() { + local a + for a in "$@"; do + case "$a" in + -h|--help) v2_usage; exit 0 ;; + *) echo "ERROR: unknown argument '$a' (this script is read-only)" >&2; v2_usage; exit 1 ;; + esac + done +} + +# v2_parse_mutating_args "$@" — state-changing scripts: --yes-mutate sets V2_MUTATE=1. +v2_parse_mutating_args() { + local a + for a in "$@"; do + case "$a" in + --yes-mutate) V2_MUTATE=1 ;; + -h|--help) v2_usage; exit 0 ;; + *) echo "ERROR: unknown argument '$a'" >&2; v2_usage; exit 1 ;; + esac + done +} + +# v2_require_var — exit with usage if the named script input env var is empty. +v2_require_var() { + local name=$1 + if [ -z "${!name:-}" ]; then + echo "ERROR: $name is required." >&2 + v2_usage + exit 1 + fi +} + +# v2_require_id — like v2_require_var, and the value must be a safe URL path segment. +v2_require_id() { + local name=$1 + v2_require_var "$name" + case "${!name}" in + *[!A-Za-z0-9_.-]*) + echo "ERROR: $name='${!name}' contains characters not allowed in a URL path segment." >&2 + exit 1 ;; + esac +} + +# v2_signer_ip [--offline] — CERTINEXT_SIGNER_IP, else auto-detect via api.ipify.org. +# --offline returns a placeholder instead of calling out (used for the dry-run preview). +v2_signer_ip() { + local ip + ip=$(v2_cfg CERTINEXT_SIGNER_IP) + if [ -n "$ip" ]; then printf '%s' "$ip"; return 0; fi + if [ "${1:-}" = "--offline" ]; then printf '%s' ""; return 0; fi + ip=$(curl -sS --max-time 10 https://api.ipify.org) || { + echo "ERROR: could not auto-detect signer IP; set CERTINEXT_SIGNER_IP." >&2 + exit 1 + } + printf '%s' "$ip" +} + +# Default usage; scripts redefine v2_usage after sourcing this file. +v2_usage() { echo "Usage: $(basename "$0")" >&2; } + +# v2_mutation_gate [body-json] — for state-changing scripts. +# Without --yes-mutate: print the planned request (no network calls, no token) and exit 3. +v2_mutation_gate() { + local method=$1 path=$2 body=${3:-} base + if [ "$V2_MUTATE" -eq 1 ]; then + return 0 + fi + base=$(v2_base_url) || exit 1 + { + echo "REFUSING TO RUN: this script changes state on the CERTInext account." + echo "It would send:" + echo " $method $base$path" + if [ -n "$body" ]; then + echo " body:" + printf '%s\n' "$body" | jq . 2>/dev/null | sed 's/^/ /' || printf ' %s\n' "$body" + fi + echo "Re-run with --yes-mutate to actually send it." + } >&2 + exit 3 +} + +# v2_uuid — random UUID for Idempotency-Key headers. +v2_uuid() { + if command -v uuidgen >/dev/null 2>&1; then + uuidgen | tr '[:upper:]' '[:lower:]' + else + python3 -c 'import uuid; print(uuid.uuid4())' + fi +} + +# v2_get_token — fetch an OAuth2 client_credentials token into V2_TOKEN (unexported). +# Never prints the token, the secret, or the raw token-endpoint response. +v2_get_token() { + local base client_id client_secret resp status body err + base=$(v2_base_url) || exit 1 + client_id=$(v2_require CERTINEXT_CLIENT_ID) || exit 1 + client_secret=$(v2_require CERTINEXT_CLIENT_SECRET) || exit 1 + + resp=$(curl -sS -X POST "$base/oauth/token" \ + -H "Accept: application/json" \ + --data-urlencode "grant_type=client_credentials" \ + --data-urlencode "client_id=$client_id" \ + --data-urlencode "client_secret@"<(printf '%s' "$client_secret") \ + -w $'\n%{http_code}') || { + echo "ERROR: V2 token request to $base/oauth/token failed (network/TLS error)." >&2 + exit 1 + } + client_secret="" + status="${resp##*$'\n'}" + body="${resp%$'\n'*}" + resp="" + + if [ "$status" != "200" ]; then + # Never echo the body: an error response could reflect submitted form fields. + err=$(printf '%s' "$body" | jq -r '.error // empty' 2>/dev/null || true) + case "$err" in + ''|*[!A-Za-z0-9_.-]*) err="" ;; # only print short OAuth2 error codes + esac + echo "ERROR: V2 token request failed: HTTP $status${err:+ ($err)} from $base/oauth/token (client_id=$client_id)." >&2 + case "$status" in + 401) echo " Hint: CERTINEXT_CLIENT_ID / CERTINEXT_CLIENT_SECRET is wrong, or the key was revoked." >&2 ;; + 403) echo " Hint: the access key exists but was not generated in OAuth mode in the portal." >&2 ;; + esac + exit 1 + fi + + V2_TOKEN=$(printf '%s' "$body" | jq -r '.access_token // empty' 2>/dev/null || true) + body="" + if [ -z "$V2_TOKEN" ]; then + echo "ERROR: V2 token response from $base/oauth/token did not contain access_token." >&2 + exit 1 + fi +} + +# v2_request [extra curl args...] — authenticated request to the V2 API. +# Fetches a token on first use. Prints "HTTP " to stderr and the response body +# (pretty-printed by jq when it is JSON) to stdout. Returns 1 on HTTP >= 400. +v2_request() { + local method=$1 path=$2 base resp status body + shift 2 + base=$(v2_base_url) || exit 1 + [ -n "$V2_TOKEN" ] || v2_get_token + + resp=$(curl -sS -X "$method" "$base$path" \ + -H @<(printf 'Authorization: Bearer %s\n' "$V2_TOKEN") \ + -H "Accept: application/json" \ + "$@" \ + -w $'\n%{http_code}') || { + echo "ERROR: $method $base$path failed (network/TLS error)." >&2 + return 1 + } + status="${resp##*$'\n'}" + body="${resp%$'\n'*}" + + echo "HTTP $status" >&2 + if [ -n "$body" ]; then + if printf '%s' "$body" | jq -e . >/dev/null 2>&1; then + printf '%s' "$body" | jq . + else + printf '%s\n' "$body" + fi + fi + [ "$status" -lt 400 ] 2>/dev/null +} diff --git a/scripts/lib/command-auth.sh b/scripts/lib/command-auth.sh new file mode 100755 index 0000000..d6bbecc --- /dev/null +++ b/scripts/lib/command-auth.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# Shared OAuth2 + REST helpers for Keyfactor Command / AnyCA REST Gateway +# *provisioning* scripts (scripts/register/*). +# +# This is distinct from certinext-auth.sh: that helper signs CERTInext API +# requests (SHA256 authKey). This one talks to Command and the gateway admin +# API using an OAuth2 client_credentials bearer token. +# +# Usage: +# . ~/.env_certinext +# . "$(dirname "$0")/lib/command-auth.sh" +# tok=$(gateway_token) +# gw_curl "$tok" GET /config/certificateprofile +# +# Required env (set in ~/.env_certinext or exported before sourcing): +# TOKEN_URL OAuth token endpoint (Authentik), e.g. +# https://auth.127.0.0.1.nip.io/application/o/token/ +# OIDC_CLIENT_ID client_credentials client id +# OIDC_CLIENT_SECRET client_credentials client secret +# GATEWAY_HOST gateway ingress host (no scheme) +# COMMAND_HOST Command ingress host (no scheme) +# Optional env (defaults shown): +# GATEWAY_SCHEME https +# GATEWAY_BASE_PATH /AnyGatewayREST (gateway admin API prefix) +# GATEWAY_SCOPE keyfactor-anyca-gateway +# COMMAND_SCHEME https +# CURL_INSECURE 1 (pass -k; set 0 to verify TLS) +# CONFIGURATION_TENANT certinext-caplugin + +GATEWAY_SCHEME="${GATEWAY_SCHEME:-https}" +# GATEWAY_BASE_PATH is the gateway *instance* mount path, NOT a fixed value. +# On a multi-tenant AnyCA REST Gateway each instance lives under its own path +# (e.g. /certinext-0). Discover it from the Portal/Swagger URL. The historical +# default /AnyGatewayREST only applies to single-instance gateways. +GATEWAY_BASE_PATH="${GATEWAY_BASE_PATH:-/AnyGatewayREST}" +GATEWAY_SCOPE="${GATEWAY_SCOPE:-keyfactor-anyca-gateway}" +COMMAND_SCHEME="${COMMAND_SCHEME:-https}" +# Command API base path. A Portal *session cookie* (COMMAND_COOKIE) only works +# against /KeyfactorProxy — the Portal's reverse proxy that injects the bearer +# token server-side. Direct bearer/OAuth auth uses /KeyfactorAPI. When unset, +# cmd_base() resolves it at call time from whether a cookie is set (so it works +# regardless of env-var ordering). Set COMMAND_BASE_PATH to force either path. +COMMAND_BASE_PATH="${COMMAND_BASE_PATH:-}" +CONFIGURATION_TENANT="${CONFIGURATION_TENANT:-certinext-caplugin}" +CURL_INSECURE="${CURL_INSECURE:-1}" + +_ca_require() { + local missing=0 v + for v in "$@"; do + if [ -z "${!v:-}" ]; then + echo "ERROR: required env var '$v' is not set" >&2 + missing=1 + fi + done + [ "$missing" -eq 0 ] || return 1 +} + +# Base curl flags shared by every call (bash 3.2 compatible — global array). +CA_CURL_OPTS=(-sS) +[ "$CURL_INSECURE" = "1" ] && CA_CURL_OPTS+=(-k) + +# oauth_token [scope] — fetch a client_credentials bearer token. +# Echoes the raw access_token. Exits non-zero (and prints the body) on failure. +oauth_token() { + _ca_require TOKEN_URL OIDC_CLIENT_ID OIDC_CLIENT_SECRET || return 1 + local scope="${1:-}" + local -a form=( + --data-urlencode "grant_type=client_credentials" + --data-urlencode "client_id=${OIDC_CLIENT_ID}" + --data-urlencode "client_secret=${OIDC_CLIENT_SECRET}" + ) + [ -n "$scope" ] && form+=(--data-urlencode "scope=${scope}") + + local resp tok + resp=$(curl "${CA_CURL_OPTS[@]}" -X POST "$TOKEN_URL" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + "${form[@]}") || { echo "ERROR: token request failed" >&2; return 1; } + tok=$(printf '%s' "$resp" | jq -r '.access_token // empty') + if [ -z "$tok" ]; then + echo "ERROR: no access_token in response:" >&2 + printf '%s\n' "$resp" >&2 + return 1 + fi + printf '%s' "$tok" +} + +# Auth resolution order (per side): +# 1. A browser-session cookie (GATEWAY_COOKIE / COMMAND_COOKIE) — paste the +# full `cookie:` header value from devtools (Copy as cURL) when the UI uses +# OIDC session cookies instead of bearer tokens. The *_token fns return +# empty in this mode; gw_curl/cmd_curl send the Cookie header instead. +# 2. An explicit pre-obtained bearer token (GATEWAY_TOKEN / COMMAND_TOKEN). +# 3. OAuth2 client_credentials via oauth_token (needs OIDC_CLIENT_* + TOKEN_URL). +gateway_token() { + if [ -n "${GATEWAY_COOKIE:-}" ]; then return 0; fi # cookie mode + if [ -n "${GATEWAY_TOKEN:-}" ]; then printf '%s' "$GATEWAY_TOKEN"; return 0; fi + oauth_token "$GATEWAY_SCOPE" +} +command_token() { + if [ -n "${COMMAND_COOKIE:-}" ]; then return 0; fi # cookie mode + if [ -n "${COMMAND_TOKEN:-}" ]; then printf '%s' "$COMMAND_TOKEN"; return 0; fi + oauth_token "" +} + +gw_base() { + _ca_require GATEWAY_HOST || return 1 + printf '%s://%s%s' "$GATEWAY_SCHEME" "$GATEWAY_HOST" "$GATEWAY_BASE_PATH" +} +cmd_base() { + _ca_require COMMAND_HOST || return 1 + local bp="$COMMAND_BASE_PATH" + if [ -z "$bp" ]; then + if [ -n "${COMMAND_COOKIE:-}" ]; then bp="/KeyfactorProxy"; else bp="/KeyfactorAPI"; fi + fi + printf '%s://%s%s' "$COMMAND_SCHEME" "$COMMAND_HOST" "$bp" +} + +# Display helpers for log headers: the base URL, or a clear "(unset)" note. +gw_show() { if [ -n "${GATEWAY_HOST:-}" ]; then gw_base; else printf '(GATEWAY_HOST unset)'; fi; } +cmd_show() { if [ -n "${COMMAND_HOST:-}" ]; then cmd_base; else printf '(COMMAND_HOST unset)'; fi; } + +# gw_curl [data] [extra curl args...] +# Hits the gateway admin API. is relative to GATEWAY_BASE_PATH +# (e.g. /config/certificateprofile). Echoes the response body. +gw_curl() { + local tok="$1" method="$2" path="$3" data="${4:-}"; shift; shift; shift + [ $# -gt 0 ] && shift || true + # In cookie mode, mimic the browser exactly (XMLHttpRequest + CSRF header). + local rw="APIClient" + [ -n "${GATEWAY_COOKIE:-}" ] && rw="XMLHttpRequest" + local -a args=("${CA_CURL_OPTS[@]}" -X "$method" "$(gw_base)$path" + -H "x-keyfactor-requested-with: $rw" + -H "Content-Type: application/json") + if [ -n "${GATEWAY_COOKIE:-}" ]; then + args+=(-H "Cookie: ${GATEWAY_COOKIE}" -H "x-requested-with: XMLHttpRequest") + fi + [ -n "$tok" ] && args+=(-H "Authorization: Bearer $tok") + [ -n "$data" ] && args+=(-d "$data") + args+=("$@") + curl "${args[@]}" +} + +# cmd_curl [data] [api-version] [extra curl args...] +# Hits the Command KeyfactorAPI. is relative to /KeyfactorAPI. +cmd_curl() { + local tok="$1" method="$2" path="$3" data="${4:-}" ver="${5:-1}" + shift; shift; shift + [ $# -gt 0 ] && shift || true + [ $# -gt 0 ] && shift || true + local rw="APIClient" + [ -n "${COMMAND_COOKIE:-}" ] && rw="XMLHttpRequest" + local -a args=("${CA_CURL_OPTS[@]}" -X "$method" "$(cmd_base)$path" + -H "x-keyfactor-api-version: $ver" + -H "x-keyfactor-requested-with: $rw" + -H "Content-Type: application/json") + if [ -n "${COMMAND_COOKIE:-}" ]; then + args+=(-H "Cookie: ${COMMAND_COOKIE}" -H "x-requested-with: XMLHttpRequest") + fi + [ -n "$tok" ] && args+=(-H "Authorization: Bearer $tok") + [ -n "$data" ] && args+=(-d "$data") + args+=("$@") + curl "${args[@]}" +} + +# manifest_product_ids [manifest-path] — emit product_ids one per line. +manifest_product_ids() { + local manifest="${1:-$REPO_ROOT/integration-manifest.json}" + jq -r '.about.carest.product_ids[]' "$manifest" +} diff --git a/scripts/register/00-register-all.sh b/scripts/register/00-register-all.sh new file mode 100755 index 0000000..1ae3de4 --- /dev/null +++ b/scripts/register/00-register-all.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Orchestrator — run the full gateway + Command registration in order. +# +# Each stage is an independent script and can be run on its own. This driver +# runs them in sequence, skipping any stage whose script does not yet exist +# (stages 02-06 are added incrementally) or whose SKIP_ flag is set to 1. +# +# make register +# SKIP_03=1 make register # skip claims +# DRY_RUN=1 make register # forwarded to every stage +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# stage number -> script basename +STAGES=( + "01:01-gateway-profiles.sh" + "02:02-gateway-ca-config.sh" + "03:03-gateway-claims.sh" + "04:04-command-register-ca.sh" + "05:05-command-import-templates.sh" + "06:06-command-enrollment-patterns.sh" +) + +for entry in "${STAGES[@]}"; do + num="${entry%%:*}" + script="${entry#*:}" + skip_var="SKIP_${num}" + path="$SCRIPT_DIR/$script" + + if [ "${!skip_var:-0}" = "1" ]; then + echo ">> stage $num ($script): SKIPPED (${skip_var}=1)" + continue + fi + if [ ! -x "$path" ]; then + echo ">> stage $num ($script): not yet implemented — skipping" + continue + fi + + echo ">> stage $num ($script): running" + "$path" + echo +done + +echo ">> registration complete" diff --git a/scripts/register/01-gateway-profiles.sh b/scripts/register/01-gateway-profiles.sh new file mode 100755 index 0000000..1d7b242 --- /dev/null +++ b/scripts/register/01-gateway-profiles.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Stage 01 — register AnyCA REST Gateway certificate profiles. +# +# Creates (or updates) one gateway certificate profile per CERTInext product, +# driven by .about.carest.product_ids in integration-manifest.json. Idempotent: +# existing profiles are PUT-updated, new ones are POSTed. +# +# Env: see scripts/lib/command-auth.sh for the OAuth/host contract. +# Optional: +# KEY_ALGS_JSON override the key_algs object (default: lab set below) +# MANIFEST path to integration-manifest.json (default: repo root) +# CHECK 1 = after applying, diff result vs the captured reference +# (docs/reference/gateway/certificate-profiles.json) +# DRY_RUN 1 = print intended actions, make no write calls +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +export REPO_ROOT + +# shellcheck disable=SC1090 +[ -f ~/.env_certinext ] && . ~/.env_certinext +# shellcheck source=../lib/command-auth.sh +. "$SCRIPT_DIR/../lib/command-auth.sh" + +MANIFEST="${MANIFEST:-$REPO_ROOT/integration-manifest.json}" +DRY_RUN="${DRY_RUN:-0}" +CHECK="${CHECK:-0}" + +# Lab default key algorithms — matches docs/reference/gateway/certificate-profiles.json. +DEFAULT_KEY_ALGS_JSON='{ + "rsa": { "bit_lengths": [2048, 3072, 4096, 6144, 8192] }, + "ecdsa": { "curves": ["1.2.840.10045.3.1.7", "1.3.132.0.34", "1.3.132.0.35"] }, + "ed25519": { "bit_lengths": [255] }, + "ed448": { "bit_lengths": [448] } +}' +KEY_ALGS_JSON="${KEY_ALGS_JSON:-$DEFAULT_KEY_ALGS_JSON}" + +if ! echo "$KEY_ALGS_JSON" | jq -e . >/dev/null 2>&1; then + echo "ERROR: KEY_ALGS_JSON is not valid JSON" >&2 + exit 1 +fi + +echo "== Stage 01: gateway certificate profiles ==" +echo " gateway : $(gw_show)" +echo " manifest: $MANIFEST" +[ "$DRY_RUN" = "1" ] && echo " DRY_RUN : no write calls will be made" + +PRODUCTS=() +while IFS= read -r _p; do + [ -n "$_p" ] && PRODUCTS+=("$_p") +done < <(manifest_product_ids "$MANIFEST") +[ "${#PRODUCTS[@]}" -gt 0 ] || { echo "ERROR: no product_ids in manifest" >&2; exit 1; } +echo " products: ${#PRODUCTS[@]}" + +if [ "$DRY_RUN" = "1" ]; then + # Fully offline preview: no token, no listing. + echo " (dry run) would upsert ${#PRODUCTS[@]} profiles with key_algs:" + echo "$KEY_ALGS_JSON" | jq -c . + for name in "${PRODUCTS[@]}"; do + printf ' [DRY ] %s\n' "$name" + done + echo "== done (dry run): no calls made ==" + exit 0 +fi + +TOK="$(gateway_token)" + +# Snapshot existing profiles once: name -> id. +EXISTING="$(gw_curl "$TOK" GET /config/certificateprofile)" +if ! echo "$EXISTING" | jq -e 'type == "array"' >/dev/null 2>&1; then + echo "ERROR: unexpected response listing certificate profiles:" >&2 + printf '%s\n' "$EXISTING" >&2 + exit 1 +fi + +created=0 updated=0 +for name in "${PRODUCTS[@]}"; do + existing_id="$(echo "$EXISTING" | jq -r --arg n "$name" \ + '.[] | select(.name == $n) | .id' | head -n1)" + + body="$(jq -n --arg name "$name" --argjson algs "$KEY_ALGS_JSON" \ + '{name: $name, key_algs: $algs}')" + + if [ -n "$existing_id" ] && [ "$existing_id" != "null" ]; then + body="$(echo "$body" | jq --argjson id "$existing_id" '. + {id: $id}')" + printf ' [PUT ] %-40s (id=%s)\n' "$name" "$existing_id" + if [ "$DRY_RUN" != "1" ]; then + resp="$(gw_curl "$TOK" PUT /config/certificateprofile "$body")" + echo "$resp" | jq -e 'has("error") or has("Message")' >/dev/null 2>&1 \ + && { echo " ! update failed: $resp" >&2; } + fi + updated=$((updated + 1)) + else + printf ' [POST] %-40s (new)\n' "$name" + if [ "$DRY_RUN" != "1" ]; then + resp="$(gw_curl "$TOK" POST /config/certificateprofile "$body")" + echo "$resp" | jq -e 'has("error") or has("Message")' >/dev/null 2>&1 \ + && { echo " ! create failed: $resp" >&2; } + fi + created=$((created + 1)) + fi +done + +echo "== done: $created created, $updated updated ==" + +if [ "$CHECK" = "1" ] && [ "$DRY_RUN" != "1" ]; then + ref="$REPO_ROOT/docs/reference/gateway/certificate-profiles.json" + echo "== CHECK: comparing live profile names vs $ref ==" + live_names="$(gw_curl "$TOK" GET /config/certificateprofile | jq -r '[.[].name] | sort')" + # Reference only captured DV/OV (no EV); compare on the set the reference covers. + ref_names="$(jq -r '[.[].name] | sort' "$ref")" + missing="$(jq -n --argjson live "$live_names" --argjson ref "$ref_names" \ + '$ref - $live')" + if [ "$(echo "$missing" | jq 'length')" -eq 0 ]; then + echo " OK: all reference profiles present on the gateway" + else + echo " MISSING reference profiles: $missing" >&2 + exit 1 + fi +fi diff --git a/scripts/register/02-gateway-ca-config.sh b/scripts/register/02-gateway-ca-config.sh new file mode 100755 index 0000000..869fe5c --- /dev/null +++ b/scripts/register/02-gateway-ca-config.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# Stage 02 — register the gateway CA configuration (CAConnection + Templates). +# +# PUTs /config/configuration on the AnyCA REST Gateway: the CERTInext plugin +# connection settings plus the Templates[] array mapping each product_id to its +# certificate profile (created in stage 01) and per-template enrollment params. +# +# STATUS: UNVERIFIED — body shapes built from kfc-in-a-box init-anygateway.sh +# and docs/reference/command/certificate-authority.json. Validate against a live +# gateway before relying on it. +# +# Env (in addition to the command-auth.sh contract): +# GATEWAY_LOGICAL_NAME CA name registered in Command (default: $CONFIGURATION_TENANT) +# GATEWAY_CERT_FILE PEM chain for GatewayRegistration (default: certinext-sandbox-chain.pem) +# CA_CONNECTION_JSON override the entire CAConnection object (advanced) +# TEMPLATE_PARAMS_JSON default per-template Parameters object (default: {}) +# FULL_SCAN_MINUTES default 720; INCR_SCAN_MINUTES default 5 +# DRY_RUN=1 print the body, make no write call +# +# CAConnection is assembled from the CERTINEXT_* env vars (same values the +# integration tests use), keyed by the plugin's ca_plugin_config field names. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +export REPO_ROOT + +# shellcheck disable=SC1090 +[ -f ~/.env_certinext ] && . ~/.env_certinext +# shellcheck source=../lib/command-auth.sh +. "$SCRIPT_DIR/../lib/command-auth.sh" + +MANIFEST="${MANIFEST:-$REPO_ROOT/integration-manifest.json}" +DRY_RUN="${DRY_RUN:-0}" +GATEWAY_LOGICAL_NAME="${GATEWAY_LOGICAL_NAME:-$CONFIGURATION_TENANT}" +GATEWAY_CERT_FILE="${GATEWAY_CERT_FILE:-$REPO_ROOT/certinext-sandbox-chain.pem}" +# NOTE: do not use ${VAR:-{}} — the first } closes the expansion, appending a +# stray } when VAR is set. Guard with an explicit empty check instead. +[ -n "${TEMPLATE_PARAMS_JSON:-}" ] || TEMPLATE_PARAMS_JSON='{}' +# Per-product CERTInext product code overrides, keyed by product_id, e.g. +# {"DV SSL":"842","OV SSL":"846"}. CERTInext numeric product codes are +# PER-ENVIRONMENT (the plugin's built-in defaults are PRODUCTION codes like +# 838; sandbox accounts use different codes). When a product_id has an entry +# here, Parameters.ProductCode is set so the gateway validates against a code +# that exists in the target account. Discover codes via GetProductDetails +# (scripts/get-product-details.sh). Products not listed fall back to defaults. +[ -n "${PRODUCT_CODE_MAP_JSON:-}" ] || PRODUCT_CODE_MAP_JSON='{}' +FULL_SCAN_MINUTES="${FULL_SCAN_MINUTES:-720}" +INCR_SCAN_MINUTES="${INCR_SCAN_MINUTES:-5}" + +echo "== Stage 02: gateway CA configuration ==" +echo " gateway : $(gw_show)" +echo " logical : $GATEWAY_LOGICAL_NAME" + +# --- CAConnection (CERTInext plugin settings) ------------------------------- +if [ -n "${CA_CONNECTION_JSON:-}" ]; then + CA_CONNECTION="$CA_CONNECTION_JSON" +else + CA_CONNECTION="$(jq -n \ + --arg apiUrl "${CERTINEXT_API_URL:-}" \ + --arg account "${CERTINEXT_ACCOUNT_NUMBER:-}" \ + --arg group "${CERTINEXT_GROUP_NUMBER:-}" \ + --arg org "${CERTINEXT_ORG_NUMBER:-}" \ + --arg authMode "${CERTINEXT_AUTH_MODE:-AccessKey}" \ + --arg apiKey "${CERTINEXT_ACCESS_KEY:-}" \ + --arg reqName "${CERTINEXT_REQUESTOR_NAME:-}" \ + --arg reqEmail "${CERTINEXT_REQUESTOR_EMAIL:-}" \ + --arg reqIsd "${CERTINEXT_REQUESTOR_ISD_CODE:-1}" \ + --arg reqMobile "${CERTINEXT_REQUESTOR_MOBILE:-}" \ + --arg signerPlace "${CERTINEXT_SIGNER_PLACE:-}" \ + --arg signerIp "${CERTINEXT_SIGNER_IP:-}" \ + '{ + ApiUrl: $apiUrl, + AccountNumber: $account, + GroupNumber: $group, + OrganizationNumber: $org, + AuthMode: $authMode, + ApiKey: $apiKey, + RequestorName: $reqName, + RequestorEmail: $reqEmail, + RequestorIsdCode: $reqIsd, + RequestorMobileNumber: $reqMobile, + SignerPlace: $signerPlace, + SignerIp: $signerIp, + Enabled: true + } | with_entries(select(.value != ""))')" +fi + +# --- GatewayRegistration cert ------------------------------------------------ +GATEWAY_CERT_BLOCK='{}' +if [ -f "$GATEWAY_CERT_FILE" ]; then + pem="$(cat "$GATEWAY_CERT_FILE")" + GATEWAY_CERT_BLOCK="$(jq -n --arg pem "$pem" \ + '{Source: "FileUpload", ImportedCertificate: $pem}')" +else + echo " warn: GATEWAY_CERT_FILE not found ($GATEWAY_CERT_FILE) — sending empty cert block" >&2 +fi + +# --- Templates[] (one per product_id) --------------------------------------- +TEMPLATES="$(manifest_product_ids "$MANIFEST" | jq -R . | jq -s \ + --argjson params "$TEMPLATE_PARAMS_JSON" \ + --argjson codes "$PRODUCT_CODE_MAP_JSON" \ + '[.[] | . as $p + | {ProductID: $p, CertificateProfile: $p, + Parameters: ($params + (if $codes[$p] then {ProductCode: $codes[$p]} else {} end))}]')" + +# --- Assemble configuration body -------------------------------------------- +BODY="$(jq -n \ + --argjson caconn "$CA_CONNECTION" \ + --arg logical "$GATEWAY_LOGICAL_NAME" \ + --argjson cert "$GATEWAY_CERT_BLOCK" \ + --argjson full "$FULL_SCAN_MINUTES" \ + --argjson incr "$INCR_SCAN_MINUTES" \ + --argjson templates "$TEMPLATES" \ + '{ + CAConnection: $caconn, + GatewayRegistration: { LogicalName: $logical, GatewayCertificate: $cert }, + ServiceSettings: { + FullScan: { Interval: { Minutes: $full } }, + IncrementalScan: { Interval: { Minutes: $incr } } + }, + Templates: $templates + }')" + +if [ "$DRY_RUN" = "1" ]; then + echo " (dry run) configuration body (ApiKey redacted):" + echo "$BODY" | jq '(.CAConnection.ApiKey) |= (if . then "***" else . end)' + echo "== done (dry run): no calls made ==" + exit 0 +fi + +TOK="$(gateway_token)" +resp="$(gw_curl "$TOK" PUT /config/configuration "$BODY")" +echo "$resp" | jq -e 'has("error") or has("Message")' >/dev/null 2>&1 \ + && { echo " ! configuration PUT failed: $resp" >&2; exit 1; } +echo "== done: configuration applied for $GATEWAY_LOGICAL_NAME ==" diff --git a/scripts/register/03-gateway-claims.sh b/scripts/register/03-gateway-claims.sh new file mode 100755 index 0000000..eccfa66 --- /dev/null +++ b/scripts/register/03-gateway-claims.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Stage 03 — register gateway access claims (IAM). +# +# POSTs /config/claim for each entry, mapping an OAuth subject to a gateway role. +# Idempotent: claims already present (matched on type+value+role) are skipped. +# +# STATUS: UNVERIFIED — shape from docs/reference/gateway/claims.json and +# kfc-in-a-box init-anygateway.sh. Validate against a live gateway. +# +# Env: +# CLAIMS_JSON JSON array of claim objects to ensure. Default mirrors the +# captured reference: the machine client (admin+user) and the +# human admin (akadmin). Each object: +# {type, value, role, provider, description} +# DRY_RUN=1 print intended actions, no writes +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +export REPO_ROOT + +# shellcheck disable=SC1090 +[ -f ~/.env_certinext ] && . ~/.env_certinext +# shellcheck source=../lib/command-auth.sh +. "$SCRIPT_DIR/../lib/command-auth.sh" + +DRY_RUN="${DRY_RUN:-0}" + +# OIDC client id drives the machine-client subject (ak-_credentials). +_machine_sub="${CLAIM_MACHINE_SUBJECT:-ak-${OIDC_CLIENT_ID:-anygateway-gateway-certinext-client}_credentials}" +_admin_user="${CLAIM_ADMIN_USER:-akadmin}" +_provider="${CLAIM_PROVIDER:-Authentik}" + +DEFAULT_CLAIMS_JSON="$(jq -n \ + --arg msub "$_machine_sub" --arg admin "$_admin_user" --arg prov "$_provider" \ + '[ + {type:"OAuth_sub", value:$msub, role:"admin", provider:$prov, description:"Authentik machine client"}, + {type:"OAuth_sub", value:$msub, role:"user", provider:$prov, description:"Authentik machine client"}, + {type:"OAuth_sub", value:$admin, role:"admin", provider:$prov, description:"Authentik admin user"} + ]')" +CLAIMS_JSON="${CLAIMS_JSON:-$DEFAULT_CLAIMS_JSON}" + +echo "== Stage 03: gateway claims ==" +echo " gateway : $(gw_show)" + +count="$(echo "$CLAIMS_JSON" | jq 'length')" +echo " claims : $count" + +if [ "$DRY_RUN" = "1" ]; then + echo "$CLAIMS_JSON" | jq -c '.[] | {type, value, role}' + echo "== done (dry run): no calls made ==" + exit 0 +fi + +TOK="$(gateway_token)" +EXISTING="$(gw_curl "$TOK" GET /config/claim)" + +added=0 skipped=0 +n=0 +while [ "$n" -lt "$count" ]; do + claim="$(echo "$CLAIMS_JSON" | jq -c ".[$n]")" + n=$((n + 1)) + t="$(echo "$claim" | jq -r .type)" + v="$(echo "$claim" | jq -r .value)" + r="$(echo "$claim" | jq -r .role)" + present="$(echo "$EXISTING" | jq --arg t "$t" --arg v "$v" --arg r "$r" \ + 'map(select(.type==$t and .value==$v and .role==$r)) | length' 2>/dev/null || echo 0)" + if [ "${present:-0}" != "0" ]; then + printf ' [skip] %s / %s\n' "$r" "$v" + skipped=$((skipped + 1)) + continue + fi + printf ' [POST] %s / %s\n' "$r" "$v" + resp="$(gw_curl "$TOK" POST /config/claim "$claim")" + echo "$resp" | jq -e 'has("error") or has("Message")' >/dev/null 2>&1 \ + && echo " ! failed: $resp" >&2 + added=$((added + 1)) +done + +echo "== done: $added added, $skipped already present ==" diff --git a/scripts/register/04-command-register-ca.sh b/scripts/register/04-command-register-ca.sh new file mode 100755 index 0000000..48c3bd0 --- /dev/null +++ b/scripts/register/04-command-register-ca.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Stage 04 — register the CA (gateway connector) in Keyfactor Command. +# +# Creates the Certificate Authority record that points Command at the gateway +# tenant, so templates can be imported (stage 05) and used for enrollment. +# Idempotent: looks up by LogicalName first and skips if it already exists. +# +# STATUS: UNVERIFIED — body modeled on docs/reference/command/certificate-authority.json. +# Command CA POST shapes are version-sensitive; validate against your Command. +# +# Env (in addition to the command-auth.sh contract): +# CA_LOGICAL_NAME default: $CONFIGURATION_TENANT +# CA_HOSTNAME gateway tenant URL Command connects to. Default derived: +# https://$GATEWAY_HOST$GATEWAY_BASE_PATH/ejbca +# CA_BODY_JSON override the entire request body (advanced) +# FULL_SCAN_MINUTES default 720; INCR_SCAN_MINUTES default 5 +# DRY_RUN=1 print the body, make no write call +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +export REPO_ROOT + +# shellcheck disable=SC1090 +[ -f ~/.env_certinext ] && . ~/.env_certinext +# shellcheck source=../lib/command-auth.sh +. "$SCRIPT_DIR/../lib/command-auth.sh" + +DRY_RUN="${DRY_RUN:-0}" +CA_LOGICAL_NAME="${CA_LOGICAL_NAME:-$CONFIGURATION_TENANT}" +CA_HOSTNAME="${CA_HOSTNAME:-${GATEWAY_HOST:+$(gw_base)/ejbca}}" +FULL_SCAN_MINUTES="${FULL_SCAN_MINUTES:-720}" +INCR_SCAN_MINUTES="${INCR_SCAN_MINUTES:-5}" + +echo "== Stage 04: Command CA registration ==" +echo " command : $(cmd_show)" +echo " logical : $CA_LOGICAL_NAME" +echo " host : ${CA_HOSTNAME:-(unset)}" + +if [ -n "${CA_BODY_JSON:-}" ]; then + BODY="$CA_BODY_JSON" +else + BODY="$(jq -n \ + --arg logical "$CA_LOGICAL_NAME" \ + --arg tenant "$CONFIGURATION_TENANT" \ + --arg host "$CA_HOSTNAME" \ + --arg clientId "${OIDC_CLIENT_ID:-}" \ + --arg clientSecret "${OIDC_CLIENT_SECRET:-}" \ + --arg tokenUrl "${TOKEN_URL:-}" \ + --arg scope "$GATEWAY_SCOPE" \ + --argjson full "$FULL_SCAN_MINUTES" \ + --argjson incr "$INCR_SCAN_MINUTES" \ + '{ + LogicalName: $logical, + ConfigurationTenant: $tenant, + ForestRoot: $tenant, + HostName: $host, + CAType: 1, + ClientId: $clientId, + ClientSecret: { SecretValue: $clientSecret }, + TokenURL: $tokenUrl, + Scope: $scope, + UseForEnrollment: true, + UseCAConnector: false, + KeyRetention: 1, + AllowOneClickRenewals: true, + AllowedEnrollmentTypes: 3, + NewEndEntityOnRenewAndReissue: true, + FullScan: { Interval: { Minutes: $full } }, + IncrementalScan: { Interval: { Minutes: $incr } } + }')" +fi + +if [ "$DRY_RUN" = "1" ]; then + echo " (dry run) CA body (secret redacted):" + echo "$BODY" | jq '(.ClientSecret.SecretValue) |= (if . then "***" else . end)' + echo "== done (dry run): no calls made ==" + exit 0 +fi + +TOK="$(command_token)" + +# Idempotency: skip if a CA with this LogicalName already exists. +EXISTING="$(cmd_curl "$TOK" GET /CertificateAuthority "" 1)" +present="$(echo "$EXISTING" | jq --arg n "$CA_LOGICAL_NAME" \ + 'map(select(.LogicalName==$n)) | length' 2>/dev/null || echo 0)" +if [ "${present:-0}" != "0" ]; then + echo "== CA '$CA_LOGICAL_NAME' already registered — skipping ==" + exit 0 +fi + +resp="$(cmd_curl "$TOK" POST /CertificateAuthority "$BODY" 1)" +echo "$resp" | jq -e 'has("Id")' >/dev/null 2>&1 \ + || { echo " ! CA registration may have failed: $resp" >&2; exit 1; } +echo "== done: CA registered (Id=$(echo "$resp" | jq -r .Id)) ==" diff --git a/scripts/register/05-command-import-templates.sh b/scripts/register/05-command-import-templates.sh new file mode 100755 index 0000000..43e165b --- /dev/null +++ b/scripts/register/05-command-import-templates.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Stage 05 — import gateway templates into Keyfactor Command. +# +# POSTs /Templates/Import for the configured ConfigurationTenant, pulling the +# gateway's product/profile set into Command as AnyCA_ templates. +# (Confirmed working for this tenant by docs/reference/command/templates-certinext.json.) +# +# Env (in addition to the command-auth.sh contract): +# CONFIGURATION_TENANT default certinext-caplugin +# CHECK=1 after import, list templates for the tenant +# DRY_RUN=1 print intended call, no writes +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +export REPO_ROOT + +# shellcheck disable=SC1090 +[ -f ~/.env_certinext ] && . ~/.env_certinext +# shellcheck source=../lib/command-auth.sh +. "$SCRIPT_DIR/../lib/command-auth.sh" + +DRY_RUN="${DRY_RUN:-0}" +CHECK="${CHECK:-0}" + +echo "== Stage 05: Command template import ==" +echo " command : $(cmd_show)" +echo " tenant : $CONFIGURATION_TENANT" + +BODY="$(jq -n --arg t "$CONFIGURATION_TENANT" '{ConfigurationTenant: $t}')" + +if [ "$DRY_RUN" = "1" ]; then + echo " (dry run) POST /Templates/Import $BODY" + echo "== done (dry run): no calls made ==" + exit 0 +fi + +TOK="$(command_token)" +resp="$(cmd_curl "$TOK" POST /Templates/Import "$BODY" 1)" +echo " response: $resp" + +if [ "$CHECK" = "1" ]; then + echo "== CHECK: templates for tenant $CONFIGURATION_TENANT ==" + cmd_curl "$TOK" GET /Templates "" 1 \ + | jq -r --arg t "$CONFIGURATION_TENANT" \ + '.[] | select(.ConfigurationTenant==$t) | " - \(.CommonName)"' +fi +echo "== done: import requested for $CONFIGURATION_TENANT ==" diff --git a/scripts/register/06-command-enrollment-patterns.sh b/scripts/register/06-command-enrollment-patterns.sh new file mode 100755 index 0000000..55e45f1 --- /dev/null +++ b/scripts/register/06-command-enrollment-patterns.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Stage 06 — enrollment patterns + template key-retention in Keyfactor Command. +# +# For each imported AnyCA template (ConfigurationTenant = CONFIGURATION_TENANT): +# (a) ensure an enrollment pattern exists and allows enrollment, and +# (b) set the template's private-key retention. +# +# VERIFIED against Command (Portal-proxy /KeyfactorProxy, API v1) on 2026-06-09. +# Schema gotchas baked in from that run — see scripts/register/README.md: +# - EnrollmentPatterns POST: `Template` is an INTEGER (not {Id:..}); +# `AllowedEnrollmentTypes` is PLURAL (singular is silently ignored -> 0); +# `Policies` is REQUIRED ({} is accepted); `TemplateDefault` must be true +# for the template's default pattern; `AssociatedRoles` are role NAME +# strings that must already exist (this instance has "Command Admin", +# NOT "InstanceAdmin"). +# - Update is PUT /EnrollmentPatterns/{id} (collection PUT returns 405). +# - Template retention: PUT /Templates with a partial {Id,KeyRetention, +# KeyRetentionDays} body (other fields are preserved). +# +# Env (in addition to the command-auth.sh contract): +# CONFIGURATION_TENANT template tenant to operate on (= gateway instance +# name, e.g. "certinext-0"). REQUIRED to match anything. +# ENROLL_ROLE role name granted on each pattern (default "Command Admin") +# ENROLL_TYPES AllowedEnrollmentTypes bitmask (default 3 = CSR+PFX) +# PATTERN_PREFIX name prefix for patterns (default "" -> use DisplayName) +# TEMPLATE_KEY_RETENTION KeyRetention value (default "Indefinite"; e.g. "None","Days") +# TEMPLATE_KEY_RETENTION_DAYS default 0 (used when retention is "Days") +# SKIP_PATTERNS=1 only do template retention +# SKIP_FIXUPS=1 only do enrollment patterns +# DRY_RUN=1 print intended actions, no writes +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +export REPO_ROOT + +# shellcheck disable=SC1090 +[ -f ~/.env_certinext ] && . ~/.env_certinext +# shellcheck source=../lib/command-auth.sh +. "$SCRIPT_DIR/../lib/command-auth.sh" + +DRY_RUN="${DRY_RUN:-0}" +ENROLL_ROLE="${ENROLL_ROLE:-Command Admin}" +ENROLL_TYPES="${ENROLL_TYPES:-3}" +PATTERN_PREFIX="${PATTERN_PREFIX:-}" +TEMPLATE_KEY_RETENTION="${TEMPLATE_KEY_RETENTION:-Indefinite}" +TEMPLATE_KEY_RETENTION_DAYS="${TEMPLATE_KEY_RETENTION_DAYS:-0}" + +echo "== Stage 06: enrollment patterns + template key-retention ==" +echo " command : $(cmd_show)" +echo " tenant : $CONFIGURATION_TENANT role: $ENROLL_ROLE types: $ENROLL_TYPES" +echo " keyret : $TEMPLATE_KEY_RETENTION (days=$TEMPLATE_KEY_RETENTION_DAYS)" + +if [ "$DRY_RUN" = "1" ]; then + echo " (dry run) for each template in tenant '$CONFIGURATION_TENANT':" + [ "${SKIP_PATTERNS:-0}" = "1" ] || echo " - ensure enrollment pattern '${PATTERN_PREFIX}' (role $ENROLL_ROLE, types $ENROLL_TYPES)" + [ "${SKIP_FIXUPS:-0}" = "1" ] || echo " - PUT /Templates KeyRetention=$TEMPLATE_KEY_RETENTION" + echo "== done (dry run): no calls made ==" + exit 0 +fi + +TOK="$(command_token)" + +# Templates for this tenant (zsh-safe: drive loops via while-read, not word-split). +TEMPLATES="$(cmd_curl "$TOK" GET "/Templates?ReturnLimit=500" "" 1 \ + | jq --arg t "$CONFIGURATION_TENANT" '[.[] | select(.ConfigurationTenant==$t)]')" +tcount="$(echo "$TEMPLATES" | jq 'length')" +echo " templates: $tcount" +if [ "$tcount" -eq 0 ]; then + echo " nothing to do — no templates in tenant '$CONFIGURATION_TENANT'." >&2 + echo " (set CONFIGURATION_TENANT to the gateway instance name; run stage 05 first.)" >&2 + exit 0 +fi + +# --- (a) enrollment patterns ------------------------------------------------- +if [ "${SKIP_PATTERNS:-0}" != "1" ]; then + EXISTING="$(cmd_curl "$TOK" GET /EnrollmentPatterns "" 1)" + echo "$TEMPLATES" | jq -c '.[]' | while IFS= read -r tmpl; do + tid="$(echo "$tmpl" | jq -r .Id)" + disp="$(echo "$tmpl" | jq -r '.DisplayName // .CommonName')" + pname="${PATTERN_PREFIX}${disp}" + body="$(jq -n --arg n "$pname" --argjson t "$tid" --argjson types "$ENROLL_TYPES" \ + --arg role "$ENROLL_ROLE" \ + '{Name:$n, Template:$t, AllowedEnrollmentTypes:$types, TemplateDefault:true, + AssociatedRoles:[$role], Policies:{}}')" + pid="$(echo "$EXISTING" | jq -r --arg n "$pname" \ + 'map(select(.Name==$n)) | (.[0].Id // empty)')" + if [ -n "$pid" ]; then + body="$(echo "$body" | jq --argjson id "$pid" '. + {Id:$id}')" + resp="$(cmd_curl "$TOK" PUT "/EnrollmentPatterns/$pid" "$body" 1)" + verb="PUT id=$pid" + else + resp="$(cmd_curl "$TOK" POST /EnrollmentPatterns "$body" 1)" + verb="POST" + fi + ok="$(echo "$resp" | jq -r 'if .Id then "AllowedEnrollmentTypes=\(.AllowedEnrollmentTypes)" else "ERR: \(.Message//.)" end')" + printf ' [pattern %-9s] %-44s %s\n' "$verb" "$pname" "$ok" + done +fi + +# --- (b) template key-retention --------------------------------------------- +if [ "${SKIP_FIXUPS:-0}" != "1" ]; then + echo "$TEMPLATES" | jq -c '.[]' | while IFS= read -r tmpl; do + tid="$(echo "$tmpl" | jq -r .Id)" + cn="$(echo "$tmpl" | jq -r .CommonName)" + body="$(jq -n --argjson id "$tid" --arg kr "$TEMPLATE_KEY_RETENTION" \ + --argjson days "$TEMPLATE_KEY_RETENTION_DAYS" \ + '{Id:$id, KeyRetention:$kr, KeyRetentionDays:$days}')" + resp="$(cmd_curl "$TOK" PUT /Templates "$body" 1)" + kr="$(echo "$resp" | jq -r '.KeyRetention // ("ERR: "+(.Message//"?"))')" + printf ' [template PUT] %-44s KeyRetention=%s\n' "$cn" "$kr" + done +fi + +echo "== done ==" diff --git a/scripts/register/README.md b/scripts/register/README.md new file mode 100644 index 0000000..a2d31dd --- /dev/null +++ b/scripts/register/README.md @@ -0,0 +1,171 @@ +# CERTInext gateway/Command registration scripts + +Provision the CERTInext AnyCA REST Gateway plugin into the **AnyCA REST Gateway** +and **Keyfactor Command**: gateway certificate profiles, the gateway CA +configuration, Command template import, enrollment patterns, and template +key-retention. Driven by `integration-manifest.json` (`.about.carest.product_ids`) +so it stays in sync with the plugin's products. + +These scripts talk to **Command and the gateway admin API** — *not* the CERTInext +vendor API. Shared auth/host logic lives in [`../lib/command-auth.sh`](../lib/command-auth.sh). + +## Stages + +| Stage | Script | `make` target | Side | Notes | +|------:|--------|---------------|------|-------| +| 01 | `01-gateway-profiles.sh` | `register-profiles` | Gateway | one cert profile per product. **Verified.** | +| 02 | `02-gateway-ca-config.sh` | `register-ca-config` | Gateway | CAConnection + Templates[]. ⚠️ touches CA config — opt-in. | +| 03 | `03-gateway-claims.sh` | `register-claims` | Gateway | OAuth claim→role mappings. Unverified. | +| 04 | `04-command-register-ca.sh` | `register-command-ca` | Command | registers the CA. ⚠️ **CA config — leave alone unless asked.** | +| 05 | `05-command-import-templates.sh` | `register-import` | Command | `POST /Templates/Import`. | +| 06 | `06-command-enrollment-patterns.sh` | `register-enrollment` | Command | enrollment patterns + template key-retention. **Verified.** | +| — | `00-register-all.sh` | `register` | both | runs 01→06; skips missing stages and `SKIP_NN=1`. | + +Every stage: idempotent (GET→POST/PUT), supports `DRY_RUN=1` (offline preview), +and reads `~/.env_certinext` + the env contract below. + +> ⚠️ **Do not modify the CA configuration** (stage 04, and stage 02's CA-connection +> PUT) unless explicitly asked — it is fragile and easily broken. Profiles, +> template import, enrollment patterns, and key-retention are safe to re-run. + +## Authentication + +Three ways to authenticate, resolved per side (gateway vs Command) in this order: + +1. **Session cookie** — `GATEWAY_COOKIE` / `COMMAND_COOKIE`. Paste the full + `cookie:` header value from your browser devtools (Copy-as-cURL) into a file: + ```sh + pbpaste > ~/.certinext_kfcportal_cookie # re-copy the cookie in devtools first + chmod 600 ~/.certinext_kfcportal_cookie + export COMMAND_COOKIE="$(tr -d '\r\n' < ~/.certinext_kfcportal_cookie)" + ``` + The `tr -d` strips the trailing newline (a newline in the header → silent 401). +2. **Bearer token** — `GATEWAY_TOKEN` / `COMMAND_TOKEN` (e.g. copied from an API + request's `authorization: Bearer` header). +3. **OAuth2 client_credentials** — `TOKEN_URL` + `OIDC_CLIENT_ID` + + `OIDC_CLIENT_SECRET` (gateway uses scope `keyfactor-anyca-gateway`). + +### Auth gotchas learned the hard way + +- **The gateway authenticates its admin API with the session cookie directly.** + **Command does not** — a `KeyfactorOIDC*` cookie only works against + **`/KeyfactorProxy`** (the Portal's reverse proxy that injects the bearer), + *not* `/KeyfactorAPI` (which returns 401 for a cookie). The lib auto-selects + `COMMAND_BASE_PATH=/KeyfactorProxy` whenever `COMMAND_COOKIE` is set. +- Cookie mode sends the browser's CSRF headers (`x-requested-with: XMLHttpRequest`) + automatically. +- Tokens/cookies are short-lived; a `401` mid-run usually just means re-grab. + +## Environment contract + +| Var | Used by | Notes | +|-----|---------|-------| +| `GATEWAY_HOST` | gateway stages | host only, no scheme | +| `GATEWAY_BASE_PATH` | gateway stages | **the gateway instance mount path** — e.g. `/certinext-0`, *not* `/AnyGatewayREST` on a multi-instance gateway. Find it in the Portal/Swagger URL. | +| `GATEWAY_COOKIE` / `GATEWAY_TOKEN` | gateway stages | see Authentication | +| `COMMAND_HOST` | command stages | host only | +| `COMMAND_BASE_PATH` | command stages | auto: `/KeyfactorProxy` if cookie, else `/KeyfactorAPI` | +| `COMMAND_COOKIE` / `COMMAND_TOKEN` | command stages | see Authentication | +| `CONFIGURATION_TENANT` | stages 04–06 | **= the gateway instance name** (e.g. `certinext-0`), which is also the templates' `ConfigurationTenant` in Command. Not the plugin name. | +| `CURL_INSECURE` | all | `1` (default) passes `-k`; set `0` to verify TLS | + +## Quick start + +The **typical** path is OAuth2 client_credentials against `/KeyfactorAPI`: + +```sh +export GATEWAY_HOST= COMMAND_HOST= +export TOKEN_URL=https:///application/o/token/ +export OIDC_CLIENT_ID=... OIDC_CLIENT_SECRET=... +make register-profiles # client_creds used automatically (no cookie/token set) +``` + +> **Cookie auth (e.g. the "HV3" lab, intdev01.lab.kfpki.com)** — used when ops +> can't issue client credentials. This is environment-specific, NOT the norm: +> the gateway instance path is `/certinext-0` (not `/AnyGatewayREST`), and a +> Command Portal cookie only works via `/KeyfactorProxy` (auto-selected when +> `COMMAND_COOKIE` is set). See the deployment's own notes for its values. +> +> ```sh +> # gateway side +> export GATEWAY_HOST=intdev01.lab.kfpki.com GATEWAY_BASE_PATH=/certinext-0 +> export GATEWAY_COOKIE="$(tr -d '\r\n' < ~/.certinext_gw_cookie)" +> make register-profiles # CHECK=1 to verify, DRY_RUN=1 to preview +> +> # command side (after templates imported) +> export COMMAND_HOST=intdev01.lab.kfpki.com CONFIGURATION_TENANT=certinext-0 +> export COMMAND_COOKIE="$(tr -d '\r\n' < ~/.certinext_kfcportal_cookie)" +> make register-enrollment # stage 06: patterns + KeyRetention=Indefinite +> ``` + +Per-stage env knobs are documented in each script's header comment. + +## Stage 02 — gateway CA config (verified 2026-06-09) + +The gateway CA config (`PUT //config/configuration`) is what maps each +product to a certificate profile so enrollment can resolve a CA. Two things bite: + +- **Product codes are per-environment.** The plugin's built-in `DefaultProductCodes` + are PRODUCTION codes (e.g. `DV SSL` → `838`). A sandbox account has different + numeric codes (e.g. `842`–`851`) and the gateway validates them at PUT time — + you'll get `Profile '838' was not found in CERTInext. Available profiles: …`. + Set `PRODUCT_CODE_MAP_JSON` (product_id → code) so each `Templates[].Parameters` + carries the right `ProductCode`. Discover codes via `scripts/get-product-details.sh`. + Product **IDs/names** are stable across environments; only the numeric codes differ. +- **`SignerPlace` is required by CERTInext** for every order. It has no fallback + (unlike `SignerIp`, which defaults to `127.0.0.1`). If it's absent the order + fails with a generic `certificate request failed … see CA logs`. Provide it via + `CERTINEXT_SIGNER_PLACE` (the test fixture uses `"Gateway"`); the stage assembles + it into `CAConnection`. +- The gateway has **no GET** for `/config/configuration` (405, POST/PUT only) — it's + not introspectable, so a PUT sends the FULL object. Stage 02 rebuilds `CAConnection` + from the `CERTINEXT_*` env vars; make sure those match the account the CA uses, or + you'll change the live connection. (A successful PUT means the creds validated.) + +```sh +export GATEWAY_LOGICAL_NAME=CertiNext # the live CA's LogicalName +export CERTINEXT_SIGNER_PLACE=Gateway +export PRODUCT_CODE_MAP_JSON='{"DV SSL":"842","OV SSL":"846", ...}' +make register-ca-config +``` + +## Stage 06 — Command EnrollmentPatterns schema (verified 2026-06-09) + +The `/KeyfactorProxy/EnrollmentPatterns` (API v1) POST body that works — the stub +originally got every one of these wrong: + +```json +{ + "Name": "AnyCA (DV SSL)", + "Template": 1, // INTEGER, not {"Id":1} + "AllowedEnrollmentTypes": 3, // PLURAL (singular is ignored → 0 = no enroll). 3 = CSR+PFX + "TemplateDefault": true, // required for a template's default pattern + "AssociatedRoles": ["Command Admin"],// role NAME strings that must already exist + "Policies": {} // REQUIRED; empty object is accepted +} +``` + +- **Update** an existing pattern with `PUT /EnrollmentPatterns/{id}` (collection + `PUT` returns **405**). +- Role names are instance-specific — this Command has **`Command Admin`**, not + `InstanceAdmin`. Check `GET /Security/Roles` and set `ENROLL_ROLE` accordingly. + +### Template key-retention + +`PUT /Templates` with a **partial** body — other fields are preserved: + +```json +{ "Id": 1, "KeyRetention": "Indefinite", "KeyRetentionDays": 0 } +``` + +Set via `TEMPLATE_KEY_RETENTION` (default `Indefinite`). Imported templates +default to `None`, so this is needed to retain private keys. + +## Environment notes for whoever runs this + +- **macOS ships bash 3.2** and the default shell is often **zsh**. The scripts use + `#!/usr/bin/env bash` and avoid bash-4 features (`mapfile`) + zsh word-split + pitfalls (loops use `while read`, not `for x in $unquoted`). Keep it that way + if you edit them. +- `docs/reference/` holds captured "known-good" JSON (profiles, templates, CA, + claims) used as validation oracles (`CHECK=1` on stages 01/05). diff --git a/scripts/reject-all-pending.sh b/scripts/reject-all-pending.sh new file mode 100755 index 0000000..09e1cd3 --- /dev/null +++ b/scripts/reject-all-pending.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Reject ALL pending (pre-issuance) CERTInext orders — to reclaim credits / declutter the +# sandbox. Targets certificateStatusId in {2,24} ("Pending for Approver"). NEVER touches +# issued certs (9 "Certificate Downloaded") or already-rejected orders (13). +# +# Safety: dry-run by default (lists what it WOULD reject). Set REJECT_ALL_PENDING=1 to fire. +# Optional: PAGE_SIZE (default 100), REMARKS. +set -euo pipefail +. ~/.env_certinext +. "$(dirname "$0")/lib/certinext-auth.sh" + +DRY=1; [ "${REJECT_ALL_PENDING:-}" = "1" ] && DRY=0 +PAGE_SIZE="${PAGE_SIZE:-100}" +REMARKS="${REMARKS:-Cancelled pending order to reclaim sandbox credits.}" + +report_page() { # $1 = page number + read -r ts txn authKey <<< "$(certinext_meta)" + curl -s -X POST "$CERTINEXT_API_URL/GetOrderReport" -H "Content-Type: application/json" \ + -d "{\"meta\":{\"ver\":\"1.0\",\"ts\":\"$ts\",\"txn\":\"$txn\",\"accountNumber\":\"$CERTINEXT_ACCOUNT_NUMBER\",\"authKey\":\"$authKey\"},\"searchCriteria\":{\"groupNumber\":\"$CERTINEXT_GROUP_NUMBER\",\"pageNumber\":\"$1\",\"pageSize\":\"$PAGE_SIZE\"}}" +} + +# --- Snapshot all pending order numbers up front (before rejecting anything) --- +first=$(report_page 1) +pages=$(echo "$first" | jq -r '.orderDetails.noOfPages // 1') +pending=$(echo "$first" | jq -r '.orderDetails.ordersArray[] | select(.certificateStatusId=="24" or .certificateStatusId=="2") | .orderNumber') +p=2 +while [ "$p" -le "$pages" ]; do + more=$(report_page "$p" | jq -r '.orderDetails.ordersArray[] | select(.certificateStatusId=="24" or .certificateStatusId=="2") | .orderNumber') + [ -n "$more" ] && pending="$pending"$'\n'"$more" + p=$((p+1)) +done +pending=$(echo "$pending" | sed '/^$/d') + +count=$(echo "$pending" | grep -c . || true) +echo "Found $count pending order(s) (certificateStatusId 2/24) across $pages page(s)." + +if [ "$DRY" = "1" ]; then + echo "DRY RUN — set REJECT_ALL_PENDING=1 to reject. First 10:" + echo "$pending" | head -10 | sed 's/^/ /' + exit 0 +fi + +ok=0; fail=0 +while IFS= read -r n; do + [ -z "$n" ] && continue + read -r ts txn authKey <<< "$(certinext_meta)" + st=$(curl -s -X POST "$CERTINEXT_API_URL/RejectOrder" -H "Content-Type: application/json" \ + -d "{\"meta\":{\"ver\":\"1.0\",\"ts\":\"$ts\",\"txn\":\"$txn\",\"accountNumber\":\"$CERTINEXT_ACCOUNT_NUMBER\",\"authKey\":\"$authKey\"},\"orderDetails\":{\"orderNumber\":\"$n\",\"rejectRemarks\":\"$REMARKS\"}}" \ + | jq -r '.meta.status // "?"') + if [ "$st" = "1" ]; then ok=$((ok+1)); else fail=$((fail+1)); echo " FAIL $n (status=$st)"; fi +done <<< "$pending" + +echo "Done. Rejected ok=$ok fail=$fail (of $count)." diff --git a/scripts/reject-order.sh b/scripts/reject-order.sh new file mode 100755 index 0000000..974a071 --- /dev/null +++ b/scripts/reject-order.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Cancel/reject a PENDING CERTInext order (pre-issuance) by order number. +# +# Unlike RevokeOrder (which targets issued certs), RejectOrder cancels an order that +# has not yet been issued — e.g. one parked at EXTERNALVALIDATION awaiting DCV. Whether +# this refunds the consumed credit is a CERTInext billing-policy question; run it on one +# order and check GetProductDetails / your credit balance before/after to confirm. +# +# Required env var: ORDER_NUMBER +# Optional env var: REMARKS (default "Cancelled pending order to reclaim sandbox credits.") +set -euo pipefail +. ~/.env_certinext +. "$(dirname "$0")/lib/certinext-auth.sh" + +if [ -z "${ORDER_NUMBER:-}" ]; then + echo "Usage: ORDER_NUMBER= [REMARKS=...] scripts/reject-order.sh" >&2 + exit 1 +fi + +REMARKS="${REMARKS:-Cancelled pending order to reclaim sandbox credits.}" + +read -r ts txn authKey <<< "$(certinext_meta)" +echo "RejectOrder orderNumber=$ORDER_NUMBER ts=$ts txn=$txn" +curl -s -X POST "$CERTINEXT_API_URL/RejectOrder" \ + -H "Content-Type: application/json" \ + -d "{\"meta\":{\"ver\":\"1.0\",\"ts\":\"$ts\",\"txn\":\"$txn\",\"accountNumber\":\"$CERTINEXT_ACCOUNT_NUMBER\",\"authKey\":\"$authKey\"},\"orderDetails\":{\"orderNumber\":\"$ORDER_NUMBER\",\"rejectRemarks\":\"$REMARKS\"}}" \ +| jq . diff --git a/scripts/v2/README.md b/scripts/v2/README.md new file mode 100644 index 0000000..ca3cdcf --- /dev/null +++ b/scripts/v2/README.md @@ -0,0 +1,110 @@ +# scripts/v2 — CERTInext V2 REST API dev helpers + +Small curl + jq scripts for poking the CERTInext V2 API (`/api/certinext/v2/...`) by hand. +They are dev tooling only and are not shipped with the plugin. For repeatable live-API +verification, use `CERTInext.IntegrationTests` / `CERTInext.IntegrationRunner` instead +(see `CLAUDE.md`). + +## Auth and credentials + +The scripts use the same auth model as the plugin's V2 mode +(`CERTInextClient.GetOrRefreshV2TokenAsync`): + +``` +POST {CERTINEXT_API_URL}/oauth/token (application/x-www-form-urlencoded) +grant_type=client_credentials&client_id=...&client_secret=... +``` + +`CERTINEXT_API_URL` is the single V2 base URL, e.g. `https://sandbox-us.certinext.io` +(no `/emSignHub-API` suffix). The old `CERTINEXT_V2_API_URL` variable and the V1 SHA256 +`authKey` token exchange are gone. + +Credentials are read from `~/.env_certinext_v2`, the same file the V2 integration tests +use. Set `CERTINEXT_V2_ENV_FILE=/path/to/file` to use a different file. + +| Key | Required | Used by | +|-----|----------|---------| +| `CERTINEXT_API_URL` | yes | all | +| `CERTINEXT_CLIENT_ID` | yes | all | +| `CERTINEXT_CLIENT_SECRET` | yes | all | +| `CERTINEXT_REQUESTOR_EMAIL` | order-create only | `create-ssl-order`, `create-private-pki-order` | +| `CERTINEXT_REQUESTOR_NAME`, `CERTINEXT_REQUESTOR_MOBILE` | no (defaults) | order-create, `accept-agreement` | +| `CERTINEXT_SIGNER_IP` | no (auto-detected via api.ipify.org) | `create-ssl-order`, `accept-agreement` | + +How the file is read (see `scripts/lib/certinext-v2-auth.sh`): + +- The file is parsed as `KEY=VALUE` lines, never `source`d. It can't run code, and nothing + from it leaks into your shell. Blank lines and `#` comments are skipped, and one pair of + surrounding `"` or `'` quotes is stripped from each value. +- A value in the file wins over a same-named exported env var, the same way + `V2EnvHelper.LoadEnvFile` works. A shell that sourced the V1 `~/.env_certinext` exports + a V1 `CERTINEXT_API_URL`, and that must not leak in. Exported env vars only fill in keys + the file doesn't define. +- A missing required key fails fast and names the key and file. +- `CERTINEXT_API_URL` must be `https://`. Plain `http://` is accepted only for + `localhost` / `127.0.0.1` stubs. A URL containing `/emSignHub-API` is rejected as a V1 URL. + +Secret handling: the client secret and the bearer token are held only in unexported shell +variables. They reach curl through process substitution (`/dev/fd` pipes), so they never +show up in `ps` output, on disk, or on stdout/stderr. On a failed token request, the scripts +print only the HTTP status and the short OAuth2 `error` code, never the response body. +Don't run these scripts with `set -x` / `bash -x`. + +Requires `bash`, `curl` (7.55+ for `-H @file`), and `jq`. + +## Read-only scripts + +These run immediately and send only GET requests (plus the token request). + +| Script | Inputs | Endpoint | +|--------|--------|----------| +| `ping.sh` | — | `GET /auth/me` | +| `list-products.sh` | — | `GET /catalog/products` | +| `get-custom-fields.sh` | `PRODUCT_CODE` | `GET /catalog/products/{code}/custom-fields` | +| `list-groups.sh` | — | `GET /groups` | +| `list-organizations.sh` | — | `GET /organizations` | +| `list-domains.sh` | — | `GET /domains` | +| `orders-report.sh` | `[PAGE=1] [SIZE=100]` | `GET /reports/orders` (1-based paging) | +| `track-order.sh` | `ORDER_ID` | `GET /ssl-certificates/{id}` | +| `get-dcv.sh` | `ORDER_ID`, `DOMAIN` | `GET /ssl-certificates/{id}/dcv?domain=` | +| `download-certificate.sh` | `ORDER_ID` | `GET /ssl-certificates/{id}/certificate` | +| `track-private-pki.sh` | `ORDER_ID` | `GET /private-pki-certificates/{id}` | +| `download-certificate-private-pki.sh` | `ORDER_ID` | `GET /private-pki-certificates/{id}/certificate` | + +## Mutating scripts (`--yes-mutate` required) + +These scripts change state on the CERTInext account, and some of them are irreversible or +cost money. Without `--yes-mutate`, a script prints the request it would send (method, URL, +JSON body) and exits with status 3. The preview makes no network calls: it doesn't fetch a +token or look up the signer IP. + +| Script | Effect | Inputs | +|--------|--------|--------| +| `create-ssl-order.sh` | places an SSL order | `PRODUCT_CODE`, `DOMAIN`, `[VARIANT=dv]` | +| `create-private-pki-order.sh` | places a Private PKI order | `PRODUCT_CODE`, `CERT_HOSTNAME`, `CA_PROFILE_ID`, `MASTER_PRODUCT_ID` | +| `verify-dcv.sh` | asks the CA to check DCV, which can advance the order | `ORDER_ID`, `DOMAIN`, `[METHOD=http-url]` | +| `submit-csr.sh` | attaches a CSR to an SSL order, which advances it | `ORDER_ID`, `CSR_FILE` | +| `submit-csr-private-pki.sh` | attaches a CSR, and the customer CA signs immediately | `ORDER_ID`, `CSR_FILE` | +| `accept-agreement.sh` | accepts the Subscriber Agreement, and the CA issues | `ORDER_ID` | +| `cancel-ssl-order.sh` | cancels an unissued SSL order | `ORDER_ID` | +| `revoke-ssl.sh` | revokes an issued SSL certificate (irreversible) | `ORDER_ID`, `[REASON=superseded]` | +| `revoke-private-pki.sh` | revokes a Private PKI certificate (irreversible) | `ORDER_ID`, `[REASON=superseded]` | + +`create-private-pki-order.sh` takes `CERT_HOSTNAME`, not `HOSTNAME`. Bash always sets +`HOSTNAME` to the local machine name, so the old input silently fell back to this +workstation's hostname. + +## Examples + +```bash +scripts/v2/ping.sh +ORDER_ID=1234567890 scripts/v2/track-order.sh + +# Preview only (prints the request and exits 3): +ORDER_ID=1234567890 scripts/v2/revoke-ssl.sh +# Actually revoke: +ORDER_ID=1234567890 scripts/v2/revoke-ssl.sh --yes-mutate + +# Via make: mutating targets forward V2_ARGS to the script. +make v2-revoke-ssl ORDER_ID=1234567890 V2_ARGS=--yes-mutate +``` diff --git a/scripts/v2/accept-agreement.sh b/scripts/v2/accept-agreement.sh new file mode 100755 index 0000000..753f8be --- /dev/null +++ b/scripts/v2/accept-agreement.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# V2 ssl-certificates/{orderId}/agreement — record Subscriber Agreement acceptance. +# MUTATING: the CA proceeds to issue the certificate. Refuses to run and prints the +# planned request unless --yes-mutate is passed. +# Required env var: ORDER_ID +# Env-file keys: CERTINEXT_REQUESTOR_NAME, CERTINEXT_SIGNER_IP (else api.ipify.org lookup) +# +# 204 No Content = recorded. Then poll track-order.sh until status=issued, and run +# download-certificate.sh. +# Credentials: see scripts/v2/README.md. +set -euo pipefail +# shellcheck source=scripts/lib/certinext-v2-auth.sh +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: ORDER_ID= scripts/v2/accept-agreement.sh [--yes-mutate]" >&2; } +v2_parse_mutating_args "$@" + +ORDER_ID="${ORDER_ID:-}" +v2_require_id ORDER_ID + +name=$(v2_cfg CERTINEXT_REQUESTOR_NAME "Keyfactor Gateway Test") + +build_body() { + jq -n --arg name "$name" --arg ip "$1" \ + '{agreement:{signerName:$name,signerIp:$ip,signerPlace:"Gateway",accepted:true}}' +} + +path="/api/certinext/v2/ssl-certificates/$ORDER_ID/agreement" +v2_mutation_gate POST "$path" "$(build_body "$(v2_signer_ip --offline)")" + +body=$(build_body "$(v2_signer_ip)") +echo "V2 POST $path signerName=$name" >&2 +v2_request POST "$path" -H "Content-Type: application/json" --data-binary "$body" diff --git a/scripts/v2/cancel-ssl-order.sh b/scripts/v2/cancel-ssl-order.sh new file mode 100755 index 0000000..bf358a3 --- /dev/null +++ b/scripts/v2/cancel-ssl-order.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# V2 ssl-certificates/{orderId}/cancel — CANCEL an SSL order before issuance. MUTATING. +# Refuses to run and prints the planned request unless --yes-mutate is passed. +# Required env var: ORDER_ID +# +# Once issued, use revoke-ssl.sh instead. +# 204 No Content = cancelled; order remains visible via track-order with status=cancelled. +# Credentials: see scripts/v2/README.md. +set -euo pipefail +# shellcheck source=scripts/lib/certinext-v2-auth.sh +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: ORDER_ID= scripts/v2/cancel-ssl-order.sh [--yes-mutate]" >&2; } +v2_parse_mutating_args "$@" + +ORDER_ID="${ORDER_ID:-}" +v2_require_id ORDER_ID + +path="/api/certinext/v2/ssl-certificates/$ORDER_ID/cancel" +body='{"reason":"No longer required"}' +v2_mutation_gate POST "$path" "$body" + +echo "V2 POST $path" >&2 +v2_request POST "$path" -H "Content-Type: application/json" --data-binary "$body" diff --git a/scripts/v2/create-private-pki-order.sh b/scripts/v2/create-private-pki-order.sh new file mode 100755 index 0000000..521fb1e --- /dev/null +++ b/scripts/v2/create-private-pki-order.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# V2 private-pki-certificates — PLACE a Private PKI certificate order. MUTATING. +# Refuses to run and prints the planned request unless --yes-mutate is passed. +# Required env vars: PRODUCT_CODE, CERT_HOSTNAME, CA_PROFILE_ID, MASTER_PRODUCT_ID +# (CERT_HOSTNAME, not HOSTNAME: bash always sets HOSTNAME to the local machine name, +# so the old HOSTNAME input silently ordered a certificate for this workstation.) +# Env-file keys: CERTINEXT_REQUESTOR_EMAIL (required), CERTINEXT_REQUESTOR_NAME, +# CERTINEXT_REQUESTOR_MOBILE +# +# On success prints the orderId. Use it with track-private-pki, submit-csr-private-pki, +# download-certificate-private-pki, and revoke-private-pki. +# Credentials: see scripts/v2/README.md. +set -euo pipefail +# shellcheck source=scripts/lib/certinext-v2-auth.sh +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: PRODUCT_CODE= CERT_HOSTNAME= CA_PROFILE_ID= MASTER_PRODUCT_ID= scripts/v2/create-private-pki-order.sh [--yes-mutate]" >&2; } +v2_parse_mutating_args "$@" + +PRODUCT_CODE="${PRODUCT_CODE:-}" +CERT_HOSTNAME="${CERT_HOSTNAME:-}" +CA_PROFILE_ID="${CA_PROFILE_ID:-}" +MASTER_PRODUCT_ID="${MASTER_PRODUCT_ID:-}" +v2_require_var PRODUCT_CODE +v2_require_var CERT_HOSTNAME +v2_require_var CA_PROFILE_ID +v2_require_var MASTER_PRODUCT_ID + +name=$(v2_cfg CERTINEXT_REQUESTOR_NAME "Keyfactor Gateway Test") +email=$(v2_require CERTINEXT_REQUESTOR_EMAIL) +phone=$(v2_cfg CERTINEXT_REQUESTOR_MOBILE "$(v2_cfg CERTINEXT_REQUESTOR_PHONE "+10000000000")") + +body=$(jq -n \ + --arg caProfileId "$CA_PROFILE_ID" \ + --arg masterProductId "$MASTER_PRODUCT_ID" \ + --arg hostname "$CERT_HOSTNAME" \ + --arg name "$name" \ + --arg email "$email" \ + --arg phone "$phone" \ + '{variant:"intranet-ssl", + caProfileId:$caProfileId, + masterProductId:$masterProductId, + hostname:$hostname, + additionalHosts:[], + emailNotifications:"all", + subscription:{validityYears:1}, + requestor:{name:$name,email:$email,phone:$phone,designation:"IT Administrator"}}') + +path="/api/certinext/v2/private-pki-certificates" +v2_mutation_gate POST "$path (X-Product-Code: $PRODUCT_CODE)" "$body" + +idempotency_key=$(v2_uuid) +echo "V2 POST $path productCode=$PRODUCT_CODE hostname=$CERT_HOSTNAME caProfileId=$CA_PROFILE_ID masterProductId=$MASTER_PRODUCT_ID idempotencyKey=$idempotency_key" >&2 +rc=0 +result=$(v2_request POST "$path" \ + -H "Content-Type: application/json" \ + -H "X-Product-Code: $PRODUCT_CODE" \ + -H "Idempotency-Key: $idempotency_key" \ + --data-binary "$body") || rc=$? + +echo "==> Full response:" +printf '%s\n' "$result" +echo "==> orderId (use with track-private-pki, submit-csr-private-pki, etc.):" +printf '%s' "$result" | jq -r '.orderId // .detail // .title // "none"' 2>/dev/null || echo "none" +exit "$rc" diff --git a/scripts/v2/create-ssl-order.sh b/scripts/v2/create-ssl-order.sh new file mode 100755 index 0000000..1be9f9b --- /dev/null +++ b/scripts/v2/create-ssl-order.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# V2 ssl-certificates — PLACE a new SSL/TLS certificate order. MUTATING (may incur cost). +# Refuses to run and prints the planned request unless --yes-mutate is passed. +# Required env vars: PRODUCT_CODE, DOMAIN +# Optional env vars: VARIANT (default dv) +# Env-file keys: CERTINEXT_REQUESTOR_EMAIL (required), CERTINEXT_REQUESTOR_NAME, +# CERTINEXT_REQUESTOR_MOBILE, CERTINEXT_SIGNER_IP (else api.ipify.org lookup) +# +# On success prints the orderId. Use it with track-order, get-dcv, verify-dcv, +# submit-csr, accept-agreement, download-certificate, revoke-ssl, cancel-ssl-order. +# Credentials: see scripts/v2/README.md. +set -euo pipefail +# shellcheck source=scripts/lib/certinext-v2-auth.sh +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: PRODUCT_CODE= DOMAIN= [VARIANT=dv] scripts/v2/create-ssl-order.sh [--yes-mutate]" >&2; } +v2_parse_mutating_args "$@" + +PRODUCT_CODE="${PRODUCT_CODE:-}" +DOMAIN="${DOMAIN:-}" +VARIANT="${VARIANT:-dv}" +v2_require_var PRODUCT_CODE +v2_require_var DOMAIN + +name=$(v2_cfg CERTINEXT_REQUESTOR_NAME "Keyfactor Gateway Test") +email=$(v2_require CERTINEXT_REQUESTOR_EMAIL) +phone=$(v2_cfg CERTINEXT_REQUESTOR_MOBILE "$(v2_cfg CERTINEXT_REQUESTOR_PHONE "+10000000000")") + +build_body() { + jq -n \ + --arg variant "$VARIANT" \ + --arg domain "$DOMAIN" \ + --arg name "$name" \ + --arg email "$email" \ + --arg phone "$phone" \ + --arg signerIp "$1" \ + '{productVariant:$variant, + emailNotifications:"all", + requestor:{name:$name,email:$email,phone:$phone,designation:"IT Administrator"}, + certificate:{domain:$domain,autoSecureWww:true}, + subscription:{validityYears:1,autoRenew:false,renewBeforeDays:30}, + agreement:{signerName:$name,signerIp:$signerIp,signerPlace:"Gateway",accepted:true}, + remarks:"Issued via Keyfactor Command AnyCA REST Gateway."}' +} + +path="/api/certinext/v2/ssl-certificates" +v2_mutation_gate POST "$path (X-Product-Code: $PRODUCT_CODE)" "$(build_body "$(v2_signer_ip --offline)")" + +body=$(build_body "$(v2_signer_ip)") +idempotency_key=$(v2_uuid) + +echo "V2 POST $path productCode=$PRODUCT_CODE domain=$DOMAIN variant=$VARIANT idempotencyKey=$idempotency_key" >&2 +rc=0 +result=$(v2_request POST "$path" \ + -H "Content-Type: application/json" \ + -H "X-Product-Code: $PRODUCT_CODE" \ + -H "Idempotency-Key: $idempotency_key" \ + --data-binary "$body") || rc=$? + +echo "==> Full response:" +printf '%s\n' "$result" +echo "==> orderId (use with track-order, get-dcv, etc.):" +printf '%s' "$result" | jq -r '.orderId // .detail // .title // "none"' 2>/dev/null || echo "none" +exit "$rc" diff --git a/scripts/v2/download-certificate-private-pki.sh b/scripts/v2/download-certificate-private-pki.sh new file mode 100755 index 0000000..39b7a24 --- /dev/null +++ b/scripts/v2/download-certificate-private-pki.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# V2 private-pki-certificates/{orderId}/certificate — download issued Private PKI +# certificate. Read-only. +# Required env var: ORDER_ID +# +# Returns JSON with certificatePem, serialNumber, subject, issuer, notBefore, notAfter. +# Returns 422 if the order is not yet in issued state. +# Credentials: see scripts/v2/README.md. +set -euo pipefail +# shellcheck source=scripts/lib/certinext-v2-auth.sh +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: ORDER_ID= scripts/v2/download-certificate-private-pki.sh" >&2; } +v2_parse_args "$@" + +ORDER_ID="${ORDER_ID:-}" +v2_require_id ORDER_ID + +echo "V2 GET /api/certinext/v2/private-pki-certificates/$ORDER_ID/certificate" >&2 +v2_request GET "/api/certinext/v2/private-pki-certificates/$ORDER_ID/certificate" diff --git a/scripts/v2/download-certificate.sh b/scripts/v2/download-certificate.sh new file mode 100755 index 0000000..4426b72 --- /dev/null +++ b/scripts/v2/download-certificate.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# V2 ssl-certificates/{orderId}/certificate — download issued SSL certificate. Read-only. +# Required env var: ORDER_ID +# +# Returns JSON with certificatePem, serialNumber, subject, issuer, notBefore, notAfter. +# Returns 422 if the order is not yet in issued state. +# Credentials: see scripts/v2/README.md. +set -euo pipefail +# shellcheck source=scripts/lib/certinext-v2-auth.sh +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: ORDER_ID= scripts/v2/download-certificate.sh" >&2; } +v2_parse_args "$@" + +ORDER_ID="${ORDER_ID:-}" +v2_require_id ORDER_ID + +echo "V2 GET /api/certinext/v2/ssl-certificates/$ORDER_ID/certificate" >&2 +v2_request GET "/api/certinext/v2/ssl-certificates/$ORDER_ID/certificate" diff --git a/scripts/v2/get-custom-fields.sh b/scripts/v2/get-custom-fields.sh new file mode 100755 index 0000000..729c79d --- /dev/null +++ b/scripts/v2/get-custom-fields.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# V2 catalog/products/{code}/custom-fields — mandatory + optional custom fields for a +# product. Read-only. +# Required env var: PRODUCT_CODE +# Credentials: see scripts/v2/README.md. +set -euo pipefail +# shellcheck source=scripts/lib/certinext-v2-auth.sh +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: PRODUCT_CODE= scripts/v2/get-custom-fields.sh" >&2; } +v2_parse_args "$@" + +PRODUCT_CODE="${PRODUCT_CODE:-}" +v2_require_id PRODUCT_CODE + +echo "V2 GET /api/certinext/v2/catalog/products/$PRODUCT_CODE/custom-fields" >&2 +v2_request GET "/api/certinext/v2/catalog/products/$PRODUCT_CODE/custom-fields" diff --git a/scripts/v2/get-dcv.sh b/scripts/v2/get-dcv.sh new file mode 100755 index 0000000..37d85f9 --- /dev/null +++ b/scripts/v2/get-dcv.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# V2 ssl-certificates/{orderId}/dcv — get DCV challenge artifacts for a domain. Read-only. +# Required env vars: ORDER_ID, DOMAIN +# +# Returns http-url, dns-txt, and email challenge methods. +# Publish the artifact for your chosen method, then run verify-dcv.sh --yes-mutate. +# Credentials: see scripts/v2/README.md. +set -euo pipefail +# shellcheck source=scripts/lib/certinext-v2-auth.sh +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: ORDER_ID= DOMAIN= scripts/v2/get-dcv.sh" >&2; } +v2_parse_args "$@" + +ORDER_ID="${ORDER_ID:-}" +DOMAIN="${DOMAIN:-}" +v2_require_id ORDER_ID +v2_require_var DOMAIN + +echo "V2 GET /api/certinext/v2/ssl-certificates/$ORDER_ID/dcv?domain=$DOMAIN" >&2 +v2_request GET "/api/certinext/v2/ssl-certificates/$ORDER_ID/dcv" \ + -G --data-urlencode "domain=$DOMAIN" diff --git a/scripts/v2/list-domains.sh b/scripts/v2/list-domains.sh new file mode 100755 index 0000000..b53e612 --- /dev/null +++ b/scripts/v2/list-domains.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# V2 domains — list domains already pre-validated under this account. Read-only. +# DCV does not need to be repeated for domains in this list. +# Credentials: see scripts/v2/README.md. +set -euo pipefail +# shellcheck source=scripts/lib/certinext-v2-auth.sh +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: scripts/v2/list-domains.sh" >&2; } +v2_parse_args "$@" + +echo "V2 GET /api/certinext/v2/domains" >&2 +v2_request GET "/api/certinext/v2/domains" diff --git a/scripts/v2/list-groups.sh b/scripts/v2/list-groups.sh new file mode 100755 index 0000000..8436e32 --- /dev/null +++ b/scripts/v2/list-groups.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# V2 groups — list billing groups accessible to this account. Read-only. +# Use a groupNumber from here in order bodies to charge a specific cost centre. +# Credentials: see scripts/v2/README.md. +set -euo pipefail +# shellcheck source=scripts/lib/certinext-v2-auth.sh +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: scripts/v2/list-groups.sh" >&2; } +v2_parse_args "$@" + +echo "V2 GET /api/certinext/v2/groups" >&2 +v2_request GET "/api/certinext/v2/groups" diff --git a/scripts/v2/list-organizations.sh b/scripts/v2/list-organizations.sh new file mode 100755 index 0000000..0c56630 --- /dev/null +++ b/scripts/v2/list-organizations.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# V2 organizations — list pre-vetted organizations available for OV/EV SSL. Read-only. +# Reference an organizationNumber in order bodies to skip re-vetting. +# Credentials: see scripts/v2/README.md. +set -euo pipefail +# shellcheck source=scripts/lib/certinext-v2-auth.sh +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: scripts/v2/list-organizations.sh" >&2; } +v2_parse_args "$@" + +echo "V2 GET /api/certinext/v2/organizations" >&2 +v2_request GET "/api/certinext/v2/organizations" diff --git a/scripts/v2/list-products.sh b/scripts/v2/list-products.sh new file mode 100755 index 0000000..7cc82fe --- /dev/null +++ b/scripts/v2/list-products.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# V2 catalog/products — list all products the account can order. Read-only. +# Each entry has a stable productCode used in the X-Product-Code header. +# Credentials: see scripts/v2/README.md. +set -euo pipefail +# shellcheck source=scripts/lib/certinext-v2-auth.sh +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: scripts/v2/list-products.sh" >&2; } +v2_parse_args "$@" + +echo "V2 GET /api/certinext/v2/catalog/products" >&2 +v2_request GET "/api/certinext/v2/catalog/products" diff --git a/scripts/v2/orders-report.sh b/scripts/v2/orders-report.sh new file mode 100755 index 0000000..3fb8ea0 --- /dev/null +++ b/scripts/v2/orders-report.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# V2 reports/orders — one page of order history. Read-only. +# This is the endpoint the plugin's V2 Synchronize pages through. +# Optional env vars: PAGE (default 1; paging is 1-based, page=0 is treated as 1), +# SIZE (default 100; the server clamps to 100) +# Credentials: see scripts/v2/README.md. +set -euo pipefail +# shellcheck source=scripts/lib/certinext-v2-auth.sh +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: [PAGE=1] [SIZE=100] scripts/v2/orders-report.sh" >&2; } +v2_parse_args "$@" + +PAGE="${PAGE:-1}" +SIZE="${SIZE:-100}" +case "$PAGE$SIZE" in + *[!0-9]*) echo "ERROR: PAGE and SIZE must be non-negative integers." >&2; exit 1 ;; +esac + +echo "V2 GET /api/certinext/v2/reports/orders?page=$PAGE&size=$SIZE" >&2 +v2_request GET "/api/certinext/v2/reports/orders?page=$PAGE&size=$SIZE" diff --git a/scripts/v2/ping.sh b/scripts/v2/ping.sh new file mode 100755 index 0000000..578bc05 --- /dev/null +++ b/scripts/v2/ping.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# V2 auth/me — returns the account context the Bearer token resolves to. +# Mirrors ICERTInextClient.PingAsync via the V2 API. Read-only. +# Credentials: see scripts/v2/README.md. +set -euo pipefail +# shellcheck source=scripts/lib/certinext-v2-auth.sh +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: scripts/v2/ping.sh" >&2; } +v2_parse_args "$@" + +echo "V2 GET /api/certinext/v2/auth/me" >&2 +v2_request GET "/api/certinext/v2/auth/me" diff --git a/scripts/v2/revoke-private-pki.sh b/scripts/v2/revoke-private-pki.sh new file mode 100755 index 0000000..71d4b8c --- /dev/null +++ b/scripts/v2/revoke-private-pki.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# V2 private-pki-certificates/{orderId}/revoke — permanently REVOKE an issued Private PKI +# certificate. MUTATING and irreversible. Refuses to run and prints the planned request +# unless --yes-mutate is passed. +# Required env var: ORDER_ID +# Optional env var: REASON (default superseded) +# +# RFC 5280 reason values: unspecified, keyCompromise, affiliationChanged, +# superseded, cessationOfOperation, privilegeWithdrawn +# +# 204 No Content = revocation recorded on the customer CA. +# 422 = order not yet issued, or already revoked. +# Credentials: see scripts/v2/README.md. +set -euo pipefail +# shellcheck source=scripts/lib/certinext-v2-auth.sh +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: ORDER_ID= [REASON=superseded] scripts/v2/revoke-private-pki.sh [--yes-mutate]" >&2; } +v2_parse_mutating_args "$@" + +ORDER_ID="${ORDER_ID:-}" +REASON="${REASON:-superseded}" +v2_require_id ORDER_ID + +path="/api/certinext/v2/private-pki-certificates/$ORDER_ID/revoke" +body=$(jq -n --arg reason "$REASON" '{reason:$reason,note:"Revoked via scripts/v2 dev helper."}') +v2_mutation_gate POST "$path" "$body" + +idempotency_key=$(v2_uuid) +echo "V2 POST $path reason=$REASON idempotencyKey=$idempotency_key" >&2 +v2_request POST "$path" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: $idempotency_key" \ + --data-binary "$body" diff --git a/scripts/v2/revoke-ssl.sh b/scripts/v2/revoke-ssl.sh new file mode 100755 index 0000000..fb76912 --- /dev/null +++ b/scripts/v2/revoke-ssl.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# V2 ssl-certificates/{orderId}/revoke — permanently REVOKE an issued SSL certificate. +# MUTATING and irreversible. Refuses to run and prints the planned request unless +# --yes-mutate is passed. +# Required env var: ORDER_ID +# Optional env var: REASON (default superseded) +# +# RFC 5280 reason values: unspecified, keyCompromise, caCompromise, affiliationChanged, +# superseded, cessationOfOperation, privilegeWithdrawn +# +# 204 No Content = revocation queued; CRL/OCSP reflect this on next publish. +# 422 = order not yet issued, or already revoked. +# Credentials: see scripts/v2/README.md. +set -euo pipefail +# shellcheck source=scripts/lib/certinext-v2-auth.sh +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: ORDER_ID= [REASON=superseded] scripts/v2/revoke-ssl.sh [--yes-mutate]" >&2; } +v2_parse_mutating_args "$@" + +ORDER_ID="${ORDER_ID:-}" +REASON="${REASON:-superseded}" +v2_require_id ORDER_ID + +path="/api/certinext/v2/ssl-certificates/$ORDER_ID/revoke" +body=$(jq -n --arg reason "$REASON" '{reason:$reason,note:"Revoked via scripts/v2 dev helper."}') +v2_mutation_gate POST "$path" "$body" + +idempotency_key=$(v2_uuid) +echo "V2 POST $path reason=$REASON idempotencyKey=$idempotency_key" >&2 +v2_request POST "$path" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: $idempotency_key" \ + --data-binary "$body" diff --git a/scripts/v2/submit-csr-private-pki.sh b/scripts/v2/submit-csr-private-pki.sh new file mode 100755 index 0000000..da056f3 --- /dev/null +++ b/scripts/v2/submit-csr-private-pki.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# V2 private-pki-certificates/{orderId}/csr — attach a PEM CSR to a Private PKI order. +# MUTATING: the customer CA signs immediately after CSR submission. Refuses to run and +# prints the planned request unless --yes-mutate is passed. +# Required env vars: ORDER_ID, CSR_FILE (path to PEM file) +# +# 204 No Content = CSR accepted. +# Credentials: see scripts/v2/README.md. +set -euo pipefail +# shellcheck source=scripts/lib/certinext-v2-auth.sh +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: ORDER_ID= CSR_FILE= scripts/v2/submit-csr-private-pki.sh [--yes-mutate]" >&2; } +v2_parse_mutating_args "$@" + +ORDER_ID="${ORDER_ID:-}" +CSR_FILE="${CSR_FILE:-}" +v2_require_id ORDER_ID +v2_require_var CSR_FILE +if [ ! -f "$CSR_FILE" ]; then + echo "ERROR: CSR_FILE '$CSR_FILE' not found" >&2 + exit 1 +fi + +path="/api/certinext/v2/private-pki-certificates/$ORDER_ID/csr" +body=$(jq -n --rawfile csr "$CSR_FILE" '{csr:$csr,attested:false}') +v2_mutation_gate PUT "$path" "$body" + +echo "V2 PUT $path csrFile=$CSR_FILE" >&2 +v2_request PUT "$path" -H "Content-Type: application/json" --data-binary "$body" diff --git a/scripts/v2/submit-csr.sh b/scripts/v2/submit-csr.sh new file mode 100755 index 0000000..66bc84f --- /dev/null +++ b/scripts/v2/submit-csr.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# V2 ssl-certificates/{orderId}/csr — attach a PEM CSR to an SSL order. MUTATING +# (advances the order). Refuses to run and prints the planned request unless +# --yes-mutate is passed. +# Required env vars: ORDER_ID, CSR_FILE (path to PEM file) +# +# 204 No Content = CSR accepted; order advances to pending-agreement. +# Credentials: see scripts/v2/README.md. +set -euo pipefail +# shellcheck source=scripts/lib/certinext-v2-auth.sh +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: ORDER_ID= CSR_FILE= scripts/v2/submit-csr.sh [--yes-mutate]" >&2; } +v2_parse_mutating_args "$@" + +ORDER_ID="${ORDER_ID:-}" +CSR_FILE="${CSR_FILE:-}" +v2_require_id ORDER_ID +v2_require_var CSR_FILE +if [ ! -f "$CSR_FILE" ]; then + echo "ERROR: CSR_FILE '$CSR_FILE' not found" >&2 + exit 1 +fi + +path="/api/certinext/v2/ssl-certificates/$ORDER_ID/csr" +body=$(jq -n --rawfile csr "$CSR_FILE" '{csr:$csr,attested:false}') +v2_mutation_gate PUT "$path" "$body" + +echo "V2 PUT $path csrFile=$CSR_FILE" >&2 +v2_request PUT "$path" -H "Content-Type: application/json" --data-binary "$body" diff --git a/scripts/v2/track-order.sh b/scripts/v2/track-order.sh new file mode 100755 index 0000000..9b99514 --- /dev/null +++ b/scripts/v2/track-order.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# V2 ssl-certificates/{orderId} — fetch current state of an SSL order. Read-only. +# Required env var: ORDER_ID +# +# Status values: pending-dcv -> pending-csr -> pending-agreement -> issued +# (or cancelled / revoked) +# Credentials: see scripts/v2/README.md. +set -euo pipefail +# shellcheck source=scripts/lib/certinext-v2-auth.sh +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: ORDER_ID= scripts/v2/track-order.sh" >&2; } +v2_parse_args "$@" + +ORDER_ID="${ORDER_ID:-}" +v2_require_id ORDER_ID + +echo "V2 GET /api/certinext/v2/ssl-certificates/$ORDER_ID" >&2 +v2_request GET "/api/certinext/v2/ssl-certificates/$ORDER_ID" diff --git a/scripts/v2/track-private-pki.sh b/scripts/v2/track-private-pki.sh new file mode 100755 index 0000000..9c65351 --- /dev/null +++ b/scripts/v2/track-private-pki.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# V2 private-pki-certificates/{orderId} — fetch current state of a Private PKI order. +# Read-only. +# Required env var: ORDER_ID +# +# Status values: pending-csr -> issued (or cancelled / revoked). +# Private PKI orders skip vetting because the CA is customer-owned. +# Credentials: see scripts/v2/README.md. +set -euo pipefail +# shellcheck source=scripts/lib/certinext-v2-auth.sh +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: ORDER_ID= scripts/v2/track-private-pki.sh" >&2; } +v2_parse_args "$@" + +ORDER_ID="${ORDER_ID:-}" +v2_require_id ORDER_ID + +echo "V2 GET /api/certinext/v2/private-pki-certificates/$ORDER_ID" >&2 +v2_request GET "/api/certinext/v2/private-pki-certificates/$ORDER_ID" diff --git a/scripts/v2/verify-dcv.sh b/scripts/v2/verify-dcv.sh new file mode 100755 index 0000000..e41c7e6 --- /dev/null +++ b/scripts/v2/verify-dcv.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# V2 ssl-certificates/{orderId}/dcv/verify — ask the CA to re-check a DCV artifact. +# MUTATING (can advance the order). Refuses to run and prints the planned request unless +# --yes-mutate is passed. +# Required env vars: ORDER_ID, DOMAIN +# Optional env var: METHOD (default http-url; also: dns-txt, email) +# +# 204 No Content = DCV passed; order advances to pending-csr. +# 422 = CA could not find the artifact; check file path or DNS propagation. +# Credentials: see scripts/v2/README.md. +set -euo pipefail +# shellcheck source=scripts/lib/certinext-v2-auth.sh +. "$(dirname "$0")/../lib/certinext-v2-auth.sh" +v2_usage() { echo "Usage: ORDER_ID= DOMAIN= [METHOD=http-url] scripts/v2/verify-dcv.sh [--yes-mutate]" >&2; } +v2_parse_mutating_args "$@" + +ORDER_ID="${ORDER_ID:-}" +DOMAIN="${DOMAIN:-}" +METHOD="${METHOD:-http-url}" +v2_require_id ORDER_ID +v2_require_var DOMAIN + +path="/api/certinext/v2/ssl-certificates/$ORDER_ID/dcv/verify" +body=$(jq -n --arg domain "$DOMAIN" --arg method "$METHOD" '{domain:$domain,method:$method}') +v2_mutation_gate POST "$path" "$body" + +echo "V2 POST $path domain=$DOMAIN method=$METHOD" >&2 +v2_request POST "$path" -H "Content-Type: application/json" --data-binary "$body" diff --git a/scripts/verify-dcv.sh b/scripts/verify-dcv.sh new file mode 100755 index 0000000..98d3bb7 --- /dev/null +++ b/scripts/verify-dcv.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Required env vars: ORDER_NUMBER, DOMAIN_NAME +# Optional: DCV_METHOD (default: 1 = DNS TXT record) +set -euo pipefail +. ~/.env_certinext +. "$(dirname "$0")/lib/certinext-auth.sh" + +ORDER_NUMBER="${ORDER_NUMBER:?Usage: ORDER_NUMBER= DOMAIN_NAME= [DCV_METHOD=1] scripts/verify-dcv.sh}" +DOMAIN_NAME="${DOMAIN_NAME:?DOMAIN_NAME is required}" +DCV_METHOD="${DCV_METHOD:-1}" + +read -r ts txn authKey <<< "$(certinext_meta)" + +# SOC2 CC6.1: do NOT echo authKey — it is a valid single-use request authenticator. +echo "VerifyDcv orderNumber=$ORDER_NUMBER domainName=$DOMAIN_NAME dcvMethod=$DCV_METHOD ts=$ts txn=$txn" + +# SOX CC6.6: use jq --arg to safely interpolate all user-supplied values into the JSON body, +# preventing shell injection via specially crafted ORDER_NUMBER or DOMAIN_NAME values. +jq -n \ + --arg ver "1.0" \ + --arg ts "$ts" \ + --arg txn "$txn" \ + --arg acct "$CERTINEXT_ACCOUNT_NUMBER" \ + --arg authKey "$authKey" \ + --arg email "$CERTINEXT_REQUESTOR_EMAIL" \ + --arg order "$ORDER_NUMBER" \ + --arg domain "$DOMAIN_NAME" \ + --arg method "$DCV_METHOD" \ + '{ + meta: {ver: $ver, ts: $ts, txn: $txn, accountNumber: $acct, authKey: $authKey}, + dcvDetails: {requestorEmail: $email, orderNumber: $order, domainName: $domain, dcvMethod: $method} + }' \ +| curl -s -X POST "$CERTINEXT_API_URL/VerifyDcv" \ + -H "Content-Type: application/json" \ + --data-binary @- \ +| jq .