Lesson 12 / 16
Remote Access Hardening
The attempt surface of password-based access is counted over a period, the surface that closes and the surface that opens when switching to key-based access are measured separately, and adding a bastion host compares the number of directly reachable machines against the single target it gives rise to.
Published Updated
In this lesson
- Counts the attempt surface of password-based access in two dimensions: how many sources can try, and how many machines fall if a password leaks
- Measures the attempts that close and the copy surface that opens when switching to key-based access, copy by copy, and gives the revocation cost in minutes
- Counts the items the bastion host drops, the items it concentrates, and its own surface, item by item
Contents
The previous lesson left the firewall at default-deny: the management shell is now reachable only from the internal network, and the management node additionally from the office network. The port narrowed. What getting through the port depends on did not change — whoever connects still types a password, and the password can be tried from every source in the allowed zones. This lesson’s measure is not the width of the port, it is the lock behind it.
- HB7. Six machines, three shell accounts, and five principals belong to the fictional regional
measurement network. No real access is set up; what is measured is the model written with
node. - HB8. The attempt log was produced with a generator whose seed is visible; every run gives the same series. In reality the distribution varies by zone and period, but the order of magnitude of the numbers does not change.
- HB9. The key strings and fingerprints are fictional; no real key was written. The fingerprint exists only to show that the same key sits in two places.
- HB10. Updating the authorized-keys file on one machine was counted as four minutes. In reality this time varies with automation; the total revocation cost stays proportional to the radius.
- HB11. The key has no lifetime; it is valid until revoked. A time-limited credential is outside this lesson.
- HB12. The bastion host was modeled as a single machine. A second one would lower the availability item and raise the single-target item to two.
The Attempt Surface of the Password Arrangement
The model holds three things: which sources the firewall lets reach, the password attempts observed over a thirty-day period, and the copy set that will appear once the switch to key-based access happens. The attempt log is built with our own generator instead of being written by hand; the seed is visible in the code.
// access.mjs — remote access principals, key copies, and the attempt generator; model, not real access export const MACHINE = ['collector-1', 'collector-2', 'validator', 'billing', 'data-store', 'management']; export const ACCOUNT = ['ops', 'deploy', 'maintenance']; // shell accounts open on every machine export const REACHABLE = { internal: 90, office: 40 }; // sources the firewall lets reach export const SHARED = { ops: 6, deploy: 6, maintenance: 1 }; // machines carrying the same password const SEED = 20260401; // generator seed; every run gives the same series function* generator(t = SEED) { // our own linear-congruential generator let x = t; for (;;) { x = (x * 1103515245 + 12345) % 2147483648; yield x / 2147483648; } } export function attemptLog(n = 400) { // thirty-day password attempt log const g = generator(); const log = []; const s = (k) => Math.floor(g.next().value * k); for (let i = 0; i < n; i += 1) { const zone = g.next().value < 0.7 ? 'internal' : 'office'; const heavy = g.next().value < 0.04; // a slice of sources try heavily log.push({ source: `${zone}-${s(REACHABLE[zone]) + 1}`, machine: MACHINE[s(6)], account: ACCOUNT[s(3)], count: heavy ? 400 + s(900) : 1 + s(6) }); } return log; } // Key copies; principal, where the copy sits, and a fictional fingerprint. export const COPY = [ ['op-a', 'laptop', 'kz7f-11ac'], ['op-a', 'backup-archive', 'kz7f-11ac'], ['op-b', 'laptop', 'kz7f-2be0'], ['op-c', 'laptop', 'kz7f-3d41'], ['deploy', 'config-tool', 'kz7f-5f92'], ['deploy', 'integration-runner', 'kz7f-5f92'], ['maintenance-ext', 'external-machine', 'kz7f-6a08'], ].map(([principal, location, fingerprint]) => ({ principal, location, fingerprint, name: `${principal}/${location}` })); // Which principal is authorized on which machine (authorized_keys files). export const AUTHORIZED = { 'op-a': MACHINE, 'op-b': MACHINE, 'op-c': ['management', 'billing'], 'deploy': MACHINE, 'maintenance-ext': ['data-store', 'management'], }; export const TOUCH_MIN = 4; // time to update authorized_keys on one machine export const radius = (c, a = AUTHORIZED) => a[c.principal].length; export const item = (a = AUTHORIZED) => COPY.reduce((acc, c) => acc + radius(c, a), 0);
// run1.mjs — the attempt surface of password-based access is counted from the thirty-day log import { MACHINE, ACCOUNT, REACHABLE, SHARED, attemptLog } from './access.mjs'; const log = attemptLog(); const top = (l) => l.reduce((a, x) => a + x.count, 0); const heavy = log.filter((x) => x.count >= 100); const reachable = REACHABLE.internal + REACHABLE.office; for (const [label, v, note = ''] of [ ['password attempt', top(log)], ['distinct source that tried', new Set(log.map((x) => x.source)).size, `${reachable} sources can reach`], ['target (machine x account)', MACHINE.length * ACCOUNT.length], ['attemptable triple', reachable * MACHINE.length * ACCOUNT.length, 'reachable sources x targets'], ['heavy source (>=100 attempts)', new Set(heavy.map((x) => x.source)).size, `produces ${top(heavy)} attempts`], ['untried target', MACHINE.length * ACCOUNT.length - new Set(log.map((x) => `${x.machine}|${x.account}`)).size], ]) console.log(`${label.padEnd(31)}${String(v).padStart(6)} ${note}`.trimEnd()); console.log('\nmachines that fall if a single password leaks:'); for (const h of ACCOUNT) console.log(` ${h.padEnd(13)} ${SHARED[h]}`);
password attempt 16505 distinct source that tried 124 130 sources can reach target (machine x account) 18 attemptable triple 2340 reachable sources x targets heavy source (>=100 attempts) 17 produces 15159 attempts untried target 0 machines that fall if a single password leaks: ops 6 deploy 6 maintenance 1
The firewall closed the external zone, and the attempt surface is nonzero regardless: ninety sources from the internal network and forty from the office network can reach the management shell. During the period, one hundred twenty-four of these sources actually tried and produced sixteen thousand five hundred five attempts. Seventeen heavy sources produce fifteen thousand one hundred fifty-nine of that on their own; block those seventeen and the remaining hundred seven sources stay put. The number that needs measuring is not how many tried, it is how many could try: a hundred thirty sources times eighteen targets, that is 2340 triples. All eighteen targets were tried at least once during the period.
The bottom block gives the second dimension of the surface. The ops and deploy accounts carry the same password on six machines, because the machines were built with the same install step. One password leaking means six machines; the maintenance account is machine-specific, so the number there is one. Two different radii sit side by side in the same arrangement, and the difference does not come from a security decision, it comes from install convenience.
Key-Based Access
// run2.mjs — switch to key-based access; the closed surface and the newly opened surface are counted separately import { MACHINE, ACCOUNT, REACHABLE, COPY, TOUCH_MIN, attemptLog, radius, item } from './access.mjs'; const log = attemptLog(); console.log(`closed ${log.reduce((a, x) => a + x.count, 0)} password attempts, ` + `${new Set(log.map((x) => x.source)).size} sources -> 0`); console.log(`opened ${COPY.length} copies, ${new Set(COPY.map((k) => k.principal)).size} principals, ` + `${new Set(COPY.map((k) => k.fingerprint)).size} distinct keys, no lifetime\n`); console.log('copy location authorized revoke min location carrying same key'); for (const k of COPY) { const es = COPY.filter((x) => x.fingerprint === k.fingerprint && x.name !== k.name).map((x) => x.location); console.log(`${k.name.padEnd(35)} ${k.location.padEnd(21)} ${String(radius(k)).padEnd(12)} ` + `${String(radius(k) * TOUCH_MIN).padEnd(12)} ${es.join(', ') || '-'}`); } const triple = (REACHABLE.internal + REACHABLE.office) * MACHINE.length * ACCOUNT.length; const total = log.reduce((a, x) => a + x.count, 0); console.log('\narrangement closed attempts attemptable triple key item'); for (const [label, closed, left, ak] of [['password only', 0, triple, 0], ['password + key', 0, triple, item()], ['key only', total, 0, item()]]) console.log(`${label.padEnd(21)} ${String(closed).padEnd(17)} ${String(left).padEnd(20)} ${ak}`); const loc = [...new Set(COPY.map((k) => k.location))]; console.log(`\n(copy, machine) item ${item()}`); console.log(`distinct location a copy sits in ${loc.length} ${loc.join(', ')}`); console.log(`widest copy radius ${Math.max(...COPY.map((k) => radius(k)))}`); console.log(`revoking every copy ${item() * TOUCH_MIN} min`);
closed 16505 password attempts, 124 sources -> 0 opened 7 copies, 5 principals, 5 distinct keys, no lifetime copy location authorized revoke min location carrying same key op-a/laptop laptop 6 24 backup-archive op-a/backup-archive backup-archive 6 24 laptop op-b/laptop laptop 6 24 - op-c/laptop laptop 2 8 - deploy/config-tool config-tool 6 24 integration-runner deploy/integration-runner integration-runner 6 24 config-tool maintenance-ext/external-machine external-machine 2 8 - arrangement closed attempts attemptable triple key item password only 0 2340 0 password + key 0 2340 34 key only 16505 0 34 (copy, machine) item 34 distinct location a copy sits in 5 laptop, backup-archive, config-tool, integration-runner, external-machine widest copy radius 6 revoking every copy 136 min
The first row is the surface that closes: sixteen thousand five hundred five attempts drop to zero, and none of the hundred twenty-four sources can try anymore. What closes is not the password, it is guessability — there is nothing left to try. The second row counts what opens at the same time: seven copies, five principals, five distinct keys. The copy count exceeds the principal count because the same key sits in two separate places.
The table shows where these copies sit and how much each carries. The key sits on a laptop, in a
backup archive, on a config tool, on an integration runner, and on an external machine — five
separate places, each guarded separately. The authorized column is each copy’s radius: four copies
are valid on six machines at once. There are thirty-four (copy, machine) items in total, and each
one is a door that opens if that copy leaks. The surface counted in the password arrangement did not
disappear, it changed shape: instead of a hundred thirty guessable sources, five places that need
guarding.
The three-row block in the middle measures the transition itself, and it gives this lesson’s easiest number to skip. Turning on key-based access closes nothing by itself: as long as password entry stays open too, closed attempts are zero and the attemptable triple stays at 2340. Against that, the new thirty-four-item surface is there the moment it is opened. The in-between state carries both arrangements’ surface at once, and most setups stay in this in-between state for weeks, because closing password entry before key distribution is finished risks locking someone out. The step that closes something is not adding the key, it is removing the password; unless the two are counted separately, the first stands in for the second.
The revoke column gives the cost of the decision. If a copy leaks, the authorized-keys file has to be touched on every machine that principal is authorized on: the widest copy is six machines, twenty-four minutes; every copy together, a hundred thirty-six minutes. The last column is revocation’s side effect. The copy in the backup archive is the same key as the copy on the operator’s laptop; revoking one revokes the other. A revocation made without knowing which copy the leak came from closes the operator’s own access too.
The “no lifetime” at the end of the second row is this lesson’s item that cannot be closed. A password attempt was something visible: it was counted, its source was known, its clustering was noticed. A login made with a leaked key is indistinguishable from a legitimate one, and the key is valid until revoked. What closes the exposure window is not time running out, it is someone noticing.
Bastion Host
// run3.mjs — a bastion host is added; direct access drops, a single target is born in return import { MACHINE, COPY, AUTHORIZED, TOUCH_MIN, radius, item } from './access.mjs'; const BASTION = Object.fromEntries(Object.keys(AUTHORIZED).map((o) => [o, ['bastion']])); const INTERNAL_KEY = 1; // key sitting on the bastion, authorized on every machine const measure = (a) => [item(a), Math.max(...COPY.map((k) => radius(k, a))), item(a) * TOUCH_MIN]; const [aK, aY, aD] = measure(AUTHORIZED), [bK, bY, bD] = measure(BASTION); console.log('measure direct with bastion'); for (const [label, x, y] of [ ['directly reachable machine', MACHINE.length, 1], ['(copy, machine) item', aK, bK], ['machines that fall if one copy leaks', aY, bY], ['revoking every copy (min)', aD, bD], ['internal key sitting at a single point', 0, INTERNAL_KEY], ['machines the internal key is authorized on', 0, MACHINE.length], ['total access item', aK, bK + INTERNAL_KEY * MACHINE.length], ]) console.log(`${label.padEnd(44)} ${String(x).padEnd(9)} ${y}`); console.log("\nthe bastion host's own surface (item by item):"); for (const [label, n] of [['listening port', 3], ['authorized principal', Object.keys(BASTION).length], ['internal key sitting on it', INTERNAL_KEY], ['single point holding session records', 1], ['emergency escape route (direct access)', 1], ['machines unreachable if it goes down', MACHINE.length], ['machines reached if it is compromised', MACHINE.length]]) console.log(` ${label.padEnd(40)}${n}`);
measure direct with bastion directly reachable machine 6 1 (copy, machine) item 34 7 machines that fall if one copy leaks 6 1 revoking every copy (min) 136 28 internal key sitting at a single point 0 1 machines the internal key is authorized on 0 6 total access item 34 13 the bastion host's own surface (item by item): listening port 3 authorized principal 5 internal key sitting on it 1 single point holding session records 1 emergency escape route (direct access) 1 machines unreachable if it goes down 6 machines reached if it is compromised 6
The first four rows make the case for the step: directly reachable machines go from six to one, the
(copy, machine) item from thirty-four to seven, machines falling if one copy leaks from six to
one, and the time to revoke every copy drops from a hundred thirty-six minutes to twenty-eight. All
four columns move the same direction. Key copies are now authorized on only a single machine, so a
stolen laptop means one machine, not six.
The next three rows give the price. An internal key authorized on all six machines sits on the bastion host; without it the bastion host is useless. Total access items drop from thirty-four to thirteen — not to zero. What actually changes is the distribution: thirty-four items were spread across five separate places, and six of the thirteen items concentrated on a single machine. The surface shrank and concentrated at the same time; these two sentences are not substitutes for each other, both are true at once.
The bottom block counts the bastion host’s own surface item by item: three listening ports, five authorized principals, one internal key sitting on it, a single point holding the session record. The last two rows read the same machine from two directions. If the bastion host goes down, none of the six machines keep management access; that is why an emergency escape route is left in place, and that escape route is exactly the direct access that was meant to be closed. If it is compromised, the same six machines are reached. A bastion host is not a defense, it is a point of concentration: its value comes from making it enough to watch one machine instead of six — as long as that one machine is actually watched.
Summary
- Even after the firewall closes the external zone, 130 sources can reach the management shell; 124 of them tried during the period and produced 16,505 attempts, and 17 heavy sources produce 15,159 of that on their own. The measured surface is not who tried, it is the 130 × 18 = 2340 attemptable triples.
- Sharing the same password on six machines is a second radius item: a single leak in the ops and deploy accounts means 6 machines, and in the machine-specific maintenance account, 1 machine.
- Switching to key-based access closes all 16,505 attempts and 124 sources; in return it opens 7
copies, 5 separate places, and 34
(copy, machine)items. The surface does not disappear, it changes shape. - Revocation cost is proportional to radius: the widest copy is 6 machines and 24 minutes, every copy together is 136 minutes. The same key sitting in two places gives revocation a side effect — revoking one copy closes the other too.
- The key has no lifetime: a login made with a leaked key is indistinguishable from a legitimate one, and the window closes only once someone notices. A password attempt could be counted; this cannot.
- The bastion host drops directly reachable machines from 6 to 1, items from 34 to 7, and revocation time from 136 minutes to 28; in return an internal key authorized on 6 machines concentrates on a single machine, total items stay at 13, and going down loses access to 6 machines while being compromised reaches the same 6 machines.
Next Step
This lesson’s key is valid until revoked. The same is true of the credentials applications carry: the data-store password, the queue key, and the mail-gateway token are written once, and nobody says when they will change. The next lesson’s axis is therefore lifetime. In a secret with no lifetime, the exposure window is infinite; in a credential with a defined lifetime, the window is as long as the lifetime. Lifetime will be scanned: a short lifetime lowers the window but raises the renewal load and the dependency on the central store, and how many services become unable to work and after how many minutes when the store goes down gets counted. The central store itself is also a surface, and it is measured by how many principals can reach how many secrets.
To keep your progress and take notes, Log in
My notes
Log in to take notes.