Lesson 08 / 16
Sender Verification Records
Running the sender policy record and the signature record against a fictional set of messages: the counts of forged messages caught, forged messages that pass, and legitimate messages mistakenly dropped, and the surface opened by overly broad authorization.
Published Updated
In this lesson
- Distinguishes which separate question the sender policy record and the signature record each answer
- Runs both checks against a fictional set of messages to derive the counts of messages caught, passed, and mistakenly dropped
- Counts the extra address surface opened by authorizing a network block in the policy record
Contents
The previous lesson measured where the name points. The same name does a second job: the
regional measurement network sends notification emails to field teams, and those messages
carry measurenet.invalid. The network is fictional; a collector that gathers meter
readings, a validator, and billing and work-order services all produce messages every night.
By default, nothing says those messages actually came from that name. The from field is just text written inside the message; writing it does not require owning the name. When nothing is done after setup, the open item is single and large: sending a message in the name’s name is open to anyone, and the more familiar the name, the more the forgery works. When a billing notification is forged, the field team cannot tell the difference.
Two records close this gap, and they answer separate questions. The sender policy record is the list of machines authorized to send messages in this name’s name; its question is “which machine did this message come from.” The signature record publishes a public key; the sending machine signs the message with the matching private key, and the receiving side verifies the signature with the key in the record. Its question is “did this content come from whoever holds the key, and did it change on the way.” Neither substitutes for the other, and as shown below, neither is sufficient alone.
Two Checks, One Run
The program below actually runs both checks. Signatures are generated and verified with
node:crypto; keys are created at run time, and no key body is ever written into the text.
DR7. The message set is 16 fictional messages: 10 legit notifications, 6 forged. The
kind field exists only for counting; the checker never sees that field.
DR8. The signature is taken over the from–subject–body triple; a real mail message’s
header set is not modeled.
DR9. The receiving side runs both checks and sees their results; what it does with the
results is not this lesson’s subject.
import { generateKeyPairSync, sign, verify } from 'node:crypto'; // Model: sender verification. Keys are generated at run time; no key body // is ever written down or printed. The message set and records are fictional. const KEYS = { k1: generateKeyPairSync('ed25519'), // the measurement network's own key k2: generateKeyPairSync('ed25519'), // the third-party sender's key }; const FORGED_KEY = generateKeyPairSync('ed25519'); // the attacker's key // The two records in the zone. The sender policy record is the list of // authorized senders; the signature record carries one public key per tag. const POLICY_RECORD = { singles: ['198.51.100.11', '198.51.100.12', '198.51.100.13', '198.51.100.14', '198.51.100.20'], networkBlocks: [{ prefix: '203.0.113.', size: 256 }], // the third-party sender's whole egress network }; const SIGNATURE_RECORD = { k1: KEYS.k1.publicKey, k2: KEYS.k2.publicKey }; const serialize = (m) => `${m.from}|${m.subject}|${m.body}`; function signMessage(m, tag, privateKey) { m.tag = tag; m.signature = sign(null, Buffer.from(serialize(m)), privateKey); return m; } function policyCheck(source) { if (POLICY_RECORD.singles.includes(source)) return true; return POLICY_RECORD.networkBlocks.some((b) => source.startsWith(b.prefix)); } function signatureCheck(m) { if (!m.signature || !SIGNATURE_RECORD[m.tag]) return false; return verify(null, Buffer.from(serialize(m)), SIGNATURE_RECORD[m.tag], m.signature); } // 16 fictional messages: 10 legit notifications, 6 forged. const MESSAGES = [ { id: 'm01', source: '198.51.100.11', from: '[email protected]', subject: 'meter batch', body: 'nightly job done', kind: 'legit', key: 'k1' }, { id: 'm02', source: '198.51.100.12', from: '[email protected]', subject: 'deviation alert', body: 'zone 4 deviation', kind: 'legit', key: 'k1' }, { id: 'm03', source: '198.51.100.13', from: '[email protected]', subject: 'period close', body: 'period 7 closed', kind: 'legit', key: 'k1' }, { id: 'm04', source: '198.51.100.14', from: '[email protected]', subject: 'field work order', body: '5 orders for team 2', kind: 'legit', key: 'k1' }, { id: 'm05', source: '198.51.100.20', from: '[email protected]', subject: 'weekly summary', body: 'summary attached', kind: 'legit', key: 'k1' }, { id: 'm06', source: '203.0.113.40', from: '[email protected]', subject: 'bulk announcement', body: 'maintenance window', kind: 'legit', key: 'k2' }, { id: 'm07', source: '203.0.113.41', from: '[email protected]', subject: 'bulk announcement', body: 'maintenance window', kind: 'legit', key: 'k2' }, { id: 'm08', source: '198.51.100.13', from: '[email protected]', subject: 'nightly report', body: 'batch job output', kind: 'legit', key: null }, { id: 'm09', source: '192.0.2.77', from: '[email protected]', subject: 'threshold exceeded', body: 'new alert service', kind: 'legit', key: 'k1' }, { id: 'm10', source: '198.51.100.30', from: '[email protected]', subject: 'field work order', body: '2 orders for team 3', kind: 'legit', key: 'k1', tampered: ' -- forwarded' }, { id: 'f01', source: '203.0.113.200', from: '[email protected]', subject: 'payment request', body: 'account changed', kind: 'forged', key: null }, { id: 'f02', source: '192.0.2.200', from: '[email protected]', subject: 'urgent work order', body: 'unplug the meter', kind: 'forged', key: null }, { id: 'f03', source: '192.0.2.201', from: '[email protected]', subject: 'password renewal', body: 'follow the link', kind: 'forged', key: 'forged' }, { id: 'f04', source: '203.0.113.201', from: '[email protected]', subject: 'period close', body: 'new account number', kind: 'forged', key: 'forged' }, { id: 'f05', source: '198.51.100.13', from: '[email protected]', subject: 'period close', body: 'compromised machine', kind: 'forged', key: 'k1' }, { id: 'f06', source: '192.0.2.202', from: '[email protected]', subject: 'meter batch', body: 'nightly job done', kind: 'forged', key: 'k1', tampered: ' extra line' }, ]; for (const m of MESSAGES) { if (m.key === 'forged') signMessage(m, 'k1', FORGED_KEY.privateKey); else if (m.key) signMessage(m, m.key, KEYS[m.key].privateKey); if (m.tampered) m.body += m.tampered; // body changes after signing } const evaluate = () => MESSAGES.map((m) => ({ id: m.id, kind: m.kind, policy: policyCheck(m.source), signature: signatureCheck(m), })); const RULES = { 'policy only': (s) => s.policy, 'signature only': (s) => s.signature, either: (s) => s.policy || s.signature, both: (s) => s.policy && s.signature, }; const legit = MESSAGES.filter((m) => m.kind === 'legit').length; const forged = MESSAGES.length - legit; function table(label) { const results = evaluate(); console.log(`\n[${label}] rule forged caught forged passed legit dropped`); for (const [name, rule] of Object.entries(RULES)) { const passed = results.filter((s) => s.kind === 'forged' && rule(s)).length; const dropped = results.filter((s) => s.kind === 'legit' && !rule(s)).length; console.log(' '.padEnd(label.length + 5) + name.padEnd(18) + `${forged - passed}/${forged}`.padEnd(17) + String(passed).padEnd(15) + `${dropped}/${legit}`); } } console.log('message kind policy signature'); for (const s of evaluate()) { console.log(`${s.id.padEnd(9)} ${s.kind.padEnd(8)} ${(s.policy ? 'passed' : 'dropped').padEnd(9)} ${s.signature ? 'passed' : 'dropped'}`); } console.log(`\nlegit messages: ${legit} forged messages: ${forged}`); table('wide'); const broadAuthorized = POLICY_RECORD.singles.length + POLICY_RECORD.networkBlocks.reduce((a, b) => a + b.size, 0); const used = [...new Set(MESSAGES.filter((m) => m.kind === 'legit' && policyCheck(m.source)) .map((m) => m.source))]; console.log('\nauthorized addresses:', broadAuthorized, '| actually sending:', used.length, '| authorized but unused:', broadAuthorized - used.length); POLICY_RECORD.networkBlocks = []; // network block removed POLICY_RECORD.singles = used; // only the used addresses remain table('narrow');
message kind policy signature
m01 legit passed passed
m02 legit passed passed
m03 legit passed passed
m04 legit passed passed
m05 legit passed passed
m06 legit passed passed
m07 legit passed passed
m08 legit passed dropped
m09 legit dropped passed
m10 legit dropped dropped
f01 forged passed dropped
f02 forged dropped dropped
f03 forged dropped dropped
f04 forged passed dropped
f05 forged passed passed
f06 forged dropped dropped
legit messages: 10 forged messages: 6
[wide] rule forged caught forged passed legit dropped
policy only 3/6 3 2/10
signature only 5/6 1 2/10
either 3/6 3 1/10
both 5/6 1 3/10
authorized addresses: 261 | actually sending: 7 | authorized but unused: 254
[narrow] rule forged caught forged passed legit dropped
policy only 5/6 1 2/10
signature only 5/6 1 2/10
either 5/6 1 1/10
both 5/6 1 3/10
Reading the Numbers
No row catches all six of the six forgeries, and no row passes every legitimate message. The decision is made between two columns.
Policy only misses three forgeries. The reason shows up in the column: f01 and f04
came from inside the authorized network block, and f05 came directly from the legitimate
machine itself. The policy record asks about the address, not the content — it passes
anything sent from an authorized address.
What this check asks about determines how much it closes. The policy record tests the address of the connecting machine, not the from field written inside the message. There is no mandatory link between the two, and because the check only sees the address, a message sent from an authorized address passes no matter what name it carries inside. What the record closes is sending a message in this name’s name from anywhere on that network — not a small thing, but it says nothing about the content.
Signature only catches five forgeries, and the only one it misses is f05. In exchange it
drops two legitimate messages: m08 is not signed (it does not go through the nightly batch
job’s signing step), and m10’s body changed after signing — an intermediate server appended
a line while forwarding the message. The signature asks about content, not source.
Combining the two is a third decision. The loose combination (accept if either passes) brings the dropped-legitimate count down to 1 but keeps the passed-forged count at 3; the strict combination (both must pass) brings the passed-forged count down to 1, at the cost of dropping three legitimate messages. The same two records give a threefold difference in outcome depending on the combination rule. Who declares this rule, and how, is the next lesson’s subject.
The Record’s Drift from Reality
The previous lesson counted the divergence between the zone file and the live inventory. The same drift exists here too, and it moves faster, because the set of senders grows independently of the set of records.
In the model, legitimate messages come from nine separate addresses, and the policy record
recognizes seven of them. The two it does not recognize speak for themselves: m09 is a
newly set-up threshold-alert service that started sending messages but was never added to the
record; m10 comes from a different machine as a result of a redirect. Adding to the record
is not part of any setup step — a service gets written, deployed, runs, sends messages, and no
one looks at the zone. The drift is one-directional and silent: a missing record does not
produce an error, it just makes a message lose one check.
Measuring this drift means comparing two lists: the addresses authorized in the record and the addresses that actually sent messages in a recent period. Both directions of the difference are items. An address that is in the record but does not send is unnecessary surface; an address that sends but is not in the record is a legitimate message that is a candidate for being dropped. Where these two numbers get read from is the reason the next lesson’s report flow exists; for now, both are visible only in this run, not in production.
Overly Broad Authorization
Authorizing a network block instead of a single address in the policy record is a common shortcut; it is done so the record does not break when the third-party sender changes its egress address. The cost shows up in the numbers:
DR10. The network block in the policy record carries 256 addresses; the third-party sender uses two of them.
The record authorizes 261 addresses, and the number actually sending is 7. The 254
addresses in between are ones no one uses but that are authorized to send messages in the
measurement network’s name. These may be allocated to the third-party sender’s other
customers; when one of those customers is compromised, the authority to send messages in
measurenet.invalid’s name changes hands along with it. That is exactly what f01 and f04
are in the run.
When the block is narrowed, the numbers settle into place: policy only goes from 3/6 to 5/6, passed-forged drops from 3 to 1, and dropped-legitimate does not increase at all. This is a step that closes an open item without stopping any legitimate work, and it is the only example of that in this topic. In exchange, a maintenance burden is created: if the record is not updated when the third-party sender changes address, legitimate announcements get dropped, and that drop is silent.
The second layer of authorization must be counted separately too. The third-party sender is
authorized not only by the address block but also by the second tag in the signature record:
messages m06 and m07 are signed with its key and pass verification. That means this party
is a second party able to produce a valid signature in the measurement network’s name. Even
if the address block is narrowed, this authority stands; it is only revoked by removing the
tag from the record. The two layers are two separate items, and unless both are counted
separately, the sentence “sender verification is set up” carries no number.
The Item That Cannot Be Closed, and the New Surface
f05 is caught by no rule.
DR11. A compromised legitimate machine also has access to the signing key sitting on that machine.
This item cannot be closed, because both records ask whether the machine is authorized, not
whose hands it is currently in. The outcome even runs backward: a message that passes
verification looks more trustworthy on the receiving side than one that does not. f05
is not just uncaught — because it passes both checks, it is the most convincing of the forged
messages. Setting up sender verification raises the value of this one message; in exchange
for the five items it closes, the one item left behind gets heavier. Sender verification
prevents the name from being impersonated; it does not prevent the name from being abused
from its own machine. The measure of that is not in this topic but in hardening the machine.
m10 is also among the items that cannot be closed: when a message is forwarded, the source
address changes and the body can change. Both checks treat this like an attack. No one can
manage forwarding from the zone file.
DR12. While the key is being rotated, the old tag stays in the record for a while longer; during that time, signatures produced with either key still verify.
Closing this item has its own surface, and it sits where the key lives. The signature record publishes a public key, but the matching private key sits on a machine and is a secret there. Because the record carries a key per tag, more than one tag can be published at once — a new tag is added while the key is rotated, and the old tag stays for a while. During that time, two keys are valid at once, and whoever compromises the old key still produces a valid signature. Key rotation is a window opened in exchange for an item closed; the length of that window depends on the time to live measured in the previous lesson.
Summary
- By default, sending a message in a name’s name does not require owning that name; the open item after setup is single, and it grows with how recognizable the name is.
- The sender policy record answers “which machine,” and the signature record answers “did it come from the key holder and did it change”; neither substitutes for the other.
- In the 16-message fictional set, policy only catches 3/6 forgeries and signature only catches 5/6; the signature drops two legitimate messages (an unsigned batch job and a notification whose body changed while being forwarded).
- The combination rule determines the outcome: the loose rule brings dropped-legitimate down to 1 but passes 3 forgeries, the strict rule passes 1 forgery but drops 3 legitimate messages.
- The network block in the policy record authorizes 261 addresses, and only 7 actually send; the 254 extra authorized addresses are a surface, and it closes without any legitimate-message loss when the block is narrowed.
- A message sent from a compromised legitimate machine is caught by no rule; key rotation, meanwhile, opens a window in which two keys are valid at once.
Next Step
The two checks now produce one outcome: two bits per message. The real gap this lesson leaves is that nothing says what to do with those two bits. Whether the receiving side delivers, quarantines, or rejects a message that fails a check is currently the receiver’s decision, not the name owner’s, and receivers behave differently. The next lesson covers declaring this decision from the zone: the policy declaration is tightened in stages — report only, quarantine, reject — and at each stage the count of dropped legitimate messages and blocked forged messages is taken separately. The report flow itself is also a measurement tool: how many unauthorized senders get found through it.
To keep your progress and take notes, Log in
My notes
Log in to take notes.