KeyNub.LicenseDongle.psm1

# KeyNub License Dongle for PowerShell: commands over the KeyNub.LicenseDongle
# .NET assembly (lib/), which calls the native library for the platform
# (runtimes/<platform>/native/).

Set-StrictMode -Version 3.0

$script:Context = $null
$script:LibraryPath = $null

$ExecutionContext.SessionState.Module.OnRemove = {
    if ($null -ne $script:Context) {
        $script:Context.Dispose()
        $script:Context = $null
    }
}

# --- the native library ------------------------------------------------------

function Get-NativeLibraryCandidate {
    # The library this process should load: KEYNUB_LICDONGLE_LIBRARY, else the
    # one inside the module for this platform and architecture.
    if ($env:KEYNUB_LICDONGLE_LIBRARY) {
        return $env:KEYNUB_LICDONGLE_LIBRARY
    }
    if ($PSVersionTable.PSEdition -eq 'Desktop' -or $IsWindows) {
        $arch = if ($PSVersionTable.PSEdition -eq 'Desktop') {
            $env:PROCESSOR_ARCHITECTURE
        } else {
            [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString()
        }
        $rid = switch ($arch) {
            { $_ -in 'AMD64', 'X64' } { 'win-x64' }
            { $_ -in 'x86', 'X86' } { 'win-x86' }
            { $_ -in 'ARM64', 'Arm64' } { 'win-arm64' }
            default { "win-$arch" }
        }
        $file = 'keynub_licdongle.dll'
    } elseif ($IsMacOS) {
        $rid = 'osx'
        $file = 'libkeynub_licdongle.dylib'
    } else {
        $arch = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture
        $rid = if ($arch -eq 'Arm64') { 'linux-arm64' } else { 'linux-x64' }
        $file = 'libkeynub_licdongle.so'
    }
    Join-Path (Join-Path (Join-Path (Join-Path $PSScriptRoot 'runtimes') $rid) 'native') $file
}

function Initialize-NativeLibrary {
    # Loads the native library once per process, before the assembly's first
    # call into it. Windows PowerShell binds the assembly's imports to a library
    # already loaded under the name keynub_licdongle.dll; PowerShell 7 routes
    # them to the file through a resolver.
    param([Parameter(Mandatory)][System.Management.Automation.PSCmdlet]$Cmdlet)

    if ($null -ne $script:LibraryPath) {
        return
    }
    $path = Get-NativeLibraryCandidate
    if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
        $Cmdlet.ThrowTerminatingError([System.Management.Automation.ErrorRecord]::new(
            [System.IO.FileNotFoundException]::new("The KeyNub native library was not found at '$path'.", $path),
            'KeyNub.LibraryNotFound', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $path))
    }
    $path = (Resolve-Path -LiteralPath $path).ProviderPath

    if ($PSVersionTable.PSEdition -eq 'Desktop') {
        if ([System.IO.Path]::GetFileName($path) -ne 'keynub_licdongle.dll') {
            $Cmdlet.ThrowTerminatingError([System.Management.Automation.ErrorRecord]::new(
                [System.ArgumentException]::new("Windows PowerShell loads the KeyNub library only under the file name keynub_licdongle.dll, not '$path'."),
                'KeyNub.LibraryName', [System.Management.Automation.ErrorCategory]::InvalidArgument, $path))
        }
        if (-not ('KeyNub.LicenseDongle.PowerShell.Kernel32' -as [type])) {
            Add-Type -Namespace 'KeyNub.LicenseDongle.PowerShell' -Name 'Kernel32' -MemberDefinition @'
[DllImport("kernel32", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern IntPtr LoadLibraryW(string path);
'@

        }
        $handle = [KeyNub.LicenseDongle.PowerShell.Kernel32]::LoadLibraryW($path)
        if ($handle -eq [IntPtr]::Zero) {
            $code = [System.Runtime.InteropServices.Marshal]::GetLastWin32Error()
            $Cmdlet.ThrowTerminatingError([System.Management.Automation.ErrorRecord]::new(
                [System.ComponentModel.Win32Exception]::new($code, "Could not load the KeyNub library '$path' (Windows error $code)."),
                'KeyNub.LibraryLoad', [System.Management.Automation.ErrorCategory]::ResourceUnavailable, $path))
        }
    } else {
        if (-not ('KeyNub.LicenseDongle.PowerShell.NativeResolver' -as [type])) {
            Add-Type -TypeDefinition @'
using System;
using System.Reflection;
using System.Runtime.InteropServices;

namespace KeyNub.LicenseDongle.PowerShell
{
    public static class NativeResolver
    {
        private static IntPtr handle;

        // Once per process: the assembly stays loaded when the module is
        // removed and imported again, and takes one resolver only.
        public static void Register(Assembly assembly, string path)
        {
            if (handle != IntPtr.Zero)
            {
                return;
            }
            handle = NativeLibrary.Load(path);
            NativeLibrary.SetDllImportResolver(assembly,
                (name, requester, searchPath) => name == "keynub_licdongle" ? handle : IntPtr.Zero);
        }
    }
}
'@

        }
        try {
            [KeyNub.LicenseDongle.PowerShell.NativeResolver]::Register(
                [KeyNub.LicenseDongle.LicenseDongleContext].Assembly, $path)
        } catch {
            $Cmdlet.ThrowTerminatingError([System.Management.Automation.ErrorRecord]::new(
                [System.DllNotFoundException]::new("Could not load the KeyNub library '$path': $($_.Exception.InnerException.Message)"),
                'KeyNub.LibraryLoad', [System.Management.Automation.ErrorCategory]::ResourceUnavailable, $path))
        }
    }
    $script:LibraryPath = $path
}

# --- errors ------------------------------------------------------------------

function Write-LicdError {
    # Ends the calling command with the error a call into the assembly raised.
    # A LicenseDongleException becomes the error's Exception, so its type,
    # Status and Detail reach the caller (its message carries the detail);
    # anything else is raised again as it came.
    param(
        [Parameter(Mandatory)][System.Management.Automation.PSCmdlet]$Cmdlet,
        [Parameter(Mandatory)][System.Management.Automation.ErrorRecord]$ErrorRecord,
        [object]$Target
    )
    $e = $ErrorRecord.Exception
    while ($null -ne $e -and $e -isnot [KeyNub.LicenseDongle.LicenseDongleException]) {
        $e = $e.InnerException
    }
    if ($null -eq $e) {
        $Cmdlet.ThrowTerminatingError($ErrorRecord)
    }
    $category = switch ($e.Status.ToString()) {
        'NoDevice' { 'ObjectNotFound' }
        'NotFound' { 'ObjectNotFound' }
        'NotGenuine' { 'SecurityError' }
        'CertificateInvalid' { 'SecurityError' }
        'AuthRequired' { 'PermissionDenied' }
        'AccessDenied' { 'PermissionDenied' }
        'InvalidArgument' { 'InvalidArgument' }
        'Range' { 'InvalidArgument' }
        'Timeout' { 'OperationTimeout' }
        'Busy' { 'ResourceBusy' }
        'StorageFull' { 'LimitsExceeded' }
        'SessionExpired' { 'InvalidOperation' }
        default { 'NotSpecified' }
    }
    $Cmdlet.ThrowTerminatingError([System.Management.Automation.ErrorRecord]::new($e,
        "KeyNub.$($e.Status)", [System.Management.Automation.ErrorCategory]::$category, $Target))
}

function ConvertTo-LicdByteArray {
    # A byte array as it is; a string as its UTF-8 bytes.
    param(
        [Parameter(Mandatory)][System.Management.Automation.PSCmdlet]$Cmdlet,
        [AllowNull()][object]$Value,
        [string]$Name
    )
    if ($null -eq $Value) {
        return , [byte[]]::new(0)
    }
    if ($Value -is [byte[]]) {
        return , $Value
    }
    if ($Value -is [string]) {
        return , [System.Text.Encoding]::UTF8.GetBytes($Value)
    }
    if ($Value -is [System.Array]) {
        return , [byte[]]$Value
    }
    $Cmdlet.ThrowTerminatingError([System.Management.Automation.ErrorRecord]::new(
        [System.ArgumentException]::new("$Name must be a byte array or a string.", $Name),
        'KeyNub.InvalidData', [System.Management.Automation.ErrorCategory]::InvalidArgument, $Value))
}

function Get-LicdContext {
    # The one context this module opens per PowerShell session, with the
    # native library loaded.
    param([Parameter(Mandatory)][System.Management.Automation.PSCmdlet]$Cmdlet)
    Initialize-NativeLibrary -Cmdlet $Cmdlet
    if ($null -eq $script:Context) {
        try {
            $script:Context = [KeyNub.LicenseDongle.LicenseDongleContext]::Create()
        } catch {
            Write-LicdError -Cmdlet $Cmdlet -ErrorRecord $_
        }
    }
    $script:Context
}

function Read-KeyFile {
    param([Parameter(Mandatory)][string]$Path)
    $full = (Resolve-Path -LiteralPath $Path -ErrorAction Stop).ProviderPath
    , [System.IO.File]::ReadAllBytes($full)
}

# --- library and context -----------------------------------------------------

function Get-KeyNubLibraryVersion {
    <#
    .SYNOPSIS
    The version of the KeyNub native library in use.
    .DESCRIPTION
    Loads the native library if no command has yet, and returns its version,
    which is the version of the SDK it was built from.
    .OUTPUTS
    System.Version
    .EXAMPLE
    Get-KeyNubLibraryVersion
    #>

    [CmdletBinding()]
    [OutputType([System.Version])]
    param()
    Initialize-NativeLibrary -Cmdlet $PSCmdlet
    try {
        [KeyNub.LicenseDongle.LicenseDongleContext]::LibraryVersion
    } catch {
        Write-LicdError -Cmdlet $PSCmdlet -ErrorRecord $_
    }
}

function Set-KeyNubTrustRoot {
    <#
    .SYNOPSIS
    Replaces the root certificate that dongle certificates are verified against.
    .DESCRIPTION
    Applications do not need this: the native library embeds the KeyNub
    production root. It exists for dongles provisioned against a different CA,
    and for vendor tooling. It applies to every later verification in this
    PowerShell session.
    .PARAMETER Certificate
    The root certificate, DER-encoded.
    .EXAMPLE
    Set-KeyNubTrustRoot -Certificate ([System.IO.File]::ReadAllBytes("$PWD/root.der"))
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([void])]
    param([Parameter(Mandatory)][byte[]]$Certificate)
    $ctx = Get-LicdContext -Cmdlet $PSCmdlet
    if ($PSCmdlet.ShouldProcess('the KeyNub context', 'Replace the trust root')) {
        try {
            $ctx.SetTrustRoot($Certificate)
        } catch {
            Write-LicdError -Cmdlet $PSCmdlet -ErrorRecord $_
        }
    }
}

function Get-KeyNubDongle {
    <#
    .SYNOPSIS
    Lists the KeyNub dongles attached to this computer.
    .DESCRIPTION
    Enumerates without opening anything. Each result has Serial, Path (the
    operating system's device path), VendorId and ProductId, and can be piped to
    Connect-KeyNubDongle.
    .OUTPUTS
    KeyNub.LicenseDongle.DeviceInfo
    .EXAMPLE
    Get-KeyNubDongle
    #>

    [CmdletBinding()]
    [OutputType([KeyNub.LicenseDongle.DeviceInfo])]
    param()
    $ctx = Get-LicdContext -Cmdlet $PSCmdlet
    try {
        $ctx.Enumerate()
    } catch {
        Write-LicdError -Cmdlet $PSCmdlet -ErrorRecord $_
    }
}

function Connect-KeyNubDongle {
    <#
    .SYNOPSIS
    Opens a KeyNub dongle.
    .DESCRIPTION
    Opens the first attached dongle, the one with the given serial, or the one
    at the given device path. Close it with Disconnect-KeyNubDongle; one that is
    not closed is released when PowerShell exits or the module is removed.
    .PARAMETER Serial
    The serial of the dongle to open, as Get-KeyNubDongle shows it.
    .PARAMETER DevicePath
    The device path from Get-KeyNubDongle. Binds from the Path property of
    piped input.
    .OUTPUTS
    KeyNub.LicenseDongle.Dongle
    .EXAMPLE
    $dongle = Connect-KeyNubDongle
    .EXAMPLE
    Get-KeyNubDongle | Select-Object -First 1 | Connect-KeyNubDongle
    #>

    [CmdletBinding(DefaultParameterSetName = 'Serial')]
    [OutputType([KeyNub.LicenseDongle.Dongle])]
    param(
        [Parameter(ParameterSetName = 'Serial', Position = 0)]
        [string]$Serial,
        [Parameter(ParameterSetName = 'Path', Mandatory, ValueFromPipelineByPropertyName)]
        [Alias('Path')]
        [string]$DevicePath
    )
    process {
        $ctx = Get-LicdContext -Cmdlet $PSCmdlet
        try {
            if ($PSCmdlet.ParameterSetName -eq 'Path') {
                $ctx.OpenByPath($DevicePath)
            } elseif ($Serial) {
                $ctx.Open($Serial)
            } else {
                # [NullString]: PowerShell would pass $null to a string parameter as "".
                $ctx.Open([NullString]::Value)
            }
        } catch {
            Write-LicdError -Cmdlet $PSCmdlet -ErrorRecord $_ -Target $(if ($DevicePath) { $DevicePath } else { $Serial })
        }
    }
}

function Disconnect-KeyNubDongle {
    <#
    .SYNOPSIS
    Closes a dongle opened with Connect-KeyNubDongle.
    .DESCRIPTION
    Ends any session still open on it and releases the dongle for other
    programs. Safe to call more than once.
    .PARAMETER Dongle
    The dongle to close.
    .EXAMPLE
    Disconnect-KeyNubDongle -Dongle $dongle
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param([Parameter(Mandatory, ValueFromPipeline)][KeyNub.LicenseDongle.Dongle]$Dongle)
    process {
        $Dongle.Dispose()
    }
}

function Get-KeyNubDongleInfo {
    <#
    .SYNOPSIS
    The dongle's firmware and protocol versions, storage and status flags.
    .PARAMETER Dongle
    An open dongle.
    .OUTPUTS
    KeyNub.LicenseDongle.DongleInfo
    .EXAMPLE
    (Get-KeyNubDongleInfo -Dongle $dongle).FirmwareVersion
    #>

    [CmdletBinding()]
    [OutputType([KeyNub.LicenseDongle.DongleInfo])]
    param([Parameter(Mandatory, ValueFromPipeline)][KeyNub.LicenseDongle.Dongle]$Dongle)
    process {
        try {
            $Dongle.GetInfo()
        } catch {
            Write-LicdError -Cmdlet $PSCmdlet -ErrorRecord $_
        }
    }
}

function Test-KeyNubDongle {
    <#
    .SYNOPSIS
    Whether the dongle is genuine: $true or $false.
    .DESCRIPTION
    Verifies the dongle's certificate chain against the trusted root and runs a
    live challenge-response against the key inside the dongle. Fails closed:
    every failure, a closed dongle included, gives $false. For the serial and
    provisioning date, or the reason for a failure, use Confirm-KeyNubDongle.

    A script that ends on "if (-not (Test-KeyNubDongle ...)) { exit }" is one
    line to delete. Put data the script needs through Protect-KeyNubData, and
    ship only the protected form.
    .PARAMETER Dongle
    An open dongle.
    .OUTPUTS
    System.Boolean
    .EXAMPLE
    Test-KeyNubDongle -Dongle $dongle
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([Parameter(Mandatory, ValueFromPipeline)][KeyNub.LicenseDongle.Dongle]$Dongle)
    process {
        try {
            $Dongle.VerifyGenuine().IsGenuine
        } catch {
            $false
        }
    }
}

function Confirm-KeyNubDongle {
    <#
    .SYNOPSIS
    Proves that the dongle is genuine, or raises an error.
    .DESCRIPTION
    Verifies the dongle's certificate chain against the trusted root and runs a
    live challenge-response against the key inside the dongle. Returns the
    identity from the verified certificate; a dongle that fails raises a
    terminating error whose exception is a KeyNub.LicenseDongle.NotGenuineException
    or CertificateInvalidException.
    .PARAMETER Dongle
    An open dongle.
    .OUTPUTS
    KeyNub.LicenseDongle.GenuineResult
    .EXAMPLE
    (Confirm-KeyNubDongle -Dongle $dongle).Serial
    #>

    [CmdletBinding()]
    [OutputType([KeyNub.LicenseDongle.GenuineResult])]
    param([Parameter(Mandatory, ValueFromPipeline)][KeyNub.LicenseDongle.Dongle]$Dongle)
    process {
        try {
            $Dongle.VerifyGenuine()
        } catch {
            Write-LicdError -Cmdlet $PSCmdlet -ErrorRecord $_
        }
    }
}

# --- sessions ----------------------------------------------------------------

function Open-KeyNubSession {
    <#
    .SYNOPSIS
    Opens the encrypted session that records, counters and data protection need.
    .DESCRIPTION
    Verifies the dongle, then sets up an encrypted, authenticated channel to it
    (P-256 ECDH, HKDF-SHA256, AES-256-GCM). One session per dongle at a time;
    close it with Close-KeyNubSession, or use Invoke-KeyNubSession, which closes
    it on every path.
    .PARAMETER Dongle
    An open dongle.
    .OUTPUTS
    KeyNub.LicenseDongle.Session
    .EXAMPLE
    $session = Open-KeyNubSession -Dongle $dongle
    #>

    [CmdletBinding()]
    [OutputType([KeyNub.LicenseDongle.Session])]
    param([Parameter(Mandatory, ValueFromPipeline)][KeyNub.LicenseDongle.Dongle]$Dongle)
    process {
        try {
            $Dongle.OpenSession()
        } catch {
            Write-LicdError -Cmdlet $PSCmdlet -ErrorRecord $_
        }
    }
}

function Close-KeyNubSession {
    <#
    .SYNOPSIS
    Ends a session. Safe to call more than once.
    .PARAMETER Session
    The session to close.
    .EXAMPLE
    Close-KeyNubSession -Session $session
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param([Parameter(Mandatory, ValueFromPipeline)][KeyNub.LicenseDongle.Session]$Session)
    process {
        try {
            $Session.Close()
        } catch {
            Write-LicdError -Cmdlet $PSCmdlet -ErrorRecord $_
        }
    }
}

function Invoke-KeyNubSession {
    <#
    .SYNOPSIS
    Runs a script block inside a session and closes the session afterwards.
    .DESCRIPTION
    Opens a session on the dongle, runs the script block with the session as its
    only argument, and closes the session whatever happens, errors included.
    Returns what the script block returns.
    .PARAMETER Dongle
    An open dongle.
    .PARAMETER ScriptBlock
    The commands to run; the session arrives as the first argument.
    .EXAMPLE
    Invoke-KeyNubSession -Dongle $dongle -ScriptBlock { param($s) Get-KeyNubRecord -Session $s }
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)][KeyNub.LicenseDongle.Dongle]$Dongle,
        [Parameter(Mandatory, Position = 1)][scriptblock]$ScriptBlock
    )
    try {
        $session = $Dongle.OpenSession()
    } catch {
        Write-LicdError -Cmdlet $PSCmdlet -ErrorRecord $_
    }
    try {
        & $ScriptBlock $session
    } finally {
        $session.Close()
    }
}

function Grant-KeyNubWriteAccess {
    <#
    .SYNOPSIS
    Unlocks writing, erasing and counter increments for the rest of the session.
    .DESCRIPTION
    Elevates the session to the write role with the dongle's write key, a P-256
    private key in PKCS#8 DER. This belongs in your licence-issuing tooling;
    never ship that key with what your users run. A key the dongle does not
    accept raises a NotGenuineException.
    .PARAMETER Session
    An open session.
    .PARAMETER Key
    The write key, DER-encoded.
    .PARAMETER KeyPath
    A file holding the write key, DER-encoded.
    .EXAMPLE
    Grant-KeyNubWriteAccess -Session $session -KeyPath ./write-key.der
    #>

    [CmdletBinding(DefaultParameterSetName = 'Path')]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)][KeyNub.LicenseDongle.Session]$Session,
        [Parameter(Mandatory, ParameterSetName = 'Bytes')][byte[]]$Key,
        [Parameter(Mandatory, ParameterSetName = 'Path')][string]$KeyPath
    )
    $der = if ($PSCmdlet.ParameterSetName -eq 'Path') { Read-KeyFile -Path $KeyPath } else { $Key }
    try {
        $Session.AuthorizeWrite($der)
    } catch {
        Write-LicdError -Cmdlet $PSCmdlet -ErrorRecord $_
    }
}

function Set-KeyNubWriteKey {
    <#
    .SYNOPSIS
    Replaces the dongle's write key with one you hold.
    .DESCRIPTION
    Needs the write role, so run Grant-KeyNubWriteAccess with the current key
    first. The session keeps the write role; from the next session on only the
    new key elevates, and the old one no longer works on this dongle. Do this
    once per dongle, when it arrives: the factory key is public.
    .PARAMETER Session
    An open session with the write role.
    .PARAMETER Key
    The new write key, a P-256 private key in PKCS#8 DER.
    .PARAMETER KeyPath
    A file holding the new write key.
    .EXAMPLE
    Set-KeyNubWriteKey -Session $session -KeyPath ./my-write-key.der
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High', DefaultParameterSetName = 'Path')]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)][KeyNub.LicenseDongle.Session]$Session,
        [Parameter(Mandatory, ParameterSetName = 'Bytes')][byte[]]$Key,
        [Parameter(Mandatory, ParameterSetName = 'Path')][string]$KeyPath
    )
    $der = if ($PSCmdlet.ParameterSetName -eq 'Path') { Read-KeyFile -Path $KeyPath } else { $Key }
    if ($PSCmdlet.ShouldProcess('the dongle', 'Replace the write key')) {
        try {
            $Session.RotateWriteKey($der)
        } catch {
            Write-LicdError -Cmdlet $PSCmdlet -ErrorRecord $_
        }
    }
}

# --- records -----------------------------------------------------------------

function Get-KeyNubRecord {
    <#
    .SYNOPSIS
    Lists the records on the dongle, with their sizes in bytes.
    .PARAMETER Session
    An open session.
    .OUTPUTS
    KeyNub.LicenseDongle.RecordInfo
    .EXAMPLE
    Get-KeyNubRecord -Session $session
    #>

    [CmdletBinding()]
    [OutputType([KeyNub.LicenseDongle.RecordInfo])]
    param([Parameter(Mandatory, ValueFromPipeline)][KeyNub.LicenseDongle.Session]$Session)
    process {
        try {
            $Session.ListRecords()
        } catch {
            Write-LicdError -Cmdlet $PSCmdlet -ErrorRecord $_
        }
    }
}

function Read-KeyNubRecord {
    <#
    .SYNOPSIS
    Reads a record: its bytes, or with -AsString its UTF-8 text.
    .DESCRIPTION
    A record that does not exist raises a terminating error whose exception is a
    KeyNub.LicenseDongle.RecordNotFoundException.
    .PARAMETER Session
    An open session.
    .PARAMETER Name
    The record name.
    .PARAMETER AsString
    Returns the record decoded as UTF-8 text.
    .OUTPUTS
    System.Byte[], or System.String with -AsString
    .EXAMPLE
    Read-KeyNubRecord -Session $session -Name license -AsString
    #>

    [CmdletBinding()]
    [OutputType([byte[]], [string])]
    param(
        [Parameter(Mandatory)][KeyNub.LicenseDongle.Session]$Session,
        [Parameter(Mandatory, Position = 0)][ValidateNotNullOrEmpty()][string]$Name,
        [switch]$AsString
    )
    try {
        $data = $Session.ReadRecord($Name, $null, [System.Threading.CancellationToken]::None)
    } catch {
        Write-LicdError -Cmdlet $PSCmdlet -ErrorRecord $_ -Target $Name
    }
    if ($AsString) {
        [System.Text.Encoding]::UTF8.GetString($data)
    } else {
        # The comma keeps the array one object on the pipeline.
        , $data
    }
}

function Write-KeyNubRecord {
    <#
    .SYNOPSIS
    Creates or replaces a record.
    .DESCRIPTION
    Needs the write role (Grant-KeyNubWriteAccess). The record is replaced
    atomically.
    .PARAMETER Session
    An open session with the write role.
    .PARAMETER Name
    The record name.
    .PARAMETER Value
    The contents: a byte array, or a string, which is stored as UTF-8.
    .EXAMPLE
    Write-KeyNubRecord -Session $session -Name license -Value 'expires 2027-12-31'
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)][KeyNub.LicenseDongle.Session]$Session,
        [Parameter(Mandatory, Position = 0)][ValidateNotNullOrEmpty()][string]$Name,
        [Parameter(Mandatory, Position = 1)][AllowEmptyString()][object]$Value
    )
    $bytes = ConvertTo-LicdByteArray -Cmdlet $PSCmdlet -Value $Value -Name 'Value'
    if ($PSCmdlet.ShouldProcess($Name, 'Write record')) {
        try {
            $Session.WriteRecord($Name, $bytes, $null, [System.Threading.CancellationToken]::None)
        } catch {
            Write-LicdError -Cmdlet $PSCmdlet -ErrorRecord $_ -Target $Name
        }
    }
}

function Remove-KeyNubRecord {
    <#
    .SYNOPSIS
    Erases one record.
    .DESCRIPTION
    Needs the write role (Grant-KeyNubWriteAccess). Clear-KeyNubRecord erases
    every record.
    .PARAMETER Session
    An open session with the write role.
    .PARAMETER Name
    The record to erase.
    .EXAMPLE
    Remove-KeyNubRecord -Session $session -Name trial
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)][KeyNub.LicenseDongle.Session]$Session,
        [Parameter(Mandatory, Position = 0)][ValidateNotNullOrEmpty()][string]$Name
    )
    if ($PSCmdlet.ShouldProcess($Name, 'Erase record')) {
        try {
            $Session.EraseRecord($Name)
        } catch {
            Write-LicdError -Cmdlet $PSCmdlet -ErrorRecord $_ -Target $Name
        }
    }
}

function Clear-KeyNubRecord {
    <#
    .SYNOPSIS
    Erases every record on the dongle.
    .DESCRIPTION
    Needs the write role (Grant-KeyNubWriteAccess). Asks for confirmation
    unless -Confirm:$false is given.
    .PARAMETER Session
    An open session with the write role.
    .EXAMPLE
    Clear-KeyNubRecord -Session $session -Confirm:$false
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [OutputType([void])]
    param([Parameter(Mandatory)][KeyNub.LicenseDongle.Session]$Session)
    if ($PSCmdlet.ShouldProcess('every record on the dongle', 'Erase')) {
        try {
            $Session.EraseAllRecords()
        } catch {
            Write-LicdError -Cmdlet $PSCmdlet -ErrorRecord $_
        }
    }
}

# --- counters ----------------------------------------------------------------

function Get-KeyNubCounter {
    <#
    .SYNOPSIS
    Reads a monotonic counter.
    .PARAMETER Session
    An open session.
    .PARAMETER Id
    The counter, from 0 upwards.
    .OUTPUTS
    System.UInt32
    .EXAMPLE
    Get-KeyNubCounter -Session $session -Id 0
    #>

    [CmdletBinding()]
    [OutputType([uint32])]
    param(
        [Parameter(Mandatory)][KeyNub.LicenseDongle.Session]$Session,
        [Parameter(Mandatory, Position = 0)][byte]$Id
    )
    try {
        $Session.ReadCounter($Id)
    } catch {
        Write-LicdError -Cmdlet $PSCmdlet -ErrorRecord $_ -Target $Id
    }
}

function Step-KeyNubCounter {
    <#
    .SYNOPSIS
    Increments a monotonic counter and returns its new value.
    .DESCRIPTION
    Needs the write role (Grant-KeyNubWriteAccess). A counter only ever goes
    up; an increment cannot be undone.
    .PARAMETER Session
    An open session with the write role.
    .PARAMETER Id
    The counter, from 0 upwards.
    .OUTPUTS
    System.UInt32
    .EXAMPLE
    Step-KeyNubCounter -Session $session -Id 0
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([uint32])]
    param(
        [Parameter(Mandatory)][KeyNub.LicenseDongle.Session]$Session,
        [Parameter(Mandatory, Position = 0)][byte]$Id
    )
    if ($PSCmdlet.ShouldProcess("counter $Id", 'Increment')) {
        try {
            $Session.IncrementCounter($Id)
        } catch {
            Write-LicdError -Cmdlet $PSCmdlet -ErrorRecord $_ -Target $Id
        }
    }
}

# --- data only a dongle can decrypt ------------------------------------------

function Protect-KeyNubData {
    <#
    .SYNOPSIS
    Encrypts data so that only a dongle can decrypt it.
    .DESCRIPTION
    The pair to build a licence check on. Put something the script needs through
    this, such as its configuration, and ship only the protected form; then
    removing the check removes the data. -Scope Developer lets any dongle you
    have issued decrypt the data, so one file serves every customer; -Scope
    Device locks it to the one dongle that encrypted it.
    .PARAMETER Session
    An open session.
    .PARAMETER Scope
    Device or Developer.
    .PARAMETER Data
    The plaintext: a byte array, or a string, which is encrypted as UTF-8.
    .OUTPUTS
    System.Byte[]
    .EXAMPLE
    $sealed = Protect-KeyNubData -Session $session -Scope Developer -Data 'the data'
    #>

    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)][KeyNub.LicenseDongle.Session]$Session,
        [Parameter(Mandatory)][KeyNub.LicenseDongle.Scope]$Scope,
        [Parameter(Mandatory, Position = 0)][AllowEmptyString()][object]$Data
    )
    $bytes = ConvertTo-LicdByteArray -Cmdlet $PSCmdlet -Value $Data -Name 'Data'
    try {
        , $Session.AppEncrypt($Scope, $bytes)
    } catch {
        Write-LicdError -Cmdlet $PSCmdlet -ErrorRecord $_
    }
}

function Unprotect-KeyNubData {
    <#
    .SYNOPSIS
    Decrypts data from Protect-KeyNubData.
    .PARAMETER Session
    An open session.
    .PARAMETER Data
    The protected data.
    .PARAMETER AsString
    Returns the plaintext decoded as UTF-8 text.
    .OUTPUTS
    System.Byte[], or System.String with -AsString
    .EXAMPLE
    Unprotect-KeyNubData -Session $session -Data $sealed -AsString
    #>

    [CmdletBinding()]
    [OutputType([byte[]], [string])]
    param(
        [Parameter(Mandatory)][KeyNub.LicenseDongle.Session]$Session,
        [Parameter(Mandatory, Position = 0)][byte[]]$Data,
        [switch]$AsString
    )
    try {
        $plain = $Session.AppDecrypt($Data)
    } catch {
        Write-LicdError -Cmdlet $PSCmdlet -ErrorRecord $_
    }
    if ($AsString) {
        [System.Text.Encoding]::UTF8.GetString($plain)
    } else {
        , $plain
    }
}