diff --git a/.editorconfig b/.editorconfig
index 558648d..62f065a 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -2,4 +2,5 @@
end_of_line=lf
[*.cs]
csharp_indent_case_contents=true
-csharp_space_after_cast=true
\ No newline at end of file
+csharp_space_after_cast=true
+csharp_indent_case_contents_when_block=false
\ No newline at end of file
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index d64f784..8e0f3b5 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -5,13 +5,21 @@ on:
# branches:
# - 'release_*'
workflow_dispatch:
+ inputs:
+ sqlite_storage:
+ description: Build SQLite Vault storage
+ type: boolean
+ required: false
+ default: false
+ cli:
+ description: Build CLI package
+ type: boolean
+ required: false
+ default: false
-
jobs:
-
build:
runs-on: windows-latest
-
steps:
- name: Setup product versions
run: |
@@ -31,61 +39,105 @@ jobs:
echo "BUILD_VERSION=${buildVersion}" >> $Env:GITHUB_ENV
shell: powershell
- - uses: actions/checkout@v2
- - uses: actions/setup-dotnet@v1
+ - uses: actions/checkout@v4
+ - uses: actions/setup-dotnet@v4
with:
- dotnet-version: 5.0.x
- - uses: microsoft/setup-msbuild@v1.0.2
+ dotnet-version: '8.0.x'
+ - uses: microsoft/setup-msbuild@v2
- - uses: nuget/setup-nuget@v1
+ - uses: nuget/setup-nuget@v2
- run: nuget restore KeeperSdk.sln
+
+ - name: Load signing certificate
+ run: |
+ if (Test-Path -Path certificate.txt) { Remove-Item certificate.txt }
+ if (Test-Path -Path certificate.pfx) { Remove-Item certificate.pfx }
+ Set-Content -Path certificate.txt -Value '${{ secrets.PFX_CERT }}'
+ certutil -decode certificate.txt certificate.pfx
+ Remove-Item certificate.txt
+ shell: powershell
+
- name: Build Keeper SDK Nuget package
working-directory: ./KeeperSdk
run: |
if (Test-Path bin) { Remove-Item -Force -Recurse bin }
- dotnet restore /P:Configuration=Release
- dotnet clean /P:Configuration=Release
- dotnet build /P:Configuration=Release /P:Version=${Env:SDK_VERSION} /P:AssemblyVersion=${Env:BUILD_VERSION} /P:FileVersion=${Env:BUILD_VERSION}
- echo "TODO binaries signing"
- dotnet pack --no-build --no-restore /P:Configuration=Release /P:Version=${Env:PACKAGE_VERSION} /P:IncludeSymbols=true /P:SymbolPackageFormat=snupkg
+ dotnet build /P:Configuration=Release /P:Version=${Env:PACKAGE_VERSION} /P:AssemblyVersion=${Env:BUILD_VERSION} /P:FileVersion=${Env:BUILD_VERSION}
+ & 'C:/Program Files (x86)/Windows Kits/10/bin/10.0.17763.0/x86/signtool.exe' sign /f ..\certificate.pfx /t "http://timestamp.digicert.com" /v /p "${{ secrets.PFX_PASS }}" /d ".NET Keeper SDK" "bin\Release\net452\KeeperSdk.dll"
+ & 'C:/Program Files (x86)/Windows Kits/10/bin/10.0.17763.0/x86/signtool.exe' sign /f ..\certificate.pfx /t "http://timestamp.digicert.com" /v /p "${{ secrets.PFX_PASS }}" /d ".NET Keeper SDK" "bin\Release\netstandard2.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
- - name: Build Security Key library for Windows
- working-directory: ./WinWebAuthn
+ - name: Build SQLite Vault Storage
+ working-directory: ./OfflineStorageSqlite
run: |
if (Test-Path bin) { Remove-Item -Force -Recurse bin }
- msbuild /T:Restore,Clean /P:Configuration=Release
- msbuild /T:Build /P:Configuration=Release /P:Version=${Env:SDK_VERSION} /P:AssemblyVersion=${Env:BUILD_VERSION} /P:FileVersion=${Env:BUILD_VERSION}
+ dotnet build --configuration=Release --no-dependencies
+ & 'C:/Program Files (x86)/Windows Kits/10/bin/10.0.17763.0/x86/signtool.exe' sign /f ..\certificate.pfx /t "http://timestamp.digicert.com" /v /p "${{ secrets.PFX_PASS }}" /d ".NET Keeper SDK Offline SQLite Storage" "bin\Release\\netstandard2.0\OfflineStorageSqlite.dll"
+ dotnet pack --no-build --no-restore --configuration=Release /P:IncludeSymbols=true /P:SymbolPackageFormat=snupkg
+ shell: powershell
- echo "TODO binaries signing"
+ - name: Build CLI library
+ working-directory: ./Cli
+ run: |
+ if (Test-Path bin) { Remove-Item -Force -Recurse bin }
+ dotnet build --configuration=Release --no-dependencies
+ & 'C:/Program Files (x86)/Windows Kits/10/bin/10.0.17763.0/x86/signtool.exe' sign /f ..\certificate.pfx /t "http://timestamp.digicert.com" /v /p "${{ secrets.PFX_PASS }}" /d ".NET Keeper SDK" "bin\Release\net472\Cli.dll"
+ & 'C:/Program Files (x86)/Windows Kits/10/bin/10.0.17763.0/x86/signtool.exe' sign /f ..\certificate.pfx /t "http://timestamp.digicert.com" /v /p "${{ secrets.PFX_PASS }}" /d ".NET Keeper SDK" "bin\Release\netstandard2.0\Cli.dll"
+ 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 }
- msbuild /T:Restore,Clean /P:Configuration=Release
- msbuild /T:Build /P:Configuration=Release /P:Version=${Env:SDK_VERSION} /P:AssemblyVersion=${Env:BUILD_VERSION} /P:FileVersion=${Env:BUILD_VERSION}
-
- echo "TODO binaries signing"
+ msbuild /T:Restore /P:Configuration=Release
+ msbuild /T:Build /P:Configuration=Release /p:BuildProjectReferences=false
+ & 'C:/Program Files (x86)/Windows Kits/10/bin/10.0.17763.0/x86/signtool.exe' sign /f ..\certificate.pfx /t "http://timestamp.digicert.com" /v /p "${{ secrets.PFX_PASS }}" /d ".NET Keeper SDK" "bin\Release\Commander.exe"
shell: powershell
+ - name: Zip Commander
+ run: |
+ $params = @{
+ Path = "Commander/bin/Release/*.exe", "Commander/bin/Release/Commander.exe.config", "Commander/bin/Release/*.dll", "OfflineStorageSqlite/bin/Release/netstandard2.0/OfflineStorageSqlite.dll"
+ CompressionLevel = "Fastest"
+ DestinationPath = "Commander-${Env:PACKAGE_VERSION}.zip"
+ }
+ Compress-Archive @params
+ shell: powershell
+
- name: Store SDK Nuget artifacts
- uses: actions/upload-artifact@v2
+ uses: actions/upload-artifact@v4
with:
name: KeeperSdk-${{ env.PACKAGE_VERSION }}-Nuget-Package
path: |
KeeperSdk/bin/Release/Keeper.Sdk.${{ env.PACKAGE_VERSION }}.nupkg
KeeperSdk/bin/Release/Keeper.Sdk.${{ env.PACKAGE_VERSION }}.snupkg
+ retention-days: 1
- name: Store Commander artifacts
- uses: actions/upload-artifact@v2
+ uses: actions/upload-artifact@v4
with:
name: Commander-${{ env.PACKAGE_VERSION }}
+ path: Commander-${{ env.PACKAGE_VERSION }}.zip
+ retention-days: 1
+
+ - name: Store SQLite Offline Storage artifacts
+ if: ${{ inputs.sqlite_storage }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: OfflineStorageSqlite
+ path: |
+ OfflineStorageSqlite/bin/Release/Keeper.Storage.Sqlite.*.nupkg
+ OfflineStorageSqlite/bin/Release/Keeper.Storage.Sqlite.*.snupkg
+ retention-days: 1
+
+ - name: Store artifacts
+ if: ${{ inputs.cli }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: Cli
path: |
- Commander/bin/Release/Commander.exe
- Commander/bin/Release/Commander.exe.config
- Commander/bin/Release/CommandLine.dll
- WinWebAuthn/bin/Release/WinWebAuthn.dll
- KeeperSdk/bin/Release/net45/*.dll
+ Cli/bin/Release/Keeper.Cli.*.nupkg
+ Cli/bin/Release/Keeper.Cli.*.snupkg
+ retention-days: 1
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 1fd26e4..76dc983 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -11,15 +11,14 @@
#
name: "Code Analysis"
-on:
-# push:
-# branches: [code_analysis]
- workflow_dispatch:
-
+on:
+ schedule:
+ - cron: '17 23 * * 3'
+
jobs:
analyze:
name: Analyze
- runs-on: ubuntu-latest
+ runs-on: windows-latest
strategy:
fail-fast: false
@@ -35,7 +34,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize Code Analysis
- uses: github/codeql-action/init@v1
+ uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -46,7 +45,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
- uses: github/codeql-action/autobuild@v1
+ uses: github/codeql-action/autobuild@v2
# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -60,4 +59,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v1
+ uses: github/codeql-action/analyze@v2
diff --git a/.github/workflows/power-commander.yml b/.github/workflows/power-commander.yml
new file mode 100644
index 0000000..f6e4aee
--- /dev/null
+++ b/.github/workflows/power-commander.yml
@@ -0,0 +1,37 @@
+name: Publish PowerCommander
+
+on: [workflow_dispatch]
+
+jobs:
+ build:
+ runs-on: windows-latest
+ environment: prod
+
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: Load signing certificate
+ run: |
+ if (Test-Path -Path certificate.txt) { Remove-Item certificate.txt }
+ if (Test-Path -Path certificate.pfx) { Remove-Item certificate.pfx }
+ Set-Content -Path certificate.txt -Value '${{ secrets.PFX_CERT }}'
+ certutil -decode certificate.txt certificate.pfx
+ Remove-Item certificate.txt
+ shell: powershell
+
+ - name: Sign PowerShell scripts
+ working-directory: ./PowerCommander
+ run: |
+ $certPassword = ConvertTo-SecureString -String "${{ secrets.PFX_PASS }}" -AsPlainText -Force
+ $certData = Get-PfxData -FilePath "..\certificate.pfx" -Password $certPassword
+ $cert = $certData.EndEntityCertificates[0]
+ Set-AuthenticodeSignature -FilePath *.ps1 -Certificate $cert
+ Set-AuthenticodeSignature -FilePath *.ps1xml -Certificate $cert
+ Set-AuthenticodeSignature -FilePath PowerCommander.psd1 -Certificate $cert
+ Set-AuthenticodeSignature -FilePath PowerCommander.psm1 -Certificate $cert
+ shell: powershell
+
+ - name: Publish to PowerShell Gallery
+ run: |
+ Publish-Module -Path .\PowerCommander\ -NuGetApiKey "${{ secrets.POWERSHELL_PUBLISH_KEY }}"
+ shell: powershell
diff --git a/.gitignore b/.gitignore
index cae5e40..afa517d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -48,4 +48,7 @@ project.lock.json
UpgradeLog.htm
nuget.config
-Help/
\ No newline at end of file
+Help/
+.vscode/
+
+packages.config
\ No newline at end of file
diff --git a/Cli/Cli.csproj b/Cli/Cli.csproj
new file mode 100644
index 0000000..09a061c
--- /dev/null
+++ b/Cli/Cli.csproj
@@ -0,0 +1,33 @@
+
+
+
+ netstandard2.0;net472
+ Cli
+ 1.0.2
+ Keeper.Cli
+ 1.0.2.3
+ 1.0.2.3
+ https://github.com/Keeper-Security/keeper-sdk-dotnet
+ https://github.com/Keeper-Security/keeper-sdk-dotnet/Cli
+ Keeper Security Inc.
+ Cli for .NET Keeper Sdk
+ MIT
+ Github
+ en-US
+ true
+ snupkg
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Cli/Commands.cs b/Cli/Commands.cs
new file mode 100644
index 0000000..fe0b98d
--- /dev/null
+++ b/Cli/Commands.cs
@@ -0,0 +1,456 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using CommandLine;
+using CommandLine.Text;
+
+namespace Cli
+{
+ public class CommandMeta
+ {
+ public int Order { get; set; }
+ public string Description { get; set; }
+ }
+
+ public interface ICommand
+ {
+ Task ExecuteCommand(string args);
+ }
+
+ public interface ICancellableCommand
+ {
+ Task ExecuteCommand(string args, CancellationToken token);
+ }
+
+ public class SimpleCommand : CommandMeta, ICommand
+ {
+ public Func Action { get; set; }
+
+ public async Task ExecuteCommand(string args)
+ {
+ if (Action != null)
+ {
+ await Action(args);
+ }
+ }
+ }
+
+ public class SimpleCancellableCommand : CommandMeta, ICancellableCommand
+ {
+ public Func Action { get; set; }
+
+ public async Task ExecuteCommand(string args, CancellationToken token)
+ {
+ if (Action != null)
+ {
+ await Action(args, token);
+ }
+ }
+ }
+
+
+ public static class CommandExtensions
+ {
+ public static bool IsWhiteSpace(char ch)
+ {
+ return char.IsWhiteSpace(ch);
+ }
+
+ public static bool IsPathDelimiter(char ch)
+ {
+ return ch == '/';
+ }
+
+ public static IEnumerable TokenizeArguments(this string args)
+ {
+ return TokenizeArguments(args, IsWhiteSpace);
+ }
+
+ public static IEnumerable TokenizeArguments(this string args, Func isDelimiter)
+ {
+ var sb = new StringBuilder();
+ var pos = 0;
+ var isQuote = false;
+ var isEscape = false;
+ while (pos < args.Length)
+ {
+ var ch = args[pos];
+
+ if (isEscape)
+ {
+ isEscape = false;
+ sb.Append(ch);
+ }
+ else
+ {
+ switch (ch)
+ {
+ case '\\':
+ isEscape = true;
+ break;
+ case '"':
+ isQuote = !isQuote;
+ break;
+ default:
+ {
+ if (!isQuote && isDelimiter(ch))
+ {
+ if (sb.Length > 0)
+ {
+ yield return sb.ToString();
+ sb.Length = 0;
+ }
+ }
+ else
+ {
+ sb.Append(ch);
+ }
+
+ break;
+ }
+ }
+ }
+
+ pos++;
+ }
+
+ if (sb.Length > 0)
+ {
+ yield return sb.ToString();
+ }
+ }
+ public static string GetCommandUsage(int width = 120)
+ {
+ var parser = new Parser(with => with.HelpWriter = null);
+ var result = parser.ParseArguments(new[] { "--help" });
+ return HelpText.AutoBuild(result, h =>
+ {
+ h.AdditionalNewLineAfterOption = false;
+ return h;
+ }, width);
+ }
+ }
+
+ public class ParseableCommandMeta : CommandMeta where T : class
+ {
+ protected T ParseArguments(string args)
+ {
+ var res = Parser.Default.ParseArguments(args.TokenizeArguments());
+ T options = null;
+ res.WithParsed(o => { options = o; });
+ return options;
+ }
+ }
+
+ public class ParseableCommand : ParseableCommandMeta, ICommand where T : class
+ {
+ public Func Action { get; set; }
+
+ public Task ExecuteCommand(string args)
+ {
+ var options = ParseArguments(args);
+ return options != null ? Action?.Invoke(options) : Task.CompletedTask;
+ }
+ }
+
+ public class ParseableCancellableCommand : ParseableCommandMeta, ICancellableCommand where T : class
+ {
+ public Func Action { get; set; }
+
+ public Task ExecuteCommand(string args, CancellationToken token)
+ {
+ return Action?.Invoke(ParseArguments(args), token);
+ }
+ }
+
+ public class CliCommands
+ {
+ public IDictionary Commands { get; } = new Dictionary();
+ public IDictionary CommandAliases { get; } = new Dictionary();
+
+ public static bool ParseBoolOption(string text, out bool value)
+ {
+ if (string.Compare(text, "on", StringComparison.InvariantCultureIgnoreCase) == 0)
+ {
+ value = true;
+ return true;
+ }
+ if (string.Compare(text, "off", StringComparison.InvariantCultureIgnoreCase) == 0)
+ {
+ value = false;
+ return true;
+ }
+
+ value = false;
+ return false;
+ }
+
+ }
+
+ public sealed class MainLoop
+ {
+ private readonly CommandMeta _exitCommand;
+ private readonly CommandMeta _clearCommand;
+ private readonly CommandMeta _quitCommand;
+
+ public MainLoop()
+ {
+ _exitCommand = new SimpleCommand
+ {
+ Order = 1000,
+ Description = "Exit",
+ Action = (args) =>
+ {
+ if (StateContext.BackStateCommands != null)
+ {
+ var oldContext = StateContext;
+ StateContext = oldContext.BackStateCommands;
+ oldContext.Dispose();
+ }
+
+ return Task.FromResult(true);
+ }
+ };
+
+ _clearCommand = new SimpleCommand
+ {
+ Order = 1001,
+ Description = "Clears the screen",
+ Action = args =>
+ {
+ Console.Clear();
+ return Task.FromResult(true);
+ }
+ };
+
+ _quitCommand = new SimpleCommand
+ {
+ Order = 1002,
+ Description = "Quit",
+ Action = (args) =>
+ {
+ Finished = true;
+ StateContext = null;
+ Environment.Exit(0);
+ return Task.FromResult(true);
+ }
+ };
+ }
+
+ public StateCommands StateContext { get; set; }
+ public bool Finished { get; set; }
+ public Queue CommandQueue { get; } = new Queue();
+
+ public async Task Run(InputManager inputManager)
+ {
+ CommandMeta runningCommand = null;
+ CancellationTokenSource tokenSource = null;
+
+ inputManager.CancelKeyPress += (sender, e) =>
+ {
+ e.Cancel = false;
+ if (runningCommand != null)
+ {
+ if (runningCommand is ICancellableCommand && tokenSource != null)
+ {
+ tokenSource.Cancel();
+ }
+ else
+ {
+ e.Cancel = true;
+ }
+ }
+ };
+ while (!Finished)
+ {
+ if (StateContext == null) break;
+ if (StateContext.NextStateCommands != null)
+ {
+ if (!ReferenceEquals(StateContext, StateContext.NextStateCommands))
+ {
+ var oldContext = StateContext;
+ StateContext = oldContext.NextStateCommands;
+ oldContext.NextStateCommands = null;
+ var contexts = StateContext;
+ while (contexts != null)
+ {
+ if (ReferenceEquals(contexts, oldContext))
+ {
+ break;
+ }
+
+ contexts = contexts.BackStateCommands;
+ }
+
+ if (contexts == null)
+ {
+ oldContext.Dispose();
+ }
+ }
+ else
+ {
+ StateContext.NextStateCommands = null;
+ }
+
+ inputManager.ClearHistory();
+ }
+
+ string command;
+ if (CommandQueue.Count > 0)
+ {
+ command = CommandQueue.Dequeue();
+ }
+ else
+ {
+ Console.Write(StateContext.GetPrompt() + "> ");
+ try
+ {
+ command = await inputManager.ReadLine(new ReadLineParameters
+ {
+ IsHistory = true
+ });
+ }
+ catch (KeyboardInterrupt)
+ {
+ command = "";
+ }
+ }
+
+ if (string.IsNullOrEmpty(command)) continue;
+
+ command = command.Trim();
+ var parameter = "";
+ var pos = command.IndexOf(' ');
+ if (pos > 1)
+ {
+ parameter = command.Substring(pos + 1).Trim();
+ command = command.Substring(0, pos).Trim();
+ }
+
+ command = command.ToLowerInvariant();
+ switch (command)
+ {
+ case "exit":
+ runningCommand = _exitCommand;
+ break;
+ case "clear":
+ case "c":
+ runningCommand = _clearCommand;
+ break;
+ case "quit":
+ case "q":
+ runningCommand = _quitCommand;
+ break;
+ default:
+ if (StateContext.CommandAliases.TryGetValue(command, out var fullCommand))
+ {
+ command = fullCommand;
+ }
+
+ StateContext.Commands.TryGetValue(command, out runningCommand);
+ break;
+ }
+
+ if (runningCommand != null)
+ {
+ try
+ {
+ if (runningCommand is ICancellableCommand cc)
+ {
+ tokenSource = new CancellationTokenSource();
+ await cc.ExecuteCommand(parameter, tokenSource.Token);
+ }
+ else if (runningCommand is ICommand c)
+ {
+ await c.ExecuteCommand(parameter);
+ }
+ else
+ {
+ Console.WriteLine("Unsupported command type");
+ }
+ }
+ catch (Exception e)
+ {
+ if (!await StateContext.ProcessException(e))
+ {
+ Console.WriteLine("Error: " + e.Message);
+ }
+ }
+ finally
+ {
+ runningCommand = null;
+ tokenSource?.Dispose();
+ tokenSource = null;
+ }
+ }
+ else
+ {
+ if (command != "?")
+ {
+ Console.WriteLine($"Invalid command: {command}");
+ }
+
+ var tab = new Tabulate(3);
+ tab.AddHeader("Command", "Alias", "Description");
+ foreach (var c in StateContext.Commands
+ .OrderBy(x => x.Value.Order))
+ {
+ var alias = StateContext.CommandAliases
+ .Where(x => x.Value == c.Key)
+ .Select(x => x.Key)
+ .FirstOrDefault();
+ tab.AddRow(c.Key, alias ?? "", c.Value.Description);
+ }
+
+ if (StateContext.BackStateCommands != null)
+ {
+ tab.AddRow("exit", "", _exitCommand.Description);
+ }
+
+ tab.AddRow("clear", "c", _clearCommand.Description);
+ tab.AddRow("quit", "q", _quitCommand.Description);
+
+ tab.DumpRowNo = false;
+ tab.LeftPadding = 1;
+ tab.MaxColumnWidth = 60;
+ tab.Dump();
+ }
+
+ Console.WriteLine();
+ }
+ }
+
+ }
+
+ public abstract class StateCommands : CliCommands, IDisposable
+ {
+ public abstract string GetPrompt();
+
+ public virtual Task ProcessException(Exception e)
+ {
+ return Task.FromResult(false);
+ }
+
+ public StateCommands NextStateCommands { get; set; }
+
+ public StateCommands BackStateCommands { get; set; }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ NextStateCommands = null;
+ BackStateCommands = null;
+ }
+ }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+ }
+}
diff --git a/KeeperSdk/utils/ConsoleAuthUi.cs b/Cli/ConsoleAuthUi.cs
similarity index 68%
rename from KeeperSdk/utils/ConsoleAuthUi.cs
rename to Cli/ConsoleAuthUi.cs
index 41d398e..0ddb7c5 100644
--- a/KeeperSdk/utils/ConsoleAuthUi.cs
+++ b/Cli/ConsoleAuthUi.cs
@@ -3,12 +3,19 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using KeeperSecurity.Authentication;
+using KeeperSecurity.Authentication.Async;
using KeeperSecurity.Utils;
-namespace KeeperSecurity.Authentication.Async
+#if NET472_OR_GREATER
+using System.Windows;
+using System.Text;
+#endif
+
+namespace Cli.Async
{
///
- public class ConsoleAuthUi : IAuthUI, IAuthInfoUI, IHttpProxyCredentialUi
+ public class ConsoleAuthUi : IAuthUI, IAuthInfoUI, IAuthSsoUI
{
protected InputManager InputManager { get; }
@@ -63,56 +70,6 @@ public virtual async Task WaitForUserPassword(IPasswordInfo info, Cancella
return true;
}
- private static string DurationToText(TwoFactorDuration duration)
- {
- switch (duration)
- {
- case TwoFactorDuration.EveryLogin:
- return "never";
- case TwoFactorDuration.Forever:
- return "forever";
- default:
- return $"{(int) duration} days";
- }
- }
-
- private static bool TryParseTextToDuration(string text, out TwoFactorDuration duration)
- {
- text = text.Trim().ToLowerInvariant();
- switch (text)
- {
- case "never":
- duration = TwoFactorDuration.EveryLogin;
- return true;
- case "forever":
- duration = TwoFactorDuration.Forever;
- return true;
- default:
- var idx = text.IndexOf(' ');
- if (idx > 0)
- {
- text = text.Substring(0, idx);
- }
-
- if (int.TryParse(text, out var days))
- {
- foreach (var d in Enum.GetValues(typeof(TwoFactorDuration)).OfType())
- {
- if ((int) d == days)
- {
- duration = d;
- return true;
- }
- }
- }
-
- break;
- }
-
- duration = TwoFactorDuration.EveryLogin;
- return false;
- }
-
public virtual Task WaitForTwoFactorCode(ITwoFactorChannelInfo[] channels, CancellationToken token)
{
var twoFactorTask = new TaskCompletionSource();
@@ -165,7 +122,7 @@ public virtual Task WaitForTwoFactorCode(ITwoFactorChannelInfo[] channels,
var dur = Enum
.GetValues(typeof(TwoFactorDuration))
.OfType()
- .Select(x => $"\"{DurationToText(x)}\"")
+ .Select(x => $"\"{AuthUIExtensions.DurationToText(x)}\"")
.ToArray();
Console.WriteLine("Available durations are: " + string.Join(", ", dur));
@@ -177,7 +134,7 @@ public virtual Task WaitForTwoFactorCode(ITwoFactorChannelInfo[] channels,
{
if (codeChannel != null)
{
- Console.Write($"[{codeChannel.ChannelName ?? ""}] ({DurationToText(codeChannel.Duration)})");
+ Console.Write($"[{codeChannel.ChannelName ?? ""}] ({AuthUIExtensions.DurationToText(codeChannel.Duration)})");
}
Console.Write(" > ");
@@ -211,7 +168,7 @@ public virtual Task WaitForTwoFactorCode(ITwoFactorChannelInfo[] channels,
if (code.StartsWith("2fa="))
{
- if (TryParseTextToDuration(code.Substring(4), out var duration))
+ if (AuthUIExtensions.TryParseTextToDuration(code.Substring(4), out var duration))
{
if (codeChannel != null)
{
@@ -226,8 +183,14 @@ public virtual Task WaitForTwoFactorCode(ITwoFactorChannelInfo[] channels,
{
if (pushChannelInfo.ContainsKey(action))
{
-
- await pushChannelInfo[action].InvokeTwoFactorPushAction(action);
+ try
+ {
+ await pushChannelInfo[action].InvokeTwoFactorPushAction(action);
+ }
+ catch (Exception e)
+ {
+ Console.WriteLine(e.Message);
+ }
}
else
{
@@ -308,7 +271,7 @@ public virtual Task WaitForDeviceApproval(IDeviceApprovalChannelInfo[] cha
var dur = Enum
.GetValues(typeof(TwoFactorDuration))
.OfType()
- .Select(x => $"\"{DurationToText(x)}\"")
+ .Select(x => $"\"{AuthUIExtensions.DurationToText(x)}\"")
.ToArray();
Console.WriteLine("Available durations are: " + string.Join(", ", dur));
@@ -318,7 +281,7 @@ public virtual Task WaitForDeviceApproval(IDeviceApprovalChannelInfo[] cha
while (true)
{
- Console.Write($"({DurationToText(duration)}) > ");
+ Console.Write($"({AuthUIExtensions.DurationToText(duration)}) > ");
var answer = await InputManager.ReadLine();
if (string.IsNullOrEmpty(answer))
{
@@ -335,7 +298,7 @@ public virtual Task WaitForDeviceApproval(IDeviceApprovalChannelInfo[] cha
Task action = null;
if (answer.StartsWith($"{twoFactorDurationPrefix}=", StringComparison.CurrentCultureIgnoreCase))
{
- TryParseTextToDuration(answer.Substring(twoFactorDurationPrefix.Length + 1), out duration);
+ AuthUIExtensions.TryParseTextToDuration(answer.Substring(twoFactorDurationPrefix.Length + 1), out duration);
}
else if (answer.StartsWith("email_", StringComparison.InvariantCultureIgnoreCase))
{
@@ -424,28 +387,124 @@ public virtual Task WaitForDeviceApproval(IDeviceApprovalChannelInfo[] cha
return deviceApprovalTask.Task;
}
+ public Task WaitForSsoToken(ISsoTokenActionInfo actionInfo, CancellationToken token)
+ {
+ Console.WriteLine($"Complete {(actionInfo.IsCloudSso ? "Cloud" : "OnSite")} SSO login");
+ Console.WriteLine($"\nLogin Url:\n\n{actionInfo.SsoLoginUrl}\n");
+ var ts = new TaskCompletionSource();
+ _ = Task.Run(async () =>
+ {
+ Task readTask = null;
+ var registration = token.Register(() =>
+ {
+ ts.SetCanceled();
+ InputManager.InterruptReadTask(readTask);
+ });
+ try
+ {
+ while (!ts.Task.IsCompleted)
+ {
+#if NET472_OR_GREATER
+ Console.WriteLine("Type \"clipboard\" to get token from the clipboard or \"cancel\"");
+#else
+ Console.WriteLine("Paste SSO token or \"cancel\"");
+#endif
+ Console.Write("> ");
+ readTask = InputManager.ReadLine();
+ var answer = await readTask;
+ switch (answer.ToLowerInvariant())
+ {
+#if NET472_OR_GREATER
+ case "clipboard":
+ var ssoToken = "";
+ var thread = new Thread(() => { ssoToken = Clipboard.GetText(); });
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ thread.Join();
+ if (string.IsNullOrEmpty(ssoToken))
+ {
+ Console.WriteLine("Clipboard is empty");
+ }
+ else
+ {
+ Console.WriteLine($"Token:\n{ssoToken}\n\nType \"yes\" to accept this token to discard");
+ Console.Write("> ");
+ answer = await InputManager.ReadLine();
+ if (answer == "yes")
+ {
+ await actionInfo.InvokeSsoTokenAction(ssoToken);
+ }
+ }
+ break;
+#endif
+ case "cancel":
+ ts.TrySetResult(false);
+ break;
+ }
+ }
+ }
+ finally
+ {
+ registration.Dispose();
+ }
+ });
+ return ts.Task;
+ }
- public virtual Task WaitForHttpProxyCredentials(IHttpProxyInfo proxyInfo)
+ public void SsoLogoutUrl(string url)
{
- var proxyTask = new TaskCompletionSource();
- Task.Run(async () =>
+ Console.WriteLine($"\nSSO Logout Url:\n\n{url}\n");
+ }
+
+ public Task WaitForDataKey(IDataKeyChannelInfo[] channels, CancellationToken token)
+ {
+ var taskSource = new TaskCompletionSource();
+
+ _ = Task.Run(async () =>
{
- Console.WriteLine("\nProxy Authentication\n");
- Console.Write("Proxy Username: ");
- var username = await InputManager.ReadLine();
- if (string.IsNullOrEmpty(username)) proxyTask.TrySetResult(false);
+ var actions = channels
+ .Select(x => x.Channel.SsoDataKeyShareChannelText())
+ .Where(x => !string.IsNullOrEmpty(x))
+ .ToArray();
- Console.Write("Proxy Password: ");
- var password = await InputManager.ReadLine(new ReadLineParameters
+ Console.WriteLine("\nRequest Data Key\n");
+ Console.WriteLine($"{string.Join("\n", actions.Select(x => $"\"{x}\""))}");
+ Console.WriteLine("\"cancel\" to stop waiting.");
+ while (true)
{
- IsSecured = true
- });
- if (string.IsNullOrEmpty(username)) proxyTask.TrySetResult(false);
- await proxyInfo.InvokeHttpProxyCredentialsDelegate.Invoke(username, password);
- return proxyTask.TrySetResult(true);
+ Console.Write("> ");
+ var answer = await InputManager.ReadLine();
+ if (token.IsCancellationRequested) break;
+ if (string.IsNullOrEmpty(answer))
+ {
+ continue;
+ }
+
+ if (string.Compare("cancel", answer, StringComparison.InvariantCultureIgnoreCase) == 0)
+ {
+ taskSource.TrySetResult(false);
+ break;
+ }
+
+ if (token.IsCancellationRequested)
+ {
+ break;
+ }
+
+ var action = channels
+ .FirstOrDefault(x => x.Channel.SsoDataKeyShareChannelText() == answer);
+ if (action != null)
+ {
+ await action.InvokeGetDataKeyAction.Invoke();
+ }
+ else
+ {
+ Console.WriteLine($"Unsupported command {answer}");
+ }
+ }
});
- return proxyTask.Task;
+ return taskSource.Task;
}
public void RegionChanged(string newRegion)
@@ -455,4 +514,40 @@ public void RegionChanged(string newRegion)
Console.WriteLine();
}
}
+
+#if NET472_OR_GREATER
+ class WinAuthUi : ConsoleAuthUi, IAuthSecurityKeyUI
+ {
+ public WinAuthUi(InputManager inputManager) : base(inputManager)
+ {
+ }
+
+ public async Task AuthenticatePublicKeyRequest(PublicKeyCredentialRequestOptions request)
+ {
+ if (request == null || string.IsNullOrEmpty(request.challenge))
+ {
+ throw new Exception("Security key challenge is empty. Try another 2FA method.");
+ }
+ var cancellationSource = new CancellationTokenSource();
+
+
+ var webAuthnSignature = await WinWebAuthn.Authenticate.GetAssertion(WinWebAuthn.Authenticate.GetConsoleWindow(), request, cancellationSource.Token);
+ var signature = new KeeperWebAuthnSignature
+ {
+ id = webAuthnSignature.credentialId.Base64UrlEncode(),
+ rawId = webAuthnSignature.credentialId.Base64UrlEncode(),
+ response = new SignatureResponse
+ {
+ authenticatorData = webAuthnSignature.authenticatorData.Base64UrlEncode(),
+ clientDataJSON = webAuthnSignature.clientData.Base64UrlEncode(),
+ signature = webAuthnSignature.signatureData.Base64UrlEncode(),
+ },
+ type = "public-key",
+ clientExtensionResults = new ClientExtensionResults(),
+ };
+ return Encoding.UTF8.GetString(JsonUtils.DumpJson(signature, false));
+ }
+ }
+#endif
+
}
diff --git a/KeeperSdk/utils/InputManager.cs b/Cli/InputManager.cs
similarity index 89%
rename from KeeperSdk/utils/InputManager.cs
rename to Cli/InputManager.cs
index 805e250..38b9a2b 100644
--- a/KeeperSdk/utils/InputManager.cs
+++ b/Cli/InputManager.cs
@@ -4,7 +4,7 @@
using System.Text;
using System.Threading.Tasks;
-namespace KeeperSecurity.Utils
+namespace Cli
{
///
public class ReadLineParameters
@@ -14,8 +14,68 @@ public class ReadLineParameters
public bool IsHistory { get; set; }
}
+ public class KeyboardInterrupt : Exception { }
+
+ ///
+ public interface IInputManager
+ {
+ Task ReadLine(ReadLineParameters parameters = null);
+ void InterruptReadTask(Task task);
+ }
+
+ public class SimpleInputManager : IInputManager
+ {
+ private string ReadPassword()
+ {
+ var result = new StringBuilder();
+ var done = false;
+ while (!done)
+ {
+ ConsoleKeyInfo key = Console.ReadKey(true);
+ switch (key.Key)
+ {
+ case ConsoleKey.Enter:
+ done = true;
+ Console.WriteLine();
+ break;
+ case ConsoleKey.Backspace:
+ if (result.Length > 0)
+ {
+ result.Length--;
+ Console.Write("\b \b");
+ }
+ break;
+ default:
+ result.Append(key.KeyChar);
+ Console.Write('*');
+ break;
+ }
+ }
+ return result.ToString();
+ }
+
+ public void InterruptReadTask(Task task)
+ {
+ Console.WriteLine("Press ");
+ }
+
+ public Task ReadLine(ReadLineParameters parameters = null)
+ {
+ string input;
+ if (parameters?.IsSecured == true)
+ {
+ input = ReadPassword();
+ }
+ else
+ {
+ input = Console.ReadLine();
+ }
+ return Task.FromResult(input);
+ }
+ }
+
///
- public class InputManager
+ public class InputManager : IInputManager
{
private readonly StringBuilder _buffer = new StringBuilder();
private bool _isSecured;
@@ -71,7 +131,7 @@ public void Run()
}
Console.WriteLine();
- Task.Run(() => { ts.TrySetResult(""); });
+ Task.Run(() => { ts.TrySetException(new KeyboardInterrupt()); });
}
else
{
@@ -281,7 +341,7 @@ public void Run()
newBuffer = _history[_history.Count - _positionInHistory];
}
}
- else
+ else if (!string.IsNullOrEmpty(_savedBuffer))
{
newBuffer = _savedBuffer;
}
@@ -443,15 +503,25 @@ public void ClearHistory()
public void InterruptReadTask(Task task)
{
+ TaskCompletionSource ts;
lock (this)
{
if (_taskSource == null || task == null) return;
if (ReferenceEquals(task, _taskSource.Task))
{
- _taskSource.TrySetCanceled();
- _taskSource = null;
+ ts = _taskSource;
+ }
+ else
+ {
+ return;
}
}
+
+ ts.TrySetCanceled();
+ if (ReferenceEquals(task, _taskSource.Task))
+ {
+ _taskSource = null;
+ }
}
public Task ReadLine(ReadLineParameters parameters = null)
diff --git a/Cli/Tabulate.cs b/Cli/Tabulate.cs
new file mode 100644
index 0000000..22ebb02
--- /dev/null
+++ b/Cli/Tabulate.cs
@@ -0,0 +1,269 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Cli
+{
+ ///
+ public class Tabulate
+ {
+ private readonly int _columns;
+ private readonly bool[] _rightAlignColumn;
+ private readonly int[] _maxChars;
+ private readonly List