From 0941e53c50711d4a85b25bd9228ab417fe71d419 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 20:03:43 +0000 Subject: [PATCH] =?UTF-8?q?Revision=203:=20h=C3=A4rdningen=20granskad=20?= =?UTF-8?q?=E2=80=94=20fyra=20fynd=20i=20en=20dag=20gammal=20kod,=20alla?= =?UTF-8?q?=20=C3=A5tg=C3=A4rdade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt --- felsokning/docs/QUALITY-AUDIT-3.md | 135 ++++++++++++++++++ .../services/plattform/integrationstest.sh | 46 ++++++ felsokning/services/plattform/openapi.yaml | 14 +- felsokning/services/plattform/server.mjs | 118 +++++++++------ 4 files changed, 270 insertions(+), 43 deletions(-) create mode 100644 felsokning/docs/QUALITY-AUDIT-3.md diff --git a/felsokning/docs/QUALITY-AUDIT-3.md b/felsokning/docs/QUALITY-AUDIT-3.md new file mode 100644 index 0000000..c5380f6 --- /dev/null +++ b/felsokning/docs/QUALITY-AUDIT-3.md @@ -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. diff --git a/felsokning/services/plattform/integrationstest.sh b/felsokning/services/plattform/integrationstest.sh index 1f54c8d..c333344 100755 --- a/felsokning/services/plattform/integrationstest.sh +++ b/felsokning/services/plattform/integrationstest.sh @@ -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) 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) kill "$SERVER_PID" 2>/dev/null || true wait "$SERVER_PID" 2>/dev/null || true diff --git a/felsokning/services/plattform/openapi.yaml b/felsokning/services/plattform/openapi.yaml index 9050a42..92e64fd 100644 --- a/felsokning/services/plattform/openapi.yaml +++ b/felsokning/services/plattform/openapi.yaml @@ -776,7 +776,19 @@ paths: den är. forsegling: 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 } "401": { $ref: "#/components/responses/Fel" } "404": { $ref: "#/components/responses/Fel" } diff --git a/felsokning/services/plattform/server.mjs b/felsokning/services/plattform/server.mjs index 095a2e6..3d6b8d5 100644 --- a/felsokning/services/plattform/server.mjs +++ b/felsokning/services/plattform/server.mjs @@ -516,22 +516,31 @@ async function skrivKedjat(arendeId, poster) { ); let sekvens = Number(sista.rows[0]?.sekvens ?? 0); let h = sista.rows[0]?.kedjehash ?? grund(arendeId); + let skrivna = 0; for (const p of poster) { 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( `insert into felsokning_handelser (id, arende_id, tidpunkt, anvandare, handelse, klientdigest, sekvens, kedjehash) values ($1, $2, $3, $4, $5, $6, $7, $8) 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: // länken ovan förbrukas bara när raden faktiskt sattes in. if (r.rowCount > 0) { sekvens += 1; h = nastaH; + skrivna += 1; } } @@ -556,7 +565,7 @@ async function skrivKedjat(arendeId, poster) { } await db.query("commit"); - return { rot: h }; + return { rot: h, skrivna, dubbletter: poster.length - skrivna }; } catch (fel) { await db.query("rollback").catch(() => {}); throw fel; @@ -569,7 +578,7 @@ async function grindHinder(pool, arendeId, nya, sprak = STANDARD) { const rader = await pool.query( `select h.handelse, a.metodik_id from felsokning_handelser h 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], ); const handelser = [...rader.rows.map((r) => r.handelse), ...nya.map((p) => p.handelse)]; @@ -1288,12 +1297,12 @@ export function skapaServer() { ? await pool.query( `select id, tidpunkt, anvandare, handelse from felsokning_handelser where arende_id = $1 and handelse->>'typ' = any($2) - order by tidpunkt, id`, + order by sekvens nulls first, tidpunkt, id`, [arendeId, synliga], ) : await pool.query( `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], ); return svara(res, 200, { arende: arende.rows[0], handelser: handelser.rows, niva }); @@ -2305,7 +2314,7 @@ export function skapaServer() { } const rader = await pool.query( `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]], ); const nycklar = await nycklarFor(anspr.org); @@ -2380,23 +2389,11 @@ export function skapaServer() { poster.push(post); } - let skrivna = 0; - let dubbletter = 0; - { - // Kedjat via samma väg som allt annat. Räkningen görs mot - // loggen efteråt — skrivKedjat äger transaktionen. - 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; - } + // Kedjat via samma väg som allt annat, och räkningen kommer ur + // transaktionen som gjorde jobbet — inte ur två count-frågor + // runtomkring, där en samtidig skrivning från någon annan + // hamnade i vår siffra (revision 3, m-2). + const { skrivna, dubbletter } = await skrivKedjat(protokollVag[1], poster); // Delvis lyckad inläsning är inte 200. Mottagaren ska behöva // hantera att något inte kom in. @@ -2565,31 +2562,64 @@ export function skapaServer() { ); const okedjade = rader.rows.filter((r) => r.sekvens == null).length; - // Förseglingen prövas mot den OMRÄKNADE roten, inte den lagrade: - // en angripare som räknat om kedjan och roten stoppas av att - // HMAC:en inte går att räkna om utan nyckeln. + // Förseglingen täcker sitt PREFIX, inte kedjans nuvarande rot. + // + // 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( `select kedjerot, forsegling, forseglad from felsokning_arenden where id = $1`, [kedjeVag[1]], ); const a = arende.rows[0] ?? {}; let forsegling = "saknas"; + let efterForsegling = null; if (a.forsegling) { - if (!FORSEGLING_NYCKEL) forsegling = "kan inte prövas — FORSEGLING_NYCKEL saknas"; - else if ( - kedja.ok && - a.kedjerot === kedja.rot && - provaForsegling( - FORSEGLING_NYCKEL, - kedjeVag[1], - a.kedjerot, - new Date(a.forseglad).toISOString(), - a.forsegling, - ) - ) { - forsegling = "giltig"; + if (!FORSEGLING_NYCKEL) { + forsegling = "kan inte prövas — FORSEGLING_NYCKEL saknas"; } 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, okedjade, 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: "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") { const rader = await pool.query( `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]], ); const nycklar = await nycklarFor(anspr.org);