Create always saves a new profile. Save updates only the loaded profile.
@@ -837,6 +864,7 @@
StandTerm
+
@@ -1282,9 +1310,11 @@
Access token required
const SSH_SESSIONS_DB = 'standterm-ssh-sessions-v1';
const SSH_SESSIONS_STORE = 'state';
+ const SSH_KEYS_STORE = 'keys';
const SSH_SESSIONS_KEY = 'current';
const SSH_HISTORY_LIMIT = 6;
const SSH_PROFILE_NAME_MAX_LENGTH = 64;
+ const SSH_KEY_ALGORITHM = 'Ed25519';
function createSshSessionId(prefix) {
if (window.crypto && typeof crypto.randomUUID === 'function') {
@@ -1360,11 +1390,14 @@ Access token required
function openSshSessionsDb() {
return new Promise((resolve, reject) => {
- const request = indexedDB.open(SSH_SESSIONS_DB, 1);
+ const request = indexedDB.open(SSH_SESSIONS_DB, 2);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains(SSH_SESSIONS_STORE)) {
request.result.createObjectStore(SSH_SESSIONS_STORE);
}
+ if (!request.result.objectStoreNames.contains(SSH_KEYS_STORE)) {
+ request.result.createObjectStore(SSH_KEYS_STORE, { keyPath: 'keyId' });
+ }
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
@@ -1404,6 +1437,137 @@ Access token required
});
}
+ async function saveSshSessionStateWithKeyChanges(value, keyChanges = []) {
+ const normalized = normalizeSshSessionState(value);
+ const db = await openSshSessionsDb();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction([SSH_SESSIONS_STORE, SSH_KEYS_STORE], 'readwrite');
+ tx.objectStore(SSH_SESSIONS_STORE).put(normalized, SSH_SESSIONS_KEY);
+ const keyStore = tx.objectStore(SSH_KEYS_STORE);
+ keyChanges.forEach(change => {
+ if (change && change.type === 'put' && change.record) keyStore.put(change.record);
+ if (change && change.type === 'delete' && change.keyId) keyStore.delete(change.keyId);
+ });
+ tx.oncomplete = () => {
+ db.close();
+ resolve(normalized);
+ };
+ tx.onerror = () => {
+ db.close();
+ reject(tx.error);
+ };
+ tx.onabort = () => {
+ db.close();
+ reject(tx.error || new Error('SSH key storage was aborted.'));
+ };
+ });
+ }
+
+ async function loadSshKeyRecord(keyId) {
+ if (!keyId) return null;
+ const db = await openSshSessionsDb();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(SSH_KEYS_STORE, 'readonly');
+ const request = tx.objectStore(SSH_KEYS_STORE).get(keyId);
+ request.onsuccess = () => resolve(request.result || null);
+ request.onerror = () => reject(request.error);
+ tx.oncomplete = () => db.close();
+ });
+ }
+
+ function base64ToUint8Array(value) {
+ const binary = atob(String(value || ''));
+ return Uint8Array.from(binary, char => char.charCodeAt(0));
+ }
+
+ function buildSshEd25519PublicBlob(rawPublicKey) {
+ const keyType = new TextEncoder().encode('ssh-ed25519');
+ const raw = rawPublicKey instanceof Uint8Array ? rawPublicKey : new Uint8Array(rawPublicKey);
+ const blob = new Uint8Array(4 + keyType.length + 4 + raw.length);
+ const view = new DataView(blob.buffer);
+ view.setUint32(0, keyType.length);
+ blob.set(keyType, 4);
+ view.setUint32(4 + keyType.length, raw.length);
+ blob.set(raw, 4 + keyType.length + 4);
+ return blob;
+ }
+
+ async function getSshPublicKeyFingerprint(publicKeyBlob) {
+ const digest = await crypto.subtle.digest('SHA-256', publicKeyBlob);
+ return `SHA256:${arrayBufferToBase64(digest).replace(/=+$/, '')}`;
+ }
+
+ async function createBrowserSshKeyRecord(ownerProfileId) {
+ if (!window.isSecureContext || !window.crypto || !crypto.subtle) {
+ throw new Error('Browser SSH keys require a secure browser context.');
+ }
+ let keyPair;
+ try {
+ keyPair = await crypto.subtle.generateKey({ name: SSH_KEY_ALGORITHM }, false, ['sign', 'verify']);
+ } catch (err) {
+ throw new Error('This browser does not support non-extractable Ed25519 SSH keys.');
+ }
+ if (!keyPair.privateKey || keyPair.privateKey.extractable) {
+ throw new Error('The browser did not create a non-extractable SSH private key.');
+ }
+ const publicRaw = new Uint8Array(await crypto.subtle.exportKey('raw', keyPair.publicKey));
+ if (publicRaw.byteLength !== 32) throw new Error('The browser returned an invalid Ed25519 public key.');
+ const probe = crypto.getRandomValues(new Uint8Array(32));
+ const signature = await crypto.subtle.sign({ name: SSH_KEY_ALGORITHM }, keyPair.privateKey, probe);
+ if (!(await crypto.subtle.verify({ name: SSH_KEY_ALGORITHM }, keyPair.publicKey, signature, probe))) {
+ throw new Error('The browser could not verify the generated SSH key.');
+ }
+ const publicKeyRawB64 = arrayBufferToBase64(publicRaw);
+ const publicKeyBlob = buildSshEd25519PublicBlob(publicRaw);
+ return {
+ version: 1,
+ keyId: createSshSessionId('sshkey'),
+ ownerProfileId,
+ targetKey: null,
+ algorithm: SSH_KEY_ALGORITHM,
+ privateKey: keyPair.privateKey,
+ publicKey: keyPair.publicKey,
+ publicKeyRawB64,
+ publicKeyOpenSsh: `ssh-ed25519 ${arrayBufferToBase64(publicKeyBlob)}`,
+ fingerprint: await getSshPublicKeyFingerprint(publicKeyBlob),
+ publicKeyFingerprintHex: await arrayBufferToHex(publicRaw),
+ createdAt: new Date().toISOString()
+ };
+ }
+
+ async function validateBrowserSshKeyRecord(record, profile) {
+ if (
+ !record || !profile
+ || record.keyId !== profile.keyId
+ || record.ownerProfileId !== profile.id
+ || record.targetKey !== getSshTargetKey(profile)
+ ) {
+ throw new Error('The browser SSH key is missing or is linked to another profile.');
+ }
+ if (record.algorithm !== SSH_KEY_ALGORITHM || !record.privateKey || !record.publicKey) {
+ throw new Error('The browser SSH key record is invalid.');
+ }
+ if (record.privateKey.type !== 'private' || record.privateKey.extractable) {
+ throw new Error('The browser SSH private key is not non-extractable.');
+ }
+ const publicRaw = new Uint8Array(await crypto.subtle.exportKey('raw', record.publicKey));
+ const publicKeyRawB64 = arrayBufferToBase64(publicRaw);
+ const publicKeyBlob = buildSshEd25519PublicBlob(publicRaw);
+ const publicKeyOpenSsh = `ssh-ed25519 ${arrayBufferToBase64(publicKeyBlob)}`;
+ const fingerprint = await getSshPublicKeyFingerprint(publicKeyBlob);
+ const publicKeyFingerprintHex = await arrayBufferToHex(publicRaw);
+ if (
+ publicRaw.byteLength !== 32
+ || publicKeyRawB64 !== record.publicKeyRawB64
+ || publicKeyOpenSsh !== record.publicKeyOpenSsh
+ || fingerprint !== record.fingerprint
+ || publicKeyFingerprintHex !== record.publicKeyFingerprintHex
+ ) {
+ throw new Error('The browser SSH key record failed integrity validation.');
+ }
+ return record;
+ }
+
async function buildBrowserIdentityFromKeys(privateKey, publicKey) {
const publicKeyBuffer = await crypto.subtle.exportKey('spki', publicKey);
return {
@@ -1528,6 +1692,8 @@ Access token required
const sshPasswordInput = document.getElementById('password');
const sshSaveHistoryInput = document.getElementById('ssh-save-history');
const sshSaveSessionInput = document.getElementById('ssh-save-session');
+ const sshUseBrowserKeyLabel = document.getElementById('ssh-use-browser-key-label');
+ const sshUseBrowserKeyInput = document.getElementById('ssh-use-browser-key');
const sshProfileIndicator = document.getElementById('ssh-profile-indicator');
const sshSessionPickerToggle = document.getElementById('ssh-session-picker-toggle');
const sshSessionPickerPanel = document.getElementById('ssh-session-picker-panel');
@@ -1538,6 +1704,11 @@ Access token required
const sshProfileHostInput = document.getElementById('ssh-profile-host');
const sshProfilePortInput = document.getElementById('ssh-profile-port');
const sshProfileUsernameInput = document.getElementById('ssh-profile-username');
+ const sshProfileKeyEnabledInput = document.getElementById('ssh-profile-key-enabled');
+ const sshProfileKeyStatus = document.getElementById('ssh-profile-key-status');
+ const sshProfileKeyPublic = document.getElementById('ssh-profile-key-public');
+ const sshProfileKeyActions = document.getElementById('ssh-profile-key-actions');
+ const sshProfileKeyCopyBtn = document.getElementById('ssh-profile-key-copy');
const sshProfileCreateBtn = document.getElementById('ssh-profile-create');
const sshProfileSaveBtn = document.getElementById('ssh-profile-save');
const sshProfileDeleteBtn = document.getElementById('ssh-profile-delete');
@@ -1635,6 +1806,11 @@ Access token required
const connectionDiagnosticsCopyBtn = document.getElementById('connection-diagnostics-copy');
const connectionDiagnosticsClearBtn = document.getElementById('connection-diagnostics-clear');
const serverAvailabilityMessage = document.getElementById('server-availability-message');
+ const serverRetryNowBtn = document.getElementById('server-retry-now');
+ const settingsTransferStatus = document.getElementById('settings-transfer-status');
+ const settingsExportBtn = document.getElementById('settings-export');
+ const settingsImportBtn = document.getElementById('settings-import');
+ const settingsImportFile = document.getElementById('settings-import-file');
const debugEnabled = (new URLSearchParams(window.location.search)).get('debug') === '1';
const CONNECTION_DIAGNOSTICS_STORAGE_KEY = 'standterm-connection-diagnostics-v1';
const CONNECTION_DIAGNOSTICS_LIMIT = 100;
@@ -1666,6 +1842,10 @@ Access token required
let sshSessionWriteQueue = Promise.resolve();
let selectedSshPickerEntry = null;
let editingSshProfileId = null;
+ let editingSshKeyRecord = null;
+ let quickConnectSshKeyRecord = null;
+ let quickConnectKeyLoadVersion = 0;
+ const handledSshSignRequestIds = new Set();
const UART_MANUAL_PORT_VALUE = '__manual__';
const UART_PORT_REFRESH_MIN_INTERVAL_MS = 3000;
let lastUartPortRefreshAt = 0;
@@ -3413,6 +3593,11 @@ Access token required
return sshSessionState.profiles.find(profile => getSshTargetKey(profile) === targetKey) || null;
}
+ function isBrowserSshKeyAllowedByPolicy() {
+ const option = terminalPolicy.connection_options.find(item => item.connection_type === 'ssh');
+ return !!(option && option.allowed && option.browser_key_allowed);
+ }
+
function closeSshSessionPicker() {
sshSessionPickerPanel.classList.remove('open');
sshSessionPickerToggle.setAttribute('aria-expanded', 'false');
@@ -3422,6 +3607,7 @@ Access token required
if (!selectedSshPickerEntry || selectedSshPickerEntry.type !== 'profile') {
sshProfileIndicator.style.display = 'none';
sshProfileIndicator.innerText = '';
+ refreshQuickConnectSshKey(false);
return;
}
const profile = findSshProfile(selectedSshPickerEntry.id);
@@ -3429,6 +3615,7 @@ Access token required
selectedSshPickerEntry = null;
sshProfileIndicator.style.display = 'none';
sshProfileIndicator.innerText = '';
+ refreshQuickConnectSshKey(false);
return;
}
const modified = getSshTargetKey(profile) !== getSshTargetKey(getCurrentSshTarget());
@@ -3436,6 +3623,44 @@ Access token required
? `Based on: ${profile.name} (modified)`
: `Profile: ${profile.name}`;
sshProfileIndicator.style.display = 'block';
+ refreshQuickConnectSshKey(false);
+ }
+
+ function applyQuickConnectKeySelection() {
+ const useKey = !!(quickConnectSshKeyRecord && sshUseBrowserKeyInput.checked);
+ sshPasswordInput.disabled = useKey;
+ if (useKey) sshPasswordInput.value = '';
+ }
+
+ async function refreshQuickConnectSshKey(preferKey) {
+ const version = ++quickConnectKeyLoadVersion;
+ const profile = selectedSshPickerEntry && selectedSshPickerEntry.type === 'profile'
+ ? findSshProfile(selectedSshPickerEntry.id)
+ : null;
+ const exactMatch = profile && getSshTargetKey(profile) === getSshTargetKey(getCurrentSshTarget());
+ if (!isBrowserSshKeyAllowedByPolicy() || !exactMatch || !profile.keyId) {
+ quickConnectSshKeyRecord = null;
+ sshUseBrowserKeyInput.checked = false;
+ sshUseBrowserKeyLabel.hidden = true;
+ applyQuickConnectKeySelection();
+ return;
+ }
+ try {
+ const record = await validateBrowserSshKeyRecord(await loadSshKeyRecord(profile.keyId), profile);
+ if (version !== quickConnectKeyLoadVersion) return;
+ const changedKey = !quickConnectSshKeyRecord || quickConnectSshKeyRecord.keyId !== record.keyId;
+ quickConnectSshKeyRecord = record;
+ sshUseBrowserKeyLabel.hidden = false;
+ if (preferKey || changedKey) sshUseBrowserKeyInput.checked = true;
+ applyQuickConnectKeySelection();
+ } catch (err) {
+ if (version !== quickConnectKeyLoadVersion) return;
+ quickConnectSshKeyRecord = null;
+ sshUseBrowserKeyInput.checked = false;
+ sshUseBrowserKeyLabel.hidden = true;
+ applyQuickConnectKeySelection();
+ setSshSessionMessage(err.message || 'The browser SSH key is unavailable.', true);
+ }
}
function applySshPickerEntry(entryType, entryId) {
@@ -3451,6 +3676,7 @@ Access token required
});
clearConnectionFieldEdited('ssh', 'password');
updateSshProfileIndicator();
+ refreshQuickConnectSshKey(entryType === 'profile');
setSshSessionMessage('');
closeSshSessionPicker();
}
@@ -3493,6 +3719,7 @@ Access token required
function clearSshProfileEditor(useQuickConnectValues = false) {
editingSshProfileId = null;
+ editingSshKeyRecord = null;
const quickTarget = useQuickConnectValues ? getCurrentSshTarget() : { host: '', port: '22', username: '' };
sshProfileNameInput.value = useQuickConnectValues && quickTarget.host
? normalizeSshProfileName(`${quickTarget.username}@${quickTarget.host}`)
@@ -3500,21 +3727,76 @@ Access token required
sshProfileHostInput.value = quickTarget.host;
sshProfilePortInput.value = quickTarget.port;
sshProfileUsernameInput.value = quickTarget.username;
+ sshProfileKeyEnabledInput.checked = false;
+ renderSshProfileKeyEditor();
renderSshProfileManager();
}
- function loadSshProfileEditor(profileId) {
+ async function loadSshProfileEditor(profileId) {
const profile = findSshProfile(profileId);
if (!profile) return;
editingSshProfileId = profile.id;
+ editingSshKeyRecord = null;
sshProfileNameInput.value = profile.name;
sshProfileHostInput.value = profile.host;
sshProfilePortInput.value = profile.port;
sshProfileUsernameInput.value = profile.username;
setSshProfileStatus(`Loaded ${profile.name}. Save will update this entry; Create will make a copy.`);
+ if (profile.keyId) {
+ try {
+ editingSshKeyRecord = await validateBrowserSshKeyRecord(await loadSshKeyRecord(profile.keyId), profile);
+ sshProfileKeyEnabledInput.checked = true;
+ } catch (err) {
+ sshProfileKeyEnabledInput.checked = false;
+ setSshProfileStatus(err.message || 'The linked browser SSH key is unavailable.', true);
+ }
+ } else {
+ sshProfileKeyEnabledInput.checked = false;
+ }
+ renderSshProfileKeyEditor();
renderSshProfileManager();
}
+ function renderSshProfileKeyEditor() {
+ const enabled = sshProfileKeyEnabledInput.checked;
+ const record = editingSshKeyRecord;
+ sshProfileKeyPublic.hidden = !record;
+ sshProfileKeyActions.hidden = !record;
+ sshProfileKeyPublic.value = record ? record.publicKeyOpenSsh : '';
+ if (record && enabled) {
+ sshProfileKeyStatus.innerText = `${record.fingerprint} ยท private key stays non-extractable in this browser`;
+ } else if (record) {
+ sshProfileKeyStatus.innerText = 'This key will be removed only when the loaded profile is saved.';
+ } else if (enabled) {
+ sshProfileKeyStatus.innerText = 'Generating a non-extractable Ed25519 key...';
+ } else {
+ sshProfileKeyStatus.innerText = 'No browser key is linked.';
+ }
+ }
+
+ async function toggleSshProfileKey() {
+ if (!sshProfileKeyEnabledInput.checked) {
+ renderSshProfileKeyEditor();
+ return;
+ }
+ if (!editingSshKeyRecord) {
+ if (!isBrowserSshKeyAllowedByPolicy()) {
+ sshProfileKeyEnabledInput.checked = false;
+ renderSshProfileKeyEditor();
+ throw new Error('Browser SSH keys are not allowed for this browser connection.');
+ }
+ renderSshProfileKeyEditor();
+ try {
+ editingSshKeyRecord = await createBrowserSshKeyRecord(editingSshProfileId || '');
+ } catch (err) {
+ sshProfileKeyEnabledInput.checked = false;
+ renderSshProfileKeyEditor();
+ throw err;
+ }
+ }
+ renderSshProfileKeyEditor();
+ }
+
function renderSshProfileManager() {
sshProfileList.replaceChildren();
if (!sshSessionState.profiles.length) {
@@ -3535,7 +3817,10 @@ Access token required
target.className = 'ssh-profile-list-target';
target.innerText = `${profile.username}@${profile.host}:${profile.port}`;
button.append(name, target);
- button.onclick = () => loadSshProfileEditor(profile.id);
+ button.onclick = () => {
+ loadSshProfileEditor(profile.id)
+ .catch(err => setSshProfileStatus(err.message || 'Profile could not be loaded.', true));
+ };
sshProfileList.appendChild(button);
});
}
@@ -3568,6 +3853,21 @@ Access token required
return operation;
}
+ function updateSshSessionStateWithKeyChanges(mutator, keyChanges) {
+ const operation = sshSessionWriteQueue
+ .catch(() => {})
+ .then(() => sshSessionReady)
+ .then(async () => {
+ const nextState = normalizeSshSessionState(sshSessionState);
+ const result = mutator(nextState);
+ sshSessionState = await saveSshSessionStateWithKeyChanges(nextState, keyChanges);
+ renderSshSessionState();
+ return result;
+ });
+ sshSessionWriteQueue = operation;
+ return operation;
+ }
+
function initializeSshSessions() {
sshSessionReady = loadSshSessionState()
.then(state => {
@@ -3658,17 +3958,33 @@ Access token required
const editorValue = validateSshProfileEditor();
if (!editorValue) return;
const profileId = createSshSessionId('profile');
- await updateSshSessionState(nextState => {
+ const skippedLoadedKey = !!(editingSshKeyRecord && editingSshProfileId);
+ const mayAttachDraftKey = editingSshKeyRecord
+ && sshProfileKeyEnabledInput.checked
+ && !editingSshProfileId;
+ const keyRecord = mayAttachDraftKey
+ ? { ...editingSshKeyRecord, ownerProfileId: profileId, targetKey: getSshTargetKey(editorValue) }
+ : null;
+ await updateSshSessionStateWithKeyChanges(nextState => {
nextState.profiles.push({
id: profileId,
sortOrder: nextState.profiles.length,
...editorValue,
- keyId: null
+ keyId: keyRecord ? keyRecord.keyId : null
});
- });
+ }, keyRecord ? [{ type: 'put', record: keyRecord }] : []);
editingSshProfileId = profileId;
+ editingSshKeyRecord = keyRecord;
+ sshProfileKeyEnabledInput.checked = !!keyRecord;
+ renderSshProfileKeyEditor();
renderSshSessionState();
- setSshProfileStatus(`Created ${editorValue.name}.`);
+ setSshProfileStatus(
+ mayAttachDraftKey
+ ? `Created ${editorValue.name} with a browser SSH key.`
+ : skippedLoadedKey
+ ? `Created ${editorValue.name}. Browser keys from loaded profiles are not copied.`
+ : `Created ${editorValue.name}.`
+ );
}
async function saveSshProfileEditor() {
@@ -3679,20 +3995,53 @@ Access token required
}
const editorValue = validateSshProfileEditor();
if (!editorValue) return;
- await updateSshSessionState(nextState => {
+ const wantsKey = sshProfileKeyEnabledInput.checked;
+ if (wantsKey && !editingSshKeyRecord) {
+ setSshProfileStatus('Generate the browser SSH key before saving.', true);
+ return;
+ }
+ if (!wantsKey && existingProfile.keyId && !window.confirm(
+ `Remove the browser SSH key from ${existingProfile.name}? The private key cannot be recovered.`
+ )) return;
+ const keyRecord = wantsKey
+ ? {
+ ...editingSshKeyRecord,
+ ownerProfileId: existingProfile.id,
+ targetKey: getSshTargetKey(editorValue)
+ }
+ : null;
+ const keyChanges = [];
+ if (keyRecord) keyChanges.push({ type: 'put', record: keyRecord });
+ if (existingProfile.keyId && (!keyRecord || existingProfile.keyId !== keyRecord.keyId)) {
+ const oldKeyRecord = await loadSshKeyRecord(existingProfile.keyId);
+ if (oldKeyRecord && oldKeyRecord.ownerProfileId === existingProfile.id) {
+ keyChanges.push({ type: 'delete', keyId: existingProfile.keyId });
+ }
+ }
+ await updateSshSessionStateWithKeyChanges(nextState => {
const existing = nextState.profiles.find(profile => profile.id === existingProfile.id);
- if (existing) Object.assign(existing, editorValue);
- });
+ if (existing) Object.assign(existing, editorValue, { keyId: keyRecord ? keyRecord.keyId : null });
+ }, keyChanges);
+ editingSshKeyRecord = keyRecord;
+ renderSshProfileKeyEditor();
renderSshSessionState();
setSshProfileStatus(`Saved ${editorValue.name}.`);
}
async function deleteEditingSshProfile() {
const profile = findSshProfile(editingSshProfileId);
- if (!profile || !window.confirm(`Delete SSH profile ${profile.name}?`)) return;
- await updateSshSessionState(nextState => {
+ if (!profile) return;
+ const prompt = profile.keyId
+ ? `Delete SSH profile ${profile.name} and its non-recoverable browser private key?`
+ : `Delete SSH profile ${profile.name}?`;
+ if (!window.confirm(prompt)) return;
+ const keyRecord = profile.keyId ? await loadSshKeyRecord(profile.keyId) : null;
+ const keyChanges = keyRecord && keyRecord.ownerProfileId === profile.id
+ ? [{ type: 'delete', keyId: profile.keyId }]
+ : [];
+ await updateSshSessionStateWithKeyChanges(nextState => {
nextState.profiles = nextState.profiles.filter(item => item.id !== profile.id);
- });
+ }, keyChanges);
if (selectedSshPickerEntry && selectedSshPickerEntry.type === 'profile' && selectedSshPickerEntry.id === profile.id) {
selectedSshPickerEntry = null;
}
@@ -3896,6 +4245,7 @@ Access token required
? userSelectedConnectionType
: (forcedConnectionType || defaultConnectionType);
setConnectionType(nextConnection);
+ refreshQuickConnectSshKey(false);
updateDebugHud(
'policy.apply',
`default=${defaultConnectionType} next=${nextConnection} local_allowed=${!!nextLocalShellOption.allowed} user_selected=${userSelectedConnectionType || ''}`
@@ -4124,6 +4474,270 @@ Access token required
URL.revokeObjectURL(url);
}
+ function crc32(bytes) {
+ let crc = 0xffffffff;
+ for (const byte of bytes) {
+ crc ^= byte;
+ for (let bit = 0; bit < 8; bit += 1) {
+ crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
+ }
+ }
+ return (crc ^ 0xffffffff) >>> 0;
+ }
+
+ function createStoredZip(filename, contentBytes) {
+ const nameBytes = new TextEncoder().encode(filename);
+ const localSize = 30 + nameBytes.length + contentBytes.length;
+ const centralSize = 46 + nameBytes.length;
+ const bytes = new Uint8Array(localSize + centralSize + 22);
+ const view = new DataView(bytes.buffer);
+ const checksum = crc32(contentBytes);
+ let offset = 0;
+ view.setUint32(offset, 0x04034b50, true); offset += 4;
+ view.setUint16(offset, 20, true); offset += 2;
+ view.setUint16(offset, 0x0800, true); offset += 2;
+ view.setUint16(offset, 0, true); offset += 2;
+ view.setUint32(offset, 0, true); offset += 4;
+ view.setUint32(offset, checksum, true); offset += 4;
+ view.setUint32(offset, contentBytes.length, true); offset += 4;
+ view.setUint32(offset, contentBytes.length, true); offset += 4;
+ view.setUint16(offset, nameBytes.length, true); offset += 2;
+ view.setUint16(offset, 0, true); offset += 2;
+ bytes.set(nameBytes, offset); offset += nameBytes.length;
+ bytes.set(contentBytes, offset); offset += contentBytes.length;
+ const centralOffset = offset;
+ view.setUint32(offset, 0x02014b50, true); offset += 4;
+ view.setUint16(offset, 20, true); offset += 2;
+ view.setUint16(offset, 20, true); offset += 2;
+ view.setUint16(offset, 0x0800, true); offset += 2;
+ view.setUint16(offset, 0, true); offset += 2;
+ view.setUint32(offset, 0, true); offset += 4;
+ view.setUint32(offset, checksum, true); offset += 4;
+ view.setUint32(offset, contentBytes.length, true); offset += 4;
+ view.setUint32(offset, contentBytes.length, true); offset += 4;
+ view.setUint16(offset, nameBytes.length, true); offset += 2;
+ view.setUint16(offset, 0, true); offset += 2;
+ view.setUint16(offset, 0, true); offset += 2;
+ view.setUint16(offset, 0, true); offset += 2;
+ view.setUint16(offset, 0, true); offset += 2;
+ view.setUint32(offset, 0, true); offset += 4;
+ view.setUint32(offset, 0, true); offset += 4;
+ bytes.set(nameBytes, offset); offset += nameBytes.length;
+ view.setUint32(offset, 0x06054b50, true); offset += 4;
+ view.setUint16(offset, 0, true); offset += 2;
+ view.setUint16(offset, 0, true); offset += 2;
+ view.setUint16(offset, 1, true); offset += 2;
+ view.setUint16(offset, 1, true); offset += 2;
+ view.setUint32(offset, centralSize, true); offset += 4;
+ view.setUint32(offset, centralOffset, true); offset += 4;
+ view.setUint16(offset, 0, true);
+ return bytes;
+ }
+
+ function readStoredSettingsZip(bytes) {
+ if (!(bytes instanceof Uint8Array) || bytes.byteLength < 22 || bytes.byteLength > 524288) {
+ throw new Error('Settings archive is too large.');
+ }
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+ let eocdOffset = -1;
+ for (let offset = bytes.length - 22; offset >= Math.max(0, bytes.length - 65557); offset -= 1) {
+ if (view.getUint32(offset, true) === 0x06054b50) { eocdOffset = offset; break; }
+ }
+ if (eocdOffset < 0 || view.getUint16(eocdOffset + 10, true) !== 1) {
+ throw new Error('Settings archive must contain exactly one file.');
+ }
+ const centralOffset = view.getUint32(eocdOffset + 16, true);
+ if (centralOffset + 46 > bytes.length || view.getUint32(centralOffset, true) !== 0x02014b50) {
+ throw new Error('Settings archive directory is invalid.');
+ }
+ const method = view.getUint16(centralOffset + 10, true);
+ const checksum = view.getUint32(centralOffset + 16, true);
+ const compressedSize = view.getUint32(centralOffset + 20, true);
+ const contentSize = view.getUint32(centralOffset + 24, true);
+ const nameLength = view.getUint16(centralOffset + 28, true);
+ if (centralOffset + 46 + nameLength > eocdOffset) throw new Error('Settings archive directory is truncated.');
+ const name = new TextDecoder().decode(bytes.slice(centralOffset + 46, centralOffset + 46 + nameLength));
+ if (method !== 0 || compressedSize !== contentSize || contentSize > 262144 || name !== 'standterm-settings.json') {
+ throw new Error('Settings archive uses an unsupported format.');
+ }
+ if (
+ view.getUint32(0, true) !== 0x04034b50
+ || view.getUint16(8, true) !== 0
+ || view.getUint32(14, true) !== checksum
+ || view.getUint32(18, true) !== contentSize
+ || view.getUint32(22, true) !== contentSize
+ ) throw new Error('Settings archive entry is invalid.');
+ const localNameLength = view.getUint16(26, true);
+ const localExtraLength = view.getUint16(28, true);
+ const contentOffset = 30 + localNameLength + localExtraLength;
+ if (contentOffset + contentSize > bytes.length) throw new Error('Settings archive is truncated.');
+ const localName = new TextDecoder().decode(bytes.slice(30, 30 + localNameLength));
+ if (localName !== name) throw new Error('Settings archive entry name is invalid.');
+ const content = bytes.slice(contentOffset, contentOffset + contentSize);
+ if (crc32(content) !== checksum) throw new Error('Settings archive checksum failed.');
+ return content;
+ }
+
+ function getExportablePreferences() {
+ return Object.fromEntries(Object.keys(PREF_DEFAULTS).map(key => [key, prefs[key]]));
+ }
+
+ function buildBrowserSettingsPayload() {
+ return {
+ format: 'standterm-browser-settings',
+ version: 1,
+ exportedAt: new Date().toISOString(),
+ preferences: getExportablePreferences(),
+ ui: {
+ agentPanelPosition: loadAgentPanelPosition()
+ },
+ ssh: {
+ profiles: sshSessionState.profiles.map(({ keyId, ...profile }) => ({ ...profile })),
+ history: sshSessionState.history.map(entry => ({ ...entry }))
+ }
+ };
+ }
+
+ async function createBrowserSettingsEnvelope() {
+ await sshSessionReady;
+ await sshSessionWriteQueue.catch(() => {});
+ const innerBytes = new TextEncoder().encode(JSON.stringify(buildBrowserSettingsPayload(), null, 2));
+ const zipBytes = createStoredZip('standterm-settings.json', innerBytes);
+ return {
+ format: 'standterm-settings-envelope',
+ version: 1,
+ contentType: 'application/zip',
+ encoding: 'base64',
+ content: arrayBufferToBase64(zipBytes)
+ };
+ }
+
+ async function exportBrowserSettings() {
+ const envelope = await createBrowserSettingsEnvelope();
+ const date = new Date().toISOString().slice(0, 10).replaceAll('-', '');
+ downloadTextFile(`standterm-settings-${date}.json`, JSON.stringify(envelope, null, 2));
+ settingsTransferStatus.textContent = 'Settings exported. SSH keys were not included.';
+ return envelope;
+ }
+
+ function normalizeImportedPreferences(value) {
+ const source = value && typeof value === 'object' ? value : {};
+ const result = {};
+ Object.entries(PREF_DEFAULTS).forEach(([key, defaultValue]) => {
+ if (typeof source[key] === typeof defaultValue) result[key] = source[key];
+ });
+ if (result.urlClickAction && !['overlay', 'popup', 'newtab'].includes(result.urlClickAction)) delete result.urlClickAction;
+ if (result.colorScheme && !SCHEMES[result.colorScheme]) delete result.colorScheme;
+ if (result.fontFace) result.fontFace = result.fontFace.slice(0, 240);
+ if ('fontSize' in result) result.fontSize = normalizeFontSize(result.fontSize);
+ if ('fontWeight' in result) result.fontWeight = normalizeFontWeight(result.fontWeight);
+ if ('cursorStyle' in result) result.cursorStyle = normalizeCursorStyle(result.cursorStyle);
+ return result;
+ }
+
+ function normalizeImportedSshState(value) {
+ const source = value && typeof value === 'object' ? value : {};
+ const validId = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value);
+ const profiles = (Array.isArray(source.profiles) ? source.profiles : [])
+ .filter(profile => profile && validId(profile.id))
+ .map((profile, index) => ({
+ id: profile.id,
+ sortOrder: index,
+ name: normalizeSshProfileName(profile.name),
+ ...normalizeSshTarget(profile),
+ keyId: null
+ }))
+ .filter(profile => profile.name && profile.host && profile.host.length <= 255 && profile.username && profile.username.length <= 128);
+ const history = (Array.isArray(source.history) ? source.history : [])
+ .filter(entry => entry && validId(entry.id))
+ .map(entry => ({
+ id: entry.id,
+ ...normalizeSshTarget(entry),
+ lastUsedAt: typeof entry.lastUsedAt === 'string' ? entry.lastUsedAt : new Date(0).toISOString()
+ }))
+ .filter(entry => entry.host && entry.host.length <= 255 && entry.username && entry.username.length <= 128);
+ return normalizeSshSessionState({ profiles, history });
+ }
+
+ function normalizeImportedUiSettings(value) {
+ const source = value && typeof value === 'object' ? value : {};
+ if (!Object.prototype.hasOwnProperty.call(source, 'agentPanelPosition')) return {};
+ const position = source.agentPanelPosition;
+ if (position === null) return { agentPanelPosition: null };
+ if (
+ !position || !Number.isFinite(position.left) || !Number.isFinite(position.top)
+ || Math.abs(position.left) > 100000 || Math.abs(position.top) > 100000
+ ) return {};
+ return { agentPanelPosition: { left: position.left, top: position.top } };
+ }
+
+ function parseBrowserSettingsEnvelope(text) {
+ if (typeof text !== 'string' || text.length > 1048576) throw new Error('Settings file is too large.');
+ const envelope = JSON.parse(text);
+ if (
+ !envelope || envelope.format !== 'standterm-settings-envelope' || envelope.version !== 1
+ || envelope.contentType !== 'application/zip' || envelope.encoding !== 'base64'
+ || typeof envelope.content !== 'string'
+ ) throw new Error('Settings envelope is not supported.');
+ const inner = JSON.parse(new TextDecoder().decode(readStoredSettingsZip(base64ToUint8Array(envelope.content))));
+ if (!inner || inner.format !== 'standterm-browser-settings' || inner.version !== 1 || inner.keys) {
+ throw new Error('Settings payload is not supported.');
+ }
+ return {
+ preferences: normalizeImportedPreferences(inner.preferences),
+ ui: normalizeImportedUiSettings(inner.ui),
+ ssh: normalizeImportedSshState(inner.ssh)
+ };
+ }
+
+ async function importBrowserSettingsText(text) {
+ const imported = parseBrowserSettingsEnvelope(text);
+ const preferenceCount = Object.keys(imported.preferences).length;
+ if (!window.confirm(
+ `Import ${preferenceCount} preferences, ${imported.ssh.profiles.length} SSH profiles, and ${imported.ssh.history.length} history entries? Existing browser SSH keys will not change.`
+ )) return false;
+ await sshSessionReady;
+ await updateSshSessionState(nextState => {
+ const importedProfiles = new Map(imported.ssh.profiles.map(profile => [profile.id, profile]));
+ nextState.profiles = nextState.profiles.map(profile => {
+ const replacement = importedProfiles.get(profile.id);
+ if (!replacement) return profile;
+ importedProfiles.delete(profile.id);
+ const protectedTarget = profile.keyId ? normalizeSshTarget(profile) : null;
+ return {
+ ...profile,
+ ...replacement,
+ ...(protectedTarget || {}),
+ sortOrder: profile.sortOrder,
+ keyId: profile.keyId
+ };
+ });
+ importedProfiles.forEach(profile => {
+ nextState.profiles.push({ ...profile, sortOrder: nextState.profiles.length, keyId: null });
+ });
+ const history = [...imported.ssh.history, ...nextState.history];
+ const seen = new Set();
+ nextState.history = history.filter(entry => {
+ const key = getSshTargetKey(entry);
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ }).slice(0, SSH_HISTORY_LIMIT);
+ });
+ prefs = { ...prefs, ...imported.preferences };
+ savePrefs(prefs);
+ if (Object.prototype.hasOwnProperty.call(imported.ui, 'agentPanelPosition')) {
+ if (imported.ui.agentPanelPosition) {
+ saveAgentPanelPosition(imported.ui.agentPanelPosition);
+ } else {
+ localStorage.removeItem('agentPanelPosition.v1');
+ }
+ }
+ settingsTransferStatus.textContent = 'Settings imported. Reloading to apply browser preferences...';
+ setTimeout(() => location.reload(), 50);
+ return true;
+ }
+
function startBrowserPairingResponseTimer() {
if (browserPairingResponseTimer) clearTimeout(browserPairingResponseTimer);
browserPairingResponseTimer = setTimeout(() => {
@@ -4678,6 +5292,9 @@ Access token required
socket.connect();
return true;
},
+ retryServerConnectionNowForTest() {
+ return retryServerConnectionNow();
+ },
getActiveAgentState() {
return serializeAgentForTest(getActiveTerminalState());
},
@@ -4865,13 +5482,93 @@ Access token required
renderSshSessionState();
return cloneForTest(sshSessionState);
},
+ async createBrowserSshKeyForProfileForTest(profileId) {
+ await sshSessionReady;
+ const profile = findSshProfile(profileId);
+ if (!profile) throw new Error('SSH profile was not found.');
+ const generatedRecord = await createBrowserSshKeyRecord(profile.id);
+ const record = { ...generatedRecord, targetKey: getSshTargetKey(profile) };
+ await updateSshSessionStateWithKeyChanges(nextState => {
+ const nextProfile = nextState.profiles.find(item => item.id === profile.id);
+ if (nextProfile) nextProfile.keyId = record.keyId;
+ }, [{ type: 'put', record }]);
+ return {
+ keyId: record.keyId,
+ ownerProfileId: record.ownerProfileId,
+ algorithm: record.algorithm,
+ privateKeyExtractable: record.privateKey.extractable,
+ publicKeyRawB64: record.publicKeyRawB64,
+ publicKeyOpenSsh: record.publicKeyOpenSsh,
+ fingerprint: record.fingerprint,
+ publicKeyFingerprintHex: record.publicKeyFingerprintHex
+ };
+ },
+ async getBrowserSshKeyMetadataForTest(profileId) {
+ await sshSessionReady;
+ const profile = findSshProfile(profileId);
+ if (!profile || !profile.keyId) return null;
+ const record = await validateBrowserSshKeyRecord(await loadSshKeyRecord(profile.keyId), profile);
+ return {
+ keyId: record.keyId,
+ ownerProfileId: record.ownerProfileId,
+ algorithm: record.algorithm,
+ privateKeyExtractable: record.privateKey.extractable,
+ publicKeyRawB64: record.publicKeyRawB64,
+ publicKeyOpenSsh: record.publicKeyOpenSsh,
+ fingerprint: record.fingerprint,
+ publicKeyFingerprintHex: record.publicKeyFingerprintHex
+ };
+ },
+ async browserSshKeyRecordExistsForTest(keyId) {
+ return !!(await loadSshKeyRecord(keyId));
+ },
+ async signBrowserSshChallengeForTest(profileId, challengeB64) {
+ const profile = findSshProfile(profileId);
+ if (!profile || !profile.keyId) throw new Error('SSH profile key was not found.');
+ const record = await validateBrowserSshKeyRecord(await loadSshKeyRecord(profile.keyId), profile);
+ const challenge = base64ToUint8Array(challengeB64);
+ return arrayBufferToBase64(await crypto.subtle.sign(
+ { name: SSH_KEY_ALGORITHM }, record.privateKey, challenge
+ ));
+ },
+ async createBrowserSettingsEnvelopeForTest() {
+ return createBrowserSettingsEnvelope();
+ },
+ decodeBrowserSettingsEnvelopeForTest(envelope) {
+ const parsed = typeof envelope === 'string' ? JSON.parse(envelope) : envelope;
+ const bytes = base64ToUint8Array(parsed.content);
+ return JSON.parse(new TextDecoder().decode(readStoredSettingsZip(bytes)));
+ },
+ parseBrowserSettingsEnvelopeForTest(envelope) {
+ return parseBrowserSettingsEnvelope(typeof envelope === 'string' ? envelope : JSON.stringify(envelope));
+ },
+ async importBrowserSettingsEnvelopeForTest(envelope) {
+ return importBrowserSettingsText(typeof envelope === 'string' ? envelope : JSON.stringify(envelope));
+ },
+ getConnectionFormDataForTest() {
+ return cloneForTest(getConnectionFormData());
+ },
+ setConnectionTypeForTest(connectionType) {
+ setConnectionType(connectionType);
+ forcedConnectionType = null;
+ selectedConnectionType = normalizeConnectionType(connectionType);
+ return getSelectedConnectionType();
+ },
+ async handleBrowserSshSignRequestForTest(payload) {
+ await handleBrowserSshSignRequest(cloneForTest(payload));
+ },
stageSshConnectionForTest(value) {
pendingSshConnectionDrafts.set(activeTerminalId, {
...normalizeSshTarget(value),
saveHistory: !(value && value.saveHistory === false),
saveSession: !!(value && value.saveSession),
profileName: normalizeSshProfileName(value && value.profileName),
- useKey: false
+ useKey: !!(value && value.useKey),
+ profileId: value && value.profileId ? value.profileId : null,
+ keyId: value && value.keyId ? value.keyId : null,
+ publicKeyFingerprintHex: value && value.publicKeyFingerprintHex
+ ? value.publicKeyFingerprintHex
+ : null
});
},
getMatchingSshProfileNameForTest() {
@@ -4944,6 +5641,7 @@ Access token required
reconnection: true,
reconnectionAttempts: Infinity
});
+ if (serverRetryNowBtn) serverRetryNowBtn.onclick = retryServerConnectionNow;
setServerConnectionState('connecting');
installBrowserTestHook();
socket.io.on('reconnect_attempt', attempt => {
@@ -5038,6 +5736,86 @@ Access token required
})
.catch(err => updateBrowserAuthUi(err.message));
});
+ async function handleBrowserSshSignRequest(data) {
+ const responseBase = {
+ request_id: data && data.request_id,
+ terminal_id: data && data.terminal_id,
+ profile_id: data && data.profile_id,
+ key_id: data && data.key_id,
+ challenge_sha256: data && data.challenge_sha256
+ };
+ const fail = message => {
+ if (socket && socket.connected) {
+ socket.emit('ssh_browser_sign_response', {
+ ...responseBase,
+ status: 'failed',
+ message: String(message || 'Browser SSH signing failed.').slice(0, 160)
+ });
+ }
+ };
+ try {
+ if (!data || typeof data !== 'object') throw new Error('Invalid SSH signing request.');
+ const stringFields = [
+ 'request_id', 'terminal_id', 'profile_id', 'key_id',
+ 'public_key_fingerprint', 'algorithm', 'challenge', 'challenge_sha256'
+ ];
+ if (stringFields.some(field => typeof data[field] !== 'string' || !data[field])) {
+ throw new Error('Incomplete SSH signing request.');
+ }
+ if (
+ data.request_id.length > 128
+ || data.profile_id.length > 128
+ || data.key_id.length > 128
+ || data.challenge.length > 8192
+ || !/^[0-9a-f]{64}$/.test(data.challenge_sha256)
+ || !/^[0-9a-f]{64}$/.test(data.public_key_fingerprint)
+ ) throw new Error('Invalid SSH signing request fields.');
+ if (data.algorithm !== 'ssh-ed25519') throw new Error('Unsupported SSH signing algorithm.');
+ if (
+ !Number.isFinite(data.expires_at)
+ || data.expires_at <= Date.now() / 1000
+ || data.expires_at > Date.now() / 1000 + 20
+ ) {
+ throw new Error('SSH signing request expired.');
+ }
+ if (handledSshSignRequestIds.has(data.request_id)) throw new Error('SSH signing request was already handled.');
+ const draft = pendingSshConnectionDrafts.get(data.terminal_id);
+ if (
+ !draft
+ || !draft.useKey
+ || draft.profileId !== data.profile_id
+ || draft.keyId !== data.key_id
+ || draft.publicKeyFingerprintHex !== data.public_key_fingerprint
+ ) {
+ throw new Error('SSH signing request does not match the active connection.');
+ }
+ const profile = findSshProfile(data.profile_id);
+ const record = await validateBrowserSshKeyRecord(await loadSshKeyRecord(data.key_id), profile);
+ const challenge = base64ToUint8Array(data.challenge);
+ if (!challenge.byteLength || challenge.byteLength > 4096) throw new Error('Invalid SSH signing challenge.');
+ if ((await arrayBufferToHex(challenge)) !== data.challenge_sha256) {
+ throw new Error('SSH signing challenge hash mismatch.');
+ }
+ handledSshSignRequestIds.add(data.request_id);
+ if (handledSshSignRequestIds.size > 256) {
+ handledSshSignRequestIds.delete(handledSshSignRequestIds.values().next().value);
+ }
+ const signature = await crypto.subtle.sign(
+ { name: SSH_KEY_ALGORITHM },
+ record.privateKey,
+ challenge
+ );
+ if (new Uint8Array(signature).byteLength !== 64) throw new Error('Invalid Ed25519 signature length.');
+ socket.emit('ssh_browser_sign_response', {
+ ...responseBase,
+ status: 'ok',
+ signature: arrayBufferToBase64(signature)
+ });
+ } catch (err) {
+ fail(err && err.message ? err.message : 'Browser SSH signing failed.');
+ }
+ }
+ socket.on('ssh_browser_sign_request', handleBrowserSshSignRequest);
socket.on('browser_pairing_file', data => {
clearBrowserPairingResponseTimer();
setBrowserAuthBusy(false);
@@ -5100,6 +5878,8 @@ Access token required
});
clearBrowserPairingResponseTimer();
setBrowserAuthBusy(false);
+ pendingSshConnectionDrafts.clear();
+ handledSshSignRequestIds.clear();
clearSessionRenewTimer();
setServerConnectionState('unavailable');
terminals.forEach(state => {
@@ -5153,6 +5933,38 @@ Access token required
connectBtn.disabled = state !== 'available';
}
+ function retryServerConnectionNow() {
+ if (!socket || socket.connected || serverConnectionState !== 'unavailable') return false;
+ recordConnectionDiagnostic('socket.retry_now', {
+ online: navigator.onLine,
+ visibility: document.visibilityState
+ });
+ if (serverRetryNowBtn) {
+ serverRetryNowBtn.disabled = true;
+ serverRetryNowBtn.innerText = 'Retrying...';
+ setTimeout(() => {
+ serverRetryNowBtn.disabled = false;
+ serverRetryNowBtn.innerText = 'Retry Now';
+ }, 1000);
+ }
+ const manager = socket.io;
+ if (manager && manager._readyState === 'closed' && manager._reconnecting) {
+ manager.cleanup();
+ manager._reconnecting = false;
+ if (manager.backoff) manager.backoff.reset();
+ }
+ if (manager && manager._readyState === 'closed') {
+ manager.open(err => {
+ if (!err) return;
+ manager._reconnecting = false;
+ manager.reconnect();
+ });
+ } else {
+ socket.connect();
+ }
+ return true;
+ }
+
function isSessionRequiredConnectError(err) {
if (!err) return false;
return (
@@ -5342,6 +6154,17 @@ Access token required
formData.password = sshPasswordInput.value;
const matchingProfile = findSshProfileForTarget(formData);
if (matchingProfile) formData.profile_name = matchingProfile.name;
+ if (
+ isBrowserSshKeyAllowedByPolicy()
+ && quickConnectSshKeyRecord
+ && sshUseBrowserKeyInput.checked
+ ) {
+ formData.password = '';
+ formData.use_browser_key = true;
+ formData.profile_id = quickConnectSshKeyRecord.ownerProfileId;
+ formData.key_id = quickConnectSshKeyRecord.keyId;
+ formData.browser_public_key = quickConnectSshKeyRecord.publicKeyRawB64;
+ }
} else if (connectionType === 'local_shell' && localShellKindSelect.value) {
formData.local_shell_kind = localShellKindSelect.value;
} else if (connectionType === 'uart') {
@@ -5359,6 +6182,10 @@ Access token required
});
sshSaveHistoryInput.checked = prefs.saveSshHistory;
sshSaveSessionInput.checked = false;
+ sshUseBrowserKeyInput.checked = false;
+ sshUseBrowserKeyLabel.hidden = true;
+ quickConnectSshKeyRecord = null;
+ applyQuickConnectKeySelection();
selectedSshPickerEntry = null;
updateSshProfileIndicator();
setSshSessionMessage('');
@@ -5385,7 +6212,12 @@ Access token required
saveHistory: !!sshSaveHistoryInput.checked,
saveSession: !!sshSaveSessionInput.checked,
profileName: normalizeSshProfileName(formData.profile_name),
- useKey: false
+ useKey: formData.use_browser_key === true,
+ profileId: typeof formData.profile_id === 'string' ? formData.profile_id : null,
+ keyId: typeof formData.key_id === 'string' ? formData.key_id : null,
+ publicKeyFingerprintHex: quickConnectSshKeyRecord
+ ? quickConnectSshKeyRecord.publicKeyFingerprintHex
+ : null
});
} else {
pendingSshConnectionDrafts.delete(formData.terminal_id);
@@ -5411,6 +6243,7 @@ Access token required
[sshHostInput, sshPortInput, sshUsernameInput].forEach(input => {
input.addEventListener('input', updateSshProfileIndicator);
});
+ sshUseBrowserKeyInput.onchange = applyQuickConnectKeySelection;
document.addEventListener('click', event => {
if (!event.target.closest('.ssh-session-picker')) closeSshSessionPicker();
});
@@ -5421,6 +6254,15 @@ Access token required
sshProfileCreateBtn.onclick = () => {
createSshProfileEditor().catch(err => setSshProfileStatus(err.message || 'Profile could not be created.', true));
};
+ sshProfileKeyEnabledInput.onchange = () => {
+ toggleSshProfileKey()
+ .catch(err => setSshProfileStatus(err.message || 'Browser SSH key could not be generated.', true));
+ };
+ sshProfileKeyCopyBtn.onclick = () => {
+ if (!editingSshKeyRecord) return;
+ copyToClipboard(editingSshKeyRecord.publicKeyOpenSsh);
+ setSshProfileStatus('SSH public key copied.');
+ };
sshProfileSaveBtn.onclick = () => {
saveSshProfileEditor().catch(err => setSshProfileStatus(err.message || 'Profile could not be saved.', true));
};
@@ -5436,6 +6278,29 @@ Access token required
sshHistoryClearBtn.onclick = () => {
clearSshHistory().catch(err => setSshProfileStatus(err.message || 'SSH history could not be cleared.', true));
};
+ if (settingsExportBtn) {
+ settingsExportBtn.onclick = () => {
+ settingsExportBtn.disabled = true;
+ exportBrowserSettings()
+ .catch(err => { settingsTransferStatus.textContent = err.message || 'Settings could not be exported.'; })
+ .finally(() => { settingsExportBtn.disabled = false; });
+ };
+ }
+ if (settingsImportBtn && settingsImportFile) {
+ settingsImportBtn.onclick = () => settingsImportFile.click();
+ settingsImportFile.onchange = () => {
+ const file = settingsImportFile.files && settingsImportFile.files[0];
+ settingsImportFile.value = '';
+ if (!file) return;
+ if (file.size > 1048576) {
+ settingsTransferStatus.textContent = 'Settings file is too large.';
+ return;
+ }
+ file.text()
+ .then(importBrowserSettingsText)
+ .catch(err => { settingsTransferStatus.textContent = err.message || 'Settings could not be imported.'; });
+ };
+ }
browserAuthUrlForm.onsubmit = event => {
event.preventDefault();
setBrowserAuthUrlError('');
diff --git a/terminal_backends/ssh.py b/terminal_backends/ssh.py
index b28218e..15035f6 100644
--- a/terminal_backends/ssh.py
+++ b/terminal_backends/ssh.py
@@ -1,7 +1,9 @@
import base64
import codecs
import getpass
+import hashlib
import os
+import re
from pathlib import Path
from .base import BackendAction, BackendSettingSchema, BackendStartFieldSchema, TerminalBackendPlugin, TerminalBridge
@@ -9,6 +11,62 @@
SSH_PROFILE_NAME_MAX_LENGTH = 64
+SSH_BROWSER_KEY_ID_MAX_LENGTH = 128
+SSH_BROWSER_KEY_ID_PATTERN = re.compile(r'^[A-Za-z0-9_-]+$')
+
+
+class BrowserSSHKeyError(Exception):
+ pass
+
+
+class BrowserEd25519Key:
+ name = 'ssh-ed25519'
+ public_blob = None
+
+ def __init__(self, paramiko_module, public_key, sign_callback):
+ if not isinstance(public_key, bytes) or len(public_key) != 32:
+ raise BrowserSSHKeyError('Browser Ed25519 public key must be 32 bytes.')
+ self._paramiko = paramiko_module
+ self._public_key = public_key
+ self._sign_callback = sign_callback
+ self._verifier = paramiko_module.Ed25519Key(data=self.asbytes())
+
+ def asbytes(self):
+ message = self._paramiko.Message()
+ message.add_string(self.name)
+ message.add_string(self._public_key)
+ return message.asbytes()
+
+ def get_name(self):
+ return self.name
+
+ def get_bits(self):
+ return 256
+
+ def get_fingerprint(self):
+ return hashlib.md5(self.asbytes()).digest()
+
+ def can_sign(self):
+ return True
+
+ def sign_ssh_data(self, data, algorithm=None):
+ if algorithm != self.name:
+ raise BrowserSSHKeyError('Browser SSH key only supports ssh-ed25519 signatures.')
+ try:
+ signature = self._sign_callback(data, algorithm)
+ except BrowserSSHKeyError:
+ raise
+ except Exception as exc:
+ raise BrowserSSHKeyError(str(exc)) from exc
+ if not isinstance(signature, bytes) or len(signature) != 64:
+ raise BrowserSSHKeyError('Browser Ed25519 signature must be 64 bytes.')
+ signature_message = self._paramiko.Message()
+ signature_message.add_string(self.name)
+ signature_message.add_string(signature)
+ verifier_message = self._paramiko.Message(signature_message.asbytes())
+ if not self._verifier.verify_ssh_sig(data, verifier_message):
+ raise BrowserSSHKeyError('Browser SSH signature verification failed.')
+ return signature_message
class SSHBridge(TerminalBridge):
@@ -24,16 +82,31 @@ def __init__(
get_paramiko,
ssh_term,
local_public_key_types,
+ request_browser_signature=None,
):
super().__init__(owner_session, terminal_id)
self._get_paramiko = get_paramiko
self._ssh_term = ssh_term
self._local_public_key_types = local_public_key_types
+ self._request_browser_signature = request_browser_signature
+ self._browser_signer_sid = None
self.ssh = None
+ self.auth_method = None
self._reset_ssh_client()
self.channel = None
self._output_decoder = codecs.getincrementaldecoder('utf-8')(errors='ignore')
+ def metadata(self, cols=None, rows=None):
+ metadata = super().metadata(cols=cols, rows=rows)
+ if self.auth_method:
+ metadata['auth_method'] = self.auth_method
+ return metadata
+
+ def set_browser_signer_sid(self, sid):
+ if self._browser_signer_sid is not None and self._browser_signer_sid != sid:
+ raise BrowserSSHKeyError('Browser SSH signer is already assigned.')
+ self._browser_signer_sid = sid
+
def _reset_ssh_client(self, trust_unknown_host=False):
paramiko_module = self._get_paramiko()
if self.ssh:
@@ -366,14 +439,45 @@ def _connect_with_local_keys(self, host, port, user, password):
return False, '; '.join(auth_errors)
- def connect(self, host, port, user, password=None, cols=80, rows=24):
+ def _connect_with_browser_key(self, host, port, user, browser_key, is_localhost):
+ if not self._browser_signer_sid or not self._request_browser_signature:
+ raise BrowserSSHKeyError('Browser SSH signer is unavailable.')
+ paramiko_module = self._get_paramiko()
+ public_key = base64.b64decode(browser_key['public_key'].encode('ascii'), validate=True)
+ signer_key = BrowserEd25519Key(
+ paramiko_module,
+ public_key,
+ lambda data, algorithm: self._request_browser_signature(
+ self,
+ self._browser_signer_sid,
+ browser_key,
+ data,
+ algorithm,
+ ),
+ )
+ self._reset_ssh_client(trust_unknown_host=is_localhost)
+ self.ssh.connect(
+ host,
+ port=int(port),
+ username=user,
+ password=None,
+ pkey=signer_key,
+ timeout=15,
+ allow_agent=False,
+ look_for_keys=False,
+ )
+ self.auth_method = 'browser-key'
+
+ def connect(self, host, port, user, password=None, browser_key=None, cols=80, rows=24):
paramiko_module = self._get_paramiko()
try:
pwd = password if password else ""
log_message(f"[*] Attempting SSH connection for {user!r} at {host!r}:{port}...")
is_localhost = self._is_local_target(host)
- if is_localhost and not pwd:
+ if browser_key:
+ self._connect_with_browser_key(host, port, user, browser_key, is_localhost)
+ elif is_localhost and not pwd:
success, key_error = self._connect_with_local_keys(host, port, user, None)
if not success:
setup_availability = self._get_local_key_setup_availability(user)
@@ -392,6 +496,7 @@ def connect(self, host, port, user, password=None, cols=80, rows=24):
raise paramiko_module.AuthenticationException(
f"Local public key auth failed: {key_error or 'no usable local key found'}"
)
+ self.auth_method = 'host-key'
else:
self._reset_ssh_client(trust_unknown_host=is_localhost)
self.ssh.connect(
@@ -403,11 +508,18 @@ def connect(self, host, port, user, password=None, cols=80, rows=24):
allow_agent=False,
look_for_keys=False,
)
+ self.auth_method = 'password'
self.channel = self.ssh.invoke_shell(term=self._ssh_term, width=cols, height=rows)
self.channel.setblocking(0)
log_message(f"[+] SSH connection established for {self.sid}")
return True, None
+ except BrowserSSHKeyError as exc:
+ log_message(f"[!] Browser SSH key error: {exc}")
+ return False, {
+ 'message': str(exc),
+ 'error_code': 'ssh_browser_key_failed',
+ }
except Exception as e:
error_msg = str(e)
log_message(f"[!] SSH Connection Error: {error_msg}")
@@ -494,6 +606,7 @@ def __init__(
max_password_bytes,
has_control_chars,
is_allowed_for_client,
+ is_browser_key_allowed,
allowed_action_types,
backend_action_store,
bridge_kwargs,
@@ -512,6 +625,7 @@ def __init__(
self._max_password_bytes = max_password_bytes
self._has_control_chars = has_control_chars
self._is_allowed_for_client = is_allowed_for_client
+ self._is_browser_key_allowed = is_browser_key_allowed
self._allowed_action_types = allowed_action_types
self._backend_action_store = backend_action_store
self._bridge_kwargs = bridge_kwargs
@@ -531,6 +645,12 @@ def build_policy_option(self, context=None, browser_authorized=False):
'allowed': allowed,
'authorization_available': not allowed,
'browser_authorized': bool(browser_authorized),
+ 'browser_key_allowed': bool(
+ allowed and self._is_browser_key_allowed(
+ client_ip,
+ browser_authorized=browser_authorized,
+ )
+ ),
}
def get_settings_schema(self):
@@ -776,12 +896,51 @@ def validate_start_payload(self, data, terminal_id, client_ip, browser_authorize
if self._has_control_chars(profile_name):
return None, 'SSH profile name contains invalid control characters.'
+ use_browser_key = data.get('use_browser_key', False)
+ if not isinstance(use_browser_key, bool):
+ return None, 'Use browser key must be a boolean.'
+ browser_key = None
+ if use_browser_key:
+ if not self._is_browser_key_allowed(client_ip, browser_authorized=browser_authorized):
+ return None, {
+ 'message': 'Browser SSH keys require a local browser or an authorized HTTPS connection.',
+ 'error_code': 'ssh_browser_key_insecure_transport',
+ }
+ if password:
+ return None, 'Password must be empty when browser key authentication is selected.'
+ profile_id = data.get('profile_id')
+ key_id = data.get('key_id')
+ for field_name, field_value in (('SSH profile id', profile_id), ('SSH key id', key_id)):
+ if (
+ not isinstance(field_value, str)
+ or not field_value
+ or len(field_value) > SSH_BROWSER_KEY_ID_MAX_LENGTH
+ or not SSH_BROWSER_KEY_ID_PATTERN.fullmatch(field_value)
+ ):
+ return None, f'{field_name} is invalid.'
+ public_key = data.get('browser_public_key')
+ if not isinstance(public_key, str):
+ return None, 'Browser SSH public key must be a Base64 string.'
+ try:
+ public_key_bytes = base64.b64decode(public_key.encode('ascii'), validate=True)
+ except (UnicodeEncodeError, ValueError):
+ return None, 'Browser SSH public key is invalid.'
+ if len(public_key_bytes) != 32:
+ return None, 'Browser SSH public key must be 32 bytes.'
+ browser_key = {
+ 'profile_id': profile_id,
+ 'key_id': key_id,
+ 'public_key': public_key,
+ 'fingerprint': hashlib.sha256(public_key_bytes).hexdigest(),
+ }
+
return {
'host': host,
'port': port,
'username': user,
'password': password,
'profile_name': profile_name or None,
+ 'browser_key': browser_key,
}, None
def create_bridge(self, session_token, terminal_id, payload):
@@ -797,6 +956,7 @@ def connect_bridge(self, bridge, payload, cols, rows):
payload['port'],
payload['username'],
payload['password'],
+ browser_key=payload.get('browser_key'),
cols=cols,
rows=rows,
)
diff --git a/tests/agent_backend_smoke.py b/tests/agent_backend_smoke.py
index 4dfff3b..75966fd 100644
--- a/tests/agent_backend_smoke.py
+++ b/tests/agent_backend_smoke.py
@@ -1,4 +1,5 @@
import base64
+import hashlib
import sys
import tempfile
import threading
@@ -17,6 +18,7 @@
import scripts.access_window as access_window
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts'))
import agent_cli
+from terminal_backends.ssh import BrowserEd25519Key, BrowserSSHKeyError
def make_test_png_base64(width, height):
@@ -91,6 +93,7 @@ def create_terminal_input_proposal(self, context, run):
def reset_state():
standterm.bridges.clear()
+ standterm.pending_terminal_starts.clear()
standterm.pending_localhost_key_setups.clear()
standterm.active_sessions.clear()
standterm.socket_session_tokens.clear()
@@ -111,6 +114,7 @@ def reset_state():
standterm.agent_headless_terminal_mirror_store.clear()
standterm.agent_viewport_snapshot_store.clear()
standterm.agent_viewport_render_request_store.clear()
+ standterm.browser_ssh_sign_request_store.clear()
standterm.external_agent_attach_store.clear()
standterm.operator_observations.clear()
standterm.serial_port_cache['expires_at'] = 0
@@ -3785,6 +3789,207 @@ def test_remote_ssh_requires_browser_authorization_or_explicit_remote_access():
standterm.os.environ['STANDTERM_ALLOW_REMOTE_SSH'] = original_standterm_env
+def test_browser_ssh_key_payload_requires_local_or_authorized_https_transport():
+ original_https_enabled = standterm.HTTPS_ENABLED
+ data = {
+ 'connection_type': standterm.CONNECTION_TYPE_SSH,
+ 'terminal_id': standterm.TERMINAL_ID_MAIN,
+ 'host': 'example.test',
+ 'port': 22,
+ 'username': 'operator',
+ 'password': '',
+ 'profile_name': 'Build Server',
+ 'profile_id': 'profile-123',
+ 'use_browser_key': True,
+ 'key_id': 'key-123',
+ 'browser_public_key': base64.b64encode(b'k' * 32).decode('ascii'),
+ }
+ try:
+ standterm.HTTPS_ENABLED = False
+ payload, error = standterm.validate_start_ssh_payload(
+ data,
+ '127.0.0.1',
+ browser_authorized=False,
+ )
+ assert error is None
+ assert payload['browser_key']['profile_id'] == 'profile-123'
+ assert payload['browser_key']['key_id'] == 'key-123'
+ assert payload['password'] == ''
+
+ payload, error = standterm.validate_start_ssh_payload(
+ data,
+ '203.0.113.10',
+ browser_authorized=True,
+ )
+ assert payload is None
+ assert error['error_code'] == 'ssh_browser_key_insecure_transport'
+
+ standterm.HTTPS_ENABLED = True
+ payload, error = standterm.validate_start_ssh_payload(
+ data,
+ '203.0.113.10',
+ browser_authorized=True,
+ )
+ assert error is None
+ assert payload['browser_key']['fingerprint'] == hashlib.sha256(b'k' * 32).hexdigest()
+
+ payload, error = standterm.validate_start_ssh_payload(
+ dict(data, password='must-not-fallback'),
+ '127.0.0.1',
+ browser_authorized=False,
+ )
+ assert payload is None
+ assert error == 'Password must be empty when browser key authentication is selected.'
+
+ for invalid_public_key in (
+ base64.b64encode(b'k' * 31).decode('ascii'),
+ base64.b64encode(b'k' * 33).decode('ascii'),
+ 'not-base64',
+ ):
+ payload, error = standterm.validate_start_ssh_payload(
+ dict(data, browser_public_key=invalid_public_key),
+ '127.0.0.1',
+ browser_authorized=False,
+ )
+ assert payload is None
+ assert error in {
+ 'Browser SSH public key is invalid.',
+ 'Browser SSH public key must be 32 bytes.',
+ }
+ finally:
+ standterm.HTTPS_ENABLED = original_https_enabled
+
+
+def test_browser_ed25519_key_wraps_and_verifies_remote_signature():
+ from cryptography.hazmat.primitives import serialization
+ from cryptography.hazmat.primitives.asymmetric import ed25519
+
+ private_key = ed25519.Ed25519PrivateKey.generate()
+ public_key = private_key.public_key().public_bytes(
+ encoding=serialization.Encoding.Raw,
+ format=serialization.PublicFormat.Raw,
+ )
+ challenges = []
+ signer = BrowserEd25519Key(
+ standterm.get_paramiko(),
+ public_key,
+ lambda data, algorithm: challenges.append((data, algorithm)) or private_key.sign(data),
+ )
+ challenge = b'structured SSH authentication challenge'
+ signature_message = signer.sign_ssh_data(challenge, 'ssh-ed25519')
+ verifier = standterm.get_paramiko().Ed25519Key(data=signer.asbytes())
+
+ assert challenges == [(challenge, 'ssh-ed25519')]
+ assert verifier.verify_ssh_sig(
+ challenge,
+ standterm.get_paramiko().Message(signature_message.asbytes()),
+ ) is True
+ assert signer.get_name() == 'ssh-ed25519'
+ assert signer.get_bits() == 256
+
+ wrong_private_key = ed25519.Ed25519PrivateKey.generate()
+ wrong_signer = BrowserEd25519Key(
+ standterm.get_paramiko(),
+ public_key,
+ lambda data, _algorithm: wrong_private_key.sign(data),
+ )
+ try:
+ wrong_signer.sign_ssh_data(challenge, 'ssh-ed25519')
+ raise AssertionError('wrong browser key signature was accepted')
+ except BrowserSSHKeyError as exc:
+ assert 'verification failed' in str(exc)
+
+
+def test_browser_ssh_sign_request_store_is_sid_bound_and_fail_closed():
+ store = standterm.BrowserSSHSignRequestStore(timeout_seconds=0.05)
+ browser_key = {
+ 'profile_id': 'profile-1',
+ 'key_id': 'key-1',
+ 'fingerprint': 'f' * 64,
+ }
+ request_payload, error = store.create(
+ 'session-1',
+ 'terminal-1',
+ 'sid-a',
+ 'browser-a',
+ browser_key,
+ b'challenge',
+ 'ssh-ed25519',
+ )
+ assert error is None
+ second_payload, second_error = store.create(
+ 'session-1',
+ 'terminal-2',
+ 'sid-a',
+ 'browser-a',
+ browser_key,
+ b'challenge-2',
+ 'ssh-ed25519',
+ )
+ assert second_payload is None
+ assert second_error == 'ssh_browser_key_sign_busy'
+
+ response = {
+ 'request_id': request_payload['request_id'],
+ 'terminal_id': request_payload['terminal_id'],
+ 'profile_id': request_payload['profile_id'],
+ 'key_id': request_payload['key_id'],
+ 'challenge_sha256': request_payload['challenge_sha256'],
+ 'status': 'ok',
+ 'signature': base64.b64encode(b's' * 64).decode('ascii'),
+ }
+ assert store.resolve('session-1', 'sid-b', response) == 'ssh_browser_key_sign_stale'
+ assert store.resolve('session-1', 'sid-a', dict(response, key_id='key-2')) == 'ssh_browser_key_sign_stale'
+ assert store.resolve('session-1', 'sid-a', response) is None
+ assert store.resolve('session-1', 'sid-a', response) == 'ssh_browser_key_sign_stale'
+ signature, wait_error = store.wait(request_payload)
+ assert wait_error is None
+ assert signature == b's' * 64
+
+ timeout_payload, error = store.create(
+ 'session-1',
+ 'terminal-1',
+ 'sid-a',
+ 'browser-a',
+ browser_key,
+ b'timeout',
+ 'ssh-ed25519',
+ )
+ assert error is None
+ signature, wait_error = store.wait(timeout_payload)
+ assert signature is None
+ assert wait_error == 'ssh_browser_key_sign_timeout'
+
+ cancelled_payload, error = store.create(
+ 'session-1',
+ 'terminal-1',
+ 'sid-a',
+ 'browser-a',
+ browser_key,
+ b'cancelled',
+ 'ssh-ed25519',
+ )
+ assert error is None
+ store.discard('session-1', sid='sid-a')
+ signature, wait_error = store.wait(cancelled_payload)
+ assert signature is None
+ assert wait_error == 'ssh_browser_key_sign_stale'
+
+
+def test_terminal_start_tokens_reject_stale_background_connections():
+ first = standterm.begin_terminal_start('session-1', 'main')
+ assert standterm.is_current_terminal_start('session-1', 'main', first) is True
+
+ replacement = standterm.begin_terminal_start('session-1', 'main')
+ assert standterm.is_current_terminal_start('session-1', 'main', first) is False
+ assert standterm.finish_terminal_start('session-1', 'main', first) is False
+ assert standterm.finish_terminal_start('session-1', 'main', replacement) is True
+
+ cancelled = standterm.begin_terminal_start('session-1', 'secondary')
+ standterm.cancel_terminal_starts('session-1', terminal_id='secondary')
+ assert standterm.is_current_terminal_start('session-1', 'secondary', cancelled) is False
+
+
def test_remote_unauthorized_socket_cannot_attach_existing_ssh_terminal():
flask_client = standterm.app.test_client()
response = flask_client.get('/?token=' + standterm.ACCESS_TOKEN)
@@ -4654,6 +4859,7 @@ def __getattr__(self, name):
max_password_bytes=standterm.MAX_PASSWORD_BYTES,
has_control_chars=standterm.has_control_chars,
is_allowed_for_client=lambda _client_ip, browser_authorized=False: True,
+ is_browser_key_allowed=lambda _client_ip, browser_authorized=False: True,
allowed_action_types={'offer_localhost_key_setup'},
backend_action_store=action_store,
bridge_kwargs={},
@@ -5772,6 +5978,10 @@ def main():
test_terminal_policy_creates_authorized_dir_for_fresh_checkout,
test_wsl_client_ips_require_explicit_trust_for_local_resources,
test_remote_ssh_requires_browser_authorization_or_explicit_remote_access,
+ test_browser_ssh_key_payload_requires_local_or_authorized_https_transport,
+ test_browser_ed25519_key_wraps_and_verifies_remote_signature,
+ test_browser_ssh_sign_request_store_is_sid_bound_and_fail_closed,
+ test_terminal_start_tokens_reject_stale_background_connections,
test_remote_unauthorized_socket_cannot_attach_existing_ssh_terminal,
test_browser_authorization_success_refreshes_visible_terminal_list,
test_settings_capabilities_are_separate_from_local_resource_access,
diff --git a/tests/agent_browser_smoke.py b/tests/agent_browser_smoke.py
index e670f75..0f22354 100644
--- a/tests/agent_browser_smoke.py
+++ b/tests/agent_browser_smoke.py
@@ -1,3 +1,5 @@
+import base64
+import hashlib
import os
import queue
import re
@@ -292,6 +294,19 @@ def test_server_unavailable_waits_for_reconnect(browser, access_url):
check(unavailable['messageDisplay'] == 'block', 'server unavailable guidance was not visible')
check(unavailable['connectionFormDisplay'] == 'none', 'connection picker remained visible while the server was unavailable')
check(unavailable['connectDisabled'] is True, 'terminal connect button remained enabled while the server was unavailable')
+ check(page.locator('#server-retry-now').is_visible(), 'Retry Now was not visible with the disconnect warning')
+ page.click('#server-retry-now')
+ check(
+ page.locator('#server-retry-now').inner_text() == 'Retrying...',
+ 'Retry Now did not trigger an immediate reconnect attempt',
+ )
+ check(
+ any(
+ event['event'] == 'socket.retry_now'
+ for event in page.evaluate('() => window.terminalTest.getConnectionDiagnostics()')
+ ),
+ 'Retry Now did not record an explicit reconnect attempt',
+ )
context.set_offline(False)
page.wait_for_function(
@@ -2040,6 +2055,7 @@ def test_ssh_profile_picker_and_settings_save_semantics(browser, access_url):
policy.default_connection = 'ssh';
const ssh = policy.connection_options.find(option => option.connection_type === 'ssh');
ssh.allowed = true;
+ ssh.browser_key_allowed = true;
window.terminalTest.applyTerminalPolicy(policy);
const sshMode = document.querySelector('input[name="connection_type"][value="ssh"]');
sshMode.checked = true;
@@ -2264,6 +2280,251 @@ def test_ssh_profile_picker_and_settings_save_semantics(browser, access_url):
close_context(context)
+def test_browser_ssh_key_lifecycle_and_settings_transfer(browser, access_url):
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
+
+ context = browser.new_context(viewport={'width': 1280, 'height': 800})
+ page = context.new_page()
+ try:
+ page.goto(debug_url(access_url), wait_until='domcontentloaded')
+ page.wait_for_function('() => !!window.terminalTest', timeout=10000)
+ page.wait_for_function(
+ "() => window.terminalTest.getSocketState().connected === true",
+ timeout=10000,
+ )
+ page.evaluate(
+ """async () => {
+ await window.terminalTest.setSshSessionState({
+ profiles: [
+ { id: 'profile-primary', sortOrder: 0, name: 'Primary', host: 'primary.example', port: '22', username: 'alice', keyId: null },
+ { id: 'profile-imported', sortOrder: 1, name: 'Imported', host: 'imported.example', port: '2200', username: 'bob', keyId: null }
+ ],
+ history: [
+ { id: 'history-imported', host: 'recent.example', port: '22', username: 'recent', lastUsedAt: '2026-08-26T01:00:00.000Z' }
+ ]
+ });
+ const policy = window.terminalTest.getTerminalPolicy();
+ policy.force_connection = null;
+ policy.default_connection = 'ssh';
+ const ssh = policy.connection_options.find(option => option.connection_type === 'ssh');
+ ssh.allowed = true;
+ ssh.browser_key_allowed = true;
+ window.terminalTest.applyTerminalPolicy(policy);
+ const sshMode = document.querySelector('input[name="connection_type"][value="ssh"]');
+ sshMode.checked = true;
+ sshMode.dispatchEvent(new Event('change', { bubbles: true }));
+ }"""
+ )
+
+ page.click('#quick-settings')
+ page.click('.settings-nav-item[data-tab="ssh-sessions"]')
+ page.click('#ssh-profile-list button[data-profile-id="profile-primary"]')
+ page.wait_for_function(
+ "() => document.getElementById('ssh-profile-name').value === 'Primary'",
+ timeout=5000,
+ )
+ page.check('#ssh-profile-key-enabled')
+ page.wait_for_function(
+ "() => document.getElementById('ssh-profile-key-status').innerText.includes('SHA256:')",
+ timeout=10000,
+ )
+ check(
+ page.locator('#ssh-profile-key-public').input_value().startswith('ssh-ed25519 '),
+ 'generated browser SSH key did not expose an OpenSSH public key',
+ )
+ page.click('#ssh-profile-save')
+ page.wait_for_function(
+ "() => document.getElementById('ssh-profile-status').innerText === 'Saved Primary.'",
+ timeout=5000,
+ )
+ metadata = page.evaluate(
+ "() => window.terminalTest.getBrowserSshKeyMetadataForTest('profile-primary')"
+ )
+ check(metadata['algorithm'] == 'Ed25519', 'browser SSH key did not use Ed25519')
+ check(metadata['privateKeyExtractable'] is False, 'browser SSH private key was extractable')
+ check(len(base64.b64decode(metadata['publicKeyRawB64'])) == 32, 'Ed25519 public key was not 32 bytes')
+ key_type, public_blob_b64 = metadata['publicKeyOpenSsh'].split()
+ public_blob = base64.b64decode(public_blob_b64)
+ key_type_length = int.from_bytes(public_blob[:4], 'big')
+ raw_length_offset = 4 + key_type_length
+ raw_length = int.from_bytes(public_blob[raw_length_offset:raw_length_offset + 4], 'big')
+ check(key_type == 'ssh-ed25519', 'OpenSSH public key used the wrong key type')
+ check(public_blob[4:raw_length_offset] == b'ssh-ed25519', 'OpenSSH public key blob omitted its key type')
+ check(raw_length == 32 and len(public_blob) == raw_length_offset + 4 + raw_length, 'OpenSSH public key blob is invalid')
+ expected_fingerprint = 'SHA256:' + base64.b64encode(hashlib.sha256(public_blob).digest()).decode('ascii').rstrip('=')
+ check(metadata['fingerprint'] == expected_fingerprint, 'browser SSH key fingerprint is not OpenSSH-compatible')
+
+ challenge = b'StandTerm browser-owned SSH signer smoke challenge'
+ signature_b64 = page.evaluate(
+ """args => window.terminalTest.signBrowserSshChallengeForTest(
+ args.profileId, args.challenge
+ )""",
+ {'profileId': 'profile-primary', 'challenge': base64.b64encode(challenge).decode('ascii')},
+ )
+ signature = base64.b64decode(signature_b64)
+ check(len(signature) == 64, 'browser returned an invalid Ed25519 signature length')
+ Ed25519PublicKey.from_public_bytes(base64.b64decode(metadata['publicKeyRawB64'])).verify(
+ signature,
+ challenge,
+ )
+
+ page.click('#settings-close')
+ check(
+ page.evaluate("() => window.terminalTest.setConnectionTypeForTest('ssh')") == 'ssh',
+ 'test policy did not select SSH Quick Connect',
+ )
+ page.evaluate(
+ """() => {
+ document.getElementById('ssh-session-picker-toggle').click();
+ document.querySelector('.ssh-session-picker-entry[data-entry-id="profile-primary"]').click();
+ }"""
+ )
+ page.wait_for_function(
+ "() => !document.getElementById('ssh-use-browser-key-label').hidden",
+ timeout=5000,
+ )
+ check(page.locator('#ssh-use-browser-key').is_checked(), 'exact keyed profile did not default Use key on')
+ check(page.locator('#password').is_disabled(), 'Use key did not disable the password field')
+ form_data = page.evaluate('() => window.terminalTest.getConnectionFormDataForTest()')
+ check(
+ form_data.get('use_browser_key') is True,
+ f'Quick Connect omitted the browser key control field: {form_data!r}',
+ )
+ check(form_data['password'] == '', 'Quick Connect sent a password with browser key authentication')
+ check(form_data['profile_id'] == 'profile-primary', 'Quick Connect sent the wrong key owner profile')
+ check(form_data['key_id'] == metadata['keyId'], 'Quick Connect sent the wrong browser key ID')
+
+ page.evaluate(
+ """metadata => {
+ window.terminalTest.stageSshConnectionForTest({
+ host: 'primary.example', port: '22', username: 'alice',
+ useKey: true, profileId: 'profile-primary', keyId: metadata.keyId,
+ publicKeyFingerprintHex: metadata.publicKeyFingerprintHex
+ });
+ window.terminalTest.clearEmitted();
+ }""",
+ metadata,
+ )
+ request_payload = {
+ 'request_id': 'request-valid-signature',
+ 'terminal_id': 'main',
+ 'profile_id': 'profile-primary',
+ 'key_id': metadata['keyId'],
+ 'public_key_fingerprint': metadata['publicKeyFingerprintHex'],
+ 'algorithm': 'ssh-ed25519',
+ 'challenge': base64.b64encode(challenge).decode('ascii'),
+ 'challenge_sha256': hashlib.sha256(challenge).hexdigest(),
+ 'expires_at': time.time() + 10,
+ }
+ page.evaluate(
+ 'payload => window.terminalTest.handleBrowserSshSignRequestForTest(payload)',
+ request_payload,
+ )
+ response = page.evaluate(
+ """() => window.terminalTest.getEmitted()
+ .filter(entry => entry.event === 'ssh_browser_sign_response').at(-1).args[0]"""
+ )
+ check(response['status'] == 'ok', 'structured browser SSH signing request failed')
+ Ed25519PublicKey.from_public_bytes(base64.b64decode(metadata['publicKeyRawB64'])).verify(
+ base64.b64decode(response['signature']),
+ challenge,
+ )
+
+ page.fill('#port', '2222')
+ page.locator('#port').dispatch_event('input')
+ page.wait_for_function(
+ "() => document.getElementById('ssh-use-browser-key-label').hidden",
+ timeout=5000,
+ )
+ check(page.locator('#password').is_enabled(), 'modified profile target kept key-only authentication active')
+
+ envelope = page.evaluate('() => window.terminalTest.createBrowserSettingsEnvelopeForTest()')
+ exported = page.evaluate(
+ 'envelope => window.terminalTest.decodeBrowserSettingsEnvelopeForTest(envelope)',
+ envelope,
+ )
+ check(exported['format'] == 'standterm-browser-settings', 'settings ZIP payload format is incorrect')
+ check('keys' not in exported, 'settings export included an SSH key collection')
+ check(
+ all('keyId' not in profile for profile in exported['ssh']['profiles']),
+ 'settings export included SSH profile key IDs',
+ )
+ exported_text = repr(exported)
+ check('ssh-ed25519 ' not in exported_text, 'settings export included an SSH public key')
+ check(metadata['keyId'] not in exported_text, 'settings export included an SSH key ID')
+
+ page.evaluate(
+ """async keyId => {
+ await window.terminalTest.setSshSessionState({
+ profiles: [
+ { id: 'profile-primary', sortOrder: 0, name: 'Changed Locally', host: 'primary.example', port: '22', username: 'alice', keyId },
+ { id: 'profile-local', sortOrder: 1, name: 'Local Only', host: 'local.example', port: '22', username: 'local', keyId: null }
+ ],
+ history: [
+ { id: 'history-local', host: 'local-recent.example', port: '22', username: 'local', lastUsedAt: '2026-08-26T02:00:00.000Z' }
+ ]
+ });
+ const saveHistory = document.getElementById('ssh-save-history');
+ saveHistory.checked = false;
+ saveHistory.dispatchEvent(new Event('change', { bubbles: true }));
+ }""",
+ metadata['keyId'],
+ )
+ page.once('dialog', lambda dialog: dialog.accept())
+ with page.expect_navigation(wait_until='domcontentloaded', timeout=10000):
+ page.evaluate(
+ 'envelope => window.terminalTest.importBrowserSettingsEnvelopeForTest(envelope)',
+ envelope,
+ )
+ page.wait_for_function('() => !!window.terminalTest', timeout=10000)
+ merged = page.evaluate('() => window.terminalTest.getSshSessionState()')
+ check(
+ [profile['id'] for profile in merged['profiles']]
+ == ['profile-primary', 'profile-local', 'profile-imported'],
+ 'settings import did not update by stable ID and append new profiles',
+ )
+ primary = next(profile for profile in merged['profiles'] if profile['id'] == 'profile-primary')
+ check(primary['name'] == 'Primary', 'settings import did not update the matching stable profile ID')
+ check(primary['keyId'] == metadata['keyId'], 'settings import changed the existing browser key link')
+ check(len(merged['history']) == 2, 'settings import did not merge SSH history')
+ check(page.locator('#ssh-save-history').is_checked(), 'settings import did not restore browser preferences')
+ check(
+ page.evaluate("keyId => window.terminalTest.browserSshKeyRecordExistsForTest(keyId)", metadata['keyId']),
+ 'settings import removed the existing browser private key',
+ )
+
+ page.click('#quick-settings')
+ page.click('.settings-nav-item[data-tab="ssh-sessions"]')
+ page.click('#ssh-profile-list button[data-profile-id="profile-primary"]')
+ page.wait_for_function("() => document.getElementById('ssh-profile-key-enabled').checked", timeout=5000)
+ page.fill('#ssh-profile-name', 'Primary Copy')
+ page.click('#ssh-profile-create')
+ page.wait_for_function(
+ """async () => (await window.terminalTest.getSshSessionState()).profiles
+ .some(profile => profile.name === 'Primary Copy')""",
+ timeout=5000,
+ )
+ copied_state = page.evaluate('() => window.terminalTest.getSshSessionState()')
+ copied = next(profile for profile in copied_state['profiles'] if profile['name'] == 'Primary Copy')
+ check(copied['keyId'] is None, 'Create copied a browser key from the loaded profile')
+
+ page.click('#ssh-profile-list button[data-profile-id="profile-primary"]')
+ page.wait_for_function("() => document.getElementById('ssh-profile-key-enabled').checked", timeout=5000)
+ page.once('dialog', lambda dialog: dialog.accept())
+ page.click('#ssh-profile-delete')
+ page.wait_for_function(
+ """async () => !(await window.terminalTest.getSshSessionState()).profiles
+ .some(profile => profile.id === 'profile-primary')""",
+ timeout=5000,
+ )
+ check(
+ page.evaluate("keyId => window.terminalTest.browserSshKeyRecordExistsForTest(keyId)", metadata['keyId']) is False,
+ 'deleting a keyed profile left its private key orphaned',
+ )
+ finally:
+ close_context(context)
+
+
def main():
sync_playwright, PlaywrightError, _ = load_playwright()
tests = [
@@ -2297,6 +2558,7 @@ def main():
test_terminal_payload_text_is_not_control,
test_ssh_history_and_auto_profile_follow_structured_success,
test_ssh_profile_picker_and_settings_save_semantics,
+ test_browser_ssh_key_lifecycle_and_settings_transfer,
]
proc = None
browser = None