From 2c67fdf902c3d63333060ab3e1c749dee97e0d3b Mon Sep 17 00:00:00 2001 From: sgaddala-ks Date: Wed, 15 Apr 2026 16:26:22 +0530 Subject: [PATCH 1/3] Powercommander sqlite storage and Skip sync functionality support for PowerCommander (#422) --- .github/workflows/biometrics.yml | 4 +- .github/workflows/build.yml | 118 ++- .github/workflows/power-commander.yml | 185 ++++- .gitignore | 1 + KeeperSdk.sln | 1 + KeeperSdk/vault/RecordSkipSyncDown.cs | 51 ++ KeeperSdk/vault/SharedFolderSkipSyncDown.cs | 4 +- KeeperSdk/vault/VaultTypes.cs | 2 +- PowerCommander/AuthCommands.ps1 | 173 ++++- PowerCommander/KeeperBiometrics.ps1 | 4 +- PowerCommander/PowerCommander.psd1 | 20 +- PowerCommander/PowerCommander.psm1 | 6 +- PowerCommander/README.md | 16 + PowerCommander/SkipSyncCommands.ps1 | 798 ++++++++++++++++++++ 14 files changed, 1266 insertions(+), 117 deletions(-) create mode 100644 PowerCommander/SkipSyncCommands.ps1 diff --git a/.github/workflows/biometrics.yml b/.github/workflows/biometrics.yml index 517b354b..b422c09d 100644 --- a/.github/workflows/biometrics.yml +++ b/.github/workflows/biometrics.yml @@ -44,12 +44,12 @@ jobs: run: | dotnet build --configuration=Release $cert = Get-ChildItem -Path Cert:\CurrentUser\My\${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }} -CodeSigningCert - Set-AuthenticodeSignature -FilePath bin\Release\net472\KeeperBiometric.dll -Certificate $cert -HashAlgorithm SHA256 -TimestampServer "http://timestamp.digicert.com" + Set-AuthenticodeSignature -FilePath bin\Release\net472\KeeperBiometrics.dll -Certificate $cert -HashAlgorithm SHA256 -TimestampServer "http://timestamp.digicert.com" shell: powershell - name: Store Commander artifacts uses: actions/upload-artifact@v4 with: name: KeeperBiometrics - path: KeeperBiometrics/bin/Release/net472/KeeperBiometric.dll + path: KeeperBiometrics/bin/Release/net472/KeeperBiometrics.dll retention-days: 1 \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6f50033e..5a9c3285 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,8 +1,13 @@ name: Build Keeper SDK for .NET -on: +on: workflow_dispatch: inputs: + publish_to_nuget: + description: Push Keeper.Sdk package to NuGet.org + type: boolean + required: false + default: false cli: description: Build CLI package type: boolean @@ -13,19 +18,22 @@ on: type: boolean required: false default: false - + jobs: - build: + configure: runs-on: windows-latest outputs: - package-version: ${{ steps.vars.outputs.package_version }} - + sdk_version: ${{ steps.vars.outputs.sdk_version }} + package_version: ${{ steps.vars.outputs.package_version }} + build_version: ${{ steps.vars.outputs.build_version }} steps: + - uses: actions/checkout@v4 + - name: Setup product versions id: vars run: | $ErrorView = 'NormalView' - $branch = ($Env:GITHUB_REF -split '/')[2] + $branch = ($Env:GITHUB_REF -split '/')[2] $comp = $branch -split '_' $sdkVersion = $comp[1] $packageVersion = $sdkVersion @@ -33,18 +41,35 @@ jobs: $packageVersion = $packageVersion + '-' + $comp[2] } - $buildVersion = $sdkVersion + '.' + $Env:GITHUB_RUN_NUMBER + if ([string]::IsNullOrWhiteSpace($sdkVersion)) { + $raw = Get-Content "KeeperSdk/KeeperSdk.csproj" -Raw + if ($raw -notmatch '([^<]+)') { + throw "Could not read from KeeperSdk/KeeperSdk.csproj" + } + $packageVersion = $Matches[1].Trim() + } - echo "SDK_VERSION=${sdkVersion}" >> $Env:GITHUB_ENV - echo "PACKAGE_VERSION=${packageVersion}" >> $Env:GITHUB_ENV - echo "BUILD_VERSION=${buildVersion}" >> $Env:GITHUB_ENV + $assemblyBase = ($packageVersion -split '-')[0].Trim() + $buildVersion = $assemblyBase + '.' + $Env:GITHUB_RUN_NUMBER + + echo "sdk_version=${assemblyBase}" >> $Env:GITHUB_OUTPUT echo "package_version=${packageVersion}" >> $Env:GITHUB_OUTPUT + echo "build_version=${buildVersion}" >> $Env:GITHUB_OUTPUT shell: powershell + keeper_sdk: + runs-on: windows-latest + needs: configure + env: + SDK_VERSION: ${{ needs.configure.outputs.sdk_version }} + PACKAGE_VERSION: ${{ needs.configure.outputs.package_version }} + BUILD_VERSION: ${{ needs.configure.outputs.build_version }} + + steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.0.x' + dotnet-version: '8.0.x' - name: Setup Code Sign Cert shell: bash @@ -53,7 +78,6 @@ jobs: - name: Set variables shell: bash - id: variables run: | echo "SM_HOST=${{ secrets.SM_HOST }}" >> "$GITHUB_ENV" echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> "$GITHUB_ENV" @@ -76,8 +100,7 @@ jobs: smctl windows certsync --keypair-alias=%KEYPAIR_ALIAS% - name: Restore solution - run: | - dotnet restore KeeperSdk.sln + run: dotnet restore KeeperSdk.sln shell: powershell - name: Build Keeper SDK Nuget package @@ -88,15 +111,12 @@ jobs: & 'C:/Program Files (x86)/Windows Kits/10/bin/10.0.26100.0/x64/signtool.exe' sign /debug /v /sha1 ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }} /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 /d ".NET Keeper SDK" "bin\Release\netstandard2.0\KeeperSdk.dll" & 'C:/Program Files (x86)/Windows Kits/10/bin/10.0.26100.0/x64/signtool.exe' sign /debug /v /sha1 ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }} /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 /d ".NET Keeper SDK" "bin\Release\net8.0\KeeperSdk.dll" dotnet pack --no-build --no-restore --no-dependencies /P:Configuration=Release /P:Version=${Env:PACKAGE_VERSION} /P:IncludeSymbols=true /P:SymbolPackageFormat=snupkg - # $cert = Get-Item "Cert:\CurrentUser\My\${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}" - # $sha256 = $cert.GetCertHashString("SHA256") - # dotnet nuget sign --certificate-fingerprint $sha256 --timestamper http://timestamp.digicert.com "bin/Release/Keeper.Sdk.${{ env.PACKAGE_VERSION }}.nupkg" - shell: powershell + shell: powershell - name: Store SDK Nuget artifacts uses: actions/upload-artifact@v4 with: - name: KeeperSdk-Nuget-Package + name: KeeperSdk-Nuget-${{ env.PACKAGE_VERSION }} path: | KeeperSdk/bin/Release/Keeper.Sdk.${{ env.PACKAGE_VERSION }}.nupkg KeeperSdk/bin/Release/Keeper.Sdk.${{ env.PACKAGE_VERSION }}.snupkg @@ -121,51 +141,6 @@ jobs: dotnet pack --no-build --no-restore --no-dependencies --configuration=Release /P:IncludeSymbols=true /P:SymbolPackageFormat=snupkg shell: powershell - - name: Build .Net Commander - working-directory: ./Commander - run: | - if (Test-Path bin) { Remove-Item -Force -Recurse bin } - dotnet build --configuration=Release - & 'C:/Program Files (x86)/Windows Kits/10/bin/10.0.26100.0/x64/signtool.exe' sign /debug /v /sha1 ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }} /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 /d ".NET Keeper SDK" "bin\Release\net472\Commander.exe" - & 'C:/Program Files (x86)/Windows Kits/10/bin/10.0.26100.0/x64/signtool.exe' sign /debug /v /sha1 ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }} /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 /d ".NET Keeper SDK" "bin\Release\net8.0\Commander.dll" - shell: powershell - - - name: Zip .Net Framework Commander - working-directory: "./Commander/bin/Release/net472" - run: | - $params = @{ - Path = "*.exe", "*.dll", "Commander.exe.config" - CompressionLevel = "Fastest" - DestinationPath = "Commander-win-${Env:PACKAGE_VERSION}.zip" - } - Compress-Archive @params - shell: powershell - - - name: Zip .Net 8.0 Commander - working-directory: "./Commander/bin/Release/net8.0" - run: | - $params = @{ - Path = "*.dll", "Commander.dll.config", "Commander.deps.json", "runtimes/", "Commander.runtimeconfig.json" - CompressionLevel = "Fastest" - DestinationPath = "Commander-net-${Env:PACKAGE_VERSION}.zip" - } - Compress-Archive @params - shell: powershell - - - name: Store Commander artifacts - uses: actions/upload-artifact@v4 - with: - name: Commander-win-${{ env.PACKAGE_VERSION }} - path: Commander/bin/Release/net472/Commander-win-${{ env.PACKAGE_VERSION }}.zip - retention-days: 1 - - - name: Store Commander artifacts - uses: actions/upload-artifact@v4 - with: - name: Commander-net-${{ env.PACKAGE_VERSION }} - path: Commander/bin/Release/net8.0/Commander-net-${{ env.PACKAGE_VERSION }}.zip - retention-days: 1 - - name: Store Cli artifacts if: ${{ inputs.cli }} uses: actions/upload-artifact@v4 @@ -187,21 +162,22 @@ jobs: retention-days: 1 publish: + if: ${{ inputs.publish_to_nuget }} runs-on: windows-latest - needs: build + needs: [configure, keeper_sdk] environment: prod env: - PACKAGE_VERSION: ${{ needs.build.outputs.package-version }} - + PACKAGE_VERSION: ${{ needs.configure.outputs.package_version }} + steps: - name: Download Nuget package uses: actions/download-artifact@v4 with: - name: KeeperSdk-Nuget-Package + name: KeeperSdk-Nuget-${{ needs.configure.outputs.package_version }} path: nuget - + - name: Publish to Nuget repo working-directory: nuget run: | - dotnet nuget push Keeper.Sdk.${{ env.PACKAGE_VERSION }}.nupkg -k "${{ secrets.NUGET_PUBLISH_KEY }}" -s https://api.nuget.org/v3/index.json - shell: powershell \ No newline at end of file + dotnet nuget push Keeper.Sdk.${{ env.PACKAGE_VERSION }}.nupkg -k "${{ secrets.NUGET_PUBLISH_KEY }}" -s https://api.nuget.org/v3/index.json + shell: powershell diff --git a/.github/workflows/power-commander.yml b/.github/workflows/power-commander.yml index 32242caf..e1206ec5 100644 --- a/.github/workflows/power-commander.yml +++ b/.github/workflows/power-commander.yml @@ -1,32 +1,60 @@ -name: Publish PowerCommander +name: PowerCommander -on: [workflow_dispatch] +on: + workflow_dispatch: + inputs: + publish_to_gallery: + description: Publish signed module to PowerShell Gallery + type: boolean + required: false + default: false jobs: - build: + configure: runs-on: windows-latest - environment: prod + outputs: + package_version: ${{ steps.vars.outputs.package_version }} + version: ${{ steps.vars.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - name: Read version from PowerCommander.psd1 + id: vars + run: | + $m = Import-PowerShellDataFile -Path "PowerCommander/PowerCommander.psd1" + $v = [string]$m.ModuleVersion + echo "package_version=$v" >> $Env:GITHUB_OUTPUT + echo "version=$v" >> $Env:GITHUB_OUTPUT + shell: powershell + + build_module: + runs-on: windows-latest + needs: configure + env: + PACKAGE_VERSION: ${{ needs.configure.outputs.package_version }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' - - name: Set up certificate + - name: Setup Code Sign Cert shell: bash run: | echo "${{ secrets.SM_CLIENT_CERT_FILE_B64 }}" | base64 --decode > /d/Certificate_pkcs12.p12 - name: Set variables shell: bash - id: variables run: | - echo "::set-output name=version::${GITHUB_REF#refs/tags/v}" - echo "SM_HOST=${{ secrets.SM_HOST }}" >> "$GITHUB_ENV" - echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> "$GITHUB_ENV" - echo "SM_CLIENT_CERT_FILE=D:/Certificate_pkcs12.p12" >> "$GITHUB_ENV" - echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> "$GITHUB_ENV" - echo "C:\Program Files (x86)\Windows Kits\10\App Certification Kit" >> $GITHUB_PATH - echo "C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools" >> $GITHUB_PATH - echo "C:\Program Files\DigiCert\DigiCert One Signing Manager Tools" >> $GITHUB_PATH + echo "SM_HOST=${{ secrets.SM_HOST }}" >> "$GITHUB_ENV" + echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> "$GITHUB_ENV" + echo "SM_CLIENT_CERT_FILE=D:/Certificate_pkcs12.p12" >> "$GITHUB_ENV" + echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> "$GITHUB_ENV" + echo "SM_CODE_SIGNING_CERT_SHA1_HASH=${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}" >> "$GITHUB_ENV" + echo "C:\Program Files (x86)\Windows Kits\10\App Certification Kit" >> $GITHUB_PATH + echo "C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools" >> $GITHUB_PATH + echo "C:\Program Files\DigiCert\DigiCert One Signing Manager Tools" >> $GITHUB_PATH - name: Setup DigiCert SSM Tools uses: digicert/ssm-code-signing@b300bb7e8c2ab85257d660fe5b6c6374131ca2ef @@ -39,17 +67,140 @@ jobs: smctl healthcheck smctl windows certsync --keypair-alias=%KEYPAIR_ALIAS% + - name: Restore solution + run: dotnet restore KeeperSdk.sln + shell: powershell + + - name: Build .Net Commander + working-directory: ./Commander + run: | + if (Test-Path bin) { Remove-Item -Force -Recurse bin } + dotnet build --configuration=Release + & 'C:/Program Files (x86)/Windows Kits/10/bin/10.0.26100.0/x64/signtool.exe' sign /debug /v /sha1 ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }} /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 /d ".NET Keeper SDK" "bin\Release\net472\Commander.exe" + & 'C:/Program Files (x86)/Windows Kits/10/bin/10.0.26100.0/x64/signtool.exe' sign /debug /v /sha1 ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }} /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 /d ".NET Keeper SDK" "bin\Release\net8.0\Commander.dll" + shell: powershell + + - name: Stage PowerCommander runtime from Commander net8.0 + shell: powershell + run: | + $out = Resolve-Path "Commander/bin/Release/net8.0" + $stage = Join-Path $PWD "artifact/PowerCommander-runtime" + $su = Join-Path $stage "StorageUtils" + New-Item -ItemType Directory -Path $su -Force | Out-Null + Copy-Item (Join-Path $out "KeeperSdk.dll") $stage -Force + foreach ($f in @('Microsoft.Data.Sqlite.dll', 'SQLitePCLRaw.batteries_v2.dll', 'SQLitePCLRaw.core.dll', 'SQLitePCLRaw.provider.e_sqlite3.dll')) { + $src = Join-Path $out $f + if (-not (Test-Path -LiteralPath $src)) { throw "Missing $f under $out" } + Copy-Item -LiteralPath $src $su -Force + } + $native = @( + (Join-Path $out 'e_sqlite3.dll'), + (Join-Path $out 'runtimes/win-x64/native/e_sqlite3.dll') + ) | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 + if (-not $native) { throw "e_sqlite3.dll not found under Commander net8.0 output: $out" } + Copy-Item -LiteralPath $native (Join-Path $su 'e_sqlite3.dll') -Force + + - name: Store PowerCommander runtime artifact + uses: actions/upload-artifact@v4 + with: + name: PowerCommander-runtime-from-Commander + path: artifact/PowerCommander-runtime/ + retention-days: 1 + + - name: Zip .Net Framework Commander + working-directory: "./Commander/bin/Release/net472" + run: | + $params = @{ + Path = "*.exe", "*.dll", "Commander.exe.config" + CompressionLevel = "Fastest" + DestinationPath = "Commander-win-${Env:PACKAGE_VERSION}.zip" + } + Compress-Archive @params + shell: powershell + + - name: Zip .Net 8.0 Commander + working-directory: "./Commander/bin/Release/net8.0" + run: | + $params = @{ + Path = "*.dll", "Commander.dll.config", "Commander.deps.json", "runtimes/", "Commander.runtimeconfig.json" + CompressionLevel = "Fastest" + DestinationPath = "Commander-net-${Env:PACKAGE_VERSION}.zip" + } + Compress-Archive @params + shell: powershell + + - name: Store Commander artifacts + uses: actions/upload-artifact@v4 + with: + name: Commander-win-${{ env.PACKAGE_VERSION }} + path: Commander/bin/Release/net472/Commander-win-${{ env.PACKAGE_VERSION }}.zip + retention-days: 1 + + - name: Store Commander artifacts + uses: actions/upload-artifact@v4 + with: + name: Commander-net-${{ env.PACKAGE_VERSION }} + path: Commander/bin/Release/net8.0/Commander-net-${{ env.PACKAGE_VERSION }}.zip + retention-days: 1 + + - name: Copy Commander net8.0 outputs into PowerCommander module folder + shell: powershell + run: | + $out = Resolve-Path "Commander/bin/Release/net8.0" + $pc = Resolve-Path "PowerCommander" + Copy-Item (Join-Path $out "KeeperSdk.dll") $pc -Force + $to = Join-Path $pc "StorageUtils" + if (-not (Test-Path -LiteralPath $to)) { New-Item -ItemType Directory -Path $to -Force | Out-Null } + foreach ($f in @('Microsoft.Data.Sqlite.dll', 'SQLitePCLRaw.batteries_v2.dll', 'SQLitePCLRaw.core.dll', 'SQLitePCLRaw.provider.e_sqlite3.dll')) { + $src = Join-Path $out $f + Copy-Item -LiteralPath $src $to -Force + } + $native = @( + (Join-Path $out 'e_sqlite3.dll'), + (Join-Path $out 'runtimes/win-x64/native/e_sqlite3.dll') + ) | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 + Copy-Item -LiteralPath $native (Join-Path $to 'e_sqlite3.dll') -Force + + - name: Sign KeeperSdk.dll + run: | + & 'C:/Program Files (x86)/Windows Kits/10/bin/10.0.26100.0/x64/signtool.exe' sign /debug /v /sha1 ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }} /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 /d ".NET Keeper SDK" "PowerCommander\KeeperSdk.dll" + shell: powershell + - name: Sign PowerShell scripts working-directory: ./PowerCommander run: | $cert = Get-ChildItem -Path Cert:\CurrentUser\My\${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }} -CodeSigningCert Set-AuthenticodeSignature -FilePath *.ps1 -Certificate $cert -HashAlgorithm SHA256 -TimestampServer "http://timestamp.digicert.com" + $root = (Get-Location).Path + Get-ChildItem -Path . -Recurse -Filter *.ps1 -File | Where-Object { $_.DirectoryName -ne $root } | ForEach-Object { + Set-AuthenticodeSignature -FilePath $_.FullName -Certificate $cert -HashAlgorithm SHA256 -TimestampServer "http://timestamp.digicert.com" + } Set-AuthenticodeSignature -FilePath *.ps1xml -Certificate $cert -HashAlgorithm SHA256 -TimestampServer "http://timestamp.digicert.com" Set-AuthenticodeSignature -FilePath PowerCommander.psd1 -Certificate $cert -HashAlgorithm SHA256 -TimestampServer "http://timestamp.digicert.com" Set-AuthenticodeSignature -FilePath PowerCommander.psm1 -Certificate $cert -HashAlgorithm SHA256 -TimestampServer "http://timestamp.digicert.com" - shell: powershell + shell: powershell + + - name: Upload signed PowerCommander module (workflow run → Artifacts) + uses: actions/upload-artifact@v4 + with: + name: PowerCommander-module-${{ needs.configure.outputs.package_version }} + path: PowerCommander/ + retention-days: 1 + + publish_gallery: + if: ${{ inputs.publish_to_gallery }} + runs-on: windows-latest + needs: [configure, build_module] + environment: prod + + steps: + - name: Download signed PowerCommander module + uses: actions/download-artifact@v4 + with: + name: PowerCommander-module-${{ needs.configure.outputs.package_version }} + path: PowerCommander - name: Publish to PowerShell Gallery run: | Publish-Module -Path .\PowerCommander\ -NuGetApiKey "${{ secrets.POWERSHELL_PUBLISH_KEY }}" - shell: powershell + shell: powershell diff --git a/.gitignore b/.gitignore index 909468f6..c88ac1ae 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ project.lock.json *.nuget.props UpgradeLog.htm nuget.config +config.json Help/ .vscode/ diff --git a/KeeperSdk.sln b/KeeperSdk.sln index 2f9c9c64..6afdbc1d 100644 --- a/KeeperSdk.sln +++ b/KeeperSdk.sln @@ -20,6 +20,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution .gitignore = .gitignore .github\workflows\build.yml = .github\workflows\build.yml .github\workflows\power-commander.yml = .github\workflows\power-commander.yml + .github\workflows\power-commander-storage-utils.yml = .github\workflows\power-commander-storage-utils.yml README.md = README.md EndProjectSection EndProject diff --git a/KeeperSdk/vault/RecordSkipSyncDown.cs b/KeeperSdk/vault/RecordSkipSyncDown.cs index a8c6424d..4192409d 100644 --- a/KeeperSdk/vault/RecordSkipSyncDown.cs +++ b/KeeperSdk/vault/RecordSkipSyncDown.cs @@ -60,6 +60,57 @@ public static async Task GetSharedFolderRecordsAsyn return await GetRecordsDetailsAsync(auth, keys.Keys, include, keys).ConfigureAwait(false); } + /// + /// Like , + /// but only requests the given record UIDs. + /// + public static async Task GetSharedFolderRecordsAsync(IAuthentication auth, + string sharedFolderUid, + IEnumerable recordUids, + RecordDetailsInclude include = RecordDetailsInclude.DataPlusShare) + { + if (auth == null || auth.AuthContext == null) + throw new VaultException("An authenticated session is needed."); + if (string.IsNullOrWhiteSpace(sharedFolderUid)) + throw new ArgumentException("Shared folder UID is required.", nameof(sharedFolderUid)); + + var uidList = (recordUids ?? Enumerable.Empty()) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x.Trim()) + .Distinct(StringComparer.Ordinal) + .ToList(); + + if (uidList.Count == 0) + { + return new RecordDetailsSkipSyncResult( + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty()); + } + + var keys = await SharedFolderSkipSyncDown.GetRecordKeysFromSharedFolderAsync(auth, sharedFolderUid.Trim()) + .ConfigureAwait(false); + + var filtered = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var uid in uidList) + { + if (keys.TryGetValue(uid, out var k) && k != null && k.Length > 0) + filtered[uid] = k; + } + + if (filtered.Count == 0) + { + return new RecordDetailsSkipSyncResult( + Array.Empty(), + Array.Empty(), + uidList, + Array.Empty()); + } + + return await GetRecordsDetailsAsync(auth, uidList, include, filtered).ConfigureAwait(false); + } + private static async Task GetRecordsDetailsAsync(IAuthentication auth, IEnumerable recordUids, RecordDetailsInclude include, diff --git a/KeeperSdk/vault/SharedFolderSkipSyncDown.cs b/KeeperSdk/vault/SharedFolderSkipSyncDown.cs index 60e77eca..feec7969 100644 --- a/KeeperSdk/vault/SharedFolderSkipSyncDown.cs +++ b/KeeperSdk/vault/SharedFolderSkipSyncDown.cs @@ -76,7 +76,7 @@ public static async Task GetSharedFolderAsync(IAuthent { new GetSharedFoldersRequestItem { - SharedFolderUid = sharedFolderUid, + SharedFolderUid = sharedFolderUid.Trim(), }, }, Include = new[] { "sfheaders", "sfusers", "sfrecords", "sfteams" }, @@ -873,4 +873,4 @@ public class SharedFolderTeamObject [DataMember(Name = "team_private_key", EmitDefaultValue = false)] public string TeamPrivateKey { get; set; } [DataMember(Name = "team_ec_private_key", EmitDefaultValue = false)] public string TeamEcPrivateKey { get; set; } } -} +} \ No newline at end of file diff --git a/KeeperSdk/vault/VaultTypes.cs b/KeeperSdk/vault/VaultTypes.cs index 0d24ecc0..f646d1a4 100644 --- a/KeeperSdk/vault/VaultTypes.cs +++ b/KeeperSdk/vault/VaultTypes.cs @@ -886,7 +886,7 @@ public RecordDetailsSkipSyncResult( /// /// UIDs from returned rows that could not be decrypted or loaded. /// For , includes unsupported recordKeyType or bad keys. - /// For , includes UIDs missing from the shared-folder key map or ciphertext load failures. + /// For and the subset overload, includes UIDs missing from the shared-folder key map or ciphertext load failures. /// public IReadOnlyList FailedRecordUids { get; } diff --git a/PowerCommander/AuthCommands.ps1 b/PowerCommander/AuthCommands.ps1 index cc4857b0..9ff8777d 100644 --- a/PowerCommander/AuthCommands.ps1 +++ b/PowerCommander/AuthCommands.ps1 @@ -1,5 +1,103 @@ #requires -Version 5.1 +function New-SqliteIdbConnectionFunc { + param([Parameter(Mandatory)][string] $ConnectionString) + $connType = [Microsoft.Data.Sqlite.SqliteConnection] + $dm = [System.Reflection.Emit.DynamicMethod]::new( + 'PcOpenSqliteConnection', + [System.Data.IDbConnection], + [Type[]]@(), + [KeeperSecurity.Vault.SqlKeeperStorage]) + $il = $dm.GetILGenerator() + $il.Emit([System.Reflection.Emit.OpCodes]::Ldstr, $ConnectionString) + $il.Emit([System.Reflection.Emit.OpCodes]::Newobj, $connType.GetConstructor(@([string]))) + $il.Emit([System.Reflection.Emit.OpCodes]::Dup) + $openMi = [System.Data.Common.DbConnection].GetMethod('Open', [Type[]]@()) + $il.Emit([System.Reflection.Emit.OpCodes]::Callvirt, $openMi) + $il.Emit([System.Reflection.Emit.OpCodes]::Ret) + $dm.CreateDelegate([Func[System.Data.IDbConnection]]) +} + +function Get-SqliteVaultStorageFromHelper { + param([Parameter(Mandatory = $true)][string] $ConnectionString, [Parameter(Mandatory = $true)][string] $OwnerUid) + $moduleRoot = $PSScriptRoot + if ($MyInvocation.MyCommand.Module) { $moduleRoot = $MyInvocation.MyCommand.Module.ModuleBase } + + $storageUtilsRoot = Join-Path $moduleRoot 'StorageUtils' + $requiredStorageDlls = @( + 'Microsoft.Data.Sqlite.dll', + 'SQLitePCLRaw.batteries_v2.dll', + 'SQLitePCLRaw.core.dll', + 'SQLitePCLRaw.provider.e_sqlite3.dll' + ) + $missingFiles = [System.Collections.Generic.List[string]]::new() + foreach ($fileName in $requiredStorageDlls) { + $filePath = Join-Path $storageUtilsRoot $fileName + if (-not (Test-Path -LiteralPath $filePath -PathType Leaf)) { + $missingFiles.Add($fileName) + } + } + + $nativeSqlitePath = Join-Path $storageUtilsRoot 'e_sqlite3.dll' + if (-not (Test-Path -LiteralPath $nativeSqlitePath -PathType Leaf)) { + $missingFiles.Add('e_sqlite3.dll') + } + + if ($missingFiles.Count -gt 0) { + $missingList = $missingFiles -join ', ' + throw "Offline storage dependencies were not found in '$storageUtilsRoot'. Missing: $missingList. When using -UseOfflineStorage, copy the SQLite assemblies from a Commander net8.0 build into the 'StorageUtils' folder under the PowerCommander module directory." + } + + if (-not $script:StorageUtilsAssemblyResolveRegistered) { + $script:StorageUtilsAssemblyResolveRegistered = $true + $sur = $storageUtilsRoot + $mr = $moduleRoot + $handler = [System.ResolveEventHandler] { + param($AssemblyResolveSource, $AssemblyResolveEventArgs) + $simpleName = ($AssemblyResolveEventArgs.Name -split ',')[0] + foreach ($root in @($sur, $mr)) { + $candidate = [System.IO.Path]::Combine($root, "$simpleName.dll") + if ([System.IO.File]::Exists($candidate)) { + return [System.Reflection.Assembly]::LoadFrom($candidate) + } + } + return $null + } + [System.AppDomain]::CurrentDomain.add_AssemblyResolve($handler) + } + + if (-not $script:PcSqlitePclInitialized) { + $batteriesPath = Join-Path $storageUtilsRoot 'SQLitePCLRaw.batteries_v2.dll' + $batteriesAsm = [System.Reflection.Assembly]::LoadFrom($batteriesPath) + $batteriesType = $batteriesAsm.GetType('SQLitePCL.Batteries_V2') + if (-not $batteriesType) { throw "Could not load type SQLitePCL.Batteries_V2 from $batteriesPath" } + $initMethod = $batteriesType.GetMethod('Init', [System.Reflection.BindingFlags]::Public -bor [System.Reflection.BindingFlags]::Static) + [void]$initMethod.Invoke($null, @()) + $script:PcSqlitePclInitialized = $true + } + + [void][System.Reflection.Assembly]::LoadFrom((Join-Path $storageUtilsRoot 'Microsoft.Data.Sqlite.dll')) + + $getConnection = New-SqliteIdbConnectionFunc -ConnectionString $ConnectionString + $dialect = [KeeperSecurity.Storage.SqliteDialect]::Instance + $vaultStorage = New-Object KeeperSecurity.Vault.SqlKeeperStorage($getConnection, $dialect, $OwnerUid) + + $verifyConn = New-Object Microsoft.Data.Sqlite.SqliteConnection($ConnectionString) + $verifyConn.Open() + try { + $schemas = @($vaultStorage.GetStorages() | ForEach-Object { $_.Schema }) + $failed = [KeeperSecurity.Storage.DatabaseUtils]::VerifyDatabase($verifyConn, $dialect, $schemas) + if ($failed -and $failed.Count -gt 0) { + [System.Diagnostics.Trace]::TraceError(($failed -join "`n")) + } + } + finally { + $verifyConn.Dispose() + } + + return $vaultStorage +} + $expires = @( [KeeperSecurity.Authentication.TwoFactorDuration]::EveryLogin, [KeeperSecurity.Authentication.TwoFactorDuration]::Every30Days, @@ -348,7 +446,8 @@ function Connect-Keeper { User password .Parameter NewLogin - Do not use Last Login information + Do not resume the stored session (full login). When omitted, resume is also skipped if -Username + differs from the stored LastLogin (switching accounts). .Parameter SsoPassword Use Master Password for SSO account @@ -361,6 +460,15 @@ function Connect-Keeper { .Parameter Config Config file name + + .Parameter UseOfflineStorage + Use SQLite file for vault cache (persists between sessions). + + .Parameter VaultDatabasePath + Path to the SQLite database file for vault storage. Default: keeper_db.sqlite in the same directory as the config file + ß + .Parameter SkipSync + After a successful login, do not call SyncDown. The authenticated session and VaultOnline instance are available. The local vault stays empty until you run Sync-Keeper. AutoSync is disabled until then. #> [CmdletBinding(DefaultParameterSetName = 'regular')] Param( @@ -370,7 +478,10 @@ function Connect-Keeper { [Parameter(ParameterSetName = 'sso_password')][switch] $SsoPassword, [Parameter(ParameterSetName = 'sso_provider')][switch] $SsoProvider, [Parameter()][string] $Server, - [Parameter()][string] $Config + [Parameter()][string] $Config, + [Parameter()][switch] $UseOfflineStorage, + [Parameter()][string] $VaultDatabasePath, + [Parameter()][switch] $SkipSync ) Disconnect-Keeper -Resume | Out-Null @@ -388,8 +499,6 @@ function Connect-Keeper { $authFlow = New-Object KeeperSecurity.Authentication.Sync.AuthSync($storage, $endpoint) - $authFlow.ResumeSession = -not ($NewLogin.IsPresent -or $Password) - Write-Verbose "Resume Session: $($authFlow.ResumeSession)" $authFlow.AlternatePassword = $SsoPassword.IsPresent if (-not $NewLogin.IsPresent -and -not $SsoProvider.IsPresent) { @@ -417,9 +526,20 @@ function Connect-Keeper { Write-Error "Non-interactive session detected" -ErrorAction Stop } + $canResume = -not ($NewLogin.IsPresent -or $Password) + if ($canResume -and $PSBoundParameters.ContainsKey('Username')) { + $cfgForResume = $storage.Get() + if ($cfgForResume.LastLogin -and $Username -and + [string]::Compare($Username, $cfgForResume.LastLogin, $true) -ne 0) { + $canResume = $false + Write-Verbose "Username differs from stored LastLogin; starting a new session (no resume)." + } + } + $authFlow.ResumeSession = $canResume + Write-Verbose "Resume Session: $($authFlow.ResumeSession)" + $biometricPresent = $false try { - # Check Windows Hello capabilities first $windowsHelloAvailable = Test-WindowsHelloCapabilities if ($windowsHelloAvailable) { $biometricPresent = Test-WindowsHelloBiometricPreviouslyUsed -Username $Username @@ -460,12 +580,12 @@ function Connect-Keeper { if ($biometricPresent) { try { Write-Host "Attempting Keeper biometric authentication..." - + $biometricResult = Assert-KeeperBiometricCredential -AuthSyncObject $authFlow -Username $Username -PassThru if ($biometricResult.Success -and $biometricResult.IsValid) { $authFlow.ResumeLoginWithToken($biometricResult.EncryptedLoginToken).GetAwaiter().GetResult() | Out-Null if ($authFlow.IsCompleted) { - Write-Verbose "Authentication completed successfully!" + Write-Debug "Authentication completed successfully!" break } Write-Debug "Biometric authentication succeeded, but additional authentication steps required" @@ -530,13 +650,37 @@ function Connect-Keeper { $auth = $authFlow if ([KeeperSecurity.Authentication.AuthExtensions]::IsAuthenticated($auth)) { - Write-Information -MessageData "Connected to Keeper as $Username" -InformationAction Continue - - $vault = New-Object KeeperSecurity.Vault.VaultOnline($auth) - $task = $vault.SyncDown() - Write-Information -MessageData 'Syncing ...' -InformationAction Continue - $task.GetAwaiter().GetResult() | Out-Null - $vault.AutoSync = $true + Write-Information -MessageData "Connected to Keeper as $($auth.Username)" -InformationAction Continue + + $vaultStorage = $null + if ($UseOfflineStorage) { + $ownerUid = [KeeperSecurity.Utils.CryptoUtils]::Base64UrlEncode($auth.AuthContext.AccountUid) + if ($VaultDatabasePath) { + $dbPath = $VaultDatabasePath + } elseif ($Config) { + $resolved = $null + try { $resolved = Resolve-Path -LiteralPath $Config -ErrorAction Stop } catch { } + $configDir = if ($resolved) { [System.IO.Path]::GetDirectoryName($resolved.Path) } else { [System.IO.Path]::GetDirectoryName([System.IO.Path]::GetFullPath($Config)) } + $dbPath = Join-Path $configDir 'keeper_db.sqlite' + } else { + $dbPath = Join-Path (Get-Location).Path 'keeper_db.sqlite' + } + $dbPath = $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($dbPath) + $connectionString = "Data Source=$dbPath;Pooling=True;" + Write-Information -MessageData "Using vault database: $dbPath" + $vaultStorage = Get-SqliteVaultStorageFromHelper -ConnectionString $connectionString -OwnerUid $ownerUid + } + $vault = New-Object KeeperSecurity.Vault.VaultOnline($auth, $vaultStorage) + if ($SkipSync.IsPresent) { + $vault.AutoSync = $false + Write-Information -MessageData 'SkipSync: vault SyncDown skipped. Local folder tree and records are empty until you run Sync-Keeper.' -InformationAction Continue + } + else { + $task = $vault.SyncDown() + Write-Information -MessageData 'Syncing ...' -InformationAction Continue + $task.GetAwaiter().GetResult() | Out-Null + $vault.AutoSync = $true + } $Script:Context.Auth = $auth $Script:Context.Vault = $vault @@ -633,6 +777,7 @@ function Sync-Keeper { Write-Host "Syncing vault with Keeper server..." $task = $vault.SyncDown() $task.GetAwaiter().GetResult() | Out-Null + $vault.AutoSync = $true Write-Host "Vault sync completed." } else { diff --git a/PowerCommander/KeeperBiometrics.ps1 b/PowerCommander/KeeperBiometrics.ps1 index 9a1e2e55..24e4da47 100644 --- a/PowerCommander/KeeperBiometrics.ps1 +++ b/PowerCommander/KeeperBiometrics.ps1 @@ -74,10 +74,10 @@ function Test-WindowsHelloCapabilities { try { if ($PassThru) { - return [KeeperBiometric.PasskeyManager]::GetCapabilities() + return [KeeperBiometrics.PasskeyManager]::GetCapabilities() } else { - return [KeeperBiometric.PasskeyManager]::IsAvailable() + return [KeeperBiometrics.PasskeyManager]::IsAvailable() } } catch { diff --git a/PowerCommander/PowerCommander.psd1 b/PowerCommander/PowerCommander.psd1 index 5bd9f0fe..7a5b7630 100644 --- a/PowerCommander/PowerCommander.psd1 +++ b/PowerCommander/PowerCommander.psd1 @@ -71,7 +71,9 @@ 'FolderCommands.ps1', 'EnterpriseHelpers.ps1', 'EnterpriseCore.ps1', 'EnterpriseUser.ps1', 'EnterpriseRole.ps1', 'EnterpriseTeam.ps1', 'EnterpriseNode.ps1', 'EnterpriseInfo.ps1', 'SecurityAuditReport.ps1', 'EnterpriseDevices.ps1', 'ManagedCompany.ps1', 'Sharing.ps1', 'SecretsManager.ps1', 'AttachmentCommands.ps1', 'BreachWatch.ps1', - 'KeeperBiometrics.ps1','TrashCommands.ps1', 'Membership.ps1','ReportCommands\ActionReport.ps1','ReportCommands\ShareReport.ps1') + 'KeeperBiometrics.ps1','TrashCommands.ps1', 'Membership.ps1','ReportCommands\ActionReport.ps1','ReportCommands\ShareReport.ps1', + 'SkipSyncCommands.ps1' + ) # Functions to export from this module FunctionsToExport = @('Connect-Keeper', 'Sync-Keeper', 'Disconnect-Keeper', 'Get-KeeperLocation', 'Set-KeeperLocation', @@ -88,9 +90,9 @@ 'Get-KeeperEnterpriseRoleUsers','Get-KeeperEnterpriseRoleTeams', 'Get-KeeperEnterpriseAdminRole', 'Edit-KeeperEnterpriseNode', 'Remove-KeeperEnterpriseNode', 'Invoke-KeeperEnterpriseNodeWipeOut','Get-PendingKeeperDeviceApproval', 'Approve-KeeperDevice', 'Deny-KeeperDevice', 'Set-KeeperEnterpriseNodeCustomInvitation', 'Get-KeeperEnterpriseNodeCustomInvitation', 'Set-KeeperEnterpriseNodeCustomLogo', - 'Get-KeeperManagedCompany', 'New-KeeperManagedCompany', 'Remove-KeeperManagedCompany', 'Edit-KeeperManagedCompany', 'Get-MspBillingReport', 'Get-KeeperMspLegacyReport', - 'Switch-KeeperMC', 'Switch-KeeperMSP', 'Copy-KeeperMCRole','Get-KeeperEnterpriseTeamUser', 'Get-KeeperInformation', 'Get-KeeperDeviceSettings', - 'Set-KeeperDeviceSettings', 'New-KeeperRecordType', 'Edit-KeeperRecordType', 'Remove-KeeperRecordType', 'Import-KeeperRecordTypes', + 'Get-KeeperManagedCompany', 'New-KeeperManagedCompany', 'Remove-KeeperManagedCompany', 'Edit-KeeperManagedCompany', 'Get-MspBillingReport', + 'Get-KeeperMspLegacyReport','Switch-KeeperMC', 'Switch-KeeperMSP', 'Copy-KeeperMCRole','Get-KeeperEnterpriseTeamUser', 'Get-KeeperInformation', + 'Get-KeeperDeviceSettings','Set-KeeperDeviceSettings', 'New-KeeperRecordType', 'Edit-KeeperRecordType', 'Remove-KeeperRecordType', 'Import-KeeperRecordTypes', 'Export-KeeperRecordTypes','Add-KeeperEnterpriseTeamMember', 'Remove-KeeperEnterpriseTeamMember', 'Set-KeeperEnterpriseRole', 'Grant-KeeperEnterpriseRoleToUser', 'Revoke-KeeperEnterpriseRoleFromUser', 'Grant-KeeperEnterpriseRoleToTeam', 'Revoke-KeeperEnterpriseRoleFromTeam', 'New-KeeperEnterpriseRole', 'Remove-KeeperEnterpriseRole', 'Copy-KeeperEnterpriseRole', @@ -110,7 +112,11 @@ 'Remove-TrashedKeeperRecordShares', 'Get-KeeperTrashedRecordDetails', 'Clear-KeeperTrash','Export-KeeperVault', 'Export-KeeperMembership','Import-KeeperMembership', 'Get-KeeperEnterpriseTeams','Find-KeeperDuplicateRecords', 'Get-KeeperFileReport', 'Get-KeeperRecordHistory', 'Get-KeeperAuditReport', 'Get-KeeperUserReport', 'Import-KeeperVault', - 'Get-KeeperActionReport','Get-KeeperShareReport', 'Get-KeeperSharedRecordsReport', 'Export-KeeperAuditLog' + 'Get-KeeperActionReport','Get-KeeperShareReport', 'Get-KeeperSharedRecordsReport', 'Export-KeeperAuditLog', + 'Get-KeeperSharedFolderDetailsSkipSync', 'Get-KeeperSharedFolderRecordUidsSkipSync', 'Get-KeeperSharedFolderRecordsSkipSync', + 'Get-KeeperRecordDetailsByUidSkipSync','Get-KeeperAvailableTeamsSkipSync', 'Get-KeeperTeamUidSkipSync', + 'Grant-KeeperSharedFolderUserSkipSync', 'Revoke-KeeperSharedFolderUserSkipSync', + 'Grant-KeeperSharedFolderTeamSkipSync', 'Revoke-KeeperSharedFolderTeamSkipSync' #'Test-Keeper', ) @@ -127,7 +133,7 @@ 'invite-user', 'lock-user', 'unlock-user', 'transfer-user', 'delete-user', 'kshrsh', 'kshr', 'kushr', 'kcancelshare', 'kshf', 'kushf', 'kat', 'ktr', 'kotsr', 'kotsg', 'kotsn', 'kwhoami', 'this-device','ksm', 'ksm-create', 'ksm-delete', 'ksm-share', 'ksm-unshare', 'ksm-addclient', 'ksm-rmclient', 'kda', 'kbw', 'kbwp', 'kbwi', 'kbwig', 'bw-report', 'krfa', - 'ktrash', 'ktrash-restore', 'ktrash-unshare', 'ktrash-get', 'ktrash-purge', 'kexport', 'kdwnmbs','kapplymbs', + 'ktrash', 'ktrash-restore', 'ktrash-unshare', 'ktrash-get', 'ktrash-purge', 'kexport', 'kdwnmbs','kapplymbs', 'kers', 'kerua', 'kerur', 'kerta', 'kertr', 'keradd', 'kerdel', 'kercopy','list-team', 'find-duplicates', 'keitree', 'kein', 'keiu', 'keit', 'keir', 'keimc', 'file-report', 'krh', 'kar', 'user-report', 'kimport', 'action-report','ksrr', 'msp-legacy-report', 'kal') @@ -161,7 +167,7 @@ 'Get-KeeperRecordHistory (krh) - get version history for a record', 'Get-KeeperFileReport (file-report) - list records with file attachments, verify download accessibility', 'Enterprise info cmdlets (SDK-276): Get-KeeperEnterpriseInfoTree/Node/User/Team/Role/ManagedCompany (keitree, kein, keiu, keit, keir, keimc)', - 'Add-KeeperRecord -GeneratePassword switch for generating passwords on add/update' + 'Add-KeeperRecord -GeneratePassword switch for generating passwords on add/update', 'Get-KeeperShareReport - A report to display with whom records and folders are shared with along with summary, owner and per-user views', 'Get-KeeperSharedRecordsReport (ksrr) - per-row shared records: share type, recipient, permissions, folder path; -AllRecords, -Folder, -ShowTeamUsers', 'Get-KeeperAuditReport (kar) - enterprise audit trail: raw events, span/day/week/month/hour aggregates, dimension (dim) views; filters for user, dates, event type, record/shared folder/team UID, IP, node', diff --git a/PowerCommander/PowerCommander.psm1 b/PowerCommander/PowerCommander.psm1 index d1e963ff..db2affd5 100644 --- a/PowerCommander/PowerCommander.psm1 +++ b/PowerCommander/PowerCommander.psm1 @@ -37,7 +37,11 @@ Remove-KeeperRecordType, Import-KeeperRecordTypes,Export-KeeperRecordTypes, Get- Get-KeeperPasswordReport, Find-KeeperDuplicateRecords, Get-KeeperRecordHistory Export-ModuleMember -Alias kr, kcc, 2fa, kadd, kdel, kmv, krti, find-duplicates, krh -Export-ModuleMember -Function Get-KeeperSharedFolder +Export-ModuleMember -Function Get-KeeperSharedFolder,Get-KeeperSharedFolderDetailsSkipSync, +Get-KeeperSharedFolderRecordUidsSkipSync, Get-KeeperSharedFolderRecordsSkipSync, Get-KeeperRecordDetailsByUidSkipSync, +Get-KeeperAvailableTeamsSkipSync, Get-KeeperTeamUidSkipSync, Grant-KeeperSharedFolderUserSkipSync, +Revoke-KeeperSharedFolderUserSkipSync, Grant-KeeperSharedFolderTeamSkipSync, Revoke-KeeperSharedFolderTeamSkipSync + Export-ModuleMember -Alias ksf Export-ModuleMember -Function Add-KeeperFolder, Edit-KeeperFolder, Remove-KeeperFolder, diff --git a/PowerCommander/README.md b/PowerCommander/README.md index 861a002b..d7f31583 100644 --- a/PowerCommander/README.md +++ b/PowerCommander/README.md @@ -8,6 +8,22 @@ To run the PowerCommander module from the source copy PowerCommander\ directory * `%USERPROFILE%\Documents\WindowsPowerShell\Modules` Per User * `C:\Program Files\WindowsPowerShell\Modules` All users +### Optional: SQLite vault storage (-UseOfflineStorage) + +To persist the vault cache between sessions, use `Connect-Keeper -UseOfflineStorage` (optionally with `-VaultDatabasePath`). Keep `KeeperSdk.dll` and `KeeperBiometrics.dll` in the **PowerCommander module folder**. Copy the **same SQLite assemblies Commander uses** into `PowerCommander\StorageUtils\` (validated and loaded only when you use `-UseOfflineStorage`): + +- `Microsoft.Data.Sqlite.dll` +- `SQLitePCLRaw.batteries_v2.dll`, `SQLitePCLRaw.core.dll`, `SQLitePCLRaw.provider.e_sqlite3.dll` +- Native library: `e_sqlite3.dll` + +Put **only** these files in `StorageUtils` — not `KeeperSdk.dll`, `*.pdb`, or a full `dotnet publish` output. + +Offline storage checks **only** these files (Windows). Copy them from a **Commander** `net8.0` build (same SQLite layout: managed DLLs plus `runtimes\win-x64\native\e_sqlite3.dll` copied next to the other SQLite assemblies as `e_sqlite3.dll` under `StorageUtils`). + +Default vault database file: **`keeper_db.sqlite`** next to your config (or in the current directory if no `-Config`). Commander continues to use **`keeper_db.sqlite`** in its config folder, so the two do not share the same SQLite file unless you point `-VaultDatabasePath` to the same path. + +Implementation: SQLite assemblies are loaded from `StorageUtils` with `AssemblyResolve`; SQLitePCL is initialized; a `Func` for `SqlKeeperStorage` is built with **Reflection.Emit** (PowerShell cannot supply that delegate type directly). **KeeperSdk** does not reference `Microsoft.Data.Sqlite`; SQLite stays optional beside the module. + ### Cmdlets | Cmdlet name | Alias | Description |---------------------------------------------------------|------------------|---------------------------- diff --git a/PowerCommander/SkipSyncCommands.ps1 b/PowerCommander/SkipSyncCommands.ps1 new file mode 100644 index 00000000..9a5c46f1 --- /dev/null +++ b/PowerCommander/SkipSyncCommands.ps1 @@ -0,0 +1,798 @@ +#requires -Version 5.1 + +function getKeeperAuth { + <# + SkipSync REST helpers only need IAuthentication — not a populated vault. + Use this instead of getVault so commands work when vault SyncDown was skipped (-SkipSync) or the cache is empty. + #> + if (-not $Script:Context.Auth) { + Write-Error -Message 'Not connected. Run Connect-Keeper first.' -ErrorAction Stop + } + $Script:Context.Auth +} + +function __ResolveSharedFolderUidSkipSync { + param($SharedFolder) + if ($SharedFolder -is [Array]) { + if ($SharedFolder.Count -ne 1) { + throw 'Only one shared folder is expected.' + } + $SharedFolder = $SharedFolder[0] + } + $uid = $null + if ($SharedFolder -is [string]) { + $uid = $SharedFolder + } + elseif ($null -ne $SharedFolder.Uid) { + $uid = $SharedFolder.Uid + } + if (-not $uid) { + throw "Cannot resolve shared folder UID from: $SharedFolder" + } + + $vault = $Script:Context.Vault + if ($vault) { + [KeeperSecurity.Vault.SharedFolder]$sf = $null + if ($vault.TryGetSharedFolder($uid, [ref]$sf)) { + return $sf.Uid + } + $sf = $vault.SharedFolders | Where-Object { $_.Name -eq $uid } | Select-Object -First 1 + if ($sf) { + return $sf.Uid + } + } + return $uid +} + +function __NewSharedFolderUserOptionsSkipSync { + param( + [System.Nullable[bool]] $ManageRecords, + [System.Nullable[bool]] $ManageUsers, + [System.Nullable[DateTimeOffset]] $Expiration + ) + $options = New-Object KeeperSecurity.Vault.SharedFolderUserOptions + if ($null -ne $ManageRecords) { + $options.ManageRecords = $ManageRecords + } else { + $options.ManageRecords = $null + } + if ($null -ne $ManageUsers) { + $options.ManageUsers = $ManageUsers + } else { + $options.ManageUsers = $null + } + if ($null -ne $Expiration) { + $options.Expiration = $Expiration + } else { + $options.Expiration = $null + } + return $options +} + +function __GetSharedFolderObjectFromResponseSkipSync { + param($GetSharedFoldersResponse, [string]$SharedFolderUid) + if (-not $GetSharedFoldersResponse -or -not $GetSharedFoldersResponse.SharedFolders) { + return $null + } + $uid = $SharedFolderUid.Trim() + foreach ($sharedFolder in $GetSharedFoldersResponse.SharedFolders) { + if ($sharedFolder -and [string]::Equals($sharedFolder.SharedFolderUid, $uid, [StringComparison]::OrdinalIgnoreCase)) { + return $sharedFolder + } + } + if ($GetSharedFoldersResponse.SharedFolders.Length -eq 1) { + return $GetSharedFoldersResponse.SharedFolders[0] + } + $null +} + +function __TestSharedFolderOwnerIsCurrentUserSkipSync { + param($SharedFolderObject, [KeeperSecurity.Authentication.IAuthentication]$Auth) + if (-not $SharedFolderObject -or -not $Auth.AuthContext.AccountUid) { + return $false + } + $owner = $SharedFolderObject.Owner + if ([string]::IsNullOrWhiteSpace($owner)) { + return $false + } + $myUid = [KeeperSecurity.Utils.CryptoUtils]::Base64UrlEncode($Auth.AuthContext.AccountUid) + [string]::Equals($owner.Trim(), $myUid.Trim(), [StringComparison]::OrdinalIgnoreCase) +} + +function __WriteRecordDetailsSkipSyncResult { + param( + [Parameter(Mandatory = $true)][KeeperSecurity.Vault.RecordDetailsSkipSyncResult] $Result, + [string] $EmptyMessage = 'No records in this shared folder (or folder unavailable).' + ) + if ($Result.Records.Count -eq 0 -and $Result.FailedRecordUids.Count -eq 0 -and $Result.NoPermissionRecordUids.Count -eq 0) { + Write-Host $EmptyMessage + return + } + foreach ($record in $Result.Records) { + $title = $record.Title + if ([string]::IsNullOrEmpty($title)) { $title = '(no title)' } + Write-Host " $($record.Uid): $title" + } + if ($Result.NoPermissionRecordUids.Count -gt 0) { + Write-Host " No permission: $($Result.NoPermissionRecordUids -join ', ')" + } + if ($Result.FailedRecordUids.Count -gt 0) { + Write-Host " Failed to decrypt: $($Result.FailedRecordUids -join ', ')" + } + if ($Result.InvalidRecordUids.Count -gt 0) { + Write-Host " Invalid UID format: $($Result.InvalidRecordUids -join ', ')" + } +} + +function __GetRecordDetailsSkipSyncIncludeValue { + param([string]$Include) + switch ($Include) { + 'DataOnly' { 1 } + 'ShareOnly' { 2 } + Default { 0 } + } +} + +function __GetRequestedRecordUidsMissingFromLoadedRecords { + param( + [string[]]$RequestedUids, + [Parameter(Mandatory = $true)][KeeperSecurity.Vault.RecordDetailsSkipSyncResult]$Result + ) + $loaded = @{} + foreach ($record in $Result.Records) { + if ($record.Uid) { $loaded[$record.Uid] = $true } + } + $missing = [System.Collections.ArrayList]::new() + foreach ($uid in $RequestedUids) { + if ([string]::IsNullOrWhiteSpace($uid)) { continue } + $trimmedUid = $uid.Trim() + $found = $false + foreach ($key in $loaded.Keys) { + if ([string]::Equals($key, $trimmedUid, [StringComparison]::OrdinalIgnoreCase)) { + $found = $true + break + } + } + if (-not $found) { + [void]$missing.Add($trimmedUid) + } + } + @($missing) +} + +function __TryFindSharedFolderUidForRecordFromVault { + param([string]$RecordUid) + if ([string]::IsNullOrWhiteSpace($RecordUid)) { + return $null + } + $vault = $Script:Context.Vault + if (-not $vault) { + return $null + } + $trimmedRecordUid = $RecordUid.Trim() + foreach ($sf in $vault.SharedFolders) { + foreach ($recordPermission in $sf.RecordPermissions) { + if ($recordPermission.RecordUid -and [string]::Equals($recordPermission.RecordUid.Trim(), $trimmedRecordUid, [StringComparison]::OrdinalIgnoreCase)) { + return $sf.Uid + } + } + } + $null +} + +function __MergeRecordDetailsSkipSyncResultsForSameRequest { + param( + [string[]]$RequestedUids, + [Parameter(Mandatory = $true)][KeeperSecurity.Vault.RecordDetailsSkipSyncResult]$OwnedResult, + [Parameter(Mandatory = $true)]$SharedFolderResults + ) + $records = [System.Collections.Generic.List[KeeperSecurity.Vault.KeeperRecord]]::new() + foreach ($record in $OwnedResult.Records) { + $records.Add($record) + } + foreach ($sf in $SharedFolderResults) { + foreach ($record in $sf.Records) { + $records.Add($record) + } + } + $loaded = [System.Collections.Generic.Dictionary[string, bool]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($record in $records) { + if ($record.Uid) { + $loaded[$record.Uid] = $true + } + } + $noPerm = [System.Collections.ArrayList]::new() + foreach ($uid in $OwnedResult.NoPermissionRecordUids) { + if ($uid -and -not $loaded.ContainsKey($uid)) { + [void]$noPerm.Add($uid) + } + } + foreach ($sfr in $SharedFolderResults) { + foreach ($uid in $sfr.NoPermissionRecordUids) { + if ($uid -and -not $loaded.ContainsKey($uid)) { + [void]$noPerm.Add($uid) + } + } + } + $invalid = [System.Collections.ArrayList]::new() + foreach ($uid in $OwnedResult.InvalidRecordUids) { + if ($uid) { [void]$invalid.Add($uid) } + } + foreach ($sfr in $SharedFolderResults) { + foreach ($uid in $sfr.InvalidRecordUids) { + if ($uid) { [void]$invalid.Add($uid) } + } + } + $failed = [System.Collections.ArrayList]::new() + foreach ($uid in $RequestedUids) { + if ([string]::IsNullOrWhiteSpace($uid)) { continue } + $trimmedUid = $uid.Trim() + if (-not $loaded.ContainsKey($trimmedUid)) { + [void]$failed.Add($trimmedUid) + } + } + New-Object KeeperSecurity.Vault.RecordDetailsSkipSyncResult( + $records.ToArray(), + [string[]]@($noPerm), + [string[]]@($failed), + [string[]]@($invalid)) +} + +function __ConvertSharedFolderObjectToDetailsView { + param( + [Parameter(Mandatory = $true)] $SharedFolderObject, + [Parameter(Mandatory = $true)][KeeperSecurity.Vault.GetSharedFoldersResponse] $ApiResponse, + [Parameter(Mandatory = $false)][switch] $IncludePermissions + ) + if (-not $IncludePermissions) { + $recordUids = [System.Collections.ArrayList]::new() + foreach ($record in @($SharedFolderObject.Records)) { + if ($record -and $record.RecordUid) { [void]$recordUids.Add($record.RecordUid) } + } + $userEmails = [System.Collections.ArrayList]::new() + foreach ($uid in @($SharedFolderObject.Users)) { + if ($uid -and $uid.Email) { [void]$userEmails.Add($uid.Email) } + } + $teamUids = [System.Collections.ArrayList]::new() + foreach ($team in @($SharedFolderObject.Teams)) { + if ($team -and $team.TeamUid) { [void]$teamUids.Add($team.TeamUid) } + } + return [pscustomobject][ordered]@{ + ApiResult = $ApiResponse.result + ApiIsSuccess = $ApiResponse.IsSuccess + ApiMessage = $ApiResponse.message + ApiCommand = $ApiResponse.command + SharedFolderUid = $SharedFolderObject.SharedFolderUid + Name = $SharedFolderObject.Name + Owner = $SharedFolderObject.Owner + Revision = $SharedFolderObject.Revision + RecordCount = $recordUids.Count + UserCount = $userEmails.Count + TeamCount = $teamUids.Count + RecordUids = @($recordUids) + UserEmails = @($userEmails) + TeamUids = @($teamUids) + } + } + + $recObjs = @() + foreach ($record in @($SharedFolderObject.Records)) { + if (-not $record) { continue } + $recObjs += [pscustomobject]@{ + RecordUid = $record.RecordUid + CanEdit = $record.CanEdit + CanShare = $record.CanShare + } + } + $userObjs = @() + foreach ($uid in @($SharedFolderObject.Users)) { + if (-not $uid) { continue } + $userObjs += [pscustomobject]@{ + Email = $uid.Email + ManageUsers = $uid.ManageUsers + ManageRecords = $uid.ManageRecords + } + } + $teamObjs = @() + foreach ($team in @($SharedFolderObject.Teams)) { + if (-not $team) { continue } + $teamObjs += [pscustomobject]@{ + TeamUid = $team.TeamUid + Name = $team.Name + ManageUsers = $team.ManageUsers + ManageRecords = $team.ManageRecords + RestrictEdit = $team.RestrictEdit + RestrictShare = $team.RestrictShare + } + } + [pscustomobject][ordered]@{ + ApiResult = $ApiResponse.result + ApiIsSuccess = $ApiResponse.IsSuccess + ApiMessage = $ApiResponse.message + ApiCommand = $ApiResponse.command + SharedFolderUid = $SharedFolderObject.SharedFolderUid + Name = $SharedFolderObject.Name + Owner = $SharedFolderObject.Owner + Revision = $SharedFolderObject.Revision + KeyType = $SharedFolderObject.KeyType + ManageUsers = $SharedFolderObject.ManageUsers + ManageRecords = $SharedFolderObject.ManageRecords + DefaultCanEdit = $SharedFolderObject.DefaultCanEdit + DefaultCanShare = $SharedFolderObject.DefaultCanShare + DefaultManageRecords = $SharedFolderObject.DefaultManageRecords + DefaultManageUsers = $SharedFolderObject.DefaultManageUsers + AccountFolder = $SharedFolderObject.AccountFolder + FullSync = $SharedFolderObject.FullSync + RecordCount = $recObjs.Count + UserCount = $userObjs.Count + TeamCount = $teamObjs.Count + Records = $recObjs + Users = $userObjs + Teams = $teamObjs + } +} + +function Get-KeeperSharedFolderDetailsSkipSync { + <# + .SYNOPSIS + Fetches shared folder payload from the server (get_shared_folders) without a full vault sync. + + .DESCRIPTION + By default returns a compact PSCustomObject: RecordUids, UserEmails, and TeamUids string arrays plus basic + folder identity (uid, name, owner, revision) and counts. Raw API objects format poorly in the console + (e.g. SharedFolders shown as a one-line collection). + Use Format-List or Select-Object -ExpandProperty Records/Users/Teams to inspect nested data when using -IncludePermissions. + + .PARAMETER IncludePermissions + When set, includes per-record CanEdit/CanShare, per-user and per-team permission flags, and folder-level + key and default-permission fields. Omit for list-only output. + + .PARAMETER PassThru + Return the raw GetSharedFoldersResponse from the SDK. + #> + [CmdletBinding()] + Param( + [Parameter(Mandatory = $true)][string] $SharedFolderUid, + [Parameter()][switch] $IncludePermissions, + [Parameter()][switch] $PassThru + ) + + $auth = getKeeperAuth + $task = [KeeperSecurity.Vault.SharedFolderSkipSyncDown]::GetSharedFolderAsync($auth, $SharedFolderUid) + $rs = $task.GetAwaiter().GetResult() + if (-not $rs) { + Write-Warning "Shared folder not found or get_shared_folders returned no data for: $SharedFolderUid" + return $null + } + if ($PassThru) { + return $rs + } + $sf = __GetSharedFolderObjectFromResponseSkipSync $rs $SharedFolderUid + if (-not $sf) { + Write-Warning 'No matching shared folder in the API response.' + return $null + } + __ConvertSharedFolderObjectToDetailsView -SharedFolderObject $sf -ApiResponse $rs -IncludePermissions:$IncludePermissions +} + +function Get-KeeperSharedFolderRecordUidsSkipSync { + <# + .SYNOPSIS + Returns record UIDs linked to a shared folder from get_shared_folders (no record bodies). + + .DESCRIPTION + Lightweight folder membership: only the list of record UIDs from the shared-folder payload. + + Use Get-KeeperSharedFolderRecordsSkipSync when you need every record in the folder decrypted in one step. + Use Get-KeeperRecordDetailsByUidSkipSync when you already know which record UIDs to load; it does not discover + which records belong to a folder. + + This cmdlet fills that gap for "what UIDs are in this folder?" without pulling + full details — useful for counts, logging, or fetching a subset of records by UID afterward. + #> + [CmdletBinding()] + [OutputType([string[]])] + Param( + [Parameter(Mandatory = $true)][string] $SharedFolderUid + ) + + $auth = getKeeperAuth + $task = [KeeperSecurity.Vault.SharedFolderSkipSyncDown]::GetRecordUidsFromSharedFolderAsync($auth, $SharedFolderUid) + $task.GetAwaiter().GetResult() +} + +function Get-KeeperSharedFolderRecordsSkipSync { + <# + .SYNOPSIS + Lists decrypted records in a shared folder without full vault sync. Chooses decryption path automatically unless you override -Mode. + + .PARAMETER SharedFolder + Shared folder UID (base64url), name if present in vault cache, or object with a Uid property. + + .PARAMETER Mode + Auto: use get_shared_folders owner flag — shared-folder record keys if you are not owner, owned record keys if you are owner. + SharedKey: always decrypt via shared-folder record keys (RecordSkipSyncDown.GetSharedFolderRecordsAsync). + OwnedKey: always load UIDs from the folder then decrypt with per-record keys (GetOwnedRecordsAsync). + + .PARAMETER Include + RecordDetailsInclude: DataPlusShare (default), DataOnly, or ShareOnly. + + .PARAMETER PassThru + Return RecordDetailsSkipSyncResult instead of printing uid/title lines. + #> + [CmdletBinding()] + [OutputType([KeeperSecurity.Vault.RecordDetailsSkipSyncResult])] + Param( + [Parameter(Mandatory = $true, Position = 0)] $SharedFolder, + [Parameter()] + [ValidateSet('DataPlusShare', 'DataOnly', 'ShareOnly')] + [string] $Include = 'DataPlusShare', + [Parameter()] + [ValidateSet('Auto', 'SharedKey', 'OwnedKey')] + [string] $Mode = 'Auto', + [Parameter()][switch] $PassThru + ) + + $auth = getKeeperAuth + $sfUid = __ResolveSharedFolderUidSkipSync $SharedFolder + $includeVal = __GetRecordDetailsSkipSyncIncludeValue $Include + + $useOwned = $false + if ($Mode -eq 'OwnedKey') { + $useOwned = $true + } + elseif ($Mode -eq 'SharedKey') { + $useOwned = $false + } + else { + $taskFolder = [KeeperSecurity.Vault.SharedFolderSkipSyncDown]::GetSharedFolderAsync($auth, $sfUid) + $folderRs = $taskFolder.GetAwaiter().GetResult() + $sfObj = __GetSharedFolderObjectFromResponseSkipSync $folderRs $sfUid + $useOwned = __TestSharedFolderOwnerIsCurrentUserSkipSync $sfObj $auth + } + + $result = if ($useOwned) { + $taskUids = [KeeperSecurity.Vault.SharedFolderSkipSyncDown]::GetRecordUidsFromSharedFolderAsync($auth, $sfUid) + $uids = $taskUids.GetAwaiter().GetResult() + if ($null -eq $uids) { + $uids = [string[]]@() + } + $taskRec = [KeeperSecurity.Vault.RecordSkipSyncDown]::GetOwnedRecordsAsync($auth, $uids, $includeVal) + $taskRec.GetAwaiter().GetResult() + } + else { + $task = [KeeperSecurity.Vault.RecordSkipSyncDown]::GetSharedFolderRecordsAsync($auth, $sfUid, $includeVal) + $task.GetAwaiter().GetResult() + } + + if ($PassThru) { + return $result + } + __WriteRecordDetailsSkipSyncResult $result +} + +function Get-KeeperRecordDetailsByUidSkipSync { + <# + .SYNOPSIS + Loads record details by UID via vault/get_records_details without a full vault sync. + + .DESCRIPTION + Default -Mode Auto loads owned records first (per-record keys), then retries any missing UIDs using shared-folder + keys from get_shared_folders. Use -SharedFolderUid when you know the folder, + or run Sync-Keeper so the vault can map each record to a shared folder. + + .PARAMETER RecordUid + One or more record UIDs. + + .PARAMETER SharedFolderUid + Optional. When set, Auto mode uses this folder for the second-step shared-folder decrypt for UIDs that failed + the owned path. SharedKey mode requires this parameter. + + .PARAMETER Mode + Auto (default): owned decrypt first, then shared-folder decrypt for remaining UIDs. + OwnedKey: only owned-record keys (previous behavior when SharedFolderUid was omitted). + SharedKey: only shared-folder keys for the folder given by -SharedFolderUid. + + .PARAMETER PassThru + Return RecordDetailsSkipSyncResult instead of printing lines. + #> + [CmdletBinding()] + Param( + [Parameter(Mandatory = $true, Position = 0)][string[]] $RecordUid, + [Parameter()][string] $SharedFolderUid, + [Parameter()] + [ValidateSet('Auto', 'OwnedKey', 'SharedKey')] + [string] $Mode = 'Auto', + [Parameter()] + [ValidateSet('DataPlusShare', 'DataOnly', 'ShareOnly')] + [string] $Include = 'DataPlusShare', + [Parameter()][switch] $PassThru + ) + + $auth = getKeeperAuth + $includeVal = __GetRecordDetailsSkipSyncIncludeValue -Include $Include + + if ($Mode -eq 'SharedKey') { + if (-not $SharedFolderUid) { + throw 'SharedKey mode requires -SharedFolderUid.' + } + $task = [KeeperSecurity.Vault.RecordSkipSyncDown]::GetSharedFolderRecordsAsync($auth, $SharedFolderUid.Trim(), $RecordUid, $includeVal) + $result = $task.GetAwaiter().GetResult() + } + elseif ($Mode -eq 'OwnedKey') { + $task = [KeeperSecurity.Vault.RecordSkipSyncDown]::GetOwnedRecordsAsync($auth, $RecordUid, $includeVal) + $result = $task.GetAwaiter().GetResult() + } + else { + $taskOwned = [KeeperSecurity.Vault.RecordSkipSyncDown]::GetOwnedRecordsAsync($auth, $RecordUid, $includeVal) + $owned = $taskOwned.GetAwaiter().GetResult() + $needSf = __GetRequestedRecordUidsMissingFromLoadedRecords -RequestedUids $RecordUid -Result $owned + if ($needSf.Count -eq 0) { + $result = $owned + } + else { + Write-Verbose "SkipSync: $($needSf.Count) record UID(s) not loaded with owned keys; trying shared-folder keys." + $sfResults = [System.Collections.ArrayList]::new() + if ($SharedFolderUid) { + $tSf = [KeeperSecurity.Vault.RecordSkipSyncDown]::GetSharedFolderRecordsAsync( + $auth, $SharedFolderUid.Trim(), [string[]]$needSf, $includeVal) + [void]$sfResults.Add($tSf.GetAwaiter().GetResult()) + } + else { + $groups = @{} + foreach ($uid in $needSf) { + $sfUid = __TryFindSharedFolderUidForRecordFromVault -RecordUid $uid + if (-not $sfUid) { + Write-Verbose "SkipSync: no shared folder in vault cache for record $uid; specify -SharedFolderUid or run Sync-Keeper." + continue + } + if (-not $groups.ContainsKey($sfUid)) { + $groups[$sfUid] = [System.Collections.ArrayList]::new() + } + [void]$groups[$sfUid].Add($uid) + } + foreach ($group in $groups.GetEnumerator()) { + $uids = [string[]]@($group.Value) + $tSf = [KeeperSecurity.Vault.RecordSkipSyncDown]::GetSharedFolderRecordsAsync($auth, $group.Key, $uids, $includeVal) + [void]$sfResults.Add($tSf.GetAwaiter().GetResult()) + } + } + if ($sfResults.Count -eq 0) { + if ($needSf.Count -gt 0 -and -not $SharedFolderUid) { + Write-Warning 'One or more records were not loaded with owned keys. They may live in a shared folder: pass -SharedFolderUid or run Sync-Keeper so the vault can resolve the folder.' + } + $result = $owned + } + else { + $result = __MergeRecordDetailsSkipSyncResultsForSameRequest -RequestedUids $RecordUid -OwnedResult $owned -SharedFolderResults @($sfResults) + } + } + } + + if ($PassThru) { + return $result + } + __WriteRecordDetailsSkipSyncResult $result +} + +function Get-KeeperAvailableTeamsSkipSync { + <# + .SYNOPSIS + Lists teams available for sharing (get_available_teams). Use with SET 3 team sharing. + #> + [CmdletBinding()] + Param() + + $auth = getKeeperAuth + $task = [KeeperSecurity.Vault.SharedFolderSkipSyncDown]::GetAvailableTeamsForShareAsync($auth) + $task.GetAwaiter().GetResult() +} + +function Get-KeeperTeamUidSkipSync { + <# + .SYNOPSIS + Resolves a team display name to a team UID (for SET 3). + #> + [CmdletBinding()] + [OutputType([string])] + Param( + [Parameter(Mandatory = $true)][string] $TeamName + ) + + $auth = getKeeperAuth + $task = [KeeperSecurity.Vault.SharedFolderSkipSyncDown]::GetTeamUidFromNameAsync($auth, $TeamName) + $task.GetAwaiter().GetResult() +} + + +function Grant-KeeperSharedFolderUserSkipSync { + <# + .SYNOPSIS + Adds or updates a user on a shared folder without a full vault sync (PutUserToSharedFolderAsync). + + .DESCRIPTION + By default, only performs the API call. Use -ShowDetail to also list decrypted records in the folder (shared-folder key path) and write a summary. + + .PARAMETER ShowDetail + If set, after a successful grant runs Get-KeeperSharedFolderRecordsSkipSync and writes a summary. + + .PARAMETER PassThru + When -ShowDetail is used, returns RecordDetailsSkipSyncResult from the listing step. + + .PARAMETER ExpireIn + Optional. Expiration offset from now: a TimeSpan, integer (minutes), or a string that parses as minutes or TimeSpan (same as Grant-KeeperRecordAccess). + + .PARAMETER ExpireAt + Optional. Absolute expiration as ISO 8601 or RFC 1123 (e.g. "2025-05-23T08:59:11Z"). + #> + [CmdletBinding(SupportsShouldProcess = $true)] + Param( + [Parameter(Mandatory = $true, Position = 0)] $SharedFolder, + [Parameter(Mandatory = $true, Position = 1)][string] $User, + [Parameter()][System.Nullable[bool]] $ManageRecords, + [Parameter()][System.Nullable[bool]] $ManageUsers, + [Parameter()][System.Object] $ExpireIn, + [Parameter()][string] $ExpireAt, + [Parameter()][switch] $ShowDetail, + [Parameter()][switch] $PassThru + ) + + $sfUid = __ResolveSharedFolderUidSkipSync $SharedFolder + $email = $User.Trim() + try { + $expirationDto = Get-ExpirationDate -ExpireIn $ExpireIn -ExpireAt $ExpireAt + } catch { + Write-Error "Error: $($_.Exception.Message)" -ErrorAction Stop + throw + } + $options = __NewSharedFolderUserOptionsSkipSync -ManageRecords $ManageRecords -ManageUsers $ManageUsers -Expiration $expirationDto + $didGrant = $false + if ($PSCmdlet.ShouldProcess("$sfUid", "Grant shared folder access to $email")) { + $auth = getKeeperAuth + $task = [KeeperSecurity.Vault.SharedFolderSkipSyncDown]::PutUserToSharedFolderAsync($auth, $sfUid, $email, $options) + $task.GetAwaiter().GetResult() | Out-Null + Write-Host "OK: Shared folder $sfUid — user $email added or updated." + $didGrant = $true + } + if ($didGrant -and $ShowDetail) { + $listed = Get-KeeperSharedFolderRecordsSkipSync -SharedFolder $sfUid -Mode SharedKey -PassThru + __WriteRecordDetailsSkipSyncResult $listed "No records listed on this shared folder (user $User has folder access)." + if ($PassThru) { $listed } + } +} + +function Revoke-KeeperSharedFolderUserSkipSync { + <# + .SYNOPSIS + Removes a user from a shared folder without a full vault sync (RemoveUserFromSharedFolderAsync). + + .DESCRIPTION + By default, only performs the API call. Use -ShowDetail to also list decrypted records still in the folder. + + .PARAMETER ShowDetail + If set, after a successful revoke runs Get-KeeperSharedFolderRecordsSkipSync and writes a summary. + + .PARAMETER PassThru + When -ShowDetail is used, returns RecordDetailsSkipSyncResult from the listing step. + #> + [CmdletBinding(SupportsShouldProcess = $true)] + Param( + [Parameter(Mandatory = $true, Position = 0)] $SharedFolder, + [Parameter(Mandatory = $true, Position = 1)][string] $User, + [Parameter()][switch] $ShowDetail, + [Parameter()][switch] $PassThru + ) + + $sfUid = __ResolveSharedFolderUidSkipSync $SharedFolder + $email = $User.Trim() + $didRevoke = $false + if ($PSCmdlet.ShouldProcess("$sfUid", "Remove shared folder access for $email")) { + $auth = getKeeperAuth + $task = [KeeperSecurity.Vault.SharedFolderSkipSyncDown]::RemoveUserFromSharedFolderAsync($auth, $sfUid, $email) + $task.GetAwaiter().GetResult() | Out-Null + Write-Host "OK: Shared folder $sfUid — user $email removed." + $didRevoke = $true + } + if ($didRevoke -and $ShowDetail) { + $listed = Get-KeeperSharedFolderRecordsSkipSync -SharedFolder $sfUid -Mode SharedKey -PassThru + __WriteRecordDetailsSkipSyncResult $listed 'No records listed on this shared folder after remove.' + if ($PassThru) { $listed } + } +} + +function Grant-KeeperSharedFolderTeamSkipSync { + <# + .SYNOPSIS + Adds or updates a team on a shared folder without a full vault sync. + Team may be a team UID (base64url) or a team name resolved via the SDK. + + .DESCRIPTION + By default, only performs the API call. Use -ShowDetail to also list decrypted records in the folder. + + .PARAMETER ShowDetail + If set, after a successful grant runs Get-KeeperSharedFolderRecordsSkipSync and writes a summary. + + .PARAMETER PassThru + When -ShowDetail is used, returns RecordDetailsSkipSyncResult from the listing step. + + .PARAMETER ExpireIn + Optional. Same semantics as Grant-KeeperRecordAccess / Grant-KeeperSharedFolderUserSkipSync. + + .PARAMETER ExpireAt + Optional. Absolute expiration (ISO 8601 or RFC 1123). + #> + [CmdletBinding(SupportsShouldProcess = $true)] + Param( + [Parameter(Mandatory = $true, Position = 0)] $SharedFolder, + [Parameter(Mandatory = $true, Position = 1)][string] $Team, + [Parameter()][System.Nullable[bool]] $ManageRecords, + [Parameter()][System.Nullable[bool]] $ManageUsers, + [Parameter()][System.Object] $ExpireIn, + [Parameter()][string] $ExpireAt, + [Parameter()][switch] $ShowDetail, + [Parameter()][switch] $PassThru + ) + + $sfUid = __ResolveSharedFolderUidSkipSync $SharedFolder + $teamKey = $Team.Trim() + try { + $expirationDto = Get-ExpirationDate -ExpireIn $ExpireIn -ExpireAt $ExpireAt + } catch { + Write-Error "Error: $($_.Exception.Message)" -ErrorAction Stop + throw + } + $options = __NewSharedFolderUserOptionsSkipSync -ManageRecords $ManageRecords -ManageUsers $ManageUsers -Expiration $expirationDto + $didGrant = $false + if ($PSCmdlet.ShouldProcess("$sfUid", "Grant shared folder access to team $Team")) { + $auth = getKeeperAuth + $task = [KeeperSecurity.Vault.SharedFolderSkipSyncDown]::PutTeamToSharedFolderAsync($auth, $sfUid, $teamKey, $options) + $task.GetAwaiter().GetResult() | Out-Null + Write-Host "OK: Shared folder $sfUid — team $teamKey added or updated." + $didGrant = $true + } + if ($didGrant -and $ShowDetail) { + $listed = Get-KeeperSharedFolderRecordsSkipSync -SharedFolder $sfUid -Mode SharedKey -PassThru + __WriteRecordDetailsSkipSyncResult $listed "No records listed on this shared folder (team $Team has folder access)." + if ($PassThru) { $listed } + } +} + +function Revoke-KeeperSharedFolderTeamSkipSync { + <# + .SYNOPSIS + Removes a team from a shared folder without a full vault sync (RemoveTeamFromSharedFolderAsync). + + .DESCRIPTION + By default, only performs the API call. Use -ShowDetail to also list decrypted records still in the folder. + + .PARAMETER ShowDetail + If set, after a successful revoke runs Get-KeeperSharedFolderRecordsSkipSync and writes a summary. + + .PARAMETER PassThru + When -ShowDetail is used, returns RecordDetailsSkipSyncResult from the listing step. + #> + [CmdletBinding(SupportsShouldProcess = $true)] + Param( + [Parameter(Mandatory = $true, Position = 0)] $SharedFolder, + [Parameter(Mandatory = $true, Position = 1)][string] $Team, + [Parameter()][switch] $ShowDetail, + [Parameter()][switch] $PassThru + ) + + $sfUid = __ResolveSharedFolderUidSkipSync $SharedFolder + $teamKey = $Team.Trim() + $didRevoke = $false + if ($PSCmdlet.ShouldProcess("$sfUid", "Remove shared folder access for team $Team")) { + $auth = getKeeperAuth + $task = [KeeperSecurity.Vault.SharedFolderSkipSyncDown]::RemoveTeamFromSharedFolderAsync($auth, $sfUid, $teamKey) + $task.GetAwaiter().GetResult() | Out-Null + Write-Host "OK: Shared folder $sfUid — team $teamKey removed." + $didRevoke = $true + } + if ($didRevoke -and $ShowDetail) { + $listed = Get-KeeperSharedFolderRecordsSkipSync -SharedFolder $sfUid -Mode SharedKey -PassThru + __WriteRecordDetailsSkipSyncResult $listed 'No records listed on this shared folder after remove.' + if ($PassThru) { $listed } + } +} + From e00e54705991c90d363c619e49bc2329a8fd9a43 Mon Sep 17 00:00:00 2001 From: sgaddala-ks Date: Fri, 17 Apr 2026 10:49:25 +0530 Subject: [PATCH 2/3] removed changes from deployment workflows --- .github/workflows/build.yml | 118 +++++++++------- .github/workflows/power-commander.yml | 185 +++----------------------- 2 files changed, 88 insertions(+), 215 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5a9c3285..6f50033e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,13 +1,8 @@ name: Build Keeper SDK for .NET -on: +on: workflow_dispatch: inputs: - publish_to_nuget: - description: Push Keeper.Sdk package to NuGet.org - type: boolean - required: false - default: false cli: description: Build CLI package type: boolean @@ -18,22 +13,19 @@ on: type: boolean required: false default: false - + jobs: - configure: + build: runs-on: windows-latest outputs: - sdk_version: ${{ steps.vars.outputs.sdk_version }} - package_version: ${{ steps.vars.outputs.package_version }} - build_version: ${{ steps.vars.outputs.build_version }} - steps: - - uses: actions/checkout@v4 + package-version: ${{ steps.vars.outputs.package_version }} + steps: - name: Setup product versions id: vars run: | $ErrorView = 'NormalView' - $branch = ($Env:GITHUB_REF -split '/')[2] + $branch = ($Env:GITHUB_REF -split '/')[2] $comp = $branch -split '_' $sdkVersion = $comp[1] $packageVersion = $sdkVersion @@ -41,35 +33,18 @@ jobs: $packageVersion = $packageVersion + '-' + $comp[2] } - if ([string]::IsNullOrWhiteSpace($sdkVersion)) { - $raw = Get-Content "KeeperSdk/KeeperSdk.csproj" -Raw - if ($raw -notmatch '([^<]+)') { - throw "Could not read from KeeperSdk/KeeperSdk.csproj" - } - $packageVersion = $Matches[1].Trim() - } + $buildVersion = $sdkVersion + '.' + $Env:GITHUB_RUN_NUMBER - $assemblyBase = ($packageVersion -split '-')[0].Trim() - $buildVersion = $assemblyBase + '.' + $Env:GITHUB_RUN_NUMBER - - echo "sdk_version=${assemblyBase}" >> $Env:GITHUB_OUTPUT + echo "SDK_VERSION=${sdkVersion}" >> $Env:GITHUB_ENV + echo "PACKAGE_VERSION=${packageVersion}" >> $Env:GITHUB_ENV + echo "BUILD_VERSION=${buildVersion}" >> $Env:GITHUB_ENV echo "package_version=${packageVersion}" >> $Env:GITHUB_OUTPUT - echo "build_version=${buildVersion}" >> $Env:GITHUB_OUTPUT shell: powershell - keeper_sdk: - runs-on: windows-latest - needs: configure - env: - SDK_VERSION: ${{ needs.configure.outputs.sdk_version }} - PACKAGE_VERSION: ${{ needs.configure.outputs.package_version }} - BUILD_VERSION: ${{ needs.configure.outputs.build_version }} - - steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.0.x' + dotnet-version: '8.0.x' - name: Setup Code Sign Cert shell: bash @@ -78,6 +53,7 @@ jobs: - name: Set variables shell: bash + id: variables run: | echo "SM_HOST=${{ secrets.SM_HOST }}" >> "$GITHUB_ENV" echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> "$GITHUB_ENV" @@ -100,7 +76,8 @@ jobs: smctl windows certsync --keypair-alias=%KEYPAIR_ALIAS% - name: Restore solution - run: dotnet restore KeeperSdk.sln + run: | + dotnet restore KeeperSdk.sln shell: powershell - name: Build Keeper SDK Nuget package @@ -111,12 +88,15 @@ jobs: & 'C:/Program Files (x86)/Windows Kits/10/bin/10.0.26100.0/x64/signtool.exe' sign /debug /v /sha1 ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }} /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 /d ".NET Keeper SDK" "bin\Release\netstandard2.0\KeeperSdk.dll" & 'C:/Program Files (x86)/Windows Kits/10/bin/10.0.26100.0/x64/signtool.exe' sign /debug /v /sha1 ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }} /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 /d ".NET Keeper SDK" "bin\Release\net8.0\KeeperSdk.dll" dotnet pack --no-build --no-restore --no-dependencies /P:Configuration=Release /P:Version=${Env:PACKAGE_VERSION} /P:IncludeSymbols=true /P:SymbolPackageFormat=snupkg - shell: powershell + # $cert = Get-Item "Cert:\CurrentUser\My\${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}" + # $sha256 = $cert.GetCertHashString("SHA256") + # dotnet nuget sign --certificate-fingerprint $sha256 --timestamper http://timestamp.digicert.com "bin/Release/Keeper.Sdk.${{ env.PACKAGE_VERSION }}.nupkg" + shell: powershell - name: Store SDK Nuget artifacts uses: actions/upload-artifact@v4 with: - name: KeeperSdk-Nuget-${{ env.PACKAGE_VERSION }} + name: KeeperSdk-Nuget-Package path: | KeeperSdk/bin/Release/Keeper.Sdk.${{ env.PACKAGE_VERSION }}.nupkg KeeperSdk/bin/Release/Keeper.Sdk.${{ env.PACKAGE_VERSION }}.snupkg @@ -141,6 +121,51 @@ jobs: dotnet pack --no-build --no-restore --no-dependencies --configuration=Release /P:IncludeSymbols=true /P:SymbolPackageFormat=snupkg shell: powershell + - name: Build .Net Commander + working-directory: ./Commander + run: | + if (Test-Path bin) { Remove-Item -Force -Recurse bin } + dotnet build --configuration=Release + & 'C:/Program Files (x86)/Windows Kits/10/bin/10.0.26100.0/x64/signtool.exe' sign /debug /v /sha1 ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }} /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 /d ".NET Keeper SDK" "bin\Release\net472\Commander.exe" + & 'C:/Program Files (x86)/Windows Kits/10/bin/10.0.26100.0/x64/signtool.exe' sign /debug /v /sha1 ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }} /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 /d ".NET Keeper SDK" "bin\Release\net8.0\Commander.dll" + shell: powershell + + - name: Zip .Net Framework Commander + working-directory: "./Commander/bin/Release/net472" + run: | + $params = @{ + Path = "*.exe", "*.dll", "Commander.exe.config" + CompressionLevel = "Fastest" + DestinationPath = "Commander-win-${Env:PACKAGE_VERSION}.zip" + } + Compress-Archive @params + shell: powershell + + - name: Zip .Net 8.0 Commander + working-directory: "./Commander/bin/Release/net8.0" + run: | + $params = @{ + Path = "*.dll", "Commander.dll.config", "Commander.deps.json", "runtimes/", "Commander.runtimeconfig.json" + CompressionLevel = "Fastest" + DestinationPath = "Commander-net-${Env:PACKAGE_VERSION}.zip" + } + Compress-Archive @params + shell: powershell + + - name: Store Commander artifacts + uses: actions/upload-artifact@v4 + with: + name: Commander-win-${{ env.PACKAGE_VERSION }} + path: Commander/bin/Release/net472/Commander-win-${{ env.PACKAGE_VERSION }}.zip + retention-days: 1 + + - name: Store Commander artifacts + uses: actions/upload-artifact@v4 + with: + name: Commander-net-${{ env.PACKAGE_VERSION }} + path: Commander/bin/Release/net8.0/Commander-net-${{ env.PACKAGE_VERSION }}.zip + retention-days: 1 + - name: Store Cli artifacts if: ${{ inputs.cli }} uses: actions/upload-artifact@v4 @@ -162,22 +187,21 @@ jobs: retention-days: 1 publish: - if: ${{ inputs.publish_to_nuget }} runs-on: windows-latest - needs: [configure, keeper_sdk] + needs: build environment: prod env: - PACKAGE_VERSION: ${{ needs.configure.outputs.package_version }} - + PACKAGE_VERSION: ${{ needs.build.outputs.package-version }} + steps: - name: Download Nuget package uses: actions/download-artifact@v4 with: - name: KeeperSdk-Nuget-${{ needs.configure.outputs.package_version }} + name: KeeperSdk-Nuget-Package path: nuget - + - name: Publish to Nuget repo working-directory: nuget run: | - dotnet nuget push Keeper.Sdk.${{ env.PACKAGE_VERSION }}.nupkg -k "${{ secrets.NUGET_PUBLISH_KEY }}" -s https://api.nuget.org/v3/index.json - shell: powershell + dotnet nuget push Keeper.Sdk.${{ env.PACKAGE_VERSION }}.nupkg -k "${{ secrets.NUGET_PUBLISH_KEY }}" -s https://api.nuget.org/v3/index.json + shell: powershell \ No newline at end of file diff --git a/.github/workflows/power-commander.yml b/.github/workflows/power-commander.yml index e1206ec5..a36d32b3 100644 --- a/.github/workflows/power-commander.yml +++ b/.github/workflows/power-commander.yml @@ -1,60 +1,32 @@ -name: PowerCommander +name: Publish PowerCommander -on: - workflow_dispatch: - inputs: - publish_to_gallery: - description: Publish signed module to PowerShell Gallery - type: boolean - required: false - default: false +on: [workflow_dispatch] jobs: - configure: + build: runs-on: windows-latest - outputs: - package_version: ${{ steps.vars.outputs.package_version }} - version: ${{ steps.vars.outputs.version }} - steps: - - uses: actions/checkout@v4 - - - name: Read version from PowerCommander.psd1 - id: vars - run: | - $m = Import-PowerShellDataFile -Path "PowerCommander/PowerCommander.psd1" - $v = [string]$m.ModuleVersion - echo "package_version=$v" >> $Env:GITHUB_OUTPUT - echo "version=$v" >> $Env:GITHUB_OUTPUT - shell: powershell - - build_module: - runs-on: windows-latest - needs: configure - env: - PACKAGE_VERSION: ${{ needs.configure.outputs.package_version }} + environment: prod steps: - - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '8.0.x' + - uses: actions/checkout@v2 - - name: Setup Code Sign Cert + - name: Set up certificate shell: bash run: | echo "${{ secrets.SM_CLIENT_CERT_FILE_B64 }}" | base64 --decode > /d/Certificate_pkcs12.p12 - name: Set variables shell: bash + id: variables run: | - echo "SM_HOST=${{ secrets.SM_HOST }}" >> "$GITHUB_ENV" - echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> "$GITHUB_ENV" - echo "SM_CLIENT_CERT_FILE=D:/Certificate_pkcs12.p12" >> "$GITHUB_ENV" - echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> "$GITHUB_ENV" - echo "SM_CODE_SIGNING_CERT_SHA1_HASH=${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}" >> "$GITHUB_ENV" - echo "C:\Program Files (x86)\Windows Kits\10\App Certification Kit" >> $GITHUB_PATH - echo "C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools" >> $GITHUB_PATH - echo "C:\Program Files\DigiCert\DigiCert One Signing Manager Tools" >> $GITHUB_PATH + echo "::set-output name=version::${GITHUB_REF#refs/tags/v}" + echo "SM_HOST=${{ secrets.SM_HOST }}" >> "$GITHUB_ENV" + echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> "$GITHUB_ENV" + echo "SM_CLIENT_CERT_FILE=D:/Certificate_pkcs12.p12" >> "$GITHUB_ENV" + echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> "$GITHUB_ENV" + echo "C:\Program Files (x86)\Windows Kits\10\App Certification Kit" >> $GITHUB_PATH + echo "C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools" >> $GITHUB_PATH + echo "C:\Program Files\DigiCert\DigiCert One Signing Manager Tools" >> $GITHUB_PATH - name: Setup DigiCert SSM Tools uses: digicert/ssm-code-signing@b300bb7e8c2ab85257d660fe5b6c6374131ca2ef @@ -67,140 +39,17 @@ jobs: smctl healthcheck smctl windows certsync --keypair-alias=%KEYPAIR_ALIAS% - - name: Restore solution - run: dotnet restore KeeperSdk.sln - shell: powershell - - - name: Build .Net Commander - working-directory: ./Commander - run: | - if (Test-Path bin) { Remove-Item -Force -Recurse bin } - dotnet build --configuration=Release - & 'C:/Program Files (x86)/Windows Kits/10/bin/10.0.26100.0/x64/signtool.exe' sign /debug /v /sha1 ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }} /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 /d ".NET Keeper SDK" "bin\Release\net472\Commander.exe" - & 'C:/Program Files (x86)/Windows Kits/10/bin/10.0.26100.0/x64/signtool.exe' sign /debug /v /sha1 ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }} /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 /d ".NET Keeper SDK" "bin\Release\net8.0\Commander.dll" - shell: powershell - - - name: Stage PowerCommander runtime from Commander net8.0 - shell: powershell - run: | - $out = Resolve-Path "Commander/bin/Release/net8.0" - $stage = Join-Path $PWD "artifact/PowerCommander-runtime" - $su = Join-Path $stage "StorageUtils" - New-Item -ItemType Directory -Path $su -Force | Out-Null - Copy-Item (Join-Path $out "KeeperSdk.dll") $stage -Force - foreach ($f in @('Microsoft.Data.Sqlite.dll', 'SQLitePCLRaw.batteries_v2.dll', 'SQLitePCLRaw.core.dll', 'SQLitePCLRaw.provider.e_sqlite3.dll')) { - $src = Join-Path $out $f - if (-not (Test-Path -LiteralPath $src)) { throw "Missing $f under $out" } - Copy-Item -LiteralPath $src $su -Force - } - $native = @( - (Join-Path $out 'e_sqlite3.dll'), - (Join-Path $out 'runtimes/win-x64/native/e_sqlite3.dll') - ) | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 - if (-not $native) { throw "e_sqlite3.dll not found under Commander net8.0 output: $out" } - Copy-Item -LiteralPath $native (Join-Path $su 'e_sqlite3.dll') -Force - - - name: Store PowerCommander runtime artifact - uses: actions/upload-artifact@v4 - with: - name: PowerCommander-runtime-from-Commander - path: artifact/PowerCommander-runtime/ - retention-days: 1 - - - name: Zip .Net Framework Commander - working-directory: "./Commander/bin/Release/net472" - run: | - $params = @{ - Path = "*.exe", "*.dll", "Commander.exe.config" - CompressionLevel = "Fastest" - DestinationPath = "Commander-win-${Env:PACKAGE_VERSION}.zip" - } - Compress-Archive @params - shell: powershell - - - name: Zip .Net 8.0 Commander - working-directory: "./Commander/bin/Release/net8.0" - run: | - $params = @{ - Path = "*.dll", "Commander.dll.config", "Commander.deps.json", "runtimes/", "Commander.runtimeconfig.json" - CompressionLevel = "Fastest" - DestinationPath = "Commander-net-${Env:PACKAGE_VERSION}.zip" - } - Compress-Archive @params - shell: powershell - - - name: Store Commander artifacts - uses: actions/upload-artifact@v4 - with: - name: Commander-win-${{ env.PACKAGE_VERSION }} - path: Commander/bin/Release/net472/Commander-win-${{ env.PACKAGE_VERSION }}.zip - retention-days: 1 - - - name: Store Commander artifacts - uses: actions/upload-artifact@v4 - with: - name: Commander-net-${{ env.PACKAGE_VERSION }} - path: Commander/bin/Release/net8.0/Commander-net-${{ env.PACKAGE_VERSION }}.zip - retention-days: 1 - - - name: Copy Commander net8.0 outputs into PowerCommander module folder - shell: powershell - run: | - $out = Resolve-Path "Commander/bin/Release/net8.0" - $pc = Resolve-Path "PowerCommander" - Copy-Item (Join-Path $out "KeeperSdk.dll") $pc -Force - $to = Join-Path $pc "StorageUtils" - if (-not (Test-Path -LiteralPath $to)) { New-Item -ItemType Directory -Path $to -Force | Out-Null } - foreach ($f in @('Microsoft.Data.Sqlite.dll', 'SQLitePCLRaw.batteries_v2.dll', 'SQLitePCLRaw.core.dll', 'SQLitePCLRaw.provider.e_sqlite3.dll')) { - $src = Join-Path $out $f - Copy-Item -LiteralPath $src $to -Force - } - $native = @( - (Join-Path $out 'e_sqlite3.dll'), - (Join-Path $out 'runtimes/win-x64/native/e_sqlite3.dll') - ) | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 - Copy-Item -LiteralPath $native (Join-Path $to 'e_sqlite3.dll') -Force - - - name: Sign KeeperSdk.dll - run: | - & 'C:/Program Files (x86)/Windows Kits/10/bin/10.0.26100.0/x64/signtool.exe' sign /debug /v /sha1 ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }} /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 /d ".NET Keeper SDK" "PowerCommander\KeeperSdk.dll" - shell: powershell - - name: Sign PowerShell scripts working-directory: ./PowerCommander run: | $cert = Get-ChildItem -Path Cert:\CurrentUser\My\${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }} -CodeSigningCert Set-AuthenticodeSignature -FilePath *.ps1 -Certificate $cert -HashAlgorithm SHA256 -TimestampServer "http://timestamp.digicert.com" - $root = (Get-Location).Path - Get-ChildItem -Path . -Recurse -Filter *.ps1 -File | Where-Object { $_.DirectoryName -ne $root } | ForEach-Object { - Set-AuthenticodeSignature -FilePath $_.FullName -Certificate $cert -HashAlgorithm SHA256 -TimestampServer "http://timestamp.digicert.com" - } Set-AuthenticodeSignature -FilePath *.ps1xml -Certificate $cert -HashAlgorithm SHA256 -TimestampServer "http://timestamp.digicert.com" Set-AuthenticodeSignature -FilePath PowerCommander.psd1 -Certificate $cert -HashAlgorithm SHA256 -TimestampServer "http://timestamp.digicert.com" Set-AuthenticodeSignature -FilePath PowerCommander.psm1 -Certificate $cert -HashAlgorithm SHA256 -TimestampServer "http://timestamp.digicert.com" - shell: powershell - - - name: Upload signed PowerCommander module (workflow run → Artifacts) - uses: actions/upload-artifact@v4 - with: - name: PowerCommander-module-${{ needs.configure.outputs.package_version }} - path: PowerCommander/ - retention-days: 1 - - publish_gallery: - if: ${{ inputs.publish_to_gallery }} - runs-on: windows-latest - needs: [configure, build_module] - environment: prod - - steps: - - name: Download signed PowerCommander module - uses: actions/download-artifact@v4 - with: - name: PowerCommander-module-${{ needs.configure.outputs.package_version }} - path: PowerCommander + shell: powershell - name: Publish to PowerShell Gallery run: | Publish-Module -Path .\PowerCommander\ -NuGetApiKey "${{ secrets.POWERSHELL_PUBLISH_KEY }}" - shell: powershell + shell: powershell \ No newline at end of file From 78c3aaf347c7b07ac6641e6aaa6594c49a276d82 Mon Sep 17 00:00:00 2001 From: sgaddala-ks Date: Fri, 17 Apr 2026 10:51:21 +0530 Subject: [PATCH 3/3] removed stale reference from keepersdk solution file --- KeeperSdk.sln | 1 - 1 file changed, 1 deletion(-) diff --git a/KeeperSdk.sln b/KeeperSdk.sln index 6afdbc1d..2f9c9c64 100644 --- a/KeeperSdk.sln +++ b/KeeperSdk.sln @@ -20,7 +20,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution .gitignore = .gitignore .github\workflows\build.yml = .github\workflows\build.yml .github\workflows\power-commander.yml = .github\workflows\power-commander.yml - .github\workflows\power-commander-storage-utils.yml = .github\workflows\power-commander-storage-utils.yml README.md = README.md EndProjectSection EndProject