Public/Join-SecretSharingSecret.ps1

function Join-SecretSharingSecret {
    <#
    .SYNOPSIS
        Reconstructs a secret from a quorum of SLIP-0039 mnemonic shares.
    .DESCRIPTION
        Accepts share mnemonics (as plain strings, or as objects with a Mnemonic
        property - the output of Split-SecretSharingSecret works directly), verifies
        each one's checksum, groups them by GroupIndex, reconstructs each resolvable
        group's share value (verifying that group's digest once its member threshold
        is met), then reconstructs the encrypted master secret from enough groups
        (verifying the group-level digest), and finally decrypts it.
    .PARAMETER Share
        The shares to combine: mnemonic strings, or objects with a Mnemonic property
        (such as Split-SecretSharingSecret's output). Accepts pipeline input.
    .PARAMETER Passphrase
        The passphrase used when the shares were created, if any.
    .EXAMPLE
        PS> $shares | Join-SecretSharingSecret
        Reconstructs a secret from a set of Split-SecretSharingSecret output objects.
    .OUTPUTS
        System.Security.SecureString
    .NOTES
        A wrong -Passphrase cannot be detected here: per SLIP-0039's design, every
        passphrase decrypts to *some* value of the right length, correct or not, with
        no way to tell the difference - this is deliberate, and enables plausible
        deniability. This cmdlet throws a clear terminating error for corrupted,
        insufficient, or mismatched *shares* (checksum and digest verification both
        fail loudly), but a wrong passphrase with otherwise-valid shares returns
        silently with the wrong secret, not an error.
    #>

    [CmdletBinding()]
    [OutputType([System.Security.SecureString])]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [PSObject[]]$Share,

        [System.Security.SecureString]$Passphrase
    )

    begin {
        $collectedShare = New-Object System.Collections.Generic.List[PSObject]
    }

    process {
        foreach ($item in $Share) {
            $collectedShare.Add($item)
        }
    }

    end {
        if ($collectedShare.Count -eq 0) {
            throw 'At least one share is required.'
        }

        $digestConstant = Get-SecretSharingDigestConstant

        $decodedShare = @(foreach ($item in $collectedShare) {
            $mnemonic = $null
            if ($item -is [string]) {
                $mnemonic = $item
            } elseif ($item.PSObject.Properties['Mnemonic']) {
                $mnemonic = $item.Mnemonic
            } else {
                throw 'Each -Share item must be a mnemonic string or an object with a Mnemonic property.'
            }

            $wordIndex = @($mnemonic.Trim() -split '\s+' | ForEach-Object { ConvertFrom-SecretSharingMnemonicWord -Word $_ })
            $header = ConvertFrom-SecretSharingShareWord -Index $wordIndex[0..($wordIndex.Count - 4)]

            if (-not (Test-SecretSharingChecksum -Data $wordIndex -Extendable:$header.Extendable)) {
                throw "Invalid checksum for the share with member index $($header.MemberIndex) in group $($header.GroupIndex). The mnemonic may be mistyped or corrupted."
            }

            $header
        })

        $first = $decodedShare[0]
        foreach ($s in $decodedShare) {
            if ($s.Identifier -ne $first.Identifier -or $s.Extendable -ne $first.Extendable -or
                $s.IterationExponent -ne $first.IterationExponent -or $s.GroupThreshold -ne $first.GroupThreshold -or
                $s.GroupCount -ne $first.GroupCount) {
                throw 'All shares must belong to the same share set (matching identifier, extendable flag, iteration exponent, group threshold, and group count).'
            }
        }

        $resolvedGroup = New-Object System.Collections.Generic.List[PSObject]

        foreach ($groupEntry in ($decodedShare | Group-Object -Property GroupIndex)) {
            $memberOfGroup = @($groupEntry.Group)

            $memberThreshold = $memberOfGroup[0].MemberThreshold
            foreach ($m in $memberOfGroup) {
                if ($m.MemberThreshold -ne $memberThreshold) {
                    throw "Shares in group $($groupEntry.Name) disagree on the member threshold. The shares may be from different sets."
                }
            }

            $distinctMemberIndexCount = @($memberOfGroup | Group-Object -Property MemberIndex).Count
            if ($distinctMemberIndexCount -lt $memberThreshold) {
                continue
            }

            if ($memberThreshold -eq 1) {
                $groupShareValue = $memberOfGroup[0].Value
            } else {
                $memberPoint = $memberOfGroup | ForEach-Object { [PSCustomObject]@{ X = [byte]$_.MemberIndex; Value = $_.Value } }
                $candidateGroupShareValue = Invoke-SecretSharingShamirInterpolation -Point $memberPoint -X $digestConstant.SecretIndex
                $candidateDigestShare = Invoke-SecretSharingShamirInterpolation -Point $memberPoint -X $digestConstant.DigestIndex

                if (-not (Test-SecretSharingDigestShare -Secret $candidateGroupShareValue -DigestShare $candidateDigestShare)) {
                    throw "Invalid digest for group $($groupEntry.Name). The shares in this group may be corrupted or inconsistent."
                }

                $groupShareValue = $candidateGroupShareValue
            }

            $resolvedGroup.Add([PSCustomObject]@{ X = [byte]$memberOfGroup[0].GroupIndex; Value = $groupShareValue })
        }

        if ($resolvedGroup.Count -lt $first.GroupThreshold) {
            throw "Not enough shares to reconstruct the secret. Resolved $($resolvedGroup.Count) of the $($first.GroupThreshold) required groups."
        }

        if ($first.GroupThreshold -eq 1) {
            $encryptedMasterSecret = $resolvedGroup[0].Value
        } else {
            $candidateEms = Invoke-SecretSharingShamirInterpolation -Point $resolvedGroup -X $digestConstant.SecretIndex
            $candidateDigestShare = Invoke-SecretSharingShamirInterpolation -Point $resolvedGroup -X $digestConstant.DigestIndex

            if (-not (Test-SecretSharingDigestShare -Secret $candidateEms -DigestShare $candidateDigestShare)) {
                throw 'Invalid digest for the reconstructed secret. The groups combined may be corrupted or inconsistent.'
            }

            $encryptedMasterSecret = $candidateEms
        }

        $passphraseByte = [byte[]]@()
        if ($Passphrase) {
            $passphraseByte = ConvertFrom-SecretSharingPassphraseSecureString -SecureString $Passphrase
        }

        $secretByte = Unprotect-SecretSharingMasterSecret -EncryptedMasterSecret $encryptedMasterSecret -Passphrase $passphraseByte `
            -Identifier $first.Identifier -IterationExponent $first.IterationExponent -Extendable:$first.Extendable

        return ConvertTo-SecretSharingSecureString -Byte $secretByte
    }
}