argus/setup/install.ps1
Local Dev 8904c1d7ba Ariadne 0.1.5: route and certify every live registry TLD, not the legacy nine
Other browsers stopped opening most BNS names because two lists had
drifted. The installer took its TLD set from the gateway's `advertised`
field — the legacy tlds.bch record, nine TLDs — while the registry had
moved to per-TLD NFTs with twenty (including .x, where most Silent Mode
sites live). And the daemon's permitted-TLD list came from the bundled
ariadne.config.json (seven TLDs) rather than the CA the installer had
just minted, so only the overlap — .bch, .p2p, .deviant, .nav — actually
worked; .sia, .nt and the rest got no certificate, .x got no route at
all.

Now: the gateway publishes `routable` (every live TLD on the public
listing plus the legacy advertised set); the installer routes and
certifies that, skipping ICANN-delegated collision TLDs (.cash, .gt,
.sc — checked against the live IANA root zone, bundled snapshot as
fallback) since the local CA must never be constrained to a real
internet TLD; it writes the certified list into ariadne.config.json;
and the daemon reads its permitted list from the installed CA's own
name constraints, so the two can no longer disagree.
2026-09-27 23:20:26 +02:00

418 lines
24 KiB
PowerShell

# BNS resolver daemon installer -- run from an ELEVATED PowerShell.
# .\setup\install.ps1 # surgical: NRPT rule routes only .bch to the daemon
# .\setup\install.ps1 -Mode full # universal: adapter DNS -> 127.0.0.1, daemon forwards the rest
param(
[ValidateSet("nrpt", "full")] [string]$Mode = "nrpt",
[string]$Upstream = "1.1.1.1",
# Collision policy the daemon should run under (docs: START-HERE.md 4b).
# bcnr-first (default): BCNR wins, ICANN fallback on miss.
# icann-first : ICANN wins, BCNR fallback on NXDOMAIN.
# bns-first is accepted as an alias for bcnr-first.
[ValidateSet("bcnr-first", "icann-first", "bns-first")] [string]$Policy = "bcnr-first",
# BNS indexer(s) for the primary TLD discovery source. Comma-separated list
# so we can fall over between mirrors -- HTTPS beats WSS-electrum from a
# silent-install context (no Node subprocess, no ws dependency, DPI-friendly).
# The first indexer that answers within its per-source deadline wins.
[string]$Indexer = "https://navigate.st,https://silentmode.st",
# Opt-in: turn off Firefox DNS-over-HTTPS. Only needed to resolve names that
# ALSO exist in ICANN (a reclaimed .de) via BCNR in Firefox -- BCNR-only names
# (all .bch, etc.) already work with DoH on, because DoH returns NXDOMAIN and
# Firefox falls back to the local resolver. Trade-off: Firefox's other DNS then
# goes through the system resolver instead of encrypted to Cloudflare.
[switch]$FirefoxNoDoH,
# Opt-in: force-close a running Firefox so it reloads the new root CA + policy
# immediately (Firefox only reads them at startup). Off by default because it
# discards the user's open tabs/unsaved data.
[switch]$CloseFirefox
)
$ErrorActionPreference = "Stop"
# ---- persistent install log --------------------------------------------------
# Silent-install callers (Theseus's NSIS chain-install, `.exe /VERYSILENT`, Inno
# `runhidden`) discard our stdout/stderr, so historically any failure between
# "files copied" and "scheduled task registered" was invisible. This appends a
# timestamped transcript to a well-known path so a broken install always leaves
# forensics behind. Errors starting the transcript are non-fatal: an unloggable
# install must still complete or fail visibly.
$LogDir = Join-Path $env:ProgramData 'Ariadne'
try { New-Item -ItemType Directory -Force -Path $LogDir | Out-Null } catch {}
$LogFile = Join-Path $LogDir 'install.log'
$ErrFile = Join-Path $LogDir 'install-error.log'
try { Start-Transcript -Path $LogFile -Append -Force -IncludeInvocationHeader | Out-Null } catch {}
Write-Host ""
Write-Host ("==== install.ps1 " + (Get-Date -Format 's') + " Mode=$Mode Policy=$Policy Indexer=$Indexer ====")
# Script-scope trap: any uncaught exception is written to install-error.log
# (separate file so a post-mortem doesn't have to grep through the whole
# transcript), the transcript is closed cleanly, then the exception is
# re-thrown so the caller (Inno's [Run], NSIS's ExecWait, or the user's PS)
# still sees a non-zero exit code and can act on it.
trap {
try {
$stamp = Get-Date -Format 's'
$msg = "[FATAL $stamp] $($_ | Out-String)`nScriptStack:`n$($_.ScriptStackTrace)`n"
Add-Content -Path $ErrFile -Value $msg -ErrorAction SilentlyContinue
Write-Host $msg -ForegroundColor Red
} catch {}
try { Stop-Transcript | Out-Null } catch {}
exit 1
}
if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
try { Stop-Transcript | Out-Null } catch {}
throw "Run this from an elevated (Administrator) PowerShell."
}
$root = Split-Path -Parent $PSScriptRoot
# Prefer a bundled Node runtime (distributable installer ships runtime\node.exe);
# fall back to a Node on PATH for running straight from the dev repo.
$node = Join-Path $root "runtime\node.exe"
if (-not (Test-Path $node)) {
$onPath = Get-Command node -ErrorAction SilentlyContinue
if (-not $onPath) { throw "No Node runtime found: expected runtime\node.exe (bundle) or node on PATH (dev repo)." }
$node = $onPath.Source
}
$daemon = Join-Path $root "src\daemon\bnsd.js"
$stateFile = Join-Path $PSScriptRoot "install-state.json"
# TLS 1.2 (PS 5.1 defaults to 1.0/1.1 on older boxes and Invoke-RestMethod fails
# against modern servers).
try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch {}
# --- helper: run `node --input-type=module -e "<script>"` and capture output
# via Windows-level stream redirection so PS 5.1's "$ErrorActionPreference=Stop
# + native stderr = NativeCommandError" quirk cannot kill us. That quirk is
# what silently broke 0.1.0/0.1.1 install.ps1 at the CA-rotation probe.
function Invoke-NodeCapture([string]$Script) {
$tempOut = [System.IO.Path]::GetTempFileName()
$tempErr = [System.IO.Path]::GetTempFileName()
try {
$p = Start-Process -FilePath $node -ArgumentList @('--input-type=module','-e', $Script) `
-WorkingDirectory $root -Wait -PassThru -NoNewWindow `
-RedirectStandardOutput $tempOut -RedirectStandardError $tempErr
return [pscustomobject]@{
ExitCode = $p.ExitCode
StdOut = if (Test-Path $tempOut) { Get-Content $tempOut -Raw } else { "" }
StdErr = if (Test-Path $tempErr) { Get-Content $tempErr -Raw } else { "" }
}
} finally {
Remove-Item $tempOut, $tempErr -Force -ErrorAction SilentlyContinue
}
}
# Discover the set of currently activated BCNR TLDs. Sources, in order of
# preference (first hit wins, tight per-source deadline):
# 1) BNS indexer(s) HTTPS GET <indexer>/api/tlds -> {advertised:[...]}
# Fast + reliable when up; -Indexer takes a comma-separated list so the
# installer can fall over between mirrors (default: navigate.st and
# silentmode.st, both live on the same VPS today; add your own to widen).
# 2) Local cache from a previous successful indexer/electrum fetch
# (C:\ProgramData\Ariadne\tlds-cache.json). Instant; always right on any
# machine that has ever successfully installed before. May be stale --
# won't have TLDs added since last install -- but never wrong.
# 3) On-chain via electrum WSS (per-TLD beacon fetchTldList; legacy
# fetchBcnrTlds as sub-fallback). Slower + WSS-fragile.
# 4) Bundled ariadne.config.json snapshot (frozen at installer build time).
# 5) Bootstrap set [bch] -- last resort so the daemon still starts.
# Cache is refreshed whenever an authoritative source (indexer or electrum)
# succeeds. This drives both the CA's nameConstraints extension and the
# surgical-mode NRPT rules below; adding a new TLD on-chain is picked up by
# re-running this installer (no code release).
$tldCachePath = Join-Path $LogDir 'tlds-cache.json'
function Fetch-TldsFromIndexer([string]$IndexerUrl) {
try {
$url = $IndexerUrl.TrimEnd('/') + "/api/tlds"
Write-Host "[..] indexer: $url"
$r = Invoke-RestMethod -Uri $url -TimeoutSec 5 -UseBasicParsing -Headers @{ 'User-Agent' = 'AriadneResolverInstaller/1' }
# Prefer the registry's routable list (every live per-TLD NFT plus the
# legacy advertised set); fall back to assembling it from tlds[] + advertised
# for a gateway that predates the field; last resort: advertised alone (the
# legacy tlds.bch record — nine TLDs, which is how 0.1.4 missed .x).
if ($r -and $r.routable -and $r.routable.Count -gt 0) {
return @{ tlds = @($r.routable | ForEach-Object { [string]$_ }); source = "indexer($IndexerUrl)" }
}
if ($r -and $r.tlds -and $r.tlds.Count -gt 0) {
$live = @($r.tlds | Where-Object { -not $_.hidden } | ForEach-Object { [string]$_.tld })
$adv = @(); if ($r.advertised) { $adv = @($r.advertised | ForEach-Object { [string]$_ }) }
$u = @($live + $adv | Where-Object { $_ } | Select-Object -Unique)
if ($u.Count -gt 0) { return @{ tlds = $u; source = "indexer($IndexerUrl)" } }
}
if ($r -and $r.advertised -and $r.advertised.Count -gt 0) {
return @{ tlds = @($r.advertised | ForEach-Object { [string]$_ }); source = "indexer($IndexerUrl)" }
}
} catch { Write-Host " [!] $IndexerUrl -> $($_.Exception.Message)" -ForegroundColor Yellow }
return $null
}
function Fetch-TldsFromCache([string]$Path) {
if (-not (Test-Path $Path)) { return $null }
try {
$c = Get-Content $Path -Raw | ConvertFrom-Json
if ($c.tlds -and $c.tlds.Count -gt 0) {
$age = try { [int]((Get-Date) - (Get-Date $c.savedAt)).TotalDays } catch { -1 }
return @{ tlds = @($c.tlds | ForEach-Object { [string]$_ }); source = "cache(age=${age}d)" }
}
} catch {}
return $null
}
function Fetch-TldsFromElectrum {
$js = "import('./src/lib/resolver-web.js').then(async m=>{const ws=(await import('ws')).default;try{const list=await m.fetchTldList({WebSocket:ws});if(list&&list.length&&!(list.length===1&&list[0]==='bch')){console.log(JSON.stringify(list));return}}catch(e){}try{const legacy=await m.fetchBcnrTlds({WebSocket:ws});console.log(JSON.stringify(legacy||[]))}catch(e){console.log('[]')}}).catch(e=>console.log('[]'))"
$r = Invoke-NodeCapture $js
try {
$arr = $r.StdOut.Trim() | ConvertFrom-Json
if ($arr -and $arr.Count -gt 0) { return @{ tlds = @($arr | ForEach-Object { [string]$_ }); source = "electrum" } }
} catch {}
return $null
}
function Fetch-TldsFromConfig([string]$CfgPath) {
try {
$c = Get-Content $CfgPath -Raw | ConvertFrom-Json
if ($c.tlds -and $c.tlds.Count -gt 0) {
return @{ tlds = @($c.tlds | ForEach-Object { [string]$_ }); source = "ariadne.config.json" }
}
} catch {}
return $null
}
$result = $null
foreach ($ix in ($Indexer -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })) {
$result = Fetch-TldsFromIndexer $ix
if ($result) { break }
}
if (-not $result) { $result = Fetch-TldsFromCache $tldCachePath; if ($result) { Write-Host "[!] no indexer reachable -- using $($result.source)" -ForegroundColor Yellow } }
if (-not $result) { $result = Fetch-TldsFromElectrum; if ($result) { Write-Host "[ok] resolved via electrum WSS" } }
if (-not $result) { $result = Fetch-TldsFromConfig (Join-Path $root "ariadne.config.json"); if ($result) { Write-Host "[!] falling back to bundled snapshot" -ForegroundColor Yellow } }
if (-not $result) { Write-Host "[!] no TLD source available -- bootstrap [bch]" -ForegroundColor Yellow; $result = @{ tlds = @('bch'); source = 'bootstrap' } }
$ACTIVATED_TLDS = $result.tlds
Write-Host "[ok] activated TLDs (source: $($result.source)): $($ACTIVATED_TLDS -join ', ')"
# Persist cache on any authoritative fetch. Cache and bootstrap don't refresh
# themselves; only network sources do.
if ($result.source -like 'indexer(*)' -or $result.source -eq 'electrum') {
try {
@{ tlds = $ACTIVATED_TLDS; savedAt = (Get-Date -Format 's'); source = $result.source } |
ConvertTo-Json | Set-Content -Path $tldCachePath -Encoding utf8
Write-Host "[ok] cached TLD list to $tldCachePath"
} catch { Write-Host "[!] could not write cache: $($_.Exception.Message)" -ForegroundColor Yellow }
}
# Split the activated set: TLDs that ICANN has delegated (.cash, .gt, .sc are
# registered in BNS on purpose as collision tests) are neither routed to the
# daemon nor certified by the local CA — the daemon's upstream forwarding has
# an open EDNS/AAAA concern, and a CA constrained to a real internet TLD could
# vouch for real sites. The IANA root-zone list is fetched live (8 s) and falls
# back to the snapshot bundled next to this script. "bcnr" is always certified:
# the daemon's default certificate is issued for gateway.bcnr.
$ianaList = @()
try {
$ianaText = (Invoke-WebRequest -Uri 'https://data.iana.org/TLD/tlds-alpha-by-domain.txt' -TimeoutSec 8 -UseBasicParsing).Content
$ianaList = @($ianaText -split "`n" | ForEach-Object { $_.Trim().ToLower() } | Where-Object { $_ -and -not $_.StartsWith('#') })
Write-Host "[ok] IANA root zone: $($ianaList.Count) delegated TLDs (live)"
} catch {
$snap = Join-Path $PSScriptRoot 'iana-tlds.txt'
if (Test-Path $snap) { $ianaList = @(Get-Content $snap | ForEach-Object { $_.Trim().ToLower() } | Where-Object { $_ }); Write-Host "[!] IANA list from bundled snapshot ($($ianaList.Count) TLDs)" -ForegroundColor Yellow }
else { Write-Host "[!] no IANA list available -- routing every activated TLD" -ForegroundColor Yellow }
}
$ianaSet = @{}; foreach ($t in $ianaList) { $ianaSet[$t] = $true }
$COLLISION_TLDS = @($ACTIVATED_TLDS | Where-Object { $ianaSet.ContainsKey($_.ToLower()) })
$ROUTE_TLDS = @($ACTIVATED_TLDS | Where-Object { -not $ianaSet.ContainsKey($_.ToLower()) })
if ($ROUTE_TLDS.Count -eq 0) { $ROUTE_TLDS = @('bch') }
$CA_TLDS = @($ROUTE_TLDS + 'bcnr' | Select-Object -Unique)
if ($COLLISION_TLDS.Count -gt 0) { Write-Host "[info] left to ICANN (delegated, resolved by the upstream): $($COLLISION_TLDS -join ', ')" }
Write-Host "[ok] routing + certifying: $($ROUTE_TLDS -join ', ')"
# Generate the name-constrained local root CA now (on THIS machine; the key
# never ships). If ca/ exists but its permitted-TLD set no longer matches, we
# rotate: delete the stale CA so loadOrCreateRootCA writes a fresh one with
# current constraints. Otherwise clients would reject leaves for any
# newly-added TLD.
$caCrt = Join-Path $root "ca\root-ca.crt"
$caKey = Join-Path $root "ca\root-ca.key"
$tldsArg = ($CA_TLDS | ForEach-Object { "'" + ($_ -replace "'","''") + "'" }) -join ","
if (Test-Path $caCrt) {
$r = Invoke-NodeCapture "import('./src/lib/ca.js').then(m=>{try{m.loadOrCreateRootCA([$tldsArg]);console.log('ok')}catch(e){console.log('err')}}).catch(e=>console.log('err'))"
# ca.js prints the constraint-mismatch phrase to stderr; we grep StdErr here
# instead of merging streams (which is what tripped PS 5.1 before).
if ($r.StdErr -match 'is constrained to \[.*\] but the requested TLD list is') {
Write-Host "[ok] rotating CA to match current TLD set"
Remove-Item -Path $caCrt, $caKey -Force -ErrorAction SilentlyContinue
}
}
$r = Invoke-NodeCapture "import('./src/lib/ca.js').then(m=>m.loadOrCreateRootCA([$tldsArg])).catch(e=>{console.error(String(e));process.exit(1)})"
if ($r.ExitCode -ne 0) {
if ($r.StdErr) { Write-Host $r.StdErr -ForegroundColor Red }
throw "CA generation failed (node exit $($r.ExitCode))"
}
if (-not (Test-Path $caCrt)) { throw "CA generation produced no root-ca.crt" }
Write-Host "[ok] CA at $caCrt"
# The daemon reads its permitted-TLD list from ariadne.config.json (and, since
# 0.1.5, from the CA's own constraints); write the same list the CA was minted
# with so the two can never drift apart again.
$cfgPath = Join-Path $root 'ariadne.config.json'
try {
$cfg = Get-Content $cfgPath -Raw | ConvertFrom-Json
$cfg.tlds = @($CA_TLDS)
$cfg | ConvertTo-Json -Depth 10 | Set-Content -Path $cfgPath -Encoding utf8
Write-Host "[ok] ariadne.config.json tlds = $($CA_TLDS -join ', ')"
} catch { Write-Host "[!] could not update ariadne.config.json tlds: $($_.Exception.Message)" -ForegroundColor Yellow }
# 0) Idempotency: clear every previous NRPT rule we own (any TLD).
Get-DnsClientNrptRule | Where-Object { $_.Comment -like 'BNS *resolver' } | ForEach-Object {
Remove-DnsClientNrptRule -Name $_.Name -Force
}
try { Stop-ScheduledTask -TaskName "BNS Resolver Daemon" -ErrorAction Stop } catch {}
try { Stop-ScheduledTask -TaskName "BNS Sia Bridge" -ErrorAction Stop } catch {}
# 1) Scheduled task: run the daemon at boot as SYSTEM (survives logoff, binds port 53/80/443)
# Pass --policy so bnsd.js applies the chosen collision mode (docs: 4b).
$action = New-ScheduledTaskAction -Execute $node -Argument "`"$daemon`" --upstream $Upstream --policy $Policy" -WorkingDirectory $root
$trigger = New-ScheduledTaskTrigger -AtStartup
$settings = New-ScheduledTaskSettingsSet -RestartCount 5 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit (New-TimeSpan -Days 3650)
Register-ScheduledTask -TaskName "BNS Resolver Daemon" -Action $action -Trigger $trigger -Settings $settings `
-User "SYSTEM" -RunLevel Highest -Force | Out-Null
Start-ScheduledTask -TaskName "BNS Resolver Daemon"
Write-Host "[ok] daemon installed as scheduled task 'BNS Resolver Daemon' and started"
# 1b) Sia bridge (optional): serves "s3" records from the Sia network
$s3d = Join-Path $root "tools\s3d.exe"
if (Test-Path $s3d) {
# runs as the logged-on user (s3d's login state lives in the user's APPDATA)
$s3dAction = New-ScheduledTaskAction -Execute $s3d -WorkingDirectory $root
$s3dTrigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME
$s3dPrincipal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive
Register-ScheduledTask -TaskName "BNS Sia Bridge" -Action $s3dAction -Trigger $s3dTrigger `
-Settings $settings -Principal $s3dPrincipal -Force | Out-Null
Start-ScheduledTask -TaskName "BNS Sia Bridge"
Write-Host "[ok] s3d Sia bridge installed (runs at your logon) and started"
}
# 2) Route queries to it
$state = @{ mode = $Mode; adapters = @() }
if ($Mode -eq "nrpt") {
# Surgical mode: one NRPT rule per TLD that tlds.bch has activated on-chain.
# BCNR-native TLDs are declared there and only there; a re-install after a new
# TLD is activated picks it up with no code release. Dual TLDs that collide
# with ICANN are still safe to route because the daemon falls through to
# UPSTREAM on any BCNR miss.
foreach ($tld in $ROUTE_TLDS) {
$ns = ".$tld"
# Idempotent: clean any pre-existing rule for this namespace first.
Get-DnsClientNrptRule | Where-Object { $_.Namespace -contains $ns -and $_.Comment -like 'BNS *' } | ForEach-Object {
Remove-DnsClientNrptRule -Name $_.Name -Force
}
Add-DnsClientNrptRule -Namespace $ns -NameServers "127.0.0.1" -Comment "BNS $ns resolver"
Write-Host "[ok] NRPT rule added: $ns -> 127.0.0.1"
}
Write-Host "[ok] all other DNS untouched"
} else {
# Reinstall-safe backup: if the machine is ALREADY pointed at us (127.0.0.1) from a
# prior install, the "current" servers are a useless restore target. Reuse the last
# good backup if we have one, else fall back to DHCP. NEVER record loopback as the
# original -- that's exactly what left a past uninstall pointing DNS at a dead daemon.
$prior = @{}
if (Test-Path $stateFile) {
try { (Get-Content $stateFile | ConvertFrom-Json).adapters | ForEach-Object { $prior[$_.alias] = $_ } } catch {}
}
foreach ($if in Get-DnsClientServerAddress -AddressFamily IPv4 |
Where-Object { $_.InterfaceAlias -notmatch "Loopback" -and $_.ServerAddresses.Count -gt 0 }) {
$orig = @($if.ServerAddresses | Where-Object { $_ -ne '127.0.0.1' -and $_ -ne '::1' })
if ($orig.Count -eq 0 -and $prior.ContainsKey($if.InterfaceAlias)) {
$orig = @($prior[$if.InterfaceAlias].servers | Where-Object { $_ -and $_ -ne '127.0.0.1' -and $_ -ne '::1' })
}
$state.adapters += @{ alias = $if.InterfaceAlias; servers = $orig; resetToDhcp = ($orig.Count -eq 0) }
Set-DnsClientServerAddress -InterfaceAlias $if.InterfaceAlias -ServerAddresses 127.0.0.1
$restoreTo = if ($orig.Count) { $orig -join ', ' } else { 'DHCP (automatic)' }
Write-Host "[ok] adapter '$($if.InterfaceAlias)' DNS -> 127.0.0.1 (restore target: $restoreTo)"
}
}
# 3) TLS trust anchor: BNS Local Root CA into the machine store, and tell
# Firefox (which keeps its own store) to honor Windows enterprise roots.
$caCert = Join-Path $root "ca\root-ca.crt"
if (Test-Path $caCert) {
# Refuse to trust a root that can vouch for the whole internet. Roots generated
# before 2026-07-26 carry no name constraints; installing one means anyone who
# can read ca\root-ca.key can forge a trusted certificate for any domain on this
# machine. Constrained roots are limited to the TLDs in src\lib\tlds.js.
$ncOid = "2.5.29.30"
$parsed = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($caCert)
$hasConstraints = @($parsed.Extensions | Where-Object { $_.Oid.Value -eq $ncOid }).Count -gt 0
$parsed.Dispose()
if (-not $hasConstraints) {
Write-Host ""
Write-Host "[STOP] ca\root-ca.crt has NO X.509 name constraints." -ForegroundColor Red
Write-Host " Trusting it would let anyone who reads ca\root-ca.key forge a" -ForegroundColor Red
Write-Host " certificate for ANY domain on this machine, not just BNS names." -ForegroundColor Red
Write-Host " Rotate the CA, then re-run this installer:" -ForegroundColor Red
Write-Host " setup\uninstall.ps1 (removes the old root from the store)" -ForegroundColor Red
Write-Host " Remove-Item -Recurse $($root)\ca"
Write-Host " setup\install.ps1 (generates a constrained root)" -ForegroundColor Red
Write-Host ""
throw "refusing to install an unconstrained root CA"
}
# .NET direct -- Import-Certificate depends on the Cert: PSDrive which is
# not always loaded in -NoProfile elevated shells (Inno's [Run] context, for
# example). Adding to X509Store('Root','LocalMachine') is idempotent.
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root','LocalMachine')
$store.Open('ReadWrite')
$imported = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($caCert)
# Sweep any prior "BNS Local Root CA" whose thumbprint differs from the one
# we're about to trust. Two roots with the same Subject-DN confuse browsers'
# chain-builders (Chromium/Edge/Firefox all pick one candidate and refuse to
# try others), which manifests as "resolver installed and DNS answers, but
# every .bch site fails with SEC_ERROR_BAD_SIGNATURE / ERR_CERT_AUTHORITY_INVALID
# in every browser except Theseus". A stale unconstrained root left over from
# an early build is enough to trigger it -- and there's no way to detect it
# without walking the store, because both roots claim the same identity.
# Post-condition: exactly one "BNS Local Root CA" remains after this block.
foreach ($c in @($store.Certificates | Where-Object { $_.Subject -match 'BNS Local Root CA' -and $_.Thumbprint -ne $imported.Thumbprint })) {
Write-Host "[ok] removing stale BNS root CA ($($c.Thumbprint), NotAfter $($c.NotAfter))"
$store.Remove($c)
}
$store.Add($imported)
$store.Close()
$state.caThumbprint = $imported.Thumbprint
New-Item -Path "HKLM:\SOFTWARE\Policies\Mozilla\Firefox\Certificates" -Force | Out-Null
Set-ItemProperty -Path "HKLM:\SOFTWARE\Policies\Mozilla\Firefox\Certificates" `
-Name "ImportEnterpriseRoots" -Value 1 -Type DWord
# Also install our specific root into Firefox's own store (works even if the
# user disabled enterprise-roots). Both policies are applied at Firefox startup.
New-Item -Path "HKLM:\SOFTWARE\Policies\Mozilla\Firefox\Certificates\Install" -Force | Out-Null
Set-ItemProperty -Path "HKLM:\SOFTWARE\Policies\Mozilla\Firefox\Certificates\Install" -Name "1" -Value $caCert -Type String
Write-Host "[ok] BNS Local Root CA trusted (machine store + Firefox enterprise-roots & install policies)"
# Firefox only loads the enterprise-roots policy + new root at startup, so a
# running Firefox keeps rejecting .bch until it is restarted.
$ff = Get-Process firefox -ErrorAction SilentlyContinue
if ($ff) {
if ($CloseFirefox) {
$ff | Stop-Process -Force
Write-Host "[ok] Firefox was closed -- reopen it and .bch certificates will be trusted"
} else {
Write-Host "[!] Firefox is open -- fully QUIT and reopen it so it trusts .bch (or re-run with -CloseFirefox)." -ForegroundColor Yellow
}
}
} else {
Write-Host "[skip] no ca\root-ca.crt found -- https for .bch not enabled"
}
# 3b) Firefox DNS-over-HTTPS. BCNR-only names (all .bch, etc.) already work in
# Firefox with DoH ON -- DoH returns NXDOMAIN and Firefox falls back to the
# system resolver (us). Only names that ALSO exist in ICANN (a reclaimed .de)
# lose to the real record. Turning DoH off fixes those but sends Firefox's
# other DNS through the system resolver instead of encrypted to Cloudflare --
# so it's opt-in (-FirefoxNoDoH) and left unlocked so the user can re-enable.
if ($FirefoxNoDoH) {
New-Item -Path "HKLM:\SOFTWARE\Policies\Mozilla\Firefox\DNSOverHTTPS" -Force | Out-Null
Set-ItemProperty -Path "HKLM:\SOFTWARE\Policies\Mozilla\Firefox\DNSOverHTTPS" -Name "Enabled" -Value 0 -Type DWord
Write-Host "[ok] Firefox DNS-over-HTTPS disabled (opt-in) -- ICANN-conflicting names now resolve via BCNR in Firefox too"
} else {
Write-Host "[info] Firefox DoH left on (BCNR-only names work anyway). To also reclaim names that exist in ICANN, re-run with -FirefoxNoDoH"
}
$state | ConvertTo-Json -Depth 5 | Set-Content $stateFile
Clear-DnsClientCache
Write-Host "[done] try: https://hello.bch (uninstall: .\setup\uninstall.ps1)"
try { Stop-Transcript | Out-Null } catch {}