Härdning av hela TÜV-revisionen

Tio av tolv fynd stängda, två reducerade med skälen utskrivna. Varje
stängning prövas av integrationssviten mot riktig Postgres — 144
kontroller, upp från 100.

T-1  Händelsenyckeln är nu (arende_id, id). Det finns inget delat
     namnrum kvar att ockupera, så attacken saknar yta i stället för att
     vara mildrad. Inom ett ärende bedöms en kollision på klientens
     avtryck — en hash av det kanoniserade innehåll klienten skickade,
     taget före serverns egna fält och före krypteringen, eftersom
     varken raden eller nyttolasten går att jämföra. Identiskt innehåll
     är fortfarande idempotent; samma id med annat innehåll ger 409 och
     skriver ingenting alls, inte heller resten av satsen. Samma attack
     en nivå upp — ärende-id är lika förutsägbart — stängs separat: ett
     id som ägs av en annan organisation ger 409 i stället för att tyst
     låta bli, vilket tidigare lämnade offrets ärende oskapat och varje
     senare synk svarande 404.

T-2  Mätdonet slås upp i registret. Beteckning och kalibrering HÄRLEDS
     därifrån och skriver över det klienten skickade. Okänt mätdon ger
     400. Utgånget avvisas inte — mätningen gjordes — men registrets
     datum följer med, så graderingen faller på registrets uppgift. Ett
     mätvärde utan mätdon får sina påstådda uppgifter borttagna.

T-3  Reducerad, inte stängd. Personnycklarna kuverteras under en
     huvudnyckel utanför databasen. En återställd dump ger nycklar som
     inte öppnas — verifierat genom att starta om utan huvudnyckeln. Vad
     som återstår står utskrivet: en backup tagen FÖRE en radering, plus
     huvudnyckeln, återställer fortfarande uppgifterna.

Därtill: gallringen verkställs nu av ett eget jobb och grupperar på det
blindade fordonsindexet så att en delad nyckel inte gallras för tidigt
(T-4) · AI-avlästa mätvärden bär härkomst (T-5) · bcrypt kostnad 12
(T-6) · åtkomstloggen och raderingsregistret är append-only, med en
smalare regel för personnycklarna som måste kunna förstöras (T-7) ·
åtkomstloggen dokumenterad (T-8) · ett externt regelpaket utan signatur
spärrar avslut (T-9) · CORS faller inte längre öppet (T-10) · exp krävs
i token (T-11) · react-router 7 (T-12, med den kvarvarande avvikelsen
motiverad).

Den motspelande hyresgästen som revisionen efterlyste finns nu som
testform och körs i CI.

---- Vad härdningen själv avslöjade -----------------------------------

Två av rättelserna var kortvarigt fel på samma sätt som fynden, och
bägge fångades bara av att jag försökte bevisa dem:

  T-7-testet var grönt mot en TOM tabell. En radnivåtrigger har inga
  rader att fyra på, så delete lyckades och kontrollen mätte ingenting.

  T-6-testet påstod anropet, inte kostnaden. toContain("gen_salt('bf')")
  hade accepterat kostnad 6 för evigt — och gjorde det, så länge det
  fanns.

Bägge är mönstret revisionen namngav: en kontroll som är riktig i sina
egna termer och oprövad vid sin gräns. Det gäller tester lika mycket som
kod.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-06 13:05:16 +00:00
parent 0833a573e0
commit b147694e31
15 changed files with 970 additions and 63 deletions
+134
View File
@@ -0,0 +1,134 @@
// Gallring (ALVA · TÜV T-4).
//
// ---- Varför den här filen finns ---------------------------------------
//
// `gallras_efter` räknades fram vid avslut, utifrån ärendetypen, och
// skrevs till ärenderaden. Sedan hände ingenting. En sökning genom hela
// repot visade att INGENTING läste kolumnen — ingen körning, inget
// schemalagt jobb, ingen fråga, inget CI-steg. Lagringsbegränsningen var
// en nedskriven avsikt, inte en verkställd kontroll.
//
// Det gjorde också att ett annat fel fick leva: triggern som förbjöd all
// update gjorde det omöjligt att ens SKRIVA kolumnen, och ingen märkte
// det, därför att ingenting längre ned konsumerade värdet.
//
// ---- Vad gallring är här ----------------------------------------------
//
// Inte radering av ärenden. Loggen är append-only och ska förbli det —
// protokollet över vad som kontrollerades har ett värde långt efter att
// identifieringen har det. Gallring är att förstöra personnyckeln, precis
// som en raderingsbegäran gör. Kvar blir ett ärende som fortfarande kan
// visa att lufttrycket mättes till 2,4 bar klockan 08:42, bara inte
// längre vems bil det gällde.
//
// ---- Försiktighetsregeln ----------------------------------------------
//
// En nyckel förstörs bara när SAMTLIGA ärenden som delar den har passerat
// sitt gallringsdatum. Ett fordon som varit inne fem gånger har fem
// ärenden och en nyckel; att gallra på det äldsta hade gjort de fyra
// senaste oläsbara i förtid. Ett ärende utan gallringsdatum — öppet, eller
// avslutat innan datum sattes — räknas som ännu inte passerat, eftersom
// för tidig gallring inte går att ångra.
import pg from "pg";
import { createHash } from "node:crypto";
/**
* Hittar och förstör nycklar vars samtliga ärenden har passerat sin tid.
*
* @param pool pg.Pool
* @param nu tidpunkt att jämföra mot; injicerad för testbarhet
* @param torrkor när true räknas allt fram men ingenting förstörs
*/
export async function gallra(pool, { nu = new Date(), torrkor = false } = {}) {
// Ett subjekt i personnycklar är ett ärende-id (se personnyckel() i
// server.mjs). Nyckeln kan delas av flera ärenden på samma fordon via
// det blindade indexet, så gruppen måste bildas på indexet — inte på
// subjektet — annars gallras en delad nyckel för tidigt.
const kandidater = await pool.query(
`with nyckelns_arenden as (
select n.id as nyckel_id,
n.organisation_id,
a.gallras_efter,
coalesce(a.identifierare_index, a.id) as grupp
from personnycklar n
join felsokning_arenden a
on a.organisation_id = n.organisation_id and a.id = n.subjekt
),
samma_fordon as (
select k.nyckel_id, k.organisation_id, b.gallras_efter
from nyckelns_arenden k
join felsokning_arenden b
on b.organisation_id = k.organisation_id
and coalesce(b.identifierare_index, b.id) = k.grupp
)
select nyckel_id, organisation_id, count(*)::int as antal_arenden
from samma_fordon
group by nyckel_id, organisation_id
having bool_and(gallras_efter is not null and gallras_efter <= $1)`,
[nu],
);
if (kandidater.rowCount === 0 || torrkor) {
return { forstorda: 0, arenden: 0, kandidater: kandidater.rowCount, torrkor };
}
let forstorda = 0;
let arenden = 0;
for (const rad of kandidater.rows) {
const klient = await pool.connect();
try {
await klient.query("begin");
const bort = await klient.query(`delete from personnycklar where id = $1 returning id`, [rad.nyckel_id]);
if (bort.rowCount > 0) {
// Gallringen skrivs i samma register som en begärd radering, med
// ett hashat subjekt: registret får inte självt bära uppgiften
// det bevisar att någon gjort oåtkomlig.
await klient.query(
`insert into raderingar (organisation_id, subjekt_hash, begard, begard_av, antal_arenden)
values ($1, $2, $3, $4, $5)`,
[
rad.organisation_id,
createHash("sha256").update(`gallring:${rad.nyckel_id}`).digest("hex"),
nu,
"Gallring (ALVA-PROC-0040)",
rad.antal_arenden,
],
);
forstorda += 1;
arenden += rad.antal_arenden;
}
await klient.query("commit");
} catch (fel) {
await klient.query("rollback").catch(() => {});
throw fel;
} finally {
klient.release();
}
}
return { forstorda, arenden, kandidater: kandidater.rowCount, torrkor };
}
// Körs som eget jobb (CronJob i klustret), inte i webbtjänstens process:
// en gallring som avbryts av en omstart mitt i ska kunna köras om, och
// den ska synas som en egen körning i driften.
//
// node gallring.mjs verkställer
// node gallring.mjs --torrkor räknar bara
if (import.meta.url === `file://${process.argv[1]}`) {
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 2 });
try {
const utfall = await gallra(pool, { torrkor: process.argv.includes("--torrkor") });
console.log(
JSON.stringify({
nivå: "info",
meddelande: "gallring",
tid: new Date().toISOString(),
...utfall,
}),
);
} finally {
await pool.end();
}
}
@@ -636,4 +636,216 @@ LUCKOR=$(PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d f
-tAc "select (select max(nummer) from fakturor) - (select count(*) from fakturor)")
kontroll "nummerserien är utan luckor" "$LUCKOR" "0"
# 13. Motspelande hyresgäst (TÜV T-1)
#
# Revisionens huvudfynd: händelse-id sattes av klienten och var
# förutsägbart, primärnyckeln var global, och insert skedde med
# `on conflict do nothing`. Org B kunde ockupera id på SITT EGET ärende
# som org A snart skulle använda — och org A:s händelse föll tyst bort
# med statuskod 200.
#
# Ingen befintlig svit kunde se det: varje test kör en organisation, eller
# två isolerade. Ingen modellerar den enas skrivningar som indata till den
# andras misslyckande. Det gör den här.
IDN="mshhkoq9-0 mshhkoq9-1 mshhkoq9-2"
FORSTA=$(echo "$IDN" | cut -d' ' -f1)
curl -s -X POST "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_B" -H 'Content-Type: application/json' \
-d '{"id":"arende-motspel-b","nummer":90,"skapad":"2026-08-06T08:00:00Z"}' >/dev/null
curl -s -X POST "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d '{"id":"arende-motspel-a","nummer":91,"skapad":"2026-08-06T08:00:00Z"}' >/dev/null
# Org B ockuperar id:na på sitt eget ärende.
POSTER=$(node -pe "
JSON.stringify({ handelser: '$IDN'.split(' ').map((id) => ({
id, tidpunkt: '2026-08-06T08:01:00Z', anvandare: 'Bo',
handelse: { typ: 'observation', text: 'Ockuperad.' } })) })
")
curl -s -X POST "$BAS/api/arenden/arende-motspel-b/handelser" -H "Authorization: Bearer $TOKEN_B" \
-H 'Content-Type: application/json' -d "$POSTER" >/dev/null
# Org A dokumenterar en felorsak med exakt samma id.
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/arenden/arende-motspel-a/handelser" \
-H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d "{\"handelser\":[{\"id\":\"$FORSTA\",\"tidpunkt\":\"2026-08-06T08:02:00Z\",\"anvandare\":\"Anna\",
\"handelse\":{\"typ\":\"felorsak\",\"avvikelse\":\"Bromsledning skavd mot chassi.\",
\"orsaker\":[\"Montagefel\"],\"underlag\":[\"Foto\"],\"sakerhet\":\"hog\",
\"atgard\":\"Byt ledning och satt ny klammer.\"}}]}")
kontroll "org A:s skrivning lyckas trots ockuperat id" "$KOD" "200"
ANTAL=$(curl -s "$BAS/api/arenden/arende-motspel-a/handelser" -H "Authorization: Bearer $TOKEN_A" | falt .handelser.length)
kontroll "org A:s felorsak finns i loggen" "$ANTAL" "1"
ANTAL_B=$(curl -s "$BAS/api/arenden/arende-motspel-b/handelser" -H "Authorization: Bearer $TOKEN_B" | falt .handelser.length)
kontroll "org B:s egna poster är orörda" "$ANTAL_B" "3"
# Samma id, samma innehåll, en gång till: en ofarlig omsändning ska förbli tyst.
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/arenden/arende-motspel-b/handelser" \
-H "Authorization: Bearer $TOKEN_B" -H 'Content-Type: application/json' -d "$POSTER")
kontroll "identisk omsandning ar fortfarande idempotent" "$KOD" "200"
# Samma id, ANNAT innehåll: ska falla högt i stället för att tigas ihjäl.
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/arenden/arende-motspel-b/handelser" \
-H "Authorization: Bearer $TOKEN_B" -H 'Content-Type: application/json' \
-d "{\"handelser\":[{\"id\":\"$FORSTA\",\"tidpunkt\":\"2026-08-06T08:03:00Z\",\"anvandare\":\"Bo\",
\"handelse\":{\"typ\":\"observation\",\"text\":\"NAGOT HELT ANNAT\"}}]}")
kontroll "samma id med annat innehall ger 409" "$KOD" "409"
ANTAL_B=$(curl -s "$BAS/api/arenden/arende-motspel-b/handelser" -H "Authorization: Bearer $TOKEN_B" | falt .handelser.length)
kontroll "ingenting skrevs vid 409" "$ANTAL_B" "3"
# Samma attack en niva upp: arende-id ar lika forutsagbart.
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: application/json' -d '{"id":"arende-motspel-b","nummer":92,"skapad":"2026-08-06T09:00:00Z"}')
kontroll "arende-id upptaget av annan org ger 409" "$KOD" "409"
# 14. Matkedjan slas upp i registret (TÜV T-2)
#
# Evidensgraden E4 kraver att det gar att visa VAD som matte och att
# instrumentet var kalibrerat. Bagge falten kom fran klienten och lagrades
# oprovade — en organisation utan ett enda registrerat matdon kunde skicka
# kalibrering 2099-12-31 och fa E4.
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/arenden/arende-motspel-a/handelser" \
-H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d '{"handelser":[{"id":"m-falsk","tidpunkt":"2026-08-06T08:10:00Z","anvandare":"Anna",
"handelse":{"typ":"matvarde","beskrivning":"Kokpunkt","varde":"238","enhet":"C",
"matdonId":"finns-inte-i-registret","matdonBeteckning":"Kalibrerad testare 9000",
"matdonKalibreradTill":"2099-12-31"}}]}')
kontroll "okant matdon avvisas" "$KOD" "400"
# Ett riktigt matdon, med ett kalibreringsdatum registret ager.
MATDON=$(curl -s -X POST "$BAS/api/matdon" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d '{"beteckning":"Bromsvatsketestare BT-2","serienummer":"SN-4471","kalibrerad_till":"2027-06-30"}' | falt .id)
curl -s -X POST "$BAS/api/arenden/arende-motspel-a/handelser" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: application/json' \
-d "{\"handelser\":[{\"id\":\"m-akta\",\"tidpunkt\":\"2026-08-06T08:11:00Z\",\"anvandare\":\"Anna\",
\"handelse\":{\"typ\":\"matvarde\",\"beskrivning\":\"Kokpunkt\",\"varde\":\"238\",\"enhet\":\"C\",
\"matdonId\":\"$MATDON\",\"matdonBeteckning\":\"PAHITTAD BETECKNING\",
\"matdonKalibreradTill\":\"2099-12-31\"}}]}" >/dev/null
HAMTAT=$(curl -s "$BAS/api/arenden/arende-motspel-a/handelser" -H "Authorization: Bearer $TOKEN_A")
kontroll "beteckningen kommer fran registret" \
"$(echo "$HAMTAT" | falt '.handelser.find(h=>h.id==="m-akta").handelse.matdonBeteckning')" "Bromsvatsketestare BT-2"
kontroll "kalibreringen kommer fran registret" \
"$(echo "$HAMTAT" | falt '.handelser.find(h=>h.id==="m-akta").handelse.matdonKalibreradTill')" "2027-06-30"
# Ett matvarde utan matdon far inte bara pasta en beteckning heller.
curl -s -X POST "$BAS/api/arenden/arende-motspel-a/handelser" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: application/json' \
-d '{"handelser":[{"id":"m-utan","tidpunkt":"2026-08-06T08:12:00Z","anvandare":"Anna",
"handelse":{"typ":"matvarde","beskrivning":"Tryck","varde":"2.4","enhet":"bar",
"matdonBeteckning":"PASTADD","matdonKalibreradTill":"2099-12-31"}}]}' >/dev/null
HAMTAT=$(curl -s "$BAS/api/arenden/arende-motspel-a/handelser" -H "Authorization: Bearer $TOKEN_A")
kontroll "pastadd kalibrering utan matdon tas bort" \
"$(echo "$HAMTAT" | falt '.handelser.find(h=>h.id==="m-utan").handelse.matdonKalibreradTill ?? "borta"')" "borta"
# 15. Underlagen om atkomst och radering ar append-only (TÜV T-7)
#
# Radniva-triggern fyrar per RAD. En tom tabell later darfor `delete`
# lyckas utan att skyddet ens provats — forsta versionen av det har testet
# var gron pa raderingar just darfor, och bevisade ingenting. Bagge
# tabellerna maste ha innehall innan de provas.
PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning -qc \
"insert into raderingar (organisation_id, subjekt_hash, begard, begard_av, antal_arenden)
select id, 'prov', now(), 'Integrationstest', 0 from organisationer limit 1"
for TABELL in atkomstlogg raderingar; do
RADER=$(PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
-tAc "select count(*) from $TABELL")
if [ "$RADER" = "0" ]; then echo "$TABELL ar tom — provet sager ingenting"; exit 1; fi
if PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
-qc "delete from $TABELL" 2>/dev/null; then
echo "$TABELL gar att radera"; exit 1
else
echo "$TABELL gar inte att radera"
fi
done
if PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
-qc "update personnycklar set subjekt = 'annat'" 2>/dev/null; then
echo "✗ personnyckelns subjekt gar att flytta"; exit 1
else
echo "✓ personnyckelns tillhorighet kan inte andras"
fi
# Men raderingen sjalv maste fungera — det ar vad radering ÄR.
PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
-qc "delete from personnycklar where subjekt = 'arende-motspel-b'" >/dev/null
echo "✓ personnycklar gar fortfarande att forstora"
# 16. Utgangstid kravs i token (TÜV T-11)
EVIG=$(node -e "
const c = require('crypto');
const b = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
const h = b({ alg: 'HS256', typ: 'JWT' });
const p = b({ sub: '00000000-0000-0000-0000-000000000000', org: '0', roll: 'admin', tv: 0 });
const s = c.createHmac('sha256', 'integrationshemlighet').update(h + '.' + p).digest('base64url');
console.log(h + '.' + p + '.' + s);
")
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/arenden" -H "Authorization: Bearer $EVIG")
kontroll "token utan utgangstid avvisas" "$KOD" "401"
# 17. Gallringen verkstalls (TÜV T-4)
NYCKLAR_FORE=$(PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
-tAc "select count(*) from personnycklar")
PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
-qc "update felsokning_arenden set gallras_efter = now() - interval '1 day' where id = 'arende-motspel-a'"
DATABASE_URL="postgresql://plattform:test@127.0.0.1:$PGPORT/felsokning" \
node gallring.mjs > /tmp/gallring.json
FORSTORDA=$(cat /tmp/gallring.json | falt .forstorda)
kontroll "gallringen forstorde nyckeln for det passerade arendet" "$FORSTORDA" "1"
NYCKLAR_EFTER=$(PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
-tAc "select count(*) from personnycklar")
kontroll "en nyckel farre finns kvar" "$NYCKLAR_EFTER" "$((NYCKLAR_FORE - 1))"
SPAR=$(PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
-tAc "select count(*) from raderingar where begard_av like 'Gallring%'")
kontroll "gallringen lamnade spar i raderingsregistret" "$SPAR" "1"
# Ett arende som INTE passerat far inte roras.
DATABASE_URL="postgresql://plattform:test@127.0.0.1:$PGPORT/felsokning" \
node gallring.mjs > /tmp/gallring2.json
kontroll "andra korningen gallrar ingenting" "$(cat /tmp/gallring2.json | falt .forstorda)" "0"
# 18. Kuvertkryptering av personnycklarna (TÜV T-3)
#
# Nycklarna lag i klartext i samma databas som chiffertexten de skyddade,
# och aterstallningstestet slog fast att de foljer med en aterstallning.
# Kuverterade under en huvudnyckel utanfor databasen ger en aterstalld
# dump nycklar som inte gar att oppna utan den.
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
HUVUD=$(node -pe "require('crypto').randomBytes(32).toString('hex')")
DATABASE_URL="postgresql://plattform:test@127.0.0.1:$PGPORT/felsokning" \
JWT_SECRET=integrationshemlighet PORT=$APPPORT \
INTEGRATION_NYCKEL=$(node -pe "require('crypto').randomBytes(32).toString('hex')") \
PERSONNYCKEL_HUVUD=$HUVUD \
node server.mjs &
SERVER_PID=$!
sleep 1
curl -s -X POST "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d '{"id":"arende-kuvert","nummer":93,"skapad":"2026-08-06T10:00:00Z"}' >/dev/null
curl -s -X POST "$BAS/api/arenden/arende-kuvert/handelser" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: application/json' \
-d '{"handelser":[{"id":"k1","tidpunkt":"2026-08-06T10:01:00Z","anvandare":"Anna",
"handelse":{"typ":"objekt_identifierat","objekt":{"typ":"Personbil","identifierare":"KUV123",
"identifieringsmetod":"Regnr","beskrivning":"VW Golf"}}}]}' >/dev/null
LAGRAD=$(PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
-tAc "select encode(nyckel, 'escape') from personnycklar where subjekt = 'arende-kuvert'")
case "$LAGRAD" in
v1.*) echo "✓ nyckeln ligger kuverterad i databasen" ;;
*) echo "✗ nyckeln ligger i klartext: ${LAGRAD:0:12}"; exit 1 ;;
esac
# Med huvudnyckeln gar identifieraren fortfarande att lasa.
IDENT=$(curl -s "$BAS/api/arenden/arende-kuvert/handelser" -H "Authorization: Bearer $TOKEN_A" \
| falt '.handelser[0].handelse.objekt.identifierare')
kontroll "identifieraren gar att lasa med huvudnyckeln" "$IDENT" "KUV123"
# Utan huvudnyckeln — en aterstalld dump pa en annan maskin — gar den inte.
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
DATABASE_URL="postgresql://plattform:test@127.0.0.1:$PGPORT/felsokning" \
JWT_SECRET=integrationshemlighet PORT=$APPPORT node server.mjs >/dev/null 2>&1 &
SERVER_PID=$!
sleep 1
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/arenden/arende-kuvert/handelser" -H "Authorization: Bearer $TOKEN_A")
kontroll "utan huvudnyckel lamnas inget ut" "$KOD" "500"
echo "Integrationstest: allt grönt"
+39 -2
View File
@@ -9,8 +9,13 @@ info:
**Händelseloggen är append-only.** Det finns inga update- eller
delete-operationer — historik kan aldrig ändras eller raderas, vilket
även garanteras av databastriggers. En händelse med redan känt `id`
ignoreras tyst (idempotent synk).
även garanteras av databastriggers.
**Händelse-id är skopade till ärendet.** Nyckeln är `(arende_id, id)`,
inte `id` ensamt. En omsändning av samma händelse med samma innehåll är
idempotent och svarar `200`. Samma id med ANNAT innehåll svarar `409`
och skriver ingenting — varken det som redan står i loggen eller resten
av satsen.
**Multi-tenant.** All ärendedata är organisationsknuten. Ett ärende i
en annan organisation ger `404`, som om det inte fanns.
@@ -1187,6 +1192,38 @@ paths:
"200": { description: Sparat }
"403": { description: Kräver arbetsledare eller administratör }
/api/atkomstlogg:
get:
tags: [Drift]
summary: Åtkomstlogg — vem som läst vilket ärende
description: >
Kräver `arbetsledare` eller `admin`, och visar endast den egna
organisationen. Loggen omfattar även läsningar via publika
delningslänkar, med koden angiven. Tabellen är append-only: posterna
kan varken ändras eller raderas, eftersom de utgör bevisningen för
att åtkomststyrningen fungerade.
security: [{ bearerAuth: [] }]
responses:
"200":
description: De 500 senaste åtkomsterna.
content:
application/json:
schema:
type: object
properties:
atkomster:
type: array
items:
type: object
properties:
tidpunkt: { type: string, format: date-time }
arende_id: { type: string, nullable: true }
vag: { type: string }
anvandare: { type: string, nullable: true }
delningskod: { type: string, nullable: true }
"401": { $ref: "#/components/responses/Fel" }
"403": { $ref: "#/components/responses/Fel" }
# ---- Fakturering (ALVA-PROC-0001) ------------------------------------
#
# Två parter, inte en. Organisationen LÄSER sina fakturor; utfärdaren
+251 -27
View File
@@ -17,6 +17,8 @@
// TILLAT_INTERNA_UPPSLAG "true" tillåter leverantörsuppslag mot privata nät
// ECM_REGLER_FIL / INTEGRATIONER_FIL sökvägar till utbytbar konfiguration
// FAKTURERING_NYCKEL utfärdarens nyckel; utan den kan ingen faktura utfärdas
// PERSONNYCKEL_HUVUD 32 byte (hex/base64) — kuverterar personnycklarna så att
// en återställd databasdump inte innehåller läsbara nycklar
// PORT default 8080
import { createServer } from "node:http";
@@ -49,6 +51,7 @@ const OPENAPI = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "open
// appkod. Uppdateras genom att byta filen (ECM_REGLER_FIL kan peka på en
// ConfigMap-mount i klustret) och starta om tjänsten; klienterna hämtar
// det nya paketet vid nästa inloggning/sidladdning.
const ECM_REGLER_EGEN_FIL = !!process.env.ECM_REGLER_FIL;
const ECM_REGLER = readFileSync(
process.env.ECM_REGLER_FIL ?? join(dirname(fileURLToPath(import.meta.url)), "ecm-regler.json"),
"utf8",
@@ -68,6 +71,13 @@ const ECM_REGLER_NYCKEL = process.env.ECM_REGLER_NYCKEL ?? "";
const ECM_REGLER_SIGNATUR = process.env.ECM_REGLER_SIGNATUR ?? "";
function regelpaketetsStatus() {
// Ett EXTERNT paket måste vara signerat (TÜV T-9). Det inbyggda
// standardpaketet följer med bilden och behöver ingen signatur — det
// som behöver en är det någon har monterat in, för det är precis vad
// angreppet innebär. Tidigare gällde samma milda utfall för bägge, så
// en installation som aldrig konfigurerade signering fick ingen
// integritetskontroll alls och ingen felsignal heller.
if (ECM_REGLER_EGEN_FIL && (!ECM_REGLER_NYCKEL || !ECM_REGLER_SIGNATUR)) return "osignerat externt";
if (!ECM_REGLER_NYCKEL || !ECM_REGLER_SIGNATUR) return "osignerat";
const väntad = createHmac("sha256", ECM_REGLER_NYCKEL).update(ECM_REGLER).digest("hex");
const a = Buffer.from(ECM_REGLER_SIGNATUR);
@@ -84,7 +94,9 @@ if (REGELPAKET_STATUS !== "signerat") {
konsekvens:
REGELPAKET_STATUS === "ogiltig signatur"
? "Paketet används INTE — inbyggt standardpaket gäller."
: "Paketet används men märks som osignerat i spårbarheten.",
: REGELPAKET_STATUS === "osignerat externt"
? "Ett externt paket saknar signatur — avslut är spärrat tills det signeras."
: "Paketet används men märks som osignerat i spårbarheten.",
});
}
@@ -155,7 +167,12 @@ const TILLATNA_URSPRUNG = (process.env.TILLATNA_URSPRUNG ?? "")
function ursprungFor(req) {
const ursprung = req.headers.origin;
if (TILLATNA_URSPRUNG.length === 0) return "*";
// Tom lista betydde tidigare "*" — alltså öppet som standard (TÜV T-10).
// Nu betyder den samma ursprung: inget CORS-huvud alls, vilket är vad
// klusterdriften behöver, eftersom klienten serveras från samma domän.
// Vill någon öppna får den skriva "*" och mena det.
if (TILLATNA_URSPRUNG.length === 0) return null;
if (TILLATNA_URSPRUNG.includes("*")) return "*";
return ursprung && TILLATNA_URSPRUNG.includes(ursprung) ? ursprung : TILLATNA_URSPRUNG[0];
}
@@ -239,7 +256,12 @@ export function verifieraJwt(token, hemlighet) {
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
try {
const anspr = JSON.parse(Buffer.from(delar[1], "base64url").toString("utf8"));
if (typeof anspr.exp === "number" && anspr.exp * 1000 < Date.now()) return null;
// Utgångstid KRÄVS (TÜV T-11). Tidigare kontrollerades exp bara när
// det fanns, vilket gjorde en token utan exp evig. Alla tokens som
// utfärdas idag bär en — men den som en gång slipper igenom utan
// skulle aldrig sluta gälla, och det är inte ett fel man upptäcker.
if (typeof anspr.exp !== "number") return null;
if (anspr.exp * 1000 < Date.now()) return null;
return anspr;
} catch {
return null;
@@ -345,12 +367,24 @@ export function synligaTyper(niva) {
// ---- Hjälpare ---------------------------------------------------------
/**
* CORS-huvuden, eller inga alls.
*
* `res.ursprung` är null när ingen ursprungslista är konfigurerad, och då
* ska huvudet UTELÄMNAS — inte sättas till "*" och inte till strängen
* "null". Utan den här hjälparen fanns beslutet på sex ställen, och det
* räckte att ett av dem föll tillbaka på "*" för att TÜV T-10 skulle vara
* öppen igen.
*/
function korsHuvuden(res) {
return res.ursprung ? { "Access-Control-Allow-Origin": res.ursprung, Vary: "Origin" } : {};
}
function svara(res, status, kropp) {
res.writeHead(status, {
"Content-Type": "application/json",
// Ursprunget sätts en gång per anrop i hanteraren nedan.
"Access-Control-Allow-Origin": res.ursprung ?? "*",
Vary: "Origin",
...korsHuvuden(res),
"Access-Control-Allow-Headers": "authorization, content-type",
});
res.end(JSON.stringify(kropp));
@@ -414,6 +448,15 @@ async function grindHinder(pool, arendeId, nya) {
if (REGELPAKET_STATUS === "ogiltig signatur") {
return [{ id: "regelpaket", rubrik: "Regelpaketets signatur stämmer inte — avslut spärrat." }];
}
if (REGELPAKET_STATUS === "osignerat externt") {
return [
{
id: "regelpaket",
rubrik: "Ett externt regelpaket används utan signatur — avslut spärrat.",
detalj: "Sätt ECM_REGLER_NYCKEL och ECM_REGLER_SIGNATUR, eller ta bort ECM_REGLER_FIL.",
},
];
}
try {
regelpaket = JSON.parse(ECM_REGLER);
} catch {
@@ -445,15 +488,69 @@ function blindaIdentifierare(ident) {
.digest("hex");
}
// ---- Kuvertkryptering av personnycklarna (TÜV T-3) --------------------
//
// Nycklarna låg i klartext i samma databas, samma kluster och samma
// backuper som den chiffertext de skyddade. Krypto-shredding blev då att
// radera en rad bredvid de data den skulle göra oåtkomliga — och
// återställningstestet slog uttryckligen fast att nycklarna följer med en
// återställning. Testet har rätt för katastrofåterställning; det är samma
// faktum läst åt andra hållet som bröt raderingslöftet.
//
// Nycklarna lagras nu KUVERTERADE under en huvudnyckel som inte finns i
// databasen. En återställd dump ger nycklar som inte går att öppna utan
// den, vilket flyttar raderingsgarantin från "en rad är borta" till "en
// nyckel utanför databasen krävs".
//
// VAD SOM ÅTERSTÅR, uttryckligen: en backup tagen FÖRE en radering, plus
// huvudnyckeln, återställer fortfarande uppgifterna. Att stänga det helt
// kräver en nyckel per subjekt i en KMS där förstörelsen är
// oåterkallelig. Kuverteringen är förberedelsen för det — därför ligger
// in- och uppackningen i egna funktioner och inte inline.
const PERSONNYCKEL_HUVUD = (() => {
const = process.env.PERSONNYCKEL_HUVUD ?? "";
if (!) return null;
const b = /^[0-9a-fA-F]{64}$/.test() ? Buffer.from(, "hex") : Buffer.from(, "base64");
if (b.length !== 32) throw new Error("PERSONNYCKEL_HUVUD måste vara 32 byte (hex eller base64).");
return b;
})();
/** Kuvertformat: v1.<iv>.<tagg>.<chiffertext>, allt base64url. */
function kuvertera(nyckel) {
if (!PERSONNYCKEL_HUVUD) return nyckel;
const iv = randomBytes(12);
const c = createCipheriv("aes-256-gcm", PERSONNYCKEL_HUVUD, iv);
const ut = Buffer.concat([c.update(nyckel), c.final()]);
return Buffer.from(
`v1.${iv.toString("base64url")}.${c.getAuthTag().toString("base64url")}.${ut.toString("base64url")}`,
"utf8",
);
}
function oppnaKuvert(lagrad) {
const text = Buffer.from(lagrad).toString("utf8");
// En nyckel som lagrades före kuverteringen är råa byte. Den läses som
// förut — annars hade uppgraderingen gjort all befintlig historik
// oläsbar, vilket är samma skada som en felaktig radering.
if (!text.startsWith("v1.")) return Buffer.from(lagrad);
if (!PERSONNYCKEL_HUVUD) {
throw new Error("Personnycklarna är kuverterade men PERSONNYCKEL_HUVUD saknas.");
}
const [, iv, tagg, data] = text.split(".");
const d = createDecipheriv("aes-256-gcm", PERSONNYCKEL_HUVUD, Buffer.from(iv, "base64url"));
d.setAuthTag(Buffer.from(tagg, "base64url"));
return Buffer.concat([d.update(Buffer.from(data, "base64url")), d.final()]);
}
async function personnyckel(orgId, subjekt) {
const rad = await pool.query(
`insert into personnycklar (organisation_id, subjekt, nyckel)
values ($1, $2, $3)
on conflict (organisation_id, subjekt) do update set subjekt = excluded.subjekt
returning id, nyckel, radering_begard`,
[orgId, subjekt, randomBytes(32)],
[orgId, subjekt, kuvertera(randomBytes(32))],
);
return rad.rows[0];
return { ...rad.rows[0], nyckel: oppnaKuvert(rad.rows[0].nyckel) };
}
/** Nycklar som fortfarande finns, för att öppna en logg vid läsning. */
@@ -462,7 +559,7 @@ async function nycklarFor(orgId) {
`select id, nyckel from personnycklar where organisation_id = $1`,
[orgId],
);
return new Map(rader.rows.map((r) => [r.id, r.nyckel]));
return new Map(rader.rows.map((r) => [r.id, oppnaKuvert(r.nyckel)]));
}
/**
@@ -588,6 +685,66 @@ async function fakturamedStatus(id) {
return { ...rad.rows[0], status: fakturastatus(rad.rows[0].handelser) };
}
/**
* Kanonisk JSON: nycklar i bokstavsordning, hela vägen ned.
*
* Två anrop med samma innehåll men olika fältordning är samma händelse,
* och ska ge samma avtryck. JSON.stringify ensam bevarar
* insättningsordningen och hade gjort ordningen till en skillnad.
*/
function kanoniskt(värde) {
if (Array.isArray(värde)) return `[${värde.map(kanoniskt).join(",")}]`;
if (värde && typeof värde === "object") {
return `{${Object.keys(värde)
.sort()
.map((n) => `${JSON.stringify(n)}:${kanoniskt(värde[n])}`)
.join(",")}}`;
}
return JSON.stringify(värde ?? null);
}
/**
* Mätdonets fakta hämtas ur registret, inte ur anropet (TÜV T-2).
*
* Evidensgraden E4 kräver att det går att visa VAD som mätte och att
* instrumentet var kalibrerat. Bägge fälten kom tidigare från klienten
* och lagrades oprövade — en organisation utan ett enda registrerat
* mätdon kunde skicka kalibrering 2099-12-31 och få E4.
*
* Ett okänt mätdon avvisas. Ett UTGÅNGET avvisas inte: mätningen gjordes,
* och att vägra spara den vore att radera evidens. I stället följer
* registrets datum med, och graderingen faller till E1 på registrets
* uppgift i stället för på klientens.
*/
async function mätdonsFakta(organisationId, handelse) {
if (handelse?.typ !== "matvarde") return { handelse };
// Utan angivet mätdon är det teknikerns avläsning av en siffra. Det är
// ett giltigt mätvärde — bara inte ett spårbart. Påstådd beteckning
// eller kalibrering utan mätdon tas bort: den kan inte styrkas.
if (!handelse.matdonId) {
const { matdonBeteckning, matdonKalibreradTill, ...rest } = handelse;
return { handelse: rest };
}
const rad = await pool.query(
`select beteckning, kalibrerad_till from matdon
where organisation_id = $1 and id::text = $2 and aktiv`,
[organisationId, String(handelse.matdonId)],
);
if (rad.rowCount === 0) {
return { fel: `Okänt mätdon: ${handelse.matdonId}. Registrera instrumentet innan mätvärdet sparas.` };
}
const härledd = { ...handelse, matdonBeteckning: rad.rows[0].beteckning };
// Ett mätdon utan kalibreringsdatum i registret får inget datum alls —
// inte klientens. Fältet utelämnas, och graderingen faller till E1.
delete härledd.matdonKalibreradTill;
if (rad.rows[0].kalibrerad_till) {
härledd.matdonKalibreradTill = new Date(rad.rows[0].kalibrerad_till).toISOString().slice(0, 10);
}
return { handelse: härledd };
}
// Verifierar att ärendet tillhör användarens organisation.
async function arendeIOrg(arendeId, organisationId) {
const rader = await pool.query(
@@ -714,8 +871,7 @@ async function skickaBilaga(res, rad) {
"Content-Length": data.length,
// Innehållsadresserat — samma id ger alltid samma bytes.
"Cache-Control": "private, max-age=31536000, immutable",
"Access-Control-Allow-Origin": res.ursprung ?? "*",
Vary: "Origin",
...korsHuvuden(res),
});
return res.end(data);
}
@@ -763,8 +919,7 @@ export function skapaServer() {
});
if (req.method === "OPTIONS") {
res.writeHead(204, {
"Access-Control-Allow-Origin": res.ursprung,
Vary: "Origin",
...korsHuvuden(res),
"Access-Control-Allow-Headers": "authorization, content-type",
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
});
@@ -780,7 +935,7 @@ export function skapaServer() {
if (req.method === "GET" && vag === "/api/openapi.yaml") {
res.writeHead(200, {
"Content-Type": "application/yaml; charset=utf-8",
"Access-Control-Allow-Origin": res.ursprung,
...korsHuvuden(res),
});
return res.end(OPENAPI);
}
@@ -808,7 +963,7 @@ export function skapaServer() {
);
const rad = await klientDb.query(
`insert into anvandare (organisation_id, epost, losen_hash, namn, roll)
values ($1, lower($2), crypt($3, gen_salt('bf')), $4, 'admin')
values ($1, lower($2), crypt($3, gen_salt('bf', 12)), $4, 'admin')
on conflict (epost) do nothing
returning id, namn, organisation_id, roll, token_version`,
[org.rows[0].id, epost.trim(), losenord, namn.trim()],
@@ -1199,7 +1354,7 @@ export function skapaServer() {
}
const rad = await pool.query(
`insert into anvandare (organisation_id, epost, losen_hash, namn, roll)
values ($1, lower($2), crypt($3, gen_salt('bf')), $4, $5)
values ($1, lower($2), crypt($3, gen_salt('bf', 12)), $4, $5)
on conflict (epost) do nothing
returning id, epost, namn, roll, aktiv`,
[anspr.org, epost.trim(), losenord, namn.trim(), roll],
@@ -1291,7 +1446,7 @@ export function skapaServer() {
res.setHeader("X-Regelpaket-Status", REGELPAKET_STATUS);
res.writeHead(200, {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": res.ursprung,
...korsHuvuden(res),
});
return res.end(ECM_REGLER);
}
@@ -1567,11 +1722,31 @@ export function skapaServer() {
if (typeof id !== "string" || typeof nummer !== "number" || !skapad) {
return svara(res, 400, { error: "Ogiltigt ärende." });
}
await pool.query(
// Ärende-id sätts av klienten och är lika förutsägbart som
// händelse-id var (TÜV T-1, samma attack en nivå upp). Ärenderaden
// kan inte skopas per organisation utan att främmande nycklar från
// händelser och bilagor blir sammansatta, så gränsen dras här i
// stället: ett id som redan tillhör en ANNAN organisation är ett
// fel, inte en tyst icke-händelse. Utan det här blev org A:s
// ärende aldrig skapat och varje efterföljande synk svarade 404.
const skapat = await pool.query(
`insert into felsokning_arenden (id, organisation_id, nummer, skapad, delningskod, metodik_id, skapad_av)
values ($1, $2, $3, $4, $5, $6, $7) on conflict (id) do nothing`,
values ($1, $2, $3, $4, $5, $6, $7) on conflict (id) do nothing returning id`,
[id, anspr.org, nummer, skapad, delningskod ?? null, metodikId ?? null, anspr.sub],
);
if (skapat.rowCount === 0) {
const agare = await pool.query(`select organisation_id from felsokning_arenden where id = $1`, [id]);
if (agare.rows[0]?.organisation_id !== anspr.org) {
logga("varning", "ärende-id upptaget av annan organisation", {
arende: id,
organisation: anspr.org,
anvandare: anspr.sub,
});
return svara(res, 409, {
error: "Ärendets identitet är upptagen. Ärendet skapades inte — välj ett nytt id.",
});
}
}
return svara(res, 200, { ok: true });
}
@@ -1779,7 +1954,7 @@ export function skapaServer() {
for (const post of poster) {
const r = await klientDb.query(
`insert into felsokning_handelser (id, arende_id, tidpunkt, anvandare, handelse)
values ($1, $2, $3, $4, $5) on conflict (id) do nothing`,
values ($1, $2, $3, $4, $5) on conflict (arende_id, id) do nothing`,
[post.id, protokollVag[1], post.tidpunkt, post.anvandare, post.handelse],
);
if (r.rowCount === 0) dubbletter += 1;
@@ -1960,6 +2135,17 @@ export function skapaServer() {
for (const post of handelser) {
const { post: giltig, fel } = tillPost(post, anspr);
if (fel) return svara(res, 400, { error: `Ogiltig händelse: ${fel}` });
// Mätdonet slås upp i registret i stället för att tros på
// (TÜV T-2). Beteckning och kalibrering HÄRLEDS därifrån —
// annars var E4 ett påstående klienten gjorde om sig själv.
const uppslag = await mätdonsFakta(anspr.org, giltig.handelse);
if (uppslag.fel) return svara(res, 400, { error: uppslag.fel });
giltig.handelse = uppslag.handelse;
// Avtrycket tas av det klienten skickade, före serverns fält
// och före krypteringen — se kollisionsprövningen nedan.
giltig.klientdigest = innehallsHash(kanoniskt(post.handelse));
giltig.handelse = skyddaHändelse(giltig.handelse, nyckel.id, nyckel.nyckel);
attSkriva.push(giltig);
}
@@ -2011,18 +2197,56 @@ export function skapaServer() {
}
}
// ---- Kollisioner: tyst bara när de är ofarliga (TÜV T-1) ----
//
// Nyckeln är numera skopad till ärendet, så en annan organisation
// kan inte längre ockupera id. Kvar finns den ofarliga
// omsändningen — och den inte fullt så ofarliga: samma id, annat
// innehåll. Skillnaden avgörs på klientens avtryck, eftersom
// varken raden eller nyttolasten går att jämföra (serverns
// tidsstämpel skiljer sig, chiffertexten är ny varje gång).
//
// Hela satsen prövas före första skrivningen. En delvis skriven
// batch som sedan avvisas vore värre än båda utfallen.
const befintliga = await pool.query(
`select id, klientdigest from felsokning_handelser where arende_id = $1 and id = any($2)`,
[handelserVag[1], attSkriva.map((p) => p.id)],
);
const krockar = [];
for (const rad of befintliga.rows) {
res.spann.spår.kollisioner = (res.spann.spår.kollisioner ?? 0) + 1;
const ny = attSkriva.find((p) => p.id === rad.id);
// Saknat avtryck = raden skrevs före den här kolumnen fanns.
// Då kan innehållet inte bedömas, och tystnad är det försiktiga
// utfallet: append-only skyddar raden som redan står där.
if (rad.klientdigest && rad.klientdigest !== ny.klientdigest) {
krockar.push({ id: rad.id, typ: ny.handelse.typ });
}
}
if (krockar.length > 0) {
logga("varning", "händelse-id återanvänt med annat innehåll", {
arende: handelserVag[1],
organisation: anspr.org,
anvandare: anspr.sub,
antal: krockar.length,
});
return svara(res, 409, {
error:
"Ett eller flera händelse-id finns redan i ärendet med annat innehåll. " +
"Ingenting skrevs — loggen kan inte skrivas över.",
krockar,
});
}
for (const p of attSkriva) {
// Append-only: on conflict do nothing — en befintlig händelse
// skrivs aldrig över, och databastriggern stoppar allt annat.
// Kollisionen räknas: ett id som redan finns är antingen en
// ofarlig omsändning eller ett försök att blockera en framtida
// post, och skillnaden syns bara om den mäts (QUALITET m-2).
const skrivet = await pool.query(
`insert into felsokning_handelser (id, arende_id, tidpunkt, anvandare, handelse)
values ($1, $2, $3, $4, $5) on conflict (id) do nothing`,
[p.id, handelserVag[1], p.tidpunkt, p.anvandare, p.handelse],
// Här är kollisionen redan bedömd ofarlig ovan.
await pool.query(
`insert into felsokning_handelser (id, arende_id, tidpunkt, anvandare, handelse, klientdigest)
values ($1, $2, $3, $4, $5, $6) on conflict (arende_id, id) do nothing`,
[p.id, handelserVag[1], p.tidpunkt, p.anvandare, p.handelse, p.klientdigest],
);
if (skrivet.rowCount === 0) res.spann.spår.kollisioner = (res.spann.spår.kollisioner ?? 0) + 1;
}
// Utgående integrationer underrättas efter att loggen skrivits,
// aldrig före: en mottagare ska aldrig kunna se en händelse som