using System; using System.Text; using System.Threading.Tasks; using System.IO; using System.Collections.Generic; using System.Net.Http; using AyaNova.Util; using AyaNova.Models; using System.Linq; using Microsoft.Extensions.Logging; using Microsoft.EntityFrameworkCore; //JSON KEY using Org.BouncyCastle.Security; using Org.BouncyCastle.OpenSsl; namespace AyaNova.Core { internal static class License { //License server address private const string LICENSE_SERVER_URL = "https://rockfish.ayanova.com/"; // private const string LICENSE_SERVER_URL = "http://localhost:5000/"; //Scheduleable users private const string SERVICE_TECHS_FEATURE_NAME = "ServiceTechs"; //Accounting add-on private const string ACCOUNTING_FEATURE_NAME = "Accounting"; //This feature name means it's a trial key private const string TRIAL_FEATURE_NAME = "TrialMode"; //This feature name means it's a SAAS or rental mode key for month to month hosted service private const string RENTAL_FEATURE_NAME = "ServiceMode"; //Trial key magic number for development and testing, all other guids will be fully licensed private static Guid TEST_TRIAL_KEY_DBID = new Guid("{A6D18A8A-5613-4979-99DA-80D07641A2FE}"); //Current license key, can be empty private static AyaNovaLicenseKey _ActiveLicense = new AyaNovaLicenseKey(); //The one and only DBID private static Guid DbId { get; set; } #region license classes //DTO object returned on license query internal class LicenseFeature { //name of feature / product public string Feature { get; set; } //Optional count for items that require it public long Count { get; set; } } //DTO object for parsed key internal class AyaNovaLicenseKey { public AyaNovaLicenseKey() { Features = new List(); RegisteredTo = "UNLICENSED"; Id = RegisteredTo; } /// /// Fetch the license status of the feature in question /// /// /// LicenseFeature object or null if there is no license public LicenseFeature GetLicenseFeature(string Feature) { if (IsEmpty) return null; string lFeature = Feature.ToLowerInvariant(); foreach (LicenseFeature l in Features) { if (l.Feature.ToLowerInvariant() == lFeature) { return l; } } return null; } public long ActiveNumber { get { return GetLicenseFeature(SERVICE_TECHS_FEATURE_NAME).Count; } } /// /// Check for the existance of license feature /// /// /// bool public bool HasLicenseFeature(string Feature) { if (IsEmpty) return false; string lFeature = Feature.ToLowerInvariant(); foreach (LicenseFeature l in Features) { if (l.Feature.ToLowerInvariant() == lFeature) { return true; } } return false; } public bool IsEmpty { get { //Key is empty if it's not registered to anyone or there are no features in it return string.IsNullOrWhiteSpace(RegisteredTo) || (Features == null || Features.Count == 0); } } public bool WillExpire { get { return LicenseExpiration < DateUtil.EmptyDateValue; } } public bool LicenseExpired { get { return LicenseExpiration < DateTime.Now; } } public bool MaintenanceExpired { get { return MaintenanceExpiration < DateTime.Now; } } public bool TrialLicense { get { return HasLicenseFeature(TRIAL_FEATURE_NAME); } } public bool RentalLicense { get { return HasLicenseFeature(RENTAL_FEATURE_NAME); } } public string LicenseFormat { get; set; } public string Id { get; set; } public string RegisteredTo { get; set; } public Guid DbId { get; set; } public DateTime LicenseExpiration { get; set; } public DateTime MaintenanceExpiration { get; set; } public List Features { get; set; } } #endregion #region sample v8 key // private static string SAMPLE_KEY = @"[KEY // { // ""Key"": { // ""LicenseFormat"": ""2018"", // ""Id"": ""34-1516288681"", <----Customer id followed by key serial id // ""RegisteredTo"": ""Super TestCo"", // ""DBID"": ""df558559-7f8a-4c7b-955c-959ebcdf71f3"", // ""LicenseExpiration"": ""2019-01-18T07:18:01.2329138-08:00"", <--- UTC, DateTime if perpetual license 1/1/5555 indicates not expiring // ""MaintenanceExpiration"": ""2019-01-18T07:18:01.2329138-08:00"", <-- UTC, DateTime support and updates subscription runs out, applies to all features // ""Features"": { // ""Feature"": [ // { // ""Name"": ""Scheduleable users"", // ""Count"":""10"", // }, // { // ""Name"": ""Accounting"" // }, // { // ""Name"": ""TrialMode""<---means is a trial key // }, // { // ""Name"": ""ServiceMode"" <----Means it's an SAAS/Rental key // } // ] // } // } // } // KEY] // [SIGNATURE // HEcY3JCVwK9HFXEFnldUEPXP4Q7xoZfMZfOfx1cYmfVF3MVWePyZ9dqVZcY7pk3RmR1BbhQdhpljsYLl+ZLTRhNa54M0EFa/bQnBnbwYZ70EQl8fz8WOczYTEBo7Sm5EyC6gSHtYZu7yRwBvhQzpeMGth5uWnlfPb0dMm0DQM7PaqhdWWW9GCSOdZmFcxkFQ8ERLDZhVMbd8PJKyLvZ+sGMrmYTAIoL0tqa7nrxYkM71uJRTAmQ0gEl4bJdxiV825U1J+buNQuTZdacZKEPSjQQkYou10jRbReUmP2vDpvu+nA1xdJe4b5LlyQL+jiIXH17lf93xlCUb0UkDpu8iNQ== // SIGNATURE]\"; #endregion #region Exposed properties /// /// Fetch a summary of the license key for displaying to the end user /// /// string containing current license information internal static string LicenseInfo { get { StringBuilder sb = new StringBuilder(); if (ActiveKey.IsEmpty) { sb.AppendLine("UNLICENSED"); sb.AppendLine($"DB ID: {DbId}"); } else { if (ActiveKey.TrialLicense) sb.AppendLine("TRIAL LICENSE FOR EVALUATION PURPOSES ONLY"); sb.AppendLine($"Registered to: {ActiveKey.RegisteredTo}"); sb.AppendLine($"Key ID: {ActiveKey.Id}"); sb.AppendLine($"DB ID: {DbId}"); sb.AppendLine($"Type: {(ActiveKey.RentalLicense ? "Service" : "Perpetual")}"); if (ActiveKey.WillExpire) sb.AppendLine($"License expires: {DateUtil.ServerDateTimeString(ActiveKey.LicenseExpiration)}"); sb.AppendLine($"Maintenance subscription expires: {DateUtil.ServerDateTimeString(ActiveKey.MaintenanceExpiration)}"); sb.AppendLine("Features:"); foreach (LicenseFeature l in ActiveKey.Features) { //don't show the rental or trial features if (l.Feature != TRIAL_FEATURE_NAME && l.Feature != RENTAL_FEATURE_NAME) { if (l.Count != 0) sb.AppendLine($"{l.Feature} - {l.Count}"); else sb.AppendLine($"{l.Feature}"); } } } return sb.ToString(); } } /// /// Fetch a summary of the license key for displaying in the log /// /// string containing current license information internal static string LicenseInfoLogFormat { get { StringBuilder sb = new StringBuilder(); if (ActiveKey.IsEmpty) { return $"UNLICENSED, DB ID: {DbId}"; } else { if (ActiveKey.TrialLicense) sb.Append("TRIAL, "); sb.Append($"regto: {ActiveKey.RegisteredTo}, "); sb.Append($"keyid: {ActiveKey.Id}, "); sb.Append($"dbid: {DbId}, "); sb.Append($"type: {(ActiveKey.RentalLicense ? "service" : "perpetual")}, "); if (ActiveKey.WillExpire) sb.Append($"exp: {DateUtil.ServerDateTimeString(ActiveKey.LicenseExpiration)}, "); sb.Append($"maint. sub. exps: {DateUtil.ServerDateTimeString(ActiveKey.MaintenanceExpiration)}, "); sb.Append("feat:"); foreach (LicenseFeature l in ActiveKey.Features) { //don't show the rental or trial features if (l.Feature != TRIAL_FEATURE_NAME && l.Feature != RENTAL_FEATURE_NAME) { if (l.Count != 0) sb.Append($"{l.Feature} - {l.Count}, "); else sb.Append($"{l.Feature}, "); } } } return sb.ToString().Trim().TrimEnd(','); } } /// /// Fetch a summary of the license key for displaying to the end user /// via the API /// /// JSON object containing current license information internal static object LicenseInfoAsJson { get { Newtonsoft.Json.Linq.JObject o = Newtonsoft.Json.Linq.JObject.FromObject(new { license = new { licensedTo = ActiveKey.RegisteredTo, dbId = ActiveKey.DbId, keySerial = ActiveKey.Id, licenseExpiration = ActiveKey.LicenseExpiration, maintenanceExpiration = ActiveKey.MaintenanceExpiration, features = from f in ActiveKey.Features orderby f.Feature select new { Feature = f.Feature, Count = f.Count } } }); return o; } } /// /// Returns the active key /// /// internal static AyaNovaLicenseKey ActiveKey { get { return _ActiveLicense; } } #endregion #region Trial license request handling /// /// Request a key /// /// Result string internal static async Task RequestTrialAsync(string email, string regto, ILogger log) { //BEFORE_RELEASE: DO THE FOLLOWING BEFORE RELEASE: //TODO: TESTING REMOVE BEFORE RELEASE //for test purposes if this route is hit and this code executed then the dbid is temporarily changed to the special trial request //dbid so I can test remotely without hassle //TO USE: just hit the trial key request route once then the license fetch route and it should be easy peasy log.LogCritical("WARNING License::RequestTrial - DEVELOPMENT TEST FORCING TRIAL DB KEY ID. UPDATE BEFORE RELEASE!!"); DbId = TEST_TRIAL_KEY_DBID; //TESTING Microsoft.AspNetCore.Http.Extensions.QueryBuilder q = new Microsoft.AspNetCore.Http.Extensions.QueryBuilder(); q.Add("dbid", DbId.ToString()); q.Add("email", email); q.Add("regto", regto); log.LogDebug($"Requesting trial license for DBID {DbId.ToString()}"); string sUrl = $"{LICENSE_SERVER_URL}rvr" + q.ToQueryString(); try { //var res = await _Client.GetStringAsync(sUrl); var res = await ServiceProviderProvider.HttpClientFactory.CreateClient().GetStringAsync(sUrl); return res; } catch (Exception ex) { var msg = "E1020 - Error requesting trial license key see log for details"; log.LogError(ex, msg); return msg; // throw new ApplicationException(msg, ex); } } #endregion trial license request handling #region License fetching and handling /// /// Fetch a key, validate it and install it in the db then initialize with it /// /// Result string internal static async Task FetchKeyAsync(AyaNova.Api.ControllerHelpers.ApiServerState apiServerState, AyContext ct, ILogger log) { log.LogDebug($"Fetching license for DBID {DbId.ToString()}"); string sUrl = $"{LICENSE_SERVER_URL}rvf/{DbId.ToString()}"; //########################################################################################################## //TODO: RELEASE WARNING: this needs to be dealt with before production release if (ServerBootConfig.AYANOVA_SERVER_TEST_MODE) { log.LogInformation("Server is in test mode, fetching trial key"); sUrl = $"{LICENSE_SERVER_URL}rvf/{TEST_TRIAL_KEY_DBID.ToString()}"; // log.LogInformation(sUrl); } //########################################################################################################## try { //string RawTextKeyFromRockfish = await _Client.GetStringAsync(sUrl); string RawTextKeyFromRockfish = await ServiceProviderProvider.HttpClientFactory.CreateClient().GetStringAsync(sUrl); //FUTURE: if there is any kind of error response or REASON or LicenseFetchStatus then here is //where to deal with it AyaNovaLicenseKey ParsedKey = Parse(RawTextKeyFromRockfish, log); if (ParsedKey != null) { await InstallAsync(RawTextKeyFromRockfish, ParsedKey, apiServerState, ct, log); } } catch (Exception ex) { var msg = "E1020 - Error fetching license key"; log.LogError(ex, msg); throw new ApplicationException(msg, ex); } } /// /// Initialize the key /// Handle if first boot scenario to tag DB ID etc /// internal static async Task InitializeAsync(AyaNova.Api.ControllerHelpers.ApiServerState apiServerState, AyContext ct, ILogger log) { log.LogDebug("Initializing license"); try { //Fetch key from db as no tracking so doesn't hang round if need to immediately clear and then re-add the key Models.License ldb = await ct.License.AsNoTracking().SingleOrDefaultAsync(); //Non existent license should restrict server to ops routes only with closed API if (ldb == null) { ldb = new Models.License(); ldb.DbId = Guid.NewGuid(); ldb.Key = "none"; ct.License.Add(ldb); await ct.SaveChangesAsync(); } //ensure DB ID if (ldb.DbId == Guid.Empty) { ldb.DbId = Guid.NewGuid(); //Convert the no tracking record fetched above to tracking //this is required because a prior call to initialize before dumping the db would mean the license is still in memory in the context ct.Entry(ldb).State = Microsoft.EntityFrameworkCore.EntityState.Modified; await ct.SaveChangesAsync(); } //Get it early and set it here so that it can be displayed early to the user even if not licensed DbId = ldb.DbId; if (ldb.Key == "none") { var msg = "E1020 - License key not found in database, running in unlicensed mode"; apiServerState.SetSystemLock(msg); if (ServerBootConfig.AYANOVA_SERVER_TEST_MODE) log.LogDebug(msg); else log.LogWarning(msg); return; } //Validate the key AyaNovaLicenseKey k = Parse(ldb.Key, log); if (k == null) { var msg = "E1020 - License key in database is not valid, running in unlicensed mode"; apiServerState.SetSystemLock(msg); log.LogCritical(msg); return; } _ActiveLicense = k; if (_ActiveLicense.LicenseExpired) { var msg = $"E1020 - License key expired {DateUtil.ServerDateTimeString(_ActiveLicense.LicenseExpiration)}"; apiServerState.SetSystemLock(msg); log.LogCritical(msg); return; } //Has someone been trying funny business with the active techs in the db? if (await AyaNova.Biz.UserBiz.ActiveCountAsync() > _ActiveLicense.ActiveNumber) { var msg = $"E1020 - Active count exceeded capacity"; apiServerState.SetSystemLock(msg); log.LogCritical(msg); return; } //Key is ok, might not have been on first boot so check and clear if locked //This works for now because system lock only means license lock //if ever changed for other purposes then need to handle that see serverstate for ideas if (apiServerState.IsSystemLocked) { apiServerState.ClearSystemLock(); } log.LogDebug("License key OK"); } catch (Exception ex) { var msg = "E1020 - Error initializing license key"; log.LogCritical(ex, msg); apiServerState.SetSystemLock(msg); throw new ApplicationException(msg, ex); } } /// /// Install key to db /// private static async Task InstallAsync(string RawTextNewKey, AyaNovaLicenseKey ParsedNewKey, AyaNova.Api.ControllerHelpers.ApiServerState apiServerState, AyContext ct, ILogger log) { try { var CurrentInDbKeyRecord = await ct.License.OrderBy(x => x.Id).FirstOrDefaultAsync(); if (CurrentInDbKeyRecord == null) throw new ApplicationException("E1020 - Can't install key, no key record found"); if (ParsedNewKey == null) { throw new ApplicationException("E1020 - License.Install -> key could not be parsed"); } //Can't install a trial into a non-empty db if (ParsedNewKey.TrialLicense && !await DbUtil.DBIsEmptyAsync(ct, log)) { throw new ApplicationException("E1020 - Can't install a trial key into a non empty AyaNova database. Erase the database first."); } //TODO: TECHCOUNT - new license causes exceeding count? if (await AyaNova.Biz.UserBiz.ActiveCountAsync() > ParsedNewKey.GetLicenseFeature(SERVICE_TECHS_FEATURE_NAME).Count) { throw new ApplicationException("E1020 - Can't install key, too many active techs and / or subcontractors in database. Deactivate enough to install key."); } //Update current license CurrentInDbKeyRecord.Key = RawTextNewKey; //LOOKAT: reason, resultcode etc //There is similar block related to this in ayschema for db schema version 8 ct.SaveChanges(); } catch (Exception ex) { var msg = "E1020 - Error installing license key"; log.LogError(ex, msg); throw new ApplicationException(msg, ex); } finally { await InitializeAsync(apiServerState, ct, log); } return true; } #endregion #region PARSE and Validate key /// /// Parses and validates the integrity of a passed in textual license key /// /// a populated key if valid or else null private static AyaNovaLicenseKey Parse(string k, ILogger log) { AyaNovaLicenseKey key = new AyaNovaLicenseKey(); log.LogDebug("Validating license"); if (string.IsNullOrWhiteSpace(k)) { throw new ApplicationException("E1020 - License.Parse -> License key is empty and can't be validated"); } try { if (!k.Contains("[KEY") || !k.Contains("KEY]") || !k.Contains("[SIGNATURE") || !k.Contains("SIGNATURE]")) { throw new ApplicationException("E1020 - License.Parse -> License key is missing required delimiters"); } string keyNoWS = System.Text.RegularExpressions.Regex.Replace(StringUtil.Extract(k, "[KEY", "KEY]").Trim(), "(\"(?:[^\"\\\\]|\\\\.)*\")|\\s+", "$1"); string keySig = StringUtil.Extract(k, "[SIGNATURE", "SIGNATURE]").Trim(); #region Check Signature //***** NOTE: this is our real 2016 public key var publicPem = @"-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz7wrvLDcKVMZ31HFGBnL WL08IodYIV5VJkKy1Z0n2snprhSiu3izxTyz+SLpftvKHJpky027ii7l/pL9Bo3J cjU5rKrxXavnE7TuYPjXn16dNLd0K/ERSU+pXLmUaVN0nUWuGuUMoGJMEXoulS6p JiG11yu3BM9fL2Nbj0C6a+UwzEHFmns3J/daZOb4gAzMUdJfh9OJ0+wRGzR8ZxyC 99Na2gDmqYglUkSMjwLTL/HbgwF4OwmoQYJBcET0Wa6Gfb17SaF8XRBV5ZtpCsbS tkthGeoXZkFriB9c1eFQLKpBYQo2DW3H1MPG2nAlQZLbkJj5cSh7/t1bRF08m6P+ EQIDAQAB -----END PUBLIC KEY-----"; PemReader pr = new PemReader(new StringReader(publicPem)); var KeyParameter = (Org.BouncyCastle.Crypto.AsymmetricKeyParameter)pr.ReadObject(); var signer = SignerUtilities.GetSigner("SHA256WITHRSA"); signer.Init(false, KeyParameter); var expectedSig = Convert.FromBase64String(keySig); var msgBytes = Encoding.UTF8.GetBytes(keyNoWS); signer.BlockUpdate(msgBytes, 0, msgBytes.Length); if (!signer.VerifySignature(expectedSig)) { throw new ApplicationException("E1020 - License.Parse -> License key failed integrity check and is not valid"); } #endregion check signature #region Get Values Newtonsoft.Json.Linq.JToken token = Newtonsoft.Json.Linq.JObject.Parse(keyNoWS); key.LicenseFormat = (string)token.SelectToken("Key.LicenseFormat"); if (key.LicenseFormat != "2018") throw new ApplicationException($"E1020 - License.Parse -> License key format {key.LicenseFormat} not recognized"); key.Id = (string)token.SelectToken("Key.Id"); key.RegisteredTo = (string)token.SelectToken("Key.RegisteredTo"); key.DbId = (Guid)token.SelectToken("Key.DBID"); key.LicenseExpiration = (DateTime)token.SelectToken("Key.LicenseExpiration"); key.MaintenanceExpiration = (DateTime)token.SelectToken("Key.MaintenanceExpiration"); //FEATURES Newtonsoft.Json.Linq.JArray p = (Newtonsoft.Json.Linq.JArray)token.SelectToken("Key.Features"); for (int x = 0; x < p.Count; x++) { LicenseFeature lf = new LicenseFeature(); lf.Feature = (string)p[x].SelectToken("Name"); if (p[x].SelectToken("Count") != null) { lf.Count = (long)p[x].SelectToken("Count"); } else { lf.Count = 0; } key.Features.Add(lf); } #endregion get values //All is well return key return key; } catch (Exception ex) { var msg = "E1020 - License key not valid"; log.LogError(ex, msg); throw new ApplicationException(msg, ex); } } #endregion }//eoc }//eons