Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Connect-Keeper: Automatically perform actions on DeviceAuth and TwoFactor #125

Open
bror-lauritz opened this issue Jul 26, 2024 · 17 comments

Comments

@bror-lauritz
Copy link

bror-lauritz commented Jul 26, 2024

When running Connect-Keeper, I want to be able to automatically answer the DeviceAuth and TwoFactor steps in the AuthFlow. I've tried to answer them by piping the answer into another process, which partially works but is quite fragile.

What I want to do

Consider the TwoFactor step:

    Keeper Username:  $username
Available Commands
channel=<authenticator> to change channel.
expire=<now | 30_days | never> to set 2fa expiration.
<code> to send a 2fa code.
<Enter> to resume
2FA channel(authenticator) expire[now]: 

I want to automatically answer the OTP here.
The same for DeviceAuth, but I want to first send "channel=2fa" and then the OTP.

What I have implemented

My current solution patches Connect-Keeper as defined in AuthCommands.ps1 with two new parameters and two if statements (see commented areas)

function Connect-Keeper {
    <#
    .Synopsis
    Login to Keeper

   .Parameter Username
    User email

    .Parameter NewLogin
    Do not use Last Login information

    .Parameter SsoPassword
    Use Master Password for SSO account

    .Parameter Server
    Change default keeper server

    # New Parameters
    .Parameter TwoFactorChannel
    Which channel to use when authenticating with 2FA

    .Parameter TwoFactorAction
    The action to take when authenticating with 2FA (i.e. push or <code>)

    .Parameter DeviceAuthChannel
    Which channel to use when authenticating a device

    .Parameter DeviceAuthAction
    The action to take when authenticating a device (i.e. push or <code>)
#>
    [CmdletBinding(DefaultParameterSetName = 'regular')]
    Param(
        [Parameter(Position = 0)][string] $Username,
        [Parameter()] [SecureString]$Password,
        [Parameter()][switch] $NewLogin,
        [Parameter(ParameterSetName = 'sso_password')][switch] $SsoPassword,
        [Parameter(ParameterSetName = 'sso_provider')][switch] $SsoProvider,
        [Parameter()][string] $Server,

        # New parameters
        [Parameter()][securestring[]] $TwoFactorActions,
        [Parameter()][SecureString[]] $DeviceAuthActions # Can be sensitive
    )

    Disconnect-Keeper -Resume | Out-Null

    $storage = New-Object KeeperSecurity.Configuration.JsonConfigurationStorage
    if (-not $Server) {
        $Server = $storage.LastServer
        if ($Server) {
            Write-Information -MessageData "`nUsing Keeper Server: $Server`n"
        }
        else {
            Write-Information -MessageData "`nUsing Default Keeper Server: $([KeeperSecurity.Authentication.KeeperEndpoint]::DefaultKeeperServer)`n"
        }
    }


    $endpoint = New-Object KeeperSecurity.Authentication.KeeperEndpoint($Server, $storage.Servers)
    $endpoint.DeviceName = 'PowerShell Commander'
    $endpoint.ClientVersion = 'c16.1.0'
    $authFlow = New-Object KeeperSecurity.Authentication.Sync.AuthSync($storage, $endpoint)

    $authFlow.ResumeSession = $true
    $authFlow.AlternatePassword = $SsoPassword.IsPresent

    if (-not $NewLogin.IsPresent -and -not $SsoProvider.IsPresent) {
        if (-not $Username) {
            $Username = $storage.LastLogin
        }
    }

    $namePrompt = 'Keeper Username'
    if ($SsoProvider.IsPresent) {
        $namePrompt = 'Enterprise Domain'
    }

    if ($Username) {
        Write-Output "$(($namePrompt + ': ').PadLeft(21, ' ')) $Username"
    }
    else {
        while (-not $Username) {
            $Username = Read-Host -Prompt $namePrompt.PadLeft(20, ' ')
        }
    }
    if ($SsoProvider.IsPresent) {
        $authFlow.LoginSso($Username).GetAwaiter().GetResult() | Out-Null
    }
    else {
        $passwords = @()
        if ($Password) {
            if ($Password -is [SecureString]) {
                $passwords += [Net.NetworkCredential]::new('', $Password).Password
            }
            elseif ($Password -is [String]) {
                $passwords += $Password
            }
        }
        $authFlow.Login($Username, $passwords).GetAwaiter().GetResult() | Out-Null
    }
    Write-Output ""
    while (-not $authFlow.IsCompleted) {
        if ($lastStep -ne $authFlow.Step.State) {
            printStepHelp $authFlow
            $lastStep = $authFlow.Step.State
        }

        $prompt = getStepPrompt $authFlow

        #### Start My Changes ####
        # If we're on the DeviceApproval step and the user has specified $DeviceAuthActions, do those actions in order
        if ($DeviceAuthActions -and $authFlow.Step -is [KeeperSecurity.Authentication.Sync.DeviceApprovalStep]) {
            executeStepAction $authFlow ($DeviceAuthActions[0] | ConvertFrom-SecureString -AsPlainText)
            $DeviceAuthActions = $DeviceAuthActions[1..$DeviceAuthActions.Length] # Just do one thing per while loop
        }
        # If we're on the TwoFactor step and the user has specified $TwoFactorActions, do those actions in order
        elseif ($TwoFactorActions -and $authFlow.Step -is [KeeperSecurity.Authentication.Sync.TwoFactorStep]) {
            executeStepAction $authFlow ($TwoFactorActions[0] | ConvertFrom-SecureString -AsPlainText)
            $TwoFactorActions = $TwoFactorActions[1..$TwoFactorActions.Length] # Just do one thing per while loop
        }

        # Otherwise, do everything like normal
        elseif ($action) {

            # move this here to avoid the "Read-Host" prompt
            if ($authFlow.Step -is [KeeperSecurity.Authentication.Sync.PasswordStep]) {
                $securedPassword = Read-Host -Prompt $prompt -AsSecureString
                if ($securedPassword.Length -gt 0) {
                    $action = [Net.NetworkCredential]::new('', $securedPassword).Password
                }
                else {
                    $action = ''
                }
            }
            else {
                $action = Read-Host -Prompt $prompt
            }

            if ($action -eq '?') {
                }
            else {
                executeStepAction $authFlow $action
            }
        }
        #### End My Changes ####
    }

    if ($authFlow.Step.State -ne [KeeperSecurity.Authentication.Sync.AuthState]::Connected) {
        if ($authFlow.Step -is [KeeperSecurity.Authentication.Sync.ErrorStep]) {
            Write-Warning $authFlow.Step.Message
        }
        return
    }

    $auth = $authFlow
    if ([KeeperSecurity.Authentication.AuthExtensions]::IsAuthenticated($auth)) {
        Write-Debug -Message "Connected to Keeper as $Username"

        $vault = New-Object KeeperSecurity.Vault.VaultOnline($auth)
        $task = $vault.SyncDown()
        Write-Information -MessageData 'Syncing ...'
        $task.GetAwaiter().GetResult() | Out-Null
        $vault.AutoSync = $true

        $Script:Context.Auth = $auth
        $Script:Context.Vault = $vault

        [KeeperSecurity.Vault.VaultData]$vaultData = $vault
        Write-Information -MessageData "Decrypted $($vaultData.RecordCount) record(s)"
        Set-KeeperLocation -Path '\' | Out-Null
    }
}

I can then run this to do all the steps I'd like:

$otp = Get-Otp $KeeperTotpSecret | ConvertTo-SecureString -AsPlainText
$params = @{
    Username = $KeeperUser
    Password = $KeeperPassword
    SsoPassword = $true
    TwoFactorActions = @(("channel=authenticator" | ConvertTo-SecureString -AsPlainText), $otp)
    DeviceAuthActions = @(("channel=2fa" | ConvertTo-SecureString -AsPlainText), $otp)
    ErrorAction = 'Stop'
}
Connect-Keeper @params 

(I don't know if the actions should be secure string or not, from a security perspective)

Limitations with my method

This works pretty well for my purposes, but I'd like for something like this in the source so I don't have to verify that it works after every new release.

One drawback is that it still unnecessarily prints out

    Keeper Username:  $username
Available Commands
channel=<authenticator> to change channel.
expire=<now | 30_days | never> to set 2fa expiration.
<code> to send a 2fa code.
<Enter> to resume
@sk-keeper
Copy link
Collaborator

If a Keeper client is used in a hosted environment (there is no user interaction) we suggest to prepare a configuration file config.json so the Device Approval and Two Factor steps are already completed.
You probably do not need to automate Device Approval and Two Factor steps.
But if you really need it then I would suggest to have a separate cmdlet that prepares a new Keeper configuration file that has Device Approval and Two Factor steps done.

@bror-lauritz
Copy link
Author

Thank you! That seems a bit easier.

But I can't seem to get it to work.
Connect-Keeper identifies the config.json file, but asks for both the 2fa code and password every time.

I generate the config file like this:

    $otp = Get-Otp $KeeperTotpSecret

    $KeeperConfig = @'
{{
    "server":"https://keepersecurity.com/api/v2/",
    "user":"{0}",
    "password":"{1}",
    "mfa_type":"device_token",
    "mfa_token":"{2}",
    "debug":false,
    "commands":[]
}}
'@
    [string]::format($KeeperConfig, $KeeperUser, (ConvertFrom-SecureString -SecureString $KeeperPassword -AsPlainText), $otp) | Out-File -FilePath config.json

Run Connect-Keeper once, enter the 2FA token and then try to connect again:

Connect-Keeper -Username $KeeperUser
... entering 2FA and masterpassword

Connect-Keeper
... prompted for 2FA and masterpassword again

If I specify the password and username on the CLI, I only get prompted for the 2FA:

Connect-Keeper -Username $KeeperUser -Password $KeeperPassword -SsoPassword
... prompted for the 2FA

@sk-keeper
Copy link
Collaborator

I can see that you use a different format for the config file. That is the Python's Commander config file.
.Net SDK config file is different.

{
  "server": "keepersecurity.com",
  "clone_code": "...",
  "user": "[email protected]",
  "devices": [
    {
      "device_token": "...",
      "private_key": "...",
      "server_info": [
        {
          "server": "keepersecurity.com",
          "clone_code": "..."
        }
      ]
    }
  ],
  "last_login": "[email protected]",
  "last_server": "keepersecurity.com",
  "servers": [
    {
      "server": "keepersecurity.com",
      "server_key_id": 3
    }
  ],
  "users": [
    {
      "last_device": {
        "device_token": "..."
      },
      "server": "keepersecurity.com",
      "user": "[email protected]"
    }
  ]
}

@sk-keeper
Copy link
Collaborator

If device approval step appears every login it means the library creates a new (so called) "device" and the backend enforces a full login flow.

@bror-lauritz
Copy link
Author

I see. I got it to work now after I deleted the entire config file and let powercommander make it from scratch.
I still have to pass the password, but that's not a problem.

I've been able to use the config file on a headless system, so it works for my purposes.

Thank you very much for your help!

@rvdwegen
Copy link

@bror-lauritz Hey sorry to ping you here but do you happen to have an example of how you got unnatended auth to work with the powershell module? I think I've correctly constructed the config.json but Connect-Keeper just keeps asking for device approval and is not giving any feedback on if its detecting the json or not.

@sk-keeper
Copy link
Collaborator

@rvdwegen If Connect-Keeper keeps asking you for device approval it means there is an issue with config.json file.
This file is either cannot be found or misformatted.
You can use the .Net Commander to create and prepare config.json file for use by a service.
Please note that the config file format is different for Python and .Net Commanders.
https://github.com/Keeper-Security/keeper-sdk-dotnet/releases/tag/v1.1.0-beta01

The default file location is the path returned by Environment.GetFolderPath(Environment.SpecialFolder.Personal) for your environment.

var personalFolder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal),

Windows: %USER_HOME%\Documents.keeper
Posix: $USER_HOME/.keeper

The config file can be setup for persistent login using this-device Commander's command.
In this case, the powershell module requires the config.json should persist between sessions if used in hosted environment

@sk-keeper sk-keeper reopened this Sep 19, 2024
@rvdwegen
Copy link

Hi, your support replied with similar instructions which I'll try out tomorrow. But does that mean the powershell module itself can't be used to generate the config.json?

@sk-keeper
Copy link
Collaborator

Powershell module generates config.json file if it does not exists using default settings.
This module does not expose methods for customizing it.

@bror-lauritz
Copy link
Author

bror-lauritz commented Sep 20, 2024

Hi @rvdwegen! In my experience this works quite well (I'm on Linux, so YMMV):

New-Item -Type File -Name config.json

$USERNAME = Read-Host -Prompt "Keeper username"
$PASSWORD = Read-Host -AsSecureString -Prompt "Keeper password"
Connect-Keeper -Username $USERNAME -Password $PASSWORD -SsoPassword

(Some of the flags might not be necessary in your case)

You probably want to set expire=never on the first prompt.

It's based on that Keeper finds the config.json file either in the current directory or $HOME/.keeper.
If you create an empty file in the local directory it'll be populated when you run Connect-Keeper. You can then use it as you please.

NOTE: I think you still need to supply the password whenever you run Connect-Keeper, but you don't have to use the 2FA code or authenticate the device.

@rvdwegen
Copy link

rvdwegen commented Sep 20, 2024

Unfortunately that still seems to result in a demand to input the password? I'm looking to use the PowerShell module (not .dotnet KeeperCommander) fully unnatended in either a function app or azure automation account.

image

The dotnet PowerCommander is correctly logging in completely unnatended now as soon as I start Keeper so the JSON is working now.

I can see that you use a different format for the config file. That is the Python's Commander config file. .Net SDK config file is different.

{
  "server": "keepersecurity.com",
  "clone_code": "...",
  "user": "[email protected]",
  "devices": [
    {
      "device_token": "...",
      "private_key": "...",
      "server_info": [
        {
          "server": "keepersecurity.com",
          "clone_code": "..."
        }
      ]
    }
  ],
  "last_login": "[email protected]",
  "last_server": "keepersecurity.com",
  "servers": [
    {
      "server": "keepersecurity.com",
      "server_key_id": 3
    }
  ],
  "users": [
    {
      "last_device": {
        "device_token": "..."
      },
      "server": "keepersecurity.com",
      "user": "[email protected]"
    }
  ]
}

The config file dotnet PowerCommander generated looks nothing like the above example though.
{ "user": "svc_keeper", "server": "keepersecurity.eu", "device_token": "", "private_key": "", "clone_code": "" }

@bror-lauritz
Copy link
Author

If your setup is like our organization, you have to specify the -SsoPassword flag for it to not ask the password. Otherwise, I don't have any other ways to make it work.

@rvdwegen
Copy link

rvdwegen commented Sep 20, 2024

image
Same result unfortunately.

But you can see that it picks up something from the JSON,
image

@rvdwegen
Copy link

I did, I've tried every combination that made sense.

@PeterD25
Copy link

Hey i'd like to follow up on the masterpassword prompt.

either i go through:
$keeperUsername = "myemailadress"
$keeperPassword = ConvertTo-SecureString "mypassword" -AsPlainText -Force
Connect-Keeper -Username $keeperUsername -Password $keeperPassword
or:
$keeperConfigPath = "C:\pathtomyconfigfile\config.json"
Connect-Keeper -Config $keeperConfigPath

it always ask me to enter the master password, but i want to get it done automatically
somehow it does not get the password from the configfile
is something wrong?

{
"devices": [
{
"device_token": "",
"private_key": "
",
"server_info": [
{
"server": "keepersecurity.com"
},
{
"clone_code": "",
"server": "keepersecurity.eu"
}
]
}
],
"last_login": "
",
"last_server": "",
"servers": [
{
"server": "keepersecurity.com",
"server_key_id": 3
},
{
"server": "keepersecurity.eu",
"server_key_id": 3
}
],
"users": [
{
"last_device": {
"device_token": "
"
},
"server": "keepersecurity.eu",
"user": "",
"password": "
"
}
]
}

@sk-keeper
Copy link
Collaborator

sk-keeper commented Oct 29, 2024

PowerCommander ignored "password" property of the configuration file.
This issue has been fixed in the latest PowerCommander release.

Passing user's password in the command line should work.
Probably your password contains powershell special characters like $ or `

PS> $password = Read-Host -AsSecureString -Prompt 'Enter Password'

@rvdwegen
Copy link

rvdwegen commented Nov 3, 2024

PowerCommander ignored "password" property of the configuration file. This issue has been fixed in the latest PowerCommander release.

Yep, works here too now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants