---
title: 'Network Security'
source: 'https://academia.sh/en/courses/network-security'
language: en
updated: '2026-09-26T03:02:54+00:00'
license: 'CC BY-SA 4.0'
lessons: 20
outcomes:
    - 'Measures weakness by counting the claims network protocols accept without verification'
    - 'Compares the path segmentation, filtering, and checkpoints close against what they cost'
    - 'Measures the physical surface of the wireless layer and its authentication options'
---

# Network Security

How many of the claims a message carries on the network can actually be proven: counting the unverified claims per layer, what an open service means, address and name resolution poisoning and the record signed verification rescues, rogue address assignment, single-source and distributed denial of service, on-path, replay and spoofing attacks, the claim each firewall generation sees and can verify, network segmentation and trust zones, proxy and jump servers, intrusion detection and prevention, honeypots, the access scope of virtual private networks, rule evaluation order, wireless encryption generations, enterprise and personal mode, evil twins, and coverage auditing.

## Protocol Security

Counting the claims network protocols accept without verification: the claim distribution per layer, the claims an open port is ready to accept, the corrupted records in address and name resolution caches, rogue address assignment, denial of service measured by capacity arithmetic, on-path attacks as an evidence-chain gap, and replay and spoofing measured in terms of freshness proof.

### Layer Models and Attack Surface

Source: https://academia.sh/en/courses/network-security/layer-models-and-attack-surface

Measuring the network surface in claims: a single meter reading carrying 18 claims across four layers with none of them having proof, five defenses making 11 claims verifiable while leaving 7 open, a defense being unable to see any claim of the layer beneath it, and verification costing 4 extra round trips, 13 key items, 25 manual items, 11 rejected connections, and 6 devices left out.

The previous course counted the machine's boundary: permission bits, the hypervisor wall,
partitioning. The question left open at its close was this — a branch server does not stand
alone; every endpoint that connects to it opens a path that passes outside the boundary the
operating system draws, and nobody counts those paths. This lesson turns those paths into a
countable unit.

The unit is not the connection itself. A cable existing between two machines says nothing on
its own; what matters is **what** the messages crossing that cable say and **what** the
receiver's belief rests on. Every message on the network carries one or more **claims**: "the
source address is mine," "this name corresponds to this address," "this segment belongs to an
open session." This course's unit of measurement is the number of claims accepted without
verification.

#### The Claim's Proof

**PS1:** every message carries a claim; a claim is a proposition the receiver is expected to
accept as true. **PS2:** a claim's **proof** is data the receiver can test independently of the
sender; a claim with no proof is accepted without verification. **PS3:** a defense makes
verifiable only the claims of its own layer and the layer above it; the claim of the layer
beneath it is invisible to it.

"This protocol is insecure" carries no decision. Three numbers carry the decision, and every
lesson in this course writes these three throughout: how many claims are accepted without
verification, how many the defense makes verifiable and how many still cannot be verified, and
what verification costs. Cost is measured in five items: the number of round trips added to the
handshake, the ongoing key items to manage, the manually held configuration items, the rejected
legitimate connections, and the number of legacy devices left out.

The layers' responsibilities, header fields, and protocol mechanics were measured in the
**Network Models and Protocols** course; they are not repeated here. Layer is used here only as
a counting axis: how many claims each layer carries and how many have proof.

#### The Eighteen Claims a Reading Carries

The list below is a **model**: it counts the claims a meter reading carries as it travels from
the field to the center in a fictional regional measurement network. There is no real network, no
real hostname, and no real public address. Carrying out this count on a real network **is both
illegal and unprofessional without written authorization and a defined scope**.

```js
// networks/claim.mjs — the claims a single meter reading carries from the field to the
// center in a fictional regional measurement network (model): there is no real network, no
// real hostname, and no real public address. No randomness.

// PS1: every message carries a claim; a claim is a proposition the receiver is expected
// to accept as true. PS2: a claim's proof is data the receiver can test independently of
// the sender; a claim with no proof is accepted without verification. The last column is
// which lesson in this topic measures the claim.
export const CLAIM = `
  link         the source hardware address is mine                    endpoint-registry      03
  link         this network address belongs to this hardware address  endpoint-registry      03
  link         I am this segment's gateway                            endpoint-registry      03
  link         this frame belongs to this segment                     -                      -
  network      the packet's source address is mine                    source-filtering       06
  network      this network is reached through me                     -                      07
  network      this fragment is part of this whole                    -                      06
  network      this control message comes from the network            -                      06
  transport    this segment belongs to an open session                session-binding        08
  transport    I opened this session                                  session-binding        06
  transport    I am closing this session                              session-binding        07
  application  this name corresponds to this address                  signed-name            04
  application  I am the server assigning addresses                    -                      05
  application  I am the server at the far end                         endpoint-verification  07
  application  I am this field terminal                                endpoint-verification  02
  application  this response answers my request                       endpoint-verification  04
  application  this reading came from this meter                      -                      08
  application  this request has not been sent before                  -                      08`
  .trim().split("\n").map((r) => {
    const p = r.trim().split(/\s{2,}/);
    return { layer: p[0], name: p[1], verifier: p[2], lesson: p[3] };
  });

// PS3: a defense makes verifiable only the claims of its own layer and the layer above
// it; the claim of the layer beneath it is invisible to it.
// Cost columns: rtt = round trips added to the handshake; key = ongoing key items to
// manage; manual = manually held configuration items; rejected = rejected legitimate
// connections; legacy = devices left out.
export const DEFENSE = {
  "endpoint-registry":     { layer: "link",        rtt: 0, key: 0, manual: 14, rejected: 3, legacy: 0 },
  "source-filtering":      { layer: "network",     rtt: 0, key: 0, manual: 6,  rejected: 2, legacy: 0 },
  "session-binding":       { layer: "transport",   rtt: 1, key: 0, manual: 0,  rejected: 0, legacy: 2 },
  "signed-name":           { layer: "application", rtt: 1, key: 4, manual: 2,  rejected: 1, legacy: 0 },
  "endpoint-verification": { layer: "application", rtt: 2, key: 9, manual: 3,  rejected: 5, legacy: 4 },
};

export const LAYER = ["link", "network", "transport", "application"];
export const COST = ["rtt", "key", "manual", "rejected", "legacy"];
export const open = (active) =>
  CLAIM.filter((i) => i.verifier === "-" || !active.includes(i.verifier)).length;
export const totalCost = (active) => COST.map((c) =>
  active.reduce((s, d) => s + DEFENSE[d][c], 0));
```

```js
// networks/count.mjs — claims per layer: how many have proof, how many the defense makes
// verifiable, how many still cannot be verified, and what it costs.
import { CLAIM, DEFENSE, LAYER, open, totalCost } from "./claim.mjs";

const write = (g, ...s) => console.log(s.map((v, i) =>
  (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join(""));
const all = Object.keys(DEFENSE);

console.log(`${CLAIM.length} claims, ${LAYER.length} layers, ${all.length} defenses; ` +
  `claims with proof ${CLAIM.length - open([])}, accepted without verification ${open([])}`);

const A = [-13, 8, 9, 14, 13];
console.log("\n1. claims per layer and claims with proof");
write(A, "layer", "claims", "proof", "verifiable", "undefended");
for (const l of LAYER) {
  const s = CLAIM.filter((i) => i.layer === l);
  write(A, l, s.length, 0, s.filter((i) => i.verifier !== "-").length,
    s.filter((i) => i.verifier === "-").length);
}

const B = [-23, -13, 12, 11, 8, 9, 8, 10];
console.log("\n2. each defense open on its own");
write(B, "defense", "layer", "verified", "remaining", "rtt", "key", "manual", "rejected");
for (const d of all) {
  const s = DEFENSE[d];
  write(B, d, s.layer, CLAIM.filter((i) => i.verifier === d).length, open([d]),
    s.rtt, s.key, s.manual, s.rejected);
}

const C = [-23, 12, 11, 8, 9, 8, 10, 8];
console.log("\n3. if defenses are added bottom to top (cumulative)");
write(C, "added", "verified", "remaining", "rtt", "key", "manual", "rejected", "legacy");
const active = [];
write(C, "none", 0, open([]), 0, 0, 0, 0, 0);
for (const d of all) {
  active.push(d);
  write(C, d, CLAIM.length - open(active), open(active), ...totalCost(active));
}

const D = [-45, -13, 8];
console.log("\n4. claims that stay unverified with every defense open");
write(D, "claim", "layer", "lesson");
for (const i of CLAIM.filter((x) => x.verifier === "-")) write(D, i.name, i.layer, i.lesson);

const E = [-23, 8, 13, 13, 13];
console.log("\n5. how many claims of the layers beneath it a defense sees");
write(E, "defense", "below", "verifies", "open below", "open above");
for (const d of all) {
  const limit = LAYER.indexOf(DEFENSE[d].layer);
  const a = CLAIM.filter((i) => LAYER.indexOf(i.layer) < limit);
  const u = CLAIM.filter((i) => LAYER.indexOf(i.layer) >= limit);
  write(E, d, a.length, CLAIM.filter((i) => i.verifier === d).length,
    a.filter((i) => i.verifier !== d).length, u.filter((i) => i.verifier !== d).length);
}
```

```
18 claims, 4 layers, 5 defenses; claims with proof 0, accepted without verification 18

1. claims per layer and claims with proof
layer          claims    proof    verifiable   undefended
link                4        0             3            1
network             4        0             1            3
transport           3        0             3            0
application         7        0             4            3

2. each defense open on its own
defense                layer            verified  remaining     rtt      key  manual  rejected
endpoint-registry      link                    3         15       0        0      14         3
source-filtering       network                 1         17       0        0       6         2
session-binding        transport               3         15       1        0       0         0
signed-name            application             1         17       1        4       2         1
endpoint-verification  application             3         15       2        9       3         5

3. if defenses are added bottom to top (cumulative)
added                      verified  remaining     rtt      key  manual  rejected  legacy
none                              0         18       0        0       0         0       0
endpoint-registry                 3         15       0        0      14         3       0
source-filtering                  4         14       0        0      20         5       0
session-binding                   7         11       1        0      20         5       2
signed-name                       8         10       2        4      22         6       2
endpoint-verification            11          7       4       13      25        11       6

4. claims that stay unverified with every defense open
claim                                        layer          lesson
this frame belongs to this segment           link                -
this network is reached through me           network            07
this fragment is part of this whole          network            06
this control message comes from the network  network            06
I am the server assigning addresses          application        05
this reading came from this meter            application        08
this request has not been sent before        application        08

5. how many claims of the layers beneath it a defense sees
defense                   below     verifies   open below   open above
endpoint-registry             0            3            0           15
source-filtering              4            1            4           13
session-binding               8            3            8            7
signed-name                  11            1           11            6
endpoint-verification        11            3           11            4
```
The numbers belong to the **measurement** class; their inputs are PS1–PS3.

#### Eighteen Claims, Zero Proof

The opening line is this course's starting point: none of the eighteen claims **has any
proof**. With no defense open, the receiver counts all eighteen propositions as true solely
because the sender said so. This is not a defect; most of the protocols being counted were
designed for an environment in which parties on the network do not intend to harm each other,
and verification is not part of that design.

The first table gives the distribution. The application layer is the heaviest with seven
claims, because a reading's meaning is established there: who sent it, which meter it came
from, which name it corresponds to. The link layer carries four, the network layer four, the
transport layer three claims. **Three** of the network layer's four claims cannot be tied to
any defense; the transport layer ties all three of its claims. This difference between the two
layers determines in advance where the defense budget goes.

The reason the transport layer can tie all three at once is that all three claims rest on the
**same** proof: a binding the two parties agree on when a session opens and that can be tested
on every following message. The network layer's three open claims, on the other hand, each
require a separate proof, and none of them can be carried inside the message itself — which
path a packet took cannot be derived from the data in the receiver's hand. Whether the claims of
a layer **can be reduced to a single proof** is the property that actually determines that
layer's defense cost.

#### What a Defense Cannot See

The second table opens each defense on its own. Endpoint verification, the most expensive
defense, verifies three claims and leaves fifteen open; endpoint registry, one of the cheapest,
also verifies three claims and leaves the same fifteen. On its own, no defense closes more than
a sixth of the claims.

The fifth table shows why, and turns PS3 into a number. Endpoint verification is at the
application layer; there are eleven claims beneath it, and **all eleven** stay open to it.
Signed name resolution has the same number: eleven. Session binding does not see eight of its
eight claims beneath it. In every row, the number of claims a defense closes at a layer beneath
it is **zero**.

This is the measure of why a secure application-layer connection says nothing about the state of
the network underneath it. With endpoint verification open, who the far end is gets verified;
which address was used to reach that endpoint, which hardware address that address resolves to,
and which network the packet crossed are not verified. These are separate claims and require
separate defenses.

#### The Cost of Verification

The third table opens the five defenses cumulatively, bottom to top. The number of verified
claims progresses 0, 3, 4, 7, 8, 11; the remainder drops from 18 to 7. Cost accumulates along
the same rows: four extra round trips, thirteen key items, twenty-five manually held
configuration items, eleven rejected legitimate connections, and six legacy devices left out.

The shape of the cost changes from defense to defense, and this difference determines the
management burden. Endpoint registry needs no extra round trip and no key at all, but on its
own it brings **fourteen manual items**: one record per endpoint. Endpoint verification, on the
other hand, holds only three manual items but adds **nine key items** and **two extra round
trips**. The first defense's cost is written to a person, the second's to every connection.
Fourteen manual items grow linearly as the network grows and a forgotten item leaves it silently
open; two extra round trips do not grow with the network — they stay the same on every
connection.

The source-filtering row sits at the opposite end: it verifies a single claim and costs six
manual items and two rejected connections. The cost per verified claim is the worst ratio in the
table. And yet that single claim — that the source address really belongs to the sender — is the
input to all of the capacity arithmetic measured in the sixth lesson. **The number of claims
verified is not, on its own, a ranking criterion; which claim is verified comes before the
count.**

#### Seven Claims Stay Open

The fourth table is this lesson's most important result. With all five defenses open, seven
claims still cannot be verified. These are not the result of weak configuration; they fall
outside the scope of every defense counted here. Three are at the network layer, three at
the application layer, one at the link layer.

Six of the seven are measured one by one across the rest of this topic. Who the server assigning
addresses is is counted in the fifth lesson, who the path runs through in the seventh, fragmentation
and the control message in the sixth, the reading's source and the request's freshness in the
eighth. Only one — which segment a frame belongs to — is not measured in this topic, because
its answer lies not in the protocol but in the boundary the network is drawn along.

These seven rows are also this topic's map. Each of the next seven lessons takes one or more of
these rows, runs them against its own message set, and writes the same three columns. The count
starts at eighteen and, in each lesson, changes only for the claims that lesson takes up.

#### Summary

- The network surface's unit of measurement is the number of claims accepted without
  verification: a meter reading carries **18 claims** across four layers, and with no defense
  open, **the number of them with proof is zero**.
- The distribution is not even: the application layer carries 7, the link and network layers 4
  each, the transport layer 3 claims; 3 of the network layer's 4 claims cannot be tied to any
  defense.
- No defense on its own verifies more than three claims; both the most expensive and the
  cheapest defense leave 15 claims open.
- A defense verifies **none** of the claims of the layers beneath it: in all five defenses, the
  number of claims closed at a layer beneath it is zero.
- The five defenses together verify 11 claims, leave 7 open, and cost 4 extra round trips, 13
  key items, 25 manual items, 11 rejected connections, and 6 devices left out.

#### Next Step

This lesson counted claims across a reading's journey — that is, it measured the moment the
network **speaks**. But an endpoint does something before anyone ever sends it anything: it
listens. An open port is a notice even if no message ever crosses it; it says the service at
that endpoint is **ready to accept** a certain class of claim. Seven open endpoints mean seven
separate acceptances waiting, and how many of them actually have work to do on that machine is a
separate count. The next lesson counts a service inventory through that lens: which port accepts
which claim, how many services are never used, and how many claims closing the unused ones
eliminates.

### Common Ports and Services

Source: https://academia.sh/en/courses/network-security/common-ports-and-services

Measuring open services in claims: 20 services opening 49 accept points across 6 hosts, 21 accept points resting on no proof at all, 4 externally reachable services receiving not one legitimate request in 30 days, closing unused services and narrowing access cutting accept points to 27 while breaking 4 periodic flows, and verifying the last 7 accept points excluding 240 devices.

The previous lesson counted claims at the moment the network spoke: a reading traveling
from the field to the center carries eighteen claims, and with no defense in place none of
them had proof. Yet an endpoint does something before anyone ever sends it anything. It
listens.

Listening is not a passive act. An open port is a notice even if no message ever crosses
it: it says the service at that point is ready to accept a certain class of claim. A remote
shell service is ready to accept the claim "I am this user"; a measurement intake endpoint is
ready to accept the claim "this reading came from this meter." This lesson counts a service
inventory through that lens.

#### An Open Point Is Notice of Acceptance

**PS4:** an open port announces, even if no message ever crosses it, that the service at
that point is ready to accept a specific class of claim. **PS5:** the size of the surface is
not the number of services but the number of **accept points**; an accept point is a pair of
one service and one zone that can reach that service. **PS6:** whether a service is used is
knowable only within an observation window; periodic use falling outside the window reads as
"never used."

PS5 is the item that separates this lesson's measure from a service count. The same service
open on two hosts is two items, but if one is reachable only from the center and the other
from all four of four zones, their contributions to the surface are not equal. Port
numbering, the distinction between a listening socket and a connected socket, and number
ranges were measured in the **Network Models and Protocols** course; they are not repeated
here. The only question here is which claim each open point accepts and on what proof.

The inventory below is a **model**: it counts the services of a fictional regional measurement
network; there is no real hostname or real public address. Producing such an inventory
outside one's own network — testing remotely which point is open — **is both illegal and
unprofessional without written authorization and a defined scope**.

```js
// networks/inventory.mjs — the service inventory of a fictional regional measurement network
// (model): there is no real network, no real hostname, and no real public address. No randomness.

// PS4: an open port is notice even if no message ever crosses it; it says the service at
// that point is ready to accept a specific class of claim.
// PS5: accept point = (service, zone that can reach it) pair; the size of the surface is
// not the number of services but the number of accept points.
// Columns: host, port, service class, verifier, 30-day legitimate requests, reachable
// zones, zones that sent requests in the last 30 days, zones that use it periodically,
// count of legacy devices sending requests that cannot verify (if these devices are
// excluded, the service's entire 30-day request volume stops).
// Zones: e = external network, f = field, b = branch, c = center.
export const SERVICE = `
  branch-gateway  22    remote-shell          client-proof      268     b,c      c      b  0
  branch-gateway  23    legacy-remote-shell   password          0       b,c      -      -  0
  branch-gateway  161   device-monitoring     community-string  9240    b,c      c      -  8
  branch-gateway  8080  management-interface  password          140     b,c      c      -  0
  collector       22    remote-shell          client-proof      412     f,b,c    b,c    f  0
  collector       1883  measurement-ingest   none              89400   e,f,b,c  f,b    -  220
  collector       9100  measurement-export   none              8640    b,c      c      -  0
  collector       514   log-collection        none              43200   f,b,c    b,c    -  14
  verifier        22    remote-shell          client-proof      190     b,c      c      -  0
  verifier        443   internal-service      token             61300   b,c      b,c    -  0
  verifier        5432  database-listener     password          0       b,c      -      c  0
  billing         443   internal-service      token             22800   b,c      b,c    -  0
  billing         80    unencrypted-web       none              1150    e,b,c    b      -  0
  billing         5432  database-listener     password          47600   b,c      c      -  0
  billing         21    legacy-file-transfer  password          36      e,b,c    c      -  0
  name-server     53    name-resolution       none              128400  e,f,b,c  f,b,c  -  0
  name-server     123   time-sync             none              34600   f,b,c    f,b,c  -  6
  directory       389   directory-query       password          19700   b,c      b,c    -  0
  directory       445   file-sharing          password          3300    b,c      b      c  0
  directory       631   printer-service       none              0       b,c      -      -  0`
  .trim().split("\n").map((r) => {
    const p = r.trim().split(/\s+/);
    const set = (x) => (x === "-" ? [] : x.split(","));
    return { host: p[0], port: +p[1], service: p[2], verifier: p[3], requests: +p[4],
      reach: set(p[5]), used: set(p[6]), periodic: set(p[7]), legacy: +p[8] };
  });

export const HOST = [...new Set(SERVICE.map((s) => s.host))];
export const points = (l) => l.reduce((t, s) => t + s.reach.length, 0);
export const openPoints = (l) =>
  l.filter((s) => s.verifier === "none").reduce((t, s) => t + s.reach.length, 0);
```

```js
// networks/surface.mjs — how many claim accept points an open service opens, how many are
// never used at all, what narrowing and adding verification cost.
import { SERVICE, HOST, points, openPoints } from "./inventory.mjs";

const write = (g, ...s) => console.log(s.map((v, i) =>
  (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join(""));
const ZONE = ["e", "f", "b", "c"];
const empty = SERVICE.filter((s) => s.requests === 0);

console.log(`${SERVICE.length} services, ${HOST.length} hosts, ${ZONE.length} zones; ` +
  `accept points ${points(SERVICE)}, unverified accept points ${openPoints(SERVICE)}, ` +
  `services with no requests in 30 days ${empty.length}`);

const A = [-16, 9, 15, 14, 10, 14];
console.log("\n1. per host");
write(A, "host", "service", "unverified", "unused", "accept", "30d requests");
for (const h of HOST) {
  const l = SERVICE.filter((s) => s.host === h);
  write(A, h, l.length, l.filter((s) => s.verifier === "none").length,
    l.filter((s) => s.requests === 0).length, points(l),
    l.reduce((t, s) => t + s.requests, 0));
}

const B = [-18, 9, 9, 14];
console.log("\n2. the proof the accepted claim rests on");
write(B, "verifier", "service", "accept", "30d requests");
for (const d of [...new Set(SERVICE.map((s) => s.verifier))]) {
  const l = SERVICE.filter((s) => s.verifier === d);
  write(B, d, l.length, points(l), l.reduce((t, s) => t + s.requests, 0));
}

const C = [-48, 8, 14, 18, 14, 10];
console.log("\n3. steps to narrow the surface (cumulative)");
write(C, "step", "accept", "unverified", "stopped requests", "cut flow", "excluded");
let remaining = SERVICE, cut = 0, stopped = 0, excluded = 0;
const row = (name) => write(C, name, points(remaining), openPoints(remaining), stopped, cut, excluded);
row("none");
cut += empty.reduce((t, s) => t + s.periodic.length, 0);
remaining = remaining.filter((s) => s.requests > 0);
row("close never-used services");
cut += remaining.reduce((t, s) => t + s.periodic.filter((z) => !s.used.includes(z)).length, 0);
remaining = remaining.map((s) => ({ ...s, reach: s.used }));
row("narrow access to zones that use it");
const verify = (sel) => {
  const l = remaining.filter((s) => s.verifier === "none" && sel(s));
  excluded += l.reduce((t, s) => t + s.legacy, 0);
  stopped += l.filter((s) => s.legacy > 0).reduce((t, s) => t + s.requests, 0);
  remaining = remaining.map((s) => (l.includes(s) ? { ...s, verifier: "added" } : s));
};
verify((s) => s.legacy === 0);
row("add verification where no legacy device exists");
verify(() => true);
row("add verification to the rest");

const D = [-14, -20, 8, 14, 16];
console.log("\n4. services still unverified after narrowing");
write(D, "host", "service", "accept", "30d requests", "legacy devices");
for (const s of SERVICE.filter((x) => x.verifier === "none" && x.requests > 0 && x.legacy > 0))
  write(D, s.host, s.service, s.used.length, s.requests, s.legacy);

const E = [-8, 11, 12, 14, 14];
console.log("\n5. reachable and used services per zone");
write(E, "zone", "reachable", "used", "unused", "unverified");
for (const z of ZONE) {
  const r = SERVICE.filter((s) => s.reach.includes(z));
  const u = SERVICE.filter((s) => s.used.includes(z));
  write(E, z, r.length, u.length, r.length - u.length,
    r.filter((s) => s.verifier === "none").length);
}
```

```
20 services, 6 hosts, 4 zones; accept points 49, unverified accept points 21, services with no requests in 30 days 3

1. per host
host              service     unverified        unused    accept  30d requests
branch-gateway          4              0             1         8          9648
collector               4              3             0        12        141652
verifier                3              0             1         6         61490
billing                 4              1             0        10         71586
name-server             2              2             0         7        163000
directory               3              1             1         6         23000

2. the proof the accepted claim rests on
verifier            service   accept  30d requests
client-proof              3        7           870
password                  7       15         70776
community-string          1        2          9240
none                      7       21        305390
token                     2        4         84100

3. steps to narrow the surface (cumulative)
step                                              accept    unverified  stopped requests      cut flow  excluded
none                                                  49            21                 0             0         0
close never-used services                             43            19                 0             1         0
narrow access to zones that use it                    27            12                 0             4         0
add verification where no legacy device exists        27             7                 0             4         0
add verification to the rest                          27             0            167200             4       240

4. services still unverified after narrowing
host          service               accept  30d requests  legacy devices
collector     measurement-ingest         2         89400             220
collector     log-collection             2         43200              14
name-server   time-sync                  3         34600               6

5. reachable and used services per zone
zone      reachable        used        unused    unverified
e                 4           0             4             3
f                 5           3             2             4
b                20          10            10             7
c                20          14             6             7
```
The numbers belong to the **measurement** class; their inputs are PS4–PS6.

#### Forty-Nine Accept Points

Twenty services open **49 accept points** across six hosts. That is two and a half times the
service count, and the reason for the gap is access breadth: most services in the inventory
are reachable from two zones, and some from all four. **Twenty-one** of the forty-nine points
rest on no proof at all.

The second table separates services by what their proof rests on. Seven services perform no
verification at all, and with 21 accept points they are the largest group in the inventory;
the same seven services carry 305,390 of the thirty-day request volume — the largest share.
Seven services open 15 accept points with a password. Three services require client proof
and open only 7 accept points; the request volume they carry is 870. **The most tightly
verified services are the ones carrying the least work**; where verification is entirely
absent, the whole of the work flows. This inverse relationship is no coincidence: adding
verification requires changing every client that uses that service, and the most-used
service is the most expensive change.

The first table shows where the load collects. The collector performs no verification on
three of its four services and opens 12 accept points on its own. The name server performs
no verification on either of its two services and, with 163,000 requests, is the busiest
host in the inventory. The branch gateway, by contrast, requires proof on all four of its
four services.

Service count per host is also not a stand-in for the surface. The branch gateway and
billing each carry four services, but one opens 8 accept points and the other 10; the
directory opens 6 points with three services while the verifier also opens 6 points with
three services, yet their unverified point counts differ. **Host count, service count, and
accept point count are three separate quantities, and only the third feeds a narrowing
decision.**

#### What Thirty Days Does Not Show

The fifth table reads per zone and produces this lesson's most striking line: **four
services are reachable from the external network, yet none of those four services received a
single legitimate request from the external network in thirty days**. Three of the four have
no verification. These four items are an accept surface that does no work in return, while
standing fully open to the external network.

A similar gap exists in the field zone: five services are reachable, three are used. The
branch zone uses ten of twenty services, the center fourteen. The gap between reachable and
used exists in every zone, and its total is close to half the surface.

Three services received no requests at all in thirty days. This is where PS6 comes into play
and measures the window's lie: one of these three services — the database listener on the
verifier — is used **periodically** by the center and did not fall inside the thirty-day
window. Closing it based on usage data cuts a flow that is actually working on this item.

#### The Four Steps of Narrowing

The third table narrows the surface step by step and records the cost of each step. Closing
the three never-used services drops the accept point count from 49 to 43, stops no requests,
and cuts **one periodic flow**. Narrowing access to the zones that actually use it gives the
biggest gain: it falls from 43 to **27**, again stopping no requests, but the number of cut
periodic flows rises to four. Together, the first two steps close 44.9% of the surface while
spending zero of the thirty-day request volume.

What the four cut flows have in common is that none of them appeared in the thirty-day
window: the remote shell connecting to the branch gateway from the branch, the remote shell
connecting to the collector from the field, the access from the center to the file sharing on
the directory, and the closed database listener. All four are legitimate, and all four would
have shown up had the observation window been longer. **The cost of every narrowing based on
usage data is a function of window length.** A thirty-day window catches monthly jobs; it
does not catch quarterly closing jobs. Lengthening the window reduces the cut flow, but it
also reduces the number of items that can be closed.

#### Seven Points That Cannot Be Verified

The last two steps try to close the remaining unverified accept points and do not change the
accept point count at all — it stays at 27. What changes is the unverified point count, which
falls from 12 to 7 and then to 0. Accept points and unverified accept points are **two
separate numbers**: if a service is not closed, it is on the surface; if it is verified, it
is only no longer without proof.

The fourth table shows the cost of the last step. Devices that cannot verify are sending
requests to all three of the three services that remain unverified: 220 devices to the
measurement intake, 14 to log collection, 6 to time sync. Adding verification excludes these
**240 devices** and stops the entirety of the three services' 167,200 thirty-day requests.
The measurement intake is the network's actual work, and it is the single largest item that
cannot be verified.

This is where defense falls short. The first three steps are free or nearly free; the last
step is equivalent to shutting down the service that carries the most work in the inventory.
Seven unverified accept points are the result not of a wrong configuration decision but of
endpoint devices being unable to produce proof.

#### Summary

- An open port is notice that a class of claim is ready to be accepted; the size of the
  surface is not the number of services but the number of service–reachable-zone pairs.
- The 20 services in the inventory open **49 accept points** across 6 hosts; **21** of them
  rest on no proof at all, and the same 21 points carry the largest share of the thirty-day
  request volume.
- 4 services are reachable from the external network, and none of the four received a single
  legitimate request from the external network in thirty days; three have no verification.
- Closing unused services and narrowing access to the zones that use it drops the accept
  point count from 49 to 27, stops no requests, but cuts **4 periodic flows** that fall
  outside the window.
- Adding verification to the remaining 7 unverified accept points excludes 240 devices and
  stops 167,200 requests; these services are unverifiable not because they are misconfigured
  but because their endpoints cannot produce proof.

#### Next Step

This lesson assumed a service is **reachable**: every row in the inventory was written by
accepting that a packet can reach a host from a zone. But even two machines on the same
segment reaching each other rests on a claim. An endpoint knows its target's network address
but not which hardware address to send the frame to; it asks, and it writes the first reply
into its cache **without demanding any proof**. The next lesson builds that cache as a data
structure and counts it: how many records an unverified reply corrupts, how many a
consistency check catches, how many a pinned record rescues, and what the manual-management
cost of that is.

### Address Resolution Attacks

Source: https://academia.sh/en/courses/network-security/address-resolution-attacks

Measuring how the address resolution cache is corrupted by an unverified reply: 6 of 18 records corrupted in a set of 26 replies, a pinned record rescuing 4 and leaving 2, a consistency check rescuing 5 and leaving the single most valuable record, accepting only the requested reply rescuing all six while cutting 3 legitimate announcements, and the three defenses together rejecting 4 legitimate replies and never forming 2 records at all.

The previous lesson assumed a service was reachable: every row in the inventory was written
by accepting that a packet can reach a host from a zone. But even two machines on the same
segment reaching each other rests on a claim. An endpoint knows its target's network address
but not which hardware address to send the frame to. It asks, and writes the reply it
receives into its cache.

The write rule is this lesson's starting point. The cache does not test whether the incoming
reply is correct; it does not even ask whether the reply was requested. When this acceptance
produces a false mapping it is called **address resolution poisoning (ARP poisoning)**; when
the sender presents itself as the owner of another address it is called **address resolution
spoofing (ARP spoofing)**. Three of the eighteen claims in the first lesson were at the link
layer, and all three rest on this single rule. This lesson runs that rule on a data structure
and counts how many records an unverified reply corrupts.

#### What the Cache Can Test

**PS7:** the address resolution cache's acceptance rule is "the last reply wins"; whether the
reply was requested and whether it is correct play no part in the rule. **PS8:** a record is
corrupted when a network address becomes bound to a hardware address it does not actually
belong to; every corrupted record misdirects all frames headed to that address. **PS9:** a
defense can test a reply only against the cache's own data; it cannot know whether the reply
is correct.

PS9 is this lesson's boundary, and it determines all three of the three defenses. The cache
has no source of "the correct mapping"; the only thing that can be tested is whether an
incoming reply is **consistent** with the cache's existing data. Protocol mechanics — how
requests and replies are carried, cache lifetime, proxying, and unsolicited usage patterns —
were measured in the **Network Models and Protocols** course; they are not repeated here.

The reply set below is a **model**: it writes as data the replies that reach the cache on one
segment of a fictional regional measurement network; there is no real network or real hardware
address. The set contains no step that generates a forged reply; only the cache's acceptance
rule and its checks run. Sending such a reply set to a real network **is both illegal and
unprofessional without written authorization and a defined scope**.

```js
// networks/resolution.mjs — the address resolution cache of one segment in a fictional
// regional measurement network (model): there is no real network and no real hardware
// address. Hardware addresses are labeled d01..d17. No randomness.

// PS7: the address resolution cache's acceptance rule is "the last reply wins"; whether
// the reply was requested and whether it is correct play no part in the rule.
// PS8: a record is corrupted when a network address becomes bound to a hardware address it
// does not actually belong to; every corrupted record misdirects all frames headed there.
// Columns: network address, claiming hardware address, is it a reply to a pending request
// (y/n), is the claim correct (y/n). The last column is for scoring only; no defense sees it.
export const REPLY = `
  10.20.0.1   d01  y y     10.20.0.5   d02  y y     10.20.0.6   d03  y y
  10.20.0.7   d04  y y     10.20.0.8   d05  y y     10.20.0.9   d06  y y
  10.20.0.10  d07  y y     10.20.1.11  d08  y y     10.20.1.12  d09  y y
  10.20.1.13  d10  y y     10.20.2.21  d11  y y     10.20.2.22  d12  y y
  10.20.0.11  d13  y y     10.20.0.12  d14  y y     10.20.0.30  d17  y y
  10.20.0.31  d17  y y     10.20.0.2   d01  n y     10.20.0.32  d17  y y
  10.20.0.7   d15  n y     10.20.2.22  d16  n y     10.20.0.1   d09  n n
  10.20.0.5   d09  n n     10.20.0.6   d09  n n     10.20.0.8   d09  n n
  10.20.0.10  d09  n n     10.20.0.9   d09  n n`
  .trim().split(/\s+/).reduce((l, _, i, a) => (i % 4 ? l : [...l,
    { net: a[i], hw: a[i + 1], req: a[i + 2] === "y", ok: a[i + 3] === "y" }]), []);

// PS9: a defense can test a reply only against the cache's own data; it cannot know
// whether the reply is correct. All three defenses work within this limit.
export const PINNED = ["10.20.0.1", "10.20.0.5", "10.20.0.6", "10.20.0.7", "10.20.0.10"];
export const THRESHOLD = 2;

// The cache processes replies in order; the selected defenses test each reply before accepting it.
export function cache(defense = []) {
  const table = new Map(), rejected = [];
  for (const rep of REPLY) {
    if (defense.includes("pinned") && PINNED.includes(rep.net) && table.has(rep.net)) {
      rejected.push(rep); continue;
    }
    if (defense.includes("requested") && !rep.req) { rejected.push(rep); continue; }
    if (defense.includes("consistency")) {
      const after = [...table].filter(([a, d]) => d === rep.hw && a !== rep.net).length + 1;
      if (after > THRESHOLD) { rejected.push(rep); continue; }
    }
    table.set(rep.net, rep.hw);
  }
  const corrupted = [...table].filter(([a, d]) =>
    !REPLY.some((rep) => rep.net === a && rep.hw === d && rep.ok));
  return { table, corrupted, rejectedLegit: rejected.filter((r) => r.ok), rejectedFalse: rejected.filter((r) => !r.ok) };
}
```

```js
// networks/cache-audit.mjs — how many records an unverified reply corrupts, which defense
// rescues how many, how many stay corrupted, and what the cost is.
import { REPLY, PINNED, THRESHOLD, cache } from "./resolution.mjs";

const write = (g, ...s) => console.log(s.map((v, i) =>
  (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join(""));
const baseline = cache();

console.log(`${REPLY.length} replies, ${baseline.table.size} records; ` +
  `corrupted records ${baseline.corrupted.length}, incorrect replies ` +
  `${REPLY.filter((r) => !r.ok).length}, unrequested replies ` +
  `${REPLY.filter((r) => !r.req).length}`);

const A = [-14, 10, 13, 9];
console.log("\n1. reply set: being requested and being correct are two separate properties");
write(A, "reply", "correct", "not correct", "total");
for (const [name, f] of [["requested", (r) => r.req], ["unrequested", (r) => !r.req]]) {
  const l = REPLY.filter(f);
  write(A, name, l.filter((r) => r.ok).length, l.filter((r) => !r.ok).length, l.length);
}

const B = [-10, 9, 15, 16];
console.log("\n2. signal: how many network addresses did a hardware address claim");
write(B, "hardware", "claimed", "accepted", "over threshold");
const hw = [...new Set(REPLY.map((r) => r.hw))];
for (const h of hw.filter((h) => REPLY.filter((r) => r.hw === h).length > 1)) {
  const l = [...new Set(REPLY.filter((r) => r.hw === h).map((r) => r.net))];
  const k = [...baseline.table].filter(([, v]) => v === h).length;
  write(B, h, l.length, k, l.length > THRESHOLD ? "yes" : "no");
}

const C = [-34, 9, 11, 12, 16, 8, 14];
console.log("\n3. defenses: rescued records and their cost");
write(C, "defense", "records", "corrupted", "rescued", "legit rejected", "manual", "extra rounds");
for (const s of [[], ["pinned"], ["consistency"], ["requested"], ["pinned", "consistency"],
  ["pinned", "consistency", "requested"]]) {
  const r = cache(s);
  write(C, s.join(" + ") || "none", r.table.size, r.corrupted.length,
    baseline.corrupted.length - r.corrupted.length, r.rejectedLegit.length,
    s.includes("pinned") ? PINNED.length : 0, s.includes("requested") ? r.table.size : 0);
}

const D = [-28, -34, -44];
console.log("\n4. which record stays corrupted, which legitimate reply is rejected");
write(D, "defense", "record still corrupted", "legitimate reply rejected");
for (const s of [["pinned"], ["consistency"], ["requested"], ["pinned", "consistency"]]) {
  const r = cache(s);
  write(D, s.join(" + "), r.corrupted.map(([a]) => a).join(" ") || "none",
    r.rejectedLegit.map((rep) => `${rep.net}=${rep.hw}`).join(" ") || "none");
}
```

```
26 replies, 18 records; corrupted records 6, incorrect replies 6, unrequested replies 9

1. reply set: being requested and being correct are two separate properties
reply            correct  not correct    total
requested             17            0       17
unrequested            3            6        9

2. signal: how many network addresses did a hardware address claim
hardware    claimed       accepted  over threshold
d01               2              1              no
d09               7              7             yes
d17               3              3             yes

3. defenses: rescued records and their cost
defense                             records  corrupted     rescued  legit rejected  manual  extra rounds
none                                     18          6           0               0       0             0
pinned                                   18          2           4               1       5             0
consistency                              17          1           5               1       0             0
requested                                17          0           6               3       0            17
pinned + consistency                     17          1           5               2       5             0
pinned + consistency + requested         16          0           6               4       5            16

4. which record stays corrupted, which legitimate reply is rejected
defense                     record still corrupted            legitimate reply rejected                   
pinned                      10.20.0.8 10.20.0.9               10.20.0.7=d15                               
consistency                 10.20.0.1                         10.20.0.32=d17                              
requested                   none                              10.20.0.2=d01 10.20.0.7=d15 10.20.2.22=d16  
pinned + consistency        10.20.0.8                         10.20.0.32=d17 10.20.0.7=d15                
```
The numbers belong to the **measurement** class; their inputs are PS7–PS9.

#### Six Records

Twenty-six replies produce eighteen records, and with no defense in place **six** of those
records are corrupted. The corruption rate is one in three, but what actually matters is
which records are corrupted: the gateway, the collector, the verifier, the name server, the
directory, and the measurement store. All six are addresses that carry traffic leaving the
segment or heading to central services; the field terminals' and the printer's records are
not corrupted.

The number of corrupted records and the traffic affected are not the same number. In the
previous lesson's inventory, the four services on the collector received 141,652 requests in
thirty days, and the two services on the name server received 163,000. For an endpoint on
this segment, all six of the six corrupted records deliver frames headed to their destination
to the wrong hardware address; the impact per record equals the request volume that address
carries. **The number of corrupted records measures the surface, while the request volume
carried measures the impact, and the two do not give the same ranking.**

The first table separates two properties. All seventeen replies that are a response to a
pending request are correct. Nine replies arrived unrequested; of these, **three are correct
and six are not**. Being unrequested does not mean being false: the announcement of the
virtual gateway address, billing address failover, and an interface change are all announced
unrequested, and all three are legitimate. A defense that rejects unrequested replies
wholesale cuts these three along with the six false ones.

The second table gives the **signal** a check can see. Three hardware addresses claim more
than one network address: one claims two, one three, one **seven**, and all seven of the
seven are accepted. The row of seven collects replies that come from one endpoint on the same
segment and claim six addresses beyond its own. The rows claiming two and three addresses are
legitimate: one is the gateway's virtual address, the other is a host running three guest
systems. **A signal is a number, not a decision**; wherever the threshold is set, it cuts
correct and incorrect together.

#### Three Defenses, Three Separate Gaps

The third table runs the three defenses separately and together. A pinned record puts five
addresses on a list and ignores any subsequent reply for those on the list: it rescues four
corrupted records and **leaves two**. The two left behind are the name server and the
directory, which are not on the list. A pinned record's protection extends exactly as far as
the list's length, and it rescues no record that is not on the list.

A consistency check allows a hardware address to claim at most two network addresses, and it
rescues **five** of the six corrupted records. The one record it cannot rescue is the
gateway. The reason is order: the hardware address claiming seven addresses claims the
gateway **first**, in addition to its own address, and at that point the threshold has not
yet been crossed. The threshold is only crossed on the second claim, and everything after
that point is rejected. **A threshold-based check accepts claims up to the threshold; the
claim it misses is always the first claim, and the first claim is usually the most valuable
target.**

Accepting only the requested reply rescues all six of the six corrupted records and drops the
corrupted record count to zero. This is the only one of the three defenses that is complete,
because all six of the six false replies arrived unrequested. This completeness is a property
of the reply set, not of the defense's strength: a reply arriving as a response to a pending
request would have passed the same check.

The third table's fifth row shows that a combination of defenses does not always add up. With
a pinned record and a consistency check both on, the corrupted record count stays at **one**
— the same number the consistency check alone gives. The rejected legitimate reply count,
though, rises from one to **two**, and five manual items are added on top. The fourth table
shows that the record left corrupted **changes**: with consistency alone, the gateway record
stayed corrupted; in the combination, the name server record stays corrupted instead. The
pinned list cuts the first three claims, so the first claim to cross the threshold is now the
name server, fourth in line, and it is accepted. **The record a threshold-based check misses
shifts when the defense ahead of it changes; the count of records missed stays the same.**

#### Three Shapes of Cost

The fourth table names the legitimate reply each defense cuts, and the shape of the three
costs is entirely different from one another.

The pinned record's cost is **manual management**: five entries are kept on a list, each is
updated by hand on every address change, and when an address on the list legitimately
changes hands the change is rejected. The table shows exactly this: billing address failover
gets cut. Five entries is a small number, but every new critical address on the network grows
that number, and a forgotten entry stays silently unprotected.

The consistency check's cost is **a false catch**: the third address of a host running three
guest systems is rejected. Raising the threshold to three rescues this legitimate record, but
the same move also accepts the second claim of the row claiming seven addresses. The
threshold shifts both the correct and the incorrect in the same direction.

The cost of accepting only the requested reply is **round-trip count and cut announcements**:
each of the seventeen records requires one request round trip, and all three of the three
legitimate unrequested announcements are rejected. Cutting a failover announcement means the
cache keeps the old record until its lifetime expires; at the moment of failover, part of the
traffic keeps going to the old hardware address.

With all three on together, corrupted records are zero, but the rejected legitimate reply
count rises to four and the record count falls from eighteen to **sixteen**. The two missing
records are two mappings that never form at all: the virtual gateway address and the third
guest system's address. Traffic headed to those two addresses does not go anywhere until a
record forms. **The cost of zeroing corrupted records is not only the rejected reply but the
record that never forms.** A rejected reply leaves a record in the log and can be searched
for; a mapping that never forms leaves no trace at all and is only noticed once that address
turns out to be unreachable.

#### Summary

- The address resolution cache's acceptance rule is "the last reply wins"; **6** of 18
  records are corrupted in a set of 26 replies, and all six are addresses carrying traffic
  leaving the segment.
- Being requested and being correct are separate properties: all 17 of 17 requested replies
  are correct, while 3 of 9 unrequested replies are correct and 6 are not.
- A pinned record rescues 4 records for the 5 addresses on its list and leaves the 2 that are
  not; a consistency check rescues 5 records and misses the gateway record, which comes
  first in order.
- Accepting only the requested reply rescues all 6 of 6 records, but it cuts 3 legitimate
  unrequested announcements and adds an extra request round trip for 17 records.
- The three defenses together zero out corrupted records; the cost is 4 rejected legitimate
  replies, 5 manual items, 16 extra round trips, and 2 records that never form at all.

#### Next Step

This lesson stayed within one segment: the question asked was "which hardware address does
this network address belong to," and the answer came from the same segment. Every request
that leaves the segment first asks a different question — which address a name corresponds
to. The answer to that question is also written into a cache, but this time the cache is not
local to one segment; it sits at every layer of the network, and a single false record
misdirects not one machine but every client that reads that record. The next lesson builds
and counts the name resolution cache the same way: how many records and how many dependent
services an unverified reply corrupts, how many signed verification rescues, and how many
entries an unsigned zone leaves out in the open.

### Name Resolution Attacks

Source: https://academia.sh/en/courses/network-security/name-resolution-attacks

Measuring how the name resolution cache is corrupted by an unverified reply: 7 of 16 names corrupted, misdirecting 38 of 73 dependent services; signed verification rescuing 3 records and leaving 4 in an unsigned zone; an expired signature making 2 names fully unresolvable and stopping 9 services; strict mode zeroing out corrupted records while stopping 47 services; and a lifetime cap cutting corrupted-record duration by 88.1% while raising query count by 346.5%.

The previous lesson stayed within one segment: the question asked was which hardware address a
network address belonged to, and the answer came from the same segment. Every request that
leaves the segment first asks a different question — which address a name corresponds to. The
answer to that question is also written into a cache.

There is a scale difference between the two caches. An address resolution record misdirects a
single target on a single segment; a name resolution record misdirects **all** services that
use that name at once, and the cache sits at every layer of the network. When a name record is
replaced by an unverified reply this is called **name resolution poisoning (DNS poisoning)**;
when zone records are signed and tested by the receiver, this is called **signed name
resolution (DNSSEC)**. This lesson counts that difference: how many records and how many
dependent services an unverified reply corrupts, how many signed verification rescues, and how
many entries an unsigned zone leaves behind.

#### What a Signature Can Test

**PS10:** a name resolution reply is a claim — "this name corresponds to this address"; in an
unsigned zone this claim has no evidence in the cache that can be tested. **PS11:** the
corruption of a name record misdirects all services bound to that name at once; the measure of
impact is not the record count but the **dependent service count**. **PS12:** a signature can
test only the replies of a signed zone; the reply of an unsigned zone can neither be verified
nor falsified.

PS12 is this lesson's determining clause. Signed name resolution does not split a reply into
"correct" or "incorrect"; it splits it three ways into **"verified," "failed verification," and
"untestable."** The third set is every reply that comes from an unsigned zone, and for these
replies the cache stays in the state counted in the first lesson: there is no evidence. Record
types, the roles in the resolution chain, and lifetime-based caching were measured in the
**Computer Networks** curriculum; they are not repeated here.

The name set below is a **model**: the names and zones are fictional, and the addresses come
from blocks reserved for private use and documentation. The set contains no step that generates
a forged reply; the code that runs is only the cache's acceptance rule and its checks. Sending
such a reply set to a resolver you do not own **is both illegal and unprofessional without
written authorization and a defined scope**.

```js
// networks/name-resolution.mjs — the name resolution cache of a fictional regional measurement
// network (model): the names and zones are fictional, there is no real domain name; the
// addresses come from blocks reserved for private use and documentation. No randomness.

// PS10: a name resolution reply is a claim: "this name corresponds to this address". In an
// unsigned zone this claim has no evidence in the cache that can be tested.
// PS11: the corruption of a name record misdirects all services bound to that name at once;
// the measure of impact is not the record count but the dependent service count.
// Columns: name, zone, lifetime (seconds), number of services bound to that name.
export const NAME = `
  collector.measurement  measurement  3600  6  verifier.measurement  measurement  3600   5
  storage.measurement    measurement  1800  4  intake.measurement    measurement   300   9
  transfer.measurement   measurement  3600  2  billing.corporate     corporate    3600   7
  workorder.corporate    corporate    3600  3  report.corporate      corporate    7200   1
  directory.corporate    corporate    1800  8  identity.corporate    corporate     900  11
  branch01.field         field         600  2  branch02.field        field         600   2
  gateway.field          field        1800  4  interface.external    external     3600   3
  status.external        external     7200  1  update.external       external     1800   5`
  .trim().split(/\s+/).reduce((l, _, i, a) => (i % 4 ? l : [...l,
    { name: a[i], zone: a[i + 1], ttl: +a[i + 2], deps: +a[i + 3] }]), []);

// PS12: a signature can test only the replies of a signed zone; the reply of an unsigned
// zone can neither be verified nor falsified.
export const SIGNED = ["measurement", "external"];

// The replies that reach the cache. sig: valid | invalid | expired | none (unsigned zone).
// The ok column is for scoring only; no defense sees it.
export const REPLY = `
  collector.measurement  10.30.0.5     valid    y  intake.measurement    10.30.0.8     valid    y
  storage.measurement    10.30.0.10    expired  y  transfer.measurement  10.30.0.11    valid    y
  verifier.measurement   10.30.0.6     expired  y  report.corporate      10.30.1.9     none     y
  billing.corporate      10.30.1.7     none     y  directory.corporate   10.30.1.12    none     y
  workorder.corporate    10.30.1.8     none     y  identity.corporate    10.30.1.13    none     y
  branch01.field         10.30.2.21    none     y  branch02.field        10.30.2.22    none     y
  gateway.field          10.30.2.1     none     y  interface.external    198.51.100.4  valid    y
  status.external        198.51.100.5  valid    y  update.external       198.51.100.6  valid    y
  collector.measurement  10.30.9.90    invalid  n  verifier.measurement  10.30.9.90    invalid  n
  interface.external     10.30.9.90    invalid  n  billing.corporate     10.30.9.90    none     n
  identity.corporate     10.30.9.90    none     n  branch01.field        10.30.9.90    none     n
  gateway.field          10.30.9.90    none     n`
  .trim().split(/\s+/).reduce((l, _, i, a) => (i % 4 ? l : [...l,
    { name: a[i], address: a[i + 1], sig: a[i + 2], ok: a[i + 3] === "y" }]), []);

// mode: "none" = signature not tested; "signed" = invalid and expired signatures rejected;
// "strict" = an unsigned zone's reply is also rejected.
export function resolve(mode = "none") {
  const table = new Map(), rejected = [];
  for (const rep of REPLY) {
    const badSig = rep.sig === "invalid" || rep.sig === "expired";
    if (mode !== "none" && badSig) { rejected.push(rep); continue; }
    if (mode === "strict" && rep.sig === "none") { rejected.push(rep); continue; }
    table.set(rep.name, rep.address);
  }
  const records = NAME.map((a) => ({ ...a, address: table.get(a.name) }));
  return {
    records,
    corrupted: records.filter((a) => a.address &&
      !REPLY.some((rep) => rep.name === a.name && rep.address === a.address && rep.ok)),
    unresolved: records.filter((a) => !a.address),
    rejectedLegit: rejected.filter((rep) => rep.ok),
  };
}
```

```js
// networks/name-audit.mjs — how many records and dependent services an unverified reply corrupts,
// how many signed verification rescues, how many entries an unsigned zone leaves, and the cost.
import { NAME, REPLY, SIGNED, resolve } from "./name-resolution.mjs";

const write = (g, ...s) => console.log(s.map((v, i) =>
  (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join(""));
const baseline = resolve(), CAP = 300;
const deps = (l) => l.reduce((s, a) => s + a.deps, 0);
const queries = (t) => NAME.reduce((s, a) => s + Math.floor(86400 / Math.min(a.ttl, t)), 0);
const KEYS = 3; // per zone: zone key, signing key, digest given to the parent zone

console.log(`${NAME.length} names, ${REPLY.length} replies, ${deps(NAME)} dependent services; ` +
  `signed zones ${SIGNED.length}/${new Set(NAME.map((a) => a.zone)).size}, ` +
  `corrupted records ${baseline.corrupted.length}, affected dependent services ${deps(baseline.corrupted)}`);

const A = [-11, 8, 9, 10, 19, 11];
console.log("\n1. per zone");
write(A, "zone", "signed", "names", "deps", "corrupted records", "affected");
for (const z of [...new Set(NAME.map((a) => a.zone))]) {
  const l = NAME.filter((a) => a.zone === z), c = baseline.corrupted.filter((a) => a.zone === z);
  write(A, z, SIGNED.includes(z) ? "yes" : "no", l.length, deps(l), c.length, deps(c));
}

const B = [-22, -11, 9, 11, 20];
console.log("\n2. baseline: corrupted records and the dependents they carry");
write(B, "name", "zone", "deps", "ttl (sec)", "rescued by signing");
for (const a of baseline.corrupted)
  write(B, a.name, a.zone, a.deps, a.ttl, SIGNED.includes(a.zone) ? "yes" : "no");

const C = [-8, 11, 11, 12, 18, 16, 9, 14];
console.log("\n3. modes: rescued records and their cost");
write(C, "mode", "corrupted", "affected", "unresolved", "stopped services", "legit rejected", "keys", "extra rounds");
for (const [m, signedZones] of [["none", 0], ["signed", SIGNED.length], ["strict", 4]]) {
  const r = resolve(m);
  const scope = NAME.filter((a) => m === "strict" || SIGNED.includes(a.zone)).length;
  write(C, m, r.corrupted.length, deps(r.corrupted), r.unresolved.length, deps(r.unresolved),
    r.rejectedLegit.length, signedZones * KEYS, m === "none" ? 0 : scope);
}

const D = [-23, 10, 12, 13, 12];
console.log(`\n4. if the lifetime cap is ${CAP} sec`);
write(D, "measure", "uncapped", "capped", "diff", "diff %");
const measures = [
  ["daily queries", queries(1e9), queries(CAP)],
  ["corrupted record-sec", baseline.corrupted.reduce((s, a) => s + a.ttl, 0),
    baseline.corrupted.reduce((s, a) => s + Math.min(a.ttl, CAP), 0)],
  ["longest corrupted sec", Math.max(...baseline.corrupted.map((a) => a.ttl)),
    Math.max(...baseline.corrupted.map((a) => Math.min(a.ttl, CAP)))],
];
for (const [name, x, y] of measures)
  write(D, name, x, y, y - x, (((y - x) / x) * 100).toFixed(1));
```

```
16 names, 23 replies, 73 dependent services; signed zones 2/4, corrupted records 7, affected dependent services 38

1. per zone
zone         signed    names      deps  corrupted records   affected
measurement     yes        5        26                  2         11
corporate        no        5        30                  2         18
field            no        3         8                  2          6
external        yes        3         9                  1          3

2. baseline: corrupted records and the dependents they carry
name                  zone            deps  ttl (sec)  rescued by signing
collector.measurement measurement        6       3600                 yes
verifier.measurement  measurement        5       3600                 yes
billing.corporate     corporate          7       3600                  no
identity.corporate    corporate         11        900                  no
branch01.field        field              2        600                  no
gateway.field         field              4       1800                  no
interface.external    external           3       3600                 yes

3. modes: rescued records and their cost
mode      corrupted   affected  unresolved  stopped services  legit rejected     keys  extra rounds
none              7         38           0                 0               0        0             0
signed            4         24           2                 9               2        6             8
strict            0          0          10                47              10       12            16

4. if the lifetime cap is 300 sec
measure                  uncapped      capped         diff      diff %
daily queries                1032        4608         3576       346.5
corrupted record-sec        17700        2100       -15600       -88.1
longest corrupted sec        3600         300        -3300       -91.7
```

The numbers are in the **measurement** class; their inputs are PS10–PS12.

#### Seven Records, Thirty-Eight Services

Sixteen names produce sixteen records, and with no defense in place **seven** of them are
corrupted. The record rate is 43.8%, but the real number is in the second column: seven
corrupted records misdirect **thirty-eight** of seventy-three dependent services. A record does
not carry one target; it carries every service that uses that name.

The second table shows this disproportion step by step. The `branch01.field` record in the
field zone carries only two services; the `identity.corporate` record carries **eleven**
services. Both are a single record, and both are corrupted by the same rule. What determines
the order of corruption in a name set is not the record count, it is the dependents per record.

The first table reads at the zone level. The two signed zones have five and three names; the
two unsigned zones have five and three. Of the seven corrupted records, **three** are in signed
zones and carry fourteen dependent services; **four** are in unsigned zones and carry
twenty-four dependent services. The corporate zone alone is the heaviest zone with thirty
dependent services, and it is unsigned.

This last sentence shows that signing coverage does not track dependency weight. The two signed
zones cover thirty-five of seventy-three dependent services; the two unsigned zones cover
thirty-eight. Coverage is an even split by name count (eight names against eight), but by
dependents carried the signed side is the minority. The single name carrying the most services
— `identity.corporate` with eleven — is in an unsigned zone. **The signing decision is made
zone by zone, while the impact is distributed name by name.**

#### What the Signature Rescues and Leaves Behind

The second row of the third table opens up signed verification. Corrupted records drop from
seven to **four**, affected dependent services from thirty-eight to **twenty-four**. All three
rescued records are in signed zones; the four in unsigned zones stay exactly as they were.
**Signed verification's coverage is the zone boundary, and nothing changes outside that
boundary.**

The fourth and fifth columns of the same row write the defense's own cost. Two names become
**fully unresolvable** and nine dependent services stop. The cause is not an attack: the
legitimate replies for these two names arrived with an expired signature, and verification
rejects them too. Two legitimate replies are cut off. With signed verification on, **a
signature's expiry makes that name behave as if it had no reply at all**; in an unsigned zone
the same situation never occurs.

The third row measures strict mode: replies from unsigned zones are rejected too. Corrupted
records drop to zero and affected dependent services drop to zero. The cost is ten names
becoming unresolvable and **forty-seven dependent services stopping** — more than two-thirds of
seventy-three. Ten legitimate replies are rejected, the key entry rises from six to twelve, and
every name requires one extra round trip.

Strict mode is one of the clearest examples in this course of a defense falling short. It
zeroes out the unverified claim, but not by verifying the claim — by rejecting everything that
cannot be verified. The real fix for the remaining four corrupted records is not strict mode
but signing the two unsigned zones; that is the row in this table that raises the key entry to
twelve and the extra round trips to sixteen.

Signed mode's net gain narrows further once both costs are written together. In the baseline,
thirty-eight dependent services are misdirected and none stop. In signed mode, twenty-four are
misdirected and nine stop; the count of correctly working services rises from thirty-five to
forty. In a set of seventy-three services, the net gain is **five services**. The same defense
is fully effective inside the signed zones; what makes the total this small is the weight the
unsigned zones carry and the signature's own failure mode.

#### The Lifetime Cap

The fourth table measures a defense independent of signing: placing a cap on record lifetime.
This does not prevent corruption; it limits **how long** a corrupted record stays corrupted.

Uncapped, the total lifetime of the seven corrupted records is 17,700 seconds and the longest
is 3,600 seconds. When the cap is pulled down to 300 seconds, the total drops to 2,100 seconds
and the longest to 300 seconds — a drop of **88.1%** and **91.7%** respectively. In exchange,
the daily query count rises from 1,032 to 4,608: a **346.5% increase**.

Read together, the two ratios reveal the cap's nature. The lifetime cap **never changes** the
count of verified claims; not a single number moves in this lesson's first column. What it
changes is how long a corrupted record's effect lasts. In a three-column measurement, this is a
defense that leaves the first and second columns empty and fills only the third — at the cost
of raising query volume by four and a half times. **Shortening the effect is not a substitute
for verifying the claim.**

#### Summary

- A name resolution reply cannot be tested in an unsigned zone; **7 of 16** names are
  corrupted, and these 7 records misdirect **38 of 73** dependent services.
- The measure of impact is not the record count but dependents: one record carries 2 services
  while another carries 11, and both are corrupted by the same rule.
- Signed verification lowers corrupted records from 7 to 4 and affected services from 38 to 24;
  all 3 rescued records are in signed zones, and nothing changes in unsigned zones.
- The signature has its own cost: an expired signature makes 2 names fully unresolvable and
  stops 9 dependent services; strict mode zeroes out corrupted records but makes 10 names
  unresolvable, stops 47 services, and raises the key entry to 12 and extra round trips to 16.
- The lifetime cap never changes the count of verified claims; it shortens a corrupted record's
  duration by 88.1% and raises daily query count by 346.5%.

#### Next Step

This lesson and the one before it shared an assumption: that the endpoint asking the cache a
question **already knew who to ask**. The name resolver's address, the gateway's address, and
the endpoint's own address were all taken as given. But when an endpoint joins a network it
knows none of these; it asks, and it accepts the first reply that arrives. That single reply
carries three separate claims at once: this is your address, the gateway is this, your name
resolver is this. The next lesson builds address assignment as a data structure and counts it:
how many claims one assignment reply carries, how many endpoints the first-reply-wins rule
binds to which configuration, how many port trust and binding tables rescue, and what the cost
of manual management turns out to be.

### Address Assignment Risks

Source: https://academia.sh/en/courses/network-security/address-assignment-risks

Measuring address assignment in claims: 14 endpoints accepting 70 claims without verification at 5 claims per reply, the first-reply-wins rule binding 7 endpoints to a source with no proof, port trust zeroing out misconfiguration while leaving 2 endpoints unconfigured, and pool inspection combined with static assignment correctly configuring the same 12 endpoints with 3 manual items while leaving 2 endpoints misconfigured.

The previous two lessons shared an assumption: that the endpoint asking the cache a question
**already knew who to ask**. The name resolver's address, the gateway's address, and the
endpoint's own address were all taken as given. But when an endpoint joins a network it knows
none of these.

Because it does not know, it asks, and at the moment it asks it has no criterion at all: it
cannot tell which reply comes from an authoritative source, because the very configuration that
would let it tell arrives with that reply. The acceptance rule is therefore very plain — **the
first reply to arrive wins**. When an unauthorized source uses this rule to hand out
configuration, this is called a **rogue address assignment server (rogue DHCP server)**. This
lesson runs that rule on a data structure and counts how many claims a single reply binds at
once.

#### One Reply, Five Claims

**PS13:** an address assignment reply carries five claims in a single message — address,
gateway, name resolver, lease time, and domain suffix — and the endpoint accepts every one of
them without testing. **PS14:** the endpoint's acceptance rule is "the first reply wins"; who
the source is plays no part in the rule. **PS15:** a defense can filter a reply only by its
arrival path and its content; there is no evidence in the message showing whether the source is
authoritative.

PS13 sets this lesson apart from the previous two. In address resolution, what was corrupted
was a single mapping; in name resolution, it was a name and the services bound to it. Here, a
single acceptance determines the endpoint's **entire** behavior on the network: which address
it uses, where it sends its outbound traffic, and who it asks for names. The third claim links
directly back to the previous lesson: once the name resolver claim is wrongly accepted, all of
that endpoint's name records are resolved again through a source that carries no signed
verification at all.

The four-step lease process, lease time and renewal thresholds, and the relay agent's role were
measured in the **Network Models and Protocols** course; they are not repeated here. The
assignment set below is a **model**: the endpoints in a fictional branch segment and their
reply delays are written as data — there is no real network or real device. The set contains no
forged assignment step; the code that runs is only the endpoint's acceptance rule and the
segment's defenses. Performing such a measurement outside a segment under your own
administration **is both illegal and unprofessional without written authorization and a defined
scope**.

```js
// networks/assignment.mjs — address assignment in a branch segment of a fictional regional
// measurement network (model): no real network or device; addresses from a private block. No randomness.

// PS13: an address assignment reply carries five claims in a single message, and the
// endpoint accepts every one of them without testing.
export const CLAIMS = ["address", "gateway", "name resolver", "lease time", "domain suffix"];

// PS14: the endpoint's acceptance rule is "the first reply wins"; the source does not matter.
// Columns: endpoint, delay from source a (ms), from b, from c, whether b's address is
// inside the authoritative pool (y/n). "-" means no reply arrived from that source.
// a = the segment's authoritative distributor (trusted port), c = backup authoritative
// distributor (untrusted port), b = a source with no proof.
export const ENDPOINT = `
  u01 40 12  -  y    u02 38 55  -  y    u03 45 20  -  n    u04 30 70  -  y
  u05 50 18  -  y    u06 42 90  -  n    u07 36 22  -  n    u08 48 14  -  y
  u09 33 60  -  y    u10 44 25  -  y    u11 39 80  -  n    u12 41 28  -  n
  u13  - 95 30  y    u14  - 45 26  y`
  .trim().split(/\s+/).reduce((l, _, i, a) => (i % 5 ? l : [...l, {
    id: a[i], delay: { a: a[i + 1], b: a[i + 2], c: a[i + 3] }, inPool: a[i + 4] === "y",
  }]), []);

// PS15: a defense can filter a reply only by its arrival path and its content; there is
// no evidence in the message showing whether the source is authoritative.
export const STATIC = ["u05", "u08"];
export const PORT = 24;      // number of ports in the segment switch where a trust setting is written
export const AUTH = ["a", "c"];

export function assign(defenses = []) {
  const result = ENDPOINT.map((u) => {
    if (defenses.includes("static") && STATIC.includes(u.id))
      return { id: u.id, source: "static", correct: true };
    const candidate = Object.entries(u.delay)
      .filter(([k, v]) => v !== "-"
        && !(defenses.includes("port") && k !== "a")
        && !(defenses.includes("pool") && k === "b" && !u.inPool))
      .sort((x, y) => +x[1] - +y[1])[0];
    return candidate
      ? { id: u.id, source: candidate[0], correct: AUTH.includes(candidate[0]) }
      : { id: u.id, source: "-", correct: false };
  });
  return {
    result,
    wrong: result.filter((s) => s.source === "b"),
    unassigned: result.filter((s) => s.source === "-"),
    rejectedLegit: ENDPOINT.filter((u) => u.delay.c !== "-" && defenses.includes("port")).length,
  };
}
```

```js
// networks/assignment-audit.mjs — how many claims one assignment reply carries, how many endpoints the
// first-reply-wins rule binds to which source, how many a defense rescues, and at what cost.
import { ENDPOINT, CLAIMS, STATIC, PORT, assign } from "./assignment.mjs";

const write = (g, ...s) => console.log(s.map((v, i) =>
  (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join(""));
const baseline = assign(), N = ENDPOINT.length, I = CLAIMS.length;

console.log(`${N} endpoints, ${I} claims per reply, ${N * I} claims accepted in total ` +
  `without verification; endpoints choosing an unproven source ${baseline.wrong.length}, ` +
  `claims accepted that way ${baseline.wrong.length * I}`);

const A = [-6, 8, 8, 8, 11, 10];
console.log("\n1. the first reply wins: which source won and by how many ms");
write(A, "id", "a (ms)", "b (ms)", "c (ms)", "b in pool", "winner");
for (const u of ENDPOINT)
  write(A, u.id, u.delay.a, u.delay.b, u.delay.c, u.inPool ? "yes" : "no",
    baseline.result.find((s) => s.id === u.id).source);

const B = [-22, 19, 17, 17];
console.log("\n2. defenses: endpoints rescued and their cost");
write(B, "defense", "correct endpoints", "wrong endpoints", "unconfigured");
const C = [-22, 14, 16, 8];
const cfg = [[], ["port"], ["pool"], ["static"], ["pool", "static"],
  ["port", "pool", "static"]];
for (const s of cfg) {
  const r = assign(s);
  write(B, s.join(" + ") || "none", r.result.filter((x) => x.correct).length,
    r.wrong.length, r.unassigned.length);
}
console.log("\n3. the cost of the same defenses");
write(C, "defense", "wrong claims", "legit rejected", "manual");
for (const s of cfg) {
  const r = assign(s);
  write(C, s.join(" + ") || "none", r.wrong.length * I, r.rejectedLegit,
    (s.includes("port") ? PORT : 0) + (s.includes("pool") ? 1 : 0) +
    (s.includes("static") ? STATIC.length : 0));
}

const D = [-22, -30, -22];
console.log("\n4. which endpoints remain");
write(D, "defense", "chose unproven source", "got no configuration");
for (const s of cfg.slice(1)) {
  const r = assign(s);
  write(D, s.join(" + "), r.wrong.map((x) => x.id).join(" ") || "none",
    r.unassigned.map((x) => x.id).join(" ") || "none");
}
```

```
14 endpoints, 5 claims per reply, 70 claims accepted in total without verification; endpoints choosing an unproven source 7, claims accepted that way 35

1. the first reply wins: which source won and by how many ms
id      a (ms)  b (ms)  c (ms)  b in pool    winner
u01         40      12       -        yes         b
u02         38      55       -        yes         a
u03         45      20       -         no         b
u04         30      70       -        yes         a
u05         50      18       -        yes         b
u06         42      90       -         no         a
u07         36      22       -         no         b
u08         48      14       -        yes         b
u09         33      60       -        yes         a
u10         44      25       -        yes         b
u11         39      80       -         no         a
u12         41      28       -         no         b
u13          -      95      30        yes         c
u14          -      45      26        yes         c

2. defenses: endpoints rescued and their cost
defense                 correct endpoints  wrong endpoints     unconfigured
none                                    7                7                0
port                                   12                0                2
pool                                   10                4                0
static                                  9                5                0
pool + static                          12                2                0
port + pool + static                   12                0                2

3. the cost of the same defenses
defense                 wrong claims  legit rejected  manual
none                              35               0       0
port                               0               2      24
pool                              20               0       1
static                            25               0       2
pool + static                     10               0       3
port + pool + static               0               2      27

4. which endpoints remain
defense               chose unproven source         got no configuration  
port                  none                          u13 u14               
pool                  u01 u05 u08 u10               none                  
static                u01 u03 u07 u10 u12           none                  
pool + static         u01 u10                       none                  
port + pool + static  none                          u13 u14               
```

The numbers are in the **measurement** class; their inputs are PS13–PS15.

#### Seven Endpoints, Thirty-Five Claims

Fourteen endpoints accept five claims per reply: **70 claims** in total, none of them backed by
proof. With no defense in place, **seven** of these endpoints receive the reply of a source
with no proof, and all five claims of that reply are accepted too — **35 claims**. Half of the
total.

The first table gives the winner in milliseconds and shows how narrow a margin the rule works
on. The authoritative distributor's delay falls between 30 and 50 ms; the unproven source's
delay spreads between 12 and 90 ms. The winning criterion is not who the source is, only which
of these two numbers is smaller. `u02` accepts the authoritative source at 38 ms, `u12` accepts
the unproven source at 28 ms, and the only difference between the two is ten milliseconds.

Two endpoints — `u13` and `u14` — receive no reply at all from the authoritative distributor
and select the backup authoritative distributor instead. These two rows are the entry that will
determine the defense's cost in the next section: the backup distributor is legitimate, but it
sits on an untrusted port.

The table's third column also carries the one **signal** the defending side can actually see.
The seven endpoints that chose the unproven source have delays between 12 and 28 ms; the five
that did not have delays between 55 and 90 ms. There is no overlap between the two sets, and
the gap between them is 27 ms. A **two-peaked**, rather than single-peaked, distribution of
reply delays is a countable sign that two separate sources answered on the segment. The signal
does not say who the source is; it only says that replies arrived from two different places,
and this is a concrete quantity that can be searched for in assignment logs.

#### Three Defenses, Two Different Outcomes

The second table runs the three defenses separately and together. Port trust lets through only
replies arriving from the trusted port: the count of misconfigured endpoints drops from seven
to **zero**. The count of correctly configured endpoints, however, rises not to fourteen but to
**twelve**. The two endpoints in between are `u13` and `u14`, bound to the backup authoritative
distributor, and they receive no configuration at all.

Pool inspection looks at the reply's content: it rejects a reply that proposes an address
outside the authoritative pool. It rescues **three** of the seven wrong endpoints and leaves
**four**. The four it leaves are replies that propose an address from inside the pool, and
content inspection cannot tell them apart. Static assignment puts two critical endpoints on a
list and rescues those two under every condition; it leaves the remaining five. Static
assignment's coverage, just like the static record in the third lesson, is exactly as long as
the list: two entries written, two endpoints rescued. Extending the list to all fourteen
endpoints would zero out misconfiguration, but at that point the assignment mechanism itself is
no longer being used, and every new endpoint added to the network demands one manual entry.

The fifth row is this lesson's point of comparison. Pool inspection combined with static
assignment correctly configures **twelve endpoints** — the exact number port trust reaches on
its own. The same outcome is reached by two separate paths, and what each path leaves behind is
**entirely different**: one misconfigures two endpoints, the other leaves two endpoints with no
configuration at all.

#### The Cost of Two Remainders

The third table puts the cost of these two paths side by side. Port trust requires 24 manual
entries — one trust setting for every port on the segment switch — and rejects two legitimate
replies. Pool inspection combined with static assignment requires **three manual items** and
rejects no legitimate reply at all; in exchange, ten claims remain accepted without
verification.

The gap between twenty-four entries and three entries is a gap that widens as scale grows:
every new port adds one entry on the first path and adds nothing on the second. Against that,
the two endpoints the second path leaves behind grow in number as scale grows, and each one
carries five claims.

The **nature** of the two remainders differs too. An endpoint that gets no configuration does
not stay silent: it never joins the network, which is visible and gets fixed. A misconfigured
endpoint, on the other hand, works — its traffic flows, it carries out its function, and
because none of its five claims is ever tested, it leaves no record at all. **Whether a
defense's remainder is visible matters as much as how many it leaves.**

The last row turns all three on at once and zeroes out misconfiguration. Its cost is 27 manual
entries and two rejected legitimate replies; two endpoints still remain unconfigured. Static
assignment adds nothing in this combination, because port trust has already rescued the two
endpoints it would have rescued — the same layered-defense pattern from the third lesson.

The weight of the five wrongly accepted claims is not equal either. The third claim — the name
resolver — invalidates the entire previous lesson's measurement for that endpoint: all sixteen
of the sixteen names now come from a source that endpoint never tested, and even the three
records signed verification rescued change with the reply that source gives. The lease time
claim is the lightest; it only determines how long the misconfiguration stays in place before
renewal. **The five claims carried in one message count as a single number, but their costs are
not the same size**; when prioritizing, note how many downstream measurements each claim
determines.

#### Summary

- An address assignment reply carries 5 claims in a single message; 14 endpoints accept **70
  claims** in total without testing, and for one endpoint this single acceptance determines its
  address, its outbound path, and its name resolver all at once.
- The acceptance rule is "the first reply wins": 7 endpoints select a source with no proof and
  **35 claims** come from that source; the winning margin is as little as 10 ms on some
  endpoints.
- Port trust zeroes out misconfiguration but leaves 2 endpoints bound to a legitimate backup
  distributor **unconfigured**, and requires 24 manual items.
- Pool inspection combined with static assignment correctly configures the same 12 endpoints,
  requires 3 manual items, rejects no legitimate reply, and leaves 2 endpoints misconfigured.
- The nature of the two remainders differs: an unconfigured endpoint is visible because it
  never joins the network, while a misconfigured endpoint leaves no record because it keeps
  working.

#### Next Step

The four lessons so far measured a claim being accepted **wrongly**: a corrupted record, a
wrong address, a wrong source. In every one of these measurements the target service stayed up
and kept answering requests. But an unverified claim does not only produce a wrong reply; it
can also lead to **producing no reply at all**. If a connection-opening request does not prove
its source, the target has no way of knowing whom it is reserving its held space for, and once
that space runs out, legitimate requests are left out too. The next lesson measures this with
capacity arithmetic: how many requests from how many sources cross the threshold, how many
seconds it takes for the state table to fill, which defense lowers which rate, and how much of
the legitimate traffic that same defense also lowers.

### Denial of Service Attacks

Source: https://academia.sh/en/courses/network-security/denial-of-service-attacks

Measuring denial of service through capacity arithmetic: the same 4,000 req/s load producing the same result across four different source distributions, a 4,096-slot state table filling in 1.02 seconds, a per-source rate limit cutting the load to 5 on a single source while changing nothing at 4,000 sources, handshake proof ending slot exhaustion without ever lowering the request rate, and load shedding cutting legitimate traffic to 22%.

The four lessons so far measured a claim being accepted **wrongly**: a corrupted record, a wrong
address, a wrong source. In all of these measurements the target service stayed up and kept
answering requests. But an unverified claim does not only produce a wrong reply.

A connection-opening request does not prove its source. The target does not know whom it is
reserving the space it holds for the request for, and a reply to the request may never come at
all. Space is limited; when it runs out, legitimate requests are also locked out. Making a
service unable to answer requests is called **denial of service**, and spreading the same load
across a large number of sources is called **distributed denial of service**. The three
unverifiable claims counted at the network layer in the first lesson — source address, being
part of a fragmented whole, that a control message came from the network — are this lesson's
input. This lesson turns them into capacity arithmetic.

#### Two Separate Resources Run Out

**PS16:** a connection-opening request does not prove its source; the target does not know
whom it is reserving the space it holds for the request for. **PS17:** denial of service
exhausts two separate resources — request capacity per second and slots in the state table —
and the two are counted separately. **PS18:** a defense does not see the source, only the shape
and rate of the request; the same criterion applies to legitimate requests too.

PS17 is the item that frees this lesson's measurement from a single number. "The service went
down" carries no decision; there are two separate accounts that carry the decision. The first
is the rate account: how many times the incoming request rate exceeds capacity. The second is
the slot account: since every unfinished request holds a slot for a fixed time, how many
seconds the table takes to fill. The two accounts respond to different defenses, and it is
ordinary for a defense to lower one while changing the other not at all.

The calculation below is a **model**: the capacity, slot count, and legitimate flow of an
ingest endpoint in a fictional measurement network are written as data; there is no real service
or real address. The code generates no requests; it only runs the defending side's arithmetic.
Putting load on a service outside your own administration — even for measurement purposes — is
**both illegal and unprofessional without written authorization and a defined scope**.

```js
// networks/capacity.mjs — capacity model of an ingest endpoint in a fictional regional
// measurement network: there is no real service, real address, or real measurement. No
// randomness. The code only runs the defending side's arithmetic; it generates no requests.

// PS16: a connection-opening request does not prove its source; the target does not know
// whom it is reserving the space it holds for the request for.
// PS17: denial of service exhausts two separate resources: request capacity per second and
// slots in the state table. The two are counted separately.
export const CAPACITY = 900;   // requests/s
export const SLOT = 4096;      // number of slots in the state table
export const HOLD = 30;        // time an unfinished request holds a slot (s)
export const LEGIT = { sources: 60, rate: 2 };   // 60 sources, 2 requests/s per source

// PS18: a defense does not see the source, only the shape and rate of the request; the
// same criterion applies to legitimate requests too.
export const PROFILE = [
  { name: "single source", sources: 1, rate: 4000 },
  { name: "twenty sources", sources: 20, rate: 200 },
  { name: "five hundred sources", sources: 500, rate: 8 },
  { name: "four thousand sources", sources: 4000, rate: 1 },
];

export const RATE_LIMIT = 5;     // allowed requests/s per source
export const FILTER_LOAD = 0.90; // share of load weeded out by a coarse pattern
export const FILTER_LEGIT = 0.15; // share of legitimate requests caught by the same pattern

// Filters a given profile and the legit flow through the given defenses; result is in requests/s.
export function measure(p, defenses = []) {
  let load = p.sources * p.rate, legit = LEGIT.sources * LEGIT.rate;
  if (defenses.includes("rate")) {
    load = p.sources * Math.min(p.rate, RATE_LIMIT);
    legit = LEGIT.sources * Math.min(LEGIT.rate, RATE_LIMIT);
  }
  if (defenses.includes("filter")) {
    load *= 1 - FILTER_LOAD;
    legit *= 1 - FILTER_LEGIT;
  }
  // An unfinished request holds a slot; when handshake proof is on, no slot is reserved.
  const halfOpen = defenses.includes("proof") ? 0 : load;
  if (defenses.includes("shed") && load + legit > CAPACITY) {
    const ratio = CAPACITY / (load + legit);
    load *= ratio; legit *= ratio;
  }
  const occupancy = Math.min(SLOT, halfOpen * HOLD);
  return {
    load, legit, total: load + legit,
    factor: (load + legit) / CAPACITY,
    occupancy: (occupancy / SLOT) * 100,
    fillSec: halfOpen * HOLD > SLOT ? SLOT / halfOpen : null,
    legitShare: (legit / (LEGIT.sources * LEGIT.rate)) * 100,
  };
}
```

```js
// networks/capacity-audit.mjs — how many requests from how many sources cross the threshold, how many seconds
// the state table takes to fill, which defense lowers which rate, and how much legitimate traffic drops.
import { CAPACITY, SLOT, HOLD, LEGIT, PROFILE, RATE_LIMIT, FILTER_LOAD, FILTER_LEGIT, measure }
  from "./capacity.mjs";

const write = (g, ...s) => console.log(s.map((v, i) =>
  (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join(""));
const sec = (x) => (x === null ? "never" : x.toFixed(2));
const n = (x) => Math.round(x);

console.log(`capacity ${CAPACITY} req/s, slots ${SLOT}, hold ${HOLD} s, ` +
  `legit flow ${LEGIT.sources} sources x ${LEGIT.rate} = ${LEGIT.sources * LEGIT.rate} req/s; ` +
  `threshold ${n(CAPACITY - LEGIT.sources * LEGIT.rate)} req/s`);
console.log(`slot threshold ${(SLOT / HOLD).toFixed(1)} req/s: if unfinished requests exceed ` +
  `this rate the table fills; its ratio to capacity is ${((SLOT / HOLD / CAPACITY) * 100).toFixed(1)}%. ` +
  `To cross the rate threshold while staying under the ${RATE_LIMIT} req/s cap, ` +
  `${Math.ceil((CAPACITY - LEGIT.sources * LEGIT.rate) / RATE_LIMIT)} sources suffice.`);

const A = [-23, 9, 10, 9, 10, 9, 10];
console.log("\n1. same total rate, four different source distributions (no defense)");
write(A, "profile", "sources", "rate/src", "total", "capacity", "slot %", "fill sec");
for (const p of PROFILE) {
  const r = measure(p);
  write(A, p.name, p.sources, p.rate, n(r.total), r.factor.toFixed(2) + "x",
    r.occupancy.toFixed(0), sec(r.fillSec));
}

const B = [-23, 14, 10, 10, 15];
console.log(`\n2. rate limit of ${RATE_LIMIT} req/s per source`);
write(B, "profile", "load through", "capacity", "fill sec", "legit through");
for (const p of PROFILE) {
  const r = measure(p, ["rate"]);
  write(B, p.name, n(r.load), r.factor.toFixed(2) + "x", sec(r.fillSec),
    r.legitShare.toFixed(0) + "%");
}

const C = [-26, 14, 10, 8, 10, 15];
console.log("\n3. defenses on the most distributed profile (4000 sources x 1 req/s)");
write(C, "defense", "load through", "capacity", "slot %", "fill sec", "legit through");
const hard = PROFILE[3];
for (const s of [[], ["rate"], ["proof"], ["filter"], ["shed"],
  ["proof", "filter"], ["proof", "filter", "shed"]]) {
  const r = measure(hard, s);
  write(C, s.join(" + ") || "none", n(r.load), r.factor.toFixed(2) + "x",
    r.occupancy.toFixed(0), sec(r.fillSec), r.legitShare.toFixed(0) + "%");
}

const D = [-26, -53, 15];
console.log("\n4. defenses' cost to legitimate traffic");
write(D, "defense", "cost", "legit through");
const cost = [
  [["rate"], `${RATE_LIMIT} req/s cap per source`],
  [["proof"], "1 extra round trip per connection"],
  [["filter"], `${FILTER_LEGIT * 100}% of legitimate requests caught by the pattern`],
  [["shed"], "requests above capacity are rejected proportionally"],
];
for (const [s, name] of cost)
  write(D, s.join(" + "), name, measure(hard, s).legitShare.toFixed(0) + "%");
console.log(`the filter pattern weeds out ${FILTER_LOAD * 100}% of the load; the same ` +
  `pattern also weeds out ${FILTER_LEGIT * 100}% of legitimate requests`);
```

```
capacity 900 req/s, slots 4096, hold 30 s, legit flow 60 sources x 2 = 120 req/s; threshold 780 req/s
slot threshold 136.5 req/s: if unfinished requests exceed this rate the table fills; its ratio to capacity is 15.2%. To cross the rate threshold while staying under the 5 req/s cap, 156 sources suffice.

1. same total rate, four different source distributions (no defense)
profile                  sources  rate/src    total  capacity   slot %  fill sec
single source                  1      4000     4120     4.58x      100      1.02
twenty sources                20       200     4120     4.58x      100      1.02
five hundred sources         500         8     4120     4.58x      100      1.02
four thousand sources       4000         1     4120     4.58x      100      1.02

2. rate limit of 5 req/s per source
profile                  load through  capacity  fill sec  legit through
single source                       5     0.14x     never           100%
twenty sources                    100     0.24x     never           100%
five hundred sources             2500     2.91x      1.64           100%
four thousand sources            4000     4.58x      1.02           100%

3. defenses on the most distributed profile (4000 sources x 1 req/s)
defense                     load through  capacity  slot %  fill sec  legit through
none                                4000     4.58x     100      1.02           100%
rate                                4000     4.58x     100      1.02           100%
proof                               4000     4.58x       0     never           100%
filter                               400     0.56x     100     10.24            85%
shed                                 874     1.00x     100      1.02            22%
proof + filter                       400     0.56x       0     never            85%
proof + filter + shed                400     0.56x       0     never            85%

4. defenses' cost to legitimate traffic
defense                   cost                                                   legit through
rate                      5 req/s cap per source                                          100%
proof                     1 extra round trip per connection                               100%
filter                    15% of legitimate requests caught by the pattern                 85%
shed                      requests above capacity are rejected proportionally              22%
the filter pattern weeds out 90% of the load; the same pattern also weeds out 15% of legitimate requests
```

The numbers are in the **measurement** class; their inputs are PS16–PS18.

#### Four Distributions, One Result

The first table puts four profiles side by side, and all four produce the same total rate:
4,000 req/s. 4,000 from one source, 200 each from twenty sources, 8 each from five hundred
sources, 1 each from four thousand sources. With no defense, **all four rows are identical**:
4,120 req/s, 4.58 times capacity, slots 100% full, fill time 1.02 seconds.

This equality is the lesson's starting point. What the target sees is not the number of
sources but the number of requests per second; source distribution never enters the target's
accounting. The slot account comes from the same place: if 4,000 unfinished requests arrive
per second and each one holds a slot for thirty seconds, the 4,096-slot table fills in **1.02
seconds**. For the target, there is no difference at all between four thousand requests from
one source and one request each from four thousand sources.

The two thresholds in the header lines also explain why the four profiles give the same
result. The rate threshold is 780 req/s: what remains of capacity once the legitimate flow's
share is subtracted. The slot threshold is only **136.5 req/s** — **15.2% of capacity**. The
moment the unfinished-request rate exceeds this number, the table fills, while the rate
account still has room to spare. The 5.7-times gap between the two thresholds is the
quantitative counterpart of why the two accounts are counted separately: a service can lose
its state table at one-sixth of its capacity, and an observation that only watches load will
not see it happen.

The second threshold calculation also gives the defending side's preparation directly. In a
service with a 5 req/s cap per source, **156 sources** are enough to cross the rate threshold,
and none of those sources ever exceeds the cap. Lowering the cap raises the number of sources
needed, but the same move also lowers the cap on legitimate endpoints.

The difference shows up once the defense is turned on. The second table applies a 5 req/s cap
per source, and the four rows split apart completely. In the single-source profile, the load
that gets through drops from 4,000 to **5** — a 99.9% drop. At twenty sources it drops to 100.
At five hundred sources it stays at 2,500 and the table fills in 1.64 seconds. At four thousand
sources **nothing changes**: every source is already under the cap.

**The effect of a per-source rate limit is the ratio of the rate per source to the cap; once
the source count reaches the cap, the limit becomes completely ineffective.** The same
defense's effect against the same total load ranges from 99.9% to 0%, and the only thing that
determines this is the number of sources on the attacking side.

#### What Lowers the Rate and What Saves the Slot

The third table runs four defenses separately on the most distributed profile and separates
PS17's two accounts from each other.

Handshake proof — requiring the other side to show it is reachable before space is reserved —
lowers slot occupancy from 100% to **0%**, and the table never fills. The load that gets
through, though, stays at 4,000 and remains 4.58 times capacity. This defense fully solves the
slot account and never touches the rate account. 100% of legitimate traffic gets through; its
cost is one extra round trip per connection.

Upstream filtering does the opposite. It weeds out 90% of the load with a coarse pattern: the
load that gets through drops from 4,000 to **400**, and the capacity ratio drops from 4.58
times to **0.56 times**. The slot account, however, is not solved; the table still fills, just
in 10.24 seconds instead of 1.02. Its cost is legitimate requests caught by the same pattern:
**15%** of legitimate traffic is also weeded out.

When the two defenses are turned on together, both accounts are solved: load through 0.56
times, slot occupancy 0%, legit through 85%. This is the best row in the table, and it shows
the two defenses add up because they protect **different resources**. In the third lesson, two
defenses protecting the same record did not add up; here they add up because one lowers the
slot and the other lowers the rate.

#### The Rate of Load Shedding

The fourth row measures load shedding: not accepting any request above capacity at all. The
total that gets through sits exactly at capacity — 1.00 times — and this is the only "target
stays up" row in the table. Its cost is in the last column: only **22%** of legitimate traffic
gets through.

The reason is proportioning. Load shedding cannot distinguish a request's source; it only cuts
the total, so when capacity is prorated against a 4,120-request flow, every source gets the
same share. Because the legitimate flow makes up 2.9% of the total, it also gets 2.9% of
capacity. **A defense that cuts without distinguishing protects legitimate traffic only in
proportion to its share of the total; if the share is small, the protection is small too.**

The last row completes this: with handshake proof and filtering both on, load shedding
**changes nothing**, because the total that gets through is already under capacity. Load
shedding remains a last resort and only comes into play when the other two defenses are not
enough. Where the defense falls short is plain in this table: none of the four defenses makes
the unverified claim verifiable. The source address's claim is still unproven; the only thing
measured is how many requests and how many slots that unproven claim is enough to consume.

#### Summary

- Denial of service exhausts two separate resources: 900 req/s of capacity and a 4,096-slot
  state table; a 4,000 req/s load exceeds capacity by 4.58 times and fills the table in **1.02
  seconds**.
- The same total rate gives an undefended target the **same** result across four different
  source distributions; the number of sources never enters the target's accounting.
- A 5 req/s cap per source lowers the load from 4,000 to 5 at a single source and changes
  nothing at four thousand sources; its effect is the ratio of the per-source rate to the cap.
- Handshake proof lowers slot occupancy from 100% to 0% and never lowers the request rate;
  upstream filtering lowers the rate to 0.56 times, does not save the slot, and weeds out 15%
  of legitimate traffic. Together the two solve both accounts and let 85% of legitimate
  traffic through.
- Load shedding sits the total at capacity but, because it does not distinguish, protects
  legitimate traffic only in proportion to its share: **22%**. None of the four defenses makes
  the source-address claim verifiable.

#### Next Step

This lesson measured the **quantity** of an unproven claim: how many requests, how many
slots, how many seconds. When load drops or the defense is enough, the service stays up and
the measurement ends. But the same unproven claim can also be used without consuming any
capacity and without tripping any counter. If the two ends of a connection do not recognize
each other by proof, every hop between them can present itself as the other side, and the
connection continues at its ordinary rate and ordinary volume. The next lesson measures this
as a missing **chain of evidence**: how many hops a path has, at how many hops the evidence
chain breaks, how many of them endpoint verification closes off, and how many hops stay open
when the chain's own builder is itself unverified.

### On-Path Attacks

Source: https://academia.sh/en/courses/network-security/on-path-attacks

Measuring an on-path attack as a missing chain of evidence: 3 of 8 flows having no chain at all across 36 hops and 16 of those hops being able to read the content, the number of parties that could produce the identity claim being 272, endpoint verification raising that number to 509, root pinning lowering it to 11, and the single flow carrying 89,400 requests staying chainless because it cannot verify.

The previous lesson measured the **quantity** of an unproven claim: how many requests, how many
slots, how many seconds. When load drops or the defense is enough, the service stays up and the
measurement ends. But the same unproven claim can also be used without consuming any capacity and
without tripping any counter.

If the two ends of a connection do not recognize each other by proof, every hop between them can
present itself as the other side, and the connection continues at its ordinary rate and ordinary
volume. What gets measured here is not a load or a counter; **what is missing is a proof**. This
lesson counts that gap.

#### An On-Path Attack Is a Gap

**PS19:** **an on-path attack** is not a capability but a gap — the **chain of evidence** that
ties the two ends to each other's identity claim is missing. **PS20:** a chain of evidence's
strength is not its link count but the **number of parties that could produce** that claim; if
that number is one the chain is single-source, and it weakens as the number grows. **PS21:** the
chain's links can be counted; a link with no anchor is a link accepted no matter who writes it.

PS20 is the item that frees this lesson's measurement from counting links. A three-link chain is
not stronger than a one-link chain; what decides it is **how many separate parties** could have
written the last of those three links. The algorithmic side of encryption in transit and
certificate verification is not this course's subject and will be measured in M24/K04; the only
thing counted here is how many parties a claim could come from.

The flow set below is a **model**: the flows, hop counts, and chain types of a fictional measurement
network are written as data; there is no real network, real name, or real root list. The code
captures or alters no traffic; the only thing that runs is chain counting. Testing a path's hops
outside your own network **is both illegal and unprofessional without written authorization and a
defined scope**.

```js
// networks/chain.mjs — the chain of evidence for flows in a fictional regional measurement network
// (model): no real network, real name, or real root list. No randomness.

// PS19: an on-path attack is not a capability but a gap: the chain of evidence that ties
// the two ends to each other's identity claim is missing.
// PS20: a chain of evidence's strength is not its link count but the number of parties
// that could produce that claim; the smaller that number, the stronger the chain.
export const ROOT = 84;   // items in the root list endpoints accept unconditionally

// Columns: flow, hop count on the path, chain type, daily requests, can the endpoint
// verify (y/n).
export const FLOW = `
  field-terminal>collector             6  none          12400 y
  meter-endpoint>intake-endpoint       4  none          89400 n
  branch-gateway>central-gateway       5  shared-root   43200 y
  management-endpoint>branch-gateway   3  first-use       268 y
  billing>verifier                     2  shared-root   22800 y
  verifier>measurement-store           2  pinned-root   61300 y
  field-terminal>name-server           6  none          34600 y
  external-client>shared-interface     8  shared-root    1150 y`
  .trim().split(/\s+/).reduce((l, _, i, a) => (i % 5 ? l : [...l, {
    name: a[i], hops: +a[i + 1], chain: a[i + 2], requests: +a[i + 3], verifiable: a[i + 4] === "y",
  }]), []);

// PS21: the chain's links can be counted; a link with no anchor is accepted no matter
// who writes it.
// links = a verification step in the chain, unanchored = a link the endpoint cannot test.
export const CHAIN = {
  "none":         { links: 0, unanchored: 0, reads: true },
  "first-use":    { links: 1, unanchored: 1, reads: false },
  "shared-root":  { links: 3, unanchored: 1, reads: false },
  "pinned-root":  { links: 3, unanchored: 0, reads: false },
};

// The number of separate parties that could produce a flow's identity claim.
export function producers(a) {
  if (a.chain === "none") return a.hops;
  if (a.chain === "first-use") return a.hops;   // only on the first connection
  if (a.chain === "shared-root") return ROOT;
  return 1;
}

// Defense: endpoint verification moves chainless flows to a shared root, pinning moves
// shared-root flows to a pinned root. A flow whose endpoint cannot verify stays as is.
export function apply(defense = []) {
  return FLOW.map((a) => {
    let z = a.chain;
    if (defense.includes("endpoint-verification") && a.verifiable && z === "none") z = "shared-root";
    if (defense.includes("endpoint-verification") && a.verifiable && z === "first-use") z = "shared-root";
    if (defense.includes("pinning") && a.verifiable && z === "shared-root") z = "pinned-root";
    return { ...a, chain: z };
  });
}
```

```js
// networks/chain-audit.mjs — how many flows have a broken chain of evidence, how many parties
// could produce the identity claim, what endpoint verification and pinning change, and the cost.
import { FLOW, ROOT, CHAIN, producers, apply } from "./chain.mjs";

const write = (g, ...s) => console.log(s.map((v, i) =>
  (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join(""));
const readingHops = (l) => l.reduce((t, a) => t + (CHAIN[a.chain].reads ? a.hops : 0), 0);
const totalProducers = (l) => l.reduce((t, a) => t + producers(a), 0);
const SETUPS = [[], ["endpoint-verification"], ["pinning"], ["endpoint-verification", "pinning"]];

console.log(`${FLOW.length} flows, ${FLOW.reduce((t, a) => t + a.hops, 0)} hops, ` +
  `root list ${ROOT} items; flows with no chain ` +
  `${FLOW.filter((a) => a.chain === "none").length}, readable hops ${readingHops(FLOW)}, ` +
  `30-day total requests ${FLOW.reduce((t, a) => t + a.requests, 0)}`);

const A = [-35, 7, 14, 8, 12, 12, 14];
console.log("\n1. chain of evidence per flow");
write(A, "flow", "hops", "chain", "links", "unanchored", "producers", "30d requests");
for (const a of FLOW)
  write(A, a.name, a.hops, a.chain, CHAIN[a.chain].links, CHAIN[a.chain].unanchored,
    producers(a), a.requests);

const B = [-33, 10, 12, 12, 15, 14, 21];
console.log("\n2. defenses: the state of the chain");
write(B, "defense", "no chain", "unanchored", "producers", "sole producer", "reading hops",
    "no-chain requests %");
for (const s of SETUPS) {
  const l = apply(s);
  write(B, s.join(" + ") || "none", l.filter((a) => a.chain === "none").length,
    l.reduce((t, a) => t + CHAIN[a.chain].unanchored, 0), totalProducers(l),
    l.filter((a) => producers(a) === 1).length, readingHops(l),
    ((l.filter((a) => a.chain === "none").reduce((t, a) => t + a.requests, 0) /
      FLOW.reduce((t, a) => t + a.requests, 0)) * 100).toFixed(1));
}

const C = [-33, 11, 13, 8, 14, 14];
console.log("\n3. the cost of the same defenses");
write(C, "defense", "root items", "extra trips", "manual", "flows cut", "out of scope");
for (const s of SETUPS) {
  const l = apply(s), scope = FLOW.filter((a) => a.verifiable);
  const pinned = l.filter((a) => a.chain === "pinned-root" && a.verifiable).length;
  write(C, s.join(" + ") || "none", s.includes("endpoint-verification") ? ROOT : 0,
    s.includes("endpoint-verification") ? scope.length * 2 : 0, s.includes("pinning") ? pinned : 0,
    s.includes("pinning") ? pinned : 0, FLOW.length - scope.length);
}

const D = [-35, 14, 12, 14];
console.log("\n4. remaining open when both defenses are on");
write(D, "flow", "chain", "producers", "30d requests");
for (const a of apply(["endpoint-verification", "pinning"]).filter((x) => producers(x) > 1))
  write(D, a.name, a.chain, producers(a), a.requests);
```

```
8 flows, 36 hops, root list 84 items; flows with no chain 3, readable hops 16, 30-day total requests 265118

1. chain of evidence per flow
flow                                  hops         chain   links  unanchored   producers  30d requests
field-terminal>collector                 6          none       0           0           6         12400
meter-endpoint>intake-endpoint           4          none       0           0           4         89400
branch-gateway>central-gateway           5   shared-root       3           1          84         43200
management-endpoint>branch-gateway       3     first-use       1           1           3           268
billing>verifier                         2   shared-root       3           1          84         22800
verifier>measurement-store               2   pinned-root       3           0           1         61300
field-terminal>name-server               6          none       0           0           6         34600
external-client>shared-interface         8   shared-root       3           1          84          1150

2. defenses: the state of the chain
defense                            no chain  unanchored   producers  sole producer  reading hops  no-chain requests %
none                                      3           4         272              1            16                 51.4
endpoint-verification                     1           6         509              1             4                 33.7
pinning                                   3           1          23              4            16                 51.4
endpoint-verification + pinning           1           0          11              7             4                 33.7

3. the cost of the same defenses
defense                           root items  extra trips  manual     flows cut  out of scope
none                                       0            0       0             0             1
endpoint-verification                     84           14       0             0             1
pinning                                    0            0       4             4             1
endpoint-verification + pinning           84           14       7             7             1

4. remaining open when both defenses are on
flow                                        chain   producers  30d requests
meter-endpoint>intake-endpoint               none           4         89400
```

The numbers are in the **measurement** class; their inputs are PS19–PS21.

#### Thirty-Six Hops, Three Chainless Flows

Eight flows pass through thirty-six hops in total. Three flows have no chain of evidence at
all, and sixteen hops across those three flows can read the content. The remaining five flows
have a chain, but the chain's mere presence is not by itself a conclusion.

The first table shows this in the sixth column. The `verifier>measurement-store` flow's chain
has three links and no unanchored link: the number of parties that could produce the identity
claim is **one**. The `billing>verifier` flow's chain also has three links, but one of them is
unanchored, and the number of producing parties is **84**. The two flows have the same link
count, and an eighty-four-fold difference sits between them.

The source of the difference is the unanchored link. Under the shared-root arrangement, the
endpoint accepts a statement written by any item in the root list; the claim can be produced by
as many parties as there are items in that list. Under the pinned-root arrangement, the
endpoint accepts only a single item's statement. **The chain's length does not measure trust;
the list's length does.**

The number is smaller in chainless flows, and at first glance this looks backward. The
`field-terminal>collector` flow's producer count is six — the six hops on its path. That is
small next to a shared-root flow's eighty-four. The difference is qualitative: in a chainless
flow, those six hops sit **on** the path and can also read the traffic; the eighty-four items
in a shared-root arrangement do not sit on the path and cannot read the content. The numbers
cannot be compared on the same axis; both are written down anyway.

The picture sharpens further when request volume is read instead of flow count. Eight flows
carry 265,118 requests over thirty days; the three flows with no chain at all carry
**136,400** of that, or **51.4%**. The single flow whose chain reduces to one link —
`verifier>measurement-store` — carries 23.1% of the total with 61,300 requests. Chainlessness,
which is three in eight by flow count, is more than half by volume. **Chain-of-evidence
coverage is reported by the request volume those flows carry, not by counting flows.**

The fourth row's first-use trust arrangement is a separate item. In this flow, the number of
parties that could produce the claim is three **only at the first connection**; once the first
connection completes, the endpoint stores the value it saw and the number drops to one. The
entire chain hangs on a single moment, and that moment does not come again. This arrangement's
measure is not a party count but a **window count**: how many times a first connection gets
established. The window reopens on a device change, a reinstall, or a key rotation.

#### Where Endpoint Verification Changes Direction

The second table produces this lesson's most striking row. When endpoint verification is
turned on, the chainless-flow count drops from three to **one** and the reading-hops count
falls from sixteen to **four** — the expected improvement. In that same row, though, the total
producer count **rises from 272 to 509**. The unanchored-link count also climbs from four to
**six**.

Why it rises is readable from the first table. Two chainless flows carried a claim producible
by the six hops on their path; once endpoint verification is turned on, these flows move to the
shared-root arrangement and the number of parties that could produce the claim jumps from six
to eighty-four. Reading closes off entirely, but the set of parties that could produce the
identity claim grows.

**Endpoint verification takes the claim away from the hops on the path and hands it to a root
list; the gain is on the reading side, and the loss is in the growth of the trust set.** An
assessment that folds these two outcomes into a single "it is secure now" sentence never sees
the second column's direction at all.

The third row measures pinning on its own: the total producer count drops from 272 to **23**
and the single-producer flow count rises from one to four. Reading hops stay at sixteen,
because pinning does not touch chainless flows. The fourth row combines both and gives the
best result on both axes: unanchored links at **zero**, total producers at **11**,
single-producer flows at **seven**, reading hops at **four**.

#### The Remaining Flow and Its Cost

The fourth table names what remains. One of the eight flows — the flow from the meter
endpoints to the intake endpoint — cannot verify and stays chainless. This flow carries
**89,400 requests** over thirty days, the largest share of the eight flows' total volume. The
same reason that left seven unverifiable acceptance points in the second lesson applies here
too: the endpoints cannot produce proof. The defense is not enough, and the reason it is not
enough is not a configuration issue.

The third table writes the cost. Endpoint verification asks for an eighty-four-item root list
to be continuously managed and two extra round trips per flow — fourteen round trips in total
for the seven flows in scope. Pinning alone brings four manual items, and each item means
that flow gets cut when the pinned root changes: **four cut flows**; the number rises to seven
when both defenses are on together. The out-of-scope flow count is one in every row, and no
defense changes it. In volume terms, this single flow means **89,400** of the 265,118 requests
that stay chainless when all three defenses are on — 33.7% of the total; down from the initial
51.4%, but a third of it is still unproven.

The root-list item and the manual pinning item work in opposite directions. The eighty-four-item
list is managed centrally and is independent of flow count; the seven pinning entries are per
flow, and every new flow adds an entry. Pinning is cheap on a small network and brings the trust
set down to one; as the flow count grows, the entry count grows linearly and every root change
produces a cutoff. The second table's last column sums the payoff of these two costs into a
single number: whichever defense is turned on, the chainless-request share either stays at
51.4% or drops to 33.7%, because the entire difference comes from the flows endpoint
verification covers, and pinning never changes that share at all.

#### Summary

- An on-path attack is a missing chain of evidence: 3 of 8 flows have no chain at all across
  36 hops, and **16 hops** across those three flows can read the content.
- The chain's strength is not its link count but the number of parties that could produce the
  claim: of two flows with the same 3 links, one can be produced by **1** party and the other
  by **84**; a single unanchored link creates the difference.
- Endpoint verification lowers reading hops from 16 to 4, but raises the total producer count
  from 272 to **509**; it takes the claim off the hops on the path and hands it to an 84-item
  root list.
- Root pinning lowers the total producer count to 23, and both together to **11**, and zeroes
  out the unanchored link; the cost is an 84-item list, 14 extra round trips, and **7 cut
  flows** at root rotation.
- The single flow that cannot verify carries 89,400 requests over 30 days, and no defense
  covers it.

#### Next Step

This lesson asked **who** a claim came from and tied the answer to a chain. Under the
pinned-root arrangement, the number of parties that could produce the claim dropped to one;
the proof of identity is complete. Yet one property of a message produced with a complete
chain of evidence, from the correct source, with no unanchored link at all, still went
unasked: **when** it was produced. When the same message is presented once more, the proof of
identity is still valid, the chain is still complete, and the recipient accepts it exactly as
it did the first time. The next lesson measures this in terms of a freshness proof: how many
of a message set get accepted when replayed, how many of them a counter, a timestamp, and a
one-time value catch, how many still go uncaught, and what that costs in state, clock, and
storage.

### Replay and Spoofing Attacks

Source: https://academia.sh/en/courses/network-security/replay-and-spoofing-attacks

Measuring replay and spoofing in terms of freshness proof: 10 message types accepting a replay as is with no freshness proof, the counter fully protecting 75% of the volume without covering even a single state-changing type, the timestamp staying open for a 120-second window, the nonce with a separate store staying open by 2 recipients, and 36,460 requests still being repeatable without limit even with all three proofs on.

The previous lesson asked **who** a claim came from and tied the answer to a chain. Under the
pinned-root setup, the number of parties who could produce the claim dropped to one; the identity
proof is complete. Yet one property of a message that was produced through a complete proof
chain, that came from the right source, with no link left unanchored, still went unasked: **when**
it was produced.

When the same message is presented once more, the identity proof is still valid, the chain is
still complete, and the recipient accepts it in the same way as the first time. The identity proof
says who the message came from; nothing says the message is **new**. This lesson measures that
missing property and closes the topic.

#### Freshness Requires Proof

Presenting the same message again is called a **replay attack**.

**PS22:** a **freshness proof** is the recipient's ability to test that a message has not been
presented before; even when the identity proof is complete, the same message is accepted again if
there is no freshness proof. **PS23:** there are three freshness proofs and their strength is not
equal — the counter is complete, the timestamp stays open for the window, the **nonce** is
complete only if the store is shared. **PS24:** a defense can only test the field a message
carries; if the field is absent the defense never touches that message type.

PS24 sets this lesson's scope. A freshness check is not an on-off switch: if the message format
has no matching field, the check has nothing to run against. That is why the measurement has two
stages — first which message type carries which field, then what protection level that field
gives.

The message set below is a **model**: the fictional measurement network's message types, the fields
they carry, and their thirty-day volumes are written as data; there is no real network and no real
message. The code produces no message and presents nothing again; the only thing that runs is a
count of the recipient-side freshness check. Presenting a message again on a system you do not own
is **both illegal and unprofessional without written authorization and a defined scope**.

```js
// networks/freshness.mjs — the freshness proof for message types in a fictional regional
// measurement network (model): no real network, real message, or real volume. No randomness.

// PS22: a freshness proof is the recipient's ability to test that a message has not been
// shown before; even a complete identity proof lets a replay through if freshness proof is missing.
// PS23: there are three freshness proofs and their strength is not equal: the counter is
// complete; the timestamp stays open for the window; the nonce is complete only if the store is shared.
export const WINDOW = 120;          // timestamp window (s)
export const RECIPIENTS = 3;        // number of recipients that accept the same message
export const ENDPOINTS = 224;       // number of endpoints that must keep a counter or clock
export const RECORD = 24;           // size of a nonce record (bytes)
export const MONTH = 2592000;       // 30 days (s)
export const OUT_OF_ORDER = 0.003;  // share of legitimate messages arriving out of order under the counter check
export const LATE = 0.008;          // share of legitimate messages arriving late through the window

// Columns: message type, does it carry a counter, does it carry a timestamp, does it
// carry a nonce, does its replay change state (state-changing), 30-day volume.
export const MESSAGE = `
  reading-notice         y y n n 89400    configuration-command  n y y y   140
  lease-renewal          n n n y  1860    daily-log              y y n n 43200
  work-order-approval    n y n y   420    login                  n n y y   870
  bulk-transfer          y n n n  8640    status-query           n n n n 34600
  meter-reading-request  n y n n  9240    update-approval        n y y y    36`
  .trim().split(/\s+/).reduce((l, _, i, a) => (i % 6 ? l : [...l, {
    type: a[i], counter: a[i + 1] === "y", timestamp: a[i + 2] === "y",
    nonce: a[i + 3] === "y", stateChanging: a[i + 4] === "y", volume: +a[i + 5],
  }]), []);

// PS24: a defense can only test the field a message carries; if the field is absent the
// defense never touches that message type.
// Protection level: full = every replay is caught; partial = it stays open for a window
// or a recipient; none = the replay is accepted as is.
export function level(m, defense = [], shared = false) {
  if (defense.includes("counter") && m.counter) return { d: "full", open: "-" };
  if (defense.includes("nonce") && m.nonce)
    return shared ? { d: "full", open: "-" } : { d: "partial", open: `${RECIPIENTS - 1} recipients` };
  if (defense.includes("timestamp") && m.timestamp) return { d: "partial", open: `${WINDOW} s` };
  return { d: "none", open: "unlimited" };
}

export const volume = (l) => l.reduce((t, m) => t + m.volume, 0);
export function cost(defense = [], shared = false) {
  const covered = (f) => volume(MESSAGE.filter(f));
  const nonceVolume = covered((m) => defense.includes("nonce") && m.nonce);
  return {
    state: defense.includes("counter") ? ENDPOINTS : 0,
    clock: defense.includes("timestamp") ? ENDPOINTS : 0,
    storageWindow: Math.ceil(WINDOW * nonceVolume / MONTH) * RECORD * (shared ? 1 : RECIPIENTS),
    storageUnlimited: nonceVolume * RECORD * (shared ? 1 : RECIPIENTS),
    rejected: Math.round(OUT_OF_ORDER * covered((m) => defense.includes("counter") && m.counter)) +
      Math.round(LATE * covered((m) => defense.includes("timestamp") && m.timestamp && !m.counter)),
  };
}
```

```js
// networks/freshness-audit.mjs — how many of the message set are accepted when replayed,
// how many of them a counter, timestamp, and nonce catch, and what it costs when one stays open.
import { MESSAGE, WINDOW, RECIPIENTS, ENDPOINTS, RECORD, level, volume, cost } from "./freshness.mjs";

const write = (g, ...s) => console.log(s.map((v, i) =>
  (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join(""));
const count = (s, p, d) => MESSAGE.filter((m) => level(m, s, p).d === d);
// [defense set, whether the nonce store is shared across recipients]
const SETUPS = [[[], false], [["counter"], false], [["timestamp"], false], [["nonce"], false],
  [["counter", "timestamp", "nonce"], false], [["counter", "timestamp", "nonce"], true]];
const label = (s, p) => (s.join(" + ") || "none") + (p ? " (shared store)" : "");

console.log(`${MESSAGE.length} message types, 30-day volume ${volume(MESSAGE)}; ` +
  `types accepted on replay with no freshness proof ${MESSAGE.length}, ` +
  `state-changing types on replay ${MESSAGE.filter((m) => m.stateChanging).length}`);

const A = [-22, 7, 13, 12, 22];
console.log("\n1. how many types carry each field");
write(A, "field", "types", "30d volume", "volume %", "state-changing types");
for (const [k, f] of [["counter", (m) => m.counter], ["timestamp", (m) => m.timestamp],
  ["nonce", (m) => m.nonce], ["none", (m) => !m.counter && !m.timestamp && !m.nonce]]) {
  const l = MESSAGE.filter(f);
  write(A, k, l.length, volume(l), ((volume(l) / volume(MESSAGE)) * 100).toFixed(1),
    l.filter((m) => m.stateChanging).length);
}

const B = [-46, 6, 9, 6, 13, 27];
console.log("\n2. what each defense catches, by type and volume");
write(B, "defense", "full", "partial", "none", "open volume", "state-changing open types");
for (const [s, p] of SETUPS) {
  const open = count(s, p, "none");
  write(B, label(s, p), count(s, p, "full").length, count(s, p, "partial").length, open.length,
    volume(open), open.filter((m) => m.stateChanging).length);
}

const C = [-46, 8, 8, 18, 15, 16];
console.log("\n3. the cost of the same defenses");
write(C, "defense", "state", "clock", "storage (window)", "storage (30d)", "legit rejected");
for (const [s, p] of SETUPS) {
  const b = cost(s, p);
  write(C, label(s, p), b.state, b.clock, b.storageWindow + " B", b.storageUnlimited + " B", b.rejected);
}

const D = [-24, 9, 16, 12, 13];
console.log("\n4. what stays open when all three proofs are on");
write(D, "type", "level", "state-changing", "30d volume", "stays open");
for (const m of MESSAGE) {
  const z = level(m, ["counter", "timestamp", "nonce"], false);
  if (z.d !== "full") write(D, m.type, z.d, m.stateChanging ? "yes" : "no", m.volume, z.open);
}
console.log(`window ${WINDOW} s, recipients ${RECIPIENTS}, endpoints ${ENDPOINTS}, record ${RECORD} B`);
```

```
10 message types, 30-day volume 188406; types accepted on replay with no freshness proof 10, state-changing types on replay 5

1. how many types carry each field
field                   types   30d volume    volume %  state-changing types
counter                     3       141240        75.0                     0
timestamp                   6       142436        75.6                     3
nonce                       3         1046         0.6                     3
none                        2        36460        19.4                     1

2. what each defense catches, by type and volume
defense                                         full  partial  none  open volume  state-changing open types
none                                               0        0    10       188406                          5
counter                                            3        0     7        47166                          5
timestamp                                          0        6     4        45970                          2
nonce                                              0        3     7       187360                          2
counter + timestamp + nonce                        3        5     2        36460                          1
counter + timestamp + nonce (shared store)         6        2     2        36460                          1

3. the cost of the same defenses
defense                                          state   clock  storage (window)  storage (30d)  legit rejected
none                                                 0       0               0 B            0 B               0
counter                                            224       0               0 B            0 B             424
timestamp                                            0     224               0 B            0 B              79
nonce                                                0       0              72 B        75312 B               0
counter + timestamp + nonce                        224     224              72 B        75312 B             503
counter + timestamp + nonce (shared store)         224     224              24 B        25104 B             503

4. what stays open when all three proofs are on
type                        level  state-changing  30d volume   stays open
configuration-command     partial             yes         140 2 recipients
lease-renewal                none             yes        1860    unlimited
work-order-approval       partial             yes         420        120 s
login                     partial             yes         870 2 recipients
status-query                 none              no       34600    unlimited
meter-reading-request     partial              no        9240        120 s
update-approval           partial             yes          36 2 recipients
window 120 s, recipients 3, endpoints 224, record 24 B
```
The numbers are in the **measurement** class; their inputs are PS22–PS24.

#### No Field, No Check

The header line gives the baseline: **all ten** of the ten message types are accepted when
replayed with no freshness proof, and **five** of them change state on replay. The volume is
188,406 requests over thirty days.

The first table shows how the fields are distributed and sets up this lesson's inverse
relationship. The counter field is in three types and carries **75%** of the volume; **none** of
those three types change state. The nonce field is also in three types, but carries only **0.6%**
of the volume; against that, **all three** of those types change state. The timestamp is in six
types and covers 75.6% of the volume.

This distribution at the two ends is not a coincidence. The counter appears on messages that flow
continuously and whose order is already meaningful — reading notices, daily logs, bulk transfers.
The nonce appears on rare, singular transactions — configuration commands, logins, update
approvals. **The strongest freshness proof sits on the highest-volume, least consequential
messages; the most expensive proof sits on the lowest-volume, most consequential ones.**

The last row gives the gap: two types carry no field at all, hold **19.4%** of the volume, and one
of them changes state. These two types cannot be protected by any defense, because the field to
protect does not exist in the message.

#### Three Proofs, Three Different Gaps

The second table runs the defenses one at a time. The counter protects three types **completely**
and brings the open volume down from 188,406 to 47,166; the timestamp brings that same volume down
to 45,970, so on the volume measure it produces the biggest drop. But the number of state-changing
open types **stays at five**; the counter does not touch any of them.

The timestamp protects six types, but the protection level is **partial**: the window is 120
seconds, and a replay presented within that time is counted as valid. The open volume drops to
45,970 and the state-changing open types drop from five to **two**. Narrowing the window shortens
the gap and raises the number of rejected legitimate messages; setting it to zero locks out every
endpoint whose clock is not fully synchronized.

The nonce protects three types, but with separate stores the protection is again **partial**: a
value one recipient has accepted is not in the other two recipients' stores, and the same message
goes through when presented to them. The gap is **two recipients** wide. The open volume stays at
187,360, because the three types it protects are only 0.6% of the volume.

With all three on together, three types are protected completely, five partially, and **two types
stay open**. When the store is shared across recipients, the number of completely protected types
rises from three to **six**, the partial count drops to two, and the open count is still two. A
shared store covers no new type; it only raises the level of the types it already covers.

#### The Cost of What Stays Open

The fourth table gives the seven items that remain when all three proofs are on, and how open each
of them stays. Three are open by **two recipients**, two by **120 seconds**, two are **unlimited**.
One of the unlimited ones is the lease renewal, and it changes state: since it carries no field, no
check can touch it. The other is the status query, which holds the bulk of the remaining open
volume at 34,600 requests.

The third table writes the cost in three separate items. The counter requires **224 state items**
— one counter per endpoint — and rejects 424 legitimate messages arriving out of order. The
timestamp requires **224 clock-synchronization items** and rejects 79 legitimate messages arriving
late through the window. Together they mean 503 rejected legitimate messages; 2.7 per thousand of
the thirty-day volume.

The nonce's cost is storage, and the gap between the two numbers is the biggest ratio in this
table. When bounded by the timestamp window, the store is **72 bytes**; without the window — that
is, with no bound on how long a replay stays possible — the thirty-day store is **75,312 bytes**,
over a thousand times more. With a single shared store, these numbers drop to 24 and 25,104 bytes.
**The nonce alone demands unbounded storage; becoming bounded depends on the timestamp, and the two
proofs can only be used together.**

#### Summary

- Without a freshness proof, all 10 of the 10 message types accept a replay as is; 5 of them
  change state on replay, and the total volume is 188,406 requests over 30 days.
- The field distribution is inverse: the counter is on 3 types that carry 75% of the volume and
  **none** of them change state; the nonce is on 3 types that carry 0.6% of the volume and **all
  three** change state. Two types carry no field at all and hold 19.4% of the volume.
- The three proofs' gaps are different sizes: the counter protects completely, the timestamp
  stays open for **120 seconds**, and the nonce with separate stores stays open by **2
  recipients**.
- With all three on together, 3 types are protected completely, 5 partially, and 2 stay open; a
  shared store raises the completely protected count to 6 but covers no new type.
- The cost is 224 state items, 224 clock-synchronization items, 503 rejected legitimate messages,
  and a 72-byte store; without the window, that same store rises to 75,312 bytes.

#### Next Step

This topic built the network's unit of measure and wrote the same three columns across eight
lessons. It counted the eighteen claims a single reading carries and showed that none of them had
proof with no defense in place; it measured that an open port is an acceptance notice and that
twenty-one of forty-nine acceptance points had no proof; it counted how many records and how many
dependent services an unverified response corrupts in address- and name-resolution caches, how a
single acceptance in address distribution ties together five claims at once, how requests with no
proof of source address exhaust capacity and the state table, how many parties can produce a proof
chain, and in how many types a freshness proof is entirely absent.

All of these measurements shared one assumption, and it was never questioned anywhere, including in
this lesson: every claim moved through **the same flat network**. It was assumed that an unverified
response could reach the cache, that a distribution response could reach the endpoint, that a
request with no proof of source could reach the ingestion endpoint, that a replayed message could
reach the recipient. If a boundary were drawn around the network itself, how many of these claims
would fail to even cross it, how many rules would that boundary require, and how many legitimate
flows would it stop? The next topic — **Perimeter Defense** — measures the same set of claims, this
time against the network's drawn boundaries.

## Perimeter Defense

How many claims the boundary drawn around the network itself makes provable: the space firewall generations see and prove, the path segmentation closes in the zone graph plus the demilitarized zone, the proxy and jump server as checkpoint, the difference between detection and prevention over the same event set, the early signal a honeypot and a sinkhole produce, the access scope of a virtual private network, and how rule evaluation order changes flow outcomes.

### Firewall Generations

Source: https://academia.sh/en/courses/network-security/firewall-generations

The forty-eight claims carried by eight flow classes are run across three firewall generations: how many the packet-filtering box sees, how many the stateful one sees, how many the application-aware one proves, how many new items each generation must watch in return, and how much of what is proven encrypted flow takes back.

The previous topic counted the claims carried by protocols, and all of them traveled the same
flat network: a message's source address, the session it belonged to, and the subject behind it
were tested at no point along the path. The first form of drawing a boundary through the middle
of the network is the firewall. Drawing the boundary alone proves no claim; the box standing at
the boundary can decide only on the field it **sees**.

This lesson's question is not a comparison of boxes. Over the same flow set, how many claims does
each of the packet-filtering, stateful, and application-aware firewalls see, how many can it
prove, how many does it accept unverified, and how many new items must it watch in order to prove
what it sees. Rule writing, default-deny, and rule breadth were measured in another course; the
axis here is not the rule itself but whether the field the rule rests on is provable.

- **PD1.** The eight flow classes, claim types, and session counts belong to a fictional regional
  measurement network. No real packets flow; what is measured is the model built with `node`.
- **PD2.** The claim types were reduced to eight. In a real exchange the field count is far
  larger; it grows the item count, it does not change the direction of the difference between
  generations.
- **PD3.** "Proves" is used here in a narrow sense: it means the box holds a counterpart that can
  test the claim. Cryptographic proof and certificate verification lie outside this course.
- **PD4.** The state-record count was taken as the period's concurrent-session total. In reality
  the table empties with timeouts; that lowers the record count, it does not change the fact that
  the table is an item.
- **PD5.** The spoofed-claim classes were written only as a model; none was given in executable
  form. What is counted is which generation stops it.
- **PD6.** In end-to-end encrypted flow, the only application claim visible from outside was
  counted as the server name.

#### Claim and Generation Model

The model holds two things: from which generation onward each claim type is seen, and from which
generation onward it is provable. These two are not the same thing; the distinction itself is the
lesson's entire claim. The packet-filtering box sees the destination port number too, but only the
box that parses the flow can test whether the service behind that number is truly that service.

```js
// generations.mjs — the flow set and what each of three firewall generations sees/can prove; a model, not real traffic
export const GENERATION = ['packet', 'state', 'application'];
const D = (sees, proves) => ({ sees, proves });
export const CLAIM = {                        // claim: from which generation onward it is seen / provable
  'source-address': D('packet', 'packet'),     // proof: the expected address block of the direction it arrived from
  'destination-service': D('packet', 'application'), // the port number does not prove the service itself
  'flow-origin': D('packet', 'state'),          // proof: whether a record exists in the state table
  'session-membership': D('state', 'state'),
  'server-name': D('application', 'application'),
  'request-path': D('application', 'application'),
  'content-type': D('application', 'application'),
  'subject-identity': D('application', null),  // carried identity; the network layer cannot prove it
};
const sr = (n) => GENERATION.indexOf(n);
export const sees = (n, i) => sr(n) >= sr(CLAIM[i].sees);
export const proves = (n, i) => CLAIM[i].proves !== null && sr(n) >= sr(CLAIM[i].proves);
const T = Object.keys(CLAIM);
const k = (...a) => T.filter((_, i) => a[i]);
// [name, claims carried, concurrent sessions in period] — fictional regional measurement network
export const FLOW = [
  ['meter -> collector', k(1, 1, 1, 1, 0, 0, 0, 0), 41200],
  ['collector -> validator', k(1, 1, 1, 1, 1, 1, 0, 0), 8600],
  ['branch -> billing interface', k(1, 1, 1, 1, 1, 1, 0, 1), 12400],
  ['field terminal -> api', k(1, 1, 1, 1, 1, 1, 1, 1), 5100],
  ['management -> shell', k(1, 1, 1, 1, 0, 0, 0, 0), 310],
  ['outside -> public interface', k(1, 1, 1, 1, 1, 1, 1, 1), 26400],
  ['application -> package repository', k(1, 1, 1, 1, 1, 1, 0, 0), 90],
  ['backup -> offsite storage', k(1, 1, 1, 1, 1, 0, 0, 0), 12],
];
export const TOTAL = FLOW.reduce((a, [, i]) => a + i.length, 0);
export const SESSION = FLOW.reduce((a, x) => a + x[2], 0);
// End-to-end encrypted flows: the only application claim visible from outside is the server name
export const ENCRYPTED = ['branch -> billing interface', 'field terminal -> api', 'outside -> public interface',
  'application -> package repository', 'backup -> offsite storage'];
const closed = (name, i) => ENCRYPTED.includes(name) && CLAIM[i].sees === 'application' && i !== 'server-name';
export function count(n, encrypted = false) {  // for one generation: seen, proven, accepted unverified
  let see = 0, prove = 0;
  for (const [name, claims] of FLOW) for (const i of claims) {
    if (encrypted && closed(name, i)) continue;
    if (sees(n, i)) see++;
    if (proves(n, i)) prove++;
  }
  return { see, prove, unverified: TOTAL - prove, unseen: TOTAL - see };
}
export const ENCRYPTED_SESSION = FLOW.filter((a) => ENCRYPTED.includes(a[0])).reduce((a, x) => a + x[2], 0);
export const ITEM = {                          // the item each generation must watch
  packet: { 'rule line': 34, 'state record': 0, 'flow buffer': 0, 'protocol parser': 0 },
  state: { 'rule line': 34, 'state record': SESSION, 'flow buffer': 0, 'protocol parser': 0 },
  application: { 'rule line': 34, 'state record': SESSION, 'flow buffer': SESSION, 'protocol parser': 6 },
};
// Claim-forgery classes were written as a model only; none was given in executable form.
export const CLASS = [                         // [name, spoofed claim, lowest generation that proves that claim]
  ['source address from another zone', 'source-address', 'packet'],
  ['packet appearing as an established session', 'flow-origin', 'state'],
  ['different protocol on an allowed port', 'destination-service', 'application'],
  ['different server name in an allowed flow', 'server-name', 'application'],
  ['subject header carried in the request', 'subject-identity', null],
];
export const stopper = (s) => s[2] === null ? '-' : s[2];
```

Each row in the flow list holds one class, the claim set it carries, and the concurrent session
count for the period. The claim set is written as an eight-digit mask; zero means that field is
never present in that flow. The flow to the meter collector carries only four fields, because
neither a server name nor a request path exists at the application layer — it is a binary flow.
The request from the branch network to the billing interface, on the other hand, carries seven
fields, and one of them is the header stating who the subject behind the request is.

#### Proven and Accepted Unverified

```js
// run1.mjs — the claims carried by eight flow classes are counted; three generations run over the same set
import { GENERATION, FLOW, TOTAL, CLAIM, sees, proves, count } from './generations.mjs';

console.log(`${'flow class'.padEnd(36)} ${'claims'.padStart(6)}` + GENERATION.map((n) => `${n} see/prove`.padStart(23)).join(''));
for (const [name, claims] of FLOW)
  console.log(`${name.padEnd(36)} ${String(claims.length).padStart(6)}` +
    GENERATION.map((n) => `${claims.filter((i) => sees(n, i)).length}/${claims.filter((i) => proves(n, i)).length}`.padStart(23)).join(''));
console.log(`\neight flow classes carry ${TOTAL} claims\n`);
console.log(`${'generation'.padEnd(13)} ${'unseen'.padStart(9)} ${'seen'.padStart(7)} ${'proven'.padStart(10)} ${'accepted unverified'.padStart(20)}`);
for (const n of GENERATION) {
  const s = count(n);
  console.log(`${n.padEnd(13)} ${String(s.unseen).padStart(9)} ${String(s.see).padStart(7)} ${String(s.prove).padStart(10)} ${String(s.unverified).padStart(20)}`);
}
const never = Object.keys(CLAIM).filter((i) => !GENERATION.some((n) => proves(n, i)));
console.log(`\ntype no generation can prove: ${never.join(', ')} — in ${FLOW.filter(([, i]) => i.includes(never[0])).length} flow classes`);
```

```
flow class                           claims       packet see/prove        state see/prove  application see/prove
meter -> collector                        4                    3/1                    4/3                    4/4
collector -> validator                    6                    3/1                    4/3                    6/6
branch -> billing interface               7                    3/1                    4/3                    7/6
field terminal -> api                     8                    3/1                    4/3                    8/7
management -> shell                       4                    3/1                    4/3                    4/4
outside -> public interface               8                    3/1                    4/3                    8/7
application -> package repository         6                    3/1                    4/3                    6/6
backup -> offsite storage                 5                    3/1                    4/3                    5/5

eight flow classes carry 48 claims

generation       unseen    seen     proven  accepted unverified
packet               24      24          8                   40
state                16      32         24                   24
application           0      48         45                    3

type no generation can prove: subject-identity — in 3 flow classes
```

The most telling column of the first table is the packet-filtering box's: no matter what the flow
class carries, the number does not change — three are seen and one is proven in every row. A flow
carrying eight claims and a flow carrying four claims are the same thing to that box; the four
fields in between lie outside its field of view. The only claim it proves is the source address,
and it can do this only because it knows the expected address block of the direction the packet
arrived from. The remaining two fields, the destination port and the flow origin, it sees but
cannot test.

Of the forty-eight claims in total, forty are accepted unverified at the packet-filtering box. The
stateful generation brings this number down to twenty-four, and the whole of its gain comes from
one place: the session table. The flow-origin claim means "this packet already belongs to an
established connection," and a field inside the packet itself states this. The box that keeps the
table tests that field not by taking its word, but by checking its own record. Sixteen claims
still lie outside the field of view.

The application-aware generation leaves no field unseen; it proves forty-five of the forty-eight
claims. The remaining three claims are of the same type, and the row below names it. The subject
identity carried in the request appears in three flow classes, and no generation can prove it —
because no box at the network layer holds a counterpart that can test that identity. The box only
sees that the header states it there. This claim's proof lies outside the network; whom it belongs
to and what it rests on is not this course's subject.

#### Spoofed Claim, Item Watched, and Encrypted Flow

A claim not being proven does not mean that claim is false; it means it was not tested. To see
where the difference starts to matter, five classes in which the same claim types are spoofed were
placed into the model. These are not method recipes but single-line labels each: the point where
what a field states diverges from the truth.

```js
// run2.mjs — which generation stops each of five claim-forgery classes, how many items each generation must watch
import { GENERATION, CLASS, ITEM, SESSION, ENCRYPTED, ENCRYPTED_SESSION, count, stopper, proves } from './generations.mjs';

console.log(`${'spoofed claim class'.padEnd(46)} ${'claim type'.padEnd(22)}` + GENERATION.map((n) => n.padStart(13)).join(''));
for (const s of CLASS)
  console.log(`${s[0].padEnd(46)} ${s[1].padEnd(22)}` +
    GENERATION.map((n) => (proves(n, s[1]) ? 'stops' : 'passes').padStart(13)).join(''));
const stops = (n) => CLASS.filter((s) => proves(n, s[1])).length;
console.log('\n' + `${'generation'.padEnd(13)} ${'stopping class'.padStart(14)}` +
  Object.keys(ITEM.application).map((k) => k.padStart(22)).join(''));
for (const n of GENERATION)
  console.log(`${n.padEnd(13)} ${`${stops(n)}/${CLASS.length}`.padStart(14)}` +
    Object.values(ITEM[n]).map((v) => String(v).padStart(22)).join(''));
console.log(`\nstate record = concurrent sessions in period (${SESSION}); once the table fills, a new session is either refused or passed unrecorded`);
console.log(`no generation stops ${CLASS.filter((s) => stopper(s) === '-').length} class: ${CLASS.filter((s) => stopper(s) === '-').map((s) => s[0]).join(', ')}`);
const [open, encrypted] = [count('application'), count('application', true)];
console.log(`\nend-to-end encrypted flow: ${ENCRYPTED.length}/8 classes, ${ENCRYPTED_SESSION} sessions`);
console.log(`claim proven by the application-aware generation  ${open.prove} -> ${encrypted.prove}  (in encrypted flow only the server name is visible)`);
console.log(`claim accepted unverified                          ${open.unverified} -> ${encrypted.unverified}`);
console.log(`closing the gap: terminate and re-establish ${ENCRYPTED_SESSION} sessions, 1 extra round trip and 2 extra key items per session`);
```

```
spoofed claim class                            claim type                   packet        state  application
source address from another zone               source-address                stops        stops        stops
packet appearing as an established session     flow-origin                  passes        stops        stops
different protocol on an allowed port          destination-service          passes       passes        stops
different server name in an allowed flow       server-name                  passes       passes        stops
subject header carried in the request          subject-identity             passes       passes       passes

generation    stopping class             rule line          state record           flow buffer       protocol parser
packet                   1/5                    34                     0                     0                     0
state                    2/5                    34                 94112                     0                     0
application              4/5                    34                 94112                 94112                     6

state record = concurrent sessions in period (94112); once the table fills, a new session is either refused or passed unrecorded
no generation stops 1 class: subject header carried in the request

end-to-end encrypted flow: 5/8 classes, 44002 sessions
claim proven by the application-aware generation  45 -> 39  (in encrypted flow only the server name is visible)
claim accepted unverified                          3 -> 9
closing the gap: terminate and re-establish 44002 sessions, 1 extra round trip and 2 extra key items per session
```

The upper table runs the five spoofed-claim classes through the same three boxes. The rule-line
count is thirty-four in all three generations; the number of stopped classes rises from one to
four. The difference lies not in the rule written, but in the counterpart the box holds. The
second table names that counterpart: the stateful generation must keep a table of ninety-four
thousand records, and without keeping that table it cannot test the flow-origin claim. The
application-aware generation carries, in addition to the same table, a flow buffer per session and
six protocol parsers. The cost of stopping one more class is the item watched climbing from zero
to ninety-four thousand, and from there doubling.

There is one more direction to the ranking. The first of the five classes is stopped in all three
generations, and what stops it is the cheapest check of the cheapest generation: whether the
direction the packet arrived from matches the source address it carries. The item watched is zero,
because the counterpart already lies inside the routing decision. Upgrading the generation does
not strengthen this check; it only adds more behind it. The expensive generation lets the same
class through in an arrangement that forgets to run the cheap check.

The state table itself is a new item, and it can fill up. At the moment it fills, there are two
options: refuse the new session — cutting off legitimate traffic — or pass it unrecorded, meaning
the flow-origin claim for that session is once again accepted unverified. The capacity of a
state-keeping box is therefore not a performance item but a security item.

The bottom block gives the place where defense falls short. Five of the eight flow classes are
end-to-end encrypted, and in these the only application claim the box can see from outside is the
server name. The claim proven goes from forty-five to thirty-nine, and the claim accepted
unverified goes from three to nine. Application awareness works only where the flow is open. The
only way to take the loss back is to terminate the connection at the box and re-establish it
behind: one extra round trip, two extra key items, and a new point where the flow is seen in the
open, for each of the forty-four thousand sessions. The move that raises the number of claims
proven adds to the network a concentration point that was not there before.

#### Summary

- Eight flow classes carry 48 claims. The packet-filtering box sees the same 3 fields in every
  class and proves only 1 of them; in total 40 claims are accepted unverified.
- The stateful generation brings the claims accepted unverified down to 24, and the whole of its
  gain comes from the session table: instead of the flow origin the packet states itself, it
  checks the box's own record.
- The application-aware generation proves 45 of the 48 claims. The remaining 3 are of the same
  type: no generation can prove the subject identity carried in the request, because there is no
  counterpart at the network layer to test it against.
- The cost is the item watched: the rule line stays at 34 across all three generations while the
  state record climbs from 0 to 94,112, the flow buffer from 0 to 94,112, and 6 protocol parsers
  are added. Once the table fills, the option is to refuse the legitimate session or pass it
  unverified.
- 5 of the 8 flow classes are end-to-end encrypted; across these 44,002 sessions the claim proven
  falls from 45 to 39. The way to take it back is to terminate the connection: 1 extra round trip,
  2 extra key items, and a new point where the flow appears open, per session.

#### Next Step

This lesson's entire measurement assumed a single box standing at a single boundary, and every
claim that box could not prove entered the whole of the network behind it. All eight flow classes
stood on the same plane: a message reaching the meter collector could reach the billing interface
with the same move. The next lesson's question is not what the box sees, but how many pieces the
network is divided into: how many zones are reachable when one zone falls once boundaries are
drawn between zones, how many paths close, which side the demilitarized zone leaves the
outward-facing items on, and how many rule lines legitimate traffic between zones needs to keep
flowing.

### Network Segmentation and the Demilitarized Zone

Source: https://academia.sh/en/courses/network-security/network-segmentation-and-dmz

Four layouts are run over an eight-zone graph: flat network, perimeter control alone, segmentation without a demilitarized zone, and full segmentation; each one counts the open path count, the item reached at the first hop and transitively from the outside zone, the zone reached when one zone falls, and the legitimate flow missed when rules were written.

The previous lesson counted what the box standing at the boundary could see, and assumed that
every unproven claim entered the network behind it. That assumption itself is this lesson's
subject: was "the network behind it" a single thing? All eight flow classes stood on the same
flat plane; a message reaching the meter collector could reach the billing interface with the
same move. Segmentation does not change the number of claims the box proves; it changes **the
number of paths along which a claim can be presented**.

Three things are measured. How many directed pairs are open in the zone graph, how many are
reached when one zone falls, and how many rule lines legitimate cross-zone traffic needs to keep
running. Address planning and subnet arithmetic at cloud-network scale were measured in another
course; the axis here is not the address, but the zone graph itself.

- **PD7.** The eight zones, item counts, and rule list belong to a fictional regional measurement
  network. No real traffic flows; what is measured is the graph model built with `node`.
- **PD8.** No constraint within a zone was assumed: whoever reaches an item in a zone reaches every
  item of that zone. In reality intra-zone controls can also be written; that lowers the item
  count, it does not change the direction of the between-zone measurement.
- **PD9.** The outside zone was counted as a destination, not a pass-through point. Leaving an
  inner zone to the outside does not mean reaching everywhere the outside zone can reach.
- **PD10.** Rule lines were counted at the level of the directed zone pair. In reality each line
  also states a port and a protocol; that grows the line count, it does not change the direction
  of the comparison.
- **PD11.** The phrase "a zone falls" names only the situation in which an item in that zone passes
  outside of control. How it does so was neither modeled nor written.
- **PD12.** The two legitimate flows missed when rules were written were placed into the model by
  hand. In reality this number depends on the observation period and on how rarely the flow runs.

#### Zone Graph and Four Layouts

The model holds zones as nodes and the allowed directed pairs as edges. Four layouts do the same
work and differ along only two axes: whether there is a boundary between the inner zones, and
whether the public-facing items sit in a separate zone. When the public-facing items stand apart,
that zone is a demilitarized zone; when they do not, the same items sit inside the application
zone.

```js
// zone.mjs — zone graph, item counts and four layouts; a model, not a real network
const COMMON = { outside: 0, branch: 120, field: 60, data: 6, management: 5, backup: 3 };
export const ITEM = {                        // whether public-interface items sit in a separate zone or the same zone as the application
  separate: { ...COMMON, dmz: 4, application: 12 },
  inside: { ...COMMON, application: 16 },
};
// Legitimate flows with rules written: allowed directed zone pair under default-deny
export const LEGITIMATE = [
  ['outside', 'dmz'], ['dmz', 'application'], ['field', 'dmz'], ['branch', 'dmz'], ['branch', 'application'],
  ['application', 'data'], ['application', 'outside'], ['data', 'backup'], ['backup', 'outside'],
  ...['dmz', 'branch', 'field', 'application', 'data', 'backup'].map((b) => ['management', b]),
];
// Two legitimate flows missed when rules were written: quarter-end reconciliation and branch archive
export const MISSED = [['field', 'data'], ['branch', 'backup']];
const unique = (l) => [...new Set(l.map((e) => e.join('>')))].map((s) => s.split('>'));
const fold = (l) => unique(l.map(([a, b]) => [a === 'dmz' ? 'application' : a, b === 'dmz' ? 'application' : b]))
  .filter(([a, b]) => a !== b);
const flatEdge = (zl) => {                    // no constraint between inner zones, outbound direction free
  const inner = zl.filter((z) => z !== 'outside');
  return [...inner.flatMap((x) => inner.filter((y) => y !== x).map((y) => [x, y])), ...inner.map((z) => [z, 'outside'])];
};
const Z8 = ['outside', 'dmz', 'branch', 'field', 'application', 'data', 'backup', 'management'];
const Z7 = Z8.filter((z) => z !== 'dmz');
export const LAYOUT = {                       // [zones, edge, rules written, item map]
  flat: [Z7, [...flatEdge(Z7), ['outside', 'application']], 0, 'inside'],
  perimeter: [Z8, [...flatEdge(Z8), ['outside', 'dmz']], 1, 'separate'],
  nodmz: [Z7, fold(LEGITIMATE), fold(LEGITIMATE).length, 'inside'],
  segmented: [Z8, LEGITIMATE, LEGITIMATE.length, 'separate'],
};
export function reached(edge, start) {        // transitive access; outside is a destination, not a pass-through point
  const seen = new Set(), queue = [start];
  while (queue.length) {
    const z = queue.shift();
    if (z === 'outside' && start !== 'outside') continue;
    for (const [x, y] of edge) if (x === z && !seen.has(y)) { seen.add(y); queue.push(y); }
  }
  seen.delete(start);
  return seen;
}
export function distance(edge, start, target) { // how many zone boundaries are crossed to reach the target
  let layer = new Set([start]), n = 0; const seen = new Set([start]);
  while (layer.size) {
    n++;
    const next = new Set();
    for (const [x, y] of edge) if (layer.has(x) && !seen.has(y)) { seen.add(y); next.add(y); }
    if (next.has(target)) return n;
    layer = next;
  }
  return '-';
}
export const path = (zl, edge) => zl.reduce((a, z) => a + reached(edge, z).size, 0);
export const itemsOf = (set, h) => [...set].reduce((a, z) => a + (ITEM[h][z] ?? 0), 0);
export const firstHop = (edge, start) => new Set(edge.filter(([x]) => x === start).map(([, y]) => y));
export const direct = (edge, [a, b]) => edge.some(([x, y]) => x === a && y === b);
```

Two measurement functions walk over the graph. Transitive access starts from one zone and,
following edges, collects every zone reached; the distance function makes the same walk layer by
layer and gives how many boundaries are crossed to reach the target. The item counts are read
from two maps, because the four public-interface items sit either in their own zone or inside the
application zone, and the total of two hundred ten items stays the same either way. What changes
place is not the item count, but which boundary the item sits behind.

#### The Path Each Layout Opens

```js
// run1.mjs — open path, item reached from the outside zone, and cut legitimate flow in four layouts
import { LAYOUT, LEGITIMATE, MISSED, reached, distance, path, itemsOf, firstHop, direct } from './zone.mjs';

console.log(`legitimate flow with rules written ${LEGITIMATE.length}, missed when rules were written ${MISSED.length}\n`);
console.log(`${'layout'.padEnd(11)} ${'zones'.padStart(5)} ${'rules'.padStart(5)} ${'open/all paths'.padStart(15)} ` +
  `${'first-hop item'.padStart(16)} ${'transitive item'.padStart(16)} ${'boundary to data'.padStart(17)} ${'legitimate cut'.padStart(15)}`);
for (const [name, [zl, edge, rules, h]] of Object.entries(LAYOUT)) {
  const total = zl.length * (zl.length - 1);
  console.log(`${name.padEnd(11)} ${String(zl.length).padStart(5)} ${String(rules).padStart(5)} ` +
    `${`${path(zl, edge)}/${total}`.padStart(15)} ${String(itemsOf(firstHop(edge, 'outside'), h)).padStart(16)} ` +
    `${String(itemsOf(reached(edge, 'outside'), h)).padStart(16)} ${String(distance(edge, 'outside', 'data')).padStart(17)} ` +
    `${String(MISSED.filter((m) => !direct(edge, m)).length).padStart(15)}`);
}
console.log('\nzones reached from the outside zone:');
for (const [name, [, edge]] of Object.entries(LAYOUT))
  console.log(`  ${name.padEnd(11)} ${[...reached(edge, 'outside')].join(', ')}`);
const cut = MISSED.filter((m) => !direct(LAYOUT.segmented[1], m));
console.log(`\nlegitimate flow cut in the segmented layout: ${cut.map(([a, b]) => `${a} -> ${b}`).join(', ')}`);
```

```
legitimate flow with rules written 15, missed when rules were written 2

layout      zones rules  open/all paths   first-hop item  transitive item  boundary to data  legitimate cut
flat            7     0           42/42               16              210                 2               0
perimeter       8     1           56/56                4              210                 2               0
nodmz           7    12           23/42               16               25                 2               2
segmented       8    15           31/56                4               25                 3               2

zones reached from the outside zone:
  flat        application, branch, field, data, backup, management
  perimeter   dmz, branch, field, application, data, backup, management
  nodmz       application, data, backup
  segmented   dmz, application, data, backup

legitimate flow cut in the segmented layout: field -> data, branch -> backup
```

The second row counts this lesson's most expensive misconception. In the perimeter layout the
public-interface items were moved into a separate zone, a single rule allowing access from
outside only to that zone was written, and the items the outside zone reaches at the first hop
dropped from sixteen to four. Against that, the transitive item count stayed at two hundred ten
and open paths came out at a full fifty-six. Because there is no boundary between the inner
zones, the side that reaches one of the four public-interface items reaches the whole of the six
inner zones. A demilitarized zone by itself is not a zone; it is a first hop — without a boundary
behind it, it only delays the first step.

The third and fourth rows show the same segmentation in two separate layouts. Both bring the
transitive item count from two hundred ten down to twenty-five; the difference is at the first
hop. When the public-interface items are left inside the application zone, a connection from the
outside zone lands directly next to sixteen items and, because there is no boundary within the
zone, reaches all of them. Moved to a separate zone, that number drops to four and the number of
boundaries that must be crossed to reach the data zone rises from two to three. The entire gain
of the separate zone is these two numbers: twelve items and one boundary.

The rule column gives the cost. The flat layout has no rules at all; the segmented layout needs
fifteen lines, and these fifteen lines are nothing but zone pairs — once ports and protocols are
added, each line splits into several. The column on the right gives a sneakier cost: segmentation
cuts two legitimate flows. The quarter-end reconciliation ran from the field zone to the data
zone, the branch archive from the branch zone to the backup zone; neither occurred to anyone
while rules were being written, because both run rarely. On the flat network these flows had
never appeared at all, because they needed no rule.

There is a reverse-reading trap in the open-path column. The segmented layout shows thirty-one
open paths, the layout without a demilitarized zone twenty-three; going by the number alone, the
second looks narrower. The difference comes from the zone count: an eight-zone graph has fifty-six
directed pairs, a seven-zone graph forty-two. Adding a zone grows the pair count. The path count
therefore cannot be compared on its own; what can be compared is the item the path lands on, and
that column reads twenty-five in both layouts.

#### When a Zone Falls

```js
// run2.mjs — zones and items reached when a zone falls; flat and segmented layouts side by side
import { LAYOUT, reached, itemsOf } from './zone.mjs';

const [Z, FLAT] = [LAYOUT.flat[0], LAYOUT.flat[1]];
const SEG = LAYOUT.segmented[1];
const inner = (s) => [...s].filter((z) => z !== 'outside');
console.log(`${'fallen zone'.padEnd(11)} ${'flat zone'.padStart(9)} ${'flat item'.padStart(9)} ` +
  `${'seg. zone'.padStart(13)} ${'seg. item'.padStart(13)}  reached in segmented layout`);
let [td, ts] = [0, 0];
for (const z of Z.filter((x) => x !== 'outside')) {
  const [f, s] = [inner(reached(FLAT, z)), inner(reached(SEG, z))];
  td += f.length; ts += s.length;
  console.log(`${z.padEnd(11)} ${String(f.length).padStart(9)} ${String(itemsOf(f, 'inside')).padStart(9)} ` +
    `${String(s.length).padStart(13)} ${String(itemsOf(s, 'separate')).padStart(13)}  ${s.join(', ') || '-'}`);
}
console.log(`\nzone total reached   flat ${td}   segmented ${ts}   closed ${td - ts} (${Math.round((td - ts) * 100 / td)}%)`);
const d = inner(reached(SEG, 'dmz')), m = inner(reached(SEG, 'management'));
console.log(`when dmz falls        ${d.length} zones, ${itemsOf(d, 'separate')} items  (${d.join(', ')})`);
console.log(`when management falls   ${m.length} zones, ${itemsOf(m, 'separate')} items  — ${SEG.filter(([a]) => a === 'management').length} direct rules`);
console.log(`paths segmentation closes ${td - ts}; zone it cannot close 1`);
```

```
fallen zone flat zone flat item     seg. zone     seg. item  reached in segmented layout
branch              5        90             4            25  dmz, application, data, backup
field               5       150             4            25  dmz, application, data, backup
application         5       194             2             9  data, backup
data                5       204             1             3  backup
backup              5       207             0             0  -
management          5       205             6           205  dmz, branch, field, application, data, backup

zone total reached   flat 30   segmented 17   closed 13 (43%)
when dmz falls        3 zones, 21 items  (application, data, backup)
when management falls   6 zones, 205 items  — 6 direct rules
paths segmentation closes 13; zone it cannot close 1
```

In the flat column every row gives the same number: five zones. Which zone falls does not change,
the result does not change; the item difference in the second column comes only from excluding
the falling zone's own items. In the segmented column the rows separate from one another, and the
direction of that separation is the direction of the rule list. When the backup zone falls, no
zone is reached, because the only rule leaving that zone points outward. When the data zone
falls, only the backup zone is reached. Because the flow direction runs downward, the zones at
the bottom of the chain cannot climb back up.

The branch and field zones carry the largest item sets — one hundred twenty and sixty items — and
when they are the zone that falls, the items reached stay at twenty-five. In the flat layout the
same two rows gave ninety and one hundred fifty. The total of closed paths is thirteen out of
thirty; segmentation closes roughly half of the zone pairs, not all of them.

The two cut legitimate flows have a counterpart in this table. If a rule is written for the
reconciliation flow from the field zone to the data zone, the line count rises to sixteen and the
field zone reaches the data zone through a single boundary instead of three. The items reached
stay at twenty-five — the data zone was already at the end of the chain — but the path shortens;
and since the flow is legitimate, the rule gets written. Segmentation's measure is therefore not
the number taken on the day it was set up. Every new legitimate flow adds a line, every line opens
a path, and lines accumulate in one direction only — no one is warned to delete the rule once the
flow stops.

The last lines name the zone segmentation cannot close. The management zone is written directly
into six zones, and when it falls, two hundred five items are reached — the same number as on the
flat network, even in the segmented layout. The reason is not a gap but the work itself: the
management zone must reach every zone to distribute configuration and troubleshoot. Segmentation
closes no path in this zone. The twenty-one items reached when the demilitarized zone falls belong
to the same class: a public-facing zone carries an inbound rule by definition, without which the
zone serves no purpose at all. The path segmentation closes can be counted; the zone it cannot
close can also be counted, and there is one of it.

#### Summary

- Perimeter control alone drops the items the outside zone reaches at the first hop from 16 to 4,
  but the transitive item count stays at 210: with no boundary between the inner zones, the side
  that reaches a single public-facing item reaches all 6 inner zones.
- Segmentation drops the transitive item count from 210 to 25. Moving the public-facing items to a
  separate zone gains 2 numbers on top of that: the item reached at the first hop goes from 16 to
  4, and the number of boundaries to the data zone goes from 2 to 3.
- The cost is 15 rule lines and 2 cut legitimate flows. Both of the cut flows are jobs that run
  rarely (the quarter-end reconciliation, the branch archive); on the flat network they had never
  appeared, because they needed no rule.
- The zone total reached when one zone falls is 30 in the flat layout, 17 in the segmented layout:
  13 closed paths. When the branch zone falls, the items reached drop from 90 to 25.
- The zone segmentation cannot close is the management zone: it is written directly into 6 zones,
  and when it falls, 205 items are reached — the same number as in the flat layout.

#### Next Step

The management-zone row leaves one item unresolved: a zone written directly into six zones is the
one place segmentation closes no path. The next lesson's question is not removing this zone, but
what is gained by routing the connections leaving it through a single point. When a checkpoint is
placed, how many connections pass through that point, what does the number of claims that point
records and proves become, how many paths are cut at once when the point falls, and most
importantly — how many paths reach the target without ever using that point.

### Proxy Servers and the Jump Server

Source: https://academia.sh/en/courses/network-security/proxy-servers-and-jump-server

Ten connection classes run across four layouts: once a checkpoint is set up, how many of the period's connections pass through a single point, how many skip the point, how many classes remain once skip paths are closed by a rule change, what the point's own load is and how much path its failure cuts, and which claim becomes provable at the point.

The previous lesson left the management zone as an unresolved item: a single zone written directly into six zones, the one zone segmentation closed no path for. This lesson's move is not to remove that zone but to route the connections leaving it through a single point. The same move also has a reverse direction: connections going from inside to outside can also be gathered at a single point.

The two do different things. The point gathering what goes from inside to outside is the forward proxy; the point gathering what comes in from outside or from the management zone is the jump server. What they share is measurable: both split a connection in two, leave a record in the middle, and become a new item themselves. The reverse proxy's configuration and the information it loses when forwarding to the application were measured in another course; the axis here rests on a single question — how many connections actually pass through that point.

- PD13. The ten connection classes and period counts belong to a fictional regional measurement network. No real connection is established; what is measured is the model built with `node`.
- PD14. The skip reasons were written by hand, and two of them were marked as unclosable by a rule change. In reality which skip can be closed depends on the physical layout of the network.
- PD15. The point's load was counted as twice the connections passing through it: a point carries two connections, a front leg and a back leg. Concurrency and duration were not modeled.
- PD16. The claim the point proves was taken from the previous lessons' claim list. What the proof rests on is outside this course.
- PD17. The number of zones the jump server reaches was read from the previous lesson's management-zone rule count.

#### Connection Classes and Layouts

The model holds four things for each connection class: its source, the period's connection count, which checkpoint it naturally belongs to, and, if any, the reason it skips the point. A non-empty skip reason means that class reaches its target by another path even if the point is set up.

```js
// checkpoint.mjs — connection classes and checkpoint layouts; a model, not a real connection
// [name, source, target, period connections, natural checkpoint, skip reason ('' if it does not skip)]
export const CONNECTION = [
  ['branch -> outside interface', 'branch', 'outside', 184000, 'proxy', ''],
  ['application -> package repository', 'application', 'outside', 90, 'proxy', ''],
  ['application -> outside measurement service', 'application', 'outside', 26400, 'proxy', ''],
  ['field terminal -> outside map', 'field', 'outside', 5100, 'proxy', 'edges exit over a cellular connection'],
  ['backup -> outside storage', 'backup', 'outside', 12, 'proxy', 'written directly for transfer volume'],
  ['every machine -> time source', 'all', 'outside', 43200, 'proxy', 'the proxy does not carry this protocol'],
  ['management -> machine shell', 'management', 'inside', 372, 'jump', ''],
  ['management -> management interface', 'management', 'inside', 860, 'jump', ''],
  ['deployment automation -> shell', 'management', 'inside', 1860, 'jump', 'automation connects directly with its own key'],
  ['branch -> shell (emergency path)', 'branch', 'inside', 26, 'jump', 'the emergency rule was never removed'],
];
export const LAYOUT = {                    // which checkpoint is set up and whether skip paths are closed
  none: { point: [], closed: false },
  jump: { point: ['jump'], closed: false },
  both: { point: ['proxy', 'jump'], closed: false },
  'both+closed': { point: ['proxy', 'jump'], closed: true },
};
// Two skips that cannot be closed: the cellular exit and the protocol that cannot be carried do not close with a network rule
const UNCLOSABLE = ['edges exit over a cellular connection', 'the proxy does not carry this protocol'];
export const passes = (b, y) => y.point.includes(b[4]) &&
  (b[5] === '' || (y.closed && !UNCLOSABLE.includes(b[5])));
export const skips = (b, y) => y.point.includes(b[4]) && !passes(b, y);
export const TOTAL = CONNECTION.reduce((a, b) => a + b[3], 0);
export const sum = (l) => l.reduce((a, b) => a + b[3], 0);
// The checkpoint records the connection and authenticates the subject itself
export const PROVEN = ['subject-identity', 'request-path'];
export const POINT_ACCESS = { proxy: 1, jump: 6 };   // number of zones the point can reach directly
```

The four layouts separate along two axes: which points are set up, and whether skip paths were closed by a rule change. Closing does not resolve every skip; two reasons were marked as unclosable. Field endpoints exit over a cellular connection without ever entering the organization's network, so there is no point on the network a written rule could reach them through. The time-source query, in turn, is not a protocol the proxy can carry; if a rule were written, the connection would not form and the job would stop. These two reasons are not a gap, they are a measurement input.

#### How Many Connections Actually Pass

```js
// run1.mjs — passing, skipping and never-monitored connections across four layouts
import { CONNECTION, LAYOUT, TOTAL, sum, passes, skips } from './checkpoint.mjs';

console.log(`ten connection classes, ${TOTAL} connections in the period\n`);
const pct = (n) => `${(n * 100 / TOTAL).toFixed(1)}%`;
console.log(`${'layout'.padEnd(13)} ${'passing class'.padStart(14)} ${'passing connections'.padStart(20)} ${'share'.padStart(6)} ` +
  `${'skipping class'.padStart(14)} ${'skipping connections'.padStart(21)} ${'unmonitored'.padStart(11)}`);
for (const [name, y] of Object.entries(LAYOUT)) {
  const g = CONNECTION.filter((b) => passes(b, y)), a = CONNECTION.filter((b) => skips(b, y));
  const u = CONNECTION.filter((b) => !passes(b, y));
  console.log(`${name.padEnd(13)} ${String(g.length).padStart(14)} ${String(sum(g)).padStart(20)} ${pct(sum(g)).padStart(6)} ` +
    `${String(a.length).padStart(14)} ${String(sum(a)).padStart(21)} ${String(sum(u)).padStart(11)}`);
}
const last = LAYOUT['both+closed'];
console.log('\nclasses still skipping with both points set up and skip paths closed:');
for (const b of CONNECTION.filter((x) => skips(x, last)))
  console.log(`  ${b[0].padEnd(30)} ${String(b[3]).padStart(6)}  ${b[5]}`);
console.log('\nclasses closing resolves with a rule change:');
for (const b of CONNECTION.filter((x) => skips(x, LAYOUT.both) && passes(x, last)))
  console.log(`  ${b[0].padEnd(30)} ${String(b[3]).padStart(6)}  ${b[5]}`);
```

```
ten connection classes, 261920 connections in the period

layout         passing class  passing connections  share skipping class  skipping connections unmonitored
none                       0                    0   0.0%              0                     0      261920
jump                       2                 1232   0.5%              2                  1886      260688
both                       5               211722  80.8%              5                 50198       50198
both+closed                8               213620  81.6%              2                 48300       48300

classes still skipping with both points set up and skip paths closed:
  field terminal -> outside map    5100  edges exit over a cellular connection
  every machine -> time source    43200  the proxy does not carry this protocol

classes closing resolves with a rule change:
  backup -> outside storage          12  written directly for transfer volume
  deployment automation -> shell   1860  automation connects directly with its own key
  branch -> shell (emergency path)     26  the emergency rule was never removed
```

The first row is the starting point: with no checkpoint set up, all two hundred sixty-one thousand nine hundred twenty of the period's connections are unmonitored. This row shows zero skipping classes, because there is nothing to skip — on a network with no checkpoint, every path is a direct path. Each of the next three rows shows how much of that number it takes back.

The second row is the measure of the jump server set up alone, and it is surprising: it passes five thousandths of the period. Looking at class count, two of four pass; looking at volume, one thousand two hundred thirty-two connections. One of the two skipping classes is deployment automation, and it alone carries one thousand eight hundred sixty connections — more than every human session passing through the jump server combined. When the jump server is set up, no one is lying — the management team really does pass through it. What does not pass is the automation, and the automation does most of the work.

The third row adds the forward proxy and the share jumps to eighty-one percent. The jump here comes from one class alone carrying seventy percent of the period on its own: the branch network's outbound connection. Eighty-one percent sounds high; the remaining nineteen percent means fifty thousand connections, and none of them is recorded anywhere.

This row has another reading as well. By class count, five of ten pass and five skip: half and half. By volume, eighty-one percent passes. The two ratios come from the same set and do not contradict each other; which unit the measurement is taken in decides the result. Monitoring coverage looks low when reported by class count and high when reported by volume. Neither is enough on its own for a decision: if one of the classes left out of coverage connects rarely but reaches the management shell, its small share of volume does not make it unimportant.

The fourth row closes skip paths with a rule change and the share climbs to eighty-one and a half percent. The payoff for all that effort is 0.8 points. The two lists below separate the reason. The three closed classes carry a volume of one thousand nine hundred connections, and two of them are one-line fixes — an emergency rule opened months ago, a backup flow written directly for volume reasons. The two classes that cannot be closed carry forty-eight thousand three hundred connections, and neither is a rule problem: field endpoints never pass over the network at all, and the proxy cannot carry the time-source query. The checkpoint's coverage is bounded not by the point itself but by the network's physical layout and by the protocol's portability.

The skipping classes share one more trait: none of them is a misconfiguration. All five were born with a reason, and often with a written approval — transfer volume, emergency access needs, automation's own credential, where the endpoints sit, a protocol that cannot be carried. The checkpoint's coverage is therefore not set by the setup decision; it is set by the exceptions that accumulate after setup, and because each exception has its own justification, none of them looks excessive taken one at a time. The only thing that can be measured is the total.

#### The Point's Own Load

```js
// run2.mjs — the point's own load, the connections its failure cuts, and the claim it makes provable
import { CONNECTION, LAYOUT, TOTAL, sum, passes, skips, PROVEN, POINT_ACCESS } from './checkpoint.mjs';

const last = LAYOUT['both+closed'];
console.log(`${'point'.padEnd(8)} ${'passing class'.padStart(14)} ${'passing connections'.padStart(20)} ${'point load'.padStart(11)} ` +
  `${'cut on drop'.padStart(15)} ${'zones reached'.padStart(14)}  claim proven`);
for (const n of ['proxy', 'jump']) {
  const g = CONNECTION.filter((b) => b[4] === n && passes(b, last));
  console.log(`${n.padEnd(8)} ${String(g.length).padStart(14)} ${String(sum(g)).padStart(20)} ${String(sum(g) * 2).padStart(11)} ` +
    `${`${g.length} class`.padStart(15)} ${String(POINT_ACCESS[n]).padStart(14)}  ${PROVEN[n === 'proxy' ? 1 : 0]}`);
}
const recorded = CONNECTION.filter((b) => passes(b, last)), unrec = CONNECTION.filter((b) => !passes(b, last));
console.log(`\n${'measure'.padEnd(41)} ${'no point'.padStart(12)} ${'both points set up'.padStart(20)}`);
for (const [name, before, after] of [
  ['connections recorded', 0, sum(recorded)],
  ['connections unrecorded', TOTAL, sum(unrec)],
  ['connections with subject identity proven', 0, sum(recorded.filter((b) => b[4] === 'jump'))],
  ["management's directly reached zones", 6, 1],
  ['connections cut by a single failure', 0, Math.max(...['proxy', 'jump'].map((n) => sum(CONNECTION.filter((b) => b[4] === n && passes(b, last)))))],
]) console.log(`${name.padEnd(41)} ${String(before).padStart(12)} ${String(after).padStart(20)}`);
console.log(`\n${sum(CONNECTION.filter((b) => skips(b, last)))} skipping connections never enter the record; ` +
  `that is ${(sum(unrec) * 100 / TOTAL).toFixed(1)}% of the period`);
```

```
point     passing class  passing connections  point load     cut on drop  zones reached  claim proven
proxy                 4               210502      421004         4 class              1  request-path
jump                  4                 3118        6236         4 class              6  subject-identity

measure                                       no point   both points set up
connections recorded                                 0               213620
connections unrecorded                          261920                48300
connections with subject identity proven             0                 3118
management's directly reached zones                  6                    1
connections cut by a single failure                  0               210502

48300 skipping connections never enter the record; that is 18.4% of the period
```

The upper table puts the two points side by side, and their measures are large in opposite directions. The proxy carries two hundred ten thousand connections and sits under a load of four hundred twenty-one thousand, yet it reaches one zone: the outside zone. The jump server carries three thousand connections, its load is six thousand two hundred thirty-six, and it reaches six zones. Load and value do not sit in the same place. The previous lesson's management-zone row has now come down to a single machine; when that machine fails, four classes are cut, and if it is compromised, six zones are reached. Management's directly reached zones went from six to one — that is the gain, and the same move lined up two hundred five items behind a single machine.

The claim-proven column closes out the previous two lessons. No firewall generation could prove subject identity; the jump server does, because it opens the session itself and authenticates the subject on its own. In return, the proof's scope is three thousand one hundred eighteen connections. Across the rest of the period, subject identity remains unproven.

The bottom table's last two rows give the cost. Before a checkpoint was set up, the number of unrecorded connections was two hundred sixty-one thousand and the connections a single failure would cut was zero; afterward, unrecorded fell to forty-eight thousand and the connections a single failure would cut rose to two hundred ten thousand. The two numbers must be read together: a checkpoint is a visibility gain and an availability debt taken on in the same move. Setting up a second point splits the debt, but it also splits the proof; now there are two separate records and two separate authorized sets on two separate machines.

The point itself is also a new surface, and in two separate ways. First, it becomes an item: the jump server is a machine that reaches six zones, and it carries that access as a rule; the previous lesson's management-zone row is now a single machine's authorized key list. Second, the connection is split in two. The four hundred twenty-one thousand figure in the point-load column is not just a performance line item: for every connection there is now a place in the middle that sees both ends open. Terminating the encrypted flow at this point is the condition for the gain measured in the first lesson, and the same move creates the one place where the flow appears in the open.

That forty-eight thousand connections never enter the record at all is this lesson's open item. These do not arise from misconfiguration but from never passing over the network at all. The question to ask when measuring a checkpoint should not be "is it set up," but "how much of the period actually passed through it" — the answer to the first question is the same in both layouts.

This lesson's items and the previous one's, read together, produce an ordering. Segmentation closes a path, and the path it closes can be counted; a checkpoint does not close a path — it gathers it at a single point, and produces a record at the point it gathers. Neither replaces the other. The management zone's rule written into six zones was not closed by segmentation; the jump server does not close that rule — it lines it up behind a single machine. The number of closed paths did not change, the number of provable claims went up, and a new single point of failure was born. Unless the measurement writes all three columns at once, whether this move is good or bad cannot be said.

#### Summary

- The jump server alone passes only 0.5% of the period: 2 of 4 classes pass, and the skipping deployment automation alone carries 1,860 connections — more than every human session combined.
- Adding the forward proxy brings the share to 80.8%; the remaining 19.2% is fifty thousand connections, and none of it enters the record.
- Closing skip paths with a rule change only moves the share to 81.6%: the 3 closed classes carry 1,898 connections, the 2 unclosable classes carry 48,300. The unclosable ones are not a rule problem — field endpoints never pass over the network, and the proxy cannot carry the time-source query.
- Load and value do not sit in the same place: the proxy carries 210,502 connections and reaches 1 zone, the jump server carries 3,118 connections and reaches 6 zones.
- The point makes subject identity provable, but only for 3,118 connections. The cost is two numbers: management's directly reached zones fall from 6 to 1, while the connections a single failure would cut rise from 0 to 210,502.

#### Next Step

This lesson's entire gain was gathered into a single record: two hundred thirteen thousand connections are now written somewhere. Being written is not the same as being seen. Keeping a record does not say which class a connection inside that record belongs to; among the period's two hundred sixty thousand lines, which ones are worth looking at has still not been asked. The next lesson's question draws three numbers out of the same set of events: how many events are caught, how many of the caught ones actually have no counterpart, and once the move goes from detection to prevention — that is, once connections start being cut instead of just recorded — how many legitimate connections get cut.

### Intrusion Detection and Prevention Systems

Source: https://academia.sh/en/courses/network-security/intrusion-detection-and-prevention

Fourteen event classes and seven signatures run over the same period: the difference in what network-based and host-based sensors see, caught and missed events, how many alerts are false positives, the legitimate work cut once switching to prevention mode, and the detection that closing a noisy signature loses in the same move.

The previous lesson wrote two hundred thirteen thousand connections into a record. Keeping a record is not the same as seeing: nothing among the period's records says which one is worth looking at. This lesson's subject is the layer that takes on that judgment — and that layer itself does something countable, because every "worth looking at" decision produces one correct call and one wrong one.

Three numbers are measured: how many of the period's bad events are caught, how many legitimate events are wrongly flagged alongside the caught ones, and how much legitimate work stops when detection switches to prevention — that is, when connections start being cut instead of merely recorded. A fourth is added to these: whether the sensor sits on the network or on the host, because the two see different event sets over the same period. No attack step is written in this lesson; the event classes are the **indicators** the defense sees.

- **PD18.** The fourteen event classes, the period counts, and the seven signatures belong to the fictional regional measurement network. No real event is produced; what is measured is the model built with `node`.
- **PD19.** The true class of each event was marked by hand. In reality this class is known only after investigation; the model assumes it is known so that false positives can be counted.
- **PD20.** An event class matches either a single signature or none. In reality an event can touch more than one signature; that enlarges the alert count without changing the direction of the ratios.
- **PD21.** Visibility was modeled with three values: from the network only, from the host only, or from both. An encrypted flow was treated as open on the host side and closed on the network side.
- **PD22.** Threshold, frequency window, and a learning phase were not modeled. A signature either alerts or it does not.
- **PD23.** In prevention mode, every event that alerts is assumed to be stopped; there is no latency or partial block.

#### The Event Set and Two Sensor Types

The model holds five things for each event class: its name, its true class, which locations can see it, its count for the period, and the signature it matches. A sensor's alerting depends on two conditions — being able to see the event, and a signature matching. Because the two can be counted separately, missed events can be split into two causes.

```js
// event.mjs — period event set, signature list, and two sensor types; a model, not a real event
// [name, true class, visibility, period count, matching signature]
export const EVENT = [
  ['heavy attempts against the management gate', 'bad', 'both', 12000, 'heavy-attempt'],
  ['outbound to an unknown server name', 'bad', 'network', 40, 'unknown-name'],
  ['new privileged process on host', 'bad', 'host', 6, 'new-process'],
  ['small outbound at regular intervals', 'bad', 'both', 2880, null],
  ['local file permission change', 'bad', 'host', 14, 'permission-change'],
  ['known payload inside encrypted flow', 'bad', 'host', 3, 'known-payload'],
  ['scan-like sequence in the inside zone', 'bad', 'network', 220, 'scan-sequence'],
  ['quarter-end batch query', 'legit', 'both', 12, 'scan-sequence'],
  ['backup transfer', 'legit', 'both', 12, 'large-volume'],
  ['patch rollout', 'legit', 'both', 90, 'new-process'],
  ['field-endpoint reconnection wave', 'legit', 'network', 5100, 'heavy-attempt'],
  ['metric pull', 'legit', 'both', 259200, null],
  ['branch interface requests', 'legit', 'network', 184000, null],
  ['time-source query', 'legit', 'network', 43200, null],
];
export const SENSOR = { network: ['network', 'both'], host: ['host', 'both'], both: ['network', 'host', 'both'] };
export const SIGNATURE = [...new Set(EVENT.map((o) => o[4]).filter(Boolean))];
export const sees = (s, o) => SENSOR[s].includes(o[2]);
export const alerts = (s, o, closed = []) => sees(s, o) && o[4] !== null && !closed.includes(o[4]);
export const sum = (l) => l.reduce((a, o) => a + o[3], 0);
export function count(s, closed = []) {
  const u = (f) => EVENT.filter((o) => f(o));
  const caught = u((o) => o[1] === 'bad' && alerts(s, o, closed));
  const missed = u((o) => o[1] === 'bad' && !alerts(s, o, closed));
  const falsePositive = u((o) => o[1] === 'legit' && alerts(s, o, closed));
  return { caught, missed, falsePositive, alert: sum(caught) + sum(falsePositive) };
}
```

The signature set has seven entries and none belongs to a product; each is the name of an indicator. The period's total event count exceeds five hundred six thousand, the large majority of which comes from three classes, and those three classes have no signature at all. Measurement therefore starts with class, not volume: which class is visible from which location, and which class a signature touches.

#### Network-Based versus Host-Based

```js
// run1.mjs — classes seen by network-based and host-based sensors, caught and missed events
import { EVENT, SENSOR, SIGNATURE, sees, alerts, sum, count } from './event.mjs';

const BAD = EVENT.filter((o) => o[1] === 'bad');
console.log(`${EVENT.length} event classes, ${BAD.length} bad / ${EVENT.length - BAD.length} legitimate; ` +
  `${sum(EVENT)} events in the period, ${SIGNATURE.length} signatures\n`);
console.log(`${'event class'.padEnd(43)} ${'class'.padStart(6)} ${'count'.padStart(7)} ${'signature'.padStart(18)} ` +
  `${'network'.padStart(8)} ${'host'.padStart(8)}`);
for (const o of EVENT)
  console.log(`${o[0].padEnd(43)} ${o[1].padStart(6)} ${String(o[3]).padStart(7)} ${String(o[4] ?? '-').padStart(18)} ` +
    `${(sees('network', o) ? (alerts('network', o) ? 'alert' : 'sees') : '-').padStart(8)} ` +
    `${(sees('host', o) ? (alerts('host', o) ? 'alert' : 'sees') : '-').padStart(8)}`);
console.log(`\n${'sensor'.padEnd(11)} ${'seen classes'.padStart(13)} ${'caught bad'.padStart(14)} ` +
  `${'missed bad'.padStart(10)} ${'false-positive classes'.padStart(23)} ${'period alerts'.padStart(14)}`);
for (const s of Object.keys(SENSOR)) {
  const r = count(s), seen = EVENT.filter((o) => sees(s, o)).length;
  console.log(`${s.padEnd(11)} ${String(seen).padStart(13)} ${`${r.caught.length}/${BAD.length}`.padStart(14)} ` +
    `${String(r.missed.length).padStart(10)} ${String(r.falsePositive.length).padStart(23)} ${String(r.alert).padStart(14)}`);
}
const both = count('both');
console.log(`\nbad classes missed with both sensors together:`);
for (const o of both.missed) console.log(`  ${o[0].padEnd(43)} ${String(o[3]).padStart(6)}  ${o[4] === null ? 'no signature' : 'not seen'}`);
```

```
14 event classes, 7 bad / 7 legitimate; 506777 events in the period, 7 signatures

event class                                  class   count          signature  network     host
heavy attempts against the management gate     bad   12000      heavy-attempt    alert    alert
outbound to an unknown server name             bad      40       unknown-name    alert        -
new privileged process on host                 bad       6        new-process        -    alert
small outbound at regular intervals            bad    2880                  -     sees     sees
local file permission change                   bad      14  permission-change        -    alert
known payload inside encrypted flow            bad       3      known-payload        -    alert
scan-like sequence in the inside zone          bad     220      scan-sequence    alert        -
quarter-end batch query                      legit      12      scan-sequence    alert    alert
backup transfer                              legit      12       large-volume    alert    alert
patch rollout                                legit      90        new-process    alert    alert
field-endpoint reconnection wave             legit    5100      heavy-attempt    alert        -
metric pull                                  legit  259200                  -     sees     sees
branch interface requests                    legit  184000                  -     sees        -
time-source query                            legit   43200                  -     sees        -

sensor       seen classes     caught bad missed bad  false-positive classes  period alerts
network                11            3/7          4                       4          17474
host                    9            4/7          3                       3          12137
both                   14            6/7          1                       4          17497

bad classes missed with both sensors together:
  small outbound at regular intervals           2880  no signature
```

The two sensor types see different sets over the same period, and their intersection is not complete. The network-based sensor sees eleven classes, the host-based sensor nine; their union is fourteen, meaning every class is visible from at least one. Where they diverge is readable row by row. A privileged process opened on the host and a local file permission change are never visible from the network, because these events produce no packet. The known payload inside an encrypted flow is likewise invisible from the network — the loss measured in the first lesson shows up here on the detection side. The scan-like connection sequence in the inside zone, by contrast, is invisible from the host: each machine sees only the single connection that falls to it, and seeing the sequence requires looking at every machine's connections at the same time.

The caught column is the sum of this difference. The network-based sensor catches three of the seven bad classes, the host-based sensor four; together they catch six. A single class escapes both, and the reason is not visibility: both sensors **see** the small outbound at regular intervals, but no signature matches. This event breaks no rule; what is anomalous is its regularity, and regularity is caught by a baseline, not a signature.

The false-positive column must be read in the opposite direction. The network-based sensor produces false alerts in four classes, the host-based sensor in three; running both together keeps the false-positive class count at four but raises the alert count from seventeen thousand four hundred seventy-four to seventeen thousand four hundred ninety-seven. When two sensors flag the same event twice, the caught class count does not grow — the queue does. The gain from setting up both — the caught class count rising from three or four to six — is not free: two separate rule sets, two separate queues, and an item to be deployed on every machine on the host side.

#### Detection, Prevention, and Closing a Signature

```js
// run2.mjs — the same set first in detection then in prevention mode; both sides of closing a signature
import { EVENT, SIGNATURE, alerts, sum, count } from './event.mjs';

console.log(`${'signature'.padEnd(18)} ${'alerted bad'.padStart(13)} ${'alerted legit'.padStart(14)} ${'noise share'.padStart(12)}`);
for (const i of SIGNATURE) {
  const e = EVENT.filter((o) => o[4] === i && alerts('both', o));
  const [k, m] = [sum(e.filter((o) => o[1] === 'bad')), sum(e.filter((o) => o[1] === 'legit'))];
  console.log(`${i.padEnd(18)} ${String(k).padStart(13)} ${String(m).padStart(14)} ${`${(m * 100 / (k + m)).toFixed(1)}%`.padStart(12)}`);
}
console.log(`\n${'signature set'.padEnd(26)} ${'alerts'.padStart(6)} ${'noise'.padStart(7)} ${'caught bad'.padStart(14)} ` +
  `${'missed bad'.padStart(10)} ${'legit cut in prevention'.padStart(24)}`);
for (const [name, closed] of [['all open', []], ['large-volume closed', ['large-volume']],
  ['+ heavy-attempt closed', ['large-volume', 'heavy-attempt']]]) {
  const r = count('both', closed);
  console.log(`${name.padEnd(26)} ${String(r.alert).padStart(6)} ${`${(sum(r.falsePositive) * 100 / r.alert).toFixed(1)}%`.padStart(7)} ` +
    `${String(sum(r.caught)).padStart(14)} ${String(sum(r.missed)).padStart(10)} ${String(sum(r.falsePositive)).padStart(24)}`);
}
const r = count('both');
console.log('\nlegitimate work cut once switched to prevention mode:');
for (const o of r.falsePositive) console.log(`  ${o[0].padEnd(34)} ${String(o[3]).padStart(5)}  signature: ${o[4]}`);
console.log(`\n${'measure'.padEnd(30)} ${'detection mode'.padStart(15)} ${'prevention mode'.padStart(16)}`);
for (const [name, t, o] of [['bad events stopped', 0, sum(r.caught)], ['legitimate events cut', 0, sum(r.falsePositive)],
  ['alerts needing manual review', r.alert, r.alert], ['bad events missed', sum(r.missed), sum(r.missed)]])
  console.log(`${name.padEnd(30)} ${String(t).padStart(15)} ${String(o).padStart(16)}`);
```

```
signature            alerted bad  alerted legit  noise share
heavy-attempt              12000           5100        29.8%
unknown-name                  40              0         0.0%
new-process                    6             90        93.8%
permission-change             14              0         0.0%
known-payload                  3              0         0.0%
scan-sequence                220             12         5.2%
large-volume                   0             12       100.0%

signature set              alerts   noise     caught bad missed bad  legit cut in prevention
all open                    17497   29.8%          12283       2880                     5214
large-volume closed         17485   29.8%          12283       2880                     5202
+ heavy-attempt closed        385   26.5%            283      14880                      102

legitimate work cut once switched to prevention mode:
  quarter-end batch query               12  signature: scan-sequence
  backup transfer                       12  signature: large-volume
  patch rollout                         90  signature: new-process
  field-endpoint reconnection wave    5100  signature: heavy-attempt

measure                         detection mode  prevention mode
bad events stopped                           0            12283
legitimate events cut                        0             5214
alerts needing manual review             17497            17497
bad events missed                         2880             2880
```

The upper table weighs each signature individually, and there are two extremes. The large-volume transfer signature caught no bad event in the period and flagged twelve legitimate backup transfers: a noise share of one hundred percent. The known-payload and permission-change signatures produced no false positive at all. The difference lies in how narrowly the signature is written; a signature that measures volume looks at the same thing the work itself produces, while a file permission changing in one specific way never occurs anywhere in that work.

The lower table runs the outcome of that weighing. Closing the signature whose noise share is one hundred percent drops twelve alerts and costs no detection — this is a gain with no cost, and it is rare. The second step shows the real trade-off. The heavy-attempt signature produces almost all of the period's false positives on its own — five thousand one hundred of the five thousand two hundred fourteen; closing it brings the alert count down from seventeen thousand to three hundred eighty-five. The same move drops caught bad events from twelve thousand to two hundred eighty-three and raises missed bad events from two thousand eight hundred eighty to fourteen thousand eight hundred eighty. The noise share, meanwhile, falls from thirty percent to twenty-six percent: the **ratio** of noise barely changes, because both the numerator and the denominator came from the same signature. A system that decides by noise ratio would mistake this move for an improvement.

The final table gives the only real difference between detection and prevention. The two modes produce the same alerts and miss the same events; where they diverge is the first two rows. Prevention mode stops twelve thousand two hundred eighty-three bad events and, in exchange, cuts five thousand two hundred fourteen legitimate events. Which of the four cut classes these are is written in the list below, and what matters more than the count is the class itself: ninety patch rollouts and twelve backup transfers get cut. When a prevention system stops a patch rollout, it does not merely protect the network for that period; by leaving machines unpatched, it produces the next period's open item itself. Cutting five thousand one hundred field-endpoint reconnection attempts, in turn, means a loss of measurement data.

One of the cut classes shows the narrowest form of this trade-off. Patch rollout and the privileged process opened on the host trigger the same signature, and that signature's noise share is ninety-four percent: ninety of ninety-six flags are wrong. Leaving such a signature active in prevention mode means cutting ninety patch rollouts to stop six real events. Closing it, on the other hand, makes those six events fully invisible. A third option does not exist in the model, and in reality it is not cheap either: making the signature context-aware, that is, knowing what started the process. That information does not exist at the network layer at all.

In detection mode none of these costs exist; in their place is seventeen thousand four hundred ninety-seven alerts forming a queue that must be reviewed by hand. The choice between the two modes is not a choice about accuracy — the same signature set, the same errors. What is chosen is where the error gets written: into cut legitimate work, or into a queue no one will ever look through to the end.

#### Summary

- The network-based sensor sees eleven of fourteen classes, the host-based sensor nine. A privileged process opened on the host and a file permission change are never visible from the network; the scan-like sequence in the inside zone is not visible from the host, because each machine sees only its own single connection.
- Together the two catch six of seven bad classes. Both **see** the one class that escapes; no signature matches — the only anomaly is its regularity.
- The noise share of the seven signatures ranges from 0% to 100%. The large-volume signature catches no bad event in the period and flags 12 legitimate transfers; closing it is a gain with no cost.
- Closing the heavy-attempt signature brings alerts down from 17,497 to 385, but drops caught bad events from 12,283 to 283 and raises missed events from 2,880 to 14,880. The noise **ratio** falls from 29.8% to 26.5%: a system that decides by ratio mistakes this move for an improvement.
- Prevention mode stops 12,283 bad events and cuts 5,214 legitimate events across 4 classes; among those cut are 90 patch rollouts. Both modes miss the same events; what is chosen is whether the error gets written into cut work or into an alert queue no one can review.

#### Next Step

Every bit of noise in this lesson came from one place: the signatures were looking at a space where both legitimate work and bad events pass through. The way to lower the false-positive rate is not to write better signatures, but to look at a place legitimate work never touches at all. That is the next lesson's question: if an item is placed that no legitimate connection in the period should ever touch, what is the false-positive rate of every interaction that reaches it, on which day of the period does that signal arrive, and how many items remain that the same system never sees at all.

### Honeypots and Sinkholes

Source: https://academia.sh/en/courses/network-security/honeypots-and-sinkholes

Four decoy items that legitimate work never visits are run over the same period: how low the interaction false-positive rate stays next to signature-based detection, how many bad event classes never appear under this arrangement, and how many legitimate connections the sinkhole cuts per connection it drops.

All of the previous lesson's noise came from one place: signatures looked at the same space where both
legitimate work and bad events passed. A backup transfer and a data exfiltration crossed the same volume
threshold; a patch rollout and a new privileged process produced the same symptom. The way to lower the
false-positive rate is not to write a narrower signature — it is to look at a place legitimate work never
visits.

A decoy item is exactly this: an endpoint, a record, or a credential that no legitimate flow should touch
during the period. When it is touched, the signal carries no ambiguity, because there is no alternative to
ask "was this perhaps legitimate." These items are deployed on one's own network and observation stays
within one's own boundary; placing a trap on someone else's network or collecting data from it, without
written authorization and a defined scope, is both illegal and unprofessional.

- **PD24.** The four decoy items, the interaction counts, and the six prefix classes belong to a fictional
  regional measurement network. No real deployment is being made; what is measured is the model built with
  `node`.
- **PD25.** The signature-based counts being compared were read from the previous lesson's two-sensor run;
  it is the same period and the same event set.
- **PD26.** The first-signal day was hand-written. In reality this day depends on where the decoy item is
  placed and the order of discovery; it carries an order, not a count.
- **PD27.** Prefix classes are referred to by label; no real address block was written. The split between
  bad and legitimate connections was marked by hand.
- **PD28.** The sinkhole was modeled as a full cut: every connection to and from the prefix is dropped.
  Partial cutting and rate limiting were not modeled.

#### Decoy Items and Coverage

The model holds four decoy items. Two of them are endpoints: a management interface with no business in
the management zone, and a data endpoint that no application calls in the data zone. The third is a
record — a row that no legitimate query returns; its appearance in an outbound flow is a signal by itself.
The fourth is a credential used nowhere.

```js
// decoy.mjs — decoy items, sources of interaction, and the prefix set; a model, not a real deployment
// [item, zone it sits in, bad interactions, legit interactions, source of legit interaction, first signal day]
export const DECOY = [
  ['honeypot-management', 'management', 412, 6, 'monthly inventory scan', 2],
  ['honeypot-data', 'data', 54, 1, 'backup verification script', 9],
  ['honey-record', 'data', 3, 0, '-', 17],
  ['honey-credential', 'management', 118, 0, '-', 2],
];
// Which of the previous lesson's seven bad event classes touches which decoy item
export const TOUCHED = {
  'heavy attempts against the management gate': ['honeypot-management', 'honey-credential'],
  'outbound to an unknown server name': [],
  'new privileged process on host': [],
  'small outbound at regular intervals': ['honey-record'],
  'local file permission change': [],
  'known payload inside encrypted flow': [],
  'scan-like sequence in the inside zone': ['honeypot-management', 'honeypot-data'],
};
// Prefix classes that can be placed in the sinkhole: [label, bad connections, legit connections, note]
export const PREFIX = [
  ['prefix-A', 8400, 0, ''], ['prefix-B', 2100, 0, ''],
  ['prefix-C', 640, 12, 'vendor in the same block'], ['prefix-D', 190, 0, ''],
  ['prefix-E', 40, 5100, "field endpoints' cellular egress in the same block"], ['prefix-F', 12000, 0, ''],
];
export const SINKHOLE = {
  none: [], clean: ['prefix-A', 'prefix-B', 'prefix-D', 'prefix-F'],
  'clean+C': ['prefix-A', 'prefix-B', 'prefix-C', 'prefix-D', 'prefix-F'],
  all: PREFIX.map((o) => o[0]),
};
export const sum = (l, i) => l.reduce((a, o) => a + o[i], 0);
export const rate = (k, m) => (k + m === 0 ? '-' : `${(m * 100 / (k + m)).toFixed(1)}%`);
// The previous lesson's signature-based counts (source: same period, both sensors combined)
export const SIGNATURE_COUNT = { alert: 17497, bad: 12283, legit: 5214, missed: 2880, class: 6 };
```

The model's second part takes the previous lesson's seven bad event classes and writes down which decoy
item each one touches. An empty list means that class never appears under this arrangement; the coverage
measure comes from here. The third part belongs to the sinkhole and works on a separate logic — what is
measured there is not a signal but a dropped connection.

```js
// run1.mjs — the decoy items' false-positive rate and the event classes left outside their coverage
import { DECOY, TOUCHED, SIGNATURE_COUNT, sum, rate } from './decoy.mjs';

console.log(`${'decoy item'.padEnd(19)} ${'zone'.padEnd(11)} ${'bad interactions'.padStart(17)} ${'legit'.padStart(6)} ` +
  `${'false positive'.padStart(15)} ${'first signal'.padStart(13)}  source of legitimate interaction`);
for (const a of DECOY)
  console.log(`${a[0].padEnd(19)} ${a[1].padEnd(11)} ${String(a[2]).padStart(17)} ${String(a[3]).padStart(6)} ` +
    `${rate(a[2], a[3]).padStart(15)} ${`day ${a[5]}`.padStart(13)}  ${a[4]}`);
const [k, m] = [sum(DECOY, 2), sum(DECOY, 3)];
console.log(`\n${'measure'.padEnd(28)} ${'signature-based'.padStart(15)} ${'decoy-based'.padStart(15)}`);
for (const [name, i, a] of [
  ['period alerts', SIGNATURE_COUNT.alert, k + m],
  ['false positives', SIGNATURE_COUNT.legit, m],
  ['false-positive rate', rate(SIGNATURE_COUNT.bad, SIGNATURE_COUNT.legit), rate(k, m)],
  ['covered bad event classes', `${SIGNATURE_COUNT.class}/7`, `${Object.values(TOUCHED).filter((d) => d.length).length}/7`],
]) console.log(`${name.padEnd(28)} ${String(i).padStart(15)} ${String(a).padStart(15)}`);
console.log('\nevent class -> decoy item touched:');
for (const [s, d] of Object.entries(TOUCHED)) console.log(`  ${s.padEnd(43)} ${d.join(', ') || '- (never seen)'}`);
```

```
decoy item          zone         bad interactions  legit  false positive  first signal  source of legitimate interaction
honeypot-management management                412      6            1.4%         day 2  monthly inventory scan
honeypot-data       data                       54      1            1.8%         day 9  backup verification script
honey-record        data                        3      0            0.0%        day 17  -
honey-credential    management                118      0            0.0%         day 2  -

measure                      signature-based     decoy-based
period alerts                          17497             594
false positives                         5214               7
false-positive rate                    29.8%            1.2%
covered bad event classes                6/7             3/7

event class -> decoy item touched:
  heavy attempts against the management gate  honeypot-management, honey-credential
  outbound to an unknown server name          - (never seen)
  new privileged process on host              - (never seen)
  small outbound at regular intervals         honey-record
  local file permission change                - (never seen)
  known payload inside encrypted flow         - (never seen)
  scan-like sequence in the inside zone       honeypot-management, honeypot-data
```

The second table is the core of this lesson. Over the same period, the signature-based arrangement
produced seventeen thousand alerts, the decoy-based arrangement five hundred ninety-four. False positives
fell from five thousand two hundred fourteen to seven; the rate fell from thirty percent to one percent.
There is a reason for this, and it is not that the signatures were poorly written. A signature looks for a
pattern in a space that legitimate traffic also crosses; a decoy item is placed where legitimate traffic
never crosses at all. The false-positive rate comes out low because **the denominator is empty by
definition**.

The source of the remaining seven false positives confirms this: both come from the organization's own
scripts. The monthly inventory scan walks every address, and the backup verification script polls every
endpoint. Since both are known by name, they can be written into an exception list and the false-positive
count brought to zero; the cost is keeping that list maintained as an item of its own. As the exception
list grows, the decoy item's signal gets diluted.

The last row shows where the real cost sits. The signature-based arrangement covered six of the seven bad
classes, the decoy-based arrangement three. Four classes never appear, and all of them are of the same
kind: events that do not require touching a decoy item. A process opened on a host or a file permission
that changes never touches any trap set up on the network. Decoy items do not replace a detection layer;
where that layer is at its noisiest, they give a very clean signal, and where it is quietest, they give
nothing at all.

The interaction counts in the first table also say something on their own. The load the four items carry
is very different from one another: the fake interface in the management zone sees four hundred twelve
interactions, while the record in the data zone sees three. Both give a signal with the same certainty and
require the same response. A decoy item's value is not measured by the volume it sees, because the volume
itself carries no information — one interaction and four hundred interactions reach the same conclusion.
The only thing that can be measured is **where** the item was placed: the signal says that someone has
reached the zone the item sits in.

There is one row in the middle of the intersection: small outbound traffic at regular intervals. Of the
previous lesson's seven classes, this was the only one that slipped past both sensors at once — it was
visible, but no signature matched it. The honey-record catches this class, and it catches it with only
three interactions in the period. That the signal arrives on day seventeen is also meaningful: a decoy
item is not a measure of speed, it is a measure of presence.

One cost of decoy items does not appear in the table either: keeping the item convincing. If an endpoint
disguised as a management interface can be told apart from the genuine items around it, no one touches it
and the signal never arrives. Staying indistinguishable requires that it also be updated, follow the
naming convention, and have a matching entry in inventory. Maintaining four items is a separate task from
maintaining the two hundred ten protected items, and when it is forgotten, the item silently becomes
non-functional — and because it produces no false positives, no one notices either. The silence of an
unmeasured defense is not proof that it is working.

#### The Sinkhole

```js
// run2.mjs — the sinkhole: bad connections dropped, legit connections cut, and bad connections remaining
import { PREFIX, SINKHOLE, sum } from './decoy.mjs';

const [TK, TM] = [sum(PREFIX, 1), sum(PREFIX, 2)];
console.log(`six prefix classes: ${TK} bad, ${TM} legit connections\n`);
console.log(`${'prefix'.padEnd(8)} ${'bad'.padStart(6)} ${'legit'.padStart(6)} ${'legit cut if sinkholed'.padStart(29)}  note`);
for (const o of PREFIX)
  console.log(`${o[0].padEnd(8)} ${String(o[1]).padStart(6)} ${String(o[2]).padStart(6)} ${String(o[2]).padStart(29)}  ${o[3]}`.trimEnd());
console.log(`\n${'sinkhole list'.padEnd(14)} ${'pfx'.padStart(4)} ${'bad dropped'.padStart(11)} ${'bad remaining'.padStart(13)} ` +
  `${'legit cut'.padStart(13)} ${'legit cut per bad'.padStart(25)}`);
for (const [name, list] of Object.entries(SINKHOLE)) {
  const inn = PREFIX.filter((o) => list.includes(o[0]));
  const [dk, km] = [sum(inn, 1), sum(inn, 2)];
  console.log(`${name.padEnd(14)} ${String(list.length).padStart(4)} ${String(dk).padStart(11)} ${String(TK - dk).padStart(13)} ` +
    `${String(km).padStart(13)} ${(dk ? (km / dk).toFixed(4) : '-').padStart(25)}`);
}
const e = PREFIX.find((o) => o[0] === 'prefix-E'), c = PREFIX.find((o) => o[0] === 'prefix-C');
console.log(`\ncost of the last two prefixes, one by one:`);
for (const o of [c, e])
  console.log(`  adding ${o[0]}  bad dropped +${o[1]}, legit cut +${o[2]}  (${o[3]})`);
console.log(`\ntraffic that falls into the sinkhole never reaches its destination, so it produces no sensor record;`);
console.log(`${sum(PREFIX.filter((o) => SINKHOLE.clean.includes(o[0])), 1)} connections drop silently under the clean list`);
```

```
six prefix classes: 23370 bad, 5112 legit connections

prefix      bad  legit        legit cut if sinkholed  note
prefix-A   8400      0                             0
prefix-B   2100      0                             0
prefix-C    640     12                            12  vendor in the same block
prefix-D    190      0                             0
prefix-E     40   5100                          5100  field endpoints' cellular egress in the same block
prefix-F  12000      0                             0

sinkhole list   pfx bad dropped bad remaining     legit cut         legit cut per bad
none              0           0         23370             0                         -
clean             4       22690           680             0                    0.0000
clean+C           5       23330            40            12                    0.0005
all               6       23370             0          5112                    0.2187

cost of the last two prefixes, one by one:
  adding prefix-C  bad dropped +640, legit cut +12  (vendor in the same block)
  adding prefix-E  bad dropped +40, legit cut +5100  (field endpoints' cellular egress in the same block)

traffic that falls into the sinkhole never reaches its destination, so it produces no sensor record;
22690 connections drop silently under the clean list
```

A sinkhole is an item that works in the reverse direction: a decoy item is placed to produce a signal, a
sinkhole to never see the traffic at all. The four clean prefixes drop twenty-two thousand six hundred
ninety bad connections and cut not a single legitimate connection. The remaining six hundred eighty bad
connections come from two prefixes, and both are mixed in with legitimate traffic.

The last column gives the price of this mixing. Adding the fifth prefix cuts five legitimate connections
per ten thousand bad connections — practically free. Adding the sixth pushes that same rate to one in
five: dropping forty bad connections costs five thousand one hundred legitimate connections. Both moves
add one row each to the list, and the rows look alike; the only thing that tells them apart is who stands
behind the prefix. The length of a block list is therefore not a measure; the measure is the legitimate
connections cut per bad connection dropped.

The list also has a time dimension that does not appear in the table. A prefix enters the list because it
carried bad traffic during the period; in a later period the party behind it may change, and the row stays
where it is. The reason for adding a row is recorded, but the reason for removing one never arises
anywhere — because traffic that falls into the sinkhole produces no record at all, so it is equally
invisible that the prefix now carries legitimate traffic. In this respect a sinkhole has the same habit as
a firewall rule set: it grows in one direction only.

The last two lines give the sinkhole's own blind spot. Traffic that falls into the sinkhole never reaches
its destination, so it produces no sensor record at all: twenty-two thousand six hundred ninety
connections vanish silently. The previous lesson's alert queue gets lighter by exactly that much, but the
same move also makes invisible what that traffic was looking for and what it was trying. Because a
sinkhole and a honeypot can be deployed together in the same period, the two items complement each other:
one drops noise without ever producing it, the other turns a single interaction into a signal at the exact
place where there is no noise at all.

This lesson's two items say one more thing together. A decoy item is a defense with narrow coverage and a
clean signal; a sinkhole is a defense with broad coverage and no signal at all. The previous lesson's
signature-based arrangement sits between the two: broad coverage, a noisy signal. When all three run
together over the same period, their measurement columns do not add up, because each one sees the same
event at a different place. The only thing that can be measured is each one's own column — the classes
covered, the false positives produced, and the legitimate work cut — and when a defense is said to improve
only one of these three, it means no one has asked where the other two went.

#### Summary

- Over the same period, the signature-based arrangement produces 17,497 alerts and a 29.8% false-positive
  rate; the decoy-based arrangement produces 594 alerts and 1.2%. The rate is low because legitimate work
  never visits a decoy item, by definition.
- Both sources of the remaining 7 false positives are the organization's own scripts. They can be zeroed
  out with an exception list; the cost is keeping that list maintained, and it dilutes the signal as it
  grows.
- Coverage is the cost: the signature-based arrangement covers 6 of 7 bad classes, the decoy-based
  arrangement covers 3. The 4 classes that never appear are of the same kind — events that require
  touching no trap at all.
- The honey-record catches, with 3 interactions, the one class that both sensors in the previous lesson
  missed. The signal arrives on day 17: a decoy item is a measure of presence, not of speed.
- In the sinkhole, 4 clean prefixes drop 22,690 bad connections at zero legitimate cost. The fifth prefix
  costs 0.0005 legitimate connections per bad connection, the sixth costs 0.2187. The sinkhole's own cost
  is blindness: the 22,690 connections it drops produce no sensor record at all.

#### Next Step

Every defense counted so far either split traffic into zones, routed it through one point, recorded it, or
dropped it. Their shared assumption was that the connecting party stood **outside** the network. A tunnel
removes that assumption: a remote endpoint, once verified, receives an address inside the network and
becomes a node in the zone graph. The next lesson's question is not whether the tunnel gets established,
but how many items become reachable once it is — how many items under a full tunnel, how many stay inside
and how many outside under a split tunnel, and how many connections a single verification is enough to
count as proven.

### Virtual Private Networks

Source: https://academia.sh/en/courses/network-security/virtual-private-networks

The access scope a tunnel opens is run across three placements: how many items the remote endpoint reaches directly when it lands in the internal zone, how many items fall out of scope when it lands in a separate zone and in a zone per role; how many connections a split tunnel leaves outside inspection, and how many connections a single verification is enough to count as proven subject identity.

Every defense counted so far either split traffic into zones, routed it through a single point, logged it,
or dropped it. Their shared assumption was that the connecting party stood outside the network. A tunnel
removes that assumption: once a remote endpoint is verified, it takes an address from the internal network
and becomes a node in the zone graph. From that moment on, distance is not a security property.

Three things are measured. Which zone the tunnel lands in when it opens and how many items are reachable
from there; how many connections a split tunnel leaves outside the tunnel; and how many connections a
single verification is enough to count as proven subject identity. Tunneling and private connectivity in
cloud networks were covered in another course; the axis here is not the connection's establishment but its
scope after establishment.

- **PD29.** The three remote endpoint classes, item counts, connection and session counts belong to a
  fictional zonal measurement network. No real tunnel is established; what is measured is the model built
  with `node`.
- **PD30.** Encryption, key exchange, and the tunnel protocol are not modeled. It is assumed that the
  tunnel is established and that traffic inside it cannot be read from outside; what that assurance rests
  on is outside this course.
- **PD31.** Restrictions inside a zone are disregarded: an endpoint that lands in a zone reaches all of
  that zone's items. Direct access is counted as the landing zone plus the targets of the rules that leave
  it.
- **PD32.** Session length is taken as eight hours, and every session is assumed to run its full length.
  Re-verification, session renewal, and idle timeout are not modeled.
- **PD33.** The split between corp-bound and external-bound connections is marked by hand; every run
  produces the same numbers.

#### The Zone the Tunnel Lands In

A single configuration item of the remote-access tunnel decides the whole scope: which zone the remote
endpoint takes its address from. The easiest path is to land it in the zone where employees already sit,
because that zone's rules are already written and no new line is needed. The model compares three
placements: landing in the internal zone, landing in a single remote-access zone, and landing each endpoint
class in its own zone.

```js
// tunnel.mjs — the zone the tunnel lands in, its access scope, and connection distribution; a model, not a real tunnel
export const ITEM = { dmz: 4, branch: 120, field: 60, app: 12, data: 6, management: 5, backup: 3 };
const MANAGEMENT = ['dmz', 'branch', 'field', 'app', 'data', 'backup'].map((b) => ['management', b]);
export const BASE = [['branch', 'dmz'], ['branch', 'app'], ['field', 'dmz'], ['dmz', 'app'],
  ['app', 'data'], ['data', 'backup'], ...MANAGEMENT];
// [class, endpoint count, corp-bound connections, external-bound connections, sessions in period]
export const ENDPOINT = [
  ['field laptop', 40, 26400, 5100, 880], ['remote worker', 25, 18600, 92000, 550],
  ['vendor', 6, 740, 0, 24],
];
// which zone an endpoint class lands in, and which rules get written from that zone
export const PLACEMENT = {
  flat: { land: () => 'branch', extra: [] },
  'single remote zone': { land: () => 'remote', extra: [['remote', 'dmz'], ['remote', 'app']] },
  'zone per role': {
    land: (s) => ({ 'field laptop': 'remote-field', 'remote worker': 'remote-office', vendor: 'remote-vendor' })[s],
    extra: [['remote-field', 'dmz'], ['remote-office', 'dmz'], ['remote-office', 'app'], ['remote-vendor', 'dmz']],
  },
};
export function reached(edges, start) {
  const seen = new Set(), queue = [start];
  while (queue.length) {
    const b = queue.shift();
    for (const [x, y] of edges) if (x === b && !seen.has(y)) { seen.add(y); queue.push(y); }
  }
  return seen;
}
export const itemsOf = (set) => [...set].reduce((a, b) => a + (ITEM[b] ?? 0), 0);
export const scope = (y, s) => {              // items an endpoint class can reach while the tunnel is open
  const edges = [...BASE, ...y.extra], start = y.land(s);
  const near = new Set([start, ...edges.filter(([x]) => x === start).map(([, b]) => b)]);
  const all = reached(edges, start); all.add(start);
  return { zone: [...all].filter((b) => ITEM[b]).length, direct: itemsOf(near), transitive: itemsOf(all) };
};
export const TOTAL_ITEMS = itemsOf(Object.keys(ITEM));
export const SESSION_HOURS = 8;               // length of a session opened by a single verification
export const sum = (i) => ENDPOINT.reduce((a, u) => a + u[i], 0);
```

The model gives two measures, and the two must not be confused. Directly reached items sum the landing
zone's own items with the targets of the rules that leave that zone; transitively reached items walk the
whole graph. The difference is what taking one more step depends on: directly reached items are in the
remote endpoint's own hands, transitive ones require an application flow to be used somewhere along the
way. The Network Segmentation and the Demilitarized Zone lesson counted the same distinction as the first hop versus transitive items.

```js
// run1.mjs — the zone and items an endpoint class can reach while the tunnel is open, across three placements
import { ENDPOINT, PLACEMENT, BASE, scope, TOTAL_ITEMS } from './tunnel.mjs';

console.log(`inside network ${TOTAL_ITEMS} items, ${BASE.length} zone rules\n`);
console.log('columns: direct / transitive items reached per endpoint class');
console.log(`${'placement'.padEnd(19)} ${'extra rules'.padStart(11)}` + ENDPOINT.map((u) => u[0].padStart(16)).join(''));
for (const [name, y] of Object.entries(PLACEMENT))
  console.log(`${name.padEnd(19)} ${String(y.extra.length).padStart(11)}` +
    ENDPOINT.map((u) => { const k = scope(y, u[0]); return `${k.direct}/${k.transitive}`.padStart(16); }).join(''));
console.log(`\n${'placement'.padEnd(19)} ${'extra rules'.padStart(11)} ${'widest direct'.padStart(17)} ` +
  `${'items out of scope'.padStart(19)} ${'closed-path share'.padStart(18)}`);
for (const [name, y] of Object.entries(PLACEMENT)) {
  const en = Math.max(...ENDPOINT.map((u) => scope(y, u[0]).direct));
  console.log(`${name.padEnd(19)} ${String(y.extra.length).padStart(11)} ${String(en).padStart(17)} ` +
    `${String(TOTAL_ITEMS - en).padStart(19)} ${`${((TOTAL_ITEMS - en) * 100 / TOTAL_ITEMS).toFixed(1)}%`.padStart(18)}`);
}
const r = PLACEMENT['zone per role'];
console.log('\nitems reached per class under zone-per-role:');
for (const u of ENDPOINT) console.log(`  ${u[0].padEnd(14)} ${u[1]} endpoints  ${String(scope(r, u[0]).direct).padStart(4)} direct, ${String(scope(r, u[0]).transitive).padStart(3)} transitive items`);
```

```
inside network 210 items, 12 zone rules

columns: direct / transitive items reached per endpoint class
placement           extra rules    field laptop   remote worker          vendor
flat                          0         136/145         136/145         136/145
single remote zone            2           16/25           16/25           16/25
zone per role                 4            4/25           16/25            4/25

placement           extra rules     widest direct  items out of scope  closed-path share
flat                          0               136                  74              35.2%
single remote zone            2                16                 194              92.4%
zone per role                 4                16                 194              92.4%

items reached per class under zone-per-role:
  field laptop   40 endpoints     4 direct,  25 transitive items
  remote worker  25 endpoints    16 direct,  25 transitive items
  vendor         6 endpoints     4 direct,  25 transitive items
```

The first row is what writing no rules at all costs. When the tunnel lands in the branch zone, the remote
endpoint stands on the same flat plane as that zone's hundred and twenty items and also inherits the rules
that leave the branch zone: directly reached items are a hundred and thirty-six, transitively reached items
a hundred and forty-five. Two-thirds of a two-hundred-and-ten-item network sits behind a single tunnel. The
vendor endpoint and the field laptop endpoint get the same number, because scope is decided not by who the
endpoint is but by which zone it lands in.

The second row is what two rule lines cost. When remote endpoints land in their own zone, directly reached
items drop to sixteen, transitively reached items to twenty-five; items out of scope climb from
seventy-four to a hundred and ninety-four. Two lines move a hundred and twenty items out of scope. This is
the highest-yield move in this lesson, and its cost is two lines.

The third row adds two more lines and does not change total scope at all: transitive access is still
twenty-five. What changes is direct access per class. The field laptop and the vendor endpoint now reach
four items directly instead of sixteen; the remote worker's scope stays the same, because its work is in
the app zone. Zone per role does not narrow total scope — **it leaves the widest scope to the fewest
endpoints**. Forty-six of the seventy-one endpoints now see four items instead of sixteen.

The shared lesson of these three rows is that the tunnel itself is not a security item. The same tunnel is
established, the same verification is performed, and traffic inside is carried the same way in all three
placements. The only thing that decides the measure is which zone the remote endpoint takes its address
from and which rules are written from that zone. A tunnel is an access mechanism; its scope is a
segmentation decision, and when no segmentation decision is made, the default scope is the widest one.

The cost of the placements shows up in the rule line, but there is one more item, and it is not in the
table: which class the remote endpoint belongs to must be determined on every session. In the flat
placement that lands in the internal zone, this question is never asked. In the zone-per-role placement, a
mapping is made on every session, and the information that mapping rests on is not something the endpoint
declares itself but an attribute that verification returns. The real cost of the four rule lines is
maintaining that mapping: an endpoint that changes class changes scope without the rule changing.

#### Split Tunnel and the Scope of Verification

```js
// run2.mjs — full tunnel versus split tunnel: concentrator load, out-of-inspection connections, and the scope of verification
import { ENDPOINT, SESSION_HOURS, sum } from './tunnel.mjs';

console.log(`${'endpoint class'.padEnd(15)} ${'eps'.padStart(3)} ${'sessions'.padStart(8)} ${'corp-bound'.padStart(13)} ` +
  `${'external-bound'.padStart(14)} ${'full tunnel'.padStart(12)} ${'split'.padStart(8)} ${'unproven per session'.padStart(22)}`);
for (const [name, eps, corp, ext, sessions] of ENDPOINT)
  console.log(`${name.padEnd(15)} ${String(eps).padStart(3)} ${String(sessions).padStart(8)} ${String(corp).padStart(13)} ` +
    `${String(ext).padStart(14)} ${String(corp + ext).padStart(12)} ${String(corp).padStart(8)} ` +
    `${(corp / sessions).toFixed(1).padStart(22)}`);
const [eps, corp, ext, sessions] = [sum(1), sum(2), sum(3), sum(4)];
console.log(`\n${'measure'.padEnd(42)} ${'full tunnel'.padStart(11)} ${'split tunnel'.padStart(14)}`);
for (const [name, full, split] of [
  ['connections through the concentrator', corp + ext, corp],
  ['connections outside inspection', 0, ext],
  ['endpoints straddling both networks at once', 0, eps],
  ['sessions opened by a single verification', sessions, sessions],
  ['hours covered by one verification', SESSION_HOURS, SESSION_HOURS],
  ['connections covered by one verification', Math.round((corp + ext) / sessions), Math.round(corp / sessions)],
]) console.log(`${name.padEnd(42)} ${String(full).padStart(11)} ${String(split).padStart(14)}`);
console.log(`\nconnections cut when the tunnel drops  full ${corp + ext}  split ${corp}`);
console.log(`${sessions} verifications happen in the period; ${corp + ext} connections' subject identity rests on these ${sessions} proofs`);
```

```
endpoint class  eps sessions    corp-bound external-bound  full tunnel    split   unproven per session
field laptop     40      880         26400           5100        31500    26400                   30.0
remote worker    25      550         18600          92000       110600    18600                   33.8
vendor            6       24           740              0          740      740                   30.8

measure                                    full tunnel   split tunnel
connections through the concentrator            142840          45740
connections outside inspection                       0          97100
endpoints straddling both networks at once           0             71
sessions opened by a single verification          1454           1454
hours covered by one verification                    8              8
connections covered by one verification             98             31

connections cut when the tunnel drops  full 142840  split 45740
1454 verifications happen in the period; 142840 connections' subject identity rests on these 1454 proofs
```

Full tunnel routes all traffic through the concentrator: a hundred and forty-two thousand eight hundred
and forty connections, zero outside inspection. Split tunnel routes only the corp-bound portion, and the
load drops to forty-five thousand — but ninety-seven thousand one hundred connections pass through no
inspection at all. Where that number comes from shows up in the second row: the remote worker class's
external-bound connections alone are ninety-two thousand. The entire source of split tunnel cutting the
load to a third is this set that it removes from inspection.

The third row is split tunnel's real item. In split tunnel, seventy-one endpoints stand in both networks
at once: holding an address from the internal network and connected directly to the external network.
There is no rule set between the two networks; the endpoint itself is a node facing both sides. Every
boundary drawn in the zone graph intersects at these seventy-one points. In full tunnel this number is
zero — its cost is that the ninety-seven thousand outbound connections also pass through the corporate
exit, and the concentrator carries three times the load.

The last three rows return to the measure itself. A thousand four hundred and fifty-four verifications
happen in the period, and once the tunnel opens, it is not asked again for eight hours. In full tunnel, one
verification counts an average of ninety-eight connections as proven; in split tunnel, thirty-one. Of the
forty-eight claims counted in the first lesson, three were subject identities that no generation could
prove; the tunnel proves it once and leans on that single proof for the next ninety-seven connections. If
the proof turns out wrong — if the endpoint is in someone else's hands — the network has no place to
notice this for eight hours, because every connection inside the tunnel appears as part of a verified
session.

There is one more detail in the first of the columns. The unproven-connections-per-session count sits
around thirty in all three classes: thirty, thirty-three point eight, and thirty point eight. The number
not changing across classes shows that the measure has nothing to do with endpoint count or volume of
work. What decides it is session length; an eight-hour session ties every piece of work done in that time
to a single proof. Halving the session length halves this number too, and in exchange doubles the number
of verifications — its cost is asking for identity two thousand nine hundred and eight times in the period
instead of a thousand four hundred and fifty-four.

The connections-cut-when-the-tunnel-drops column is the last item of the cost. The concentrator belongs to
the same class as the inspection points measured in the previous lesson: a visibility gain and an
availability debt taken on in the same move. In full tunnel the debt is a hundred and forty-two thousand
connections, in split tunnel forty-five thousand. The choice is not between the two; it is over which
number gets written on which side.

Site-to-site tunnels repeat the same measure at a different scale. In a remote-access tunnel a single
endpoint lands in a zone; in a site-to-site tunnel an entire network lands. A tunnel established between a
branch network and the central zone collapses the two networks into a single edge in the zone graph, and
which items that edge covers still depends on the rules written. When no rule is written, the two networks
become a single flat plane; the measure is still directly reached items, and this time the branch
network's item count is added not to the remote endpoint's scope but to the other side's.

#### Summary

- When the tunnel lands in the internal zone, the remote endpoint reaches 136 items directly and 145
  transitively: two-thirds of a 210-item network. Scope depends not on who the endpoint is but on which
  zone it lands in.
- A separate remote-access zone, with 2 rule lines, brings direct access down to 16 and transitive access
  down to 25; items out of scope climb from 74 to 194. This is the highest-yield move.
- Zone per role adds 2 more lines and does not change total scope: it leaves the widest scope to the
  fewest endpoints. 46 of 71 endpoints see 4 items instead of 16.
- Split tunnel brings the concentrator load down from 142,840 to 45,740; in exchange, 97,100 connections
  pass through no inspection at all, and 71 endpoints stand in both networks at once.
- 1,454 verifications happen in the period and one verification covers 8 hours: in full tunnel an average
  of 98, in split tunnel 31 connections rest on a single proof. If the proof turns out wrong, there is no
  place for the network to notice for eight hours.

#### Next Step

Six lessons of writing defenses have each come down to the same thing: a rule list. Zone boundaries are
fifteen lines, remote-access zones are four lines, inspection points and block lists have their own lines.
All of these lines sit side by side in a single box, and when a packet arrives, they are not all looked at
at once — they are looked at **in order**, and the first match wins. The next lesson's question is not
which rule is written but which one comes first: for a known set of flows, the first matching rule is
found for each flow, and how many flows' outcomes change when the list's order changes is counted.

### Access Control Lists

Source: https://academia.sh/en/courses/network-security/access-control-lists

Rule evaluation order is run across ten rules and sixteen known flows: the first matching rule is found for each flow, the same list is evaluated in five separate orders, and how many flows' outcomes change when the order changes is counted; how many legitimate flows a hardening move cuts and the path that specific-to-general ordering opens are measured.

Six lessons of writing defenses have each come down to the same thing: a rule list. Zone boundaries are
fifteen lines, remote-access zones are four lines, inspection points and block lists brought their own
lines. These lines sit side by side in a single box, and when a flow arrives, they are not all looked at
at once — they are looked at in order, and **the first match wins**.

This lesson's question is not which rule is written but which one comes first. Without a single character
of the rule text changing, only the order of the lines changing, the same flow set's outcomes can change.
This is what is measured: for a known flow set, the first matching rule is found for each flow, and how
many flows' outcomes change when the order changes is counted.

- **PD34.** The ten rules, sixteen flows, and zone names belong to a fictional zonal measurement network.
  No real packet flows; what is measured is the model built with `node`.
- **PD35.** Matching is done on three axes: source zone, destination zone, and service name. In practice,
  address range, protocol, and port are also written; that grows the number of axes but does not change
  the effect of order.
- **PD36.** Whether flows are legitimate is marked by hand and stays fixed throughout the model.
- **PD37.** Rule numbers are the order the operator wrote them in: older lines have smaller numbers, ones
  added later have larger ones. Moving a line is modeled by giving it a new number.
- **PD38.** State and the return direction are not modeled; every flow is evaluated in a single direction.
- **PD39.** A flow can match every rule in the list; only the first match decides the outcome.

#### The List and the First Matching Rule

The rule list was not written all at once. The line with the smallest number is the oldest, and it gives
the management zone access everywhere; this is exactly the line measured in the Network Segmentation and the Demilitarized Zone lesson. The
line numbered a hundred sixty was added later, during hardening, and it denies the management shell from
everywhere. The two sit in the same list, and both match the same flows.

```js
// rules.mjs — numbered rule list and known flow set; a model, not a real filter
// [no, source zone, dest zone, service, decision] — '*' means no distinction on that axis
export const RULE = [
  [100, 'management', '*', '*', 'allow'],              // oldest line: management reaches everywhere
  [110, '*', 'dmz', 'public-interface', 'allow'],
  [120, 'dmz', 'app', 'app-api', 'allow'],
  [130, 'branch', 'app', 'billing-api', 'allow'],
  [140, 'field', 'dmz', 'measurement-intake', 'allow'],
  [150, 'app', 'data', 'data-endpoint', 'allow'],
  [160, '*', '*', 'management-shell', 'deny'],          // added during hardening
  [170, 'remote', 'dmz', '*', 'allow'],
  [180, 'data', 'backup', 'backup-endpoint', 'allow'],
  [190, '*', '*', '*', 'deny'],                          // default deny
];
// [name, source, dest, service, legit?]
export const FLOW = [
  ['management -> app shell', 'management', 'app', 'management-shell', true],
  ['management -> data shell', 'management', 'data', 'management-shell', true],
  ['management -> dmz interface', 'management', 'dmz', 'public-interface', true],
  ['branch -> billing api', 'branch', 'app', 'billing-api', true],
  ['field -> measurement intake', 'field', 'dmz', 'measurement-intake', true],
  ['external -> public interface', 'external', 'dmz', 'public-interface', true],
  ['dmz -> app api', 'dmz', 'app', 'app-api', true],
  ['app -> data endpoint', 'app', 'data', 'data-endpoint', true],
  ['data -> backup endpoint', 'data', 'backup', 'backup-endpoint', true],
  ['remote -> public interface', 'remote', 'dmz', 'public-interface', true],
  ['remote -> measurement intake', 'remote', 'dmz', 'measurement-intake', true],
  ['field -> data endpoint (quarter close)', 'field', 'data', 'data-endpoint', true],
  ['remote -> dmz shell', 'remote', 'dmz', 'management-shell', false],
  ['branch -> app shell', 'branch', 'app', 'management-shell', false],
  ['external -> app api', 'external', 'app', 'app-api', false],
  ['dmz -> data endpoint', 'dmz', 'data', 'data-endpoint', false],
];
const matches = (k, a) => (k[1] === '*' || k[1] === a[1]) && (k[2] === '*' || k[2] === a[2]) &&
  (k[3] === '*' || k[3] === a[3]);
export const firstMatch = (list, a) => list.find((k) => matches(k, a));
export const decide = (list, a) => firstMatch(list, a)[4];
export function tally(list) {
  const r = FLOW.map((a) => [a, decide(list, a)]);
  const n = (f) => r.filter(([a, k]) => f(a, k)).length;
  return { allow: n((a, k) => k === 'allow'), deny: n((a, k) => k === 'deny'),
    wronglyClosed: n((a, k) => a[4] && k === 'deny'), leaked: n((a, k) => !a[4] && k === 'allow') };
}
export const shadowed = (list) => list.filter((k) => !FLOW.some((a) => firstMatch(list, a)[0] === k[0]));
export const move = (no, next) => [...RULE].map((k) => (k[0] === no ? [next, ...k.slice(1)] : k))
  .sort((a, b) => a[0] - b[0]);
export const wildcardCount = (k) => k.slice(1, 4).filter((x) => x === '*').length;
export const ORDER = {
  'as written': RULE,
  '160 -> 95': move(160, 95),
  '170 -> 105': move(170, 105),
  'specific to general': [...RULE].sort((a, b) => wildcardCount(a) - wildcardCount(b) || a[0] - b[0]),
  'reversed': [...RULE].reverse(),
};
export const changed = (a1, a2) => FLOW.filter((a) => decide(a1, a) !== decide(a2, a));
```

```js
// run1.mjs — first matching rule and outcome for each flow, in the order the rules were written
import { RULE, FLOW, firstMatch, decide, tally, shadowed } from './rules.mjs';

console.log(`${RULE.length} rules, ${FLOW.length} known flows\n`);
console.log(`${'flow'.padEnd(40)} ${'legit'.padStart(5)} ${'first match'.padStart(11)} ${'decision'.padStart(8)}  outcome`);
for (const a of FLOW) {
  const k = firstMatch(RULE, a), c = decide(RULE, a);
  const s = a[4] === (c === 'allow') ? 'correct' : (a[4] ? 'wrongly closed' : 'leaked');
  console.log(`${a[0].padEnd(40)} ${String(a[4] ? 'yes' : 'no').padStart(5)} ${String(k[0]).padStart(11)} ${c.padStart(8)}  ${s}`);
}
const s = tally(RULE);
console.log(`\nallow ${s.allow}, deny ${s.deny}, wrongly closed ${s.wronglyClosed}, leaked ${s.leaked}`);
console.log(`shadowed rules: ${shadowed(RULE).map((k) => k[0]).join(', ') || 'none'}`);
const k160 = FLOW.filter((a) => a[3] === 'management-shell');
console.log(`\n"management-shell" service: the ${k160.length} flows that touch it and the rule that decides each:`);
for (const a of k160) console.log(`  ${a[0].padEnd(40)} ${firstMatch(RULE, a)[0]} rule -> ${decide(RULE, a)}`);
```

```
10 rules, 16 known flows

flow                                     legit first match decision  outcome
management -> app shell                    yes         100    allow  correct
management -> data shell                   yes         100    allow  correct
management -> dmz interface                yes         100    allow  correct
branch -> billing api                      yes         130    allow  correct
field -> measurement intake                yes         140    allow  correct
external -> public interface               yes         110    allow  correct
dmz -> app api                             yes         120    allow  correct
app -> data endpoint                       yes         150    allow  correct
data -> backup endpoint                    yes         180    allow  correct
remote -> public interface                 yes         110    allow  correct
remote -> measurement intake               yes         170    allow  correct
field -> data endpoint (quarter close)     yes         190     deny  wrongly closed
remote -> dmz shell                         no         160     deny  correct
branch -> app shell                         no         160     deny  correct
external -> app api                         no         190     deny  correct
dmz -> data endpoint                        no         190     deny  correct

allow 11, deny 5, wrongly closed 1, leaked 0
shadowed rules: none

"management-shell" service: the 4 flows that touch it and the rule that decides each:
  management -> app shell                  100 rule -> allow
  management -> data shell                 100 rule -> allow
  remote -> dmz shell                      160 rule -> deny
  branch -> app shell                      160 rule -> deny
```

As written, the list looks good: fifteen of sixteen flows resolve correctly, and the one wrong closure is
the quarter-close reconciliation — that lesson cut the same flow, and here too no line looks
out for it. No flow leaks, and no rule sits shadowed.

The sub-list poses the lesson's real question. Four flows touch the management-shell service, and the line
numbered a hundred decides two of them, the line numbered a hundred sixty the other two. The line numbered
a hundred sixty was written as "deny the management shell from everywhere"; its text says that, and it
reads the same way in the audit record. What it does at runtime is "deny from everywhere outside the
management zone," because flows coming from the management zone never reach it — they stop at the line
numbered a hundred. The two lines conflict, and what resolves the conflict is not the text but the number.

The same list has another example running the opposite way. The line numbered a hundred seventy decides
the flow from the remote-access zone to measurement intake; the line numbered a hundred ten decides the flow
to the public interface, because it comes first and carries a wildcard on the source axis. Both flows get
allowed, the outcome is the same — but the lines that decide it are different. In an audit record, the
answer to "which rule allowed this" may not be the line the person who wrote the rule expected. Someone
removing the line numbered a hundred seventy might see remote access still reaching the public interface
and conclude the change had no effect; the measurement-intake flow, by then, is already cut.

#### When the Order Changes

```js
// run2.mjs — the same rule set in five orders; how many flows change outcome when order changes
import { RULE, FLOW, ORDER, decide, tally, shadowed, changed } from './rules.mjs';

console.log(`${'order'.padEnd(20)} ${'allow'.padStart(5)} ${'deny'.padStart(6)} ${'wrongly closed'.padStart(14)} ` +
  `${'leaked'.padStart(6)} ${'changed flows'.padStart(13)} ${'shadowed rules'.padStart(14)}`);
for (const [name, list] of Object.entries(ORDER)) {
  const s = tally(list), g = shadowed(list);
  console.log(`${name.padEnd(20)} ${String(s.allow).padStart(5)} ${String(s.deny).padStart(6)} ${String(s.wronglyClosed).padStart(14)} ` +
    `${String(s.leaked).padStart(6)} ${String(changed(RULE, list).length).padStart(13)} ${(g.map((k) => k[0]).join(',') || '-').padStart(14)}`);
}
for (const name of ['160 -> 95', 'specific to general']) {
  console.log(`\nflows whose outcome changes under "${name}" order:`);
  for (const a of changed(RULE, ORDER[name]))
    console.log(`  ${a[0].padEnd(40)} ${decide(RULE, a)} -> ${decide(ORDER[name], a)}  (legit: ${a[4] ? 'yes' : 'no'})`);
}
console.log(`\nthe rule text never changed across the ${RULE.length} lines; the only thing that changed is order`);
const O = RULE.filter((k) => k[0] !== 190);      // default deny conflicts with every allow line; left out
const pairs = O.flatMap((a, i) => O.slice(i + 1).filter((b) =>
  FLOW.some((x) => [a, b].every((k) => (k[1] === '*' || k[1] === x[1]) && (k[2] === '*' || k[2] === x[2]) &&
    (k[3] === '*' || k[3] === x[3]))) && a[4] !== b[4]).map((b) => [a[0], b[0]]));
console.log(`outside the default-deny line, the pairs that match the same flow and decide differently: ${pairs.map(([a, b]) => `${a}/${b}`).join(', ')}`);
```

```
order                allow   deny wrongly closed leaked changed flows shadowed rules
as written              11      5              1      0             0              -
160 -> 95                9      7              3      0             2              -
170 -> 105              12      4              1      1             1              -
specific to general     12      4              1      1             1              -
reversed                 0     16             12      0            11 180,170,160,150,140,130,120,110,100

flows whose outcome changes under "160 -> 95" order:
  management -> app shell                  allow -> deny  (legit: yes)
  management -> data shell                 allow -> deny  (legit: yes)

flows whose outcome changes under "specific to general" order:
  remote -> dmz shell                      deny -> allow  (legit: no)

the rule text never changed across the 10 lines; the only thing that changed is order
outside the default-deny line, the pairs that match the same flow and decide differently: 100/160, 160/170
```

The second row is the result of the hardening intuition. The line that denies the management shell is
moved to the top of the list; now it really does deny from everywhere. Wrongly closed goes from one to
three, and both of the two changed flows are legitimate: shell sessions going from the management zone to
the app and data zones. The move is a correction in the text and a cutoff of management access at runtime.
When the cutoff will be noticed is also predictable — at the next maintenance window.

The third and fourth rows do the same thing running the opposite way. "Specific rule first, general rule
last" is a common ordering intuition, and running it here brings the leaked count from zero to one. Why is
in the pair list in the last row: the deny line numbered a hundred sixty carries a wildcard on two axes,
the allow line numbered a hundred seventy on one. An ordering that sorts by specificity brings the allow
line forward, and the flow from the remote-access zone to the management shell gets through. The same
outcome also comes from moving the line numbered a hundred seventy forward by hand; the two moves have
different justifications and the same result.

The fifth row is the limit case. When the list is reversed, the default-deny line moves to the top, all
sixteen flows are denied, and nine rules sit shadowed. The shadowed column is therefore a health measure:
a rule that decides no flow is either unnecessary or covered by a line ahead of it. The two are different
things, and they cannot be told apart by looking at the list text; what tells them apart is running it
against the flow set.

Read together, the two move-to-front moves show the lesson's measure. Moving the hundred sixty forward cut
two legitimate flows; moving the hundred seventy forward let one illegitimate flow through. Both were
single-line changes, both had a reasonable justification, and neither could be assessed by looking at the
list's text. The only way to make them measurable was to run a known flow set against both orders and
compare the results: the changed-flows column reads two and one, respectively. Every ordering change made
without this column is a change whose outcome is learned from the next incident report.

The last row gives the two conflicting pairs: a hundred with a hundred sixty, a hundred sixty with a
hundred seventy. In a ten-rule list, only these two pairs are order-sensitive, and the same line sits in
the middle of both. As the rule count grows, the pair count grows fast; the effect of order is measured
not by looking at lines one by one but by running a known flow set.

The topic's measure completes here. Every defense counted across seven lessons ended up looking at a
field: source zone, destination zone, service name, subject identity. All of these fields are claims, and
which of them could be proven was counted in the first lesson. The rule list uses those claims but does
not prove them; the value written in the source-zone field is not proof that the packet really came from
that zone, only proof that the direction it arrived from fits the expected block. The ordering discussion
is therefore one layer deeper: the list decides not only which rule wins but also which claim is never
tested at all. While a flow sits on the line numbered a hundred, the service axis is never read.

#### Summary

- As written, 15 of 16 flows resolve correctly; the one wrong closure is the quarter-close reconciliation
  that the Network Segmentation and the Demilitarized Zone lesson also cut, and here too no line looks out for it.
- The line written as "deny the management shell from everywhere" means, at runtime, "deny from everywhere
  outside the management zone": a line ahead of it decides 2 of the 4 shell flows.
- Moving that line to the top of the list is a correction in the text; at runtime it cuts 2 legitimate
  management flows and brings wrongly closed from 1 to 3.
- "Specific first, general last" ordering brings leaked from 0 to 1: because the deny line carries 2
  wildcards and the allow line 1, the allow line moves forward, and the flow from the remote-access zone
  to the management shell gets through.
- When the list is reversed, all 16 flows are denied and 9 rules sit shadowed. The rule text never changed
  in any order; the only thing that changed is order, and the order-sensitive pairs are 2 out of 10 rules.

#### Next Step

Across seven lessons the perimeter was drawn, zones were split apart, inspection points were placed, and
rules were put in order. Closed paths in the zone graph were counted, the zone a tunnel lands in was
measured, the first matching rule was found flow by flow. All of these boundaries shared one property, and
it was never written down anywhere: every one of them was the boundary of a **cable**. Belonging to a zone
meant sitting at the end of a cable connected to that zone's switch; the source-zone field in the rule list
rested on this same assumption. Signal does not stop at the building's wall. The next topic is `Wireless
Security`: how far an access point's coverage reaches beyond the building's boundary, which claim can be
verified over the air, and what belonging to a zone rests on when there is no cable.

## Wireless Security

How many of the claims still hold once the cable ends: the claim encryption generations prove and leave open, along with the devices left out in the transition, the difference between a single shared secret and a per-person identity in terms of trust root and revocation step, the claim a client cannot verify against an evil twin and the path that mutual authentication closes, the frame type that protected management frames close and the one they leave open, the area where the signal spills past the building boundary on a coverage map, and the item unauthorized device scanning finds and the one it does not.

### Wireless Encryption Generations

Source: https://academia.sh/en/courses/network-security/wireless-encryption-generations

Four wireless encryption generations are measured against the same set of ten claims: the old generation built on a stream cipher proves none of the ten claims, the generation using a block cipher in counter mode proves five, and the simultaneous-authentication generation raises that to seven. The cost of the transition is counted in the device inventory: when the top generation is made mandatory, four thousand three hundred twenty-one of four thousand five hundred forty devices are left out.

The previous topic drew the perimeter: zones were separated, checkpoints were placed, and
rules were put in order. All of those boundaries shared one common foundation — that a message
had entered through a specific cable. The wireless layer has no such foundation. A signal does
not stop at the building wall; whether the device receiving a frame is inside or outside the
building cannot be read from the frame itself.

So in this topic, encryption does the work the cable used to do. The difference between
generations is not one of speed or compatibility; it is a difference in **which claim can be
proven**. This lesson refers to the four generations not by generation name but by the
properties they carry, and runs all of them against the same set of ten claims: how many are
proven, how many stay open, and how many devices the upgrade costs.

- **WS1.** The fictional regional measurement network is treated on its wireless side: branch
  networks, the field teams' handheld terminals, and the aggregation network the meter
  endpoints connect to. No real network is set up and no real network name or location is
  written; what is measured is the model built with `node`.
- **WS2.** A claim is a proposition that one party asserts while a connection is being
  established and the other party accepts. It takes three values: proven, weakly proven
  (breakable with a finite amount of work), or unproven.
- **WS3.** The generations are reduced to four classes: the old generation, which mixes a
  stream cipher with a static key and a short counter; the transitional generation, which keeps
  the same stream cipher but derives a separate key for every frame; the generation that uses a
  block cipher in counter mode and verifies integrity with the same key; and the
  simultaneous-authentication generation, which derives a fresh key from the shared secret for
  every session.
- **WS4.** The device inventory is fictional; the top generation each class supports is a model
  decision.
- **WS5.** The offline guessing rate is a fictional model constant and varies with hardware; the
  comparison is made for the ratio between two generations, not for absolute time.
- **WS6.** The claims the generations carry are treated here only on the **verification** axis;
  the encryption algorithms themselves are the subject of M24/K04.

#### The Claims a Connection Carries

A device does not ask a single question when connecting to a wireless network. It accepts ten
separate propositions, and it accepts most of them without asking.

```js
// generations.mjs — claim table for wireless generations (model). Generations are referred to
// not by name but by the property they carry. Fictional regional measurement network; no real network is set up.
export const GENERATION = [
  ['stream-cipher-short-counter', 'generation that mixes a stream cipher with a static key and a short counter'],
  ['stream-cipher-per-frame-key', 'generation that keeps the same stream cipher but derives a separate key for every frame'],
  ['block-cipher-counter-mode', 'generation that uses a block cipher in counter mode and verifies integrity with the same key'],
  ['simultaneous-authentication', 'simultaneous-authentication generation that derives a fresh key from the shared secret for every session'],
];
// Claims carried while a connection is being established. Value: 2 = proven, 1 = weak proof
// (breakable with a finite amount of work), 0 = no proof at all.
export const CLAIM = [
  ['network name is correct',            [0, 0, 0, 0]],
  ['client knows the secret',            [1, 2, 2, 2]],
  ['access point knows the secret',      [0, 1, 2, 2]],
  ['frame content is unchanged',         [1, 1, 2, 2]],
  ['frame is not a replay',              [0, 2, 2, 2]],
  ['key is specific to this session',    [0, 1, 2, 2]],
  ['past traffic cannot be decrypted',   [0, 0, 0, 2]],
  ['management frame came from the network', [0, 0, 0, 1]],
  ['passphrase cannot be tried offline', [0, 0, 0, 2]],
  ['source address is correct',          [0, 0, 0, 0]],
];
// Device classes in the fictional measurement network: [class, count, top generation supported (index)]
export const DEVICE = [
  ['meter endpoint (old series)', 1840, 0],
  ['meter endpoint (new series)', 2260, 2],
  ['field handheld terminal', 145, 2],
  ['field handheld terminal (old)', 38, 1],
  ['branch laptop', 210, 3],
  ['barcode printer', 26, 1],
  ['panel display', 12, 2],
  ['maintenance laptop', 9, 3],
];
```

```js
// run1.mjs — how many claims each generation proves, how many stay open
import { GENERATION, CLAIM } from './generations.mjs';

const LEVEL = ['none', 'weak', 'proven'];
const E = [41, 8, 8, 8, 8];
const row = (h) => h.map((d, i) => String(d)[i ? 'padStart' : 'padEnd'](E[i])).join('');
console.log(row(['claim', 'gen1', 'gen2', 'gen3', 'gen4']));
for (const [name, d] of CLAIM) console.log(row([name, ...d.map((v) => LEVEL[v])]));
const count = (k, v) => CLAIM.filter(([, d]) => d[k] === v).length;
const S = [34, 12, 8, 12, 14];
const row2 = (h) => h.map((d, i) => String(d)[i ? 'padStart' : 'padEnd'](S[i])).join('');
console.log('\n' + row2(['generation', 'proven', 'weak', 'unproven', 'open claims']));
GENERATION.forEach(([name], k) =>
  console.log(row2([name, count(k, 2), count(k, 1), count(k, 0), count(k, 1) + count(k, 0)])));
```

```
claim                                        gen1    gen2    gen3    gen4
network name is correct                      none    none    none    none
client knows the secret                      weak  proven  proven  proven
access point knows the secret                none    weak  proven  proven
frame content is unchanged                   weak    weak  proven  proven
frame is not a replay                        none  proven  proven  proven
key is specific to this session              none    weak  proven  proven
past traffic cannot be decrypted             none    none    none  proven
management frame came from the network       none    none    none    weak
passphrase cannot be tried offline           none    none    none  proven
source address is correct                    none    none    none    none

generation                              proven    weak    unproven   open claims
stream-cipher-short-counter                  0       2           8            10
stream-cipher-per-frame-key                  2       3           5             8
block-cipher-counter-mode                    5       0           5             5
simultaneous-authentication                  7       1           2             3
```

The number in the first row is the starting point of this topic: the old generation proves
**none** of the ten claims. Two claims have weak proof, eight have none at all. The two weak
ones are weak for the same reason: because the counter that changes per frame is short, the same
value repeats once enough frames accumulate, and a repeated value makes both the content and the
integrity sum solvable. This generation's problem is not the absence of encryption but that the
proof is exhausted by a **finite amount of work**.

The transitional generation ties two claims to proof and leaves three weak. The item it actually
gains is replay protection: because a sequence counter is added, a previously seen frame being
replayed is rejected. But because the stream cipher stays in place, integrity and session
separation still rest on a derived mixing operation; there is proof, it is just not complete.

The third generation ties five claims to proof and **zeroes out** the weak column. This is the
most important thing to read in this table: weak proof disappears, but the number of unproven
claims stays at five — from five to five, no change at all. Strengthening the encryption does
not turn a claim that has **no** proof into a proven one.

#### Two Claims That Never Close

The fourth generation ties seven claims to proof, and two rows of the table stay empty **in all
four columns**: the correctness of the network name and the correctness of the source address.

These are not randomly chosen claims. The network name is an announcement the access point makes
about itself, and it is broadcast before a connection is established — that is, before any key
comes into play. Encryption begins after the connection; no generation signs the announcement
made before the connection. The source address is in the same place: the address field in the
frame header is not encrypted, because the frame has to be readable in order to reach the
correct recipient.

These two rows determine the rest of this topic. The third lesson will measure the network name
claim, the fourth lesson will measure the management frame claim — both are claims that stay
outside the encryption generation's scope, and upgrading the generation closes neither.

The two items the fourth generation gains, however, belong to a separate class. **Past traffic
cannot be decrypted** means that even if the shared secret is learned later, sessions recorded
earlier cannot be solved; in the other three generations this claim has no proof at all, meaning
traffic recorded today can be decrypted tomorrow with a password learned later. **The passphrase
cannot be tried offline** is a measurable item, and the run below turns it into a number.

#### The Cost of the Upgrade

Choosing a generation is not a preference, it is an inventory decision. The lowest mandatory
generation leaves out of the network every device that does not support it.

```js
// run2.mjs — cost of the generation upgrade: devices left out and resistance to offline guessing
import { GENERATION, CLAIM, DEVICE } from './generations.mjs';

const TOTAL = DEVICE.reduce((s, [, n]) => s + n, 0);
const open = (k) => CLAIM.filter(([, d]) => d[k] < 2).length;
const E = [34, 10, 12, 10, 13];
const row = (h) => h.map((d, i) => String(d)[i ? 'padStart' : 'padEnd'](E[i])).join('');
console.log(`devices ${TOTAL}, classes ${DEVICE.length}`);
console.log(row(['minimum mandatory generation', 'connected', 'left out', 'classes', 'open claims']));
GENERATION.forEach(([name], k) => {
  const out = DEVICE.filter(([, , m]) => m < k);
  console.log(row([name, TOTAL - out.reduce((s, [, n]) => s + n, 0),
    out.reduce((s, [, n]) => s + n, 0), out.length, open(k)]));
});
console.log('\nclasses left out (if the top generation is made mandatory):');
for (const [name, n, m] of DEVICE.filter(([, , m]) => m < GENERATION.length - 1))
  console.log(`  ${name.padEnd(32)}${String(n).padStart(6)}  top support: gen${m + 1}`);

// Offline guessing resistance. The rate is a fictional model constant; the real value depends on hardware.
// Generations 1-3: a candidate passphrase can be tried from a captured handshake without talking to the network.
// Generation 4: every attempt requires a round trip with the network; the access point's rate is the limit.
const OFFLINE = 400000, ONLINE = 3, YEAR = 365 * 24 * 3600;
const duration = (u, h) => u / h / YEAR;
const format = (y) => y < 1 ? `${(y * 365).toFixed(1)} days` :
  y < 1e6 ? `${y.toFixed(0)} years` : `${y.toExponential(1)} years`;
const P = [30, 14, 18, 21];
const row2 = (h) => h.map((d, i) => String(d)[i ? 'padStart' : 'padEnd'](P[i])).join('');
console.log('\n' + row2(['shared secret composition', 'candidate count', 'offline', 'network round trip']));
for (const [name, u] of [['8 lowercase letters', 26 ** 8], ['10 letters and digits', 36 ** 10],
  ['12 letters, digits, symbols', 72 ** 12], ['4 words (7776-word list)', 7776 ** 4]])
  console.log(row2([name, u.toExponential(1), format(duration(u, OFFLINE)), format(duration(u, ONLINE))]));
```

```
devices 4540, classes 8
minimum mandatory generation       connected    left out   classes  open claims
stream-cipher-short-counter             4540           0         0           10
stream-cipher-per-frame-key             2700        1840         1            8
block-cipher-counter-mode               2636        1904         3            5
simultaneous-authentication              219        4321         6            3

classes left out (if the top generation is made mandatory):
  meter endpoint (old series)       1840  top support: gen1
  meter endpoint (new series)       2260  top support: gen3
  field handheld terminal            145  top support: gen3
  field handheld terminal (old)       38  top support: gen2
  barcode printer                     26  top support: gen2
  panel display                       12  top support: gen3

shared secret composition     candidate count           offline   network round trip
8 lowercase letters                  2.1e+11          6.0 days           2207 years
10 letters and digits                3.7e+15         290 years         3.9e+7 years
12 letters, digits, symbols          1.9e+22      1.5e+9 years        2.1e+14 years
4 words (7776-word list)             3.7e+15         290 years         3.9e+7 years
```

Two columns of the first table decide this lesson's conclusion. When the third generation is
made mandatory, **one thousand nine hundred four** of four thousand five hundred forty devices
are left out, and open claims drop from ten to five. When the fourth generation is made
mandatory, open claims drop to three, but connectable devices fall to **two hundred nineteen**:
all of the meter endpoints, all of the field terminals, and the panel displays are left out.

The second column read on its own cannot decide anything. Moving to the top generation closes
two more open claims; in exchange, four thousand three hundred twenty-one devices do not work
unless replaced. This is not a network decision, it is a budget decision, and that is why real
organizations usually pick the third row: zeroing out weak proof in exchange for an acceptable
device loss.

The table has a silent assumption: every row assumes that a **single** generation is made
mandatory. The common workaround for avoiding the device loss is to leave the access point
accepting two generations at once. In that case no device is left out, but the number of open
claims is not the top generation's number but the **bottom generation's**: a client chooses for
itself which generation to connect with, and the lowest accepted generation is an accessible
option for every connection on the network. A network that accepts two generations together
measures not five but ten; the first row of the table is that network's row.

The measurement does not show a second thing: where the left-out devices go. In practice they
are not deleted, they are **moved to a separate network**. That network runs on the first or
second generation, meaning it carries its own column's open-claim count. The second network
opened for the one thousand eight hundred forty meter endpoints keeps a connection with ten open
claims inside the organization; the five claims gained are not gained where those devices are.
This is the correct reading: the upgrade does not reduce open claims, it **moves the open claim
to a different zone** — and how that zone is separated is the segmentation question from the
previous topic.

#### The Measure of Offline Guessing

The second table counts the fourth generation's most concrete item. In the first three
generations, if the messages exchanged while a connection is being established can be captured,
candidate shared secrets can be tried without ever talking to the network: the number of
attempts is limited not by the network but by the processing power of the party doing the
guessing. In the fourth generation, every attempt requires a round trip with the network, meaning
the limit is the rate the access point accepts.

The difference shows up in the numbers. An eight-lowercase-letter shared secret is exhausted
offline in **six days**; the same secret takes two thousand two hundred years when a network
round trip is required. The ratio between them is the ratio of the model constants: four hundred
thousand per second against three per second.

The bottom two rows deliver a second conclusion. A ten-character letter-and-digit composition
and a four-word phrase give the **same** candidate count — three point seven times ten to the
fifteenth — and both hold out offline for two hundred ninety years. The same resistance can be
reached in two different forms; which one to choose is not a security question but a question of
what can be typed into a device. The twelve-character wide composition already makes offline
guessing meaningless, but it is also the most expensive string to enter by hand into a meter
endpoint.

The rule that follows is this: in the first three generations, the length of the shared secret
is not a configuration preference, it is the **only line of defense**. In the fourth generation,
the same secret cannot be tried offline even if it is short; the real thing the generation gains
is that the protocol carries the weakness in the user's choice.

#### Summary

- A wireless connection carries ten claims. The old generation, which mixes a stream cipher
  with a short counter, proves **none** of them: two weak proofs, eight unproven claims.
- The generation that uses a block cipher in counter mode zeroes out the weak-proof column and
  ties five claims to proof; the number of unproven claims stays at five. Strengthening the
  encryption does not turn a claim with no proof into a proven one.
- The correctness of the network name and the source address stay unproven **in all four
  generations**; both are carried before a connection is established or openly in the header, so
  neither falls within any generation's scope.
- When the third generation is made mandatory, 1,904 of 4,540 devices are left out; when the
  fourth generation is made mandatory, 4,321 are. The left-out devices are not deleted, they are
  moved to a separate network — the open claim does not decrease, it changes location.
- An eight-lowercase-letter shared secret is exhausted offline in 6 days, and holds out for
  2,207 years when a network round trip is required. A four-word phrase and a ten-character
  letter-and-digit composition give the same candidate count.

#### Next Step

This lesson measured the length of the shared secret but never asked **who it belongs to**. All
four generations had a single string, and that string was identical across one thousand eight
hundred forty meter endpoints, two hundred ten branch laptops, and one hundred forty-five field
terminals. When a person leaves, a terminal is lost, or a meter endpoint is unmounted and taken,
that string is still valid, and changing it means touching every device separately. The next
lesson's question is where the trust root stands: how many items a single shared secret grants
access to, how many items a per-person identity separates, how many steps revoking an identity
takes, and what additional infrastructure items a per-person identity requires.

### Authentication Methods

Source: https://academia.sh/en/courses/network-security/authentication-methods

Personal and enterprise mode are separated in terms of trust root: a single shared secret in personal mode grants access to all twelve items on a network, and changing it means touching four thousand five hundred forty devices; per-person identity in enterprise mode brings that down to an average of two and a half items, and revocation is a single step. Enterprise mode ties six of seven claims to proof but adds one claim, and does not verify it under the default configuration.

The previous lesson measured the length of the shared secret and calculated its resistance to
offline guessing. **Who the secret belongs to** was never asked. All four generations had a
single string; that string was identical across meter endpoints, branch laptops, and field
terminals.

This distinction is what separates the two modes of wireless authentication from each other. In
**personal mode** the trust root is a single shared secret: whoever knows it is on the network.
In **enterprise mode** the trust root is a separate identity per subject, and a central
authentication server performs the verification. This lesson measures the two in three columns:
how many items are reached when a trust root is compromised, how many steps revocation takes,
and how many infrastructure items the separation costs.

- **WS7.** An item is a resource reachable once connected to the wireless network: a service, a
  repository, a management interface. Twelve items are defined in the fictional regional
  measurement network.
- **WS8.** In personal mode a network has a **single** shared secret. Every subject that enters
  that network reaches all of the network's items; separating items can only be done by
  splitting the network — that is, by opening a separate network and a separate secret.
- **WS9.** In enterprise mode, authorization is tied to identity; a subject reaches only the
  items legitimate to its role. The mapping of roles to items is a model decision.
- **WS10.** Manual configuration per device is four minutes, and annual staff turnover is
  fourteen percent; both are fictional model constants.
- **WS11.** The annual maintenance touch of an additional infrastructure item is a model value:
  how many times a year that item is manually replaced or renewed.
- **WS12.** The internal workings of the authentication server, token mechanics, and identity
  federation are outside this lesson; what is measured is only where the trust root stands.

#### The Item a Secret Opens

Personal mode's measure is a single number: how many items are reached when a secret is
compromised. The only way to shrink that number is to split the network, and every split means a
new secret.

```js
// mode.mjs — trust-root model of personal and enterprise mode. Fictional regional measurement
// network; no real network name, real location, or real identity is written.
export const ITEM = ['measurement-store', 'aggregator-ingress', 'work-order-service',
  'subscriber-registry', 'billing-db', 'configuration', 'management-interface', 'panel-feed',
  'printer-queue', 'branch-file-share', 'field-map', 'update-repository'];
// [role, subject count, device count, items legitimately reachable]
export const ROLE = [
  ['field-technician', 43, 183, ['work-order-service', 'field-map', 'subscriber-registry']],
  ['branch-staff', 210, 210, ['work-order-service', 'subscriber-registry', 'billing-db',
    'branch-file-share', 'printer-queue']],
  ['maintenance', 9, 9, ['configuration', 'management-interface', 'update-repository', 'measurement-store']],
  ['panel', 0, 12, ['panel-feed']],
  ['barcode-printer', 0, 26, ['printer-queue']],
  ['meter-endpoint', 0, 4100, ['aggregator-ingress']],
];
// In personal mode a network has one single shared secret; every subject that enters that
// network reaches all of the network's items. Role distribution per network is a design decision.
export const PLAN = [
  ['single-network', [['field-technician', 'branch-staff', 'maintenance', 'panel',
    'barcode-printer', 'meter-endpoint']]],
  ['three-networks', [['field-technician', 'branch-staff'], ['maintenance', 'panel',
    'barcode-printer'], ['meter-endpoint']]],
  ['network-per-role', ROLE.map(([r]) => [r])],
];
export const items = (roles) =>
  [...new Set(roles.flatMap((r) => ROLE.find(([a]) => a === r)[3]))];
export const devices = (roles) =>
  roles.reduce((s, r) => s + ROLE.find(([a]) => a === r)[2], 0);
// Enterprise mode's additional infrastructure items and each item's annual maintenance touch (model).
export const INFRASTRUCTURE = [
  ['identity authentication server', 2], ['server identity and trust root', 1],
  ['identity store connection', 2], ['client profile distribution', 6],
  ['access point shared secret', 4], ['identity revocation list', 12],
  ['backup authentication path', 2],
];
// Device classes that support enterprise mode (model decision).
export const ENTERPRISE_SUPPORT = ['field-technician', 'branch-staff', 'maintenance'];
```

```js
// run1.mjs — items reached when a secret leaks, and the step count for changing that secret
import { ITEM, ROLE, PLAN, items, devices } from './mode.mjs';

const DEVICE_TOTAL = ROLE.reduce((s, [, , n]) => s + n, 0);
const MANUAL = 4;                     // manual configuration minutes per device (model constant)
const E = [20, 8, 16, 14, 16, 14];
const row = (h) => h.map((d, i) => String(d)[i ? 'padStart' : 'padEnd'](E[i])).join('');
console.log(`item ${ITEM.length}, role ${ROLE.length}, device ${DEVICE_TOTAL}`);
console.log(row(['personal mode plan', 'secret', 'widest item', 'avg. item', 'most devices',
  'secret change']));
for (const [name, nets] of PLAN) {
  const item = nets.map((g) => items(g).length);
  const device = nets.map((g) => devices(g));
  console.log(row([name, nets.length, Math.max(...item),
    (item.reduce((a, b) => a + b, 0) / nets.length).toFixed(1), Math.max(...device),
    `${Math.max(...device) * MANUAL} min`]));
}
// Enterprise mode: identity is per subject, authorization is tied to identity.
const enterprise = ROLE.map(([r, , , k]) => k.length);
console.log(row(['enterprise mode', ROLE.reduce((s, [, o, a]) => s + (o || a), 0),
  Math.max(...enterprise), (enterprise.reduce((a, b) => a + b, 0) / ROLE.length).toFixed(1), 1,
  `${MANUAL} min`]));
```

```
item 12, role 6, device 4540
personal mode plan    secret     widest item     avg. item    most devices secret change
single-network             1              12          12.0            4540     18160 min
three-networks             3               6           4.3            4100     16400 min
network-per-role           6               5           2.5            4100     16400 min
enterprise mode         4400               5           2.5               1         4 min
```

The first row is personal mode in its plainest form: a single network, a single secret, twelve
items. A compromised secret opens the entire network; there is no separation between items,
because there is no identity to make that separation.

Splitting the network works. Moving to three networks brings the widest secret down from twelve
items to six, and the average down to four point three. Splitting per role brings the average
down to two point five — the **same** value as enterprise mode. This row is the lesson's first
surprise: enterprise mode is not required for item separation; splitting the network finely
enough gives the same separation.

The difference is in the last two columns. Network-per-role means six separate broadcasts, six
separate secrets, and six separate configurations at every access point; none of this shows up in
the item count. The real difference is the **secret change** column. In personal mode, when a
secret changes, every device on that network is touched: the most crowded network carries four
thousand one hundred devices, so that is sixteen thousand four hundred minutes, roughly two
hundred seventy-three hours. In enterprise mode what changes is a single identity, and the
number of devices touched is one.

The number four thousand four hundred, appearing in enterprise mode's column, is the cost
itself: the number of identities to manage climbs from one secret to four thousand four hundred.
Item separation is not free; the cost of the separation is the scale of identity management.

#### The Step Count of Revocation, and the Claim Added

The point where the two modes actually diverge is a subject's **departure**. The run below first
builds the claim table, then counts the cost of a departing subject.

```js
// run2.mjs — the two modes' claim table and the cost of enterprise mode
import { ROLE, INFRASTRUCTURE, ENTERPRISE_SUPPORT, items, devices } from './mode.mjs';

const LEVEL = ['none', 'weak', 'proven'];
const CLAIM = [
  ['connecting party knows the secret', [2, 2, 2]],
  ['connecting party is a specific subject', [0, 2, 2]],
  ['this identity is still valid', [0, 2, 2]],
  ["access is limited to the subject's items", [0, 2, 2]],
  ["other party is the organization's identity server", [1, 0, 2]],
  ['departed subject is automatically outside', [0, 2, 2]],
  ['session key is separate per subject', [0, 2, 2]],
];
const HEADER = ['personal', 'enterprise (server not verified)', 'enterprise (trust root fixed)'];
const E = [52, 10, 36, 32];
const row = (h) => h.map((d, i) => String(d)[i ? 'padStart' : 'padEnd'](E[i])).join('');
console.log(row(['claim', ...HEADER]));
for (const [name, d] of CLAIM) console.log(row([name, ...d.map((v) => LEVEL[v])]));
const count = (i, v) => CLAIM.filter(([, d]) => d[i] === v).length;
console.log(row(['proven / open', ...HEADER.map((_, i) =>
  `${count(i, 2)} / ${count(i, 1) + count(i, 0)}`)]));

// A departing subject: in personal mode the network secret changes, in enterprise mode a single identity changes.
const MANUAL = 4, TURNOVER = 0.14;    // minutes per device and annual staff turnover (model)
const SUBJECT = ROLE.reduce((s, [, o]) => s + o, 0);
const NETWORKS = [['field-technician', 'branch-staff'], ['maintenance', 'panel',
  'barcode-printer'], ['meter-endpoint']];
const group = NETWORKS.find((x) => x.includes('field-technician'));
const touched = devices(group);
console.log(`\nsubject ${SUBJECT}, annual departures ${Math.round(SUBJECT * TURNOVER)}`);
console.log(`personal: 1 secret changes -> ${touched} devices, ${touched * MANUAL} min, ` +
  `${items(group).length} items reopened`);
console.log('enterprise: 1 identity revoked -> 0 devices, 0 min, 0 items');
console.log(`annual: personal ${Math.round(SUBJECT * TURNOVER) * touched * MANUAL} min, enterprise 0 min`);

// Cost: additional infrastructure items and devices that cannot support enterprise mode.
const outside = ROLE.filter(([r]) => !ENTERPRISE_SUPPORT.includes(r));
console.log(`\nadditional infrastructure items ${INFRASTRUCTURE.length}, annual maintenance ` +
  `touches ${INFRASTRUCTURE.reduce((s, [, n]) => s + n, 0)}`);
for (const [name, n] of INFRASTRUCTURE) console.log(`  ${name.padEnd(38)}${String(n).padStart(3)}`);
console.log(`classes not supporting enterprise mode ${outside.length}, devices ` +
  `${outside.reduce((s, [, , n]) => s + n, 0)} -> stay in personal mode`);
```

```
claim                                                 personal    enterprise (server not verified)   enterprise (trust root fixed)
connecting party knows the secret                       proven                              proven                          proven
connecting party is a specific subject                    none                              proven                          proven
this identity is still valid                              none                              proven                          proven
access is limited to the subject's items                  none                              proven                          proven
other party is the organization's identity server         weak                                none                          proven
departed subject is automatically outside                 none                              proven                          proven
session key is separate per subject                       none                              proven                          proven
proven / open                                            1 / 6                               6 / 1                           7 / 0

subject 262, annual departures 37
personal: 1 secret changes -> 393 devices, 1572 min, 6 items reopened
enterprise: 1 identity revoked -> 0 devices, 0 min, 0 items
annual: personal 58164 min, enterprise 0 min

additional infrastructure items 7, annual maintenance touches 29
  identity authentication server          2
  server identity and trust root          1
  identity store connection               2
  client profile distribution             6
  access point shared secret              4
  identity revocation list               12
  backup authentication path              2
classes not supporting enterprise mode 3, devices 4138 -> stay in personal mode
```

Personal mode ties **one** of seven claims to proof: that the connecting party knows the secret.
Five of the remaining six have no counterpart at all, because the secret belongs to the network,
not to the person; the sixth has only a weak counterpart. The question "is this identity still
valid" has no answer at all in personal mode; there is validity of a secret, but no such concept
as validity of an identity.

The departure row turns this into money. Thirty-seven of two hundred sixty-two subjects depart
per year. In personal mode, every departing subject means that network's secret must change:
three hundred ninety-three devices on the network are touched, one thousand five hundred
seventy-two minutes are spent, and six items are reopened. The annual total is fifty-eight
thousand one hundred sixty-four minutes, roughly nine hundred seventy hours. In enterprise mode
the counterpart to that same number is zero.

In practice this number is not paid. Instead of spending one thousand five hundred seventy-two
minutes thirty-seven times over, the secret is **not changed**; the string the departing subject
knew stays on the network. This is personal mode's real weakness — not that the secret is
singular, but that the cost of revoking it is too large to be paid.

The model also leaves one source completely uncounted. A departing subject does not only carry
away the string in their memory; a copy of the secret also sits in the configuration of every
device it was left on. A lost field terminal, a meter endpoint that is unmounted and taken, or a
panel display that is discarded produces the same result, and none of it shows up in the staff
turnover number. In personal mode **device loss and staff departure are the same event**,
because both carry the same single trust root outward. In enterprise mode these are two separate
events, and both have the same remedy: revoking a single record.

#### The Claim Added and the Cost Paid

The middle column is this lesson's real finding. Enterprise mode ties six claims to proof but
**adds a new claim**: that the other party is the organization's authentication server. In
personal mode this claim is met only weakly — the shared secret is known by both parties, and
whoever knows the secret is treated as the network itself. In enterprise mode the client presents
its identity to a central server, and who that server is becomes a separate question.

If the client does not verify the server's identity and trust root, this claim has no proof at
all. The table states this plainly: that row is empty in the middle column. Enterprise mode,
when not configured correctly, loses even personal mode's one proof, and gains in exchange a
server whose identity must be presented. When the trust root is fixed, seven of seven claims are
tied to proof; when it is not fixed, the number of open claims drops to one, but the claim that
stays open is the **most expensive one**. The next lesson will measure what it means for that
claim to stay open.

The last section counts the cost. Enterprise mode brings seven additional infrastructure items,
and these items are touched by hand twenty-nine times a year. The most frequently touched item is
the identity revocation list; the most critical one is the trust root, because when it is
renewed, every client profile must be renewed with it.

The device side limits the decision. Three of the six roles support enterprise mode; the
remaining three classes, four thousand one hundred thirty-eight devices, must stay in personal
mode. Most of these are meter endpoints, and each connects to a single item, the aggregator
ingress. This means the move to enterprise mode is a partial move: roles with subjects go to
enterprise mode, subjectless devices go to personal-mode networks split per role. The measurement
supports this split — the average item count opened for all four thousand one hundred
thirty-eight devices is already one.

#### Summary

- In personal mode, a single network and a single secret open all twelve items; splitting the
  network per role brings the average down to 2.5 items, the same separation enterprise mode
  gives. Enterprise mode is not required for item separation.
- The separation is not free: in personal mode, changing a secret touches every device on that
  network (16,400 minutes on the most crowded network); in enterprise mode a single identity is
  revoked (0 minutes, 0 devices). The number of trust roots to manage climbs from 1 to 4,400.
- A departing subject requires touching 393 devices in personal mode; 37 annual departures cost
  58,164 minutes. Because this cost is not paid, the secret is not changed, and the string the
  departed subject knew stays on the network.
- Enterprise mode ties six of seven claims to proof but **adds** a claim: that the other party is
  the organization's authentication server. If the trust root is not fixed, that claim has no
  proof.
- The cost is seven additional infrastructure items and twenty-nine manual touches a year; three
  of six roles, 4,138 devices, cannot support enterprise mode and stay in personal mode.

#### Next Step

This lesson put the trust root in place and left one row of the table open: whether the client
verifies who the other party is. What it means for that row to stay empty is not a configuration
detail. When a client decides to connect to a network, all it has is the announcements that
network makes about itself — this is exactly the network name claim that stayed unproven in all
four generations in the first lesson's table. The next lesson's question is: what does a client
look at to accept a network's identity, how many of those signals carry proof, how many paths
does a saved network profile open by itself, and how many of those paths does mutual
authentication close.

### Rogue Access Points

Source: https://academia.sh/en/courses/network-security/rogue-access-points

Six of the eight signals a client looks at when choosing a network are seen before the connection is made, and none of them carry proof. Across eight registered profiles, four paths to the evil twin are open and three are partially open; trust-root pinning, deleting unused profiles, and turning off automatic connection bring the open path to zero, while the two partial paths close only with a generation mandate that leaves four thousand two hundred ninety-two devices out.

The previous lesson found the claim enterprise mode adds and left one row open in the table:
whether the client verifies who the other party is. The same row had a personal-mode
counterpart as well, and in the first lesson's table the network-name claim was unproven
across all four generations.

This gap is where the **evil twin** lives. Setting up a rogue access point means
running a device that announces the name the organization uses and appears with a stronger
signal. The lesson does not measure how this technique is set up, but **what the client looks
at to decide** and how many paths mutual authentication closes.

- **WS13.** A signal is data the client uses when choosing a network. It can have three
  sources: the other party's own announcement, the client's own measurement, or signed proof
  that can be tied to a trust root.
- **WS14.** A path is a registered client profile matching an evil twin. It has three
  outcomes: **open** (the client connects or presents its credentials), **partial** (the
  connection does not complete but handshake messages leak out), **closed** (the client rejects
  the other party).
- **WS15.** The evil twin announces the network's name, channel, and security
  indicator as they are, and appears with a stronger signal. It does not know the shared secret
  or the identity of the organization's authentication server.
- **WS16.** The profile list is fictional; how many devices each profile is registered on is a
  model value. The same device can carry more than one profile, so the total of
  profile-carrying devices exceeds the device count.
- **WS17.** Manual configuration per device is four minutes, and manual approval on an open
  network is twice daily; both are model constants.

#### What the Client Looks At

When a device chooses a network, what it has at hand can be counted item by item.

```js
// twin.mjs — the signals a client looks at when choosing a network, and its registered profiles (model).
// Fictional regional measurement network; real network name, real location, and real hardware address are not written.
// [signal, source, can the client verify it]
export const SIGNAL = [
  ['network name', 'the other party\'s own announcement', false],
  ['signal strength', 'the client\'s own measurement', false],
  ['channel', 'the other party\'s own announcement', false],
  ['hardware address', 'unencrypted header field', false],
  ['security indicator', 'the other party\'s own announcement', false],
  ['capability announcements', 'the other party\'s own announcement', false],
  ['identity presented by the authentication server', 'signed, can be tied to a trust root', true],
  ['verification derived from the shared secret', 'a secret value both parties know', true],
];
// Registered client profiles: [profile, mode, is server identity verified, automatic
//  connection, device count carrying this profile]
export const PROFILE = [
  ['branch-network', 'enterprise', true, true, 210],
  ['field-network', 'enterprise', false, true, 183],
  ['aggregator-network', 'personal', false, true, 4100],
  ['maintenance-network', 'personal', false, true, 9],
  ['guest-network', 'open', false, true, 222],
  ['old-field-network', 'personal', false, true, 183],
  ['coworking-space', 'open', false, true, 219],
  ['conference-hall', 'open', false, true, 47],
];
// The evil twin announces the same name, the same channel, and a stronger signal. Outcome:
// 'open' = the client connects or presents its credentials, 'partial' = the connection does not
// complete but handshake messages leak out, 'closed' = the client rejects the other party.
export function outcome([name, mode, verifies], d) {
  if (d.autoOff.has(name)) return 'closed';        // does not connect without user approval
  if (mode === 'open') return 'open';
  if (mode === 'enterprise') return (verifies || d.fixed) ? 'closed' : 'open';
  return d.gen4 ? 'closed' : 'partial';            // personal mode: shared secret verifies mutually
}
```

```js
// run1.mjs — what does the client look at to accept a network's identity
import { SIGNAL, PROFILE } from './twin.mjs';

const E = [52, 40, 12];
const row = (h) => h.map((d, i) => String(d)[i ? 'padStart' : 'padEnd'](E[i])).join('');
console.log(row(['signal', 'source', 'is proof']));
for (const [name, source, k] of SIGNAL) console.log(row([name, source, k ? 'yes' : 'no']));
const proof = SIGNAL.filter(([, , k]) => k).length;
console.log(`\nsignal seen before connecting ${SIGNAL.length - proof}, with proof 0`);
console.log(`signal carrying proof ${proof}; both appear only once the connection begins`);
const A = [22, 12, 22, 14, 10];
const row2 = (h) => h.map((d, i) => String(d)[i ? 'padStart' : 'padEnd'](A[i])).join('');
console.log('\n' + row2(['profile', 'mode', 'server verified', 'automatic', 'device']));
for (const [name, mode, d, o, n] of PROFILE)
  console.log(row2([name, mode, d ? 'yes' : 'no', o ? 'yes' : 'no', n]));
console.log(`\nprofile ${PROFILE.length}, device carrying a profile ` +
  `${PROFILE.reduce((s, p) => s + p[4], 0)}`);
```

```
signal                                                                                source    is proof
network name                                              the other party's own announcement          no
signal strength                                                 the client's own measurement          no
channel                                                   the other party's own announcement          no
hardware address                                                    unencrypted header field          no
security indicator                                        the other party's own announcement          no
capability announcements                                  the other party's own announcement          no
identity presented by the authentication server          signed, can be tied to a trust root         yes
verification derived from the shared secret                 a secret value both parties know         yes

signal seen before connecting 6, with proof 0
signal carrying proof 2; both appear only once the connection begins

profile                       mode       server verified     automatic    device
branch-network          enterprise                   yes           yes       210
field-network           enterprise                    no           yes       183
aggregator-network        personal                    no           yes      4100
maintenance-network       personal                    no           yes         9
guest-network                 open                    no           yes       222
old-field-network         personal                    no           yes       183
coworking-space               open                    no           yes       219
conference-hall               open                    no           yes        47

profile 8, device carrying a profile 5173
```

The second column of the first table is the whole point of this lesson. Of the six signals
the client sees **before** connecting, four are the other party's own announcement about
itself, one is the client's own measurement, and one is an unencrypted header field. None of
them carry proof.

Signal strength occupies a special place, because it is the only "own measurement" row, and
that does not make it trustworthy. The client generally chooses **the strongest signal** among
candidates announcing the same name. This is not a flaw but a design decision — it is required
so that a moving device can hand off between access points. But as a selection criterion,
signal strength is a value the other party can raise with a single hardware setting; being its
own measurement does not make it proof.

There are two signals that carry proof, and both appear only **after** the connection is made.
Verification derived from the shared secret comes into play in personal mode, and signed
identity in enterprise mode. There is no proof available at the moment of selection; the
selection is always made with unproven signals, and proof is only requested after the client
starts talking with the other party.

The second table shows this gap's counterpart inside the organization. There are eight
registered profiles, and **automatic connection is on for all of them**. Two are not the
organization's networks: the coworking-space and conference-hall profiles are open networks
employees connected to outside, and they have stayed in the device's profile list. One more is
an organization network that is no longer in use.

The figure of five thousand one hundred seventy-three in the last line is not a device count
but a **profile-carrying device** count: a branch laptop keeps the branch network, the guest
network, and two external networks registered at the same time. The unit of measure is
therefore not the device but the profile. How many paths a device is open to is determined not
by how many networks it is connected to, but by how many networks it has ever connected to; the
profile list is the one item that keeps growing until it is deleted.

#### The Path Mutual Authentication Closes

Each of the eight profiles is a path. The run below stacks defenses on top of each other and
prints the count of open, partial, and closed paths at every step.

```js
// run2.mjs — how many paths does mutual authentication close, how many stay open, at what cost
import { PROFILE, outcome } from './twin.mjs';

const EMPTY = new Set();
const UNUSED = new Set(['old-field-network', 'conference-hall']);
const OPEN_NETWORKS = new Set(PROFILE.filter((p) => p[1] === 'open').map((p) => p[0]));
const ALL = new Set([...UNUSED, ...OPEN_NETWORKS]);
const DEFENSE = [
  ['baseline (none)', { fixed: false, gen4: false, autoOff: EMPTY }],
  ['trust root is pinned', { fixed: true, gen4: false, autoOff: EMPTY }],
  ['unused profile is deleted', { fixed: true, gen4: false,
    autoOff: UNUSED }],
  ['automatic connection off on open networks', { fixed: true, gen4: false,
    autoOff: ALL }],
  ['simultaneous authentication generation', { fixed: true, gen4: true,
    autoOff: ALL }],
];
const E = [45, 7, 9, 9, 13, 16];
const row = (h) => h.map((d, i) => String(d)[i ? 'padStart' : 'padEnd'](E[i])).join('');
console.log(row(['defense (stacked)', 'open', 'partial', 'closed', 'open device',
  'partial device']));
const results = DEFENSE.map(([name, d]) => {
  const r = PROFILE.map((p) => [p, outcome(p, d)]);
  const n = (t) => r.filter(([, s]) => s === t).length;
  const a = (t) => r.filter(([, s]) => s === t).reduce((s, [p]) => s + p[4], 0);
  console.log(row([name, n('open'), n('partial'), n('closed'), a('open'), a('partial')]));
  return r;
});
const P = [22, 10, 10, 10];
const row2 = (h) => h.map((d, i) => String(d)[i ? 'padStart' : 'padEnd'](P[i])).join('');
console.log('\n' + row2(['profile', 'baseline', 'pinning', 'all']));
PROFILE.forEach((p, i) => console.log(row2([p[0], results[0][i][1], results[1][i][1],
  results.at(-1)[i][1]])));

// Cost: the devices each defense touches and the legitimate work it interrupts (model constants).
const MANUAL = 4, APPROVAL = 2;             // minutes per device, daily manual approvals
const enterprise = PROFILE.filter((p) => p[1] === 'enterprise').reduce((s, p) => s + p[4], 0);
const deleted = PROFILE.filter((p) => UNUSED.has(p[0])).reduce((s, p) => s + p[4], 0);
const openDevice = PROFILE.filter((p) => OPEN_NETWORKS.has(p[0])).reduce((s, p) => s + p[4], 0);
const personal = PROFILE.filter((p) => p[1] === 'personal').reduce((s, p) => s + p[4], 0);
console.log(`\ncost:`);
console.log(`  trust root pinning     ${enterprise} devices with profiles, ${enterprise * MANUAL} min`);
console.log(`  profile deletion       ${deleted} devices touched, ${deleted * MANUAL} min`);
console.log(`  automatic connection   ${openDevice} devices, ${openDevice * APPROVAL} manual approvals per day`);
console.log(`  top-generation mandate ${personal} devices left out`);
```

```
defense (stacked)                               open  partial   closed  open device  partial device
baseline (none)                                    4        3        1          671            4292
trust root is pinned                               3        3        2          488            4292
unused profile is deleted                          2        2        4          441            4109
automatic connection off on open networks          0        2        6            0            4109
simultaneous authentication generation             0        0        8            0               0

profile                 baseline   pinning       all
branch-network            closed    closed    closed
field-network               open    closed    closed
aggregator-network       partial   partial    closed
maintenance-network      partial   partial    closed
guest-network               open      open    closed
old-field-network        partial   partial    closed
coworking-space             open      open    closed
conference-hall             open      open    closed

cost:
  trust root pinning     393 devices with profiles, 1572 min
  profile deletion       230 devices touched, 920 min
  automatic connection   488 devices, 976 manual approvals per day
  top-generation mandate 4292 devices left out
```

At baseline, only **one** of the eight paths is closed: the enterprise profile with a pinned
trust root. Four paths are open, and three are partially open. This is the opposite of the
answer one would give without reading the table — most of the organization's networks are
encrypted, but encryption does not close the path to an evil twin.

The partial result shows exactly what personal mode does. Because the shared secret is known
by both parties, an access point that does not know the secret cannot complete the connection;
the client rejects it. But the client only realizes this after it has **started** the
handshake, and those messages have already gone out. For the three generations that lesson
one's table found no proof of offline attempts against, this means a recorded handshake exists
even if the connection is never established. Personal mode closes the path, not the
trace.

The pinning row closes one path: the enterprise profile that does not verify. The affected
device count is one hundred eighty-three, and this is the counterpart of the one row the
second lesson left open. A profile that has moved to enterprise mode but does not verify server
identity is **worse off** than personal mode: where personal mode's result is partial, here it
is open, because the client presents its identity to the other party.

Deleting unused profiles closes two more paths, and this has nothing to do with defense
technology. The old-field-network and conference-hall profiles kept the names of networks that
no longer exist in the client; the name of a nonexistent network means the network belongs to
whoever announces that name. These two rows are the **cheapest** defense in the whole table:
nine hundred twenty minutes of cleanup, no new infrastructure item, and no legitimate
connection is cut, because there is no legitimate connection left to cut. The path it closes
being cheap makes it the most frequently skipped defense — an item invisible in inventory does
not make it onto the maintenance list either.

#### The Path That Drops to Zero, and the Cost Paid

The fourth row drops the open path to zero. Turning off automatic connection on open networks
affects four hundred eighty-eight devices and adds nine hundred seventy-six manual approvals
per day. What closes the path is not verification but shifting the decision to the user; this
is the row where the defense's cost is most visible, because the cost is paid every day.

The two partial paths close only on the last row, at a cost of four thousand two hundred
ninety-two devices. The first lesson's inventory table says where this number comes from:
nearly all of the devices remaining in personal mode do not support the top generation. The
last row is therefore measurable but not selectable; the two partial paths stay
open in practice.

The total cost adds up across four items: a one-time configuration of two thousand four hundred
ninety-two minutes, nine hundred seventy-six manual approvals per day, and a renewal affecting
four thousand two hundred ninety-two devices. The first three defenses are one-time and cheap;
the fourth is continuous; the fifth is a budget decision.

There is something the model does not see. The table counts the client matching a
**registered profile**; it does not count connecting to an unregistered name at all. When a
user manually selects a network, the pinned trust root still works, but when they select an
open network, no defense is active. The eight paths dropping to zero means the registered
profiles on this network drop to zero; it does not mean the paths the client could choose drop
to zero.

#### Summary

- **None** of the six signals the client sees before connecting carry proof: four are the
  other party's announcement, one is its own measurement, one is an unencrypted header field.
  The two signals that do carry proof appear only once the connection is being made.
- Of the eight registered profiles, only one is closed at baseline; four are open, three are
  partially open. The encryption generation alone does not close the path to an evil
  twin.
- Personal mode does not let the connection complete, but handshake messages leak out; a
  profile that has moved to enterprise mode but does not verify server identity is worse off
  than personal mode, because it presents its identity to the other party.
- Trust-root pinning, deleting unused profiles, and turning off automatic connection on open
  networks bring the open path from 4 to 0; the cost is 2,492 minutes of configuration and 976
  manual approvals per day.
- The two partial paths close only when the top generation is made mandatory, at a cost of
  4,292 devices; it is therefore measurable but not a practical option.

#### Next Step

This lesson's whole measurement rested on one assumption: that the client establishes its
connection **on its own**. Once six of the eight profiles are closed, the client stays on the
network it is already connected to and never sees the rogue candidate. But a client staying
connected also requires the messages that keep that connection alive to be verified. Data
frames are encrypted and carry integrity proof; the frames that establish, sustain, and
**terminate** the connection are a separate class. This is the next lesson's question: which
frame type in a wireless connection carries integrity proof, which does not, how many frame
types gain proof once protection is turned on, and in which defense counter the deviation shows
up.

### Deauthentication Attacks

Source: https://academia.sh/en/courses/network-security/deauthentication-attacks

Of twelve frame types, only one carries integrity proof at baseline; protected management frames bring this count to four and tie three of eight management frames to proof, leaving five unprotected because they are pre-connection. Three of the eight mechanisms that disconnect a connection close; the record the defense sees moves from the disconnect counter to the rejected-frame counter, and the catch rate rises from three sixths to six sixths.

The previous lesson counted the unproven signals a client looks at when choosing a network
and measured how many profiles mutual authentication closes. The whole measurement rested on
one assumption: that the client establishes its connection on its own. A client that is
already connected never sees a rogue candidate.

A client **staying** connected, however, is a separate verification question. Data frames are
encrypted and carry integrity proof; the frames that establish, sustain, and terminate the
connection are a separate class. **Deauthentication** is the name for this distinction: a
management frame that terminates the connection is accepted without any proof of the claim it
carries. This lesson does not measure how that technique is carried out, but which frame is
protected, how many paths protection closes, and what the defense sees in which counter.

- **WS18.** Frame types fall into three classes: data, control, management. The
  classification and whether each type carries integrity proof is a model decision.
- **WS19.** A pre-connection frame is one exchanged between the parties before they share a
  common key. Because there is no shared key, integrity proof cannot be produced.
- **WS20.** A path is a mechanism that causes a client's connection to drop. A path closing
  means the claim that mechanism carries becomes verifiable.
- **WS21.** The log data is fictional and produced by a hand-written generator; the seed is
  visible and the run is reproducible. The expected normal disconnects per hour is a model
  value.
- **WS22.** The threshold is a multiple of the average and standard deviation drawn from past
  days for the same access point and the same hour. The event day is excluded from the
  baseline calculation.
- **WS23.** The attack steps, tooling, and frame generation are outside this lesson; what is
  measured is the scope of protection and what the defense's record shows.

#### Which Frame Is Protected

A wireless connection does not carry only data. Three classes of frame travel over the same
medium, and their encryption coverage is not the same.

```js
// frame.mjs — wireless frame types and protected management frames (model).
// Attack steps are not written; what is measured is which frame carries integrity proof.
// [frame type, class, is it pre-connection, proof at baseline, proof with protection on]
export const FRAME = [
  ['data frame', 'data', false, true, true],
  ['acknowledgment', 'control', false, false, false],
  ['request to send / clear to send', 'control', false, false, false],
  ['power-save poll', 'control', false, false, false],
  ['beacon frame', 'management', true, false, false],
  ['probe request', 'management', true, false, false],
  ['probe response', 'management', true, false, false],
  ['authentication frame', 'management', true, false, false],
  ['association request / response', 'management', true, false, false],
  ['disassociation', 'management', false, false, true],
  ['deauthentication', 'management', false, false, true],
  ['action frame (channel, measurement)', 'management', false, false, true],
];
// Mechanisms that cause a connection to drop (model). The 'closing defense' field says
// which defense closes the path; 'none' = no defense.
export const PATH = [
  ['forged disassociation frame', 'management frame', 'protected management frames'],
  ['forged deauthentication frame', 'management frame', 'protected management frames'],
  ['forged action frame (channel change)', 'management frame', 'protected management frames'],
  ['impersonating a pre-connection probe response', 'management frame', 'none'],
  ['making the medium appear busy with a control frame', 'control frame', 'none'],
  ['jamming at the physical layer', 'physical', 'none'],
  ['the authentication server being unreachable', 'infrastructure', 'local authentication cache'],
  ['a client that does not support protection connecting', 'client', 'protection is made mandatory'],
];
```

```js
// run1.mjs — which frame is protected, how many paths close with protection on
import { FRAME, PATH } from './frame.mjs';

const E = [38, 13, 18, 12, 14];
const row = (h) => h.map((d, i) => String(d)[i ? 'padStart' : 'padEnd'](E[i])).join('');
const mark = (b) => b ? 'present' : 'absent';
console.log(row(['frame type', 'class', 'pre-connection', 'baseline', 'protection on']));
for (const [name, s, o, a, b] of FRAME) console.log(row([name, s, o ? 'yes' : 'no', mark(a), mark(b)]));
const count = (i) => FRAME.filter((c) => c[i]).length;
console.log(`\nframe type ${FRAME.length}; carrying integrity proof: baseline ${count(3)}, ` +
  `protection on ${count(4)}`);
const management = FRAME.filter((c) => c[1] === 'management');
console.log(`management frame ${management.length}; protected ${management.filter((c) => c[4]).length}, ` +
  `unprotected ${management.filter((c) => !c[4]).length} (all pre-connection)`);

const A = [50, 22, 32];
const row2 = (h) => h.map((d, i) => String(d)[i ? 'padStart' : 'padEnd'](A[i])).join('');
console.log('\n' + row2(['mechanism causing the disconnection', 'class', 'closing defense']));
for (const [name, s, k] of PATH) console.log(row2([name, s, k]));
const protectionCloses = PATH.filter((y) => y[2] === 'protected management frames').length;
console.log(`\npath ${PATH.length}; protected management frames close ${protectionCloses}, ` +
  `${PATH.length - protectionCloses} stay open`);
console.log(`of those left open, ${PATH.filter((y) => y[2] === 'none').length} are closed by no ` +
  `verification at all (not a claim problem)`);
```

```
frame type                                    class    pre-connection    baseline protection on
data frame                                     data                no     present       present
acknowledgment                              control                no      absent        absent
request to send / clear to send             control                no      absent        absent
power-save poll                             control                no      absent        absent
beacon frame                             management               yes      absent        absent
probe request                            management               yes      absent        absent
probe response                           management               yes      absent        absent
authentication frame                     management               yes      absent        absent
association request / response           management               yes      absent        absent
disassociation                           management                no      absent       present
deauthentication                         management                no      absent       present
action frame (channel, measurement)      management                no      absent       present

frame type 12; carrying integrity proof: baseline 1, protection on 4
management frame 8; protected 3, unprotected 5 (all pre-connection)

mechanism causing the disconnection                                class                 closing defense
forged disassociation frame                             management frame     protected management frames
forged deauthentication frame                           management frame     protected management frames
forged action frame (channel change)                    management frame     protected management frames
impersonating a pre-connection probe response           management frame                            none
making the medium appear busy with a control frame         control frame                            none
jamming at the physical layer                                   physical                            none
the authentication server being unreachable               infrastructure      local authentication cache
a client that does not support protection connecting                client    protection is made mandatory

path 8; protected management frames close 3, 5 stay open
of those left open, 3 are closed by no verification at all (not a claim problem)
```

At baseline, **one** of the twelve frame types carries integrity proof. Encryption's coverage
is the data frame; the remaining eleven types travel in the open. This row is the entire basis
for deauthentication — the frame that terminates the connection carries no proof at all of the
"this connection is ending" claim it makes, and the client accepts it.

When protection is turned on, the count rises from one to four. The three types gained are
management frames sent **after** the connection is established: disassociation,
deauthentication, and the action frame. What the three have in common is that a shared key
exists between the parties at that point; that is the condition for proof to be produced.

The five management frames that stay unprotected also share what is read in the same column:
all of them are **pre-connection**. The beacon frame, the probe request and response,
authentication, and the association request are exchanged while there is no shared key yet.
The previous lesson's twin-access-point path lives exactly in these five rows; protected
management frames do not close that path, and cannot.

Control frames are a third set. Acknowledgment and request-to-send frames never carry proof
under any circumstance, because the timing of medium sharing depends on these frames being
small and fast. There is also no content in them worth protecting; the only claim they carry
is "I will use the medium for this long" or "received." Adding proof would enlarge these
frames and slow down the medium sharing itself — their staying outside protection's scope is
not a gap, it is a measured trade-off.

The second table translates this into a path count. Of the eight mechanisms, **three** close,
and five stay open. Three of the five that stay open are closed by no verification at all, and
the reason matters: jamming at the physical layer is not a claim problem. There is no claim
accepted without proof there; the medium is unusable, and verification does not bring
the medium back. This course's measure reaches its limit here.

The remaining two paths fall to other items of defense. The authentication server being
unreachable is met with a local authentication cache; the problem of a client that does not
support protection is closed by making protection **mandatory**, and its cost is the devices
left out in the previous lessons' inventory table. When protection is left optional, a client
can choose to connect without it, and for that client the three paths reopen.

#### The Counter the Defense Sees

When protection is not on, the disassociation frame is treated as valid, and the only trace it
leaves is clients reconnecting. The run below extracts, from thirty days of fictional log
data, what threshold selection actually buys.

```js
// run2.mjs — what the defense sees in the record: the deviation in the disconnect counter and threshold selection.
// The generator is hand-written, the seed is visible; the run is reproducible.
const SEED = 20240917;
let s = SEED;
const rand = () => (s = (s * 1103515245 + 12345) % 2147483648) / 2147483648;
const poisson = (l) => { let k = 0, p = 1, L = Math.exp(-l); do { k++; p *= rand(); } while (p > L); return k - 1; };

const DAY = 30, HOUR = 24, AP = 6, EVENT_DAY = 17, EVENT_AP = 3;
const EVENT_HOURS = [7, 10, 13, 16, 19, 22];
// Expected normal disconnects per hour (model): higher during work hours, low at night.
const BASELINE = Array.from({ length: HOUR }, (_, h) => (h >= 8 && h < 18) ? 6 : 1.2);
const disconnects = [], rejected = [];
for (let g = 0; g < DAY; g++) {
  disconnects.push([]); rejected.push([]);
  for (let a = 0; a < AP; a++) {
    disconnects[g].push([]); rejected[g].push([]);
    for (let h = 0; h < HOUR; h++) {
      const event = g === EVENT_DAY && a === EVENT_AP && EVENT_HOURS.includes(h);
      disconnects[g][a].push(poisson(BASELINE[h]) + (event ? poisson(7) : 0));
      // While protection is on, the same frames are rejected at the integrity check and counted separately.
      rejected[g][a].push(event ? poisson(7) : 0);
    }
  }
}
// The baseline is derived per hour and access point from the days outside the event day.
const historyDays = [...Array(DAY).keys()].filter((g) => g !== EVENT_DAY);
const avg = (v) => v.reduce((a, b) => a + b, 0) / v.length;
const stdev = (v) => Math.sqrt(avg(v.map((x) => (x - avg(v)) ** 2)));
const E = [10, 14, 16, 18, 16];
const row = (h) => h.map((d, i) => String(d)[i ? 'padStart' : 'padEnd'](E[i])).join('');
console.log(`day ${DAY}, access point ${AP}, hour ${DAY * AP * HOUR}, event hour ${EVENT_HOURS.length}`);
console.log(row(['threshold', 'caught', 'missed', 'false alarm', 'false/day']));
for (const k of [2, 3, 4, 5, 6]) {
  let caught = 0, falseAlarm = 0;
  for (let g = 0; g < DAY; g++) for (let a = 0; a < AP; a++) for (let h = 0; h < HOUR; h++) {
    const hist = historyDays.map((x) => disconnects[x][a][h]);
    const threshold = avg(hist) + k * stdev(hist);
    if (disconnects[g][a][h] <= threshold) continue;
    if (g === EVENT_DAY && a === EVENT_AP && EVENT_HOURS.includes(h)) caught++; else falseAlarm++;
  }
  console.log(row([k, `${caught}/${EVENT_HOURS.length}`, EVENT_HOURS.length - caught, falseAlarm,
    (falseAlarm / DAY).toFixed(2)]));
}
// While protection is on: the same frames do not disconnect the client, they accumulate in the rejected-frame counter.
const totalRejected = rejected.flat(2).reduce((a, b) => a + b, 0);
const rejectedHours = rejected.flat(2).filter((x) => x > 0).length;
console.log(`\nprotection on: rejected management frame ${totalRejected}, deviating hour ${rejectedHours}`);
console.log(`because this counter's baseline is 0, the threshold is 1: caught ${rejectedHours}/${EVENT_HOURS.length}, false alarm 0`);
const eventDisconnects = EVENT_HOURS.reduce((t, h) => t + disconnects[EVENT_DAY][EVENT_AP][h], 0);
const normalDisconnects = EVENT_HOURS.reduce((t, h) => t + avg(historyDays.map((x) => disconnects[x][EVENT_AP][h])), 0);
console.log(`disconnects during event hours ${eventDisconnects}, the same hours' normal ` +
  `${normalDisconnects.toFixed(1)}, factor ${(eventDisconnects / normalDisconnects).toFixed(1)}`);
```

```
day 30, access point 6, hour 4320, event hour 6
threshold         caught          missed       false alarm       false/day
2                    4/6               2               167            5.57
3                    4/6               2                20            0.67
4                    3/6               3                 2            0.07
5                    1/6               5                 0            0.00
6                    0/6               6                 0            0.00

protection on: rejected management frame 42, deviating hour 6
because this counter's baseline is 0, the threshold is 1: caught 6/6, false alarm 0
disconnects during event hours 58, the same hours' normal 23.8, factor 2.4
```

The disconnect counter is a weak indicator, and the table shows why. During event hours, the
counter rises to **two point four times** the normal; that is a deviation hard to distinguish
within a record of four thousand three hundred twenty hours. With threshold two, four of the
six event hours are caught, but thirty days produce one hundred sixty-seven false alarms: five
and a half alarms a day, none of them real. Raising the threshold to four brings false alarms
down to two, while the catch rate drops to three. At threshold six, false alarms are zero, and
so is the catch rate.

None of the five rows is a good option. The counter's baseline is not zero, it is noise that
varies with the hour of day, and the event itself is spread inside that noise. For a counter to
produce a usable indicator, a large deviation is not enough; **its baseline has to be small**.

The last three lines give protection's second gain, and it is a gain the path table does not
show. While protection is on, the same frames do not disconnect the client; they fail the
integrity check and are rejected. The rejected management frame is a separate counter, and
this counter's normal value is **zero**. Forty-two frames accumulate over six hours, the
threshold is taken as one, and all six of the six event hours are caught with zero false
alarms.

The three paths protection closes are therefore not the whole measurement. Protection does not
only make the frame verifiable; it also gives the defense **a counter whose baseline is zero**.
The byproduct of every claim that becomes verifiable is that the case where verification fails
becomes countable.

#### Summary

- Of the twelve frame types, only **one** carries integrity proof at baseline: the data frame.
  Protected management frames bring this count to four.
- Three of the eight management frames are protected, five are not, and all five unprotected
  ones are **pre-connection** frames: proof cannot be produced without a shared key. The
  twin-access-point path lives in these five rows, and protection does not close it.
- Of the eight mechanisms causing a connection to drop, three close, and five stay open. Three
  of the ones that stay open cannot be closed by any verification; jamming at the physical
  layer is not a claim problem.
- In the disconnect counter, the event is 2.4 times the normal. At threshold 2, 4/6 is caught
  but at a cost of 5.57 false alarms a day; at threshold 4, the catch rate is 3/6; at
  threshold 6, it is 0/6. No threshold is a good option.
- While protection is on, the same frames fall into the rejected-management-frame counter;
  because that counter's baseline is zero, threshold 1 gives a 6/6 catch rate with 0 false
  alarms. Protection's second gain is a counter with a zero baseline.

#### Next Step

This lesson separated out three paths that protection cannot close, and all three had one
thing in common: none of them is about verifying a claim. Jamming at the physical layer,
making the medium appear busy, and impersonating pre-connection frames can all be done
**anywhere the signal reaches**. That is why wireless defense's last question is not a
protocol question but a geography question: how far the signal travels. The next lesson
actually calculates a coverage map — how many square meters of area the signal spills outside
the building's boundary, and which items an unauthorized-device scan finds in that area, and
which it does not. The same lesson also gathers the course's twenty lessons into a single
table.

### Wireless Network Auditing

Source: https://academia.sh/en/courses/network-security/wireless-network-auditing

A fictional branch building's coverage map is calculated on a one-meter grid: the portion of the connectable area that spills outside the building is one thousand eight hundred thirty-eight square meters, the area where frames can only be heard is twenty-nine thousand six hundred twenty-two square meters, twelve times the inside area. Lowering transmit power zeroes the outside connectable area but does not close the audible area; an unauthorized-device scan finds three of six out-of-inventory sources in two hours, one of which is not found even with continuous monitoring.

The previous lesson separated out three paths that protection cannot close, and what the three
had in common was that none of them was about verifying a claim. Jamming, making the medium
appear busy, and impersonating pre-connection frames can all be done **anywhere the signal
reaches**. Every defense in this course assumed a boundary; in the wireless layer, that boundary
is not a rule, it is a surface.

This lesson calculates that surface. First it measures how far the signal spills outside the
building wall, then it counts what an unauthorized-device scan finds and does not find on that
surface. **Scanning and listening outside one's own network, without written authorization and a
defined scope, is both illegal and unprofessional**; all the measurements below are done as a
model, over a fictional inventory.

- **WS24.** The fictional branch building is sixty-two by thirty-eight meters and carries four
  access points. Positions are a model decision; no real building, real location, or real
  measurement is written.
- **WS25.** The path-loss calculation is real; the exponents, the outside-wall loss, and the
  thresholds are model values and would be derived by measurement in a real building. For points
  outside the building, a single wall crossing is assumed. The grid has a one-meter step and
  includes the building area's edges.
- **WS26.** Two thresholds are separated: the power at which a connection can be established, and
  the power at which a signal can only be heard. The second is the level at which frames can be
  received but a connection cannot be established.
- **WS27.** In the scanning model, a source is found only if it is on a scanned channel, the
  listening window is longer than the source's transmission interval, and it is on during the
  scan.

#### The Physical Surface on the Coverage Map

Coverage is not a management preference, it is a path-loss calculation. The building and its
surroundings are divided into a one-meter grid, the power received from the strongest access
point is calculated in each cell, and classified against the two thresholds.

```js
// coverage.mjs — a fictional branch building's coverage map (model). No real location or real
// measurement is written; the path-loss calculation is real, the environment constants are model values.
const BUILDING = { x1: 62, y1: 38 }, AP = [[15, 10], [47, 10], [15, 28], [47, 28]];
const IN_EXP = 3.5, OUT_EXP = 2.6, WALL = 8;    // path-loss exponent and outside-wall loss (dB)
const FSPL1 = 20 * Math.log10((4 * Math.PI * 5.2e9) / 2.998e8);   // 1 m reference loss
const CONNECT = -70, HEAR = -85, LAPTOP = 210;     // dBm thresholds and laptops inside the building
const inside = (x, y) => x >= 0 && x <= BUILDING.x1 && y >= 0 && y <= BUILDING.y1;
const received = (x, y, eirp) => Math.max(...AP.map(([ax, ay]) => {
  const d = Math.max(1, Math.hypot(x - ax, y - ay));
  return eirp - (inside(x, y) ? FSPL1 + 10 * IN_EXP * Math.log10(d)
    : FSPL1 + 10 * OUT_EXP * Math.log10(d) + WALL);
}));
function coverageMap(eirp) {                    // grid: each cell counts as 1 square meter
  const h = { inside: 0, covered: 0, outConnect: 0, outHear: 0, farthest: 0 };
  for (let x = -120; x <= 182; x++) for (let y = -120; y <= 158; y++) {
    const g = received(x, y, eirp);
    if (inside(x, y)) { h.inside++; if (g > CONNECT) h.covered++; continue; }
    if (g > CONNECT) h.outConnect++;
    if (g > HEAR) { h.outHear++; h.farthest = Math.max(h.farthest, -x, x - BUILDING.x1, -y, y - BUILDING.y1); }
  }
  return h;
}
const E = [11, 13, 12, 16, 14, 11, 15];
const row = (a) => a.map((d, i) => String(d)[i ? 'padStart' : 'padEnd'](E[i])).join('');
console.log(`building ${BUILDING.x1} x ${BUILDING.y1} m, access point ${AP.length}, 1 m loss ` +
  `${FSPL1.toFixed(1)} dB, threshold ${CONNECT} / ${HEAR} dBm`);
console.log(row(['transmit', 'in covered', 'in gap', 'out connectable', 'out audible',
  'farthest m', 'devices cut']));
for (const eirp of [20, 17, 14, 11, 8]) {
  const h = coverageMap(eirp);
  console.log(row([`${eirp} dBm`, h.covered, h.inside - h.covered, h.outConnect, h.outHear, h.farthest,
    Math.round((LAPTOP * (h.inside - h.covered)) / h.inside)]));
}
const t = coverageMap(20);
console.log(`\nout audible area is ${(t.outHear / t.inside).toFixed(1)} times the inside area; at ` +
  `8 dBm out connectable 0, out audible still ${coverageMap(8).outHear} m2`);
```

```
building 62 x 38 m, access point 4, 1 m loss 46.8 dB, threshold -70 / -85 dBm
transmit      in covered      in gap out connectable   out audible farthest m    devices cut
20 dBm              2416          41            1838         29622         75              4
17 dBm              1998         459             612         18148         55             39
14 dBm              1554         903             124         11046         40             77
11 dBm              1158        1299               0          6558         28            111
8 dBm                740        1717               0          3702         19            147

out audible area is 12.1 times the inside area; at 8 dBm out connectable 0, out audible still 3702 m2
```

The first row gives this lesson's central number. At twenty dBm transmit power, two thousand
four hundred sixteen square meters are covered inside the building, leaving a gap of forty-one
square meters. At the same time, **outside the building**, a connection can be established over
an area of one thousand eight hundred thirty-eight square meters, and frames can be heard over an
area of twenty-nine thousand six hundred twenty-two square meters. The second number is **twelve
times** the inside area and extends seventy-five meters past the building wall.

No rule can be written at any point in this zone. All the paths measured by the third and fourth
lessons live here: an evil twin, or a source impersonating pre-connection frames, can be
positioned anywhere within the twenty-nine-thousand-square-meter area.

Lowering transmit power is the first defense that comes to mind, and the table counts its cost.
At seventeen dBm, the outside usable area drops to six hundred twelve; in exchange, the inside
coverage gap rises from forty-one to four hundred fifty-nine square meters, and thirty-nine
laptops are left outside. At eleven dBm, the outside usable area reaches **zero**, the inside gap
is one thousand two hundred ninety-nine square meters, and the number of affected devices is one
hundred eleven.

The last row gives the real conclusion. Even though the outside usable area is zero at eight dBm,
the outside audible area is still three thousand seven hundred two square meters. Since transmit
power cannot be reduced to zero, this number cannot be zeroed either: **the connectable surface
can be closed, the audible surface cannot be closed.** The physical boundary is not a defense; at
most, it is a narrowing.

Lowering power also has a side effect that does not appear in the table: since a client picks the
strongest-signaled candidate among those announcing the same name, weakening one's own access
points **makes it easier** for an evil twin to win that comparison.

#### What the Scan Finds and Does Not Find

The second measurement asks who is present on the same surface. A scan listens over a list of
channels and compares the sources it hears against the inventory.

```js
// scan.mjs — what an unauthorized-device scan finds and does not find (model). The scan runs
// only over a fictional inventory; no operation targets a real network.
// [name, channel, broadcast interval (s), always on?, in inventory?]
const SOURCE = [
  ['meter endpoint', 6, 900, true, true], ['field terminal', 36, 5, true, true],
  ['branch laptop', 44, 2, true, true], ['panel display', 36, 30, true, true],
  ['barcode printer', 6, 600, true, true], ['maintenance laptop', 149, 2, true, true],
  ['personal access point', 11, 0.1, true, false],
  ['non-broadcasting access point', 157, 300, true, false],
  ['wireless bridge', 165, 1800, true, false],
  ['temporary evil twin', 44, 0.1, false, false],
  ['old-generation test access point', 1, 0.1, true, false],
  ['access point on an unscanned band', 100, 0.1, true, false],
];
const CHANNEL = [1, 6, 11, 36, 40, 44, 48, 149, 153, 157, 161, 165];
const PLAN = [['8 channels, 20 minutes', 8, 1200], ['12 channels, 20 minutes', 12, 1200],
  ['12 channels, 2 hours', 12, 7200], ['12 channels, continuous', 12, 86400]];
const seen = ([, channel, interval, always], [, channelCount, duration]) =>
  !CHANNEL.slice(0, channelCount).includes(channel) ? 'out of channel range'
    : duration / channelCount < interval ? 'listening window too short'
      : (!always && duration < 86400) ? 'off during the scan' : 'found';
const inInventory = SOURCE.filter((k) => k[4]), outInventory = SOURCE.filter((k) => !k[4]);
const E = [25, 14, 17, 18, 18];
const row = (a) => a.map((d, i) => String(d)[i ? 'padStart' : 'padEnd'](E[i])).join('');
console.log(`source ${SOURCE.length} (in inventory ${inInventory.length}, out of inventory ` +
  `${outInventory.length}), scannable channels ${CHANNEL.length}`);
console.log(row(['scan plan', 'channel/time', 'inventory item', 'out of inventory',
  'per channel (s)']));
for (const p of PLAN) {
  const b = (l) => l.filter((k) => seen(k, p) === 'found').length;
  console.log(row([p[0], `${p[1]}/${p[2]}s`, `${b(inInventory)}/${inInventory.length}`,
    `${b(outInventory)}/${outInventory.length}`, (p[2] / p[1]).toFixed(0)]));
}
const A = [35, 14, 28, 28];
const row2 = (a) => a.map((d, i) => String(d)[i ? 'padStart' : 'padEnd'](A[i])).join('');
console.log('\n' + row2(['source', 'in inventory', '2-hour scan', 'continuous monitoring']));
for (const k of SOURCE)
  console.log(row2([k[0], k[4] ? 'yes' : 'no', seen(k, PLAN[2]), seen(k, PLAN[3])]));
```

```
source 12 (in inventory 6, out of inventory 6), scannable channels 12
scan plan                  channel/time   inventory item  out of inventory   per channel (s)
8 channels, 20 minutes          8/1200s              4/6               2/6               150
12 channels, 20 minutes        12/1200s              4/6               2/6               100
12 channels, 2 hours           12/7200s              5/6               3/6               600
12 channels, continuous       12/86400s              6/6               5/6              7200

source                               in inventory                 2-hour scan       continuous monitoring
meter endpoint                                yes  listening window too short                       found
field terminal                                yes                       found                       found
branch laptop                                 yes                       found                       found
panel display                                 yes                       found                       found
barcode printer                               yes                       found                       found
maintenance laptop                            yes                       found                       found
personal access point                          no                       found                       found
non-broadcasting access point                  no                       found                       found
wireless bridge                                no  listening window too short                       found
temporary evil twin                            no         off during the scan                       found
old-generation test access point               no                       found                       found
access point on an unscanned band              no        out of channel range        out of channel range
```

The first table shows what scan duration buys. A twenty-minute scan, whether it covers eight
channels or twelve, finds four of the six inventory items and two of the six out-of-inventory
sources: increasing the channel count alone gains nothing, because with the total duration fixed,
the per-channel listening window **drops** from one hundred fifty seconds to one hundred. In two
hours, the numbers rise to five and three. What matters is the listening window: a scan that
listens for six hundred seconds per channel cannot hear a source that speaks once every nine
hundred seconds.

The first row of the second table is this lesson's real finding. **The organization's own four
thousand one hundred meter endpoints do not show up in the two-hour scan** — not an unauthorized
device, but the most populous item in the inventory. A scan reporting that it "matches the
inventory" does not mean the inventory has been verified; it only means the items the scan heard
match.

For two of the reasons a source is not found, the solution is time: continuous monitoring catches
the meter endpoint, the wireless bridge, and the temporary evil twin. The third is
different — a source that stays **out of channel range** is not found even with continuous
monitoring, because the scan's scope is the channel list, not the duration.

The same boundary ties back to the map in the first section: a scan only covers where the scanner
can hear. The two measurements give the same surface — the area where the signal spills out is
both the vulnerable zone and the scan's blind spot.

#### Summary

- On the coverage map, a connection can be established over 1,838 square meters outside the
  building, and frames can be heard over 29,622 square meters: the second figure is **12.1
  times** the inside area and reaches 75 meters past the wall.
- Lowering transmit power zeroes the outside usable area at 11 dBm; the cost is a 1,299-square-
  meter coverage gap and 111 devices. The outside **audible** area never reaches zero at any
  power level: 3,702 square meters even at 8 dBm.
- A twenty-minute scan finds 4 of the 6 inventory items; increasing the channel count alone gains
  nothing, since it shortens the per-channel listening window, and the organization's own 4,100
  meter endpoints do not show up in a two-hour scan.
- 3 of the 6 out-of-inventory sources are found in two hours, 5 in continuous monitoring; a
  source operating on an unscanned band is not found under any plan.

#### Course Wrap-Up

| Lesson | Unverified claim | What the defense verifies | Cost of verification |
|---|---|---|---|
| Layer models and attack surface | 18 claims, 0 proof | 5 layers 11 claims | 4 extra round trips, 25 manual items |
| Ports and services | 49 acceptance points, 21 unproven | narrowing brings it down to 27 points | 4 periodic flows are cut |
| Address resolution attacks | 6 poisoned records among 26 responses | three defenses rescue 6 records | 16 extra round trips, 4 rejected responses |
| Name resolution attacks | 7 poisoned names, 38 services | signed verification 3 names, 14 services | strict mode stops 47 services |
| Address assignment risks | 70 claims across 14 endpoints, 35 forged | pool and static separation 12 endpoints | 3 manual items, 2 endpoints wrong |
| Denial of service attacks | 4,096 sockets fill in 1.02 seconds | handshake proof zeroes the socket | 15% of legitimate traffic is eliminated |
| On-path attacks | 16 of 36 steps read the content | endpoint verification and root pinning 16 steps | 84-item list, 7 cut flows |
| Replay and spoofing attacks | 5 of 10 types change state via replay | three proofs 3 full, 5 partial types | 503 rejected messages, 224 state items |
| Firewall generations | 40 of 48 claims unproven | the application-aware generation 45 claims | 94,112 state records, 6 resolvers |
| Network segmentation and the DMZ | 210 transitive items | segmentation brings it down to 25 items | 15 rule lines, 2 cut flows |
| Proxy and jump servers | 99.5% of connections unrecorded | subject identity on 3,118 connections | 210,502 connections in a single outage |
| Intrusion detection and prevention | 3 of 14 classes invisible from the network | two sensors 6 of 7 classes | prevention cuts 5,214 legitimate events |
| Honeypots and sinkholes | 29.8% false positive in the signature scheme | deception 594 alerts, 1.2% wrong | 22,690 connections unrecorded at the sinkhole |
| Virtual private networks | the remote endpoint directly accesses 136 items | a separate zone brings it down to 16 items | 8 hours of proof, 97,100 unaudited connections |
| Access control lists | 2 order-sensitive pairs among 10 rules | 15 of 16 flows in the order written | in reverse order 16 flows are rejected |
| Wireless encryption generations | 10 claims, 0 proof in the old generation | the simultaneous-authentication generation 7 claims | 4,321 devices are left out |
| Authentication methods | 6 of 7 claims unproven | enterprise mode and a fixed root 7 claims | 7 infrastructure items, 4,138 devices |
| Rogue access points | 6 signals 0 proof, 4 open paths | three defenses zero out the open path | 976 manual approvals a day |
| Deauthentication | 11 of 12 frame types unproven | protection 3 frames, 3 paths | 5 paths open, protection made mandatory |
| Wireless network auditing | outside audible area is 12.1 times the inside area | at 11 dBm outside usable area 0 | 1,299 m² gap, 111 devices |

The second column writes this course's rule: **network security is not a checklist of boxes, it
is how many of the claims a message carries can be proven; every claim that cannot be proven is a
path.** No defense was measured as "installed and done"; each one was run against a set of
claims.

The third column gathers into four groups. **Extra round trips and key items** are the cost
verification writes into the protocol; **legitimate work cut off** is the cost written into the
work itself; **devices left out** is the cost written into the inventory, and in the wireless
lessons it is measured in the thousands; **manually managed items** are both a maintenance load
and a count of places that can be misconfigured.

In none of the twenty lessons did the number of open claims fall to zero, and the claims that
remained were of the same kind: who a subject is, whether a frame was sent before the connection
was established, whether a signal can be heard outside the wall. Underneath this entire
measurement, there is also an assumption that was never asked. Across the twenty lessons, calling
a claim "verifiable" meant that a proof could be tested: a signature held up, a shared secret was
known, an identity was tied to a trust root. What that proof **rested on** was never asked.
Where did the key that verifies the signature come from, who holds the shared secret, why is the
trust root trusted? **M24/K04 Identity, Access and Cryptography** begins with exactly this
question: what does a claim's provability rest on, who holds the key, and where does trust begin?

