-
+
diff --git a/backend/package.json b/backend/package.json
index 1218b4d..15efc2c 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-backend",
- "version": "0.23.23-beta",
+ "version": "0.23.24-beta",
"description": "Mikrotik Network Management Platform - Backend API",
"main": "dist/index.js",
"scripts": {
diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts
index d1e0303..9d9abd4 100644
--- a/backend/src/db/migrate.ts
+++ b/backend/src/db/migrate.ts
@@ -862,6 +862,37 @@ CREATE TABLE IF NOT EXISTS lte_data_cap_sends (
error TEXT
);
CREATE INDEX IF NOT EXISTS idx_data_cap_sends ON lte_data_cap_sends(device_id, at DESC);
+
+-- Per-device SSH keypairs (#110).
+--
+-- One key per device, never a fleet key: a shared key makes any single
+-- compromise fleet-wide and makes rotating one device impossible without
+-- touching all of them.
+--
+-- status is deliberately not a boolean. A key that has been generated, a key
+-- that has been pushed to the device, and a key that has been *proved* to
+-- authenticate on a fresh connection are three different states, and only the
+-- last is safe to rely on. Collapsing them is how a device ends up unreachable
+-- because something looked applied.
+--
+-- The password is retained alongside. Key auth falling back to a password is a
+-- degraded state worth knowing about; key auth failing with no fallback is an
+-- outage.
+CREATE TABLE IF NOT EXISTS device_ssh_keys (
+ device_id INTEGER PRIMARY KEY REFERENCES devices(id) ON DELETE CASCADE,
+ key_type VARCHAR(24) NOT NULL DEFAULT 'ed25519',
+ public_key TEXT NOT NULL,
+ private_key_encrypted TEXT NOT NULL,
+ fingerprint VARCHAR(80),
+ ssh_username VARCHAR(50),
+ -- pending | deployed | verified | failed
+ status VARCHAR(16) NOT NULL DEFAULT 'pending',
+ last_error TEXT,
+ last_verified_at TIMESTAMPTZ,
+ deployed_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW()
+);
`;
const DEFAULT_SETTINGS = [
diff --git a/backend/src/routes/devices.ts b/backend/src/routes/devices.ts
index 877f5ed..1f00f40 100644
--- a/backend/src/routes/devices.ts
+++ b/backend/src/routes/devices.ts
@@ -1571,6 +1571,121 @@ router.post('/:id/check-update', async (req: Request, res: Response) => {
}
});
+// ─── Per-device SSH keys (#110) ──────────────────────────────────────────────
+
+const SSH_TARGET_COLS =
+ `id, name, ip_address, ssh_port, ssh_username, ssh_password_encrypted,
+ api_port, api_username, api_password_encrypted`;
+
+/**
+ * GET /api/devices/:id/ssh-key — key status.
+ *
+ * The private half is never returned. Not here, not anywhere: it exists so that
+ * nobody has to hold a credential, and handing it back through the API would
+ * defeat the point while widening the surface at the same time.
+ */
+router.get('/:id/ssh-key', async (req: Request, res: Response) => {
+ try {
+ const row = await queryOne>(
+ `SELECT device_id, key_type, public_key, fingerprint, ssh_username, status,
+ last_error, last_verified_at, deployed_at, created_at
+ FROM device_ssh_keys WHERE device_id = $1`,
+ [req.params.id]
+ );
+ res.json({ key: row ?? null });
+ } catch (error) {
+ console.error('Error fetching SSH key:', error);
+ res.status(500).json({ error: 'Failed to fetch SSH key' });
+ }
+});
+
+// POST /api/devices/:id/ssh-key — generate, deploy and verify (rotates if one exists)
+router.post('/:id/ssh-key', requireWrite, async (req: Request, res: Response) => {
+ const target = await queryOne(`SELECT ${SSH_TARGET_COLS} FROM devices WHERE id = $1`, [req.params.id]);
+ if (!target) return res.status(404).json({ error: 'Device not found' });
+
+ try {
+ const { sshKeyService } = await import('../services/SshKeyService');
+ const rotate = (req.body as { rotate?: boolean })?.rotate === true;
+ const key = await sshKeyService.deploy(target, { rotate });
+ res.json({
+ // Not "password access is unchanged" — that was wrong, and testing on
+ // hardware proved it. RouterOS refuses password SSH for a user once a key
+ // is bound to them, so this is a real trade the operator must know about.
+ message: rotate
+ ? 'New key deployed and verified; the previous one has been removed.'
+ : 'Key deployed and verified. Note that RouterOS now requires this key for SSH — '
+ + 'password SSH is disabled for this user until the key is revoked. '
+ + 'The API and WinBox are unaffected.',
+ key: { fingerprint: key.fingerprint, status: key.status, public_key: key.public_key },
+ });
+ } catch (error) {
+ // Deployment failures leave password auth untouched by design, and the
+ // message says so — otherwise the natural fear is that access was lost.
+ console.error('SSH key deployment failed:', (error as Error).message);
+ res.status(502).json({ error: (error as Error).message });
+ }
+});
+
+// POST /api/devices/:id/ssh-key/verify — re-prove an existing key
+router.post('/:id/ssh-key/verify', requireWrite, async (req: Request, res: Response) => {
+ const target = await queryOne(`SELECT ${SSH_TARGET_COLS} FROM devices WHERE id = $1`, [req.params.id]);
+ if (!target) return res.status(404).json({ error: 'Device not found' });
+
+ const { sshKeyService } = await import('../services/SshKeyService');
+ const result = await sshKeyService.verify(target);
+ res.status(result.ok ? 200 : 502).json(
+ result.ok
+ ? { message: 'Key authenticated successfully.' }
+ : { error: `Key did not authenticate: ${result.error}` }
+ );
+});
+
+// DELETE /api/devices/:id/ssh-key — stop using the key, and remove it if reachable
+router.delete('/:id/ssh-key', requireWrite, async (req: Request, res: Response) => {
+ const target = await queryOne(`SELECT ${SSH_TARGET_COLS} FROM devices WHERE id = $1`, [req.params.id]);
+ if (!target) return res.status(404).json({ error: 'Device not found' });
+
+ const { sshKeyService } = await import('../services/SshKeyService');
+ const result = await sshKeyService.revoke(target);
+ res.json({
+ message: result.removedFromDevice
+ ? 'Key revoked and removed from the device.'
+ : `Key forgotten here, but it could not be removed from the device (${result.error}). Remove it manually via /user/ssh-keys.`,
+ removed_from_device: result.removedFromDevice,
+ });
+});
+
+/**
+ * POST /api/devices/ssh-keys/deploy-all — retroactively key every device missing one.
+ *
+ * Sequential rather than parallel: each deployment opens two SSH sessions and
+ * ends by proving the key, and running sixty of those at once would be a
+ * thundering herd against the fleet for no wall-clock gain worth having.
+ */
+router.post('/ssh-keys/deploy-all', requireWrite, async (_req: Request, res: Response) => {
+ const targets = await query(
+ `SELECT ${SSH_TARGET_COLS} FROM devices d
+ WHERE d.status != 'disabled' AND d.ssh_username IS NOT NULL
+ AND d.ssh_password_encrypted IS NOT NULL
+ AND NOT EXISTS (
+ SELECT 1 FROM device_ssh_keys k WHERE k.device_id = d.id AND k.status = 'verified')
+ ORDER BY d.name`
+ );
+
+ const { sshKeyService } = await import('../services/SshKeyService');
+ const results: { device_id: number; name: string; ok: boolean; error?: string }[] = [];
+ for (const t of targets) {
+ try {
+ await sshKeyService.deploy(t);
+ results.push({ device_id: t.id, name: t.name, ok: true });
+ } catch (e) {
+ results.push({ device_id: t.id, name: t.name, ok: false, error: (e as Error).message });
+ }
+ }
+ res.json({ considered: targets.length, succeeded: results.filter((r) => r.ok).length, results });
+});
+
/**
* POST /api/devices/:id/install-update — install a pending RouterOS update.
*
diff --git a/backend/src/services/BackupService.ts b/backend/src/services/BackupService.ts
index 5bba669..c1af3a2 100644
--- a/backend/src/services/BackupService.ts
+++ b/backend/src/services/BackupService.ts
@@ -1,7 +1,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { Client as SSHClient } from 'ssh2';
-import { query } from '../config/database';
+import { query, queryOne } from '../config/database';
import { decrypt } from '../utils/crypto';
const BACKUPS_DIR = process.env.BACKUPS_DIR || '/app/backups';
@@ -25,14 +25,41 @@ export class BackupService {
}
}
- /** Run `/export compact` over SSH and return the raw .rsc text (no file written). */
+ /**
+ * Run `/export compact` over SSH and return the raw .rsc text.
+ *
+ * Prefers a *verified* key over the stored password. Only verified, because a
+ * key that has merely been generated or pushed is not evidence the device will
+ * accept it — and a backup that fails on an unproven credential is worse than
+ * one that succeeds on a working password (#110). If the key fails at connect
+ * time we fall back rather than giving up, so introducing keys can never make
+ * backups less reliable than they were without them.
+ */
async exportConfig(device: BackupDevice): Promise {
const sshUser = device.ssh_username || device.api_username;
+ const port = device.ssh_port || 22;
+
+ const key = await queryOne<{ private_key_encrypted: string; ssh_username: string | null }>(
+ `SELECT private_key_encrypted, ssh_username FROM device_ssh_keys
+ WHERE device_id = $1 AND status = 'verified'`,
+ [device.id]
+ ).catch(() => null);
+
+ if (key) {
+ try {
+ return await this.sshExport(
+ device.ip_address, port, key.ssh_username || sshUser,
+ { privateKey: decrypt(key.private_key_encrypted) }
+ );
+ } catch (e) {
+ console.warn(`[Backup] ${device.name}: key auth failed, falling back to password: ${(e as Error).message}`);
+ }
+ }
+
const sshPass = device.ssh_password_encrypted
? decrypt(device.ssh_password_encrypted)
: decrypt(device.api_password_encrypted);
-
- return this.sshExport(device.ip_address, device.ssh_port || 22, sshUser, sshPass);
+ return this.sshExport(device.ip_address, port, sshUser, { password: sshPass });
}
/** Persist already-fetched .rsc text as a backup file + DB row. Returns the backup id. */
@@ -113,7 +140,7 @@ export class BackupService {
host: string,
port: number,
username: string,
- password: string
+ auth: { password: string } | { privateKey: string }
): Promise {
return new Promise((resolve, reject) => {
const conn = new SSHClient();
@@ -152,7 +179,7 @@ export class BackupService {
reject(err);
});
- conn.connect({ host, port, username, password, readyTimeout: 10_000 });
+ conn.connect({ host, port, username, ...auth, readyTimeout: 10_000 });
});
}
diff --git a/backend/src/services/SshKeyService.ts b/backend/src/services/SshKeyService.ts
new file mode 100644
index 0000000..0b8feb3
--- /dev/null
+++ b/backend/src/services/SshKeyService.ts
@@ -0,0 +1,355 @@
+/**
+ * Deploying and verifying per-device SSH keys.
+ *
+ * The order of operations here is the whole point. A key is generated, pushed,
+ * imported, and only then *proved* by opening a completely fresh connection and
+ * authenticating with it. Nothing downstream trusts a key until that last step
+ * succeeds — because "imported without error" and "the device will let me in"
+ * are different claims, and the gap between them is where fleets become
+ * unreachable (#110).
+ *
+ * The existing password is never removed as a side effect. Losing password
+ * access is an explicit, separate decision, taken after keys are seen working.
+ */
+import { Client as SSHClient } from 'ssh2';
+import { query, queryOne } from '../config/database';
+import { encrypt, decrypt } from '../utils/crypto';
+import {
+ generateDeviceKeyPair, keyFileName, keyComment, isUsablePrivateKey, type KeyStatus,
+} from '../utils/sshKeys';
+import { RouterOSClient } from './mikrotik/RouterOSClient';
+
+const CONNECT_TIMEOUT_MS = 20_000;
+
+export interface SshTarget {
+ id: number;
+ name: string;
+ ip_address: string;
+ ssh_port: number | null;
+ ssh_username: string | null;
+ ssh_password_encrypted: string | null;
+ /** The binary API is the recovery path: it is unaffected by SSH key state. */
+ api_port: number | null;
+ api_username: string | null;
+ api_password_encrypted: string | null;
+}
+
+export interface DeviceKeyRow {
+ device_id: number;
+ key_type: string;
+ public_key: string;
+ private_key_encrypted: string;
+ fingerprint: string | null;
+ ssh_username: string | null;
+ status: KeyStatus;
+ last_error: string | null;
+ last_verified_at: string | null;
+}
+
+type Auth = { password: string } | { privateKey: string };
+
+/** One SSH session, with whichever credential the caller chose. */
+function withSsh(
+ target: SshTarget,
+ username: string,
+ auth: Auth,
+ work: (conn: SSHClient) => Promise,
+): Promise {
+ return new Promise((resolve, reject) => {
+ const conn = new SSHClient();
+ const timer = setTimeout(() => {
+ conn.end();
+ reject(new Error(`SSH timed out connecting to ${target.ip_address}`));
+ }, CONNECT_TIMEOUT_MS);
+
+ conn.on('ready', () => {
+ work(conn)
+ .then((r) => { clearTimeout(timer); conn.end(); resolve(r); })
+ .catch((e) => { clearTimeout(timer); conn.end(); reject(e); });
+ });
+ conn.on('error', (err) => { clearTimeout(timer); reject(err); });
+
+ conn.connect({
+ host: target.ip_address,
+ port: target.ssh_port ?? 22,
+ username,
+ readyTimeout: CONNECT_TIMEOUT_MS,
+ ...auth,
+ });
+ });
+}
+
+/** Run one command and collect its output. */
+function exec(conn: SSHClient, command: string): Promise {
+ return new Promise((resolve, reject) => {
+ conn.exec(command, (err, stream) => {
+ if (err) return reject(err);
+ let out = '';
+ stream.on('data', (d: Buffer) => { out += d.toString(); });
+ stream.stderr.on('data', (d: Buffer) => { out += d.toString(); });
+ stream.on('close', () => resolve(out));
+ });
+ });
+}
+
+/** Upload the public key, which RouterOS needs as a real file before importing. */
+function putFile(conn: SSHClient, remoteName: string, contents: string): Promise {
+ return new Promise((resolve, reject) => {
+ conn.sftp((err, sftp) => {
+ if (err) return reject(err);
+ const ws = sftp.createWriteStream(remoteName);
+ ws.on('close', () => resolve());
+ ws.on('error', reject);
+ ws.end(contents);
+ });
+ });
+}
+
+export class SshKeyService {
+ async getKey(deviceId: number): Promise {
+ return queryOne(`SELECT * FROM device_ssh_keys WHERE device_id = $1`, [deviceId]);
+ }
+
+ /**
+ * Remove an authorised key from the device over the binary API.
+ *
+ * The API is deliberately the tool for this. Once a key is bound to a user,
+ * RouterOS refuses password SSH for that user, so a failed key deployment
+ * would otherwise leave no way in over SSH at all. The API is unaffected by
+ * any of that, which makes it the one reliable way to undo this feature.
+ */
+ private async removeKeyViaApi(
+ target: SshTarget, comment: string, fingerprint?: string | null,
+ ): Promise {
+ if (!target.api_username || !target.api_password_encrypted) {
+ throw new Error('No API credentials available to remove the key');
+ }
+ const client = new RouterOSClient(
+ target.ip_address, target.api_port ?? 8728,
+ target.api_username, decrypt(target.api_password_encrypted),
+ );
+ // RouterOS keeps the key comment in `info`, not `key-owner` — matching the
+ // latter silently matched nothing and left every rotation's old key behind.
+ // Fingerprint is the stronger identity, so it is preferred where we have it;
+ // RouterOS keeps the base64 padding that we strip, hence the normalisation.
+ const norm = (f: string) => f.replace(/^SHA256:/, '').replace(/=+$/, '');
+ const wantFp = fingerprint ? norm(fingerprint) : null;
+
+ let removed = 0;
+ try {
+ await client.connect();
+ const keys = await client.execute('/user/ssh-keys/print', { detail: '' });
+ for (const k of keys) {
+ const matchesFp = wantFp != null && norm(k['fingerprint'] || '') === wantFp;
+ const matchesComment = comment.length > 0 && (k['info'] || '').includes(comment);
+ if (matchesFp || matchesComment) {
+ await client.execute('/user/ssh-keys/remove', { '.id': k['.id'] });
+ removed++;
+ }
+ }
+ } finally {
+ client.disconnect();
+ }
+ return removed;
+ }
+
+ /**
+ * Generate, deploy and verify a key for one device.
+ *
+ * Two things here were learned the hard way, on a switch that lost SSH.
+ *
+ * **Installing a key disables password SSH for that user.** RouterOS binds the
+ * key and then refuses password authentication — confirmed by removing the key
+ * and watching password login start working again. So a rotation cannot
+ * authenticate with the password; it must use the key already in place.
+ *
+ * **The stored private key is not replaced until the new one is proved.** It
+ * was, once, and a rotation that failed halfway left the database holding a
+ * key the device had never authorised while the device held one we no longer
+ * had. Nothing is overwritten until a fresh connection has authenticated with
+ * the new key, and a deployment that fails verification removes the key it
+ * just installed rather than leaving the device in that state.
+ */
+ async deploy(target: SshTarget, opts: { rotate?: boolean } = {}): Promise {
+ const username = target.ssh_username?.trim();
+ if (!username) throw new Error('No SSH username configured for this device');
+
+ const existing = await this.getKey(target.id);
+ const canUseExistingKey = existing && existing.status === 'verified';
+ if (!canUseExistingKey && !target.ssh_password_encrypted) {
+ throw new Error('An SSH password is required to install the first key');
+ }
+
+ const pair = generateDeviceKeyPair(target.id);
+ if (!isUsablePrivateKey(pair.privateKey)) {
+ throw new Error('Generated key could not be parsed — refusing to deploy it');
+ }
+
+ // Authenticate with whatever currently works: the existing key if we have a
+ // proved one, otherwise the password. After the first key is installed the
+ // password is no longer an option, so this order is not a preference.
+ const auth: Auth = canUseExistingKey
+ ? { privateKey: decrypt(existing!.private_key_encrypted) }
+ : { password: decrypt(target.ssh_password_encrypted!) };
+
+ const fileName = keyFileName(target.id);
+ try {
+ await withSsh(target, username, auth, async (conn) => {
+ await putFile(conn, fileName, pair.publicKey + '\n');
+ const out = await exec(conn, `/user/ssh-keys/import public-key-file=${fileName} user=${username}`);
+ // RouterOS reports failure in the output rather than an exit code.
+ if (/failure|error|no such/i.test(out)) {
+ throw new Error(`Device rejected the key import: ${out.trim().slice(0, 200)}`);
+ }
+ await exec(conn, `/file/remove ${fileName}`).catch(() => '');
+ });
+ } catch (e) {
+ const msg = (e as Error).message;
+ // The most likely cause of an auth failure here is a key this manager
+ // installed previously and no longer holds — it keeps password SSH
+ // disabled, so neither credential works. Say so, rather than leaving
+ // someone to work it out.
+ if (/authentication/i.test(msg) && !canUseExistingKey) {
+ const orphans = await this.countManagerKeys(target).catch(() => 0);
+ if (orphans > 0) {
+ throw new Error(
+ `Key deployment failed: ${msg}. This device already holds ${orphans} key(s) ` +
+ `from this manager, which disables password SSH. Revoke to clear them, then retry.`,
+ { cause: e }
+ );
+ }
+ }
+ throw new Error(`Key deployment failed: ${msg}`, { cause: e });
+ }
+
+ // Prove it on a connection that shares nothing with the one above.
+ const proved = await this.tryKey(target, username, pair.privateKey);
+ if (!proved.ok) {
+ // Undo, or the device is left with a key it will demand and we cannot
+ // supply — which is exactly how SSH was lost the first time.
+ await this.removeKeyViaApi(target, pair.comment, pair.fingerprint).catch((e) =>
+ console.error(`[SshKey] ${target.name}: could not remove the failed key: ${(e as Error).message}`));
+ throw new Error(
+ `The key was installed but would not authenticate (${proved.error}). ` +
+ `It has been removed from the device and nothing was changed.`
+ );
+ }
+
+ // Only now is it safe to replace what we had.
+ await query(
+ `INSERT INTO device_ssh_keys
+ (device_id, key_type, public_key, private_key_encrypted, fingerprint,
+ ssh_username, status, last_error, deployed_at, last_verified_at, updated_at)
+ VALUES ($1,$2,$3,$4,$5,$6,'verified',NULL,NOW(),NOW(),NOW())
+ ON CONFLICT (device_id) DO UPDATE SET
+ key_type = EXCLUDED.key_type, public_key = EXCLUDED.public_key,
+ private_key_encrypted = EXCLUDED.private_key_encrypted,
+ fingerprint = EXCLUDED.fingerprint, ssh_username = EXCLUDED.ssh_username,
+ status = 'verified', last_error = NULL, deployed_at = NOW(),
+ last_verified_at = NOW(), updated_at = NOW()`,
+ [target.id, pair.keyType, pair.publicKey, encrypt(pair.privateKey), pair.fingerprint, username]
+ );
+
+ // Retire the previous key only once its replacement is proved, and target it
+ // by its own unique comment so this cannot remove the new one as well.
+ if (existing?.public_key) {
+ const oldComment = existing.public_key.trim().split(/\s+/).pop();
+ if (oldComment && oldComment !== pair.comment) {
+ await this.removeKeyViaApi(target, oldComment, existing.fingerprint)
+ .then((n) => { if (n === 0) console.warn(`[SshKey] ${target.name}: old key not found on device`); })
+ .catch((e) => console.warn(`[SshKey] ${target.name}: old key left in place: ${(e as Error).message}`));
+ }
+ }
+
+ void opts;
+ return (await this.getKey(target.id))!;
+ }
+
+ /** How many keys on this device were installed by this manager? */
+ private async countManagerKeys(target: SshTarget): Promise {
+ if (!target.api_username || !target.api_password_encrypted) return 0;
+ const client = new RouterOSClient(
+ target.ip_address, target.api_port ?? 8728,
+ target.api_username, decrypt(target.api_password_encrypted),
+ );
+ try {
+ await client.connect();
+ const keys = await client.execute('/user/ssh-keys/print', { detail: '' });
+ return keys.filter((k) => (k['info'] || '').includes(keyComment(target.id))).length;
+ } finally {
+ client.disconnect();
+ }
+ }
+
+ /** Can this private key authenticate, right now, on its own connection? */
+ private async tryKey(
+ target: SshTarget, username: string, privateKey: string,
+ ): Promise<{ ok: boolean; error?: string }> {
+ try {
+ await withSsh(target, username, { privateKey }, (conn) => exec(conn, '/system/identity/print'));
+ return { ok: true };
+ } catch (e) {
+ return { ok: false, error: (e as Error).message };
+ }
+ }
+
+ /**
+ * Prove the key works, on a connection that shares nothing with deployment.
+ *
+ * This is the only evidence that counts. Reusing the deployment session would
+ * prove nothing — it authenticated with a password.
+ */
+ async verify(target: SshTarget): Promise<{ ok: boolean; error?: string }> {
+ const row = await this.getKey(target.id);
+ if (!row) return { ok: false, error: 'No key on record for this device' };
+ const username = row.ssh_username || target.ssh_username || '';
+ if (!username) return { ok: false, error: 'No SSH username configured' };
+
+ const result = await this.tryKey(target, username, decrypt(row.private_key_encrypted));
+ if (result.ok) {
+ await query(
+ `UPDATE device_ssh_keys SET status='verified', last_verified_at=NOW(),
+ last_error=NULL, updated_at=NOW() WHERE device_id=$1`, [target.id]);
+ } else {
+ await query(
+ `UPDATE device_ssh_keys SET status='failed', last_error=$2, updated_at=NOW()
+ WHERE device_id=$1`, [target.id, result.error]);
+ }
+ return result;
+ }
+
+ /**
+ * Forget a key, and remove it from the device.
+ *
+ * Removal goes over the binary API rather than SSH, because SSH is precisely
+ * what may be broken. Deliberately tolerant of the device being unreachable:
+ * the operator's intent is that the manager stop using this key, and that must
+ * not depend on the hardware answering — but the message says plainly when the
+ * device still holds it, since a leftover key keeps password SSH disabled.
+ */
+ async revoke(target: SshTarget): Promise<{ removedFromDevice: boolean; error?: string }> {
+ const row = await this.getKey(target.id);
+ if (!row) return { removedFromDevice: false, error: 'No key on record' };
+
+ let removed = false;
+ let error: string | undefined;
+ try {
+ // Match the device's *base* comment, which is a prefix of every key this
+ // manager has ever installed for it. Removing only the key we currently
+ // know about leaves orphans behind — from a rotation that failed halfway,
+ // or an older install — and every orphan keeps password SSH disabled with
+ // no way back through the product. Anything tagged for this device is
+ // unambiguously ours to remove.
+ const n = await this.removeKeyViaApi(target, keyComment(target.id), null);
+ removed = n > 0;
+ if (n === 0) error = 'the device does not appear to hold a key from this manager';
+ } catch (e) {
+ error = (e as Error).message;
+ }
+
+ await query(`DELETE FROM device_ssh_keys WHERE device_id = $1`, [target.id]);
+ return { removedFromDevice: removed, error };
+ }
+}
+
+export const sshKeyService = new SshKeyService();
diff --git a/backend/src/utils/__tests__/sshKeys.test.ts b/backend/src/utils/__tests__/sshKeys.test.ts
new file mode 100644
index 0000000..7eb67f4
--- /dev/null
+++ b/backend/src/utils/__tests__/sshKeys.test.ts
@@ -0,0 +1,92 @@
+import {
+ generateDeviceKeyPair, fingerprintPublicKey, isUsablePrivateKey,
+ keyComment, keyFileName, preferredAuth,
+} from '../sshKeys';
+
+describe('generateDeviceKeyPair', () => {
+ const kp = generateDeviceKeyPair(42);
+
+ it('produces an OpenSSH ed25519 pair', () => {
+ expect(kp.keyType).toBe('ed25519');
+ expect(kp.publicKey.startsWith('ssh-ed25519 ')).toBe(true);
+ expect(kp.privateKey).toContain('BEGIN OPENSSH PRIVATE KEY');
+ });
+
+ it('produces a private key ssh2 can authenticate with', () => {
+ // A key we cannot parse is a key that will fail at connect time, on a device
+ // we may have just removed password access from.
+ expect(isUsablePrivateKey(kp.privateKey)).toBe(true);
+ });
+
+ it('tags the public key exactly once', () => {
+ // The generator appends the comment itself; appending again produced keys
+ // tagged twice, which reads like a bug when seen on the device.
+ const occurrences = kp.publicKey.split(keyComment(42)).length - 1;
+ expect(occurrences).toBe(1);
+ expect(kp.publicKey.trim().split(/\s+/)).toHaveLength(3);
+ });
+
+ it('tags the public key so it can be attributed on the device', () => {
+ // An operator reading /user/ssh-keys/print must be able to tell which entry
+ // this is. A key nobody can attribute is a key nobody dares remove.
+ expect(kp.publicKey).toContain(keyComment(42));
+ expect(keyFileName(42)).toBe('mm-device-42.pub');
+ });
+
+ it('never repeats a key across devices', () => {
+ const a = generateDeviceKeyPair(1), b = generateDeviceKeyPair(2);
+ expect(a.publicKey).not.toBe(b.publicKey);
+ expect(a.fingerprint).not.toBe(b.fingerprint);
+ });
+});
+
+describe('fingerprintPublicKey', () => {
+ it('is stable and OpenSSH-shaped', () => {
+ const kp = generateDeviceKeyPair(7);
+ expect(kp.fingerprint).toMatch(/^SHA256:[A-Za-z0-9+/]+$/);
+ expect(fingerprintPublicKey(kp.publicKey)).toBe(kp.fingerprint);
+ });
+
+ it('ignores the comment, so re-tagging does not change identity', () => {
+ const kp = generateDeviceKeyPair(8);
+ const [type, b64] = kp.publicKey.split(/\s+/);
+ expect(fingerprintPublicKey(`${type} ${b64} someone-elses-comment`)).toBe(kp.fingerprint);
+ });
+});
+
+describe('isUsablePrivateKey', () => {
+ it('rejects junk rather than failing later at connect time', () => {
+ expect(isUsablePrivateKey('not a key')).toBe(false);
+ expect(isUsablePrivateKey('')).toBe(false);
+ });
+});
+
+describe('preferredAuth', () => {
+ it('uses the key whenever one is installed', () => {
+ // Not a preference: RouterOS refuses password SSH for a user once a key is
+ // bound to them, confirmed on hardware by removing the key and watching
+ // password login start working again.
+ expect(preferredAuth('verified', true, true)).toBe('key');
+ expect(preferredAuth('deployed', true, true)).toBe('key');
+ });
+
+ it('uses the password only when no key is installed', () => {
+ expect(preferredAuth(null, false, true)).toBe('password');
+ expect(preferredAuth('failed', false, true)).toBe('password');
+ });
+
+ it('reports honestly when there is nothing to authenticate with', () => {
+ expect(preferredAuth(null, false, false)).toBe('none');
+ });
+});
+
+describe('key comments', () => {
+ it('are unique per key, not per device', () => {
+ // Sharing one comment across a device's keys meant removing "the old key"
+ // during a rotation removed the new one too, taking SSH with it.
+ const a = generateDeviceKeyPair(5), b = generateDeviceKeyPair(5);
+ expect(a.comment).not.toBe(b.comment);
+ expect(a.comment.startsWith(keyComment(5))).toBe(true);
+ expect(a.publicKey.trim().split(/\s+/).pop()).toBe(a.comment);
+ });
+});
diff --git a/backend/src/utils/sshKeys.ts b/backend/src/utils/sshKeys.ts
new file mode 100644
index 0000000..950282e
--- /dev/null
+++ b/backend/src/utils/sshKeys.ts
@@ -0,0 +1,108 @@
+/**
+ * Per-device SSH keypairs: generation, fingerprints, and the naming that ties a
+ * key on the manager to a key on the device.
+ *
+ * Keys are per device rather than per fleet. A shared key turns any single
+ * compromise into fleet-wide compromise, and makes rotating one device
+ * impossible without touching every other one — the blast radius should match
+ * the thing that failed (#110).
+ */
+import * as crypto from 'crypto';
+import { utils as sshUtils } from 'ssh2';
+
+export interface GeneratedKeyPair {
+ keyType: 'ed25519';
+ publicKey: string;
+ privateKey: string;
+ fingerprint: string;
+ /** The comment RouterOS will show as key-owner; unique per key. */
+ comment: string;
+}
+
+/**
+ * Comment embedded in the public key, and the filename used on the device.
+ *
+ * Both carry the device id so an operator reading `/user/ssh-keys/print` on the
+ * hardware can tell which manager entry a key belongs to. A key nobody can
+ * attribute is a key nobody dares remove.
+ */
+export function keyComment(deviceId: number, suffix?: string): string {
+ const base = `mikrotik-manager-device-${deviceId}`;
+ return suffix ? `${base}-${suffix}` : base;
+}
+
+/**
+ * A short, unique tag per generated key.
+ *
+ * Every key for a device once shared one comment, and RouterOS matches on it —
+ * so removing "the old key" during a rotation removed the new one too, taking
+ * SSH access with it. Comments must identify a *key*, not a device.
+ */
+export function newKeySuffix(): string {
+ return crypto.randomBytes(4).toString('hex');
+}
+
+export function keyFileName(deviceId: number): string {
+ return `mm-device-${deviceId}.pub`;
+}
+
+/** OpenSSH-style SHA256 fingerprint of a public key, for display and audit. */
+export function fingerprintPublicKey(publicKey: string): string {
+ const parts = publicKey.trim().split(/\s+/);
+ const b64 = parts.length > 1 ? parts[1] : parts[0];
+ const hash = crypto.createHash('sha256').update(Buffer.from(b64, 'base64')).digest('base64');
+ return `SHA256:${hash.replace(/=+$/, '')}`;
+}
+
+/**
+ * Generate a keypair for one device.
+ *
+ * Ed25519 rather than RSA: shorter, faster to verify, and supported by the
+ * RouterOS versions this platform targets. The private half never leaves the
+ * manager and is never returned by the API.
+ */
+export function generateDeviceKeyPair(deviceId: number, suffix = newKeySuffix()): GeneratedKeyPair {
+ const comment = keyComment(deviceId, suffix);
+ const kp = sshUtils.generateKeyPairSync('ed25519', { comment });
+ // generateKeyPairSync already appends the comment; appending it again produced
+ // keys tagged twice, which is harmless but reads like a bug on the device.
+ const generated = kp.public.trim();
+ const publicKey = generated.endsWith(comment) ? generated : `${generated} ${comment}`;
+ return {
+ keyType: 'ed25519',
+ publicKey,
+ privateKey: kp.private,
+ fingerprint: fingerprintPublicKey(kp.public),
+ comment,
+ };
+}
+
+/** Is this a private key ssh2 can actually authenticate with? */
+export function isUsablePrivateKey(privateKey: string): boolean {
+ const parsed = sshUtils.parseKey(privateKey);
+ return !(parsed instanceof Error);
+}
+
+export type KeyStatus = 'pending' | 'deployed' | 'verified' | 'failed';
+
+/**
+ * Which credential should an SSH connection use?
+ *
+ * There is no password fallback once a key is installed. RouterOS binds a key to
+ * a user and then refuses password authentication for that user entirely —
+ * verified on hardware: removing the authorised key restored password login
+ * immediately. So a verified key is not merely preferred, it is the only thing
+ * that will work, and treating the password as a safety net would be fiction.
+ *
+ * The real recovery path is the binary API, which is unaffected by any of this
+ * and can always remove the key.
+ */
+export function preferredAuth(
+ status: KeyStatus | null,
+ hasPrivateKey: boolean,
+ hasPassword: boolean,
+): 'key' | 'password' | 'none' {
+ if (hasPrivateKey && (status === 'verified' || status === 'deployed')) return 'key';
+ if (hasPassword) return 'password';
+ return 'none';
+}
diff --git a/frontend/package.json b/frontend/package.json
index f3e8636..0754e77 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
- "version": "0.23.23-beta",
+ "version": "0.23.24-beta",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/components/device-detail/SshKeyCard.tsx b/frontend/src/components/device-detail/SshKeyCard.tsx
new file mode 100644
index 0000000..7b0b253
--- /dev/null
+++ b/frontend/src/components/device-detail/SshKeyCard.tsx
@@ -0,0 +1,147 @@
+import { useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { KeyRound, ShieldCheck, AlertTriangle, RefreshCw, Trash2 } from 'lucide-react';
+import { sshKeyApi } from '../../services/api';
+
+/**
+ * Per-device SSH key management.
+ *
+ * The warning about password SSH is not boilerplate. RouterOS refuses password
+ * authentication for a user once a key is bound to them — verified on hardware —
+ * so installing a key is a real trade, not a pure addition, and the operator has
+ * to be told before they make it rather than after (#110).
+ */
+export default function SshKeyCard({ deviceId }: { deviceId: number }) {
+ const queryClient = useQueryClient();
+ const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
+ const [confirmRevoke, setConfirmRevoke] = useState(false);
+
+ const { data, isLoading } = useQuery({
+ queryKey: ['ssh-key', deviceId],
+ queryFn: () => sshKeyApi.get(deviceId).then(r => r.data.key),
+ });
+
+ const run = (fn: () => Promise<{ data: { message: string } }>) => ({
+ mutationFn: fn,
+ onSuccess: (r: { data: { message: string } }) => {
+ setMsg({ ok: true, text: r.data.message });
+ setConfirmRevoke(false);
+ queryClient.invalidateQueries({ queryKey: ['ssh-key', deviceId] });
+ },
+ onError: (e: unknown) => setMsg({
+ ok: false,
+ text: (e as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Request failed',
+ }),
+ });
+
+ const deploy = useMutation(run(() => sshKeyApi.deploy(deviceId, false)));
+ const rotate = useMutation(run(() => sshKeyApi.deploy(deviceId, true)));
+ const verify = useMutation(run(() => sshKeyApi.verify(deviceId)));
+ const revoke = useMutation(run(() => sshKeyApi.revoke(deviceId)));
+ const busy = deploy.isPending || rotate.isPending || verify.isPending || revoke.isPending;
+
+ if (isLoading) return
+ A unique keypair for this device, used for backups and config export instead of a
+ stored password. The private half never leaves the server and is never shown.
+
+
+
+
+ {data ? (
+
+
+ Fingerprint
+ {data.fingerprint}
+
+
+ User
+ {data.ssh_username}
+
+
+ Last proved
+
+ {data.last_verified_at ? new Date(data.last_verified_at).toLocaleString() : 'never'}
+
+
+ {data.last_error && (
+
+ Last error
+ {data.last_error}
+
+ )}
+
+ ) : (
+
+
+
+ Installing a key disables password SSH for this user.{' '}
+ RouterOS requires key authentication once a key is bound to an account. The binary API and
+ WinBox are unaffected, and revoking the key restores password SSH.
+