diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ea9f3c..062581c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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_.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 diff --git a/docs/functions.md b/docs/functions.md index a552222..b2d58c2 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -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; @@ -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 diff --git a/docs/keysmith.md b/docs/keysmith.md index aafda90..32a8cdd 100644 --- a/docs/keysmith.md +++ b/docs/keysmith.md @@ -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. diff --git a/src/IDBridge/IDBridge.psd1 b/src/IDBridge/IDBridge.psd1 index 8867b5c..0436570 100644 --- a/src/IDBridge/IDBridge.psd1 +++ b/src/IDBridge/IDBridge.psd1 @@ -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' diff --git a/src/IDBridge/Private/AD/Get-ADUsersToResetPassword.ps1 b/src/IDBridge/Private/AD/Get-ADUsersToResetPassword.ps1 index d4ff387..a1057a8 100644 --- a/src/IDBridge/Private/AD/Get-ADUsersToResetPassword.ps1 +++ b/src/IDBridge/Private/AD/Get-ADUsersToResetPassword.ps1 @@ -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. @@ -36,7 +40,9 @@ function Get-ADUsersToResetPassword { $UserList, [Parameter(Mandatory = $true)] - [hashtable]$PassphraseAPI + [hashtable]$PassphraseAPI, + + [switch]$ExportOnly ) $UserList = @($UserList) @@ -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 diff --git a/src/IDBridge/Public/Core/Reset-IDBridgeADPassword.ps1 b/src/IDBridge/Public/Core/Reset-IDBridgeADPassword.ps1 index 55b1507..5552123 100644 --- a/src/IDBridge/Public/Core/Reset-IDBridgeADPassword.ps1 +++ b/src/IDBridge/Public/Core/Reset-IDBridgeADPassword.ps1 @@ -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 @@ -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 @@ -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) @@ -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." } @@ -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 @@ -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) } @@ -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 { @@ -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 diff --git a/tests/Private/AD/Get-ADUsersToResetPassword.Tests.ps1 b/tests/Private/AD/Get-ADUsersToResetPassword.Tests.ps1 index 0bc83c1..de6e69f 100644 --- a/tests/Private/AD/Get-ADUsersToResetPassword.Tests.ps1 +++ b/tests/Private/AD/Get-ADUsersToResetPassword.Tests.ps1 @@ -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)