Revision 3: härdningen granskad — fyra fynd i en dag gammal kod, alla åtgärdade

Revisionens föremål är hashkedjan, förseglingen och säkerhetstaket från
igår. Skälet att granska det nyaste först: det farligaste ögonblicket för
en skyddsmekanism är veckan efter att den byggts, när testerna är skrivna
av samma person som skrev mekanismen och provar samma antaganden.

K-1, KRITISK. Förseglingen larmade falskt i normal drift. Offline-synk
kan lämna in händelser EFTER att ärendet stängts — en sen bild, ett sent
kundbesked — och verifieringen jämförde förseglingens rot med kedjans
NUVARANDE rot. Varje legitim efterhändelse gav OGILTIG. Reproducerad i
integrationsmiljön innan den kallades fynd. En verifiering som larmar
falskt avfärdas snart som trasig, och därefter avfärdas även de äkta
larmen — falsklarmet är inte en mindre bugg i ett skydd, det är det som
dödar skyddet. Åtgärd: förseglingen täcker sitt PREFIX. Den förseglade
roten ska vara en länk i den omräknade kedjan; det som kom efter
redovisas öppet i `efterForsegling` i stället för att smittas eller
smitta. Motvikten är prövad: sabotage INUTI prefixet fäller fortfarande
både kedjan och förseglingen — annars vore "täcker sitt prefix" bara ett
artigare ord för "täcker ingenting".

m-1. Kedjelänken vilade på ett tidsformat som garanterades tre filer
bort. Inte utlösbart i dag — alla vägar går genom tillPost — men första
nya skrivväg utan millisekunder hade gett en kedja som aldrig verifierar.
Normaliseringen bor nu i skrivKedjat, i samma uttryck som länken och
databasraden får den ur.

m-2. Protokollvägens skrivna/dubbletter räknades med två count(*) runt
anropet; en samtidig skrivning från någon annan hamnade i vår siffra.
skrivKedjat returnerar nu räkningen ur transaktionen som gjorde jobbet.

m-3. Läsordning och kedjeordning var två ordningar. Kedjan ordnas av
sekvens; GET, grinden och delningsvyn sorterade på tidpunkt och id — inom
en batch kunde id:ts bokstavsordning avgöra vilken händelse som var
"senast". Alla läsvägar sorterar nu i kedjeordning.

Granskat utan anmärkning, bokfört så nästa revision vet att "hittade
inget" betyder "letade": numerisk rundresa genom jsonb (provad med 1e-7
och 0.10000000000000009, inte antagen), dubbletter flyttar inte kedjan,
krypto-shredding mot kedjan, signaturens serverägdhet, takets monotoni.

Mönstret i alla fyra fynd är detsamma och står i rapporten: testerna som
skrevs med mekanismen provade manipulation, som är det man tänker på när
man bygger ett skydd. Det som gick sönder var normal drift — sen synk,
samtidiga grannar, en batch i samma millisekund. Skydd fallerar oftare
genom att larma falskt än genom att missa angrepp.

766 tester, 206 integrationskontroller, genomgång 4/4, portalspärr,
typkontroll, lint och artefaktmätning gröna. API-specen dokumenterar
prefixsemantiken och efterForsegling. docs/QUALITY-AUDIT-3.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-06 20:03:43 +00:00
parent 1ba5aaaefa
commit 0941e53c50
4 changed files with 270 additions and 43 deletions
+135
View File
@@ -0,0 +1,135 @@
# Kvalitetsrevision · Revision 3
**ALVA 3.3 · 2026-08-06 · ALVA-DOC-0006**
Revisionens föremål är **härdningen själv**: hashkedjan, förseglingen och
säkerhetstaket som infördes efter panelgranskningen, plus deras sömmar mot
resten av systemet. Skälet att revidera det nyaste först är erfarenheten
från revision 1 och 2: det farligaste ögonblicket för en skyddsmekanism är
veckan efter att den byggdes, när alla tester är skrivna av samma person
som skrev mekanismen och provar samma antaganden.
Metoden är densamma som tidigare: varje misstanke reproduceras innan den
kallas fynd, varje fynd åtgärdas eller bokförs, och varje åtgärd
mutationsprövas eller motviktsprövas. Ett fynd utan repro är en åsikt.
---
## K-1 · KRITISK — Förseglingen larmade falskt i normal drift
**Fyndet.** Offline-synk är en kärnfunktion: en annan enhet kan lämna in
händelser *efter* att någon stängt ärendet — en sen bild, ett sent
kundbesked. Verifieringen jämförde förseglingens rot med kedjans
*nuvarande* rot. Varje legitim efterhändelse flyttade kedjan förbi den
förseglade punkten, och förseglingen rapporterades **OGILTIG**.
**Reproducerad** i integrationsmiljön: ett komplett ärende avslutades och
förseglades (`giltig`), en observation synkades in efteråt, och samma
anrop svarade `OGILTIG`.
**Varför det är kritiskt, fast inget manipulerats.** En verifiering som
larmar falskt i normal drift avfärdas snart som trasig — och därefter
avfärdas även de äkta larmen. Falsklarmet är inte en mindre bugg i ett
skydd; det är det som dödar skyddet.
**Åtgärden.** Förseglingen täcker sitt **prefix**. Den förseglade roten
ska vara *en länk i* den omräknade kedjan; är den det bevisar
förseglingen loggen fram till avslutet, och det som kom efter redovisas
öppet i fältet `efterForsegling` i stället för att smittas eller smitta.
Prefixet prövas separat, så ett kedjebrott *efter* förseglingspunkten
inte heller det fäller ett bevisat prefix — brottet pekas ut för sig.
**Motvikten** — prövad, inte antagen: en rad ändrad som databasägare
*inuti* prefixet fäller både kedjan och förseglingen. Utan det testet
hade "täcker sitt prefix" kunnat vara ett artigare ord för "täcker
ingenting".
**Kvarstående begränsning, avsiktlig:** efterhändelser förseglas inte om
— triggern tillåter inte en andra försegling, och en försegling som kan
skrivas om är ingen försegling. Att `efterForsegling` är synlig är
hanteringen: läsaren ser exakt hur långt beviset når.
---
## m-1 — Kedjelänken vilade på ett tidsformat
**Fyndet.** Länken räknas över tidpunkten som sträng. Vid skrivning
användes strängen som kom in; vid verifiering den som kom *ur databasen*
(`toISOString()`, millisekunder). Alla nuvarande vägar går genom
`tillPost`, som redan producerar exakt den formen — så felet var inte
utlösbart i dag. Men garantin låg i en konvention tre filer bort, och
den första nya skrivvägen som skickade `"…T10:00:00Z"` utan millisekunder
hade gett en kedja som aldrig verifierar. Ett falsklarm igen — se K-1
för varför det är den farligaste sortens fel här.
**Åtgärden.** `skrivKedjat` normaliserar tidpunkten själv, i samma
uttryck som både länken och databasraden får den ur. Garantin bor nu där
den används.
## m-2 — Protokollvägens räkning kunde störas av grannen
**Fyndet.** `skrivna`/`dubbletter` räknades som skillnaden mellan två
`count(*)` runt anropet. En samtidig skrivning från någon annan — sen
synk, kundbesked — hamnade i vår siffra. Fel räkning är inte kosmetik i
ett 207-svar vars poäng är att mottagaren ska veta vad som inte kom in.
**Åtgärden.** `skrivKedjat` äger transaktionen och returnerar nu
räkningen ur den: `{rot, skrivna, dubbletter}`.
## m-3 — Läsordning och kedjeordning var två ordningar
**Fyndet.** Kedjan ordnas av `sekvens` (insättningsordning). GET,
grinden, sammanfattningen och delningsvyn sorterade på `tidpunkt, id`.
Inom en batch kan två händelser dela millisekund, och då avgjorde id:ts
*bokstavsordning* vilken som var "senast" — grindens `.at(-1)` kunde läsa
en annan ordning än den loggen skrevs i. Latent (batchar med två
händelser av samma typ i samma millisekund är sällsynta), men två
sanningar om ordning i ett system vars bevis *är* ordningen är en för
mycket.
**Åtgärden.** Alla läsvägar sorterar `sekvens nulls first, tidpunkt, id`
— kedjeordningen, med tidsordning som arv för rader från före kedjan.
---
## Granskat utan anmärkning
Bokförs för att nästa revision ska veta vad som redan är prövat — och
för att "hittade inget" ska betyda "letade", inte "antog".
| Område | Hur det prövades |
|---|---|
| Numerisk rundresa genom jsonb | Kedjans digest räknas om ur lagrad jsonb, och Postgres normaliserar tal. Provat med de former som brukar gå sönder: `0.55`, `1e-7`, `0.10000000000000009`. Kedjan verifierar — normaliseringarna är samstämmiga eftersom allt passerat JS `JSON.parse` vid intaget. Integrationskontroll, inte antagande. |
| Sabotage som databasägare | Trigger släppt, rad ändrad, trigger återskapad — brottet pekas ut med radens id. Provat både utanför (18c) och inuti (18f) det förseglade prefixet. |
| Dubbletter och kedjan | En omsänd händelse skrivs inte och flyttar inte kedjan — roten är identisk före och efter omsändningen. |
| Samtidighet i kedjan | Radlås per ärende (`select … for update`) serialiserar batchar; en fork hade sett ut som manipulation utan att vara det. Konstruktionsgranskad; lasttest återstår (känt sedan panelen, §5.2). |
| Krypto-shredding mot kedjan | Radering förstör nycklar, inte rader. Digest tas över den lagrade (krypterade) formen, så kedjan verifierar även efter en radering. |
| Signaturen | `arende_avslutat.signatur` skrivs ur verifierad token; klientens värde ignoreras. Mutationsprövad. |
| Säkerhetstakets monotoni | Taket beräknas ur händelser som bara kan tillkomma, och varje villkor kan bara slå om till sant — taket kan aldrig sjunka under ett redan valt värde. Ett valt värde förblir därmed alltid inom tak. |
| Gallring mot förseglingskolumnerna | Gallringen raderar hela ärenden; förseglingen följer raden. Ingen särbehandling krävs. |
## Kända och accepterade
| | Beslut |
|---|---|
| Två samtidiga avslut kan båda passera grinden | Grindprövning och skrivning är inte atomära. Följden är två avslutshändelser, varav den första förseglar. Ofarligt för beviset (allt är kedjat), förvirrande i loggen. Accepterat tills verkliga data visar att det inträffar. |
| Extern förankring saknas | Förseglingen är serverns egen nyckel. RFC 3161 kvarstår från panelens lista — utan den är serverns klocka vår klocka. |
| Klienthash av foto vid upptagning | Kvarstår från panelens lista. Kedjan förseglar innehållet från mottagandet; upptagningsögonblicket är fortfarande obevisat. |
---
## Sammanfattning
Ett kritiskt fynd, tre mindre — **alla fyra i kod som var en dag gammal
och redan hade gröna tester.** Det är inte ett argument mot härdningen;
det är argumentet för revisionen. Testerna som skrevs med mekanismen
provade manipulation, som är det man tänker på när man bygger ett skydd.
Det som gick sönder var i stället normal drift: sen synk, samtidiga
grannar, en batch i samma millisekund. Skydd fallerar oftare genom att
larma falskt än genom att missa angrepp, och tre av fyra fynd var just
falsklarmsrisker.
Efter åtgärd: 766 enhetstester, 206 integrationskontroller mot riktig
Postgres — varav sabotage som databasägare i två varianter, numerisk
rundresa och skrivning efter avslut — genomgång 4/4, portalspärr,
typkontroll, lint och artefaktmätning gröna.
@@ -1022,6 +1022,52 @@ OMSKRIVNING=$(PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform
"update felsokning_arenden set forsegling = 'ffff' where id = 'arende-forseglat'" 2>&1 || true) "update felsokning_arenden set forsegling = 'ffff' where id = 'arende-forseglat'" 2>&1 || true)
kontroll "förseglingen kan inte skrivas om" "$(echo "$OMSKRIVNING" | grep -c 'kan inte ändras')" "1" kontroll "förseglingen kan inte skrivas om" "$(echo "$OMSKRIVNING" | grep -c 'kan inte ändras')" "1"
# 18e. Skrivning efter avslut får inte ogiltigförklara förseglingen
#
# Offline-synk är en kärnfunktion: en annan enhet kan lämna in händelser
# EFTER att någon stängt ärendet. Förseglingen intygar loggen fram till
# avslutet — den får varken larma falskt för att livet fortsatte, eller
# tyst låtsas täcka det som kom efteråt.
curl -s -o /dev/null -X POST "$BAS/api/arenden/arende-forseglat/handelser" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: application/json' \
-d '{"handelser":[{"id":"sen-1","tidpunkt":"2026-08-06T12:00:00Z","anvandare":"Johan","handelse":{"typ":"observation","text":"Sent synkad observation fran en annan enhet."}}]}'
EFTER=$(curl -s "$BAS/api/arenden/arende-forseglat/kedja" -H "Authorization: Bearer $TOKEN_A")
kontroll "kedjan verifierar med efterhandelsen" "$(echo "$EFTER" | falt .ok)" "true"
kontroll "förseglingen är fortfarande giltig — den täcker sitt prefix" "$(echo "$EFTER" | falt .forsegling)" "giltig"
kontroll "men efterhandelserna redovisas" "$(echo "$EFTER" | falt .efterForsegling)" "1"
# 18f. Prefixsemantikens motvikt: sabotage INUTI prefixet fäller den
#
# Prefixfixen (K-1) får inte vara för snäll. En rad ändrad FÖRE
# förseglingspunkten ska fälla både kedjan och förseglingen — annars
# vore "täcker sitt prefix" bara ett artigare ord för "täcker ingenting".
PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning -q <<'SQL'
drop trigger handelser_append_only on felsokning_handelser;
update felsokning_handelser
set handelse = jsonb_set(handelse, '{beskrivning}', '"Manipulerad reproducering."')
where arende_id = 'arende-forseglat' and handelse->>'typ' = 'reproducering';
create trigger handelser_append_only
before update or delete on felsokning_handelser
for each row execute function forbjud_andring();
SQL
SABOT2=$(curl -s "$BAS/api/arenden/arende-forseglat/kedja" -H "Authorization: Bearer $TOKEN_A")
kontroll "sabotage i prefixet bryter kedjan" "$(echo "$SABOT2" | falt .ok)" "false"
kontroll "sabotage i prefixet fäller förseglingen" "$(echo "$SABOT2" | falt .forsegling)" "OGILTIG"
# 18g. Siffror i händelsen överlever databasens rundresa
#
# Kedjans digest räknas om ur lagrad jsonb. Tal normaliseras av Postgres
# — om normaliseringen skilde sig från JavaScripts vore varje händelse
# med ett tal ett falsklarm. Konfidensvärden är de enda talen i schemat,
# så de provas med de former som brukar gå sönder: decimaler, exponenter,
# stora tal.
curl -s -X POST "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d '{"id":"arende-tal","nummer":90,"skapad":"2026-08-06T10:00:00Z"}' >/dev/null
curl -s -o /dev/null -X POST "$BAS/api/arenden/arende-tal/handelser" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d '{"handelser":[{"id":"tal-1","tidpunkt":"2026-08-06T10:01:00Z","anvandare":"Anna","handelse":{"typ":"arbetsorder_skannad","falt":[{"id":"kund_namn","varde":"Test","konfidens":0.9},{"id":"fordon_regnr","varde":"ABC123","konfidens":0.55},{"id":"ao_nummer","varde":"1","konfidens":1},{"id":"fordon_vin","varde":"X","konfidens":1e-7},{"id":"fordon_marke","varde":"Y","konfidens":0.10000000000000009}]}}]}'
TAL=$(curl -s "$BAS/api/arenden/arende-tal/kedja" -H "Authorization: Bearer $TOKEN_A")
kontroll "kedjan överlever numerisk rundresa genom jsonb" "$(echo "$TAL" | falt .ok)" "true"
# 19. Support och felanmälan (ALVA-PROC-0050) # 19. Support och felanmälan (ALVA-PROC-0050)
kill "$SERVER_PID" 2>/dev/null || true kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true wait "$SERVER_PID" 2>/dev/null || true
+13 -1
View File
@@ -776,7 +776,19 @@ paths:
den är. den är.
forsegling: forsegling:
type: string type: string
description: saknas · giltig · OGILTIG · kan inte prövas description: >
saknas · giltig · OGILTIG · kan inte prövas.
Förseglingen täcker sitt PREFIX — loggen fram till
avslutet. Händelser som tillkommit efter (sen
offline-synk, sena kundbesked) gör den inte
ogiltig; de redovisas i `efterForsegling`.
efterForsegling:
type: integer
nullable: true
description: >
Kedjade händelser efter förseglingspunkten. Noll är
det normala; ett tal är inte ett fel utan ett
faktum läsaren ska se.
bevisar: { type: string } bevisar: { type: string }
"401": { $ref: "#/components/responses/Fel" } "401": { $ref: "#/components/responses/Fel" }
"404": { $ref: "#/components/responses/Fel" } "404": { $ref: "#/components/responses/Fel" }
+76 -42
View File
@@ -516,22 +516,31 @@ async function skrivKedjat(arendeId, poster) {
); );
let sekvens = Number(sista.rows[0]?.sekvens ?? 0); let sekvens = Number(sista.rows[0]?.sekvens ?? 0);
let h = sista.rows[0]?.kedjehash ?? grund(arendeId); let h = sista.rows[0]?.kedjehash ?? grund(arendeId);
let skrivna = 0;
for (const p of poster) { for (const p of poster) {
const digest = innehallsHash(kanoniskt(p.handelse)); const digest = innehallsHash(kanoniskt(p.handelse));
const nastaH = lank(h, { id: p.id, tidpunkt: p.tidpunkt, anvandare: p.anvandare, digest }); // Tidpunkten normaliseras HÄR, till exakt den form verifieringen
// räknar om ur databasen (millisekunders ISO). Alla nuvarande
// vägar går genom tillPost som redan ger den formen — men länken
// får inte vila på att det förblir sant. En kedja som bryts av ett
// tidsformat är ett falsklarm, och falsklarm dödar förtroendet
// för verifieringen (revision 3, m-1).
const tid = new Date(p.tidpunkt).toISOString();
const nastaH = lank(h, { id: p.id, tidpunkt: tid, anvandare: p.anvandare, digest });
const r = await db.query( const r = await db.query(
`insert into felsokning_handelser `insert into felsokning_handelser
(id, arende_id, tidpunkt, anvandare, handelse, klientdigest, sekvens, kedjehash) (id, arende_id, tidpunkt, anvandare, handelse, klientdigest, sekvens, kedjehash)
values ($1, $2, $3, $4, $5, $6, $7, $8) values ($1, $2, $3, $4, $5, $6, $7, $8)
on conflict (arende_id, id) do nothing`, on conflict (arende_id, id) do nothing`,
[p.id, arendeId, p.tidpunkt, p.anvandare, p.handelse, p.klientdigest ?? null, sekvens + 1, nastaH], [p.id, arendeId, tid, p.anvandare, p.handelse, p.klientdigest ?? null, sekvens + 1, nastaH],
); );
// En dubblett skrivs inte och får därför inte heller flytta kedjan: // En dubblett skrivs inte och får därför inte heller flytta kedjan:
// länken ovan förbrukas bara när raden faktiskt sattes in. // länken ovan förbrukas bara när raden faktiskt sattes in.
if (r.rowCount > 0) { if (r.rowCount > 0) {
sekvens += 1; sekvens += 1;
h = nastaH; h = nastaH;
skrivna += 1;
} }
} }
@@ -556,7 +565,7 @@ async function skrivKedjat(arendeId, poster) {
} }
await db.query("commit"); await db.query("commit");
return { rot: h }; return { rot: h, skrivna, dubbletter: poster.length - skrivna };
} catch (fel) { } catch (fel) {
await db.query("rollback").catch(() => {}); await db.query("rollback").catch(() => {});
throw fel; throw fel;
@@ -569,7 +578,7 @@ async function grindHinder(pool, arendeId, nya, sprak = STANDARD) {
const rader = await pool.query( const rader = await pool.query(
`select h.handelse, a.metodik_id from felsokning_handelser h `select h.handelse, a.metodik_id from felsokning_handelser h
join felsokning_arenden a on a.id = h.arende_id join felsokning_arenden a on a.id = h.arende_id
where h.arende_id = $1 order by h.tidpunkt, h.id`, where h.arende_id = $1 order by h.sekvens nulls first, h.tidpunkt, h.id`,
[arendeId], [arendeId],
); );
const handelser = [...rader.rows.map((r) => r.handelse), ...nya.map((p) => p.handelse)]; const handelser = [...rader.rows.map((r) => r.handelse), ...nya.map((p) => p.handelse)];
@@ -1288,12 +1297,12 @@ export function skapaServer() {
? await pool.query( ? await pool.query(
`select id, tidpunkt, anvandare, handelse from felsokning_handelser `select id, tidpunkt, anvandare, handelse from felsokning_handelser
where arende_id = $1 and handelse->>'typ' = any($2) where arende_id = $1 and handelse->>'typ' = any($2)
order by tidpunkt, id`, order by sekvens nulls first, tidpunkt, id`,
[arendeId, synliga], [arendeId, synliga],
) )
: await pool.query( : await pool.query(
`select id, tidpunkt, anvandare, handelse from felsokning_handelser `select id, tidpunkt, anvandare, handelse from felsokning_handelser
where arende_id = $1 order by tidpunkt, id`, where arende_id = $1 order by sekvens nulls first, tidpunkt, id`,
[arendeId], [arendeId],
); );
return svara(res, 200, { arende: arende.rows[0], handelser: handelser.rows, niva }); return svara(res, 200, { arende: arende.rows[0], handelser: handelser.rows, niva });
@@ -2305,7 +2314,7 @@ export function skapaServer() {
} }
const rader = await pool.query( const rader = await pool.query(
`select tidpunkt, anvandare, handelse from felsokning_handelser `select tidpunkt, anvandare, handelse from felsokning_handelser
where arende_id = $1 order by tidpunkt, id`, where arende_id = $1 order by sekvens nulls first, tidpunkt, id`,
[sammanfattningVag[1]], [sammanfattningVag[1]],
); );
const nycklar = await nycklarFor(anspr.org); const nycklar = await nycklarFor(anspr.org);
@@ -2380,23 +2389,11 @@ export function skapaServer() {
poster.push(post); poster.push(post);
} }
let skrivna = 0; // Kedjat via samma väg som allt annat, och räkningen kommer ur
let dubbletter = 0; // transaktionen som gjorde jobbet — inte ur två count-frågor
{ // runtomkring, där en samtidig skrivning från någon annan
// Kedjat via samma väg som allt annat. Räkningen görs mot // hamnade i vår siffra (revision 3, m-2).
// loggen efteråt — skrivKedjat äger transaktionen. const { skrivna, dubbletter } = await skrivKedjat(protokollVag[1], poster);
const fore = await pool.query(
`select count(*)::int as n from felsokning_handelser where arende_id = $1`,
[protokollVag[1]],
);
await skrivKedjat(protokollVag[1], poster);
const efter = await pool.query(
`select count(*)::int as n from felsokning_handelser where arende_id = $1`,
[protokollVag[1]],
);
skrivna = efter.rows[0].n - fore.rows[0].n;
dubbletter = poster.length - skrivna;
}
// Delvis lyckad inläsning är inte 200. Mottagaren ska behöva // Delvis lyckad inläsning är inte 200. Mottagaren ska behöva
// hantera att något inte kom in. // hantera att något inte kom in.
@@ -2565,31 +2562,64 @@ export function skapaServer() {
); );
const okedjade = rader.rows.filter((r) => r.sekvens == null).length; const okedjade = rader.rows.filter((r) => r.sekvens == null).length;
// Förseglingen prövas mot den OMRÄKNADE roten, inte den lagrade: // Förseglingen täcker sitt PREFIX, inte kedjans nuvarande rot.
// en angripare som räknat om kedjan och roten stoppas av att //
// HMAC:en inte går att räkna om utan nyckeln. // Skillnaden är inte akademisk. Offline-synk är en kärnfunktion:
// en annan enhet kan lämna in händelser EFTER att ärendet
// stängts, och då flyttar kedjans rot förbi den förseglade.
// Revision 3 (K-1) fann att verifieringen då svarade OGILTIG —
// ett falsklarm i normal drift, och en verifiering som larmar
// falskt avfärdas snart som trasig. Det är så ett skydd dör.
//
// Rätt semantik: den förseglade roten ska vara EN LÄNK I den
// omräknade kedjan. Är den det bevisar förseglingen loggen fram
// till den punkten, och det som kom efter redovisas som
// oförseglat i stället för att smittas eller smitta. En
// angripare som räknat om kedjan stoppas fortfarande: den
// omräknade kedjan innehåller inte den gamla roten, och en ny
// försegling kan inte skapas utan nyckeln.
const arende = await pool.query( const arende = await pool.query(
`select kedjerot, forsegling, forseglad from felsokning_arenden where id = $1`, `select kedjerot, forsegling, forseglad from felsokning_arenden where id = $1`,
[kedjeVag[1]], [kedjeVag[1]],
); );
const a = arende.rows[0] ?? {}; const a = arende.rows[0] ?? {};
let forsegling = "saknas"; let forsegling = "saknas";
let efterForsegling = null;
if (a.forsegling) { if (a.forsegling) {
if (!FORSEGLING_NYCKEL) forsegling = "kan inte prövas — FORSEGLING_NYCKEL saknas"; if (!FORSEGLING_NYCKEL) {
else if ( forsegling = "kan inte prövas — FORSEGLING_NYCKEL saknas";
kedja.ok &&
a.kedjerot === kedja.rot &&
provaForsegling(
FORSEGLING_NYCKEL,
kedjeVag[1],
a.kedjerot,
new Date(a.forseglad).toISOString(),
a.forsegling,
)
) {
forsegling = "giltig";
} else { } else {
forsegling = "OGILTIG"; const kedjade = rader.rows.filter((r) => r.sekvens != null);
const idx = kedjade.findIndex((r) => r.kedjehash === a.kedjerot);
// Prefixet prövas för sig: ett brott EFTER förseglingspunkten
// gör inte prefixet mindre bevisat — brottet pekas ut ovan.
const prefixOk =
idx >= 0 &&
verifieraKedja(
kedjeVag[1],
kedjade.slice(0, idx + 1).map((r) => ({
id: r.id,
tidpunkt: new Date(r.tidpunkt).toISOString(),
anvandare: r.anvandare,
digest: innehallsHash(kanoniskt(r.handelse)),
kedjehash: r.kedjehash,
})),
).ok;
if (
prefixOk &&
provaForsegling(
FORSEGLING_NYCKEL,
kedjeVag[1],
a.kedjerot,
new Date(a.forseglad).toISOString(),
a.forsegling,
)
) {
forsegling = "giltig";
efterForsegling = kedjade.length - (idx + 1);
} else {
forsegling = "OGILTIG";
}
} }
} }
@@ -2604,6 +2634,10 @@ export function skapaServer() {
kedjade: rader.rows.length - okedjade, kedjade: rader.rows.length - okedjade,
okedjade, okedjade,
forsegling, forsegling,
// Hur många kedjade händelser som ligger EFTER förseglingen.
// Noll är det normala; ett tal här är inte ett fel utan ett
// faktum som läsaren ska se: sen synk, sena kundbesked.
efterForsegling,
bevisar: bevisar:
"Content unchanged since receipt, in the recorded order. Nothing about the time before receipt, nothing about accuracy.", "Content unchanged since receipt, in the recorded order. Nothing about the time before receipt, nothing about accuracy.",
}); });
@@ -2618,7 +2652,7 @@ export function skapaServer() {
if (req.method === "GET") { if (req.method === "GET") {
const rader = await pool.query( const rader = await pool.query(
`select id, tidpunkt, anvandare, handelse from felsokning_handelser `select id, tidpunkt, anvandare, handelse from felsokning_handelser
where arende_id = $1 order by tidpunkt, id`, where arende_id = $1 order by sekvens nulls first, tidpunkt, id`,
[handelserVag[1]], [handelserVag[1]],
); );
const nycklar = await nycklarFor(anspr.org); const nycklar = await nycklarFor(anspr.org);