Skip to content
academia.sh

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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close