From 87b4363eeb1eb803067dd069e886cb61f9e8f2d3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 28 Feb 2026 12:28:16 -0500 Subject: [PATCH] feat(batch9): implement f3 nats server ocsp wiring --- .../Auth/Ocsp/OcspHandler.cs | 45 ++ .../Auth/Ocsp/OcspTypes.cs | 3 + .../ZB.MOM.NatsNet.Server/NatsServer.Init.cs | 8 +- .../NatsServer.Lifecycle.cs | 16 + .../ZB.MOM.NatsNet.Server/NatsServer.Ocsp.cs | 400 ++++++++++++++++++ .../NatsServerOcspTests.cs | 189 +++++++++ porting.db | Bin 6533120 -> 6533120 bytes 7 files changed, 659 insertions(+), 2 deletions(-) create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Ocsp.cs create mode 100644 dotnet/tests/ZB.MOM.NatsNet.Server.Tests/NatsServerOcspTests.cs diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspHandler.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspHandler.cs index 14a6499..dec85b4 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspHandler.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspHandler.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0 using System.Globalization; +using System.Formats.Asn1; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text.Json; @@ -15,6 +16,8 @@ namespace ZB.MOM.NatsNet.Server; internal static class OcspHandler { private const string CertPemLabel = "CERTIFICATE"; + private const string TlsFeaturesOid = "1.3.6.1.5.5.7.1.24"; + private const int StatusRequestExtension = 5; internal static (List? certificates, Exception? error) ParseCertPEM(string name) { @@ -50,6 +53,43 @@ internal static class OcspHandler } } + internal static bool HasOCSPStatusRequest(X509Certificate2 cert) + { + foreach (var extension in cert.Extensions) + { + if (!string.Equals(extension.Oid?.Value, TlsFeaturesOid, StringComparison.Ordinal)) + { + continue; + } + + try + { + var reader = new AsnReader(extension.RawData, AsnEncodingRules.DER); + var seq = reader.ReadSequence(); + while (seq.HasData) + { + if (seq.ReadInteger() == StatusRequestExtension) + { + return true; + } + } + + if (reader.HasData) + { + return false; + } + } + catch + { + return false; + } + + break; + } + + return false; + } + internal static (X509Certificate2? issuer, Exception? error) GetOCSPIssuerLocally( IReadOnlyList trustedCAs, IReadOnlyList certBundle) @@ -85,6 +125,11 @@ internal static class OcspHandler if (!chain.Build(leaf) || chain.ChainElements.Count < 2) { + if (string.Equals(leaf.Subject, leaf.Issuer, StringComparison.Ordinal)) + { + return (leaf, null); + } + return (null, null); } diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspTypes.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspTypes.cs index 7a078e8..bdec1f8 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspTypes.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspTypes.cs @@ -86,6 +86,9 @@ internal sealed class OcspMonitor /// Path to the TLS certificate file being monitored. public string? CertFile { get; set; } + /// Connection kind this monitor applies to (client/router/gateway/leaf). + public string Kind { get; set; } = string.Empty; + /// Path to the CA certificate file used to verify OCSP responses. public string? CaFile { get; set; } diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Init.cs b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Init.cs index b33f0a0..f48937d 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Init.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Init.cs @@ -411,8 +411,12 @@ public sealed partial class NatsServer // Assign leaf options. s._leafNodeEnabled = opts.LeafNode.Port != 0 || opts.LeafNode.Remotes.Count > 0; - // OCSP (stub — session 23). - // s.EnableOcsp() — deferred + var ocspError = s.EnableOCSP(); + if (ocspError != null) + { + s._mu.ExitWriteLock(); + return (null, ocspError); + } // Gateway (stub — session 16). // s.NewGateway(opts) — deferred diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Lifecycle.cs b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Lifecycle.cs index dbf0a39..6f6bd3d 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Lifecycle.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Lifecycle.cs @@ -139,6 +139,22 @@ public sealed partial class NatsServer Noticef("Server Exiting.."); + var monitors = GetOcspMonitors(); + foreach (var monitor in monitors) + { + monitor.Stop(); + } + + _mu.EnterWriteLock(); + try + { + _ocsps = null; + } + finally + { + _mu.ExitWriteLock(); + } + if (_ocsprc != null) { /* stub — stop OCSP cache in session 23 */ } DisposeSignalHandlers(); diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Ocsp.cs b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Ocsp.cs new file mode 100644 index 0000000..666465a --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Ocsp.cs @@ -0,0 +1,400 @@ +// Copyright 2012-2026 The NATS Authors +// Licensed under the Apache License, Version 2.0 + +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider; +using ZB.MOM.NatsNet.Server.Auth.Ocsp; + +namespace ZB.MOM.NatsNet.Server; + +/// +/// TLS configuration slot used by OCSP wiring to apply wrapped TLS settings. +/// Mirrors Go tlsConfigKind shape used by configureOCSP. +/// +internal sealed class OcspTlsConfig +{ + public required string Kind { get; init; } + public required SslServerAuthenticationOptions TlsConfig { get; init; } + public required TlsConfigOpts? TlsOptions { get; init; } + public required Action Apply { get; init; } + public bool IsLeafSpoke { get; init; } +} + +public sealed partial class NatsServer +{ + private const string ClientKindName = "client"; + private const string RouterKindName = "router"; + private const string GatewayKindName = "gateway"; + private const string LeafKindName = "leaf"; + private const string DefaultOcspStoreDir = "ocsp"; + + internal OcspMonitor[] GetOcspMonitors() + { + _mu.EnterReadLock(); + try + { + return _ocsps is null ? [] : [.. _ocsps]; + } + finally + { + _mu.ExitReadLock(); + } + } + + internal Exception? SetupOCSPStapleStoreDir() + { + var storeDir = GetOpts().StoreDir; + if (string.IsNullOrEmpty(storeDir)) + { + return null; + } + + var ocspDir = Path.Combine(storeDir, DefaultOcspStoreDir); + try + { + if (!Directory.Exists(ocspDir)) + { + Directory.CreateDirectory(ocspDir); + } + else + { + var attributes = File.GetAttributes(ocspDir); + if ((attributes & FileAttributes.Directory) != FileAttributes.Directory) + { + return new InvalidOperationException("OCSP storage directory is not a directory"); + } + } + } + catch (Exception ex) + { + return new InvalidOperationException($"could not create OCSP storage directory - {ex.Message}", ex); + } + + return null; + } + + internal List ConfigureOCSP() + { + var opts = GetOpts(); + var configs = new List(); + + if (opts.TlsConfig != null) + { + configs.Add(new OcspTlsConfig + { + Kind = ClientKindName, + TlsConfig = opts.TlsConfig, + TlsOptions = opts.TlsConfigOpts, + Apply = tls => opts.TlsConfig = tls, + }); + } + + if (opts.Websocket.TlsConfig != null) + { + configs.Add(new OcspTlsConfig + { + Kind = ClientKindName, + TlsConfig = opts.Websocket.TlsConfig, + TlsOptions = opts.Websocket.TlsConfigOpts, + Apply = tls => opts.Websocket.TlsConfig = tls, + }); + } + + if (opts.Mqtt.TlsConfig != null) + { + configs.Add(new OcspTlsConfig + { + Kind = ClientKindName, + TlsConfig = opts.Mqtt.TlsConfig, + TlsOptions = opts.Mqtt.TlsConfigOpts, + Apply = tls => opts.Mqtt.TlsConfig = tls, + }); + } + + if (opts.Cluster.TlsConfig != null) + { + configs.Add(new OcspTlsConfig + { + Kind = RouterKindName, + TlsConfig = opts.Cluster.TlsConfig, + TlsOptions = opts.Cluster.TlsConfigOpts, + Apply = tls => opts.Cluster.TlsConfig = tls, + }); + } + + if (opts.LeafNode.TlsConfig != null) + { + configs.Add(new OcspTlsConfig + { + Kind = LeafKindName, + TlsConfig = opts.LeafNode.TlsConfig, + TlsOptions = opts.LeafNode.TlsConfigOpts, + Apply = tls => opts.LeafNode.TlsConfig = tls, + }); + } + + foreach (var remote in opts.LeafNode.Remotes) + { + if (remote.TlsConfig == null) + { + continue; + } + + var capturedRemote = remote; + configs.Add(new OcspTlsConfig + { + Kind = LeafKindName, + TlsConfig = remote.TlsConfig, + TlsOptions = remote.TlsConfigOpts, + IsLeafSpoke = true, + Apply = tls => capturedRemote.TlsConfig = tls, + }); + } + + if (opts.Gateway.TlsConfig != null) + { + configs.Add(new OcspTlsConfig + { + Kind = GatewayKindName, + TlsConfig = opts.Gateway.TlsConfig, + TlsOptions = opts.Gateway.TlsConfigOpts, + Apply = tls => opts.Gateway.TlsConfig = tls, + }); + } + + foreach (var gateway in opts.Gateway.Gateways) + { + if (gateway.TlsConfig == null) + { + continue; + } + + var capturedGateway = gateway; + configs.Add(new OcspTlsConfig + { + Kind = GatewayKindName, + TlsConfig = gateway.TlsConfig, + TlsOptions = gateway.TlsConfigOpts, + Apply = tls => capturedGateway.TlsConfig = tls, + }); + } + + return configs; + } + + internal (SslServerAuthenticationOptions? tlsConfig, OcspMonitor? monitor, Exception? error) NewOCSPMonitor( + OcspTlsConfig config) + { + var opts = GetOpts(); + var ocspConfig = opts.OcspConfig; + + var certFile = config.TlsOptions?.CertFile ?? opts.TlsCert; + var caFile = config.TlsOptions?.CaFile ?? opts.TlsCaCert; + + if (config.TlsConfig.ServerCertificate is not X509Certificate2 leaf) + { + return (null, null, new InvalidOperationException("no certificate found")); + } + + var shutdownOnRevoke = false; + var mustStaple = OcspHandler.HasOCSPStatusRequest(leaf); + if (ocspConfig != null) + { + switch (ocspConfig.Mode) + { + case OcspMode.Never: + if (mustStaple) + { + Warnf("Certificate at '{0}' has MustStaple but OCSP is disabled", certFile); + } + return (config.TlsConfig, null, null); + case OcspMode.Always: + mustStaple = true; + shutdownOnRevoke = true; + break; + case OcspMode.Must when mustStaple: + shutdownOnRevoke = true; + break; + case OcspMode.Auto when !mustStaple: + return (config.TlsConfig, null, null); + } + } + + if (!mustStaple) + { + return (config.TlsConfig, null, null); + } + + var setupError = SetupOCSPStapleStoreDir(); + if (setupError != null) + { + return (null, null, setupError); + } + + var chain = new List { leaf.RawData }; + if (config.TlsConfig.ServerCertificateContext != null) + { + foreach (var intermediate in config.TlsConfig.ServerCertificateContext.IntermediateCertificates) + { + chain.Add(intermediate.RawData); + } + } + + var (issuer, issuerError) = OcspHandler.GetOCSPIssuer(caFile, chain); + if (issuerError != null || issuer == null) + { + return (null, null, issuerError ?? new InvalidOperationException("no issuers found")); + } + + var monitor = new OcspMonitor + { + Kind = config.Kind, + Server = this, + CertFile = certFile, + CaFile = caFile, + Leaf = leaf, + Issuer = issuer, + ShutdownOnRevoke = shutdownOnRevoke, + }; + + var (_, response, statusError) = monitor.GetStatus(); + if (statusError != null) + { + return (null, null, + new InvalidOperationException($"bad OCSP status update for certificate at '{certFile}': {statusError.Message}", statusError)); + } + + if (response != null && response.Status != OcspStatusAssertion.Good && shutdownOnRevoke) + { + return (null, null, + new InvalidOperationException( + $"found existing OCSP status for certificate at '{certFile}': {OcspHandler.OcspStatusString((int)response.Status)}")); + } + + return (config.TlsConfig, monitor, null); + } + + internal Exception? EnableOCSP() + { + var configs = ConfigureOCSP(); + var monitors = new List(configs.Count); + + foreach (var config in configs) + { + if (config.Kind != LeafKindName) + { + var (tlsConfig, monitor, error) = NewOCSPMonitor(config); + if (error != null) + { + return error; + } + + if (monitor != null && tlsConfig != null) + { + monitors.Add(monitor); + config.Apply(tlsConfig); + } + } + + // OCSP peer verification hook is implemented in batch 9 F4. + } + + _mu.EnterWriteLock(); + try + { + _ocsps = monitors.Count == 0 ? null : [.. monitors]; + } + finally + { + _mu.ExitWriteLock(); + } + + return null; + } + + internal void StartOCSPMonitoring() + { + OcspMonitor[]? monitors; + _mu.EnterReadLock(); + try + { + monitors = _ocsps; + } + finally + { + _mu.ExitReadLock(); + } + + if (monitors == null || monitors.Length == 0) + { + return; + } + + foreach (var monitor in monitors) + { + Noticef("OCSP Stapling enabled for {0} connections", monitor.Kind); + monitor.Start(); + StartGoRoutine(() => monitor.Run(_quitCts.Token).GetAwaiter().GetResult()); + } + } + + internal Exception? ReloadOCSP() + { + var setupError = SetupOCSPStapleStoreDir(); + if (setupError != null) + { + return setupError; + } + + var existingMonitors = GetOcspMonitors(); + foreach (var monitor in existingMonitors) + { + monitor.Stop(); + } + + var configs = ConfigureOCSP(); + var replacement = new List(configs.Count); + + _mu.EnterWriteLock(); + try + { + _ocspPeerVerify = false; + } + finally + { + _mu.ExitWriteLock(); + } + + foreach (var config in configs) + { + if (config.Kind != LeafKindName) + { + var (tlsConfig, monitor, error) = NewOCSPMonitor(config); + if (error != null) + { + return error; + } + + if (monitor != null && tlsConfig != null) + { + replacement.Add(monitor); + config.Apply(tlsConfig); + } + } + } + + _mu.EnterWriteLock(); + try + { + _ocsps = replacement.Count == 0 ? null : [.. replacement]; + } + finally + { + _mu.ExitWriteLock(); + } + + StartOCSPMonitoring(); + return null; + } +} diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/NatsServerOcspTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/NatsServerOcspTests.cs new file mode 100644 index 0000000..b040a11 --- /dev/null +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/NatsServerOcspTests.cs @@ -0,0 +1,189 @@ +using System.Net.Security; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using Shouldly; +using ZB.MOM.NatsNet.Server; + +namespace ZB.MOM.NatsNet.Server.Tests; + +public sealed class NatsServerOcspTests : IDisposable +{ + private readonly List _tempDirs = []; + private readonly List _certs = []; + + [Fact] + public void SetupOCSPStapleStoreDir_WithStoreDir_CreatesDirectory() + { + var dir = MakeTempDir(); + var server = NewServer(new ServerOptions + { + StoreDir = dir, + }); + + var err = server.SetupOCSPStapleStoreDir(); + + err.ShouldBeNull(); + Directory.Exists(Path.Combine(dir, "ocsp")).ShouldBeTrue(); + } + + [Fact] + public void ConfigureOCSP_WithTlsConfig_ReturnsClientEntry() + { + var cert = CreateSelfSignedCertificate("CN=configure-ocsp"); + var server = NewServer(new ServerOptions + { + TlsConfig = new SslServerAuthenticationOptions { ServerCertificate = cert }, + }); + + var configs = server.ConfigureOCSP(); + + configs.Count.ShouldBe(1); + configs[0].Kind.ShouldBe("client"); + } + + [Fact] + public void NewOCSPMonitor_OcspNever_ReturnsNoMonitor() + { + var cert = CreateSelfSignedCertificate("CN=monitor-never"); + var server = NewServer(new ServerOptions + { + StoreDir = MakeTempDir(), + OcspConfig = new OcspConfig { Mode = ZB.MOM.NatsNet.Server.OcspMode.Never }, + }); + + var config = new OcspTlsConfig + { + Kind = "client", + TlsConfig = new SslServerAuthenticationOptions { ServerCertificate = cert }, + TlsOptions = null, + Apply = _ => { }, + }; + + var (_, monitor, err) = server.NewOCSPMonitor(config); + + err.ShouldBeNull(); + monitor.ShouldBeNull(); + } + + [Fact] + public void EnableOCSP_WithAlwaysMode_AddsMonitor() + { + var cert = CreateSelfSignedCertificate("CN=enable-ocsp"); + var storeDir = MakeTempDir(); + WriteLocalOcspStatus(storeDir, cert); + var server = NewServer(new ServerOptions + { + StoreDir = storeDir, + OcspConfig = new OcspConfig + { + Mode = ZB.MOM.NatsNet.Server.OcspMode.Always, + OverrideUrls = ["https://ocsp.example.test"], + }, + TlsConfig = new SslServerAuthenticationOptions { ServerCertificate = cert }, + }); + + var err = server.EnableOCSP(); + + err.ShouldBeNull(); + server.GetOcspMonitors().Length.ShouldBe(1); + } + + [Fact] + public void StartOCSPMonitoring_NoMonitors_DoesNotThrow() + { + var server = NewServer(new ServerOptions()); + + Should.NotThrow(() => server.StartOCSPMonitoring()); + } + + [Fact] + public void ReloadOCSP_WithConfiguredTls_ReplacesMonitors() + { + var cert = CreateSelfSignedCertificate("CN=reload-ocsp"); + var storeDir = MakeTempDir(); + WriteLocalOcspStatus(storeDir, cert); + var server = NewServer(new ServerOptions + { + StoreDir = storeDir, + OcspConfig = new OcspConfig + { + Mode = ZB.MOM.NatsNet.Server.OcspMode.Always, + OverrideUrls = ["https://ocsp.example.test"], + }, + TlsConfig = new SslServerAuthenticationOptions { ServerCertificate = cert }, + }); + server.EnableOCSP().ShouldBeNull(); + server.GetOcspMonitors().Length.ShouldBe(1); + + var err = server.ReloadOCSP(); + + err.ShouldBeNull(); + server.GetOcspMonitors().Length.ShouldBe(1); + } + + [Fact] + public void HasOCSPStatusRequest_CertificateWithoutExtension_ReturnsFalse() + { + var cert = CreateSelfSignedCertificate("CN=no-status-request"); + + OcspHandler.HasOCSPStatusRequest(cert).ShouldBeFalse(); + } + + public void Dispose() + { + foreach (var cert in _certs) + { + cert.Dispose(); + } + + foreach (var dir in _tempDirs) + { + try { Directory.Delete(dir, recursive: true); } catch { } + } + } + + private NatsServer NewServer(ServerOptions options) + { + var (server, err) = NatsServer.NewServer(options); + err.ShouldBeNull(); + return server!; + } + + private X509Certificate2 CreateSelfSignedCertificate(string subject) + { + var req = new CertificateRequest( + subject, + RSA.Create(2048), + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + req.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + req.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(req.PublicKey, false)); + + var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(90)); + _certs.Add(cert); + return cert; + } + + private string MakeTempDir() + { + var path = Path.Combine(Path.GetTempPath(), "nats-ocsp-" + Path.GetRandomFileName()); + Directory.CreateDirectory(path); + _tempDirs.Add(path); + return path; + } + + private static void WriteLocalOcspStatus(string storeDir, X509Certificate2 cert) + { + var key = Convert.ToHexString(SHA256.HashData(cert.RawData)).ToLowerInvariant(); + var ocspDir = Path.Combine(storeDir, "ocsp"); + Directory.CreateDirectory(ocspDir); + var payload = JsonSerializer.SerializeToUtf8Bytes(new + { + Status = 0, + ThisUpdate = DateTime.UtcNow.AddMinutes(-5), + NextUpdate = DateTime.UtcNow.AddHours(6), + }); + File.WriteAllBytes(Path.Combine(ocspDir, key), payload); + } +} diff --git a/porting.db b/porting.db index 6aa7eea0726b4195f59ffc3cc6f00d727345d741..7a380aa37d930213a4eb6e9052803ce8d04030b2 100644 GIT binary patch delta 2237 zcmb7EYiv_x7(VB;U%PXWWoeC1{?5hz`8>x3vS@X#rQ+0 zhIa`eI!-Td35$UL2s*$Rjxk2TpBNEu5lxhfBu3x|f*KN$_^q3TCj9|Fp5%S{z0dPr z`h92L)wfLhI8Gm{6g%HMUTGY!6#pC(`ziX1_=C9E7`P%{GMC#;9!cBYzvJb=&VU>< zUl800Mw~ekEJ|B%h}%WoxO_ugYV||};jz>(XOB`tM&oVq!jSl-!Mde>k@oqeSLvKz zav1mhlEo(3+oJta+j>Wu(6c4b8yFl6Y>{J}txYiEtSP~wocR(g!dZEOg*kI4Sco$R z1%Fp_jdT4{!NPelN0%^nXy>jTnRw)lM^PTlj7Je36~&`4kFw)Yh(}61(uZ@$Bo332 zJ7&IhZI%=iGKFnI#wLd)eb`p4?3ORduC!k3`__ERKCxQZ#xAo4^Ip?cQzfk~6c;GL z#i1;xATGC;(V6q6EV>tAODXfAX$EbMvKlHr%oy|5H`dhAXq5d$hj#LlkBF7WPqO1D ze6M5NqSjt!ua(3c3hQhkW&C6Q+4&=tz#zLmT+XQP}&YeH<+L<4sYxGZ!s#7g3pj*0BLP%h72WT)Ui?rgdnV)G{E zI2SW9>I_e|JUPtUVYZC!8a5A&)EB2mBdqlCQ7a;B72WM<^3b_YO*XptqbZHvi%gcA zbl5+_Vn*3vw#h`diJdS!huBteczBd?SU9X4X&f?#!eQe`=dg1)jN#FY*M`h#&B7lB zQ`PT9nk!0*#d_NMt~G4jbgIY9G?K3#ps|WhH&x}STj-P0PCFUxYPs=6o?7R#vQgol z|hVs9d=zlD8DZ6kbC5{a#Qpbe$8&Qs&>&jUCCDz`2qdybe0;0 zZR#5)v;074qGRpqsvXL*2K##Z_)VuI7B$j4RLSH^`Z1Ek!bvQY#B^%fpq@4J zYtn)_+50~rFqQb)6(pS zrcIV|>TBbrT5^KcN863j`6HT7oI_$v%gY=J4D#1k(z0YtYssFz!M=f)Xv(OT>xq9Z zgHC--3I}>Bhqe?B(Wi1)ox&mVBo6NXr)W*#K+d`T8XDQJS)OzkEh$_=Ptr$Xo2ybd Q=uhIn;*%Kqm^L8(2X$c3x&QzG delta 1117 zcmYk3eN2^A9LJyYJkPoJx%cus_khToT;9asUJVjoF!BX8B`Jb#O7cVWUKmXakOgLI z+F0f9Zh&y*?T1Jz{n1=Cs;zlie>D4-Hpg66Zmz(wmEopsj@A07<7HWYe9k`Ko%20A zFFkVuMvvn2%cZR5y{S?;RmvVtvlBG-Gy9QM$c|swtQj(Av{1|O_RhA}?pDuvm9ihF zxJu6BG)PAm*m2e;e_CLB+<^g2``^;DD*d;#Usl{>GhOU^$s>9@bL@}oal6+(Wp~&S zJ4|mx^|$1oQQhU!9c!#z50mFNVbdq~MHQXCE3)LZc0IFl+j-}ZwzaGKM6)NZtSn)J z%8C;Ct~%w?=slP2J~3R5Qkd%Z0W$k1p@j`JI(SOL4xQW)0IvW#{-qnkx2&sBTC# z5{EiD!LOuPwY$}SoJ-c&T06LUh0az|5%EnlJEV>?H_qMkKJhiQW4wjSOyW&OEH=p% zE`?iRDLe|V!ly`5*a}C+CjD=BnXX>#uH-`8WWxH?x@g6%cdc&gRqLQtEq_lImrc_> zqV1)&)Z|Kv{N;D(cAEIgw5qibC8di(nki}AFGYs7k?Z3Of;Tcs;3#tfP$XbjS4=^~px$Pnc+qh2iOrn=}Bx_Bp2N#O?ZvH7Tg zAP|xfQV>=kq#~?DNJB_R$Uw+M$U<0!kc|*P$U(?O$V13SC_q?^P>8Sw;R%Eygkpps zLI`0k!a9T}5lRqB5uQR=k5GoN0bwJ;CWOrhmgclKN5$X_LLI@+gjIbZ!075;&D+mV>8V~@X z5#bO*6T)GHBM6bJ5M}6g7iza3J*M{yJx6Ywi(X*%RsD)Sq4(?W=$(4AygnbT=cf0% z=1}}jBu#GrH9BmtPV&AkHqhx~>ScGnriS1#VaKW$qq2Ij`Rgw&-5JvS`