Format kryptograficzny WB1
Techniczna specyfikacja szyfrowania używanego w WyślijBezpiecznie, dla osób, które chcą sprawdzić konstrukcję albo napisać zgodną implementację. Wektory testowe: wektory.json.
Wersja 1. Ten dokument jest źródłem prawdy dla każdej implementacji (przeglądarka, klient Windows, narzędzia). Implementacja jest zgodna z WB1 wtedy, gdy przechodzi wszystkie wektory testowe z pliku wb1.json, w tym negatywne (w repozytorium docs/test-vectors/wb1.json, publicznie: wyslijbezpiecznie.pl/bezpieczenstwo/wb1/wektory.json).
Implementacja referencyjna: biblioteka @wb/crypto (TypeScript, WebCrypto), ta sama w przeglądarce i w programie dla Windows. Testy sprawdzają wektory tą implementacją oraz niezależnie modułem node:crypto.
Założenia
- Szyfrowanie i deszyfrowanie odbywa się wyłącznie u klientów. Serwer przechowuje szyfrogram i materiał, z którego sam nie odtworzy klucza.
- Jedna wysyłka (paczka) = jeden losowy klucz treści CEK. Pliki szyfrujemy raz, niezależnie od liczby odbiorców.
- Każdy odbiorca ma własny klucz KEK z dwóch części: A (fragment linku z maila; serwer nigdy jej nie zapisuje, a w trybie „wyślę link sam” w ogóle jej nie dostaje, patrz „Dostarczenie linku”) i B (na serwerze, wydawana po poprawnym kodzie). Sam kod nie jest częścią klucza.
Prymitywy
| Nazwa | Definicja |
|---|---|
HKDF(ikm, salt, info) |
HKDF-SHA-256 (RFC 5869), wynik 32 bajty. Pusta sól = ciąg zerowej długości. info to tekst UTF-8. |
GCM(k, nonce, pt, aad) |
AES-256-GCM, nonce 12 bajtów, znacznik 16 bajtów doklejony na końcu szyfrogramu. |
rand(n) |
n bajtów z kryptograficznego generatora (WebCrypto getRandomValues). |
b64u(x) |
base64url bez dopełnienia = (RFC 4648 §5). |
u64be(x) |
liczba jako 8 bajtów big-endian. |
Klucze
CEK = rand(32) jeden na wysyłkę
A_i, B_i = rand(32), rand(32) dla każdego odbiorcy i
kekSalt_i = rand(16) jawna, przechowywana na serwerze
KEK_i = HKDF(A_i ‖ B_i, kekSalt_i, "WB1 recipient kek")
MK = HKDF(CEK, "", "WB1 manifest") klucz manifestu
FK_f = HKDF(CEK, fileSalt_f, "WB1 file") klucz pliku f
Owinięcie CEK dla odbiorcy
wrapNonce_i = rand(12)
wrapped_i = wrapNonce_i ‖ GCM(KEK_i, wrapNonce_i, CEK, "WB1 wrap:" ‖ recipientId_i) 60 bajtów
recipientId_i to publiczny identyfikator odbiorcy z linku (tekst ASCII). Wiąże owinięty klucz z odbiorcą: nie da się go podstawić innemu odbiorcy.
Serwer przechowuje: B_i (dodatkowo zaszyfrowane kluczem serwera), kekSalt_i, wrapped_i. Link w mailu: https://wyslijbezpiecznie.pl/o/<recipientId>#<b64u(A_i)>. Przeglądarka nie wysyła fragmentu #… na serwer.
Manifest
Manifest opisuje paczkę i jest szyfrowany, bo nazwy plików często zawierają dane osobowe.
{
"v": 1,
"message": "opcjonalna wiadomość nadawcy",
"files": [
{ "index": 0, "path": "PIT-11/Kowalski.pdf", "size": 12345, "type": "application/pdf", "sha256": "<hex>" }
]
}
pathto ścieżka względna z/jako separatorem (zachowuje strukturę folderów). Nie może zaczynać się od/, zawierać..ani znaków sterujących.indexodpowiada kolejności plików w wysyłce.sha256to SHA-256 tekstu jawnego pliku (hex, małe litery).- Paczka przesłana w odpowiedzi na prośbę o pliki (patrz „Prośba o pliki”) ma dodatkowo
items(kopia listy dokumentów z prośby, najwyżej 20 pozycji po 200 znaków) i przy plikachitem: numer pozycji zitems, do której klient dołączył plik. Plik bezitemto „inne pliki”. Może też miećrequired(kopia oznaczeń z prośby) iskipped: listę{ "item": n, "reason": "…" }z obowiązkowymi pozycjami, które klient pominął z wyjaśnieniem (najwyżej 500 znaków); pominięta pozycja nie ma plików.
manifestNonce = rand(12)
manifestCt = manifestNonce ‖ GCM(MK, manifestNonce, UTF8(JSON), "WB1 manifest:" ‖ transferId)
Plik: szyfrowanie strumieniowe
Nagłówek (28 bajtów)
| Bajty | Pole | Wartość |
|---|---|---|
| 0–3 | magic | ASCII WB1F |
| 4 | wersja | 1 |
| 5 | algorytm | 1 = AES-256-GCM |
| 6–7 | zarezerwowane | 0x0000 |
| 8–11 | chunkSize |
rozmiar kawałka tekstu jawnego, u32 big-endian (domyślnie 4 MiB = 4194304) |
| 12–27 | fileSalt |
rand(16) |
Kawałki
Tekst jawny długości L dzielimy na n = max(1, ceil(L / chunkSize)) kawałków po chunkSize bajtów. Ostatni może być krótszy, a pusty plik to jeden pusty kawałek.
nonce_k = 0x000000 ‖ u64be(k) ‖ final 11 bajtów licznika + 1 bajt: 0x01 dla ostatniego kawałka, 0x00 dla pozostałych
ct_k = GCM(FK_f, nonce_k, pt_k, header) AAD = cały 28-bajtowy nagłówek
To konstrukcja STREAM (jak w age):
- zamiana kolejności kawałków zmienia licznik w nonce;
- obcięcie pliku zostawia jako ostatni kawałek zaszyfrowany z
final = 0; - doklejenie kawałka po ostatnim jest wykrywane;
- zmiana nagłówka (np.
chunkSize) psuje AAD.
Każdy z tych przypadków kończy się błędem uwierzytelnienia. Klucz FK_f jest unikalny dla każdego pliku (losowa sól), więc licznik jako nonce jest bezpieczny.
Postać kanoniczna zaszyfrowanego pliku (używana w wektorach): header ‖ ct_0 ‖ ct_1 ‖ … ‖ ct_{n-1}. API przenosi nagłówek jako metadane pliku, a kawałki osobno.
Odbiorca po odszyfrowaniu liczy SHA-256 tekstu jawnego i porównuje go z sha256 z manifestu.
Skrót paczki (packageSha256)
Potwierdza kompletność paczki: żaden plik nie został dodany, usunięty ani podmieniony.
lista = pliki posortowane rosnąco po (sha256 jako bajty, potem size)
packageSha256 = SHA-256( "WB1-PKG" ‖ Σ ( sha256_f[32 bajty] ‖ u64be(size_f) ) ) hex
Nazwy plików nie wchodzą do skrótu, więc ten sam komplet plików daje ten sam skrót niezależnie od nazw. Serwer zna sha256 i size każdego pliku (potrzebne do metryki), ale nie zna nazw ani treści.
Dostarczenie linku
- Domyślnie mail z linkiem wysyła platforma. Klient przekazuje część A serwerowi wyłącznie na czas wysłania maila: A jest używana w pamięci procesu, nie jest zapisywana w bazie, kolejce, logach ani kopiach zapasowych. Wyciek bazy, magazynu plików lub kopii nie ujawnia A.
- Opcja „wyślę link sam”: klient nie przekazuje A. Nadawca wysyła link ze swojej poczty (gotowa treść do skopiowania lub otwarcia w programie pocztowym). Serwer nigdy nie widzi A.
- Kod dostępu zawsze generuje i dostarcza platforma (SMS, drugi e-mail) albo pokazuje go nadawcy (tryb ręczny). Kod nie jest częścią klucza.
Prośba o pliki
Zamawiający (użytkownik z kontem) prosi klienta (bez konta) o dokumenty. Dwa kroki, oba w tym samym formacie:
1. Treść prośby (wiadomość i lista dokumentów) jest szyfrowana w przeglądarce zamawiającego kluczem prośby RK:
RA, RB = rand(32), rand(32)
reqSalt = rand(16) jawna, przechowywana na serwerze
RK = HKDF(RA ‖ RB, reqSalt, "WB1 request key")
contentCt = nonce ‖ GCM(RK, nonce, UTF8(JSON), "WB1 request:" ‖ requestId) nonce = rand(12)
{ "v": 1, "message": "opcjonalna wiadomość", "items": ["PIT-11 od pracodawcy", "Zaświadczenie o dochodach"], "required": [true, false] }
required[i] oznacza, że pozycja items[i] jest obowiązkowa (tablica tej samej długości co items). Bez pola wszystkie pozycje są opcjonalne.
requestId to publiczny identyfikator prośby z linku (q_ + 16 znaków base64url). Link do klienta: https://wyslijbezpiecznie.pl/przeslij/<requestId>#<b64u(RA)>. Serwer przechowuje RB (zaszyfrowane kluczem serwera), reqSalt i contentCt; RB wydaje po poprawnym kodzie SMS (gdy zamawiający go włączył) albo bez kodu. Link wysyła platforma (RA tylko w pamięci, jak przy zwykłej wysyłce) albo zamawiający ze swojej poczty (serwer nie zna RA).
2. Pliki od klienta to zwykła paczka WB1 tworzona w przeglądarce klienta: nowy CEK, pliki, manifest z items i item, jeden odbiorca: zamawiający. Klient losuje dla niego A, B i kekSalt i owija CEK jak dla każdego odbiorcy. Serwer przechowuje B, a A dostaje wyłącznie na czas wysłania zamawiającemu maila z linkiem https://wyslijbezpiecznie.pl/app/prosby/pliki/<recipientId>#<b64u(A)>. Zamiast kodu B wydawane jest tylko zalogowanemu zamawiającemu (konto z hasłem i, jeśli włączona, weryfikacją dwuetapową).
Zabezpieczenia są takie jak przy zwykłej wysyłce: do treści prośby potrzebny jest link klienta (RA) i serwer (RB, po kodzie), do plików link z maila zamawiającego (A) i serwer (B, po zalogowaniu na konto zamawiającego). Link do klienta nie daje dostępu do przesłanych plików.
Czego nie chroni WB1
- Atakujący, który ma jednocześnie link z maila (A) i dostęp do serwera (B), odszyfruje paczkę. To samo dotyczy kogoś, kto przejmie działający serwer w chwili wysyłki maila przez platformę albo podmieni kod strony odbiorcy; przed tym nie chroni żadna aplikacja webowa. Ochronę daje rozdzielenie kanałów oraz to, że B jest wydawane dopiero po kodzie, z limitem prób.
- Firmowe filtry poczty (np. Microsoft Safe Links, Proofpoint, Mimecast) przepisują linki razem z fragmentem
#…, więc część A może trafić do dostawcy poczty odbiorcy. Bez kodu i B nie wystarcza do odszyfrowania. - Kod jest krótki (6 cyfr), bo nie jest częścią klucza: nie da się go sprawdzać offline, a serwer wydaje B dopiero po poprawnym kodzie, z limitem 5 prób na odbiorcę (potem blokada i powiadomienie nadawcy) i limitem prób z jednego adresu IP. Kody SMS i e-mail serwer przechowuje jako HMAC; kod do podyktowania (tryb ręczny) jest przechowywany zaszyfrowany kluczem serwera, żeby nadawca mógł go odczytać w historii.
- Ktoś, kto ma link, może celowo wyczerpać limit prób i zablokować odbiorcę; nadawca wysyła wtedy nowy kod.
- Strona odbiorcy po odczytaniu usuwa fragment
#Az paska adresu i historii przeglądarki (na czas karty trzyma go wsessionStorage). - Metadane jawne dla serwera to: liczba plików, ich rozmiary (z dokładnością do kawałka), skróty SHA-256 tekstu jawnego, czas, nadawca i odbiorcy.