Et hemmeligt engangslink er en URL, der viser sit indhold præcis én gang
og derefter holder op med at virke for altid. Afsenderens browser
krypterer hemmeligheden med AES-GCM-256, serveren gemmer kun
chifferteksten, og nøglen ligger i den del af linket, der står efter #k=,
som browsere aldrig sender. Når modtageren åbner indholdet første gang,
dekrypteres det, og den gemte kopi slettes i samme operation.
Du kan lave et uden konto med den gratis generator til hemmelige links.
Selve mekanismen er enkel. Det interessante er grænsetilfældene, for det er dér, sjuskede implementeringer lækker, uden at nogen opdager det.
Det grundlæggende forløb
- Afsenderen indsætter en hemmelighed. Browseren genererer en tilfældig
nøgle, krypterer indholdet med AES-GCM-256 via
Web Crypto API og sender
kun chifferteksten. Serveren gemmer den under en tilfældig slug, der er svær at gætte,
og returnerer en URL. Browseren tilføjer nøglen som et
#k=-fragment, der aldrig forlader afsenderens enhed. - Afsenderen deler URL'en via en hvilken som helst kanal: e-mail, chat, sms.
- Modtageren åbner URL'en. Et almindeligt
GETreturnerer kun status. Når modtageren klikker på Vis, markerer serveren hemmeligheden som brugt og returnerer chifferteksten i ét og samme trin, der ikke kan deles op. Modtagerens browser dekrypterer den med nøglen fra fragmentet. - Alle senere besøg får en statusside om, at indholdet er slettet, i stedet for indholdet.
Det afgørende trin er trin 3. Sker læsningen og statusopdateringen som to
separate forespørgsler, først SELECT for at tjekke og så UPDATE for at
markere, kan to samtidige besøgende begge komme igennem tjekket, før nogen
af dem skriver. Begge får indholdet. Det er en klassisk TOCTOU-fejl
(time-of-check to time-of-use), og den gør garantien om ”præcis én gang”
til ”for det meste én gang”.
Hvorfor skal sletningen og læsningen ske i ét trin?
Fordi to samtidige besøgende ellers begge kan komme igennem tjekket, før
nogen af dem markerer hemmeligheden som brugt. Løsningen er at samle
tjekket, markeringen og læsningen i én databaseoperation. I Postgres ser
det sådan ud med
UPDATE ... RETURNING:
UPDATE secrets
SET burned_at = now()
WHERE slug = $1
AND burned_at IS NULL
AND expires_at > now()
RETURNING payload;
Den rækkelås, som UPDATE tager, betyder, at samtidige kald bliver
afviklet ét ad gangen: Det første matcher WHERE-betingelsen, sætter
burned_at og får indholdet tilbage via RETURNING. Alle senere kald
finder burned_at allerede sat, matcher nul rækker og får status som
slettet. Der er intet vindue mellem ”tjek” og ”forbrug”, fordi de er den
samme sætning.
Netop derfor registrerer LinkPilot visninger i ét udeleligt trin gennem en databasefunktion: Samtidige forsøg på at vise hemmeligheden kan ikke lække den to gange. Vurderer du et værktøj, er det et rimeligt spørgsmål at stille leverandøren direkte: sker sletningen og læsningen i ét trin, der ikke kan deles op?
Hvorfor returnerer slettede links HTTP 410 og ikke 404?
Fordi 410 Gone siger, at ressourcen har eksisteret og er permanent utilgængelig, og det er præcis, hvad en slettet hemmelighed er. Når linket er brugt, skal det fejle tydeligt og permanent. LinkPilot gør to ting, når indholdet slettes:
- Det gemte indhold bliver overskrevet med
[REDACTED]i den samme skrivning, som ændrer status. Selv hvis databasen senere bliver kompromitteret, kan en brugt hemmelighed ikke gendannes. - Senere forsøg på at vise indholdet returnerer HTTP 410 Gone med en tydelig side om, at indholdet ”allerede er vist og destrueret”.
410 er den rigtige statuskode: Den betyder, at ressourcen har eksisteret og med vilje er permanent utilgængelig, i modsætning til 404, som siger ”aldrig hørt om den”. Forskellen betyder også noget for mennesker. En modtager, der ser ”denne hemmelighed er allerede vist”, ved, at afsenderen skal have besked. Afsenderen kan så tjekke revisionsloggen og skifte adgangskoden, hvis det ikke var modtageren, der åbnede den.
Slugs, der er svære at gætte, og hvorfor de ikke er nok
Sluggen er det eneste, der står mellem det offentlige internet og en ulæst chiffertekst (nøglen ligger i fragmentet, ikke på serveren), så den skal have reel entropi. En slug, der er trukket fra en kryptografisk sikker tilfældighedskilde med fx 22 tegn i base62, har omkring 128 bit. Det er ikke et realistisk angreb at gennemløbe dem ved nogen forespørgselsrate, en server ville overleve.
Men entropien i sluggen beskytter kun mod at blive gættet. Den gør intet ved, at URL'en bliver set: browserhistorik, proxylogfiler, videresendte e-mails, skærmdeling, udklipsholderprogrammer. Derfor bygger seriøse implementeringer flere lag ovenpå:
- Udløb, så en URL, der er blevet set, holder op med at virke til tiden.
- En adgangssætning, så en URL, der er blevet set, ikke er nok i sig selv.
- Sletning efter læsning, så en URL, der er blevet set, er værdiløs, når den rette modtager har læst indholdet.
Linkforhåndsvisninger og bots
Chatklienter (Slack, Teams, iMessage) og sikkerhedsscannere til e-mail
laver forhåndsvisninger af URL'er ved at hente dem med en GET-forespørgsel, i det øjeblik
beskeden bliver leveret. Sletter dit endpoint til visning indholdet ved
GET, sletter forhåndsvisningsbotten hemmeligheden, før et menneske
overhovedet ser den. Det er den mest almindelige fejl i praksis hos naive
værktøjer med sletning efter læsning.
Sådan beskytter LinkPilot mod det:
- Separate handlinger til metadata og visning. Et
GETpå visnings-endpointet returnerer kun status, og om der kræves en adgangssætning. En visning registreres kun ved et eksplicitPOST, som er den handling, et menneske udfører ved at klikke på knappen Vis. - Adgangssætning (Pro). Når der er sat en adgangssætning, returneres indholdet ikke til nogen, hverken bot eller menneske, uden den korrekte hash.
Cache-Control: no-storepå alle svar med indhold, så mellemled aldrig cacher hemmeligt indhold.
LinkPilots side om sikkerhedsarkitektur dokumenterer håndteringen fra ende til anden.
Livscyklussen, tilstand for tilstand
| Tilstand | Udløses af | Hvad en besøgende ser | Kan indholdet gendannes? |
|---|---|---|---|
| Aktiv | Oprettelse | Visningsside (beder om adgangssætning, hvis den er sat) | Ja, én visning er tilbage |
| Slettet | Grænsen for antal visninger er nået | ”Allerede vist og destrueret”, HTTP 410 | Nej, indholdet er overskrevet |
| Udløbet | Udløbstidspunktet passeres | Statusside om udløb | Nej, overskrives ved næste adgang |
| Tilbagekaldt | Afsenderen lukker det fra dashboardet | Viser ikke indholdet, HTTP 410 | Nej, med det samme og permanent |
Hver overgang havner i revisionsloggen, som er en Pro-funktion i
LinkPilot. Hændelsestyperne er created, viewed, failed_passphrase,
burned, expired og revoked, hver med et tidsstempel og en hashet
IP-adresse (SHA-256 med et salt, der skiftes dagligt; den rå IP-adresse
gemmes aldrig). Det spor gør ”jeg tror, de fik det” til ”vist kl. 10:05
fra det forventede land, én visning, derefter slettet”.
Almindelige udvidelser
- Adgangssætning: Kræv en adgangssætning, der sendes ad en anden kanal, før indholdet vises, så en lækket URL alene ikke er en lækket hemmelighed. I LinkPilot er det en Pro-funktion (29 $/md.) sammen med selvvalgt udløb og revisionsloggen; generatoren uden konto kan ikke sætte en. Begræns antallet af forsøg: LinkPilot tillader 5 pr. IP-adresse pr. minut pr. hemmelighed og logger hver fejl.
- Tidsbaseret udløb: Hemmeligheden forsvinder efter et fastsat tidsrum, også selv om den aldrig er læst. LinkPilots formular for brugere, der er logget ind, tilbyder 1 time, 24 timer eller 7 dage på alle planer og en selvvalgt dato og tid på Pro. Generatoren uden konto sætter 7 dage som standard. Det dækker det tilfælde, hvor linket bliver glemt i en indbakke, som sletning efter læsning ikke kan klare.
- Manuel tilbagekaldelse: Afsenderen kan lukke en ulæst hemmelighed fra dashboardet, så snart noget virker forkert. En billig forsikring, når man har sendt det til den forkerte Alex.
Grænsetilfælde, der skiller gode implementeringer fra dårlige
Den forkerte person åbner det først
Sletning efter læsning forhindrer ikke den forkerte i at se indholdet, men den gør fejlleveringen synlig. Den rette modtager møder en side om, at indholdet er slettet, revisionsloggen viser en visning, du ikke ventede, og du skifter loginoplysningerne. Det er ubehageligt, men afgrænset: præcis én eksponering, opdaget hurtigt. Sammenlign med en hemmelighed, der er indsat i en chat, hvor du aldrig ville opdage det.
Problemet med skærmbilleder
Et engangslink giver én visning, ikke én kopi. Modtageren kan tage et skærmbillede af hemmeligheden, kopiere den eller skrive den af. Intet ved linket forhindrer det, og leverandører, der antyder noget andet, sælger fiktion. Den ærlige beskrivelse: Sletning efter læsning styrer leveringskanalen, ikke modtageren. Tillid til modtageren er en forudsætning, ikke et resultat.
Prefetch og spekulativ indlæsning i browseren
Nogle browsere og apps henter på forhånd de URL'er, man sandsynligvis vil
klikke på. Fordi et velbygget værktøj kun sletter ved et eksplicit POST,
opfører prefetch sig præcis som en forhåndsvisningsbot: Den kan hente selve
siden, men aldrig indholdet.
Hvornår du ikke skal bruge et engangslink
- Værdien skal bruges igen. At kunne hente noget frem igen er hele formålet med en adgangskodeadministrator og det modsatte af formålet med et engangslink. Brug et element i en boks.
- Flere modtagere skal have den. Ét link, én læsning. Skal flere have den, må du enten oprette ét link pr. person (fint, og det kan spores) eller erkende, at du i stedet har brug for fælles adgangsstyring.
- Det er en maskine, der skal bruge den. Tjenester og CI skal læse fra en secrets manager, ikke klikke på links.
Vil du se mekanismen i aktion, så opret et med den gratis generator til hemmelige links: ingen konto, krypteret i din browser og væk efter første visning.
Læs også
- Sikker deling af adgangskoder: 5 metoder sammenlignet
- One-time secret, engangslink og hemmeligt link: de korte sider med værktøjet øverst
- Er OneTimeSecret sikkert?
- De bedste alternativer til OneTimeSecret
- OneTimeSecret vs Privnote vs Password Pusher
- Udløbende links: hemmeligheden destrueres, den bliver ikke bare viderestillet
- Gratis generator til hemmelige links
Kilder
- W3C, Web Cryptography API.
- IETF, RFC 3986, section 3.5 Fragment.
- IETF, RFC 9110: HTTP Semantics, section 15.5.11 410 Gone.
- PostgreSQL Documentation, UPDATE.
