Private/LicenseManager.ps1
|
# Module-level license cache ā populated by Get-SPCLicenseInfo, cleared by Register-SPCLicense # and Disconnect-SPCTenant. Not persisted across PowerShell sessions. $script:SPCLicenseCache = $null function ConvertFrom-Base64UrlInternal { param([string]$InputString) $s = $InputString.Replace('-', '+').Replace('_', '/') switch ($s.Length % 4) { 2 { $s += '==' } 3 { $s += '=' } } [System.Convert]::FromBase64String($s) } function ConvertFrom-HexStringInternal { param([string]$Hex) $bytes = [byte[]]::new($Hex.Length / 2) for ($i = 0; $i -lt $Hex.Length; $i += 2) { $bytes[$i / 2] = [System.Convert]::ToByte($Hex.Substring($i, 2), 16) } $bytes } function Compare-ByteArrayConstantTimeInternal { param([byte[]]$A, [byte[]]$B) if ($A.Length -ne $B.Length) { return $false } $result = 0 for ($i = 0; $i -lt $A.Length; $i++) { $result = $result -bor ($A[$i] -bxor $B[$i]) } return ($result -eq 0) } function Get-SPCLicensePathInternal { Join-Path ([System.Environment]::GetFolderPath('ApplicationData')) 'SPClean' 'license.lic' } function Get-SPCSecretKeyBytesInternal { # THIS VALUE IS REPLACED AT BUILD TIME BY tools/Inject-Secret.ps1 # DO NOT COMMIT A REAL KEY HERE $hexSecret = 'a3f8e2d1c4b7960524a1e38f7c2d90b5461fe8a237c90d5b8e4f1a263c7d9e0b' ConvertFrom-HexStringInternal -Hex $hexSecret } function Test-SPCLicenseKey { [OutputType([PSCustomObject])] param( [Parameter(Mandatory)] [string]$LicenseKey, [Parameter(Mandatory)] [byte[]]$SecretKeyBytes ) $makeResult = { param([bool]$Valid, [string]$Reason, [string]$Tier, [string]$Email, [string]$LicId, $Exp, $Iss) $r = [PSCustomObject][ordered]@{ IsValid = $Valid Tier = $Tier Email = $Email LicenseId = $LicId ExpiresAt = $Exp IssuedAt = $Iss FailureReason = $Reason } $r.PSObject.TypeNames.Insert(0, 'SPC.LicenseValidation') $r } # Step 1: trim $trimmed = $LicenseKey.Trim() # Step 2: split on '-' with limit 3 $parts = $trimmed -split '-', 3 if ($parts.Count -ne 3 -or $parts[0] -ne 'SPCLEAN') { return (& $makeResult $false 'MalformedFormat' $null $null $null $null $null) } $tier = $parts[1] $remainder = $parts[2] # Step 3: validate tier if ($tier -notin @('PRO', 'CONSULTANT')) { return (& $makeResult $false 'InvalidTier' $null $null $null $null $null) } # Step 4: extract sig (always exactly 43 Base64URL chars: HMAC-SHA256 = 32 bytes) # Using fixed-length avoids ambiguity when sig contains '-' chars (valid in Base64URL) $sigB64Len = 43 $sepPos = $remainder.Length - $sigB64Len - 1 if ($sepPos -lt 1 -or $remainder[$sepPos] -ne [char]'-') { return (& $makeResult $false 'MalformedFormat' $null $null $null $null $null) } $payloadB64 = $remainder.Substring(0, $sepPos) $sigB64 = $remainder.Substring($sepPos + 1) # Step 5: decode sig try { $sigBytes = ConvertFrom-Base64UrlInternal -InputString $sigB64 } catch { return (& $makeResult $false 'Base64DecodeFailure' $null $null $null $null $null) } # Step 6: HMAC check BEFORE JSON decode (prevent oracle attacks) $hmac = $null $expectedSig = $null try { $hmac = [System.Security.Cryptography.HMACSHA256]::new($SecretKeyBytes) $expectedSig = $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($payloadB64)) } finally { if ($null -ne $hmac) { $hmac.Dispose() } } if (-not (Compare-ByteArrayConstantTimeInternal -A $expectedSig -B $sigBytes)) { return (& $makeResult $false 'SignatureMismatch' $null $null $null $null $null) } # Step 7: decode payload try { $payloadBytes = ConvertFrom-Base64UrlInternal -InputString $payloadB64 $payloadJson = [System.Text.Encoding]::UTF8.GetString($payloadBytes) } catch { return (& $makeResult $false 'Base64DecodeFailure' $null $null $null $null $null) } # Step 8: parse JSON try { $payload = $payloadJson | ConvertFrom-Json } catch { return (& $makeResult $false 'InvalidPayloadJson' $null $null $null $null $null) } foreach ($field in @('tier', 'expiry', 'licenseId', 'email', 'issuedAt')) { if ($null -eq $payload.$field -or [string]::IsNullOrWhiteSpace([string]$payload.$field)) { return (& $makeResult $false 'InvalidPayloadJson' $null $null $null $null $null) } } # Step 9: check expiry try { $expiry = [datetime]::Parse( $payload.expiry, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::RoundtripKind) } catch { return (& $makeResult $false 'InvalidPayloadJson' $null $null $null $null $null) } if ($expiry -le [datetime]::UtcNow) { return (& $makeResult $false 'Expired' $null $null $null $null $null) } # Step 10: check issuedAt (allow 5-minute clock skew) try { $issuedAt = [datetime]::Parse( $payload.issuedAt, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::RoundtripKind) } catch { return (& $makeResult $false 'InvalidPayloadJson' $null $null $null $null $null) } if ($issuedAt -gt [datetime]::UtcNow.AddMinutes(5)) { return (& $makeResult $false 'FutureIssuedAt' $null $null $null $null $null) } # Step 11: all checks passed return (& $makeResult $true $null $payload.tier $payload.email $payload.licenseId $expiry $issuedAt) } function Assert-SPCProLicense { param([Parameter(Mandatory)] [string]$Feature) $info = Get-SPCLicenseInfo if ($info.Status -eq 'Active' -and $info.Tier -in @('PRO', 'CONSULTANT')) { return } throw "ERR-LIC-003: '$Feature' requires a Pro or Consultant license.`nCurrent status: $($info.Status).`nā Purchase at: https://spclean.gumroad.com`nā Register with: Register-SPCLicense -LicenseKey 'SPCLEAN-PRO-...'" } function Assert-SPCConsultantLicense { param([Parameter(Mandatory)] [string]$Feature) $info = Get-SPCLicenseInfo if ($info.Status -eq 'Active' -and $info.Tier -eq 'CONSULTANT') { return } throw "ERR-LIC-004: '$Feature' requires a Consultant license.`nCurrent status: $($info.Status). Tier: $($info.Tier).`nā Upgrade at: https://spclean.gumroad.com" } |