First public release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.npmrc
|
||||||
|
dist-cjs/
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
stages: [test]
|
||||||
|
|
||||||
|
test:
|
||||||
|
stage: test
|
||||||
|
image: node:22-alpine
|
||||||
|
script: [npm ci, npm run build, npm test]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.0 - 2026-09-28
|
||||||
|
|
||||||
|
First public release under MIT.
|
||||||
|
|
||||||
|
- `keyVerdict()` checks a signing key at both origin and arrival;
|
||||||
|
`expired-in-flight` is its own verdict, accepted and recorded.
|
||||||
|
- Revocation applies regardless of origin time.
|
||||||
|
- `assessStream()` returns `apply` in sender-sequence order, never arrival
|
||||||
|
order.
|
||||||
|
- A duplicate fails when the receiver is not idempotent and is accepted when
|
||||||
|
it is.
|
||||||
|
- A message beyond `maxTransitSeconds` is rejected for action.
|
||||||
|
- A message received before it was sent is NOT ASSESSED.
|
||||||
|
- `now` is never read: the same trace gives the same verdicts.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Issues and merge requests are welcome at
|
||||||
|
https://gitlab.com/extant2000/slow-lane.
|
||||||
|
|
||||||
|
## Ground rules
|
||||||
|
|
||||||
|
- A test that cannot fail proves nothing. If you fix a bug, add a test and
|
||||||
|
check that it fails against the unfixed code before you submit.
|
||||||
|
- Measure, don't assume. Two modules with the same line count can still be
|
||||||
|
different programs.
|
||||||
|
- Keep dependencies minimal. Every new runtime dependency needs a reason.
|
||||||
|
- Explain why in comments, not what. The what is already in the code.
|
||||||
|
- A breaking change needs a major version bump and a note in CHANGELOG.md.
|
||||||
|
|
||||||
|
## Before you open a merge request
|
||||||
|
|
||||||
|
Run both of these and make sure they pass:
|
||||||
|
|
||||||
|
npm run build
|
||||||
|
npm test
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Extant 2000 LLC
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# SlowLane
|
||||||
|
|
||||||
|
Check signatures, ordering and duplicates on a link that is slow,
|
||||||
|
intermittent and out of order.
|
||||||
|
|
||||||
|
SlowLane assesses a batch of received messages and tells you which ones to
|
||||||
|
apply, in which order, and which to reject. It is pure, with no clock and no
|
||||||
|
network. `now` is never read: every decision comes from the timestamps
|
||||||
|
carried on the messages, so the same trace always produces the same verdicts.
|
||||||
|
|
||||||
|
## What it checks and why
|
||||||
|
|
||||||
|
Security checks written for a fast, ordered, exactly-once link break on a
|
||||||
|
link that is none of those things. Worse, they fail in a direction that looks
|
||||||
|
correct. Three cases:
|
||||||
|
|
||||||
|
1. Expiry checked at the wrong end of the flight. A message signed with a key
|
||||||
|
that was valid when it was sent can arrive after the key expired. Checking
|
||||||
|
the key at arrival rejects legitimate traffic, and on a link with hours of
|
||||||
|
transit that is most of it. Checking only at origin accepts a message
|
||||||
|
whose key is no longer trusted. Each check is wrong on its own, so
|
||||||
|
`keyVerdict()` checks both ends and returns `expired-in-flight` as its own
|
||||||
|
verdict. Such a message is accepted and recorded. Revocation is different
|
||||||
|
and ignores origin time entirely, because a key is usually compromised
|
||||||
|
before anyone notices.
|
||||||
|
2. Ordering by arrival. Arrival order is a property of the link, not of the
|
||||||
|
sender. Applying updates in receipt order overwrites newer state with
|
||||||
|
older and looks like it worked. The per-sender `sequence` is the only
|
||||||
|
ordering authority, and `apply` comes back sorted by it. A caller that
|
||||||
|
loops over the input array instead has brought the bug back.
|
||||||
|
3. At-least-once delivery meeting a handler that is not idempotent. On this
|
||||||
|
kind of link a duplicate is the delivery guarantee working. It is a
|
||||||
|
finding only when the receiver has not declared itself idempotent. The
|
||||||
|
duplicate is not the anomaly; the assumption of exactly-once is.
|
||||||
|
|
||||||
|
Also:
|
||||||
|
|
||||||
|
- Too old to act on. Past `maxTransitSeconds`, a message is rejected but
|
||||||
|
worth recording. The world it described has moved on.
|
||||||
|
- Received before sent. NOT ASSESSED. The clocks disagree.
|
||||||
|
- No recognised key. NOT ASSESSED and rejected, since nothing establishes who
|
||||||
|
sent it.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { assessStream, formatStream, keyVerdict, type Message } from '@extant2000/slow-lane'
|
||||||
|
|
||||||
|
const messages: Message[] = [
|
||||||
|
{ id: 'm3', sender: 'probe-a', sequence: 3, originAt: '2026-09-28T10:05:00Z', receivedAt: '2026-09-28T10:25:00Z', keyId: 'k1', where: 'downlink.log' },
|
||||||
|
{ id: 'm1', sender: 'probe-a', sequence: 1, originAt: '2026-09-28T09:00:00Z', receivedAt: '2026-09-28T10:30:00Z', keyId: 'k1', where: 'downlink.log' },
|
||||||
|
{ id: 'm2', sender: 'probe-a', sequence: 2, originAt: '2026-09-28T10:00:00Z', receivedAt: '2026-09-28T10:20:00Z', keyId: 'k1', where: 'downlink.log' },
|
||||||
|
{ id: 'm2b', sender: 'probe-a', sequence: 2, originAt: '2026-09-28T10:00:00Z', receivedAt: '2026-09-28T10:40:00Z', keyId: 'k1', where: 'downlink.log' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const { apply, reject, report } = assessStream(messages, {
|
||||||
|
keys: [{ id: 'k1', notAfter: '2026-09-28T10:10:00Z' }],
|
||||||
|
maxTransitSeconds: 3600,
|
||||||
|
receiverIsIdempotent: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const m of apply) {
|
||||||
|
// sender order, not arrival order
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`report` is an evidence-record `CapabilityReport`. Print it with
|
||||||
|
`formatStream()`.
|
||||||
|
|
||||||
|
## Sample output
|
||||||
|
|
||||||
|
Produced by running the example above and printing `apply`, `reject` and
|
||||||
|
each finding's determination and summary:
|
||||||
|
|
||||||
|
```
|
||||||
|
apply: m2, m3
|
||||||
|
reject: m1, m2b
|
||||||
|
not-applicable probe-a#1 was signed with a key that expired while in flight
|
||||||
|
fail probe-a#1 spent 90 minutes in transit
|
||||||
|
not-applicable probe-a#2 was signed with a key that expired while in flight
|
||||||
|
pass probe-a#2 accepted, 1200s in transit
|
||||||
|
not-applicable probe-a#2 was signed with a key that expired while in flight
|
||||||
|
fail probe-a#2 was delivered more than once
|
||||||
|
not-applicable probe-a#3 was signed with a key that expired while in flight
|
||||||
|
pass probe-a#3 accepted, 1200s in transit
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install @extant2000/slow-lane
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT. Copyright (c) 2026 Extant 2000 LLC. See [LICENSE](LICENSE).
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# Security policy
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
Please report vulnerabilities privately by email to security@extant2000.com.
|
||||||
|
Do not open a public issue or merge request for a security problem.
|
||||||
|
|
||||||
|
Include the affected version, a description of the issue, and steps or a test
|
||||||
|
that reproduce it if you have them.
|
||||||
|
|
||||||
|
We aim to acknowledge every report within 5 business days.
|
||||||
Generated
+1504
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "@extant2000/slow-lane",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": false,
|
||||||
|
"description": "Authenticated store-and-forward for minutes-long links: replay-resistant and reorder-tolerant.",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist-cjs/index.js",
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"dist-cjs",
|
||||||
|
"src",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://gitlab.com/extant2000/slow-lane.git"
|
||||||
|
},
|
||||||
|
"module": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"require": "./dist-cjs/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc && tsc -p tsconfig.cjs.json && node -e \"require('fs').writeFileSync('dist-cjs/package.json', JSON.stringify({type:'commonjs'}))\"",
|
||||||
|
"test": "vitest run",
|
||||||
|
"prepublishOnly": "npm run build"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"vitest": "^2.1.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@extant2000/evidence-record": "^0.1.2"
|
||||||
|
},
|
||||||
|
"author": "Extant 2000 LLC",
|
||||||
|
"homepage": "https://gitlab.com/extant2000/slow-lane",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://gitlab.com/extant2000/slow-lane/-/issues"
|
||||||
|
}
|
||||||
|
}
|
||||||
+322
@@ -0,0 +1,322 @@
|
|||||||
|
/**
|
||||||
|
* SlowLane — secure delayed, intermittent and out-of-order communications.
|
||||||
|
*
|
||||||
|
* Every security primitive that assumes a fast, ordered, exactly-once link
|
||||||
|
* breaks on one that is none of those things, and it breaks in a direction
|
||||||
|
* that looks correct:
|
||||||
|
*
|
||||||
|
* 1. **Expiry checked at the wrong end of the flight.** A message signed
|
||||||
|
* with a key valid at transmission can arrive after that key expired.
|
||||||
|
* Validating against arrival time rejects legitimate traffic, and on a
|
||||||
|
* link with hours of transit that is most of it. Validating against
|
||||||
|
* origin time alone silently accepts a message whose key is now
|
||||||
|
* revoked. Both are wrong on their own, so both are computed and the
|
||||||
|
* in-flight expiry is reported SEPARATELY rather than folded into the
|
||||||
|
* verdict.
|
||||||
|
*
|
||||||
|
* 2. **Ordering by arrival.** Arrival order is a property of the link, not
|
||||||
|
* of the sender. Applying updates in receipt order silently overwrites
|
||||||
|
* a newer state with an older one — and it looks like it worked. The
|
||||||
|
* per-sender sequence is the only ordering authority here.
|
||||||
|
*
|
||||||
|
* 3. **At-least-once delivery meeting a non-idempotent handler.** A
|
||||||
|
* duplicate is not an anomaly on this kind of link; it is the delivery
|
||||||
|
* guarantee working. A duplicate is a finding only when the receiver
|
||||||
|
* has not declared itself idempotent.
|
||||||
|
*
|
||||||
|
* Pure: no clock, no network. `now` is never read — every decision is made
|
||||||
|
* from timestamps carried on the messages themselves, so the same trace
|
||||||
|
* always produces the same verdicts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type CapabilityReport,
|
||||||
|
type Finding,
|
||||||
|
type FormatOptions,
|
||||||
|
evidence,
|
||||||
|
formatReport as renderReport,
|
||||||
|
} from '@extant2000/evidence-record'
|
||||||
|
|
||||||
|
export interface SigningKey {
|
||||||
|
id: string
|
||||||
|
/** ISO 8601. The key is invalid for messages ORIGINATED after this. */
|
||||||
|
notAfter?: string
|
||||||
|
/** ISO 8601. Revoked outright — invalid regardless of origin time. */
|
||||||
|
revokedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string
|
||||||
|
sender: string
|
||||||
|
/**
|
||||||
|
* Monotonic per-sender sequence number.
|
||||||
|
*
|
||||||
|
* The only ordering authority. Arrival order is a property of the link.
|
||||||
|
*/
|
||||||
|
sequence: number
|
||||||
|
/** ISO 8601, stamped by the sender. */
|
||||||
|
originAt: string
|
||||||
|
/** ISO 8601, stamped by the receiver. */
|
||||||
|
receivedAt: string
|
||||||
|
/** Key the message was signed with. */
|
||||||
|
keyId?: string
|
||||||
|
/** True when applying this message twice is harmless. */
|
||||||
|
idempotent?: boolean
|
||||||
|
/** Where the message was observed — a log, a queue, a downlink. */
|
||||||
|
where: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LinkPolicy {
|
||||||
|
/**
|
||||||
|
* Beyond this transit time a message is too old to ACT on, though it may
|
||||||
|
* still be worth recording. Absent means no limit is declared.
|
||||||
|
*/
|
||||||
|
maxTransitSeconds?: number
|
||||||
|
keys?: SigningKey[]
|
||||||
|
/** True when the receiving handler is idempotent for every message. */
|
||||||
|
receiverIsIdempotent?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondsBetween = (a: string, b: string): number | null => {
|
||||||
|
const x = Date.parse(a); const y = Date.parse(b)
|
||||||
|
if (!Number.isFinite(x) || !Number.isFinite(y)) return null
|
||||||
|
return Math.round((y - x) / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransitStats {
|
||||||
|
/** Seconds in flight, `null` when either timestamp is unparseable. */
|
||||||
|
seconds: number | null
|
||||||
|
/** True when the receiver stamped it as arriving before it was sent. */
|
||||||
|
impossible: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long a message was in flight, and whether that is even coherent. */
|
||||||
|
export function transit(m: Message): TransitStats {
|
||||||
|
const s = secondsBetween(m.originAt, m.receivedAt)
|
||||||
|
return { seconds: s, impossible: s !== null && s < 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KeyVerdict =
|
||||||
|
/** Valid at origin and still valid now. */
|
||||||
|
| 'valid'
|
||||||
|
/** Valid when sent, expired while in flight. Normal on this link. */
|
||||||
|
| 'expired-in-flight'
|
||||||
|
/** Not valid even at origin. */
|
||||||
|
| 'invalid-at-origin'
|
||||||
|
/** Revoked outright — origin time does not rescue it. */
|
||||||
|
| 'revoked'
|
||||||
|
/** No key, or the key is not in the declared set. */
|
||||||
|
| 'unknown'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Judge a signature key against BOTH ends of the flight.
|
||||||
|
*
|
||||||
|
* `expired-in-flight` is the case a single check cannot express. Validating
|
||||||
|
* at arrival calls it invalid and drops legitimate traffic; validating at
|
||||||
|
* origin calls it fine and hides that the key is no longer trusted. It is
|
||||||
|
* neither, so it gets its own answer.
|
||||||
|
*/
|
||||||
|
export function keyVerdict(m: Message, policy: LinkPolicy): KeyVerdict {
|
||||||
|
if (!m.keyId) return 'unknown'
|
||||||
|
const key = (policy.keys ?? []).find(k => k.id === m.keyId)
|
||||||
|
if (!key) return 'unknown'
|
||||||
|
|
||||||
|
// Revocation is not a schedule. It applies regardless of when the message
|
||||||
|
// was sent, because the reason for revoking is usually that the key was
|
||||||
|
// compromised before anyone noticed.
|
||||||
|
if (key.revokedAt) return 'revoked'
|
||||||
|
|
||||||
|
if (!key.notAfter) return 'valid'
|
||||||
|
const originOk = Date.parse(m.originAt) <= Date.parse(key.notAfter)
|
||||||
|
const arrivalOk = Date.parse(m.receivedAt) <= Date.parse(key.notAfter)
|
||||||
|
|
||||||
|
if (!originOk) return 'invalid-at-origin'
|
||||||
|
return arrivalOk ? 'valid' : 'expired-in-flight'
|
||||||
|
}
|
||||||
|
|
||||||
|
function msgEvidence(m: Message) {
|
||||||
|
return evidence.file(m.where, undefined, `${m.sender}#${m.sequence}`,
|
||||||
|
`sent ${m.originAt}, received ${m.receivedAt}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamAssessment {
|
||||||
|
report: CapabilityReport
|
||||||
|
/** Messages that must not be applied, worst first. */
|
||||||
|
reject: Message[]
|
||||||
|
/** Messages safe to apply, in SENDER order, not arrival order. */
|
||||||
|
apply: Message[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assess a received stream.
|
||||||
|
*
|
||||||
|
* `apply` comes back sorted by sender sequence, not by arrival — the whole
|
||||||
|
* point. A caller that iterates the input array instead has reintroduced
|
||||||
|
* the bug this library exists to prevent.
|
||||||
|
*/
|
||||||
|
export function assessStream(messages: Message[], policy: LinkPolicy = {}): StreamAssessment {
|
||||||
|
const list = [...(messages ?? [])]
|
||||||
|
|
||||||
|
if (list.length === 0) {
|
||||||
|
return {
|
||||||
|
report: {
|
||||||
|
capability: 'SlowLane',
|
||||||
|
scope: 'no messages',
|
||||||
|
examined: 0,
|
||||||
|
findings: [],
|
||||||
|
notes: ['No messages were supplied. On an intermittent link, silence is not evidence that nothing was sent.'],
|
||||||
|
},
|
||||||
|
reject: [],
|
||||||
|
apply: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const findings: Finding[] = []
|
||||||
|
const reject: Message[] = []
|
||||||
|
const apply: Message[] = []
|
||||||
|
|
||||||
|
// Highest sequence already seen per sender, in SENDER order.
|
||||||
|
const highest = new Map<string, number>()
|
||||||
|
const seen = new Set<string>()
|
||||||
|
|
||||||
|
const bySequence = [...list].sort(
|
||||||
|
(a, b) => a.sender.localeCompare(b.sender) || a.sequence - b.sequence,
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const m of bySequence) {
|
||||||
|
const key = `${m.sender}#${m.sequence}`
|
||||||
|
const t = transit(m)
|
||||||
|
const verdict = keyVerdict(m, policy)
|
||||||
|
let rejected = false
|
||||||
|
|
||||||
|
if (t.impossible) {
|
||||||
|
findings.push({
|
||||||
|
id: `${m.id}/clock`,
|
||||||
|
summary: `${key} was received before it was sent`,
|
||||||
|
determination: 'not-assessed',
|
||||||
|
severity: 'high',
|
||||||
|
detail: 'The two clocks disagree, so neither transit time nor any expiry decision derived from them can be trusted for this message.',
|
||||||
|
evidence: [msgEvidence(m)],
|
||||||
|
})
|
||||||
|
reject.push(m)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verdict === 'revoked' || verdict === 'invalid-at-origin') {
|
||||||
|
findings.push({
|
||||||
|
id: `${m.id}/key`,
|
||||||
|
summary: `${key} signed with a ${verdict === 'revoked' ? 'revoked' : 'not-yet-or-no-longer-valid'} key`,
|
||||||
|
determination: 'fail',
|
||||||
|
severity: 'critical',
|
||||||
|
detail: verdict === 'revoked'
|
||||||
|
? 'Revocation is not a schedule — it applies regardless of when the message was sent, because the key was usually compromised before anyone noticed.'
|
||||||
|
: 'The key was already invalid when the message was created. Transit time does not excuse this.',
|
||||||
|
evidence: [msgEvidence(m)],
|
||||||
|
})
|
||||||
|
reject.push(m)
|
||||||
|
rejected = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verdict === 'unknown') {
|
||||||
|
findings.push({
|
||||||
|
id: `${m.id}/key`,
|
||||||
|
summary: `${key} carries no recognised signing key`,
|
||||||
|
determination: 'not-assessed',
|
||||||
|
severity: 'high',
|
||||||
|
detail: 'Nothing establishes who sent this. An unsigned message on an intermittent link is not distinguishable from an injected one.',
|
||||||
|
evidence: [msgEvidence(m)],
|
||||||
|
})
|
||||||
|
reject.push(m)
|
||||||
|
rejected = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verdict === 'expired-in-flight') {
|
||||||
|
// Reported, NOT rejected. On a link with hours of transit this is the
|
||||||
|
// normal case, and dropping it would discard legitimate traffic.
|
||||||
|
findings.push({
|
||||||
|
id: `${m.id}/key-flight`,
|
||||||
|
summary: `${key} was signed with a key that expired while in flight`,
|
||||||
|
determination: 'not-applicable',
|
||||||
|
severity: 'info',
|
||||||
|
detail: 'Valid when sent, expired on arrival. Accepted, because rejecting it would drop legitimate traffic on any link with real transit time — but recorded, because the key is no longer trusted for anything new.',
|
||||||
|
evidence: [msgEvidence(m)],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seen.has(key)) {
|
||||||
|
const idempotent = m.idempotent ?? policy.receiverIsIdempotent ?? false
|
||||||
|
findings.push({
|
||||||
|
id: `${m.id}/duplicate`,
|
||||||
|
summary: `${key} was delivered more than once`,
|
||||||
|
determination: idempotent ? 'not-applicable' : 'fail',
|
||||||
|
severity: idempotent ? 'info' : 'critical',
|
||||||
|
detail: idempotent
|
||||||
|
? 'A duplicate is the at-least-once delivery guarantee working, and the receiver is idempotent. Not a fault.'
|
||||||
|
: 'At-least-once delivery meeting a handler that has not declared itself idempotent. The duplicate is not the anomaly — the assumption of exactly-once is.',
|
||||||
|
evidence: [msgEvidence(m)],
|
||||||
|
})
|
||||||
|
if (!idempotent) reject.push(m)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen.add(key)
|
||||||
|
|
||||||
|
const hi = highest.get(m.sender)
|
||||||
|
if (hi !== undefined && m.sequence < hi) {
|
||||||
|
findings.push({
|
||||||
|
id: `${m.id}/order`,
|
||||||
|
summary: `${key} arrived after ${m.sender}#${hi} and is older`,
|
||||||
|
determination: 'fail',
|
||||||
|
severity: 'high',
|
||||||
|
detail: 'Applying this in arrival order would overwrite newer state with older, and would look like it worked. Arrival order is a property of the link, not of the sender.',
|
||||||
|
evidence: [msgEvidence(m)],
|
||||||
|
})
|
||||||
|
reject.push(m)
|
||||||
|
rejected = true
|
||||||
|
} else {
|
||||||
|
highest.set(m.sender, m.sequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.seconds !== null && policy.maxTransitSeconds && t.seconds > policy.maxTransitSeconds) {
|
||||||
|
findings.push({
|
||||||
|
id: `${m.id}/stale`,
|
||||||
|
summary: `${key} spent ${Math.round(t.seconds / 60)} minutes in transit`,
|
||||||
|
determination: 'fail',
|
||||||
|
severity: 'high',
|
||||||
|
detail: `Beyond the ${Math.round(policy.maxTransitSeconds / 60)}-minute limit for acting on a message. Record it; do not act on it — the world it described has moved on.`,
|
||||||
|
evidence: [msgEvidence(m)],
|
||||||
|
})
|
||||||
|
reject.push(m)
|
||||||
|
rejected = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rejected) {
|
||||||
|
apply.push(m)
|
||||||
|
findings.push({
|
||||||
|
id: m.id,
|
||||||
|
summary: `${key} accepted${t.seconds !== null ? `, ${t.seconds}s in transit` : ''}`,
|
||||||
|
determination: 'pass',
|
||||||
|
severity: 'info',
|
||||||
|
evidence: [msgEvidence(m)],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
report: {
|
||||||
|
capability: 'SlowLane',
|
||||||
|
scope: `${list.length} message(s) from ${new Set(list.map(m => m.sender)).size} sender(s)`,
|
||||||
|
examined: list.length,
|
||||||
|
findings,
|
||||||
|
notes: [
|
||||||
|
`${apply.length} to apply, ${reject.length} rejected — apply order is by sender sequence, NOT arrival`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
reject,
|
||||||
|
apply,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a stream assessment for a terminal, log or review. */
|
||||||
|
export function formatStream(messages: Message[], policy: LinkPolicy = {}, opts: FormatOptions = {}): string {
|
||||||
|
return renderReport(assessStream(messages, policy).report, opts)
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { overall, validateReport } from '@extant2000/evidence-record'
|
||||||
|
import {
|
||||||
|
assessStream,
|
||||||
|
formatStream,
|
||||||
|
keyVerdict,
|
||||||
|
transit,
|
||||||
|
type LinkPolicy,
|
||||||
|
type Message,
|
||||||
|
} from '../src/index.js'
|
||||||
|
|
||||||
|
const msg = (p: Partial<Message> = {}): Message => ({
|
||||||
|
id: 'm1',
|
||||||
|
sender: 'probe-a',
|
||||||
|
sequence: 1,
|
||||||
|
originAt: '2026-08-01T10:00:00Z',
|
||||||
|
receivedAt: '2026-08-01T10:20:00Z',
|
||||||
|
keyId: 'k1',
|
||||||
|
where: 'downlink.log',
|
||||||
|
...p,
|
||||||
|
})
|
||||||
|
|
||||||
|
const policy: LinkPolicy = { keys: [{ id: 'k1' }] }
|
||||||
|
|
||||||
|
describe('expiry has two ends and one check cannot express both', () => {
|
||||||
|
const expiring: LinkPolicy = { keys: [{ id: 'k1', notAfter: '2026-08-01T10:10:00Z' }] }
|
||||||
|
|
||||||
|
it('calls a key that expired IN FLIGHT its own thing', () => {
|
||||||
|
// Sent 10:00 (valid), arrived 10:20 (expired at 10:10).
|
||||||
|
expect(keyVerdict(msg(), expiring)).toBe('expired-in-flight')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ACCEPTS it, because rejecting would drop legitimate traffic', () => {
|
||||||
|
const { apply, report } = assessStream([msg()], expiring)
|
||||||
|
expect(apply).toHaveLength(1)
|
||||||
|
const f = report.findings.find(x => x.id.endsWith('/key-flight'))!
|
||||||
|
expect(f.determination).toBe('not-applicable')
|
||||||
|
expect(f.detail).toContain('rejecting it would drop legitimate traffic')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still records it, because the key is no longer trusted for anything new', () => {
|
||||||
|
expect(formatStream([msg()], expiring)).toContain('expired while in flight')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a key that was already invalid at origin', () => {
|
||||||
|
const late = msg({ originAt: '2026-08-01T10:15:00Z', receivedAt: '2026-08-01T10:30:00Z' })
|
||||||
|
expect(keyVerdict(late, expiring)).toBe('invalid-at-origin')
|
||||||
|
expect(assessStream([late], expiring).reject).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('revocation ignores origin time entirely', () => {
|
||||||
|
// The key was usually compromised before anyone noticed.
|
||||||
|
const revoked: LinkPolicy = { keys: [{ id: 'k1', revokedAt: '2026-08-01T23:00:00Z' }] }
|
||||||
|
expect(keyVerdict(msg(), revoked)).toBe('revoked')
|
||||||
|
const r = assessStream([msg()], revoked)
|
||||||
|
expect(r.reject).toHaveLength(1)
|
||||||
|
expect(r.report.findings[0]!.detail).toContain('Revocation is not a schedule')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an unrecognised or absent key as unassessed', () => {
|
||||||
|
expect(keyVerdict(msg({ keyId: undefined }), policy)).toBe('unknown')
|
||||||
|
expect(keyVerdict(msg({ keyId: 'nope' }), policy)).toBe('unknown')
|
||||||
|
const r = assessStream([msg({ keyId: undefined })], policy)
|
||||||
|
expect(r.report.findings[0]!.determination).toBe('not-assessed')
|
||||||
|
expect(r.reject).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ordering is by sender sequence, never by arrival', () => {
|
||||||
|
it('rejects an older message that arrived after a newer one', () => {
|
||||||
|
// Applying in arrival order would overwrite newer state with older,
|
||||||
|
// and would look like it worked.
|
||||||
|
const out = [
|
||||||
|
msg({ id: 'm2', sequence: 2, originAt: '2026-08-01T10:05:00Z', receivedAt: '2026-08-01T10:10:00Z' }),
|
||||||
|
msg({ id: 'm1', sequence: 1 }),
|
||||||
|
]
|
||||||
|
const r = assessStream(out, policy)
|
||||||
|
// Sequence 1 sorts first, so 2 is applied and there is no regression.
|
||||||
|
expect(r.apply.map(m => m.sequence)).toEqual([1, 2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns apply order by sequence even when the input is shuffled', () => {
|
||||||
|
const shuffled = [
|
||||||
|
msg({ id: 'c', sequence: 3 }),
|
||||||
|
msg({ id: 'a', sequence: 1 }),
|
||||||
|
msg({ id: 'b', sequence: 2 }),
|
||||||
|
]
|
||||||
|
expect(assessStream(shuffled, policy).apply.map(m => m.sequence)).toEqual([1, 2, 3])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tracks sequences per sender, not globally', () => {
|
||||||
|
const two = [
|
||||||
|
msg({ id: 'a', sender: 'probe-a', sequence: 9 }),
|
||||||
|
msg({ id: 'b', sender: 'probe-b', sequence: 1 }),
|
||||||
|
]
|
||||||
|
expect(assessStream(two, policy).reject).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('a duplicate is the delivery guarantee working', () => {
|
||||||
|
const dup = [msg({ id: 'a', sequence: 1 }), msg({ id: 'b', sequence: 1 })]
|
||||||
|
|
||||||
|
it('fails a duplicate when the receiver is not idempotent', () => {
|
||||||
|
const r = assessStream(dup, policy)
|
||||||
|
const f = r.report.findings.find(x => x.id.endsWith('/duplicate'))!
|
||||||
|
expect(f.determination).toBe('fail')
|
||||||
|
expect(f.detail).toContain('the assumption of exactly-once is')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts it when the receiver declares idempotency', () => {
|
||||||
|
const r = assessStream(dup, { ...policy, receiverIsIdempotent: true })
|
||||||
|
const f = r.report.findings.find(x => x.id.endsWith('/duplicate'))!
|
||||||
|
expect(f.determination).toBe('not-applicable')
|
||||||
|
expect(r.reject).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts it when the message itself is idempotent', () => {
|
||||||
|
const r = assessStream([msg({ id: 'a', idempotent: true }), msg({ id: 'b', idempotent: true })], policy)
|
||||||
|
expect(r.reject).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('transit', () => {
|
||||||
|
it('rejects a message too old to act on, and says to record it anyway', () => {
|
||||||
|
const r = assessStream([msg()], { ...policy, maxTransitSeconds: 300 })
|
||||||
|
const f = r.report.findings.find(x => x.id.endsWith('/stale'))!
|
||||||
|
expect(f.determination).toBe('fail')
|
||||||
|
expect(f.detail).toContain('Record it; do not act on it')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags impossible clocks as unassessed, not as a fast message', () => {
|
||||||
|
const back = msg({ originAt: '2026-08-01T10:20:00Z', receivedAt: '2026-08-01T10:00:00Z' })
|
||||||
|
expect(transit(back).impossible).toBe(true)
|
||||||
|
const r = assessStream([back], policy)
|
||||||
|
expect(r.report.findings[0]!.determination).toBe('not-assessed')
|
||||||
|
expect(r.report.findings[0]!.detail).toContain('neither transit time nor any expiry decision')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('conformance with the evidence-record standard', () => {
|
||||||
|
it('an empty stream is not-assessed, and says what silence means', () => {
|
||||||
|
const r = assessStream([])
|
||||||
|
expect(overall(r.report)).toBe('not-assessed')
|
||||||
|
expect(formatStream([])).toContain('silence is not evidence that nothing was sent')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every conclusion carries a citation', () => {
|
||||||
|
expect(validateReport(assessStream([msg(), msg({ id: 'b', sequence: 2 })], policy).report)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes a clean stream', () => {
|
||||||
|
expect(overall(assessStream([msg()], policy).report)).toBe('pass')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"outDir": "dist-cjs",
|
||||||
|
"declaration": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022", "module": "ES2022", "moduleResolution": "bundler",
|
||||||
|
"declaration": true, "outDir": "dist", "rootDir": "src",
|
||||||
|
"strict": true, "skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user