From a1d064ed9e412804de0615e01f6912401ef1273b Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Wed, 9 Aug 2017 17:01:37 -0400 Subject: [PATCH] license verification services for user/org --- src/Api/Api.csproj | 8 ++ src/Api/Properties/launchSettings.json | 2 +- src/Api/licensing.cer | Bin 0 -> 1303 bytes src/Billing/Billing.csproj | 4 + src/Billing/licensing.cer | Bin 0 -> 1303 bytes src/Core/GlobalSettings.cs | 1 + src/Core/Models/Business/ILicense.cs | 17 +++ .../Models/Business/OrganizationLicense.cs | 119 ++++++++++++++++++ src/Core/Models/Business/UserLicense.cs | 90 +++++++++++++ src/Core/Models/Table/Organization.cs | 2 + src/Core/Models/Table/User.cs | 1 + .../Services/ILicenseVerificationService.cs | 10 ++ .../RsaLicenseVerificationService.cs | 111 ++++++++++++++++ .../NoopLicenseVerificationService.cs | 29 +++++ .../Utilities/ServiceCollectionExtensions.cs | 10 ++ src/Identity/Identity.csproj | 8 ++ src/Identity/Properties/launchSettings.json | 2 +- src/Identity/licensing.cer | Bin 0 -> 1303 bytes .../Stored Procedures/Organization_Create.sql | 6 + .../Stored Procedures/Organization_Update.sql | 4 + src/Sql/dbo/Stored Procedures/User_Create.sql | 3 + src/Sql/dbo/Stored Procedures/User_Update.sql | 2 + src/Sql/dbo/Tables/Organization.sql | 2 + src/Sql/dbo/Tables/User.sql | 1 + util/Setup/Program.cs | 1 + util/SqlUpdate/2017-08-09_00_OrgSelfHost.sql | 26 ++++ 26 files changed, 457 insertions(+), 2 deletions(-) create mode 100644 src/Api/licensing.cer create mode 100644 src/Billing/licensing.cer create mode 100644 src/Core/Models/Business/ILicense.cs create mode 100644 src/Core/Models/Business/OrganizationLicense.cs create mode 100644 src/Core/Models/Business/UserLicense.cs create mode 100644 src/Core/Services/ILicenseVerificationService.cs create mode 100644 src/Core/Services/Implementations/RsaLicenseVerificationService.cs create mode 100644 src/Core/Services/NoopImplementations/NoopLicenseVerificationService.cs create mode 100644 src/Identity/licensing.cer create mode 100644 util/SqlUpdate/2017-08-09_00_OrgSelfHost.sql diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj index d5aaef9d89..0f6e643b9c 100644 --- a/src/Api/Api.csproj +++ b/src/Api/Api.csproj @@ -9,6 +9,14 @@ ..\..\docker\Docker.dcproj + + + + + + + + diff --git a/src/Api/Properties/launchSettings.json b/src/Api/Properties/launchSettings.json index d56f373353..b846e65755 100644 --- a/src/Api/Properties/launchSettings.json +++ b/src/Api/Properties/launchSettings.json @@ -4,7 +4,7 @@ "anonymousAuthentication": true, "iisExpress": { "applicationUrl": "http://localhost:4000", - "sslPort": 44377 + "sslPort": 0 } }, "profiles": { diff --git a/src/Api/licensing.cer b/src/Api/licensing.cer new file mode 100644 index 0000000000000000000000000000000000000000..0dbb09c3c6e070d788dc84985ef36f955b9758bf GIT binary patch literal 1303 zcmXqLVih)MV*0&+nTe5!iId^=m%=$KUe0VX;AP{~YV&CO&dbQi&B|b)U?^uG!^RxS z!ptL@lvz@qSd@~Qr{I&BoSIjhs^F4ZW*{fdYiMp@VPI)!YGh(;5+%-S0^%A$xkQ-I z#H565Eh8%fa}yIk1JHR~OifIT46pCa*%`=O{^I=xv(xwgeHGQX`$6A@(Pa0xyScqn zbzbf&5#tcZ;5cU)wlsYp%svmnWXbokO1e zRtuSDAmv?UDYu|9RB+kxu*E4I$9~S#4&CLnew)b?t?eogD<=Fi3u25eF!$Q?h4Iv8 zyMUfn-FB^m3xd>{4k-V!+sKtS!E(vWjGY(moe0>v_UOjg{NqO7&y=3Ep0zu$YEt9( z!py_RE7JUrcXEEY_vWF$f!y@x*Lf|!^h-9`ED>qvC`)PIGAm+h$#35257uncm)`c! zT#!dtJ-%JN>&R2Z9*-B|@2XGcYcreQiTG3Y;Mcm?3zzfvpDq1RdTq@fxkhc)HSPCB zf{y(z{NliP)ytsE>wJu0`1Mx@WA;ppE@krkY8$+s<+bI>hgT)zw)@E^XYZ}Z}xCo?P-emz0h&Pz0(s;hi%$% zO1ks8)Aw^v8lw}srw5*BF~6o-r(#{(rl_*{n%K@afAnX?e^=S%%j(U<%*epFIKaTq zKo*!1W%*ddSVWHgEZB7A?d|6of)^h=**3e6$I0`UfjmfBnMJ}ttO2_M9*_cI7FGjh zM#lfhX%3jwfoYDBAvnvg>o`M^+u63=CT?us9E9%ud1Q4!C9yr+IH!ElmE>z|^6R%b zh@Zc*%wpn0_5I>YXNdCKTl;cP->@Z9W$Mf+yV4ie_&XlaV0JZA(&@19s(X|4J1X2@ zwZf0@n{HX=Y4EOCd!m}1{f^k2$nKaRfo(poJ@)2j?G9h_G+7$yn7KOLR)v=dP7B*E5biFSv&3;EiZ1T^ehge@8G5ddf+4PGOj5S$!g*fy@ znHDY4@zt9mo@lCjQ{P|XMxEaGV=~L1&JbCyQ?tAOsY->B+`Ej2qQ^fPtiSEO;yu?lMd|tzo^%MwycV6!?rdK9X??PdvZ-2$#0UEu#2cX;ZH7xVi#PE~L6I=}Uf=(pcIKX?lS4NaE1=r~%}e7JNpsjOj&@{PWa zCfzRQbFPW=-Tib%M(q1N5k1RqXYN{WRzEl4&7{Djxte=6trgFekT7pn2|mMTQWRhM zYx{cXK+DIaCDM#yEVpJ&Rn%8(?Yz|b%{aR{f|WH#ZnJ}a#P`j!@6}d5ecU?rUVH35 ibitwarden-Billing + + + + diff --git a/src/Billing/licensing.cer b/src/Billing/licensing.cer new file mode 100644 index 0000000000000000000000000000000000000000..0dbb09c3c6e070d788dc84985ef36f955b9758bf GIT binary patch literal 1303 zcmXqLVih)MV*0&+nTe5!iId^=m%=$KUe0VX;AP{~YV&CO&dbQi&B|b)U?^uG!^RxS z!ptL@lvz@qSd@~Qr{I&BoSIjhs^F4ZW*{fdYiMp@VPI)!YGh(;5+%-S0^%A$xkQ-I z#H565Eh8%fa}yIk1JHR~OifIT46pCa*%`=O{^I=xv(xwgeHGQX`$6A@(Pa0xyScqn zbzbf&5#tcZ;5cU)wlsYp%svmnWXbokO1e zRtuSDAmv?UDYu|9RB+kxu*E4I$9~S#4&CLnew)b?t?eogD<=Fi3u25eF!$Q?h4Iv8 zyMUfn-FB^m3xd>{4k-V!+sKtS!E(vWjGY(moe0>v_UOjg{NqO7&y=3Ep0zu$YEt9( z!py_RE7JUrcXEEY_vWF$f!y@x*Lf|!^h-9`ED>qvC`)PIGAm+h$#35257uncm)`c! zT#!dtJ-%JN>&R2Z9*-B|@2XGcYcreQiTG3Y;Mcm?3zzfvpDq1RdTq@fxkhc)HSPCB zf{y(z{NliP)ytsE>wJu0`1Mx@WA;ppE@krkY8$+s<+bI>hgT)zw)@E^XYZ}Z}xCo?P-emz0h&Pz0(s;hi%$% zO1ks8)Aw^v8lw}srw5*BF~6o-r(#{(rl_*{n%K@afAnX?e^=S%%j(U<%*epFIKaTq zKo*!1W%*ddSVWHgEZB7A?d|6of)^h=**3e6$I0`UfjmfBnMJ}ttO2_M9*_cI7FGjh zM#lfhX%3jwfoYDBAvnvg>o`M^+u63=CT?us9E9%ud1Q4!C9yr+IH!ElmE>z|^6R%b zh@Zc*%wpn0_5I>YXNdCKTl;cP->@Z9W$Mf+yV4ie_&XlaV0JZA(&@19s(X|4J1X2@ zwZf0@n{HX=Y4EOCd!m}1{f^k2$nKaRfo(poJ@)2j?G9h_G+7$yn7KOLR)v=dP7B*E5biFSv&3;EiZ1T^ehge@8G5ddf+4PGOj5S$!g*fy@ znHDY4@zt9mo@lCjQ{P|XMxEaGV=~L1&JbCyQ?tAOsY->B+`Ej2qQ^fPtiSEO;yu?lMd|tzo^%MwycV6!?rdK9X??PdvZ-2$#0UEu#2cX;ZH7xVi#PE~L6I=}Uf=(pcIKX?lS4NaE1=r~%}e7JNpsjOj&@{PWa zCfzRQbFPW=-Tib%M(q1N5k1RqXYN{WRzEl4&7{Djxte=6trgFekT7pn2|mMTQWRhM zYx{cXK+DIaCDM#yEVpJ&Rn%8(?Yz|b%{aR{f|WH#ZnJ}a#P`j!@6}d5ecU?rUVH35 i Convert.FromBase64String(Signature); + + public byte[] GetSignatureData() + { + string data = null; + if(Version == 1) + { + data = string.Format("organization:{0}_{1}_{2}_{3}_{4}_{5}_{6}_{7}_{8}_{9}_{10}_{11}_{12}_{13}", + Version, + Utilities.CoreHelpers.ToEpocMilliseconds(Issued), + Utilities.CoreHelpers.ToEpocMilliseconds(Expires), + LicenseKey, + Id, + Enabled, + PlanType, + Seats, + MaxCollections, + UseGroups, + UseDirectory, + UseTotp, + MaxStorageGb, + SelfHost); + } + else + { + throw new NotSupportedException($"Version {Version} is not supported."); + } + + return Encoding.UTF8.GetBytes(data); + } + + public bool VerifyData(Organization organization) + { + if(Issued > DateTime.UtcNow) + { + return false; + } + + if(Expires < DateTime.UtcNow) + { + return false; + } + + if(Version == 1) + { + return + organization.LicenseKey.Equals(LicenseKey, StringComparison.InvariantCultureIgnoreCase) && + organization.Enabled == Enabled && + organization.PlanType == PlanType && + organization.Seats == Seats && + organization.MaxCollections == MaxCollections && + organization.UseGroups == UseGroups && + organization.UseDirectory == UseDirectory && + organization.UseTotp == UseTotp && + organization.SelfHost == SelfHost; + } + else + { + throw new NotSupportedException($"Version {Version} is not supported."); + } + } + + public bool VerifySignature(X509Certificate2 certificate) + { + using(var rsa = certificate.GetRSAPublicKey()) + { + return rsa.VerifyData(GetSignatureData(), SignatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + } + } + } +} diff --git a/src/Core/Models/Business/UserLicense.cs b/src/Core/Models/Business/UserLicense.cs new file mode 100644 index 0000000000..3320a19b6a --- /dev/null +++ b/src/Core/Models/Business/UserLicense.cs @@ -0,0 +1,90 @@ +using Bit.Core.Models.Table; +using System; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace Bit.Core.Models.Business +{ + public class UserLicense : ILicense + { + public UserLicense() + { } + + public UserLicense(User user) + { + LicenseKey = ""; + Id = user.Id; + Email = user.Email; + Version = 1; + } + + public string LicenseKey { get; set; } + public Guid Id { get; set; } + public string Email { get; set; } + public bool Premium { get; set; } + public short? MaxStorageGb { get; set; } + public int Version { get; set; } + public DateTime Issued { get; set; } + public DateTime Expires { get; set; } + public bool Trial { get; set; } + public string Signature { get; set; } + public byte[] SignatureBytes => Convert.FromBase64String(Signature); + + public byte[] GetSignatureData() + { + string data = null; + if(Version == 1) + { + data = string.Format("user:{0}_{1}_{2}_{3}_{4}_{5}_{6}_{7}", + Version, + Utilities.CoreHelpers.ToEpocMilliseconds(Issued), + Utilities.CoreHelpers.ToEpocMilliseconds(Expires), + LicenseKey, + Id, + Email, + Premium, + MaxStorageGb); + } + else + { + throw new NotSupportedException($"Version {Version} is not supported."); + } + + return Encoding.UTF8.GetBytes(data); + } + + public bool VerifyData(User user) + { + if(Issued > DateTime.UtcNow) + { + return false; + } + + if(Expires < DateTime.UtcNow) + { + return false; + } + + if(Version == 1) + { + return + user.LicenseKey.Equals(LicenseKey, StringComparison.InvariantCultureIgnoreCase) && + user.Premium == Premium && + user.Email.Equals(Email, StringComparison.InvariantCultureIgnoreCase); + } + else + { + throw new NotSupportedException($"Version {Version} is not supported."); + } + } + + public bool VerifySignature(X509Certificate2 certificate) + { + using(var rsa = certificate.GetRSAPublicKey()) + { + return rsa.VerifyData(GetSignatureData(), SignatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + } + } + } +} diff --git a/src/Core/Models/Table/Organization.cs b/src/Core/Models/Table/Organization.cs index 146ca21c59..e0ba24829e 100644 --- a/src/Core/Models/Table/Organization.cs +++ b/src/Core/Models/Table/Organization.cs @@ -19,12 +19,14 @@ namespace Bit.Core.Models.Table public bool UseGroups { get; set; } public bool UseDirectory { get; set; } public bool UseTotp { get; set; } + public bool SelfHost { get; set; } public long? Storage { get; set; } public short? MaxStorageGb { get; set; } public GatewayType? Gateway { get; set; } public string GatewayCustomerId { get; set; } public string GatewaySubscriptionId { get; set; } public bool Enabled { get; set; } = true; + public string LicenseKey { get; set; } public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow; diff --git a/src/Core/Models/Table/User.cs b/src/Core/Models/Table/User.cs index 40faeb3b98..f242bccf8c 100644 --- a/src/Core/Models/Table/User.cs +++ b/src/Core/Models/Table/User.cs @@ -35,6 +35,7 @@ namespace Bit.Core.Models.Table public GatewayType? Gateway { get; set; } public string GatewayCustomerId { get; set; } public string GatewaySubscriptionId { get; set; } + public string LicenseKey { get; set; } public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow; diff --git a/src/Core/Services/ILicenseVerificationService.cs b/src/Core/Services/ILicenseVerificationService.cs new file mode 100644 index 0000000000..3a077fa1e0 --- /dev/null +++ b/src/Core/Services/ILicenseVerificationService.cs @@ -0,0 +1,10 @@ +using Bit.Core.Models.Table; + +namespace Bit.Core.Services +{ + public interface ILicenseVerificationService + { + bool VerifyOrganizationPlan(Organization organization); + bool VerifyUserPremium(User user); + } +} diff --git a/src/Core/Services/Implementations/RsaLicenseVerificationService.cs b/src/Core/Services/Implementations/RsaLicenseVerificationService.cs new file mode 100644 index 0000000000..d684707d97 --- /dev/null +++ b/src/Core/Services/Implementations/RsaLicenseVerificationService.cs @@ -0,0 +1,111 @@ +using Bit.Core.Models.Business; +using Bit.Core.Models.Table; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Hosting; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace Bit.Core.Services +{ + public class RsaLicenseVerificationService : ILicenseVerificationService + { + private readonly X509Certificate2 _certificate; + private readonly GlobalSettings _globalSettings; + private IDictionary _userLicenseCache; + private IDictionary _organizationLicenseCache; + + public RsaLicenseVerificationService( + IHostingEnvironment environment, + GlobalSettings globalSettings) + { + if(!environment.IsDevelopment() && !globalSettings.SelfHosted) + { + throw new Exception($"{nameof(RsaLicenseVerificationService)} can only be used for self hosted instances."); + } + + _globalSettings = globalSettings; + _certificate = CoreHelpers.GetCertificate("licensing.crt", null); + if(false && !_certificate.Thumbprint.Equals("")) + { + throw new Exception("Invalid licensing certificate."); + } + + if(!CoreHelpers.SettingHasValue(_globalSettings.LicenseDirectory)) + { + throw new InvalidOperationException("No license directory."); + } + } + + public bool VerifyOrganizationPlan(Organization organization) + { + if(_globalSettings.SelfHosted && !organization.SelfHost) + { + return false; + } + + var license = ReadOrganiztionLicense(organization); + return license != null && license.VerifyData(organization) && license.VerifySignature(_certificate); + } + + public bool VerifyUserPremium(User user) + { + if(!user.Premium) + { + return false; + } + + var license = ReadUserLicense(user); + return license != null && license.VerifyData(user) && license.VerifySignature(_certificate); + } + + private UserLicense ReadUserLicense(User user) + { + if(_userLicenseCache != null && _userLicenseCache.ContainsKey(user.LicenseKey)) + { + return _userLicenseCache[user.LicenseKey]; + } + + var filePath = $"{_globalSettings.LicenseDirectory}/user/{user.LicenseKey}.json"; + if(!File.Exists(filePath)) + { + return null; + } + + var data = File.ReadAllText(filePath, Encoding.UTF8); + var obj = JsonConvert.DeserializeObject(data); + if(_userLicenseCache == null) + { + _userLicenseCache = new Dictionary(); + } + _userLicenseCache.Add(obj.LicenseKey, obj); + return obj; + } + + private OrganizationLicense ReadOrganiztionLicense(Organization organization) + { + if(_organizationLicenseCache != null && _organizationLicenseCache.ContainsKey(organization.LicenseKey)) + { + return _organizationLicenseCache[organization.LicenseKey]; + } + + var filePath = $"{_globalSettings.LicenseDirectory}/organization/{organization.LicenseKey}.json"; + if(!File.Exists(filePath)) + { + return null; + } + + var data = File.ReadAllText(filePath, Encoding.UTF8); + var obj = JsonConvert.DeserializeObject(data); + if(_organizationLicenseCache == null) + { + _organizationLicenseCache = new Dictionary(); + } + _organizationLicenseCache.Add(obj.LicenseKey, obj); + return obj; + } + } +} diff --git a/src/Core/Services/NoopImplementations/NoopLicenseVerificationService.cs b/src/Core/Services/NoopImplementations/NoopLicenseVerificationService.cs new file mode 100644 index 0000000000..b88cea21df --- /dev/null +++ b/src/Core/Services/NoopImplementations/NoopLicenseVerificationService.cs @@ -0,0 +1,29 @@ +using Bit.Core.Models.Table; +using Microsoft.AspNetCore.Hosting; +using System; + +namespace Bit.Core.Services +{ + public class NoopLicenseVerificationService : ILicenseVerificationService + { + public NoopLicenseVerificationService( + IHostingEnvironment environment, + GlobalSettings globalSettings) + { + if(!environment.IsDevelopment() && globalSettings.SelfHosted) + { + throw new Exception($"{nameof(NoopLicenseVerificationService)} cannot be used for self hosted instances."); + } + } + + public bool VerifyOrganizationPlan(Organization organization) + { + return true; + } + + public bool VerifyUserPremium(User user) + { + return user.Premium; + } + } +} diff --git a/src/Core/Utilities/ServiceCollectionExtensions.cs b/src/Core/Utilities/ServiceCollectionExtensions.cs index 4e7c50a61e..571ee4d6bc 100644 --- a/src/Core/Utilities/ServiceCollectionExtensions.cs +++ b/src/Core/Utilities/ServiceCollectionExtensions.cs @@ -107,6 +107,15 @@ namespace Bit.Core.Utilities { services.AddSingleton(); } + + if(globalSettings.SelfHosted) + { + services.AddSingleton(); + } + else + { + services.AddSingleton(); + } } public static void AddNoopServices(this IServiceCollection services) @@ -117,6 +126,7 @@ namespace Bit.Core.Utilities services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); } public static IdentityBuilder AddCustomIdentityServices( diff --git a/src/Identity/Identity.csproj b/src/Identity/Identity.csproj index d8e8b58bad..e962ebd5a8 100644 --- a/src/Identity/Identity.csproj +++ b/src/Identity/Identity.csproj @@ -9,6 +9,14 @@ ..\..\docker\Docker.dcproj + + + + + + + + diff --git a/src/Identity/Properties/launchSettings.json b/src/Identity/Properties/launchSettings.json index d822506f3f..2edb5e39ff 100644 --- a/src/Identity/Properties/launchSettings.json +++ b/src/Identity/Properties/launchSettings.json @@ -4,7 +4,7 @@ "anonymousAuthentication": true, "iisExpress": { "applicationUrl": "http://localhost:33656/", - "sslPort": 44392 + "sslPort": 0 } }, "profiles": { diff --git a/src/Identity/licensing.cer b/src/Identity/licensing.cer new file mode 100644 index 0000000000000000000000000000000000000000..0dbb09c3c6e070d788dc84985ef36f955b9758bf GIT binary patch literal 1303 zcmXqLVih)MV*0&+nTe5!iId^=m%=$KUe0VX;AP{~YV&CO&dbQi&B|b)U?^uG!^RxS z!ptL@lvz@qSd@~Qr{I&BoSIjhs^F4ZW*{fdYiMp@VPI)!YGh(;5+%-S0^%A$xkQ-I z#H565Eh8%fa}yIk1JHR~OifIT46pCa*%`=O{^I=xv(xwgeHGQX`$6A@(Pa0xyScqn zbzbf&5#tcZ;5cU)wlsYp%svmnWXbokO1e zRtuSDAmv?UDYu|9RB+kxu*E4I$9~S#4&CLnew)b?t?eogD<=Fi3u25eF!$Q?h4Iv8 zyMUfn-FB^m3xd>{4k-V!+sKtS!E(vWjGY(moe0>v_UOjg{NqO7&y=3Ep0zu$YEt9( z!py_RE7JUrcXEEY_vWF$f!y@x*Lf|!^h-9`ED>qvC`)PIGAm+h$#35257uncm)`c! zT#!dtJ-%JN>&R2Z9*-B|@2XGcYcreQiTG3Y;Mcm?3zzfvpDq1RdTq@fxkhc)HSPCB zf{y(z{NliP)ytsE>wJu0`1Mx@WA;ppE@krkY8$+s<+bI>hgT)zw)@E^XYZ}Z}xCo?P-emz0h&Pz0(s;hi%$% zO1ks8)Aw^v8lw}srw5*BF~6o-r(#{(rl_*{n%K@afAnX?e^=S%%j(U<%*epFIKaTq zKo*!1W%*ddSVWHgEZB7A?d|6of)^h=**3e6$I0`UfjmfBnMJ}ttO2_M9*_cI7FGjh zM#lfhX%3jwfoYDBAvnvg>o`M^+u63=CT?us9E9%ud1Q4!C9yr+IH!ElmE>z|^6R%b zh@Zc*%wpn0_5I>YXNdCKTl;cP->@Z9W$Mf+yV4ie_&XlaV0JZA(&@19s(X|4J1X2@ zwZf0@n{HX=Y4EOCd!m}1{f^k2$nKaRfo(poJ@)2j?G9h_G+7$yn7KOLR)v=dP7B*E5biFSv&3;EiZ1T^ehge@8G5ddf+4PGOj5S$!g*fy@ znHDY4@zt9mo@lCjQ{P|XMxEaGV=~L1&JbCyQ?tAOsY->B+`Ej2qQ^fPtiSEO;yu?lMd|tzo^%MwycV6!?rdK9X??PdvZ-2$#0UEu#2cX;ZH7xVi#PE~L6I=}Uf=(pcIKX?lS4NaE1=r~%}e7JNpsjOj&@{PWa zCfzRQbFPW=-Tib%M(q1N5k1RqXYN{WRzEl4&7{Djxte=6trgFekT7pn2|mMTQWRhM zYx{cXK+DIaCDM#yEVpJ&Rn%8(?Yz|b%{aR{f|WH#ZnJ}a#P`j!@6}d5ecU?rUVH35 i