---
title: 'Remote Access Hardening'
source: 'https://academia.sh/en/courses/servers-and-hardening/remote-access-hardening'
course: 'Servers, Networking and Hardening'
language: en
updated: '2026-09-13T05:08:30+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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'
---

# 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.

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.

```js
// 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);
```

```js
// 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

```js
// 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

```js
// 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.
