Public/New-KeytabFromPassword.ps1
|
<#
SPDX-License-Identifier: Apache-2.0 Copyright (c) 2025 Stefan Ploch #> function New-KeytabFromPassword { <# .SYNOPSIS Generate a keytab from a password using MIT/Heimdal/Windows salt policies (AES only). .DESCRIPTION Derives AES keys via PBKDF2-HMAC-SHA1 (Etype 17/18) or PBKDF2-HMAC-SHA256/SHA384 (Etype 19/20) and writes a MIT v0x0502 keytab. Supports two identity parameter sets (User via -SamAccountName, or service via -Principal). Defaults to AES-only selection and deterministic outputs when -FixedTimestampUtc is provided. This path does not use replication; the caller controls KVNO. Ensure KVNO matches the account’s actual key version when using these keytabs with AD. .PARAMETER Realm Kerberos realm (usually the AD domain in uppercase). .PARAMETER SamAccountName Account name when deriving a user or computer principal (use -Principal for service names). .PARAMETER Principal Full principal (e.g., http/web01.contoso.com@CONTOSO.COM) for service principals. .PARAMETER Password SecureString password to derive keys from. Alternatively use -Credential. .PARAMETER Credential PSCredential; the password part is used if -Password not provided. .PARAMETER Compatibility Salt policy for string-to-key: MIT, Heimdal, or Windows. .PARAMETER IncludeEtype Encryption types to include. Defaults to AES-256 and AES-128 (18,17). .PARAMETER ExcludeEtype Encryption types to exclude from selection. .PARAMETER IncludeLegacyRC4 Includes the RC4 encryption type (23). .PARAMETER AllowDeadCiphers Allow the use of deprecated or weak encryption types (other than 17,18,19,20,23). No support guaranteed. .PARAMETER AESOnly Restrict to AES encryption types only (18,17). .PARAMETER ModernCrypto Include modern AES-SHA2 encryption types (19,20) in addition to defaults. Requires newer Kerberos implementations. .PARAMETER OutputPath Path to write the generated keytab. .PARAMETER Kvno Key Version Number to stamp into entries (default 1). Ensure this matches the account’s actual KVNO. .PARAMETER Iterations PBKDF2 iteration count (default 4096). .PARAMETER RestrictAcl Apply a user-only ACL on outputs. .PARAMETER Force Overwrite OutputPath if it exists. .PARAMETER Summary Generate a JSON summary file. .PARAMETER SummaryPath Path to write a JSON summary; defaults next to OutputPath when -Summary or -PassThru is specified. .PARAMETER PassThru Return a summary object in addition to writing files. .PARAMETER FixedTimestampUtc Use a fixed timestamp for deterministic output. Determinism is opt-in and not auto-populated. .INPUTS None. Parameters are bound by name. .OUTPUTS System.String (OutputPath) or summary object when -PassThru. .EXAMPLE New-KeytabFromPassword -Realm CONTOSO.COM -SamAccountName user1 -Password (Read-Host -AsSecureString) -OutputPath .\user1.keytab Generate a user keytab from a password with default AES types. .EXAMPLE New-KeytabFromPassword -Realm CONTOSO.COM -Principal http/web01.contoso.com@CONTOSO.COM -Credential (Get-Credential) -IncludeEtype 18 -Kvno 3 -OutputPath .\http.keytab Generate a service keytab with AES-256 only and KVNO 3. .EXAMPLE New-KeytabFromPassword -Realm CONTOSO.COM -SamAccountName user1 -Password (Read-Host -AsSecureString) -ModernCrypto -OutputPath .\user1-modern.keytab Generate a user keytab with modern AES-SHA2 encryption types (17,18,19,20). #> [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'User', ConfirmImpact='Medium')] param( # Identity [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Realm, [Parameter(ParameterSetName='User', Mandatory)][ValidateNotNullOrEmpty()][string]$SamAccountName, [Parameter(ParameterSetName='Principal', Mandatory)][ValidateNotNullOrEmpty()][string]$Principal, [string]$OutputPath, # Secret [Parameter(ParameterSetName='User')][SecureString]$Password, [Parameter(ParameterSetName='User')][pscredential]$Credential, # Options [Alias('Comp')][ValidateSet('MIT','Heimdal','Windows')][string]$Compatibility = 'MIT', [object[]]$IncludeEtype = @(17,18), [object[]]$ExcludeEtype, [int]$Kvno = 1, [ValidateRange(1, 10000000)][int]$Iterations = 4096, [switch]$RestrictAcl, [switch]$Force, [switch]$Summary, [Alias('JsonSummaryPath')] [string]$SummaryPath, [switch]$PassThru, [datetime]$FixedTimestampUtc, # Quick settings [switch]$IncludeLegacyRC4, [switch]$AESOnly, [switch]$AllowDeadCiphers, [switch]$ModernCrypto # Include AES-SHA2 types (19,20) ) begin { # Firstly, check if the parameters specified make sense if (($AESOnly.IsPresent) -and ($IncludeLegacyRC4.IsPresent -or $AllowDeadCiphers.IsPresent)) { throw "-AESOnly cannot be defined with -IncludeLegacyRC4 or -AllowDeadCiphers." } if (-not $PSBoundParameters.ContainsKey('Password') -and -not $PSBoundParameters.ContainsKey('Credential')) { throw "-Password and -Credential cannot be specified together." } # Handle modern crypto convenience parameter if ($ModernCrypto.IsPresent -and -not $PSBoundParameters.ContainsKey('IncludeEtype')) { $IncludeEtype = @(17,18,19,20) # All AES types } # Then, build policy intent with single source of truth (BigBrother) $policy = Get-PolicyIntent -IncludeEtype $IncludeEtype -ExcludeEtype $ExcludeEtype -AESOnly:$AESOnly ` -IncludeLegacyRC4:$IncludeLegacyRC4 -AllowDeadCiphers:$AllowDeadCiphers -PathKind 'Password' if ($OutputPath) { $out = Resolve-PathUniversal -Path $OutputPath -Purpose Output } else { $out = Resolve-OutputPath -Directory (Get-Location).Path -BaseName ($SamAccountName.TrimEnd('$')) -Extension '.keytab' -CreateDirectory } if ($Summary.IsPresent -and (-not $SummaryPath)) { $SummaryPath = "$($out)_summary_$(Get-Date -Format 'yyyyMMdd_HHmmss').json" } } process { if ($Credential -and -not $Password) { $Password = $Credential.GetNetworkCredential().SecurePassword } if (-not $Password) { throw "Password (or PSCredential) is required." } if ($Iterations -lt 1) { throw "Iterations must be at least 1." } # Principal descriptor $princDesc = if ($PSCmdlet.ParameterSetName -eq 'Principal') { if ($Principal -notmatch '@') { throw "Principal must include realm (e.g. user@<realm>)"} $split = $Principal.Split('@',2) $left,$prRealm = $split[0],$split[1] if ($prRealm -ne $Realm) { throw "Realm mismatch: -Realm '$Realm' vs principal '@$prRealm'"} if ($left -match '/') { $svcHost = $left.Split('/',2) [pscustomObject]@{ Components = @($svcHost[0], $svcHost[1]) Realm = $Realm NameType = 3 Display = ("{0}@{1}" -f $left, $Realm) } } else { [pscustomObject]@{ Components = @($left) Realm = $Realm NameType = 1 Display = ("{0}@{1}" -f $left, $Realm) } } } else { $base = $SamAccountName.TrimEnd('$') [pscustomobject]@{ Components = @($base) Realm = $Realm NameType = 1 Display = ("{0}@{1}" -f $base, $Realm) } } # Enforce password-path compatibility and resolve selection once Validate-PasswordPathCompatibility -Policy $policy $available = @(17,18,19,20) $selection = Resolve-EtypeSelection -AvailableIds $available -Policy $policy if ($selection.Selected.Count -eq 0) { throw "No Encryption types selected."} if ($selection.UnknownInclude.Count -gt 0) { Write-Warning "Unknown IncludeEtype: " + ($selection.UnknownInclude -join ', ')} if ($selection.UnknownExclude.Count -gt 0) { Write-Warning "Unknown ExcludeEtype: " + ($selection.UnknownExclude -join ', ')} if ($selection.Missing.Count -gt 0) { Write-Warning "Requested Etype not present: " + ($selection.Missing -join ', ') } # Derive $plain = ConvertFrom-SecureStringToPlain -Secure $Password try { $saltBytes = Get-DefaultSalt -Compatibility $Compatibility -PrincipalDescriptor $princDesc $keys = @{} foreach ($etype in $selection.Selected) { if ($etype -notin 17,18,19,20) { throw "Only AES etypes (17,18,19,20) supported in this path."} $key = Derive-AesKeyWithPbkdf2 -Etype $etype -PasswordPlain $plain -SaltBytes $saltBytes -Iterations $Iterations $keys[$etype] = $key } } finally { if ($plain) { $pad = New-Object string (' ', $plain.Length) $plain = $pad; $plain = $null } if ($saltBytes) { [Array]::Clear($saltBytes, 0, $saltBytes.Length) } } $keySet = [pscustomobject]@{ Kvno = [int]$Kvno Keys = $keys Source = "PasswordS2K:$Compatibility/PBKDF2-SHA1($Iterations)" RetrievedAt = (Get-Date).ToUniversalTime() } $tsArg = @{} if ($PSBoundParameters.ContainsKey('FixedTimestampUtc') -and $FixedTimestampUtc) { $tsArg.FixedTimestampUtc = $FixedTimestampUtc } # Write file via keytab writer $writer = Get-Command -Name New-KeytabFile -ErrorAction Stop if (-not $writer) { Write-Verbose "New-KeytabFile not found; returning structure." $out = [pscustomobject]@{ PrincipalDescriptors = @($princDesc) KeySets = @($keySet) SelectedEtypes = $selection.Selected Compatibility = $Compatibility Iterations = $Iterations } if ($PassThru) { return $out } else { $out; return } } if (-not $out) { $base = ($princDesc.Components -join '_') $out = Join-Path -Path (Get-Location) -ChildPath ("{0}.keytab" -f $base) } if ((Test-Path -LiteralPath $out) -and -not $Force) { throw "Output file '$out' exists. Use -Force." } if ($PSCmdlet.ShouldProcess($princDesc.Display, "Create password-derived keytab file '$out'")) { $final = New-KeytabFile -Path $out -PrincipalDescriptors @($princDesc) -KeySets @($keySet) -RestrictAcl:$RestrictAcl @tsArg if ($summary -or $PassThru) { if (-not $SummaryPath) { $SummaryPath = [IO.Path]::ChangeExtension($final,'.json') } $etypeNames = @($selection.Selected | ForEach-Object { switch ($_) { 17 { 'AES128_CTS_HMAC_SHA1_96' } 18 { 'AES256_CTS_HMAC_SHA1_96' } 19 { 'AES128_CTS_HMAC_SHA256_128' } 20 { 'AES256_CTS_HMAC_SHA384_192' } default { "ETYPE_$_" } } }) $summaryObj = [ordered]@{ Principal = $princDesc.Display Realm = $princDesc.Realm Compatibility = $Compatibility Iterations = $Iterations Kvno = $Kvno Etypes = $selection.Selected EncryptionTypes = $etypeNames OutputPath = (Resolve-Path -LiteralPath $final).Path GeneratedAtUtc = if ($tsArg.FixedTimestampUtc) { $tsArg.FixedTimestampUtc.ToUniversalTime().ToString('o') } else { (Get-Date).ToUniversalTime().ToString('o') } } $summaryObj | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $SummaryPath -Encoding UTF8 if ($RestrictAcl) { if (Get-Command -Name Set-UserOnlyAcl -ErrorAction SilentlyContinue) { Set-UserOnlyAcl -Path $SummaryPath } } } if ($PassThru) { [pscustomobject]@{ Principal = $princDesc.Display Kvno = $Kvno Iterations = $Iterations Etypes = $selection.Selected SummaryPath = $SummaryPath OutputPath = $final } } else { $final } } } } |