<# .SYNOPSIS Alagezia PKI - request a certificate FROM OUTSIDE THE LAN (Windows PowerShell 5.1 / PowerShell 7, no admin rights). .DESCRIPTION For every network that is not the Alagezia LAN, VPN peers included (inside the LAN: request-cert.ps1). Nothing goes over plain HTTP. The only server is https://pki.hanei-no-choten.com: TLS ends on the CA host itself (the VDS on the way only passes the stream through), and this script accepts it only if the certificate is valid for the name in the Windows certificate store AND carries the CA host's own public key (pinned below). A forged certificate for the same name - even a real Let's Encrypt one obtained by whoever controls the VDS - has another key and is refused. Confirmation: VK community - a CLIENT certificate. The script shows an 8-digit code. In a private chat with the Alagezia VK community open "Sertifikaty" -> "Vypustit sertifikat" (certificates -> issue), send the code there, compare the key fingerprint in the reply, press "issue". Names: a single word gives .external.alagezia.internal; a full name works only if it is reserved for your VK account. The manual approval queue is a LAN-only route. The private key is created on this computer and never leaves it. Results are plain files in Documents\Alagezia\Certificates\\ .key .csr .crt .chain.pem [.pfx] Nothing is installed anywhere: importing the .pfx (double-click -> Current User) is your decision. The issued chain must lead to the Alagezia root built into this file. .EXAMPLE [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 3072 Invoke-WebRequest https://pki.hanei-no-choten.com/pki/request-cert-ext.ps1 -OutFile $HOME\request-cert-ext.ps1 powershell -ExecutionPolicy Bypass -File $HOME\request-cert-ext.ps1 .EXAMPLE .\request-cert-ext.ps1 -Name laptop -Pfx #> [CmdletBinding()] param( [string]$Name, [int]$Days = 0, # 0 = ask / the CA's default [switch]$Rsa, [switch]$Pfx, [switch]$NoPfx, [string]$PfxPassword, [string]$OutDir ) $ErrorActionPreference = 'Stop' # Any unexpected error: say WHERE it happened (Windows PowerShell drops the position for some errors). trap { Write-Host '' Write-Host (' Unexpected error: ' + $_.Exception.Message) -ForegroundColor Red $ii = $_.InvocationInfo if ($ii) { Write-Host (' Line ' + $ii.ScriptLineNumber + ': ' + ([string]$ii.Line).Trim()) -ForegroundColor DarkGray } if ($_.ScriptStackTrace) { Write-Host (' Stack: ' + ($_.ScriptStackTrace -replace "`r?`n", ' <- ')) -ForegroundColor DarkGray } Write-Host (' PowerShell ' + $PSVersionTable.PSVersion + ', .NET ' + [Environment]::Version + ', ' + [Globalization.CultureInfo]::CurrentCulture.Name) -ForegroundColor DarkGray exit 1 } Add-Type -AssemblyName System.Net.Http # Windows PowerShell 5.1 may still offer only old TLS versions; the CA host speaks TLS 1.2 / 1.3 [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 $ExtHost = 'pki.hanei-no-choten.com' # SHA-256 of the CA host's public key (SubjectPublicKeyInfo) of its Let's Encrypt certificate "hanei-ext". # certbot keeps the key on renewal (reuse_key); if the key is ever replaced, this pin and the .sh one must follow. $KeyPin = '7C1FA9CF849E19BD9570E1DDFB8F95B277B7A3EE0A83A648FCBABC85B228B156' $VkChat = 'https://vk.me/alagezia_al' $VkGroup = 'https://vk.ru/alagezia_al (@alagezia_al)' # The Alagezia root certificate travels inside this script: the issued chain is checked against it. $RootSha256 = 'A728875111FC347DC18E7D3418F38DAA5C31EDB867B1526D5EED0499DEAC725A' $RootPem = @' -----BEGIN CERTIFICATE----- MIIFiTCCA3GgAwIBAgIUNK5FWIcb/ZRATmMBRU/TtM55I1IwDQYJKoZIhvcNAQEL BQAwLjEZMBcGA1UEAwwQQWxhZ2V6aWEgUm9vdCBDQTERMA8GA1UECgwIQWxhZ2V6 aWEwHhcNMjYwOTExMjEyMjU5WhcNNDYwOTA2MjEyMjU4WjAuMRkwFwYDVQQDDBBB bGFnZXppYSBSb290IENBMREwDwYDVQQKDAhBbGFnZXppYTCCAiIwDQYJKoZIhvcN AQEBBQADggIPADCCAgoCggIBAMZ05y5jqC6TV6uuRWHtkEU2r2nD+IZARH7jyque CjLin1xmzWDUUnDupXUpdpEpGsQnf/zsncSciVWXjEuwIrh7Q1xVL0EHdq6xnc83 Z2lyiNj2NqTenko1qUDvph0GLdWtFs/10tY8FGXI+UHhIYPiwcGrJC0LWfKO8VnK iLu1j8BrxfvxA4S+K6RRjAOFU7pRZqmK0Ttzpa82SHvpBqKpnNuLEIUT2YaoDFg7 /7pkGs50s5HLwUAIR6L6nlCE3riSepTqsNpjz2lZFFKdlO/iFhP8IY/WWT/yrUu2 92YI/I5t75VaBTIGdnnS28g+vsiiVQ9FwV+o5cyh92l9Uz4gcR8lpGbit7b7Si0y lNqpLykdhFt9yUwBFM9mNiLlIYJRdzGRlSHVhJ2Ft+qnftr087ZsxzdYeUsLw+zp I+v71vQfcqg7JyGSVvMuNTL/tYuocAXIYHgBCdDm57YnGDT6rB3K3xArTkswZ+2r 3BqEtFUWZ1NPH4qxHNmJpxitiQPlH0V2LIPcHAD3LXgCQ0bSZ0GVlQNVJ2dl0CIL WhxWo3XQkN1bL5qy35jN1qFMnkH3lAGmN4pN3NmWEHC+dECmEKhdNgHLojxQoYn1 MZTGrhgcURmDy16npGmH6xu/X/xob6MW8QhWPiIjCodXSOpQE5SPLraJT2NcZuG1 g011AgMBAAGjgZ4wgZswDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBQmJs6W FIsUoJ1jLBTEVTbaftze/zA4BgNVHR4EMTAvoC0wE4IRYWxhZ2V6aWEuaW50ZXJu YWwwCocIwKgAAP//AAAwCocIrBAgAP///wAwHQYDVR0OBBYEFCYmzpYUixSgnWMs FMRVNtp+3N7/MA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOCAgEAOJD3 /ybFKJSyVvB7qyhcYXsrXLeMwQgi+X/dZlfo/XzvMpdMo9wwElmQAeO0uHxCfAb5 Lur39QDmDJ5kH0cwqvK6r3HdqwQckc32ZAGA0Qkp3FBkHmqwhPZEqJpwBz29k6DE tc6iJMjWjOz+6p/wImSmeAF1CyhGrp3q7AIJo48Ir0tdBQA/NSV6HgjLFjCIWrL4 e1obeCRY7Lks8tY9mgOEtZ34h/QixbydT28w464767VY9aoz5TXaR0tUKTEuniV4 /0/iPcb7yhCNn6i70igAwBrW4aVg21+AJOvvP3jMOgxZs2+IIEwNg31OCaOFORfH 3L9YPvaJfEUYHzxABXFJq7KBb5tyHmTNIY2rXSFC1GgOaou6wG8wd3h4SByK0Ndq cOn+4actwySWgZi/M3hI+W2psLdgLzUdDEA4oMmRL850ZSrmeksINFehCAZTe6Vx gvWJ5wQfv16hxpa00rUedJ1sb2Lxsxwa/MTw6i2izw1I5m6gqD1tVN596uoyagEF CApK5tbWvqLA7N6CxOxz/s93glTrJCIAeRGTV/d04hZzgQxvPWRqgFpaegr7rhsf ZCmPdtum9qq3xPRyVjyd7c566ReSmnsWbrKNBdkjOemcATwATrkoHCozr41muxBQ /IDs2XzS0sNC3Ruwt3esihhkJCIMMEnZ7sn64XE= -----END CERTIFICATE----- '@ $Interactive = -not $Name function Say([string]$t, [string]$c = 'Gray') { Write-Host $t -ForegroundColor $c } function Fail([string]$t) { Write-Host ''; Write-Host $t -ForegroundColor Red; exit 1 } # ---------------------------------------------------------------- TLS: valid for the name + pinned key of the CA host if (-not ('AlageziaExtTls' -as [type])) { Add-Type -ReferencedAssemblies System.Net.Http -TypeDefinition @' using System; using System.Net.Http; using System.Net.Security; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; public static class AlageziaExtTls { public static string Pin; public static string Seen = ""; static int Len(byte[] d, ref int i) { int b = d[i++]; if (b < 0x80) return b; int k = b & 0x7f, v = 0; for (int j = 0; j < k; j++) v = (v << 8) | d[i++]; return v; } static int Skip(byte[] d, int i) { i++; int n = Len(d, ref i); return i + n; } // SubjectPublicKeyInfo of a certificate, read from DER (the .NET Framework has no accessor for the whole SPKI) public static byte[] Spki(byte[] d) { int i = 0; i++; Len(d, ref i); // Certificate SEQUENCE i++; Len(d, ref i); // TBSCertificate SEQUENCE if (d[i] == 0xA0) i = Skip(d, i); // [0] version for (int k = 0; k < 5; k++) i = Skip(d, i); // serial, signature, issuer, validity, subject int end = Skip(d, i); byte[] s = new byte[end - i]; Array.Copy(d, i, s, 0, s.Length); return s; } public static string Sha(byte[] b) { return BitConverter.ToString(SHA256.Create().ComputeHash(b)).Replace("-", ""); } public static bool Validate(HttpRequestMessage req, X509Certificate2 cert, X509Chain chain, SslPolicyErrors errors) { if (errors != SslPolicyErrors.None) { Seen = "certificate not valid for the name (" + errors + ")"; return false; } string h = Sha(Spki(cert.RawData)); if (h != Pin) { Seen = "unexpected server key " + h.Substring(0, 16) + "..."; return false; } return true; } public static HttpClientHandler Handler() { var h = new HttpClientHandler(); h.ServerCertificateCustomValidationCallback = Validate; return h; } // SHA-256 of the SubjectPublicKeyInfo inside a PKCS#10 request - the CA computes the same for the VK reply public static string KeyFingerprint(byte[] csr) { int i = 0; i++; Len(csr, ref i); // CertificationRequest SEQUENCE i++; Len(csr, ref i); // CertificationRequestInfo SEQUENCE i++; int n = Len(csr, ref i); i += n; // version INTEGER i++; n = Len(csr, ref i); i += n; // subject Name int start = i; i++; n = Len(csr, ref i); // subjectPKInfo byte[] spki = new byte[i + n - start]; Array.Copy(csr, start, spki, 0, spki.Length); string h = Sha(spki); return h.Substring(0, 4) + " " + h.Substring(4, 4) + " " + h.Substring(8, 4) + " " + h.Substring(12, 4); } } '@ } $root = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new([byte[]][Text.Encoding]::ASCII.GetBytes($RootPem)) $rootFp = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($root.RawData)).Replace('-', '') if ($rootFp -ne $RootSha256) { Fail 'The built-in root certificate does not match its own fingerprint: this copy of the script is damaged.' } [AlageziaExtTls]::Pin = $KeyPin Say '' Say ' Alagezia PKI - certificate request from outside the LAN' 'Cyan' Say ' The private key is created on this computer and never leaves it. Nothing is installed.' 'DarkGray' Say '' $Base = "https://$ExtHost" $http = New-Object System.Net.Http.HttpClient([AlageziaExtTls]::Handler()) $http.Timeout = [TimeSpan]::FromSeconds(30) $Utf8 = [Text.UTF8Encoding]::new($false) function Invoke-Ext([string]$method, [string]$path, $body = $null, [string]$secret = '', [switch]$Soft) { # -Soft: a network failure comes back as Status 0 instead of ending the script (used while waiting) $m = New-Object System.Net.Http.HttpRequestMessage([System.Net.Http.HttpMethod]::new($method), "$Base/ca/ext/$path") if ($secret) { $m.Headers.Add('X-Ticket-Secret', $secret) } if ($null -ne $body) { $m.Content = [System.Net.Http.StringContent]::new(($body | ConvertTo-Json -Compress), $Utf8, 'application/json') } try { $r = $http.SendAsync($m).GetAwaiter().GetResult() } catch { $why = $_.Exception.GetBaseException().Message if ([AlageziaExtTls]::Seen) { $why = [AlageziaExtTls]::Seen Fail " The server at $Base is not the Alagezia CA host ($why): someone on the way answers instead of it, or this script is outdated - get a fresh copy from https://$ExtHost/ ." } if ($Soft) { return [pscustomobject]@{ Status = 0; Data = $null; Text = $why } } Fail "Cannot reach the Alagezia CA at $Base ($why)." } $text = $r.Content.ReadAsStringAsync().GetAwaiter().GetResult() $data = try { $text | ConvertFrom-Json } catch { $null } return [pscustomobject]@{ Status = [int]$r.StatusCode; Data = $data; Text = $text } } function Show-Refusal($r) { $e = if ($r.Data -and $r.Data.error) { $r.Data.error } else { "HTTP $($r.Status)" } $msg = if ($r.Data -and $r.Data.message) { " - $($r.Data.message)" } else { '' } Fail "The CA refused: $e$msg" } function ToPem([string]$label, [byte[]]$der) { $b64 = [Convert]::ToBase64String($der) $lines = for ($i = 0; $i -lt $b64.Length; $i += 64) { $b64.Substring($i, [Math]::Min(64, $b64.Length - $i)) } return "-----BEGIN $label-----`n" + ($lines -join "`n") + "`n-----END $label-----`n" } function New-KeyAndCsr([string]$cn, [string]$dir, [string]$file) { $p = New-Object System.Security.Cryptography.CngKeyCreationParameters $p.ExportPolicy = [System.Security.Cryptography.CngExportPolicies]::AllowPlaintextExport $p.KeyCreationOptions = [System.Security.Cryptography.CngKeyCreationOptions]::None $noName = [NullString]::Value # $null would create a PERSISTED key named "" in the user's key store if ($Rsa) { $p.Parameters.Add((New-Object System.Security.Cryptography.CngProperty('Length', [BitConverter]::GetBytes(2048), 'None'))) $cng = [System.Security.Cryptography.CngKey]::Create([System.Security.Cryptography.CngAlgorithm]::Rsa, $noName, $p) $alg = New-Object System.Security.Cryptography.RSACng($cng) $req = New-Object System.Security.Cryptography.X509Certificates.CertificateRequest("CN=$cn, O=Alagezia", $alg, [System.Security.Cryptography.HashAlgorithmName]::SHA256, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) } else { $cng = [System.Security.Cryptography.CngKey]::Create([System.Security.Cryptography.CngAlgorithm]::ECDsaP256, $noName, $p) $alg = New-Object System.Security.Cryptography.ECDsaCng($cng) $req = New-Object System.Security.Cryptography.X509Certificates.CertificateRequest("CN=$cn, O=Alagezia", $alg, [System.Security.Cryptography.HashAlgorithmName]::SHA256) } [IO.File]::WriteAllText((Join-Path $dir "$file.key"), (ToPem 'PRIVATE KEY' $cng.Export([System.Security.Cryptography.CngKeyBlobFormat]::Pkcs8PrivateBlob)), $Utf8) $der = $req.CreateSigningRequest() $pem = ToPem 'CERTIFICATE REQUEST' $der [IO.File]::WriteAllText((Join-Path $dir "$file.csr"), $pem, $Utf8) return [pscustomobject]@{ Alg = $alg; Pem = $pem; Fp = [AlageziaExtTls]::KeyFingerprint($der) } } function Save-Result([string]$chain, [string]$dir, [string]$file, $key) { $all = [regex]::Matches($chain, '-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----') if ($all.Count -lt 1) { Fail ' The CA sent no certificate.' } $leafPem = $all[0].Value + "`n" $leaf = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new([byte[]][Text.Encoding]::ASCII.GetBytes($leafPem)) # the chain must lead to the built-in root (not to whatever the network says) $c = New-Object System.Security.Cryptography.X509Certificates.X509Chain $c.ChainPolicy.ExtraStore.Add($root) | Out-Null for ($i = 1; $i -lt $all.Count; $i++) { $c.ChainPolicy.ExtraStore.Add([System.Security.Cryptography.X509Certificates.X509Certificate2]::new([byte[]][Text.Encoding]::ASCII.GetBytes($all[$i].Value))) | Out-Null } $c.ChainPolicy.VerificationFlags = [System.Security.Cryptography.X509Certificates.X509VerificationFlags]::AllowUnknownCertificateAuthority $c.ChainPolicy.RevocationMode = [System.Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck if (-not $c.Build($leaf) -or $c.ChainElements[$c.ChainElements.Count - 1].Certificate.Thumbprint -ne $root.Thumbprint) { Fail ' The received certificate does not lead to the Alagezia root. Not saved.' } [IO.File]::WriteAllText((Join-Path $dir "$file.chain.pem"), $chain, $Utf8) [IO.File]::WriteAllText((Join-Path $dir "$file.crt"), $leafPem, $Utf8) Say '' Say " Issued: $($leaf.Subject)" 'Green' Say " Valid until $($leaf.NotAfter.ToString('yyyy-MM-dd')), serial $($leaf.SerialNumber)" 'Green' $makePfx = $Pfx if (-not $Pfx -and -not $NoPfx -and $Interactive) { $makePfx = (Read-Host ' Also build a .pfx for import into Windows? [Y/n]') -notmatch '^[nN]' } if ($makePfx) { $pw = if ($PfxPassword) { $PfxPassword } else { $sec = Read-Host ' Password for the .pfx' -AsSecureString [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec)) } # CopyWithPrivateKey also refuses a certificate that is not for this key $withKey = if ($Rsa) { [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::CopyWithPrivateKey($leaf, $key) } else { [System.Security.Cryptography.X509Certificates.ECDsaCertificateExtensions]::CopyWithPrivateKey($leaf, $key) } $coll = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2Collection $coll.Add($withKey) | Out-Null for ($i = 1; $i -lt $all.Count; $i++) { $coll.Add([System.Security.Cryptography.X509Certificates.X509Certificate2]::new([byte[]][Text.Encoding]::ASCII.GetBytes($all[$i].Value))) | Out-Null } [IO.File]::WriteAllBytes((Join-Path $dir "$file.pfx"), $coll.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pkcs12, $pw)) } Say '' Say " Files: $dir" 'Cyan' Get-ChildItem $dir | ForEach-Object { Say " $($_.Name)" } if ($makePfx) { Say ' Import: double-click the .pfx -> Current User -> Personal.' 'DarkGray' } } $BaseDir = if ($OutDir) { $OutDir } else { Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'Alagezia\Certificates' } New-Item -ItemType Directory -Force -Path $BaseDir | Out-Null [IO.File]::WriteAllText((Join-Path $BaseDir 'alagezia-root.crt'), $RootPem, $Utf8) # ================================================================ VK confirmation while ($true) { if (-not $Name) { Say '' Say ' Certificate name: one word (a-z 0-9 -) gives .external.alagezia.internal;' 'DarkGray' Say ' a full name (host.alagezia.internal) works only if it is reserved for your VK account.' 'DarkGray' $Name = (Read-Host ' Name').Trim().ToLower() } $h = Invoke-Ext 'POST' 'hello' @{ name = $Name; days = $(if ($Days) { $Days } else { $null }) } if ($h.Status -eq 200) { break } if (-not $Interactive) { Show-Refusal $h } Say " Not accepted: $($h.Data.message)" 'Yellow'; $Name = ''; $Days = 0 } $cn = $h.Data.cn Say " Name: $cn" 'Cyan' if ($h.Data.bot -ne 'online') { Fail ' The VK confirmation service is offline right now: a code could not be confirmed. Try again later.' } if (-not $Days) { $Days = [int]$h.Data.days if ($Interactive) { $d = Read-Host " Days (1-$($h.Data.max_days)) [$Days]"; if ($d -match '^\d+$') { $Days = [int]$d } } } $dir = Join-Path $BaseDir $cn New-Item -ItemType Directory -Force -Path $dir | Out-Null $k = New-KeyAndCsr $cn $dir $cn $o = Invoke-Ext 'POST' 'open' @{ name = $cn; days = $Days; csr = $k.Pem } if ($o.Status -ne 201) { Show-Refusal $o } $t = $o.Data if ($t.key_fp -ne $k.Fp) { Invoke-Ext 'POST' "cancel/$($t.id)" $null $t.secret | Out-Null Fail " The CA saw a different key ($($t.key_fp)) than this computer made ($($k.Fp)): the request was altered on the way. Cancelled." } $code = $t.code.Substring(0, 4) + ' ' + $t.code.Substring(4, 4) Say '' Say ' ==================================================' 'Cyan' Say " Confirmation code: $code" 'White' Say " Key fingerprint: $($k.Fp)" 'White' Say ' ==================================================' 'Cyan' Say " In a PRIVATE chat with the VK community Alagezia: $VkGroup" Say " (direct link to the chat: $VkChat) open" Say ' "Sertifikaty" -> "Vypustit sertifikat" (certificates -> issue) and send the code there' Say ' within 5 minutes. A code sent anywhere else is ignored, group chats included.' Say ' The reply shows a key fingerprint: it must be exactly the one above - otherwise press "Cancel" there.' Say ' Ctrl+C here cancels the request.' 'DarkGray' Say '' $done = $false; $last = ''; $trouble = $false $deadline = (Get-Date).AddMinutes(11) try { while ((Get-Date) -lt $deadline) { $s = Invoke-Ext 'GET' "t/$($t.id)" $null $t.secret -Soft # temporary trouble (no network, rate limit shared with neighbours behind one address, CA restarting) # must not cost the request: the code may already be on its way in VK. Only a final answer ends the wait. if ($s.Status -eq 0 -or $s.Status -eq 429 -or $s.Status -ge 500) { if (-not $trouble) { Say " Connection problem ($(if ($s.Status) { "HTTP $($s.Status)" } else { $s.Text })), retrying..." 'Yellow'; $trouble = $true } Start-Sleep -Seconds 10; continue } if ($s.Status -ne 200) { Show-Refusal $s } if ($trouble) { Say ' Connection restored.' 'DarkGray'; $trouble = $false } $st = $s.Data.state if ($st -ne $last) { switch ($st) { 'open' { Say ' Waiting for the code in VK...' } 'claimed' { Say " Code received from VK id $($s.Data.vk_id). Waiting for 'issue' in VK..." } 'issuing' { Say ' Issuing...' } 'issued' { } 'rejected' { $done = $true; Fail ' The request was cancelled.' } 'expired' { $done = $true; Fail ' Time is up - run the script again for a new code.' } 'failed' { $done = $true; Fail ' The CA could not issue the certificate.' } } $last = $st } if ($st -eq 'issued') { $done = $true; break } Start-Sleep -Seconds 5 } if (-not $done) { Fail ' Time is up - run the script again for a new code.' } } finally { if (-not $done) { Invoke-Ext 'POST' "cancel/$($t.id)" $null $t.secret -Soft | Out-Null; Say ' Request cancelled.' 'Yellow' } } Save-Result $s.Data.chain $dir $cn $k.Alg Say '' Say ' Services: https://hanei-no-choten.com/' 'DarkGray' exit 0