Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,20 @@ All notable changes to IDBridge are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions use
a calendar scheme `YY.M.D.build` (see [CONTRIBUTING.md](CONTRIBUTING.md#versioning--releases)).

## [Unreleased]
## [26.9.1.1] - 2026-09-01

### Added
- **Export-only mode in `Reset-IDBridgeADPassword`.** A new "Export Passphrases" button
regenerates the loaded users' deterministic Keysmith passphrases and writes a
username/passphrase/OU CSV (`ADPasswordExport_<timestamp>.csv`) to `Exports` —
**without touching AD** — e.g. for printing login slips. Confirmed with the exact
count before generating; matches the accounts' current passwords only when the nonce,
mode, word count, and word-list rev match what the passwords were last set with (pin
the rev for accounts from an earlier era).

### Changed
- The reset run's optional handout CSV now includes each user's `OrgUnit` column, the
same format as the export-only CSV.
- **Docs:** `docs/secrets.md` secret-names note now mentions that
`Reset-IDBridgeADPassword` reads `ApiKey-Passphrase` and the two nonce secrets as its
defaults, so sites using the bulk reset GUI want them in the vault even when no
Expand Down
8 changes: 6 additions & 2 deletions docs/functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,10 @@ blank rev = server's latest), and run options (include sub-OUs, enabled accounts
ChangePasswordAtLogon, passphrase-CSV export — off by default). "Load Users" lists the matching
accounts for review; "Reset Passwords" confirms with the exact count, fetches every passphrase
up front (`Get-ADUsersToResetPassword` — any Keysmith failure aborts before a single write),
then applies `Set-ADAccountPassword -Reset` per account. Writes to AD when confirmed
then applies `Set-ADAccountPassword -Reset` per account. "Export Passphrases" instead writes
the loaded users' regenerated passphrases as a username/passphrase/OU CSV to `Exports`
**without touching AD** (matches current passwords only when nonce/mode/word count/rev match
what they were last set with — pin the rev for older accounts). Writes to AD when confirmed
**regardless of `Debug.readOnly`** — interactive, gated by its own confirmation dialog.
Passphrases are never logged; the optional CSV lands in `Exports`. Requires Windows, an STA
session, and the ActiveDirectory module. **Returns:** `@{ Total; Succeeded; Failed;
Expand Down Expand Up @@ -514,7 +517,8 @@ applied. **Returns:** `@{ UpdateList; RenameList; MoveList }` (items carry `CN`
### `Get-ADUsersToResetPassword` 🔒 🧮🌐
**Params:** `-UserList` (AD user objects: SamAccountName + DistinguishedName), `-PassphraseAPI`
(hashtable — Nonce/AuthToken SecureStrings, Mode, WordCount, optional Rev; the `ADPassphraseAPI`
shape, forwarded to `New-Passphrase`). Decide phase for `Reset-IDBridgeADPassword`: fetches a
shape, forwarded to `New-Passphrase`), `-ExportOnly` (logs the proposed lines as exports, not
resets; output identical). Decide phase for `Reset-IDBridgeADPassword`: fetches a
deterministic passphrase per SamAccountName (one batched call per 500 users, the Keysmith
per-request max; phrases pair with users by position) and builds a `Set-ADAccountPassword -Reset`
splat each. An API failure or returned-count mismatch throws — no partial reset list. The
Expand Down
7 changes: 4 additions & 3 deletions docs/keysmith.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,10 @@ Each district gets its own Keysmith organization, API token, and nonce:
- **Auth header.** The token is sent as `x-api-key` — the Static Web App proxy
overwrites `Authorization` before it reaches the API. `New-Passphrase`
handles this; don't hand-roll calls with Bearer headers.
- **Bulk resets.** `Reset-IDBridgeADPassword` (GUI, run by hand) resets every AD
account in the checked OUs back to its deterministic passphrase using these same
vault secrets — see [functions.md](functions.md#reset-idbridgeadpassword).
- **Bulk resets and handout exports.** `Reset-IDBridgeADPassword` (GUI, run by hand)
resets every AD account in the checked OUs back to its deterministic passphrase using
these same vault secrets, or just exports the regenerated passphrases to a CSV without
touching AD — see [functions.md](functions.md#reset-idbridgeadpassword).
- **Annual renewal.** Keysmith users and tokens expire each August 1 by default.
Token renewal/regeneration is done by SC Networks; a regenerated token must be
re-provisioned into the school's vault.
Expand Down
2 changes: 1 addition & 1 deletion src/IDBridge/IDBridge.psd1
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
@{
RootModule = 'IDBridge.psm1'
ModuleVersion = '26.9.1.0'
ModuleVersion = '26.9.1.1'
GUID = 'a0a0c664-888e-44e2-9c12-fc8647a520c0'
Author = 'Sam Cattanach'
CompanyName = 'SC Networks LLC'
Expand Down
12 changes: 10 additions & 2 deletions src/IDBridge/Private/AD/Get-ADUsersToResetPassword.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ The AD user objects to reset. Each needs SamAccountName and DistinguishedName.
Hashtable with Nonce (SecureString), AuthToken (SecureString), Mode, WordCount, and optional
Rev — the same shape as a source record's ADPassphraseAPI block, forwarded to New-Passphrase.

.PARAMETER ExportOnly
The caller is only exporting the passphrases, not resetting — the per-user proposed line logs
"Export passphrase" instead of "Reset password". Output is identical either way.

.OUTPUTS
[object[]] of @{ SamAccountName; DistinguishedName; Passphrase; Splat } where Splat is the
Set-ADAccountPassword parameter hashtable.
Expand All @@ -36,7 +40,9 @@ function Get-ADUsersToResetPassword {
$UserList,

[Parameter(Mandatory = $true)]
[hashtable]$PassphraseAPI
[hashtable]$PassphraseAPI,

[switch]$ExportOnly
)

$UserList = @($UserList)
Expand Down Expand Up @@ -71,10 +77,12 @@ function Get-ADUsersToResetPassword {

$itemList = @()

$proposedAction = if ($ExportOnly) { "Export passphrase" } else { "Reset password" }

for ($i = 0; $i -lt $UserList.Count; $i++) {
$item = $UserList[$i]

Write-Log -Message ("AD: Proposed: Reset password for $($item.SamAccountName) ($($item.DistinguishedName)).")
Write-Log -Message ("AD: Proposed: $proposedAction for $($item.SamAccountName) ($($item.DistinguishedName)).")

$itemList += [PSCustomObject]@{
SamAccountName = $item.SamAccountName
Expand Down
95 changes: 81 additions & 14 deletions src/IDBridge/Public/Core/Reset-IDBridgeADPassword.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@ account, optionally also setting ChangePasswordAtLogon.

Because Keysmith passphrases are deterministic, the phrases never need to be stored — anyone
with the nonce can regenerate them at keysmith.scnlabs.net. Optionally the run can export a
username/passphrase CSV to the Exports folder for handout (off by default). Passphrases are
never written to the log.
username/passphrase/OU CSV to the Exports folder for handout (off by default). "Export
Passphrases" instead writes that same CSV for the loaded users WITHOUT touching AD —
regenerating what the current passwords already are, e.g. for login slips. The exported
phrases match the accounts' current passwords only when the nonce, mode, word count, and
word-list rev match what the passwords were last set with — pin the rev for accounts from an
earlier era. Passphrases are never written to the log.

This tool writes to AD when confirmed regardless of Debug.readOnly — it is interactive and gated
by its own confirmation dialog instead (the pipeline's ReadOnly/ChangeThreshold gates protect
Expand All @@ -28,7 +32,7 @@ Base dir for Config/Logs/Exports/Plugins/Data/Vault. Defaults to C:\IDBridge.

.OUTPUTS
[pscustomobject] @{ Total; Succeeded; Failed; ExportPath } for the last reset run (zeroes when
the window is closed without running one).
the window is closed without running one; an export-only run fills ExportPath only).

.EXAMPLE
Reset-IDBridgeADPassword
Expand Down Expand Up @@ -322,6 +326,13 @@ function Reset-IDBridgeADPassword {
$btnReset.Enabled = $false
$form.Controls.Add($btnReset)

$btnExport = [System.Windows.Forms.Button]::new()
$btnExport.Text = "Export Passphrases"
$btnExport.Location = [System.Drawing.Point]::new(560, 600)
$btnExport.Size = [System.Drawing.Size]::new(160, 32)
$btnExport.Enabled = $false
$form.Controls.Add($btnExport)

$btnClose = [System.Windows.Forms.Button]::new()
$btnClose.Text = "Close"
$btnClose.Location = [System.Drawing.Point]::new(870, 600)
Expand Down Expand Up @@ -394,14 +405,17 @@ function Reset-IDBridgeADPassword {
Write-Log -Message "Reset: Loaded $($state.Users.Count) user(s) from $($checkedDNs.Count) org unit(s) for review."
$lblCount.Text = "$($state.Users.Count) user(s) loaded from $($checkedDNs.Count) org unit(s)."
$btnReset.Enabled = ($state.Users.Count -gt 0)
$btnExport.Enabled = ($state.Users.Count -gt 0)
})
#endregion Load Users Handler



#region Reset Passwords Handler
$btnReset.add_Click({
#Resolve the nonce and token - from the vault by secret name, or the masked manual entry
#region Resolve Keysmith Settings
#Shared by the reset and export handlers: nonce/token from the vault by secret name (or
#the masked manual entry) plus the optional rev. Returns the PassphraseAPI hashtable, or
#$null after showing the error - the caller just returns.
$getKeysmithApi = {
try {
if ($cboNonce.Text -eq $ManualEntry) {
if ([string]::IsNullOrWhiteSpace($txtNonce.Text)) { Throw "Enter the nonce, or pick a vault secret name." }
Expand All @@ -419,22 +433,17 @@ function Reset-IDBridgeADPassword {
}
catch {
[System.Windows.Forms.MessageBox]::Show($form, "Keysmith settings error: $($_.Exception.Message)", "IDBridge", 'OK', 'Error') | Out-Null
return
return $null
}

$rev = 0
if (-not [string]::IsNullOrWhiteSpace($txtRev.Text)) {
if (-not [int]::TryParse($txtRev.Text, [ref]$rev) -or $rev -lt 1 -or $rev -gt 99) {
[System.Windows.Forms.MessageBox]::Show($form, "Rev must be a number from 1 to 99, or blank for the server's latest.", "IDBridge", 'OK', 'Error') | Out-Null
return
return $null
}
}

$confirmText = "$($state.Users.Count) AD account password(s) will be RESET to their Keysmith passphrase.`n`nThis cannot be undone. Continue?"
$answer = [System.Windows.Forms.MessageBox]::Show($form, $confirmText, "IDBridge - Confirm Password Reset", 'YesNo', 'Warning', 'Button2')
if ($answer -ne [System.Windows.Forms.DialogResult]::Yes) { return }

#Decide: fetch every passphrase up front - any Keysmith failure aborts before a single write
$passphraseAPI = @{
Nonce = $nonce
AuthToken = $token
Expand All @@ -443,6 +452,22 @@ function Reset-IDBridgeADPassword {
}
if ($rev -gt 0) { $passphraseAPI.Rev = $rev }

return $passphraseAPI
}
#endregion Resolve Keysmith Settings



#region Reset Passwords Handler
$btnReset.add_Click({
$passphraseAPI = & $getKeysmithApi
if (-not $passphraseAPI) { return }

$confirmText = "$($state.Users.Count) AD account password(s) will be RESET to their Keysmith passphrase.`n`nThis cannot be undone. Continue?"
$answer = [System.Windows.Forms.MessageBox]::Show($form, $confirmText, "IDBridge - Confirm Password Reset", 'YesNo', 'Warning', 'Button2')
if ($answer -ne [System.Windows.Forms.DialogResult]::Yes) { return }

#Decide: fetch every passphrase up front - any Keysmith failure aborts before a single write
try {
$resets = @(Get-ADUsersToResetPassword -UserList $state.Users -PassphraseAPI $passphraseAPI)
}
Expand Down Expand Up @@ -495,7 +520,7 @@ function Reset-IDBridgeADPassword {
if ($chkExport.Checked -and $succeededItems.Count -gt 0) {
try {
$state.ExportPath = Join-Path $IDConfig.Paths.ExportsRoot ("ADPasswordReset_" + (Get-Date -Format "yyyy-MM-dd-HH.mm.ss") + ".csv")
$succeededItems | Select-Object SamAccountName, Passphrase | Export-Csv -Path $state.ExportPath -NoTypeInformation
$succeededItems | Select-Object SamAccountName, Passphrase, @{n = 'OrgUnit'; e = { $_.DistinguishedName -replace '^CN=(?:\\.|[^,\\])*,', '' }} | Export-Csv -Path $state.ExportPath -NoTypeInformation
Write-Log -Message "Reset: Exported $($succeededItems.Count) passphrase(s) to $($state.ExportPath). Delete the file after handout."
}
catch {
Expand All @@ -517,6 +542,48 @@ function Reset-IDBridgeADPassword {



#region Export Passphrases Handler
#Export-only: regenerate the loaded users' deterministic passphrases and write them to a
#CSV with each user's OU - NOTHING is written to AD. Matches the accounts' current
#passwords only when nonce/mode/word count/rev match what they were last set with.
$btnExport.add_Click({
$passphraseAPI = & $getKeysmithApi
if (-not $passphraseAPI) { return }

$confirmText = "$($state.Users.Count) passphrase(s) will be generated and written in PLAIN TEXT to the Exports folder.`n`nNo passwords are changed. Continue?"
$answer = [System.Windows.Forms.MessageBox]::Show($form, $confirmText, "IDBridge - Confirm Passphrase Export", 'YesNo', 'Warning', 'Button2')
if ($answer -ne [System.Windows.Forms.DialogResult]::Yes) { return }

try {
$resets = @(Get-ADUsersToResetPassword -UserList $state.Users -PassphraseAPI $passphraseAPI -ExportOnly)
}
catch {
[System.Windows.Forms.MessageBox]::Show($form, "Keysmith request failed - nothing was exported.`n`n$($_.Exception.Message)", "IDBridge", 'OK', 'Error') | Out-Null
return
}

try {
$state.ExportPath = Join-Path $IDConfig.Paths.ExportsRoot ("ADPasswordExport_" + (Get-Date -Format "yyyy-MM-dd-HH.mm.ss") + ".csv")
$resets | Select-Object SamAccountName, Passphrase, @{n = 'OrgUnit'; e = { $_.DistinguishedName -replace '^CN=(?:\\.|[^,\\])*,', '' }} | Export-Csv -Path $state.ExportPath -NoTypeInformation
}
catch {
Write-Log -Message ("Reset: Passphrase export failed: $($_.Exception.Message)") -Level Error
[System.Windows.Forms.MessageBox]::Show($form, "Passphrase export failed: $($_.Exception.Message)", "IDBridge", 'OK', 'Error') | Out-Null
return
}

foreach ($resetItem in $resets) {
$state.ItemByDN[$resetItem.DistinguishedName].SubItems[3].Text = "Exported"
}

Write-Log -Message "Reset: Exported $($resets.Count) passphrase(s) to $($state.ExportPath) without resetting. Delete the file after handout."

[System.Windows.Forms.MessageBox]::Show($form, "Exported $($resets.Count) passphrase(s) - no passwords were changed.`n`n$($state.ExportPath)", "IDBridge", 'OK', 'Information') | Out-Null
})
#endregion Export Passphrases Handler



#region Show
Write-Log -Message "Reset: Opening the AD password reset window."
$form.ShowDialog() | Out-Null
Expand Down
15 changes: 15 additions & 0 deletions tests/Private/AD/Get-ADUsersToResetPassword.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,21 @@ Describe 'Get-ADUsersToResetPassword' {
}
}

It 'logs the proposed line as an export, not a reset, when -ExportOnly is set' {
$users = @(New-TestADUser)

InModuleScope IDBridge -Parameters @{ users = $users; api = $TestAPI } {
Mock Write-Log {}
Mock New-Passphrase { @($Username | ForEach-Object { 'p' }) }

$result = @(Get-ADUsersToResetPassword -UserList $users -PassphraseAPI $api -ExportOnly)

$result[0].Passphrase | Should -Be 'p'
Should -Invoke Write-Log -Times 1 -Exactly -ParameterFilter { $Message -like '*Proposed: Export passphrase*' }
Should -Invoke Write-Log -Times 0 -Exactly -ParameterFilter { $Message -like '*Proposed: Reset password*' }
}
}

It 'throws without building any splat when the passphrase API fails' {
$users = @(New-TestADUser)

Expand Down
Loading