Tests/Public/Join-SecretSharingSecret.Tests.ps1

BeforeDiscovery {
    Import-Module (Join-Path $PSScriptRoot '../../Posh-SecretSharing.psd1') -Force
}

Describe 'Join-SecretSharingSecret' -Tag Unit {
    BeforeAll {
        function Get-TestPlainByte {
            param([System.Security.SecureString]$SecureString)
            $ptr = [System.Runtime.InteropServices.Marshal]::SecureStringToGlobalAllocUnicode($SecureString)
            try {
                $out = [byte[]]::new($SecureString.Length)
                for ($i = 0; $i -lt $SecureString.Length; $i++) {
                    $out[$i] = [byte]([System.Runtime.InteropServices.Marshal]::ReadInt16($ptr, $i * 2) -band 0xFF)
                }
                return , $out
            } finally {
                [System.Runtime.InteropServices.Marshal]::ZeroFreeGlobalAllocUnicode($ptr)
            }
        }

        # Builds a SecureString directly rather than via ConvertTo-SecureString, which is
        # unavailable in some Windows PowerShell 5.1 environments (a PSModulePath conflict
        # between the 5.1 and 7 copies of Microsoft.PowerShell.Security, unrelated to this
        # module) - confirmed to fail even outside Pester and outside this module.
        function New-TestSecureString {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
                'PSUseShouldProcessForStateChangingFunctions', '',
                Justification = 'Test helper; pure in-memory construction, no external state is changed.')]
            param([string]$PlainText)
            $secure = New-Object System.Security.SecureString
            foreach ($ch in $PlainText.ToCharArray()) {
                $secure.AppendChar($ch)
            }
            return $secure
        }
    }

    Context 'round-trip' {
        It 'reconstructs a secret from exactly the threshold number of shares (single group)' {
            $secret = New-SecretSharingSecret -Entropy 128
            $shares = Split-SecretSharingSecret -Secret $secret -Group @{ Threshold = 3; Count = 5 }
            $reconstructed = $shares | Select-Object -First 3 | Join-SecretSharingSecret
            (Get-TestPlainByte $reconstructed) | Should -Be (Get-TestPlainByte $secret)
        }

        It 'reconstructs a secret from more than the threshold number of shares' {
            $secret = New-SecretSharingSecret -Entropy 128
            $shares = Split-SecretSharingSecret -Secret $secret -Group @{ Threshold = 3; Count = 5 }
            $reconstructed = $shares | Join-SecretSharingSecret
            (Get-TestPlainByte $reconstructed) | Should -Be (Get-TestPlainByte $secret)
        }

        It 'reconstructs a 256-bit secret' {
            $secret = New-SecretSharingSecret -Entropy 256
            $shares = Split-SecretSharingSecret -Secret $secret -Group @{ Threshold = 2; Count = 3 }
            $reconstructed = $shares | Select-Object -First 2 | Join-SecretSharingSecret
            (Get-TestPlainByte $reconstructed) | Should -Be (Get-TestPlainByte $secret)
        }

        It 'reconstructs correctly with threshold 1 (every share is the secret itself)' {
            $secret = New-SecretSharingSecret -Entropy 128
            $shares = Split-SecretSharingSecret -Secret $secret -Group @{ Threshold = 1; Count = 3 }
            $reconstructed = $shares | Select-Object -First 1 | Join-SecretSharingSecret
            (Get-TestPlainByte $reconstructed) | Should -Be (Get-TestPlainByte $secret)
        }

        It 'reconstructs correctly when Extendable is set' {
            $secret = New-SecretSharingSecret -Entropy 128
            $shares = Split-SecretSharingSecret -Secret $secret -Group @{ Threshold = 2; Count = 3 } -Extendable
            $reconstructed = $shares | Select-Object -First 2 | Join-SecretSharingSecret
            (Get-TestPlainByte $reconstructed) | Should -Be (Get-TestPlainByte $secret)
        }

        It 'reconstructs correctly across multiple groups' {
            $secret = New-SecretSharingSecret -Entropy 128
            $shares = Split-SecretSharingSecret -Secret $secret -GroupThreshold 2 -Group @{ Threshold = 2; Count = 3 }, @{ Threshold = 1; Count = 1 }
            $pick = @($shares | Where-Object GroupIndex -eq 0 | Select-Object -First 2) + @($shares | Where-Object GroupIndex -eq 1)
            $reconstructed = $pick | Join-SecretSharingSecret
            (Get-TestPlainByte $reconstructed) | Should -Be (Get-TestPlainByte $secret)
        }

        It 'accepts raw mnemonic strings as well as Split-SecretSharingSecret output objects' {
            $secret = New-SecretSharingSecret -Entropy 128
            $shares = Split-SecretSharingSecret -Secret $secret -Group @{ Threshold = 2; Count = 3 }
            $mnemonics = $shares | Select-Object -First 2 -ExpandProperty Mnemonic
            $reconstructed = Join-SecretSharingSecret -Share $mnemonics
            (Get-TestPlainByte $reconstructed) | Should -Be (Get-TestPlainByte $secret)
        }

        It 'accepts shares via -Share without piping' {
            $secret = New-SecretSharingSecret -Entropy 128
            $shares = Split-SecretSharingSecret -Secret $secret -Group @{ Threshold = 2; Count = 3 }
            $reconstructed = Join-SecretSharingSecret -Share ($shares | Select-Object -First 2)
            (Get-TestPlainByte $reconstructed) | Should -Be (Get-TestPlainByte $secret)
        }
    }

    Context 'passphrase' {
        It 'reconstructs correctly with the same passphrase' {
            $secret = New-SecretSharingSecret -Entropy 128
            $passphrase = New-TestSecureString -PlainText 'hunter2'
            $shares = Split-SecretSharingSecret -Secret $secret -Passphrase $passphrase -Group @{ Threshold = 2; Count = 3 }
            $reconstructed = $shares | Select-Object -First 2 | Join-SecretSharingSecret -Passphrase $passphrase
            (Get-TestPlainByte $reconstructed) | Should -Be (Get-TestPlainByte $secret)
        }

        It 'silently returns the wrong secret for a wrong passphrase, without throwing' {
            $secret = New-SecretSharingSecret -Entropy 128
            $passphrase = New-TestSecureString -PlainText 'hunter2'
            $shares = Split-SecretSharingSecret -Secret $secret -Passphrase $passphrase -Group @{ Threshold = 2; Count = 3 }
            $wrongPassphrase = New-TestSecureString -PlainText 'wrong'
            $reconstructed = $shares | Select-Object -First 2 | Join-SecretSharingSecret -Passphrase $wrongPassphrase
            (Get-TestPlainByte $reconstructed) | Should -Not -Be (Get-TestPlainByte $secret)
        }
    }

    Context 'error handling' {
        It 'throws when there are not enough shares to meet the threshold' {
            $secret = New-SecretSharingSecret -Entropy 128
            $shares = Split-SecretSharingSecret -Secret $secret -Group @{ Threshold = 3; Count = 5 }
            { $shares | Select-Object -First 2 | Join-SecretSharingSecret } | Should -Throw
        }

        It 'throws on a share with an invalid checksum' {
            $secret = New-SecretSharingSecret -Entropy 128
            $shares = Split-SecretSharingSecret -Secret $secret -Group @{ Threshold = 2; Count = 3 }
            $words = ($shares[0].Mnemonic -split ' ')
            $words[-1] = if ($words[-1] -eq 'academic') { 'zero' } else { 'academic' }
            $corrupted = $words -join ' '
            { Join-SecretSharingSecret -Share @($corrupted, $shares[1].Mnemonic) } | Should -Throw
        }

        It 'throws when shares come from different share sets' {
            $secretA = New-SecretSharingSecret -Entropy 128
            $secretB = New-SecretSharingSecret -Entropy 128
            $sharesA = Split-SecretSharingSecret -Secret $secretA -Group @{ Threshold = 2; Count = 3 }
            $sharesB = Split-SecretSharingSecret -Secret $secretB -Group @{ Threshold = 2; Count = 3 }
            { Join-SecretSharingSecret -Share @($sharesA[0], $sharesB[0]) } | Should -Throw
        }

        It 'throws when a -Share item is neither a string nor has a Mnemonic property' {
            { Join-SecretSharingSecret -Share @([PSCustomObject]@{ NotMnemonic = 'x' }) } | Should -Throw
        }
    }

    Context 'official SLIP-0039 test vectors' {
        It 'reconstructs vector 1 (valid mnemonic without sharing, 128 bits)' {
            $mnemonic = 'duckling enlarge academic academic agency result length solution fridge kidney coal piece deal husband erode duke ajar critical decision keyboard'
            $passphrase = New-TestSecureString -PlainText 'TREZOR'
            $reconstructed = Join-SecretSharingSecret -Share $mnemonic -Passphrase $passphrase
            $expected = [byte[]](0xbb, 0x54, 0xaa, 0xc4, 0xb8, 0x9d, 0xc8, 0x68, 0xba, 0x37, 0xd9, 0xcc, 0x21, 0xb2, 0xce, 0xce)
            (Get-TestPlainByte $reconstructed) | Should -Be $expected
        }

        It 'reconstructs vector 4 (basic sharing 2-of-3, 128 bits)' {
            $mnemonics = @(
                'shadow pistol academic always adequate wildlife fancy gross oasis cylinder mustang wrist rescue view short owner flip making coding armed',
                'shadow pistol academic acid actress prayer class unknown daughter sweater depict flip twice unkind craft early superior advocate guest smoking'
            )
            $passphrase = New-TestSecureString -PlainText 'TREZOR'
            $reconstructed = Join-SecretSharingSecret -Share $mnemonics -Passphrase $passphrase
            $expected = [byte[]](0xb4, 0x3c, 0xeb, 0x7e, 0x57, 0xa0, 0xea, 0x87, 0x66, 0x22, 0x16, 0x24, 0xd0, 0x1b, 0x08, 0x64)
            (Get-TestPlainByte $reconstructed) | Should -Be $expected
        }

        It 'rejects vector 2 (invalid checksum, 128 bits)' {
            $mnemonic = 'duckling enlarge academic academic agency result length solution fridge kidney coal piece deal husband erode duke ajar critical decision kidney'
            { Join-SecretSharingSecret -Share $mnemonic } | Should -Throw
        }

        It 'rejects vector 3 (invalid padding, 128 bits)' {
            $mnemonic = 'duckling enlarge academic academic email result length solution fridge kidney coal piece deal husband erode duke ajar music cargo fitness'
            { Join-SecretSharingSecret -Share $mnemonic } | Should -Throw
        }
    }
}