Tests/Private/Crypto.Tests.ps1

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

Describe 'Private/Crypto' -Tag Unit {
    InModuleScope 'Posh-SecretSharing' {
        Describe 'Get-SecretSharingCipherConstant' {
            It 'returns the SLIP-0039 cipher constants' {
                $constant = Get-SecretSharingCipherConstant
                $constant.BaseIterationCount | Should -Be 10000
                $constant.RoundCount | Should -Be 4
                $constant.IdentifierLengthByte | Should -Be 2
                $constant.MinimumSecretLength | Should -Be 16

                $customization = $constant.CustomizationString
                $customization | Should -Be ([System.Text.Encoding]::ASCII.GetBytes('shamir'))
            }
        }

        Describe 'Get-SecretSharingRandomIdentifier' {
            It 'returns a value within the 15-bit range (0-32767)' {
                for ($i = 0; $i -lt 100; $i++) {
                    $identifier = Get-SecretSharingRandomIdentifier
                    $identifier | Should -BeGreaterOrEqual 0
                    $identifier | Should -BeLessOrEqual 32767
                }
            }

            It 'produces different values across repeated calls' {
                $values = 1..20 | ForEach-Object { Get-SecretSharingRandomIdentifier }
                ($values | Select-Object -Unique).Count | Should -BeGreaterThan 1
            }
        }

        Describe 'Get-SecretSharingCipherSaltPrefix' {
            It 'returns an empty prefix when Extendable is set' {
                (Get-SecretSharingCipherSaltPrefix -Identifier 1234 -Extendable).Count | Should -Be 0
            }

            It 'returns "shamir" + the identifier as 2 big-endian bytes when not extendable' {
                $actual = Get-SecretSharingCipherSaltPrefix -Identifier 0x1234
                $expected = [byte[]]([System.Text.Encoding]::ASCII.GetBytes('shamir') + [byte[]](0x12, 0x34))
                $actual | Should -Be $expected
            }

            It 'encodes identifier 0 and the maximum identifier 32767 correctly' {
                $zero = Get-SecretSharingCipherSaltPrefix -Identifier 0
                $zero | Should -Be ([byte[]]([System.Text.Encoding]::ASCII.GetBytes('shamir') + [byte[]](0x00, 0x00)))

                $max = Get-SecretSharingCipherSaltPrefix -Identifier 32767
                $max | Should -Be ([byte[]]([System.Text.Encoding]::ASCII.GetBytes('shamir') + [byte[]](0x7F, 0xFF)))
            }
        }

        Describe 'Invoke-SecretSharingCipherRoundFunction' {
            It 'returns exactly R.Length bytes' {
                $r = [byte[]](1, 2, 3, 4, 5, 6, 7, 8)
                $f = Invoke-SecretSharingCipherRoundFunction -RoundIndex 0 -Passphrase @() -SaltPrefix ([byte[]]@()) -R $r -IterationExponent 0
                $f.Count | Should -Be $r.Count
            }

            It 'is deterministic for identical inputs' {
                $r = [byte[]](9, 9, 9, 9, 9, 9, 9, 9)
                $passphrase = [System.Text.Encoding]::UTF8.GetBytes('correct horse battery staple')
                $a = Invoke-SecretSharingCipherRoundFunction -RoundIndex 2 -Passphrase $passphrase -SaltPrefix ([byte[]](1, 2)) -R $r -IterationExponent 0
                $b = Invoke-SecretSharingCipherRoundFunction -RoundIndex 2 -Passphrase $passphrase -SaltPrefix ([byte[]](1, 2)) -R $r -IterationExponent 0
                $a | Should -Be $b
            }

            It 'produces a different result for a different RoundIndex' {
                $r = [byte[]](5, 5, 5, 5, 5, 5, 5, 5)
                $a = Invoke-SecretSharingCipherRoundFunction -RoundIndex 0 -Passphrase @() -SaltPrefix ([byte[]]@()) -R $r -IterationExponent 0
                $b = Invoke-SecretSharingCipherRoundFunction -RoundIndex 1 -Passphrase @() -SaltPrefix ([byte[]]@()) -R $r -IterationExponent 0
                ($a -join ',') | Should -Not -Be ($b -join ',')
            }

            It 'matches an independently constructed PBKDF2-HMAC-SHA256 call for the same password/salt/iterations' {
                $roundIndex = [byte]1
                $passphrase = [System.Text.Encoding]::UTF8.GetBytes('hunter2')
                $saltPrefix = [byte[]]([System.Text.Encoding]::ASCII.GetBytes('shamir') + [byte[]](0x00, 0x2A))
                $r = [byte[]](11, 22, 33, 44, 55, 66, 77, 88)
                $iterationExponent = 1

                $actual = Invoke-SecretSharingCipherRoundFunction -RoundIndex $roundIndex -Passphrase $passphrase -SaltPrefix $saltPrefix -R $r -IterationExponent $iterationExponent

                $expectedPassword = [byte[]](@($roundIndex) + $passphrase)
                $expectedSalt = [byte[]]($saltPrefix + $r)
                $expectedIterations = [int]((10000 -shl $iterationExponent) / 4)
                $reference = [System.Security.Cryptography.Rfc2898DeriveBytes]::new($expectedPassword, $expectedSalt, $expectedIterations, [System.Security.Cryptography.HashAlgorithmName]::SHA256)
                try {
                    $expected = $reference.GetBytes($r.Length)
                } finally {
                    $reference.Dispose()
                }

                $actual | Should -Be $expected
            }
        }

        Describe 'Protect-SecretSharingMasterSecret / Unprotect-SecretSharingMasterSecret' {
            It 'round-trips: decrypting with the same parameters recovers the original secret' {
                $secret = Get-SecretSharingRandomByte -Length 16
                $passphrase = [System.Text.Encoding]::UTF8.GetBytes('TREZOR')

                $encrypted = Protect-SecretSharingMasterSecret -MasterSecret $secret -Passphrase $passphrase -Identifier 12345 -IterationExponent 0
                $decrypted = Unprotect-SecretSharingMasterSecret -EncryptedMasterSecret $encrypted -Passphrase $passphrase -Identifier 12345 -IterationExponent 0

                $decrypted | Should -Be $secret
            }

            It 'round-trips with an empty passphrase' {
                $secret = Get-SecretSharingRandomByte -Length 32
                $encrypted = Protect-SecretSharingMasterSecret -MasterSecret $secret -Identifier 1 -IterationExponent 0
                $decrypted = Unprotect-SecretSharingMasterSecret -EncryptedMasterSecret $encrypted -Identifier 1 -IterationExponent 0
                $decrypted | Should -Be $secret
            }

            It 'round-trips when Extendable is set' {
                $secret = Get-SecretSharingRandomByte -Length 16
                $encrypted = Protect-SecretSharingMasterSecret -MasterSecret $secret -Identifier 999 -IterationExponent 0 -Extendable
                $decrypted = Unprotect-SecretSharingMasterSecret -EncryptedMasterSecret $encrypted -Identifier 999 -IterationExponent 0 -Extendable
                $decrypted | Should -Be $secret
            }

            It 'produces the same ciphertext for different identifiers when Extendable is set' {
                $secret = [byte[]](1..16)
                $a = Protect-SecretSharingMasterSecret -MasterSecret $secret -Identifier 1 -IterationExponent 0 -Extendable
                $b = Protect-SecretSharingMasterSecret -MasterSecret $secret -Identifier 2 -IterationExponent 0 -Extendable
                $a | Should -Be $b
            }

            It 'produces different ciphertext for different identifiers when not extendable' {
                $secret = [byte[]](1..16)
                $a = Protect-SecretSharingMasterSecret -MasterSecret $secret -Identifier 1 -IterationExponent 0
                $b = Protect-SecretSharingMasterSecret -MasterSecret $secret -Identifier 2 -IterationExponent 0
                ($a -join ',') | Should -Not -Be ($b -join ',')
            }

            It 'decrypting with the wrong passphrase silently returns the wrong secret rather than throwing' {
                $secret = Get-SecretSharingRandomByte -Length 16
                $encrypted = Protect-SecretSharingMasterSecret -MasterSecret $secret -Passphrase ([System.Text.Encoding]::UTF8.GetBytes('right')) -Identifier 5 -IterationExponent 0
                $decrypted = Unprotect-SecretSharingMasterSecret -EncryptedMasterSecret $encrypted -Passphrase ([System.Text.Encoding]::UTF8.GetBytes('wrong')) -Identifier 5 -IterationExponent 0
                ($decrypted -join ',') | Should -Not -Be ($secret -join ',')
            }

            It 'throws when MasterSecret is shorter than the minimum length' {
                { Protect-SecretSharingMasterSecret -MasterSecret ([byte[]](1..8)) -Identifier 1 -IterationExponent 0 } | Should -Throw
            }

            It 'throws when MasterSecret has an odd length' {
                { Protect-SecretSharingMasterSecret -MasterSecret ([byte[]](1..17)) -Identifier 1 -IterationExponent 0 } | Should -Throw
            }
        }
    }
}