feat(ssh): per-device SSH keys, deployed and proved (v0.23.24-beta)
Closes #110. Also the prerequisite for #118 — arbitrary console commands need SSH on every device, and stored passwords do not scale to sixty of them. Each device gets its own ed25519 keypair, generated on the manager, pushed over the existing password session, imported, and then *proved* by opening a fresh connection and authenticating with it. Nothing trusts a key until that last step succeeds. Backups and config export use a verified key where one exists. Verified end to end on hardware: deploy, rotate twice leaving exactly one authorised key each time, verify, and a 4.6 KB config backup pulled over key-only SSH with no password fallback. **The design note on the issue was wrong about the central safety property**, and testing on a switch is what proved it. I claimed the stored password remained as a fallback. It does not: RouterOS binds a key to a user and then refuses password authentication for that user entirely. Removing the key restored password login immediately, which settles the causation. That mistake compounded with a second one. Deployment wrote the new private key to the database before proving it, so a rotation that failed halfway left the database holding a key the device had never authorised while the device demanded one we no longer had — SSH lost on the test switch, recoverable only through the binary API. Three changes follow: * Rotation authenticates with the *existing* key, since the password is no longer an option once a key is installed. * Nothing is written until a fresh connection has authenticated with the new key. A deployment that fails verification removes the key it just installed rather than leaving the device demanding a credential nobody holds. * The binary API, unaffected by any of this, is the removal path — and is therefore the real recovery route, not the password. Two further faults came out of live testing rather than reasoning. Every key for a device shared one comment, so removing "the old key" during a rotation removed the new one too; comments are now unique per key. And RouterOS stores that comment in `info`, not `key-owner` — matching the wrong field silently matched nothing and left every rotation's old key behind, which is why the switch accumulated keys that later blocked it. Revoke now clears *every* key this manager installed for a device, not just the one on record. Orphans from a half-failed rotation otherwise keep password SSH disabled with no way back through the product, which is exactly the state the test switch got stuck in. When deployment fails with an authentication error and orphans are present, the message says so instead of leaving someone guessing. The UI states the trade before it is made: installing a key disables password SSH for that user, the API and WinBox are unaffected, and revoking restores it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a06ebd4d71
commit
1ab2e6547e
@@ -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": {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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<Record<string, unknown>>(
|
||||
`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<any>(`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<any>(`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<any>(`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<any>(
|
||||
`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.
|
||||
*
|
||||
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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 });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<T>(
|
||||
target: SshTarget,
|
||||
username: string,
|
||||
auth: Auth,
|
||||
work: (conn: SSHClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return new Promise<T>((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<string> {
|
||||
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<void> {
|
||||
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<DeviceKeyRow | null> {
|
||||
return queryOne<DeviceKeyRow>(`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<number> {
|
||||
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<DeviceKeyRow> {
|
||||
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<number> {
|
||||
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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user