01Model dowodu
Aplikacja nie polega na jednym zabezpieczeniu. Dowód składa się z czterech niezależnych warstw; podważenie materiału wymaga złamania wszystkich naraz.
Hard binding C2PA
Manifest C2PA (claim v2) osadzony w pliku zawiera hash bajtów materiału i jest podpisany kluczem P-256, który nigdy nie opuszcza Keystore/StrongBox telefonu. Certyfikat wystawia nasze API po weryfikacji key attestation.
Czas związany z dwóch stron
Serwer wydaje id zdarzenia przed nagraniem; kod QR z tym id jest wypalany w pikselach. Plik wraca na serwer po. Moment powstania mieści się między dwoma znacznikami czasu serwera.
Sygnały otoczenia
Dwa źródła lokalizacji z flagami isMock, BSSID WiFi, komórki, Play Integrity, stan urządzenia — wszystko trafia do asercji manifestu i jest oceniane po stronie API. Nic nie blokuje nagrania, wszystko jest raportowane.
Potwierdzenie out-of-band
Po odebraniu pliku API wysyła push (FCM) z kodem; dotknięcie notyfikacji odsyła kod z powrotem. Zamyka to pętlę: to urządzenie, ten użytkownik, ten moment.
Zasada projektowa: hash liczymy w tym samym procesie, w tej samej sekundzie, w której plik został zamknięty. Wszystko późniejsze (manifest, upload, push) może się opóźnić lub powtórzyć — ale originalSha256 jest już zapisany w Room, a materiał leży w prywatnym katalogu, do którego żadna inna aplikacja nie ma dostępu.
02Architektura
Jeden moduł :app, MVVM z jednokierunkowym przepływem danych (ViewModel → StateFlow<UiState>, zdarzenia jako wywołania funkcji), pakiety według funkcji. Room jest jedynym źródłem prawdy dla UI; sieć zasila Room, a nie ekrany.
core/ i da się testować bez UI.Mapa pakietów
- core/network
- Retrofit + kotlinx.serialization, DTO,
AuthInterceptor(Bearer, globalne wylogowanie na 401),apiCall {}mapujący wyjątki naApiException,FakeNotaryApi. - core/auth
SessionStore(EncryptedSharedPreferences),AuthRepository,GoogleSignInClient(Credential Manager), przepływ Apple przez Custom Tabs.- core/events
- Model
NotaryEvent,EventStatus,EventRepository— scalanie stron z API do Room bez degradacji lokalnego statusu. - core/storage
- Room (
EventEntity,EventDao),EventFileStore— pliki wfilesDir/events/{id}/. - core/crypto
DeviceKeyManager(klucz, attestation, CSR, enrollment, podpis ES256),Hashing,EcdsaDer,SelfSignedCertificates(tylko tryb fake).- core/c2pa
ManifestBuilder(JSON manifestu),C2paSealer(osadzenie i podpis przezSigner.withCallback, lokalny odczytReader).- core/location · integrity
LocationSnapshotProvider,RadioSnapshotProvider,PlayIntegrityClient,DeviceStateCollector, modelEvidenceMetadata.- core/upload
EventPipeline(hash + rejestracja + kolejkowanie),SealAndUploadWorker(pieczętowanie i wysyłka w tle).- core/push
NotaryFcmService,PushConfirmHandler(notyfikacja z deep linkiem),PushTokenRegistrar.- feature/*
- auth, home, history (+ szczegóły), capture (foto/wideo/audio), settings — każdy z własnym ViewModelem.
- navigation
- Navigation 3: klucze
@Serializable, dwa niezależne backstacki (przed i po zalogowaniu), parser deep linków.
03Cykl życia zdarzenia
Każde zdarzenie ma wiersz w Room od chwili wydania id przez serwer. Status przesuwa się tylko do przodu; jedyne przejścia wsteczne to jawne ponowienia.
EventStatus. Przejścia sieciowe są ponawiane przez WorkManager (do 20 prób, wykładniczy backoff od 30 s); błędy kryptograficzne i 4xx kończą się FAILED z komunikatem widocznym w szczegółach zdarzenia.| Status | Kto ustawia | Co jest już nieodwracalnie zapisane |
|---|---|---|
| CREATED | EventRepository.createEvent | id, nonce, verifyUrl, serverTime z API, elapsedRealtimeNanos urządzenia |
| CAPTURED | EventPipeline.registerCapture | ścieżka pliku, originalSha256, pełne EvidenceMetadata (JSON), token Play Integrity |
| SEALED | SealAndUploadWorker.seal | sealed.* z manifestem, SHA-256 pliku wynikowego, SHA-256 manifestu, securityLevel; oryginał usunięty |
| UPLOADING → UPLOADED | SealAndUploadWorker.upload | serwer ma plik, hash, metadane, token |
| CONFIRMED | EventRepository.confirm (po deep linku z pusha) | kod zgodny z zapisanym w API |
| FAILED | worker / discard / błąd zapisu | errorMessage; plik zostaje, jeśli istniał |
04Ścieżki krok po kroku
4.1 Start aplikacji i sesja
MainActivity jest jedyną aktywnością (launchMode=singleTask). Przy starcie przekazuje intent.data do MainViewModel.onIntentUri, a AppRoot obserwuje isLoggedIn wyprowadzone z SessionStore.session:
- Brak sesji → backstack
AuthNavigation(Login → EmailCode). - Sesja jest → backstack
AppNavigation(Home → History / EventDetail / Capture / Settings).
W init MainViewModel robi dwie rzeczy w tle: EventPipeline.resumePending() (patrz 4.12) i rejestrację tokenu FCM po każdym pojawieniu się sesji (PushTokenRegistrar). Token sesji jest opaque i długożyjący; AuthInterceptor dokleja go jako Authorization: Bearer i — gdy dowolny endpoint spoza /auth/ odpowie 401 — czyści SessionStore, co natychmiast przełącza UI na ekran logowania.
4.2 Logowanie
E-mail (kod jednorazowy)
sequenceDiagram
autonumber
participant U as Użytkownik
participant L as LoginScreen / AuthViewModel
participant A as API
participant S as SessionStore
U->>L: adres e-mail, „Zarejestruj / zaloguj”
L->>A: POST /auth/email/request {email}
A-->>L: 204 (kod ważny 1 h wysłany mailem)
L->>L: codeSentTo = email → nawigacja do EmailCodeKey
U->>L: kod z maila
L->>A: POST /auth/email/verify {email, code}
A-->>L: {sessionToken, user}
L->>S: save(Session) → isLoggedIn = true
000000.Google (Credential Manager)
GoogleSignInClient.signIn buduje GetGoogleIdOption z BuildConfig.GOOGLE_WEB_CLIENT_ID (identyfikator klienta webowego OAuth, nie androidowego), pobiera GoogleIdTokenCredential i przekazuje sam idToken do POST /auth/google. Backend weryfikuje podpis tokenu i aud, po czym wystawia własną sesję. Anulowanie przez użytkownika nie jest błędem; brak konta Google na urządzeniu (NoCredentialException) dostaje czytelny komunikat.
Apple (web flow)
sequenceDiagram
participant L as LoginScreen
participant B as Custom Tab (przeglądarka)
participant A as API
participant AP as Apple ID
participant M as MainActivity
L->>B: otwórz GET /auth/apple/start?state=<uuid>
B->>A: start
A->>AP: redirect do Sign in with Apple (response_mode=form_post)
AP->>A: POST callback {code, id_token, state}
A-->>B: 302 → digitalnotary://auth/apple?code=…&state=…
B->>M: intent VIEW (intent-filter scheme=digitalnotary host=auth)
M->>L: DeepLink.AppleCallback(code) → AuthViewModel.completeApple
L->>A: POST /auth/apple/exchange {code}
A-->>L: {sessionToken, user}
code do wymiany. W trybie fake link „Zaloguj z Apple” prowadzi od razu do deep linku.4.3 Rejestracja zdarzenia (przed nagraniem)
Dotknięcie „Zdjęcie / Wideo / Audio” na ekranie głównym otwiera CaptureScreen z kluczem CaptureKey(type). CaptureViewModel (assisted: type) w init wykonuje EventRepository.createEvent(type):
POST /events {type}→{id, verifyUrl, nonce, serverTime}. Wymaga sieci — to celowe:idmusi pochodzić z zegara serwera. Offline użytkownik widzi komunikat i „Spróbuj ponownie”.- Wiersz
EventEntityw Room ze statusemCREATEDicreatedAtElapsedNanos = SystemClock.elapsedRealtimeNanos()(zegar monotoniczny, którego nie da się cofnąć w ustawieniach). QrCodes.encode(verifyUrl, 512)(ZXing, korekcja M — przeżywa kompresję JPEG/H.264) i podpisDN <id> · dd.MM.yyyy HH:mm UTC.- Asynchronicznie:
PlayIntegrityClient.requestToken(id, nonce)z nonce = base64url(SHA-256(id‖nonce)).
Uprawnienia w momencie użycia
Ekran prosi o zestaw zależny od typu: kamera (foto/wideo), mikrofon (wideo/audio), lokalizacja fine+coarse, powiadomienia. Wynik trafia do onPermissions(camera, mic, location). Odmowa nie blokuje — z wyjątkiem uprawnienia niezbędnego dla samego nośnika (kamera dla foto/wideo, mikrofon dla audio), gdzie pokazujemy ekran z przyciskiem do ustawień systemowych. Wideo bez mikrofonu nagrywa się bez dźwięku (audioDenied=true w metadanych), brak lokalizacji daje locationDenied=true.
Gdy tylko lokalizacja jest przyznana i istnieje id, startują kolektory sygnałów: LocationSnapshotProvider.start() (do 6 par próbek fused+GNSS co 5 s, dopóki użytkownik kadruje) oraz RadioSnapshotProvider.requestScan() (jeden skan WiFi — na API 29+ skany są dławione do 4 na 2 min).
4.4 Capture
Zdjęcie i wideo (CameraX)
NotaryCamera.bind() binduje do cyklu życia trzy use case'y naraz — Preview, ImageCapture i VideoCapture<Recorder> — w jednej UseCaseGroup z OverlayEffect(PREVIEW | VIDEO_CAPTURE | IMAGE_CAPTURE). To nie jest fanaberia: CameraX odrzuca OverlayEffect z celami IMAGE_CAPTURE|PREVIEW; jedyna dozwolona kombinacja obejmująca zdjęcia to wszystkie trzy. Nieużywany use case po prostu stoi bezczynnie.
QrOverlayRenderer.draw(canvas, cropRect, rotationDegrees) jest wołany dla każdej klatki trafiającej do enkodera. Płótno jest w orientacji sensora, więc renderer mapuje „pionowe” płótno wirtualne: przesunięcie do środka cropRect, obrót o -rotationDegrees, przesunięcie o połowę wymiarów — dzięki temu QR (20 % krótszego boku, prawy dolny róg) i podpis (lewy dolny róg) są pionowe w gotowym pliku niezależnie od orientacji telefonu.
- Zdjęcie:
ImageCapture.takePicture(OutputFileOptions(original.jpg)), trybMAXIMIZE_QUALITY, JPEG 92. Wynik już zawiera QR w pikselach (efekt działa przed enkoderem JPEG). - Wideo:
RecorderzQualitySelector(FHD → fallback ≥ SD),FileOutputOptions(original.mp4),withAudioEnabled()tylko gdy mikrofon przyznany. Koniec nagrania to zdarzenieVideoRecordEvent.Finalize; każdy błąd inny niż limit rozmiaru pliku jest traktowany jako porażka.
Cykl życia composable: CameraCaptureContent pozostaje zmontowane także w fazie Finishing (z półprzezroczystą nakładką postępu). Gdyby ekran przełączył się na inny composable, rememberCoroutineScope zostałby anulowany w trakcie takePhoto, a DisposableEffect zwolniłby kamerę — dokładnie ten błąd wyszedł w pierwszym teście na emulatorze.
Audio (MediaRecorder)
AudioRecorderController: źródło MIC, kontener MPEG-4, koder AAC 44,1 kHz / 128 kb/s → original.m4a. Kontener BMFF jest wspierany przez c2pa-rs, więc audio dostaje ten sam manifest i hard binding co wideo. QR nie ma gdzie się wypalić — id i nonce żyją w manifeście; ekran pokazuje QR i podpis tylko informacyjnie. Poziom sygnału to maxAmplitude/32767 odpytywany co 250 ms.
Wyjście z ekranu
„Wstecz” podczas nagrywania pokazuje dialog „Przerwać nagranie?”. Potwierdzenie (albo wyjście przed nagraniem) woła CaptureViewModel.cancel(): jeśli zdarzenie ma nadal status CREATED, EventRepository.discard kasuje katalog i oznacza je FAILED („Przerwane przez użytkownika”). Wydane przez serwer id jest spalone — celowo, żeby nie dało się „przećwiczyć” kadru na jednym id.
4.5 Hash i przekazanie do pipeline'u
W chwili gdy nośnik zwraca zamknięty plik, CaptureViewModel.onFileReady(Result<File>) zatrzymuje próbkowanie lokalizacji, czeka na token Integrity i buduje CaptureSignals. Dalej wszystko dzieje się w EventPipeline.registerCapture na Dispatchers.IO:
sequenceDiagram
participant VM as CaptureViewModel
participant P as EventPipeline
participant H as Hashing
participant D as DeviceStateCollector
participant R as Room (EventDao)
participant W as WorkManager
VM->>P: registerCapture(id, file, signals)
P->>H: sha256Hex(file) — streaming, 64 KiB bufor
H-->>P: originalSha256
P->>D: collect() (model, patch, dev-options, ADB, auto-time, root/emulator hints)
P->>P: EvidenceMetadata(id, verifyUrl, nonce, serverTime, captureElapsedMs, location, radio, device, integrityTokenSha256…)
P->>R: update(status=CAPTURED, filePath, hash, metadataJson, integrityToken)
P->>W: enqueueUniqueWork("seal-upload-{id}", KEEP, expedited, CONNECTED)
P-->>VM: success → Phase.Done(id) → nawigacja do EventDetail
metadata.json obok pliku — przydatne przy analizie poza aplikacją.captureElapsedMs = różnica elapsedRealtimeNanos między wydaniem id a zamknięciem pliku. Backend zna serverTimeAtCreate i czas odebrania pliku, więc może sprawdzić, czy deklarowany czas nagrania mieści się w tym oknie i czy nie jest podejrzanie długi.
4.6 Klucz urządzenia i certyfikat (enrollment)
Pieczętowanie wymaga certyfikatu dla klucza sprzętowego. DeviceKeyManager.ensureEnrolled() zwraca istniejący, jeśli jest ważny dłużej niż 24 h; w przeciwnym razie przeprowadza enrollment (pod mutexem, żeby dwa workery nie wygenerowały dwóch kluczy):
sequenceDiagram
participant K as DeviceKeyManager
participant KS as Android Keystore
participant CM as CertificateManager (c2pa-android)
participant A as API (CA)
K->>A: POST /devices/enroll/challenge
A-->>K: {challenge}
K->>KS: generateKeyPair EC P-256, setAttestationChallenge(challenge), StrongBox → fallback TEE
KS-->>K: klucz + łańcuch attestation (Google root)
K->>CM: createCSR(alias, CN=Digital Notary Device)
CM->>KS: podpis CSR kluczem sprzętowym
K->>A: POST /devices/enroll {csr, attestationChain[], integrityToken, securityLevel}
A->>A: weryfikuje łańcuch do Google Hardware Attestation Root, challenge, poziom (StrongBox/TEE), pakiet
A-->>K: {certChain[] PEM, expiresAt}
K->>K: zapis łańcucha + expiresAt + securityLevel (EncryptedSharedPreferences)
- StrongBox
setIsStrongBoxBacked(true), gdy urządzenie deklarujeFEATURE_STRONGBOX_KEYSTORE;StrongBoxUnavailableException→ ponowna generacja w TEE. Poziom trafia dosecurityLevel(STRONGBOX / TEE / SOFTWARE) i do manifestu.- Biometria
setUserAuthenticationRequired(false)— pieczętowanie działa w tle, bez ekranu.- Podpis
signEs256Raw(data):SHA256withECDSAprzez Keystore, wynik DER konwertowany przezEcdsaDer.toRawna 64-bajtower‖s, którego wymaga COSE/C2PA.- Tryb fake
- API zwraca pusty łańcuch →
SelfSignedCertificates.pemForbuduje self-signed leaf podpisany tym samym kluczem sprzętowym, żeby ścieżka działała bez CA.
Profil certyfikatu, którego pilnuje c2pa-rs (§14.5.1 specyfikacji) — CA musi go spełnić co do joty: X.509 v3, end-entity (basicConstraints cA=false), keyUsage=digitalSignature (critical), EKU z konkretną wartością (np. emailProtection; anyExtendedKeyUsage zabronione), AuthorityKeyIdentifier obowiązkowy, SubjectKeyIdentifier zalecany, klucz EC P-256/384/521, bez issuer/subject unique id, bez nieobsłużonych rozszerzeń krytycznych. Brak AKI dał na emulatorze błąd „the certificate is invalid”.
4.7 Pieczętowanie C2PA
SealAndUploadWorker (@HiltWorker, expedited, foreground dataSync na API < 31) dla zdarzenia w stanie CAPTURED:
- Odczyt
EvidenceMetadataz Room,ensureEnrolled(), uzupełnieniesecurityLevel. ManifestBuilder.build(type, metadata, captureTime)→ JSON manifestu (sekcja 6).C2paSealer.seal(original, sealed, mimeType, manifest, certChainPem):C2PASettingszcreated_assertion_labelsrozszerzonym o naszą asercję — c2pa-rs oznacza ją jako created, nie gathered;Builder.fromJson(manifest, settings),Signer.withCallback(ES256, certChainPem) { keyManager.signEs256Raw(it) };builder.sign(mimeType, FileStream(original, READ), FileStream(sealed, WRITE), signer)— c2pa-rs liczy hard binding (c2pa.hash.datadla JPEG, BMFF hash dla MP4/M4A), buduje claim, podpisuje COSE i osadza JUMBF w kopii pliku;- wynik: SHA-256 pliku
sealed.*(to jesthashwysyłany do API) i SHA-256 bajtów manifestu.
- Oryginał usunięty; Room:
SEALED,filePath=sealed.*, oba hashe, wzbogacone metadane (także nadpisane wmetadata.json).
Bez tsaURL — brak zewnętrznego znacznika czasu RFC 3161; rolę kotwicy czasowej pełni serwer (patrz 10).
4.8 Upload
Ten sam worker kontynuuje dla SEALED/UPLOADING: status UPLOADING, potem POST /events/{id}/content jako multipart:
| Część | Typ | Zawartość |
|---|---|---|
| content | image/jpeg · video/mp4 · audio/mp4 | plik sealed.* z osadzonym manifestem |
| hash | text/plain | SHA-256 hex pliku content — API liczy własny i porównuje |
| manifestHash | text/plain | SHA-256 bajtów manifestu (szybki indeks / deduplikacja) |
| metadata | application/json | EvidenceMetadata — ta sama treść, co w asercji manifestu, ale czytelna bez parsera C2PA |
| integrityToken | text/plain (opcjonalne) | token Play Integrity do dekodowania po stronie serwera |
Polityka błędów (failOrRetry): ApiException.Network lub HTTP ≥ 500 → Result.retry() (status wraca z UPLOADING do SEALED, żeby UI nie pokazywało wiecznego „Wysyłanie…”), maksymalnie 20 prób; wszystko inne → FAILED z komunikatem. Praca jest unikalna per id (ExistingWorkPolicy.KEEP), więc podwójne zakolejkowanie nie robi drugiego uploadu.
4.9 Potwierdzenie push
sequenceDiagram
participant A as API
participant F as FCM
participant S as NotaryFcmService
participant H as PushConfirmHandler
participant U as Użytkownik
participant M as MainActivity / MainViewModel
participant R as EventRepository
A->>A: po 200 na /content: losowy confirmCode zapisany przy zdarzeniu
A->>F: data message {type:"confirm", eventId, code}
F->>S: onMessageReceived (także w tle — data-only)
S->>H: onConfirmationPush(eventId, code)
H->>H: kanał "event_confirmations", notyfikacja z PendingIntent VIEW digitalnotary://event/{id}/confirm?code=…
U->>M: dotknięcie notyfikacji (singleTask → onNewIntent)
M->>M: DeepLink.ConfirmEvent → consumeDeepLink()
M->>R: confirm(id, code) → POST /events/{id}/confirm {confirmCode}
A-->>R: {status:"confirmed"}
R->>R: Room: CONFIRMED
M->>U: snackbar „Zdarzenie … potwierdzone”, ekran szczegółów
- Dwa mechanizmy zapasowe: rotacja tokenu (
onNewToken→PUT /me/push-token) i przycisk „Potwierdź ponownie” na ekranie szczegółów (POST /events/{id}/resend-confirmation), gdy push nie dotarł. - Notyfikacja wymaga
POST_NOTIFICATIONS— prosimy o nie razem z uprawnieniami capture, aPushConfirmHandlersprawdza je przednotify(). - W trybie fake
FakeNotaryApi.uploadContentwołaPushConfirmHandlerbezpośrednio po 2 s — ta sama ścieżka od notyfikacji w dół.
4.10 Historia i szczegóły
Ekran główny obserwuje EventRepository.observeRecent(3) (Room, sortowanie po createdAtServer malejąco) i przy wejściu odświeża stronę 1 z API. Historia obserwuje observeAll(), a HistoryViewModel.loadNext() dokłada kolejne strony (GET /events?page=n&perPage=3); „Pokaż starsze” znika, gdy page·perPage ≥ total.
EventRepository.mergeRemote scala stronę z lokalnym stanem według reguł, które chronią zdarzenia będące w toku:
- nieznane lokalnie → wstawiane ze statusem z serwera (bez pliku,
nonce=""); - lokalnie
CONFIRMEDalbo zdalnieconfirmed→CONFIRMED; - lokalnie przed
UPLOADED(i nieFAILED) → status lokalny zostaje — serwer nie może „cofnąć” zdarzenia docreated; - pola dowodowe (ścieżka, hash) nigdy nie są nadpisywane danymi z API;
trustLevelzverificationjest dopisywany.
Szczegóły zdarzenia (EventDetailViewModel, assisted eventId): podgląd (Coil; ikona dla audio), status, ID, czas, SHA-256, verifyUrl, wynik lokalnej walidacji (C2paSealer.readManifest → Reader.json() musi zawierać naszą asercję i nie mieć validation_state: Invalid), lokalizacja z ostrzeżeniem przy anyMock, poziom klucza, oraz akcje: „Potwierdź ponownie” (UPLOADED), „Ponów wysyłanie” (FAILED z plikiem → EventPipeline.enqueue), „Udostępnij plik” (FileProvider {applicationId}.files, grant tylko do odczytu).
4.11 Ustawienia i konto
- Wyloguj —
POST /auth/logoutbest-effort; lokalna sesja jest czyszczona niezależnie od odpowiedzi. - Usuń konto — dialog, potem
DELETE /me; dopiero po sukcesieEventRepository.clearLocal()(kasujefilesDir/eventsi tabelę) iDeviceKeyManager.wipe()(usuwa klucz z Keystore i certyfikat). Backend powinien przy tym unieważnić certyfikat urządzenia. - Regulamin, polityka prywatności, strona — Custom Tabs; adresy w
BuildConfig.
4.12 Awarie i wznawianie
| Scenariusz | Co się dzieje |
|---|---|
| Proces ubity między zamknięciem pliku a pieczętowaniem | Wiersz ma CAPTURED + hash + metadane. resumePending() przy następnym starcie (i sam WorkManager po reboocie) ponownie kolejkuje workera dla wszystkich CAPTURED / SEALED / UPLOADING. |
Brak sieci przy POST /events | Ekran capture nie wystartuje — komunikat i „Spróbuj ponownie”. Bez id serwera nie ma zdarzenia (założenie modelu czasu). |
| Brak sieci przy enrollment / upload | Worker ma constraint CONNECTED i backoff; zdarzenie widnieje w historii jako „Zapisane” / „Zapieczętowane” do skutku. |
| Certyfikat wygasł | ensureEnrolled odnawia go z wyprzedzeniem 24 h; nowa para kluczy, nowe attestation. |
| Wygaśnięcie sesji (401) | Globalne wylogowanie; kolejka zdarzeń i pliki zostają — po zalogowaniu worker kontynuuje. |
| Odmowa uprawnień po drodze | Nagranie idzie dalej z flagami locationDenied / audioDenied; w metadanych brak odpowiednich sekcji, co samo w sobie jest sygnałem dla oceny. |
05Sygnały anty-manipulacyjne
Aplikacja nie rozstrzyga, czy materiał jest wiarygodny — zbiera dowody i przekazuje ocenę serwerowi. Każdy sygnał ma swoje miejsce w EvidenceMetadata i w manifeście.
| Zagrożenie | Co zbiera aplikacja | Co powinien sprawdzić backend |
|---|---|---|
| Symulowana lokalizacja (mock provider) | Location.isMock (API 31+) / isFromMockProvider dla każdej próbki; osobno fused i czysty GNSS (LocationManager.GPS_PROVIDER); fusedGnssDistanceM; dokładność, wiek fixu, czas fixu z satelitów (Location.time) | anyMock → niska wiarygodność; duża rozbieżność fused/GNSS; zgodność z geolokalizacją BSSID/komórek |
| Podmiana radia / lokalizacja z innego miejsca | do 20 najsilniejszych BSSID z RSSI i częstotliwością, podłączony BSSID, do 12 komórek (LTE/NR/WCDMA/GSM: id, MCC/MNC, dBm, registered) | geolokalizacja BSSID/CID (własna baza lub usługa) vs GPS |
| Zmodyfikowana aplikacja, root, emulator | Play Integrity z nonce związanym ze zdarzeniem; rootHints (test-keys, ścieżki su/magisk, pakiety), emulatorHints, developer options, ADB | dekodowanie tokenu: MEETS_STRONG_INTEGRITY → pełna, DEVICE → standardowa, brak → niska; appRecognitionVerdict |
| Podpis spoza urządzenia | klucz w StrongBox/TEE, cert tylko po weryfikacji attestation, securityLevel w manifeście | łańcuch attestation przy enrollment, krótki okres ważności certu, revocation przy usunięciu konta |
| Manipulacja zegarem | serverTimeAtCreate, captureElapsedMs (monotoniczny), deviceWallClock, autoTimeEnabled, czas fixu GNSS, strefa czasowa | czas nagrania ⊂ [Tcreate, Tupload]; różnica zegara urządzenia vs serwera |
| Podmiana pliku przed hashem | hash w tym samym procesie natychmiast po zamknięciu pliku; plik w prywatnym filesDir; allowBackup=false | hash z żądania == SHA-256 odebranego pliku; walidacja hard bindingu C2PA (c2pa-rs) do naszego CA |
| Replay / podszycie | nonce serwera w manifeście; id jednorazowe; kod z pusha | nonce w asercji == wydany; id nieużyte wcześniej; confirmCode zgodny |
| Nagranie ekranu podczas capture | FLAG_SECURE na ekranie capture (build release) | — |
06Manifest C2PA
ManifestBuilder produkuje definicję claim v2 z trzema asercjami. c2pa-rs dokłada sam c2pa.hash.data / BMFF hash, miniaturę i podpis COSE.
{
"claim_version": 2,
"claim_generator_info": [{ "name": "Digital Notary", "version": "1.0" }],
"title": "Digital Notary photo 8CE625C1",
"format": "image/jpeg",
"assertions": [
{ "label": "c2pa.actions",
"data": { "actions": [{
"action": "c2pa.created",
"digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture",
"when": "2026-09-15T07:33:12.480Z",
"softwareAgent": { "name": "Digital Notary", "version": "1.0" } }] } },
{ "label": "c2pa.metadata",
"data": { "@context": { "exif": "http://ns.adobe.com/exif/1.0/" },
"exif:DateTimeOriginal": "2026-09-15T07:33:12Z",
"exif:GPSLatitude": "37,25.31990N", "exif:GPSLongitude": "122,5.04000W",
"exif:GPSAltitude": "5.0/1", "exif:GPSHPositioningError": "5.0/1",
"exif:GPSTimeStamp": "2026-09-15T07:33:05Z", "exif:GPSProcessingMethod": "gps" } },
{ "label": "com.arctical.digitalnotary.event",
"data": {
"eventId": "8CE625C1",
"verifyUrl": "https://verify.digitalnotary.example/e/8CE625C1",
"nonce": "7f59e650-…",
"serverTimeAtCreate": "2026-09-15T07:33:03.412Z",
"captureElapsedMs": 8976,
"deviceWallClock": "2026-09-15T07:33:12.480Z",
"originalSha256": "fb4374a0…",
"location": { "fused": {…, "isMock": false}, "gnss": {…, "isMock": false},
"samples": [...], "anyMock": false, "fusedGnssDistanceM": 0.0037,
"gpsProviderEnabled": true },
"locationDenied": false, "audioDenied": false,
"radio": { "connectedBssid": null, "wifi": [...], "cells": [...], "wifiScanAllowed": true },
"device": { "manufacturer": "Google", "model": "sdk_gphone64_arm64", "sdkInt": 35,
"securityPatch": "…", "buildFingerprint": "…", "appVersion": "1.0 (1)",
"adbEnabled": true, "autoTimeEnabled": true,
"rootHints": [], "emulatorHints": ["fingerprint","product"], "timeZone": "…" },
"integrityTokenSha256": null,
"securityLevel": "SOFTWARE" } }
]
}
stds.exifjest w C2PA 2.x deprecjonowane — stądc2pa.metadataz przestrzenią nazw EXIF; współrzędne w formacie XMPDDD,MM.mmmmk.- Preferowana jest próbka GNSS (czas z satelitów), fused tylko gdy GNSS nie dał fixu.
- Pola
nullsą pomijane (explicitNulls=false) — brak pola znaczy „nie dało się zebrać”. - Nasza asercja jest oznaczona jako created (a nie gathered) przez
created_assertion_labelsw ustawieniach buildera.
Weryfikacja poza aplikacją: c2patool sealed.jpg (CLI c2pa-rs) z zaufanym rootem naszego CA; każda edycja pliku łamie hard binding.
07Kontrakt API
Interfejs NotaryApi (Retrofit). Wszystkie odpowiedzi mogą nieść serverTime (ISO-8601). Poza /auth/* wymagany nagłówek Authorization: Bearer <sessionToken>.
| Metoda | Endpoint | Żądanie → odpowiedź |
|---|---|---|
| POST | /auth/google | {idToken} → {sessionToken, user} |
| GET | /auth/apple/start?state= | redirect do Apple; callback → digitalnotary://auth/apple?code=…&state=… |
| POST | /auth/apple/exchange | {code} → {sessionToken, user} |
| POST | /auth/email/request | {email} → 204 (kod ważny 1 h) |
| POST | /auth/email/verify | {email, code} → {sessionToken, user} |
| POST | /auth/logout | → 204 |
| DELETE | /me | → 204 (+ revocation certyfikatu urządzenia) |
| PUT | /me/push-token | {fcmToken} → 204 |
| POST | /devices/enroll/challenge | → {challenge} |
| POST | /devices/enroll | {csr, attestationChain[], integrityToken?, securityLevel} → {certChain[], expiresAt} |
| POST | /events | {type: photo|video|audio} → {id, verifyUrl, nonce, serverTime} |
| POST | /events/{id}/content | multipart content, hash, manifestHash, metadata, integrityToken? → {status:"received"}; potem push |
| POST | /events/{id}/confirm | {confirmCode} → {status:"confirmed"} |
| POST | /events/{id}/resend-confirmation | → 204 (ponowny push) |
| GET | /events?page=&perPage=3 | → {items[{id,type,createdAt,status,verifyUrl?,thumbnailUrl?,verification?}], page, perPage, total} |
| GET | /events/{id} | → EventDto z verification{hashMatches, c2paValid, integrityVerdict, locationConsistent, trustLevel} |
Statusy serwera mapowane w EventRepository: created, uploaded|received, confirmed, failed|rejected; nieznane traktowane jak uploaded. Błędy: {code, message} → ApiException.Http(code, apiCode, message); 401 → Unauthorized; IOException → Network.
08Bezpieczeństwo danych na urządzeniu
- Pliki dowodowe
filesDir/events/{id}/— prywatne dla UID aplikacji, poza MediaStore i poza zasięgiem innych aplikacji; oryginał kasowany po pieczętowaniu (jedno źródło prawdy).- Backup
allowBackup=false,data_extraction_ruleswyklucza pliki, bazę i preferencje z backupu chmurowego i transferu D2D.- Sesja i certyfikat
EncryptedSharedPreferences(AES-256-GCM, klucz główny w Keystore) — osobne plikisessionidevice_key.- Klucz podpisujący
- Android Keystore, StrongBox jeśli jest; nieeksportowalny; usuwany przy usunięciu konta.
- Ruch sieciowy
usesCleartextTraffic=false; logowanie OkHttp tylko w debug (poziom BASIC — bez treści). Certificate pinning do dodania po wystawieniu certów produkcyjnych (TODO wNetworkModule).- Ekran
FLAG_SECUREna ekranie capture w buildach release (w debug wyłączone, żeby dało się robić zrzuty do QA).- Udostępnianie
FileProviderzgrantUriPermissions, tylkoFLAG_GRANT_READ_URI_PERMISSION, ścieżka ograniczona doevents/.- R8
- Włączone w release; reguły
keepRules/rules.keepchronią JNI c2pa-android, JNA, BouncyCastle, serializery kotlinx i interfejsy Retrofit.
09Konfiguracja i tryb fake
Wartości środowiskowe czyta app/build.gradle.kts z local.properties (poza repozytorium) do BuildConfig:
api.baseUrl=https://api.example.com/ # domyślnie placeholder
api.useFake=true # debug: FakeNotaryApi zamiast HTTP
api.useFake.release=false
google.webClientId=xxx.apps.googleusercontent.com
- Firebase: plugin
google-servicesjest aplikowany tylko, gdy istniejeapp/google-services.json; bez niego build i tryb fake działają, aPushTokenRegistrarpo cichu pomija rejestrację. - FakeNotaryApi implementuje cały interfejs Retrofita w pamięci: sesje dla dowolnego adresu, kod e-mail
000000, generowanieid/verifyUrl/nonce, lokalny „push” 2 s po uploadzie, weryfikacja kodu, stronicowanie. Zwraca pusty łańcuch przy enrollment → self-signed cert. - Buildy: debug ograniczony do ABI
arm64-v8aix86_64(natywne biblioteki c2pa-rs ważą ~45 MB na ABI); release zawiera wszystkie — App Bundle dzieli je per urządzenie.
| Polecenie | Co robi |
|---|---|
| ./gradlew :app:installDebug | build + instalacja na podłączonym urządzeniu (tryb fake) |
| ./gradlew :app:testDebugUnitTest | 13 testów: ManifestBuilder, EcdsaDer, Hashing, reguły scalania EventRepository |
| ./gradlew :app:lintDebug | lint bez błędów (NewApi, LocalContext, CredentialManager) |
| ./gradlew :app:assembleRelease | R8, wszystkie ABI |
10Ograniczenia i następne kroki
| Obszar | Stan | Uwaga |
|---|---|---|
| Backend / CA | brak | Kontrakt z sekcji 7 do implementacji; profil certyfikatu z sekcji 4.6 jest twardym wymogiem c2pa-rs. |
| Znacznik czasu RFC 3161 | nie | Signer.withCallback(tsaURL=null). Kotwicą czasu jest serwer (id przed, odbiór po). Dodanie TSA to jedna zmiana w C2paSealer. |
| Play Integrity | tylko na Play | Poza dystrybucją przez Google Play token zwykle nie przychodzi; brak tokenu jest raportowany, nie blokuje. |
| StrongBox / TEE | niezweryfikowane | Na emulatorze poziom to SOFTWARE; ścieżka attestation wymaga fizycznego urządzenia. |
| Certificate pinning | TODO | CertificatePinner w NetworkModule po wystawieniu certów produkcyjnych, z planem rotacji. |
| FCM | wymaga google-services.json | Kod gotowy (NotaryFcmService), zweryfikowany przez lokalny odpowiednik pusha. |
| QR w audio | n/d | Brak nośnika na QR; id/nonce w manifeście. Ewentualnie watermark audio w przyszłości. |
| Skan WiFi | ograniczony | Android dławi skany (4/2 min) i wymaga lokalizacji; odmowa lokalizacji = brak WiFi/cell — raportowane. |
| Weryfikacja zewnętrzna | do zrobienia | Uruchomić c2patool na plikach z urządzenia z prawdziwym łańcuchem CA; na emulatorze potwierdzono strukturę JUMBF i lokalny odczyt Reader. |