Files
alva/felsokning/services/gemensam/personuppgifter.mjs
T
Claude fbc034a280 Revisionen: C-3, C-4, M-1, M-4, M-5, M-6, m-3, m-5 och m-7 åtgärdade
C-3 · Dataskydd genom krypto-shredding. Identifierande fält krypteras
med en nyckel per ärende; radering sker genom att nyckeln förstörs.
Loggen förblir intakt och hashverifierbar — det som blir oåtkomligt är
identifieringen, inte protokollet över vad som kontrollerades. Ett
raderat ärende visar fortfarande att lufttrycket mättes till 2,4 bar
klockan 08:42, bara inte längre vems bil det gällde.

Vad som inte krypteras är lika viktigt: mätvärden, observationer och
kontrollresultat är verksamhetsdata. Krypteras allt raderas beviset
tillsammans med personuppgiften.

En raderingsbegäran gäller ett fordon, inte ett ärende — men
identifieraren är krypterad och går inte att söka på. Därför ett blindat
index: HMAC av den normaliserade identifieraren, samma fordon ger alltid
samma värde, värdet går inte att vända tillbaka utan nyckeln.

Gallringsdatum sätts vid avslut utifrån ärendetypen. Ett ärende utan
datum gallras aldrig — för tidig gallring går inte att ångra.

C-4 · Modellanropen kan stängas av per organisation. Flaggan bärs i
token så orkestern kan neka utan databasåtkomst. Metodikmotorn fungerar
ensam; en verkstad som inte kan acceptera överföringen till
modelleverantören kan ändå använda produkten.

M-1 · Mätvärden bär vilket mätdon som användes och när det var
kalibrerat. Utan spårbart instrument nedgraderas värdet från E4 till E1
— det är teknikerns observation av en siffra, inte en mätning.
Kalibreringen bedöms vid mättillfället, inte i dag. Demoärendet fick
kalibrerade instrument: det ska visa den praxis produkten kräver.

M-4 · Läslogg. Varje skrivning loggades redan; ingen läsning gjorde det.
M-5 · Återställningstest i CI. Larmet visade att backup sker, inte att
den går att återställa. Testet kontrollerar det som faktiskt brukar
tappas: att append-only-triggarna följde med och fortfarande biter.
M-6 · Regelpaketet verifieras mot HMAC. Ogiltig signatur spärrar avslut;
saknad nyckel ger granskningsläge i stället för driftavbrott — det är så
säkerhetsfunktioner blir avstängda.
m-3 · EXIF-borttagningen är nu avsiktlig och låst av test. Den höll av
en slump, och en ändring i stil med "bevara originalkvaliteten" hade
tyst börjat publicera var kundens bil stod.
m-5 · SBOM och sårbarhetsskanning i CI.
m-7 · Promptversionen binds till varje svar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
2026-08-05 13:44:37 +00:00

151 lines
5.8 KiB
JavaScript

// Personuppgifter i en append-only-logg.
//
// Revisionen (docs/QUALITY-AUDIT.md, C-3) fann att systemet inte kunde
// uppfylla artikel 17: databastriggern hindrar aktivt radering, och
// kundens namn, telefonnummer, e-post, registreringsnummer och VIN
// ligger i klartext i loggen.
//
// "Append-only" och "radering på begäran" är inte oförenliga, men de
// måste förenas medvetet. Lösningen här är krypto-shredding:
//
// Identifierande fält lagras krypterade med en nyckel som är unik per
// registrerad person. Radering sker genom att nyckeln förstörs. Loggen
// förblir intakt och hashverifierbar — det som blir oåtkomligt är
// identifieringen, inte protokollet över vad som kontrollerades.
//
// Det är den enda konstruktion jag känner till där bevisvärdet överlever
// en raderingsbegäran. Ett ärende där kunden begärt radering kan
// fortfarande visa att lufttrycket mättes till 2,4 bar av en behörig
// tekniker klockan 08:42 — bara inte längre vem bilen tillhörde.
//
// Vad som INTE krypteras: mätvärden, observationer, kontrollresultat,
// tidsstämplar, teknikerns roll. De är verksamhetsdata och bär inget
// personvärde i sig. Att kryptera allt vore enklare att beskriva och
// sämre i praktiken — då raderas beviset tillsammans med personuppgiften.
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
/**
* Fält som räknas som identifierande och därför krypteras.
*
* Registreringsnummer och VIN står med avsikt med. De pekar ut ett
* fordon, men ett fordon pekar i praktiken ut en ägare — EDPB behandlar
* VIN som personuppgift när kopplingen finns, och här finns den.
*/
export const IDENTIFIERANDE = {
objekt_identifierat: ["identifierare", "vin", "agare", "kund"],
arbetsorder_skannad: [
"kund_namn",
"kund_foretag",
"kund_telefon",
"kund_epost",
"fordon_regnr",
"fordon_vin",
],
kundbeslut: ["kontaktperson"],
};
export const MASKERAT = "[raderat på begäran]";
/**
* Krypterar ett värde med den registrerades nyckel.
* Formatet bär nyckelns id, så att en post går att koppla till rätt
* nyckel utan att nyckeln behöver finnas.
*/
export function skydda(värde, nyckelId, nyckel) {
const iv = randomBytes(12);
const chiffer = createCipheriv("aes-256-gcm", nyckel, iv);
const ut = Buffer.concat([chiffer.update(String(värde), "utf8"), chiffer.final()]);
return `pd1:${nyckelId}:${iv.toString("base64url")}:${chiffer.getAuthTag().toString("base64url")}:${ut.toString("base64url")}`;
}
/**
* Öppnar ett skyddat värde.
*
* Saknas nyckeln returneras MASKERAT — det är det normala utfallet efter
* en radering, inte ett fel. Ett kastat undantag här skulle göra hela
* ärendet oläsbart, vilket vore precis fel: resten av bevisningen ska
* överleva.
*/
export function öppna(skyddat, nycklar) {
if (typeof skyddat !== "string" || !skyddat.startsWith("pd1:")) return skyddat;
const [, nyckelId, iv, tagg, data] = skyddat.split(":");
const nyckel = nycklar.get(nyckelId);
if (!nyckel) return MASKERAT;
try {
const chiffer = createDecipheriv("aes-256-gcm", nyckel, Buffer.from(iv, "base64url"));
chiffer.setAuthTag(Buffer.from(tagg, "base64url"));
return chiffer.update(Buffer.from(data, "base64url"), undefined, "utf8") + chiffer.final("utf8");
} catch {
// Fel nyckel eller manipulerad chiffertext. Att skilja de två fallen
// åt i ett felmeddelande vore ett orakel; behandla dem lika.
return MASKERAT;
}
}
/** Krypterar de identifierande fälten i en händelse. */
export function skyddaHändelse(handelse, nyckelId, nyckel) {
const fält = IDENTIFIERANDE[handelse.typ];
if (!fält) return handelse;
if (handelse.typ === "arbetsorder_skannad") {
return {
...handelse,
falt: (handelse.falt ?? []).map((f) =>
fält.includes(f.id) && f.varde ? { ...f, varde: skydda(f.varde, nyckelId, nyckel) } : f,
),
};
}
if (handelse.typ === "objekt_identifierat") {
const objekt = { ...handelse.objekt };
for (const n of fält) if (objekt[n]) objekt[n] = skydda(objekt[n], nyckelId, nyckel);
return { ...handelse, objekt };
}
const ut = { ...handelse };
for (const n of fält) if (ut[n]) ut[n] = skydda(ut[n], nyckelId, nyckel);
return ut;
}
/** Öppnar de identifierande fälten igen för visning. */
export function öppnaHändelse(handelse, nycklar) {
const fält = IDENTIFIERANDE[handelse?.typ];
if (!fält) return handelse;
if (handelse.typ === "arbetsorder_skannad") {
return { ...handelse, falt: (handelse.falt ?? []).map((f) => ({ ...f, varde: öppna(f.varde, nycklar) })) };
}
if (handelse.typ === "objekt_identifierat") {
const objekt = { ...handelse.objekt };
for (const n of fält) if (objekt[n]) objekt[n] = öppna(objekt[n], nycklar);
return { ...handelse, objekt };
}
const ut = { ...handelse };
for (const n of fält) if (ut[n]) ut[n] = öppna(ut[n], nycklar);
return ut;
}
/**
* Gallringsbeslut.
*
* Retention är inte en teknisk detalj utan ett juridiskt ställningstagande,
* och det skiljer sig mellan ärendetyper: ett garantiärende måste kunna
* visas upp under garantitiden, ett kontantärende inte. Standardvärdena
* nedan är utgångspunkter — varje organisation ska sätta sina egna, och
* den som inte gör ett aktivt val får det försiktigaste.
*/
export const GALLRING_MANADER = {
Garanti: 120, // Följer den längsta rimliga garantitiden på en drivlina.
Goodwill: 120,
Försäkring: 120, // Preskription för försäkringskrav.
Reklamation: 36, // Konsumentköplagens reklamationsrätt.
Begagnatgaranti: 60,
standard: 36,
};
export function gallringsdatum(arendetyp, avslutat, manader = GALLRING_MANADER) {
const antal = manader[arendetyp] ?? manader.standard;
const d = new Date(avslutat);
d.setMonth(d.getMonth() + antal);
return d;
}