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:
Generated
+40
-24
@@ -55,7 +55,7 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.61.1",
|
||||
"react-resizable-panels": "^2.1.9",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"recharts": "^2.15.4",
|
||||
"sonner": "^1.7.4",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
@@ -2297,15 +2297,6 @@
|
||||
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.3",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
|
||||
"integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -4266,6 +4257,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -6561,35 +6565,41 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
|
||||
"integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
|
||||
"integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.3"
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
|
||||
"integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
|
||||
"integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.3",
|
||||
"react-router": "6.30.4"
|
||||
"react-router": "7.18.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-smooth": {
|
||||
@@ -6878,6 +6888,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.61.1",
|
||||
"react-resizable-panels": "^2.1.9",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"recharts": "^2.15.4",
|
||||
"sonner": "^1.7.4",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
|
||||
@@ -44,11 +44,20 @@ describe("AI-orkestern", () => {
|
||||
"/api/auth/registrera",
|
||||
"/api/arenden",
|
||||
String.raw`\/api\/delad\/`,
|
||||
"gen_salt('bf')",
|
||||
"JWT_SECRET",
|
||||
]) {
|
||||
expect(plattform).toContain(bit);
|
||||
}
|
||||
|
||||
// Lösenordshashens KOSTNAD, inte bara att bcrypt används (TÜV T-6).
|
||||
// pgcryptos gen_salt('bf') utan argument ger kostnad 6 — 2^6 = 64
|
||||
// varv, mot dagens golv på 2^10. Testet var tidigare skrivet på
|
||||
// anropet och hade därför godkänt den svaga varianten för alltid.
|
||||
const kostnader = [...plattform.matchAll(/gen_salt\('bf'(?:,\s*(\d+))?\)/g)].map((m) =>
|
||||
Number(m[1] ?? 6),
|
||||
);
|
||||
expect(kostnader.length).toBeGreaterThan(0);
|
||||
for (const k of kostnader) expect(k).toBeGreaterThanOrEqual(12);
|
||||
// API:t exponerar medvetet inga update/delete-operationer.
|
||||
expect(plattform).not.toMatch(/\b(update|delete)\s+felsokning_handelser/i);
|
||||
// Multi-tenant: all ärendedata är organisationsknuten.
|
||||
|
||||
@@ -111,6 +111,14 @@ export type Handelse =
|
||||
matdonId?: string;
|
||||
matdonBeteckning?: string;
|
||||
matdonKalibreradTill?: string;
|
||||
/**
|
||||
* Härkomst när värdet inte skrevs in av teknikern själv — en
|
||||
* modelltolkad instrumentavläsning, eller en importerad protokollrad
|
||||
* (TÜV T-5). Ett bekräftat maskinläst värde är riktigt men inte
|
||||
* spårbart utan det här: rapporten ska kunna svara på hur siffran
|
||||
* kom dit, inte bara vilken den blev.
|
||||
*/
|
||||
kalla?: string;
|
||||
}
|
||||
| { typ: "hypotes"; text: string; niva: Exclude<Tillforlitlighet, "hog"> }
|
||||
| ({ typ: "foto"; beskrivning: string } & Bilaga)
|
||||
|
||||
@@ -102,9 +102,16 @@ export function evidensposter(arende: Arende): Evidenspost[] {
|
||||
// att en okalibrerad mätning väger som en kalibrerad är precis den
|
||||
// sortens obefogade säkerhet produkten finns för att undvika.
|
||||
const spårbar = Boolean(h.matdonId) && kalibreradVid(h.matdonKalibreradTill, post.tidpunkt);
|
||||
const märkning = h.matdonBeteckning
|
||||
? `${h.matdonBeteckning}${spårbar ? "" : " — kalibrering saknas eller utgången"}`
|
||||
: "instrument ej angivet";
|
||||
const märkning = [
|
||||
h.matdonBeteckning
|
||||
? `${h.matdonBeteckning}${spårbar ? "" : " — kalibrering saknas eller utgången"}`
|
||||
: "instrument ej angivet",
|
||||
// Härkomsten står i rapporten, inte bara i loggen: den som läser
|
||||
// ska se att siffran är maskinläst utan att gräva (TÜV T-5).
|
||||
h.kalla,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
lagg(
|
||||
post,
|
||||
"mätvärde",
|
||||
|
||||
@@ -1734,7 +1734,19 @@ function SnabbDokumentation({
|
||||
// strukturerad data ersätter aldrig originalevidensen.
|
||||
skicka({ typ: "foto", beskrivning: `Instrumentavläsning (${avlasning.tolkning.instrumenttyp})`, dataUrl: avlasning.foto });
|
||||
for (const v of avlasning.tolkning.varden) {
|
||||
skicka({ typ: "matvarde", beskrivning: v.beskrivning, varde: v.varde, enhet: v.enhet });
|
||||
// `kalla` sätts därför att värdet AVLÄSTES av en modell ur fotot
|
||||
// (TÜV T-5). Teknikern bekräftar innan något skrivs, vilket täcker
|
||||
// riktigheten — men bekräftelsen ger inte spårbarheten: utan
|
||||
// härkomst går ett maskinläst värde inte att skilja från ett
|
||||
// teknikern läste av instrumentet, och "hur kom siffran hit" är
|
||||
// just vad en granskare frågar om en avskriven avläsning.
|
||||
skicka({
|
||||
typ: "matvarde",
|
||||
beskrivning: v.beskrivning,
|
||||
varde: v.varde,
|
||||
enhet: v.enhet,
|
||||
kalla: "Instrumentavläsning (modelltolkad, bekräftad av tekniker)",
|
||||
});
|
||||
}
|
||||
paSparad?.(
|
||||
`Instrumentavläsning (${avlasning.tolkning.instrumenttyp}): ${avlasning.tolkning.varden
|
||||
|
||||
@@ -454,4 +454,67 @@ be recorded as `claimed` until T-2 is closed.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Remediation status
|
||||
|
||||
Recorded after the fixes were implemented. Each entry states what was actually
|
||||
changed, and what was **not**, so a re-audit can verify rather than take it on
|
||||
trust. Every closure below is exercised by the platform integration suite
|
||||
against a real Postgres — 144 checks, up from 100.
|
||||
|
||||
| # | Status | What changed |
|
||||
| --- | --- | --- |
|
||||
| **T-1** | ✅ Closed | The event primary key is now `(arende_id, id)`. There is no shared namespace left to occupy, so the cross-tenant attack has no surface rather than a mitigation. Within a case, a collision is judged on a **client digest** — a SHA-256 of the canonicalised body the client sent, taken before the server's own fields and before encryption, because neither the row nor the payload can be compared (the server timestamp always differs on a resend, and the ciphertext is new every time). Identical content stays idempotent and answers `200`; the same id with different content answers `409` and writes **nothing**, not even the rest of the batch. The same attack one level up — case ids are equally predictable — is closed separately: a case id already owned by another organisation answers `409` instead of silently doing nothing, which previously left the victim's case uncreated and every later sync answering `404`. The **adversarial-tenant harness** the audit recommended now exists and runs in CI. |
|
||||
| **T-2** | ✅ Closed | `matdonId` is resolved against the `matdon` register for the caller's organisation, and the designation and calibration date are **derived from the register**, overwriting whatever the client sent. An unknown instrument is rejected with `400`. An expired one is not — the measurement was still taken, and refusing it would destroy evidence — but the register's date is what travels, so the grade falls to E1 on the register's word rather than the client's. A measurement with no instrument at all has any claimed designation or calibration **stripped**: it cannot be substantiated. |
|
||||
| **T-3** | ◐ Reduced, not closed | Subject keys are now stored **enveloped** under `PERSONNYCKEL_HUVUD`, a 32-byte master key held in Secrets Manager and injected via External Secrets — never in the database. A restored dump yields keys that do not open, verified by restarting the service without the master key and confirming the identifier cannot be read. **What remains, stated plainly:** a backup taken *before* an erasure, combined with the master key, still restores the data. Closing that needs a key per subject in a KMS where destruction is irreversible. The envelope is the preparation for it — wrap and unwrap are two functions, not inline code — but the erasure promise is still bounded by backup retention, and must be stated as such to data subjects. |
|
||||
| **T-4** | ✅ Closed | `services/plattform/gallring.mjs` destroys the keys of subjects whose cases have **all** passed `gallras_efter`, and records each destruction in `raderingar`. The grouping is on the blinded vehicle index, not the case id: a vehicle seen five times has five cases and one key, and gallringen on the oldest would have made the four newer ones unreadable early. A case with no retention date counts as not yet passed — early erasure cannot be undone. Scheduled as a Kubernetes CronJob, nightly, `concurrency_policy = Forbid`. |
|
||||
| **T-5** | ✅ Closed | Model-read instrument values carry `kalla`, declared on the domain type and rendered in the evidence report, so a machine-read number is distinguishable from one the technician read off the instrument. |
|
||||
| **T-6** | ✅ Closed | `gen_salt('bf', 12)`. The test that was supposed to lock this asserted the literal string `gen_salt('bf')` — it would have passed the weak variant forever. It now parses the cost out of every call site and requires ≥12. |
|
||||
| **T-7** | ✅ Closed | `atkomstlogg` and `raderingar` are append-only by the same trigger as the event log. `personnycklar` gets the narrower rule it needs: the key and the erasure-requested timestamp may change, identity and ownership may not — a key must not be movable to another subject. **The first version of this test was worthless**: a row-level trigger fires per row, so `delete` on an empty table succeeds without the protection ever being exercised. The test now requires the table to be non-empty before it proves anything. |
|
||||
| **T-8** | ✅ Closed | `/api/atkomstlogg` is documented. The spec's own description of idempotent sync was rewritten — it still claimed a known id is ignored silently, which is no longer true and was the behaviour T-1 depended on. |
|
||||
| **T-9** | ✅ Closed | An **external** rule package (`ECM_REGLER_FIL` set) without a signature now blocks case closure, exactly as an invalid signature does. The built-in default ships with the image and needs no signature — what needs one is a package somebody mounted in, because that is precisely the attack. |
|
||||
| **T-10** | ✅ Closed | An unset origin list now means same-origin, not `*`. Opening up requires writing `*` and meaning it. The decision lived at six call sites, any one of which could fall back to `*`; it is now one helper that either emits the headers or emits nothing. |
|
||||
| **T-11** | ✅ Closed | `exp` is required, not merely checked when present. |
|
||||
| **T-12** | ◐ Traded, with reasons | Upgraded to `react-router-dom` 7.18.2, which closes the applicable advisory (open redirect via backslash in `Link`/`useNavigate`). This introduces one **high** advisory that does not apply to this build: it concerns RSC mode, and the application uses `BrowserRouter`/`HashRouter` with no RSC imports, no server actions and no SSR — verified by search. No published version is clean of both; `npm audit fix --force` would downgrade to 7.11.0 and reinstate the applicable one. Real exposure is reduced; the audit line looks worse. Verified across the full suite, the case walkthrough and the portal-guard run. |
|
||||
|
||||
### What the hardening itself exposed
|
||||
|
||||
Two of the fixes were briefly wrong in the same way the findings were, and both
|
||||
were caught only by trying to prove them:
|
||||
|
||||
- The **T-7 test passed against an empty table.** A row-level trigger has no
|
||||
rows to fire on, so `delete` succeeded and the check reported green. It was
|
||||
measuring nothing.
|
||||
- The **T-6 test asserted the call, not the cost.** `expect(plattform).toContain("gen_salt('bf')")`
|
||||
would have accepted cost 6 indefinitely — and did, for as long as it existed.
|
||||
|
||||
Both are the pattern the audit named: a control that is correct in its own terms
|
||||
and unexamined at its boundary. It applies to tests as readily as to code.
|
||||
|
||||
### Residual
|
||||
|
||||
| Item | Why it is still open |
|
||||
| --- | --- |
|
||||
| **T-3** (partial) | A pre-erasure backup plus the master key still restores the data. Needs per-subject keys in a KMS with irreversible destruction. Until then, the backup retention window is part of the erasure promise and must be stated to data subjects. |
|
||||
| **T-12** (partial) | No `react-router` version is free of both advisories. The one carried does not apply to this build. |
|
||||
| Terraform | The CronJob and the secret wiring are written but **not applied** — no Terraform binary was available in this environment, so they are unvalidated beyond review. |
|
||||
| Rev 1 · C-4 | Processor agreement and transfer impact assessment. Documents to be written and signed, not code. |
|
||||
| Rev 1 · m-4 | Retiring the Supabase orchestrator. A deployment decision. |
|
||||
| Rev 1 · m-6 | Manual accessibility review. |
|
||||
|
||||
### Re-audit verdict
|
||||
|
||||
Two of three Critical findings are closed at the structural level rather than
|
||||
mitigated: T-1 has no attack surface left, and T-2 derives from the register
|
||||
instead of trusting the caller. The third is materially reduced and honestly
|
||||
bounded.
|
||||
|
||||
Applying the product's own standard: measurement traceability moves from
|
||||
`claimed` to `validated`. Erasure stays at `tested` — the envelope closes the
|
||||
"key beside the ciphertext" defect but not the backup window, and it should not
|
||||
be recorded as `validated` until it does.
|
||||
|
||||
---
|
||||
|
||||
*ALVA-DOC-0003 · Internal engineering review · Not endorsed by any inspection body*
|
||||
|
||||
@@ -45,14 +45,25 @@ resource "random_id" "integration" {
|
||||
byte_length = 32
|
||||
}
|
||||
|
||||
# Huvudnyckeln som personnycklarna kuverteras under (TÜV T-3).
|
||||
#
|
||||
# Den ligger i Secrets Manager och INTE i databasen — det är hela poängen.
|
||||
# Låg nyckeln kvar bredvid chiffertexten var krypto-shredding bara "radera
|
||||
# en rad i samma databas", och en återställd dump gav uppgifterna i
|
||||
# läsbart skick igen.
|
||||
resource "random_id" "personnyckel_huvud" {
|
||||
byte_length = 32
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "app" {
|
||||
secret_id = aws_secretsmanager_secret.app.id
|
||||
|
||||
secret_string = jsonencode({
|
||||
# Fylls i efter första apply — Terraform ska inte känna Claude-nyckeln.
|
||||
anthropic_api_key = "ERSATT-MIG"
|
||||
jwt_secret = random_password.jwt.result
|
||||
integration_nyckel = random_id.integration.hex
|
||||
jwt_secret = random_password.jwt.result
|
||||
integration_nyckel = random_id.integration.hex
|
||||
personnyckel_huvud = random_id.personnyckel_huvud.hex
|
||||
})
|
||||
|
||||
lifecycle {
|
||||
|
||||
@@ -105,17 +105,49 @@ create table if not exists bilage_innehall (
|
||||
skapad timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- ---- Händelseloggen -----------------------------------------------------
|
||||
--
|
||||
-- Nyckeln är (arende_id, id) och INTE id ensamt. Skillnaden är hela
|
||||
-- TÜV-revisionens T-1.
|
||||
--
|
||||
-- Klienten sätter id, och gjorde det som Date.now() + en räknare — alltså
|
||||
-- förutsägbart. Med en global primärnyckel och `on conflict do nothing`
|
||||
-- kunde en organisation skriva de id:n en ANNAN organisation snart skulle
|
||||
-- använda, till sitt eget ärende, och den andras händelse föll tyst bort.
|
||||
-- Servern svarade 200. Teknikern såg händelsen på skärmen, ärendet
|
||||
-- stängdes, och felorsaken fanns inte i loggen.
|
||||
--
|
||||
-- Append-only skyddade det som skrevs. Ingenting skyddade det som
|
||||
-- hindrades från att skrivas. Med nyckeln skopad till ärendet finns det
|
||||
-- inget delat namnrum kvar att ockupera.
|
||||
create table if not exists felsokning_handelser (
|
||||
id text primary key,
|
||||
id text not null,
|
||||
arende_id text not null references felsokning_arenden(id),
|
||||
tidpunkt timestamptz not null,
|
||||
anvandare text not null,
|
||||
handelse jsonb not null,
|
||||
insatt timestamptz not null default now()
|
||||
insatt timestamptz not null default now(),
|
||||
primary key (arende_id, id)
|
||||
);
|
||||
create index if not exists felsokning_handelser_arende_idx
|
||||
on felsokning_handelser (arende_id, tidpunkt);
|
||||
|
||||
-- Migrering av en befintlig installation: den gamla nyckeln på enbart id
|
||||
-- byts mot den skopade. Kollisioner mellan ärenden kan inte uppstå vid
|
||||
-- bytet — den gamla nyckeln var strängare — så bytet är alltid möjligt.
|
||||
do $$
|
||||
begin
|
||||
if exists (
|
||||
select 1 from pg_constraint c
|
||||
join pg_class t on t.oid = c.conrelid
|
||||
where t.relname = 'felsokning_handelser' and c.contype = 'p'
|
||||
and (select count(*) from unnest(c.conkey)) = 1
|
||||
) then
|
||||
alter table felsokning_handelser drop constraint felsokning_handelser_pkey;
|
||||
alter table felsokning_handelser add primary key (arende_id, id);
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
create or replace function forbjud_andring() returns trigger
|
||||
language plpgsql as $$
|
||||
begin
|
||||
@@ -340,3 +372,54 @@ drop trigger if exists fakturahandelser_append_only on fakturahandelser;
|
||||
create trigger fakturahandelser_append_only
|
||||
before update or delete on fakturahandelser
|
||||
for each row execute function forbjud_andring();
|
||||
|
||||
-- ---- Underlagen om åtkomst och radering (TÜV T-7) ----------------------
|
||||
--
|
||||
-- Fyra tabeller bar append-only-skydd: loggen, ärendet, fakturorna och
|
||||
-- fakturahändelserna. Utelämnandena var påfallande — det var just de
|
||||
-- tabeller som UTGÖR bevisningen om dataskydd som gick att skriva om.
|
||||
--
|
||||
-- Åtkomstloggen är beviset för att åtkomststyrningen fungerade. Den är
|
||||
-- det första en granskare ber om efter en incident, och den enda handling
|
||||
-- som kunde ändras av den som hade motiv att göra det.
|
||||
drop trigger if exists atkomstlogg_append_only on atkomstlogg;
|
||||
create trigger atkomstlogg_append_only
|
||||
before update or delete on atkomstlogg
|
||||
for each row execute function forbjud_andring();
|
||||
|
||||
-- Beviset för att en radering verkställdes fick inte självt kunna raderas.
|
||||
drop trigger if exists raderingar_append_only on raderingar;
|
||||
create trigger raderingar_append_only
|
||||
before update or delete on raderingar
|
||||
for each row execute function forbjud_andring();
|
||||
|
||||
-- Personnycklarna behöver radering — det är vad radering ÄR — så här går
|
||||
-- det inte att förbjuda. Regeln är i stället smalare: nyckeln och
|
||||
-- raderingsbegäran får ändras, identiteten och tillhörigheten inte.
|
||||
-- Annars kunde en nyckel flyttas till ett annat subjekt och en radering
|
||||
-- träffa fel person.
|
||||
create or replace function skydda_personnyckel() returns trigger
|
||||
language plpgsql as $$
|
||||
begin
|
||||
if TG_OP = 'DELETE' then
|
||||
return old;
|
||||
end if;
|
||||
if (new.id, new.organisation_id, new.subjekt, new.skapad)
|
||||
is distinct from
|
||||
(old.id, old.organisation_id, old.subjekt, old.skapad) then
|
||||
raise exception 'En personnyckels identitet och tillhörighet kan inte ändras';
|
||||
end if;
|
||||
return new;
|
||||
end $$;
|
||||
|
||||
drop trigger if exists personnycklar_skydd on personnycklar;
|
||||
create trigger personnycklar_skydd
|
||||
before update or delete on personnycklar
|
||||
for each row execute function skydda_personnyckel();
|
||||
|
||||
-- Avtryck av det klienten faktiskt skickade, före serverns egna fält och
|
||||
-- före krypteringen (TÜV T-1). Utan det går en kollision inte att bedöma:
|
||||
-- serverns tidsstämpel skiljer sig alltid vid en omsändning, och
|
||||
-- krypteringen ger ny chiffertext för samma klartext, så varken raden
|
||||
-- eller nyttolasten kan jämföras. Avtrycket kan.
|
||||
alter table felsokning_handelser add column if not exists klientdigest text;
|
||||
|
||||
@@ -107,6 +107,12 @@ resource "kubernetes_manifest" "hemligheter" {
|
||||
secretKey = "INTEGRATION_NYCKEL"
|
||||
remoteRef = { key = local.aws.hemlighet_app, property = "integration_nyckel" }
|
||||
},
|
||||
# Kuverterar personnycklarna. Utan den går en återställd databas
|
||||
# inte att läsa — vilket är avsikten (TÜV T-3).
|
||||
{
|
||||
secretKey = "PERSONNYCKEL_HUVUD"
|
||||
remoteRef = { key = local.aws.hemlighet_app, property = "personnyckel_huvud" }
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,3 +199,88 @@ resource "kubernetes_pod_disruption_budget_v1" "plattform" {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ---- Gallringen (ALVA-PROC-0040 · TÜV T-4) -----------------------------
|
||||
#
|
||||
# Gallringsdatumet räknades fram vid avslut och lästes sedan aldrig av
|
||||
# någon. Lagringsbegränsningen var en nedskriven avsikt, inte en
|
||||
# verkställd kontroll — och eftersom ingenting konsumerade värdet märkte
|
||||
# heller ingen att det under en period inte ens gick att skriva.
|
||||
#
|
||||
# Egen körning och inte en timer inne i webbtjänsten: 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 i stället för att gömma sig i en podd som
|
||||
# samtidigt betjänar trafik.
|
||||
resource "kubernetes_cron_job_v1" "gallring" {
|
||||
metadata {
|
||||
name = "gallring"
|
||||
namespace = kubernetes_namespace_v1.denna.metadata[0].name
|
||||
labels = merge(local.etiketter, { app = "gallring" })
|
||||
}
|
||||
|
||||
spec {
|
||||
# Nattetid, en gång per dygn. Gallring är aldrig brådskande — ett
|
||||
# dygns fördröjning är oproblematiskt, en för tidig gallring är
|
||||
# oåterkallelig.
|
||||
schedule = "17 2 * * *"
|
||||
concurrency_policy = "Forbid"
|
||||
successful_jobs_history_limit = 3
|
||||
failed_jobs_history_limit = 3
|
||||
starting_deadline_seconds = 3600
|
||||
|
||||
job_template {
|
||||
metadata {
|
||||
labels = merge(local.etiketter, { app = "gallring" })
|
||||
}
|
||||
|
||||
spec {
|
||||
# Misslyckas den lämnas nycklarna kvar. Det försiktiga utfallet:
|
||||
# för tidig gallring går inte att ångra, en försenad gör det.
|
||||
backoff_limit = 2
|
||||
|
||||
template {
|
||||
metadata {
|
||||
labels = merge(local.etiketter, { app = "gallring" })
|
||||
}
|
||||
|
||||
spec {
|
||||
restart_policy = "Never"
|
||||
service_account_name = kubernetes_service_account_v1.plattform.metadata[0].name
|
||||
|
||||
security_context {
|
||||
run_as_non_root = true
|
||||
|
||||
seccomp_profile {
|
||||
type = "RuntimeDefault"
|
||||
}
|
||||
}
|
||||
|
||||
container {
|
||||
name = "gallring"
|
||||
image = local.tjanster.plattform.bild
|
||||
command = ["node", "gallring.mjs"]
|
||||
|
||||
# Samma hemligheter som tjänsten: DATABASE_URL och
|
||||
# PERSONNYCKEL_HUVUD behövs, inget annat.
|
||||
env_from {
|
||||
secret_ref {
|
||||
name = "felsokning-hemligheter"
|
||||
}
|
||||
}
|
||||
|
||||
resources {
|
||||
requests = {
|
||||
cpu = "50m"
|
||||
memory = "128Mi"
|
||||
}
|
||||
limits = {
|
||||
memory = "256Mi"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 rå = process.env.PERSONNYCKEL_HUVUD ?? "";
|
||||
if (!rå) return null;
|
||||
const b = /^[0-9a-fA-F]{64}$/.test(rå) ? Buffer.from(rå, "hex") : Buffer.from(rå, "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
|
||||
|
||||
Reference in New Issue
Block a user