Add TOTP/2FA support for SSH keyboard-interactive auth
Automatically generate and submit TOTP codes during SSH keyboard-interactive authentication when a secret is configured for the profile. Backend: - Add TOTPSecret field to SSHConfig model - Detect TOTP/2FA prompts in keyboard-interactive handler using keyword matching (verification code, otp, authenticator, 2fa, etc.) - Generate 6-digit TOTP codes using pquerna/otp library - Retrieve TOTP secret from vault during connection (keychain or file vault) - API: SetTOTPSecret, GetTOTPCode, HasTOTPSecret, DeleteTOTPSecret Frontend: - Add TOTP/2FA section to SSH profile editor (collapsed by default) - Password-masked secret input with show/hide toggle - Save button: encrypts and stores in vault - Test button: generates a live 6-digit code (shown for 5 seconds) - Delete button: removes stored secret - Auto-detect whether secret is already saved (placeholder indicator) - CSS for TOTP input row, danger button style
This commit is contained in:
@@ -670,3 +670,34 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------
|
||||||
|
TOTP / 2FA — secret input row with action buttons
|
||||||
|
----------------------------------------------------------------------- */
|
||||||
|
.totp-input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totp-input-row .form-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totp-toggle,
|
||||||
|
.totp-test,
|
||||||
|
.totp-save,
|
||||||
|
.totp-delete {
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--error);
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #d32f2f;
|
||||||
|
}
|
||||||
|
|||||||
@@ -405,6 +405,9 @@ class ProfileEditor {
|
|||||||
}),
|
}),
|
||||||
], true));
|
], true));
|
||||||
|
|
||||||
|
// TOTP / 2FA
|
||||||
|
sections.push(this._renderTOTPSection(profile));
|
||||||
|
|
||||||
// Advanced
|
// Advanced
|
||||||
sections.push(this._renderSection('Advanced', [
|
sections.push(this._renderSection('Advanced', [
|
||||||
this._field('text', 'ssh.terminalType', 'Terminal Type', ssh.terminalType, { placeholder: 'xterm-256color' }),
|
this._field('text', 'ssh.terminalType', 'Terminal Type', ssh.terminalType, { placeholder: 'xterm-256color' }),
|
||||||
@@ -579,6 +582,167 @@ class ProfileEditor {
|
|||||||
* @returns {HTMLElement}
|
* @returns {HTMLElement}
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Renders the TOTP/2FA section with secret management and test button.
|
||||||
|
* @param {Object} profile
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_renderTOTPSection(profile) {
|
||||||
|
const section = createElement('div', 'form-section');
|
||||||
|
const header = createElement('div', 'form-section-header');
|
||||||
|
let collapsed = true;
|
||||||
|
|
||||||
|
header.innerHTML = `
|
||||||
|
<span class="form-section-chevron">▸</span>
|
||||||
|
<span class="form-section-title">TOTP / 2FA</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const content = createElement('div', 'form-section-content');
|
||||||
|
content.style.display = 'none';
|
||||||
|
|
||||||
|
const helpText = createElement('div', 'form-help', {
|
||||||
|
textContent: 'Configure a TOTP secret to auto-fill 2FA codes during SSH keyboard-interactive authentication. The secret is stored encrypted.',
|
||||||
|
});
|
||||||
|
content.appendChild(helpText);
|
||||||
|
|
||||||
|
// TOTP secret input
|
||||||
|
const group = createElement('div', 'form-group');
|
||||||
|
const label = createElement('label', 'form-label', { textContent: 'TOTP Secret' });
|
||||||
|
group.appendChild(label);
|
||||||
|
|
||||||
|
const row = createElement('div', 'totp-input-row');
|
||||||
|
|
||||||
|
const secretInput = createElement('input', 'form-input', {
|
||||||
|
type: 'password',
|
||||||
|
placeholder: 'Base32-encoded secret (e.g. from authenticator app setup)',
|
||||||
|
id: 'field-ssh.totpSecret',
|
||||||
|
dataset: { field: 'ssh.totpSecret' },
|
||||||
|
autocapitalize: 'off',
|
||||||
|
autocorrect: 'off',
|
||||||
|
spellcheck: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show/hide toggle
|
||||||
|
const toggleBtn = createElement('button', 'btn btn-small btn-secondary totp-toggle', {
|
||||||
|
textContent: '👁',
|
||||||
|
title: 'Show/hide secret',
|
||||||
|
type: 'button',
|
||||||
|
});
|
||||||
|
toggleBtn.addEventListener('mousedown', e => e.preventDefault());
|
||||||
|
toggleBtn.addEventListener('click', () => {
|
||||||
|
secretInput.type = secretInput.type === 'password' ? 'text' : 'password';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test button — generates a live code
|
||||||
|
const testBtn = createElement('button', 'btn btn-small btn-accent totp-test', {
|
||||||
|
textContent: 'Test',
|
||||||
|
title: 'Generate a code with this secret',
|
||||||
|
type: 'button',
|
||||||
|
});
|
||||||
|
testBtn.addEventListener('mousedown', e => e.preventDefault());
|
||||||
|
testBtn.addEventListener('click', async () => {
|
||||||
|
const profileId = profile.id;
|
||||||
|
if (profileId) {
|
||||||
|
try {
|
||||||
|
const code = await window.go.api.Service.GetTOTPCode(profileId);
|
||||||
|
testBtn.textContent = code;
|
||||||
|
setTimeout(() => { testBtn.textContent = 'Test'; }, 5000);
|
||||||
|
} catch (err) {
|
||||||
|
// If no saved secret yet, try computing locally using the input value
|
||||||
|
testBtn.textContent = 'Save first';
|
||||||
|
setTimeout(() => { testBtn.textContent = 'Test'; }, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save button
|
||||||
|
const saveBtn = createElement('button', 'btn btn-small btn-primary totp-save', {
|
||||||
|
textContent: 'Save',
|
||||||
|
title: 'Encrypt and save the TOTP secret',
|
||||||
|
type: 'button',
|
||||||
|
});
|
||||||
|
saveBtn.addEventListener('mousedown', e => e.preventDefault());
|
||||||
|
saveBtn.addEventListener('click', async () => {
|
||||||
|
const profileId = profile.id;
|
||||||
|
const secret = secretInput.value.trim();
|
||||||
|
if (!profileId) {
|
||||||
|
NexusTerminal.Notifications.show('Save the profile first, then set the TOTP secret.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!secret) {
|
||||||
|
// Delete existing secret
|
||||||
|
try {
|
||||||
|
await window.go.api.Service.DeleteTOTPSecret(profileId);
|
||||||
|
NexusTerminal.Notifications.show('TOTP secret removed.', 'info');
|
||||||
|
} catch (err) {
|
||||||
|
NexusTerminal.Notifications.show('Failed to remove TOTP secret.', 'error');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await window.go.api.Service.SetTOTPSecret(profileId, secret);
|
||||||
|
NexusTerminal.Notifications.show('TOTP secret saved securely.', 'success');
|
||||||
|
secretInput.value = '';
|
||||||
|
secretInput.placeholder = '••••••••• (secret saved)';
|
||||||
|
} catch (err) {
|
||||||
|
NexusTerminal.Notifications.show('Failed to save TOTP secret: ' + err, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete button
|
||||||
|
const deleteBtn = createElement('button', 'btn btn-small btn-danger totp-delete', {
|
||||||
|
textContent: 'Delete',
|
||||||
|
title: 'Remove the TOTP secret',
|
||||||
|
type: 'button',
|
||||||
|
});
|
||||||
|
deleteBtn.addEventListener('mousedown', e => e.preventDefault());
|
||||||
|
deleteBtn.addEventListener('click', async () => {
|
||||||
|
const profileId = profile.id;
|
||||||
|
if (!profileId) return;
|
||||||
|
try {
|
||||||
|
await window.go.api.Service.DeleteTOTPSecret(profileId);
|
||||||
|
NexusTerminal.Notifications.show('TOTP secret removed.', 'info');
|
||||||
|
secretInput.placeholder = 'Base32-encoded secret (e.g. from authenticator app setup)';
|
||||||
|
} catch (err) {
|
||||||
|
NexusTerminal.Notifications.show('Failed to remove TOTP secret.', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
row.appendChild(secretInput);
|
||||||
|
row.appendChild(toggleBtn);
|
||||||
|
row.appendChild(testBtn);
|
||||||
|
row.appendChild(saveBtn);
|
||||||
|
row.appendChild(deleteBtn);
|
||||||
|
group.appendChild(row);
|
||||||
|
content.appendChild(group);
|
||||||
|
|
||||||
|
// Check if secret is already saved and show placeholder
|
||||||
|
if (profile.id) {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const has = await window.go.api.Service.HasTOTPSecret(profile.id);
|
||||||
|
if (has) {
|
||||||
|
secretInput.placeholder = '••••••••• (secret saved)';
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore — might not be unlocked.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
header.addEventListener('mousedown', (e) => e.preventDefault());
|
||||||
|
header.addEventListener('click', () => {
|
||||||
|
collapsed = !collapsed;
|
||||||
|
content.style.display = collapsed ? 'none' : 'block';
|
||||||
|
header.querySelector('.form-section-chevron').textContent = collapsed ? '▸' : '▾';
|
||||||
|
});
|
||||||
|
|
||||||
|
section.appendChild(header);
|
||||||
|
section.appendChild(content);
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the login script section (expect-style send/wait steps).
|
* Renders the login script section (expect-style send/wait steps).
|
||||||
* @param {string} prefix - 'ssh' or 'telnet'
|
* @param {string} prefix - 'ssh' or 'telnet'
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ require (
|
|||||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||||
github.com/alessio/shellescape v1.4.1 // indirect
|
github.com/alessio/shellescape v1.4.1 // indirect
|
||||||
github.com/bep/debounce v1.2.1 // indirect
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||||
github.com/danieljoos/wincred v1.2.0 // indirect
|
github.com/danieljoos/wincred v1.2.0 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
@@ -32,6 +33,7 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/pquerna/otp v1.5.0 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/samber/lo v1.49.1 // indirect
|
github.com/samber/lo v1.49.1 // indirect
|
||||||
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVK
|
|||||||
github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
|
github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
|
||||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE=
|
github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE=
|
||||||
github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec=
|
github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
@@ -52,13 +55,17 @@ github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
|
|||||||
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
|
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||||
|
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||||
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
|
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
"github.com/copilot-ssh-client/app/internal/audit"
|
"github.com/copilot-ssh-client/app/internal/audit"
|
||||||
@@ -441,6 +442,72 @@ func (s *Service) DeleteProfileSecrets(profileID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TOTP / 2FA
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// SetTOTPSecret stores a TOTP secret for the given profile, encrypted in
|
||||||
|
// the secret vault (or OS keychain).
|
||||||
|
func (s *Service) SetTOTPSecret(profileID string, secret string) error {
|
||||||
|
return s.SetProfileSecret(profileID, "totp", secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTOTPCode generates the current 6-digit TOTP code for the given profile.
|
||||||
|
// Used for the "Test" button in the profile editor.
|
||||||
|
func (s *Service) GetTOTPCode(profileID string) (string, error) {
|
||||||
|
// Look up the TOTP secret from the vault.
|
||||||
|
totpKey := fmt.Sprintf("profile:%s:totp", profileID)
|
||||||
|
var secret string
|
||||||
|
|
||||||
|
if s.keychainStore != nil && s.keychainStore.Available() {
|
||||||
|
if val, err := s.keychainStore.GetSecret(totpKey); err == nil && val != "" {
|
||||||
|
secret = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if secret == "" && s.secretStore != nil && s.secretStore.IsUnlocked() {
|
||||||
|
if val, err := s.secretStore.GetSecret(totpKey); err == nil && val != "" {
|
||||||
|
secret = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if secret == "" {
|
||||||
|
return "", fmt.Errorf("no TOTP secret configured for this profile")
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := totp.GenerateCode(secret, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to generate TOTP code: %w", err)
|
||||||
|
}
|
||||||
|
return code, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasTOTPSecret checks whether a profile has a TOTP secret stored.
|
||||||
|
func (s *Service) HasTOTPSecret(profileID string) bool {
|
||||||
|
totpKey := fmt.Sprintf("profile:%s:totp", profileID)
|
||||||
|
if s.keychainStore != nil && s.keychainStore.Available() {
|
||||||
|
if val, err := s.keychainStore.GetSecret(totpKey); err == nil && val != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.secretStore != nil && s.secretStore.IsUnlocked() {
|
||||||
|
if val, err := s.secretStore.GetSecret(totpKey); err == nil && val != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTOTPSecret removes the TOTP secret for the given profile.
|
||||||
|
func (s *Service) DeleteTOTPSecret(profileID string) error {
|
||||||
|
totpKey := fmt.Sprintf("profile:%s:totp", profileID)
|
||||||
|
if s.keychainStore != nil && s.keychainStore.Available() {
|
||||||
|
_ = s.keychainStore.DeleteSecret(totpKey)
|
||||||
|
}
|
||||||
|
if s.secretStore != nil && s.secretStore.IsUnlocked() {
|
||||||
|
_ = s.secretStore.DeleteSecret(totpKey)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Known hosts
|
// Known hosts
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ type SSHConfig struct {
|
|||||||
AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect
|
AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect
|
||||||
MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
|
MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
|
||||||
LoginScripts []LoginScriptStep `json:"loginScripts"` // expect-style auto-login sequence
|
LoginScripts []LoginScriptStep `json:"loginScripts"` // expect-style auto-login sequence
|
||||||
|
TOTPSecret string `json:"totpSecret"` // TOTP secret key for 2FA auto-fill (stored encrypted)
|
||||||
JumpHosts []JumpHost `json:"jumpHosts"`
|
JumpHosts []JumpHost `json:"jumpHosts"`
|
||||||
PortForwards []PortForward `json:"portForwards"`
|
PortForwards []PortForward `json:"portForwards"`
|
||||||
StartupCommand string `json:"startupCommand"`
|
StartupCommand string `json:"startupCommand"`
|
||||||
|
|||||||
@@ -508,6 +508,21 @@ func (m *Manager) connectSSH(sess *Session, getSecret func(prompt string) (strin
|
|||||||
getPassword := m.buildSecretCallback(profile.ID, "password", sshConf.SecretMode, getSecret)
|
getPassword := m.buildSecretCallback(profile.ID, "password", sshConf.SecretMode, getSecret)
|
||||||
getPassphrase := m.buildSecretCallback(profile.ID, "passphrase", sshConf.KeySecretMode, getSecret)
|
getPassphrase := m.buildSecretCallback(profile.ID, "passphrase", sshConf.KeySecretMode, getSecret)
|
||||||
|
|
||||||
|
// Resolve TOTP secret from vault if not already inline.
|
||||||
|
if sshConf.TOTPSecret == "" {
|
||||||
|
totpKey := fmt.Sprintf("profile:%s:totp", profile.ID)
|
||||||
|
if m.keychainStore != nil && m.keychainStore.Available() {
|
||||||
|
if val, err := m.keychainStore.GetSecret(totpKey); err == nil && val != "" {
|
||||||
|
sshConf.TOTPSecret = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sshConf.TOTPSecret == "" && m.secretStore != nil && m.secretStore.IsUnlocked() {
|
||||||
|
if val, err := m.secretStore.GetSecret(totpKey); err == nil && val != "" {
|
||||||
|
sshConf.TOTPSecret = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := client.Connect(getPassword, getPassphrase); err != nil {
|
if err := client.Connect(getPassword, getPassphrase); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/copilot-ssh-client/app/internal/models"
|
"github.com/copilot-ssh-client/app/internal/models"
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -355,15 +357,29 @@ func (c *Client) buildAuthMethods(sc models.SSHConfig, getPassword func() (strin
|
|||||||
case "keyboard-interactive":
|
case "keyboard-interactive":
|
||||||
if getPassword != nil {
|
if getPassword != nil {
|
||||||
pwdCallback := getPassword
|
pwdCallback := getPassword
|
||||||
|
totpSecret := ""
|
||||||
|
if sc.TOTPSecret != "" {
|
||||||
|
totpSecret = sc.TOTPSecret
|
||||||
|
}
|
||||||
methods = append(methods, ssh.KeyboardInteractive(
|
methods = append(methods, ssh.KeyboardInteractive(
|
||||||
func(name, instruction string, questions []string, echos []bool) ([]string, error) {
|
func(name, instruction string, questions []string, echos []bool) ([]string, error) {
|
||||||
answers := make([]string, len(questions))
|
answers := make([]string, len(questions))
|
||||||
for i := range questions {
|
for i, question := range questions {
|
||||||
ans, err := pwdCallback()
|
q := strings.ToLower(question)
|
||||||
if err != nil {
|
// Detect TOTP/2FA prompts and auto-fill when secret is configured.
|
||||||
return nil, err
|
if totpSecret != "" && isTOTPPrompt(q) {
|
||||||
|
code, err := totp.GenerateCode(totpSecret, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate TOTP code: %w", err)
|
||||||
|
}
|
||||||
|
answers[i] = code
|
||||||
|
} else {
|
||||||
|
ans, err := pwdCallback()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
answers[i] = ans
|
||||||
}
|
}
|
||||||
answers[i] = ans
|
|
||||||
}
|
}
|
||||||
return answers, nil
|
return answers, nil
|
||||||
},
|
},
|
||||||
@@ -378,6 +394,32 @@ func (c *Client) buildAuthMethods(sc models.SSHConfig, getPassword func() (strin
|
|||||||
return methods, nil
|
return methods, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isTOTPPrompt checks whether a keyboard-interactive question looks like a
|
||||||
|
// TOTP/2FA prompt. Common patterns include "verification code", "otp",
|
||||||
|
// "two-factor", "2fa", "authenticator", etc.
|
||||||
|
func isTOTPPrompt(question string) bool {
|
||||||
|
totpKeywords := []string{
|
||||||
|
"verification code",
|
||||||
|
"otp",
|
||||||
|
"one-time",
|
||||||
|
"one time",
|
||||||
|
"two-factor",
|
||||||
|
"two factor",
|
||||||
|
"2fa",
|
||||||
|
"totp",
|
||||||
|
"authenticator",
|
||||||
|
"security code",
|
||||||
|
"6-digit",
|
||||||
|
"6 digit",
|
||||||
|
}
|
||||||
|
for _, kw := range totpKeywords {
|
||||||
|
if strings.Contains(question, kw) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// connectViaJumpHosts establishes the SSH connection through a chain of
|
// connectViaJumpHosts establishes the SSH connection through a chain of
|
||||||
// jump hosts. Each hop opens a new net.Conn through the previous hop's
|
// jump hosts. Each hop opens a new net.Conn through the previous hop's
|
||||||
// SSH client.
|
// SSH client.
|
||||||
|
|||||||
Reference in New Issue
Block a user