Private/License.ps1

<#
.SYNOPSIS
    Offline verification of an EntraTripwire Pro licence, and the entitlement the rest of
    the module reads.

.DESCRIPTION
    Everything that DETECTS is free: planting decoys, collecting touches, the whole
    confidence engine, the HTML report. Pro unlocks the parts that ACT or scale --
    executing containment, emitting to a SOAR/SIEM pipeline, and running more than one
    tenant profile.

    The licence is an ECDSA P-256 token verified entirely offline against a public key
    embedded below. No account, no callback, no telemetry: a detection tool that phones
    home during an incident is a detection tool that leaks the incident.

    A key is TRIPWIRE-<base64url(payloadJson)>.<base64url(DER-signature)>.

    Ed25519 has no native verifier in .NET; P-256 is verified by
    System.Security.Cryptography.ECDsa with no third-party dependency, which keeps the
    module installable with nothing but PowerShell 7.
#>


# Embedded verification public key (SubjectPublicKeyInfo, base64). Public by design: it can
# verify a signature and can never mint one. The signing key stays with the project owner.
$script:LicensePublicKeySpki = 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEHuoPb6Um/DahcfIjvehd4WtL/At7LWdqquUeWLZTN9EFUcN7A/HMkWa5ZAhwGEF8LvTXYSOABj66A8zXfPrDcw=='

$script:LicenseKeyPrefix = 'TRIPWIRE-'
$script:ProFeatures = @('containment', 'soar', 'multitenant')

<#
.SYNOPSIS
    Decode a base64url string to bytes.
#>

function ConvertFrom-TripwireBase64Url {
    [CmdletBinding()]
    [OutputType([byte[]])]
    param([Parameter(Mandatory)] [string] $Text)

    $s = $Text.Replace('-', '+').Replace('_', '/')
    switch ($s.Length % 4) { 2 { $s += '==' } 3 { $s += '=' } }
    return [Convert]::FromBase64String($s)
}

<#
.SYNOPSIS
    Verify a licence key offline. Returns an object with Valid, Payload and Reason.
.PARAMETER Key
    The licence key string.
.PARAMETER PublicKeySpki
    Override the embedded key (base64 SPKI). For tests, so the real signing key is never
    needed to exercise the mechanism.
.PARAMETER NowUnix
    Current time as epoch seconds, for deterministic expiry tests.
#>

function Test-TripwireLicenseKey {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [AllowNull()] [AllowEmptyString()] [string] $Key,
        [string] $PublicKeySpki = $script:LicensePublicKeySpki,
        [int64] $NowUnix = [System.DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
    )

    function fail($reason) { [pscustomobject]@{ Valid = $false; Payload = $null; Reason = $reason } }

    if ([string]::IsNullOrWhiteSpace($Key) -or -not $Key.StartsWith($script:LicenseKeyPrefix)) {
        return fail('The key is not an EntraTripwire licence key.')
    }
    $body = $Key.Substring($script:LicenseKeyPrefix.Length)
    $dot = $body.IndexOf('.')
    if ($dot -lt 1) { return fail('The key is malformed (missing signature).') }

    try {
        $payloadBytes = ConvertFrom-TripwireBase64Url -Text $body.Substring(0, $dot)
        $signature = ConvertFrom-TripwireBase64Url -Text $body.Substring($dot + 1)
    }
    catch {
        return fail('The key is not valid base64url.')
    }

    $ecdsa = [System.Security.Cryptography.ECDsa]::Create()
    try {
        $ecdsa.ImportSubjectPublicKeyInfo([Convert]::FromBase64String($PublicKeySpki), [ref] 0)
    }
    catch {
        return fail('The embedded public key could not be loaded.')
    }

    $ok = $false
    try {
        $ok = $ecdsa.VerifyData(
            $payloadBytes, $signature,
            [System.Security.Cryptography.HashAlgorithmName]::SHA256,
            [System.Security.Cryptography.DSASignatureFormat]::Rfc3279DerSequence)
    }
    catch { $ok = $false }

    if (-not $ok) { return fail('The signature does not verify. This key was not issued for EntraTripwire.') }

    try {
        $payload = [System.Text.Encoding]::UTF8.GetString($payloadBytes) | ConvertFrom-Json
    }
    catch {
        return fail('The signed payload is not valid JSON.')
    }

    if ($payload.PSObject.Properties.Name -contains 'exp' -and $NowUnix -gt [int64]$payload.exp) {
        return [pscustomobject]@{ Valid = $false; Payload = $payload; Reason = 'The licence expired.' }
    }

    return [pscustomobject]@{ Valid = $true; Payload = $payload; Reason = $null }
}

<#
.SYNOPSIS
    The current entitlement, re-verified from disk (or $env:ENTRATRIPWIRE_LICENSE_KEY) on
    every call.
.DESCRIPTION
    Re-verified rather than cached at import: a licence that expires mid-session stops
    working in that session, and a test that activates a licence cannot leak entitlement
    into the next test.
#>

function Get-TripwireEntitlement {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param([AllowNull()] [string] $Key)

    if (-not $Key) { $Key = $env:ENTRATRIPWIRE_LICENSE_KEY }
    if (-not $Key) {
        $path = Get-TripwireStatePath -Name 'license.json'
        if (Test-Path -LiteralPath $path) {
            try { $Key = (Get-Content -LiteralPath $path -Raw | ConvertFrom-Json).key } catch { $Key = $null }
        }
    }
    if (-not $Key) {
        return [pscustomobject]@{ Pro = $false; Plan = $null; Features = @(); Sub = $null; Reason = 'No licence activated.' }
    }

    $r = Test-TripwireLicenseKey -Key $Key
    if (-not $r.Valid) {
        return [pscustomobject]@{ Pro = $false; Plan = $null; Features = @(); Sub = $null; Reason = $r.Reason }
    }

    $features = if ($r.Payload.PSObject.Properties.Name -contains 'features' -and $r.Payload.features) {
        @($r.Payload.features)
    }
    else { $script:ProFeatures }

    [pscustomobject]@{ Pro = $true; Plan = $r.Payload.plan; Features = $features; Sub = $r.Payload.sub; Reason = $null }
}

<#
.SYNOPSIS
    Does the current entitlement include a named Pro feature?
#>

function Test-TripwireFeature {
    [CmdletBinding()]
    [OutputType([bool])]
    param([Parameter(Mandatory)] [string] $Feature)

    $ent = Get-TripwireEntitlement
    if (-not $ent.Pro) { return $false }
    return (@($ent.Features) -contains $Feature)
}