diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e7d17b0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + - run: flutter pub get + - run: flutter test + + secrets: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Scan tracked files for leaked secrets + run: | + set -e + patterns='eyJhbGci[A-Za-z0-9_-]{10,}|sk-proj-[A-Za-z0-9]{10,}|GOCSPX-[A-Za-z0-9_-]{10,}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|[0-9]{12,}-[a-z0-9]+\.apps\.googleusercontent\.com' + if git grep -E "$patterns" -- ':!google_oauth.example.env' ':!*.md' ':!lib/screens/account_screen.dart' ':!.github/workflows/ci.yml'; then + echo 'Possible secret found in tracked files — see matches above.' + exit 1 + fi + echo 'No secret patterns in tracked source files.' diff --git a/.gitignore b/.gitignore index 3820a95..8384b67 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,12 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Local Google OAuth secrets (use google_oauth.example.env as a template) +google_oauth.env + +# Android release signing (never commit) +android/key.properties +android/*.jks +android/*.keystore +**/upload-keystore.jks diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..49ca69d --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2026 Wejdan Al Amri. All rights reserved. + +This repository contains the Signata digital watermarking application source code. + +Permission is granted to view and fork this repository for personal learning, +non-commercial evaluation, and security research. + +Without prior written permission from the copyright holder, you may NOT: + +- Use this software or its design to operate a competing product or service +- Redistribute, sublicense, or sell copies of this software +- Remove copyright or attribution notices +- Use the "Signata" name, logo, or branding in a way that implies endorsement + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. THE AUTHOR +SHALL NOT BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY ARISING FROM +USE OF THIS SOFTWARE. + +For licensing inquiries: FocusMindDev@gmail.com diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..f3bba64 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,57 @@ +# Privacy Policy for Signata + +**Last Updated:** August 10, 2026 + +At Signata, we are fully committed to protecting the privacy of our users and securing digital content with the highest security standards. This Privacy Policy explains how we collect, use, process, and protect your personal data, media files, and the links you trace through our platform. + +## 1. Data We Collect and Process + +Given Signata's nature as a digital watermarking and ownership verification platform, we handle specific categories of data: + +- **Basic Account Data:** Upon registration, we collect the necessary information to create and manage your account (e.g., name, email address, and authentication data). +- **Media Files:** This includes images (PNG, JPG), audio (WAV), video clips (MP4/MOV), and documents (PDF) that you protect or verify using the app. +- **Metadata & Encrypted Fingerprints:** We process and generate structural identifiers (such as SHA-256) and hidden encrypted fingerprints to prove content ownership. +- **Trace Data & Public URLs:** The public links you input into the "Trace" tool to scan for your content on platforms like Instagram, TikTok, and X. + +## 2. On-Device Verification & Processing + +At Signata, the security and privacy of your content are our top priorities: + +- **Media Protection:** Embedding watermarks, audio fingerprints, and structural identifiers for videos and documents runs locally on your device. +- By default Signata does not upload your original media or claim keys to Signata servers. History, Trace radar, and published claims stay in on-device storage scoped to your account. +- If you later configure an optional self-hosted claim registry, only fingerprint metadata you choose to publish may leave the device — never your original files. + +## 3. How We Use Your Data + +The data we collect is strictly used for the following purposes: + +- To enable you to embed hidden ownership fingerprints in media without altering its visual or auditory quality. +- To operate the Radar and Trace services, scanning public links and matching them against your registered fingerprints to detect unauthorized use. +- To improve our hidden fingerprint algorithms and their robustness against common modifications (such as compression, cropping, and color shifts). +- To provide technical support and manage your account. + +## 4. Third-Party Services and External Links + +- **Trace Tool:** This tool interacts with the public interfaces of platforms (like Instagram, TikTok, and X) to scan the links you provide. We do not control the privacy practices of these platforms, and your interaction with them is governed by their respective privacy policies. +- We will never share your Trace logs, search history, or encrypted fingerprints with any third parties for marketing or advertising purposes. + +## 5. Data Security + +We implement industry-standard security protocols and advanced encryption (including SHA-256 hashing algorithms) to protect your data from unauthorized access, alteration, or disclosure. The binding of encrypted structural identifiers ensures that your proof of ownership remains secure and tamper-proof. + +## 6. User Rights (Access and Deletion) + +In accordance with applicable data protection laws, you have the right to: + +- **Access:** View your ownership records (History) and actively tracked links (Watching) directly in the app. +- **Deletion:** Use Account → Delete account to erase your local Signata account record, claim key, history, Trace data, and usage flags on this device. This does not remove watermarks already embedded in exported files. Email FocusMindDev@gmail.com for privacy requests beyond what the app can erase locally. + +## 7. Governing Law and Policy Updates + +This Privacy Policy is governed by and construed in accordance with the applicable laws of the Kingdom of Saudi Arabia, primarily the Personal Data Protection Law (PDPL). We may update this policy periodically, and we will notify you of any material changes via the app or email. + +## 8. Contact Us + +For any legal or technical inquiries regarding your privacy and data management, please contact us at: + +**Support Email:** FocusMindDev@gmail.com diff --git a/README.md b/README.md index 6878c27..e7cf160 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,84 @@ -# Signata (Flutter) +# Signata -Invisible ownership. Verifiable authenticity. +**Invisible ownership. Verifiable authenticity.** -Native Flutter app for digital watermarking — images, audio, video, and PDFs — processing entirely on-device. +Native Flutter app for digital watermarking — images, audio, video, and PDFs — with processing entirely on-device. -## What's included +**Latest release:** [v1.2.4](https://github.com/WejdanBa-CS/Signata/releases/tag/v1.2.4) -- **Home** — full landing story (features, how it works, architecture, roadmap) -- **Tools hub** — Image, Audio, Video, and PDF protect/verify flows -- **Auth** — email accounts + Google Sign-In gate -- **History** — local on-device record of every embed/verify run -- Sealed JSON verification reports (shareable) +## Screenshots -## Run +

+ Signata home + Image watermarking + Audio and video + Tools hub + Trace online +

+ +| Screen | File | +|--------|------| +| Home | [`docs/screenshots/01-home.png`](docs/screenshots/01-home.png) | +| Image watermarking | [`docs/screenshots/02-capabilities-image.png`](docs/screenshots/02-capabilities-image.png) | +| Audio & video | [`docs/screenshots/03-capabilities-audio-video.png`](docs/screenshots/03-capabilities-audio-video.png) | +| Tools | [`docs/screenshots/04-tools.png`](docs/screenshots/04-tools.png) | +| Trace | [`docs/screenshots/05-trace.png`](docs/screenshots/05-trace.png) | + +## Features + +- **Protect** — embed signed ownership fingerprints in images, audio, video (MP4/MOV), and PDFs +- **Verify** — read fingerprints back from shared files and confirm they match your claim key +- **Trace online** — scan public media URLs, watchlist re-scans, publish claims for catalog matching +- **Sealed reports** — shareable JSON verification reports with tamper detection +- **Auth** — email accounts + Google Sign-In; accounts and claim keys stay on-device +- **History** — local record of every embed/verify run +- **Privacy-first** — no Signata cloud database required; optional self-hosted claim registry + +## Stack + +| Layer | Tech | +|-------|------| +| App | Flutter 3.x, Dart 3.12+ | +| Crypto | PBKDF2, Ed25519-style claim signing, SHA-256 structural IDs | +| Media | On-device codecs for PNG/JPG, WAV, MP4/MOV, PDF | +| Auth | Google Sign-In (optional), local email accounts | +| Release | Android App Bundle (Play Console) | + +## Quick start ```sh +git clone https://github.com/WejdanBa-CS/Signata.git +cd Signata flutter pub get flutter run ``` +Optional remote claim registry (so other devices can look up fingerprints): + +```sh +flutter run --dart-define=SIGNATA_REGISTRY_URL=https://your-registry.example +``` + +See [`tool/registry_server.example.md`](tool/registry_server.example.md) for the expected HTTP API. Without it, tracing still works by reading fingerprints from downloaded files and matching your on-device published claims. + +## Google Sign-In (optional) + +Copy `google_oauth.example.env` → `google_oauth.env` and set your Web client ID: + +```env +GOOGLE_SERVER_CLIENT_ID=YOUR_WEB_CLIENT_ID.apps.googleusercontent.com +``` + +Then run: + +```sh +flutter run --dart-define-from-file=google_oauth.env +``` + +Full Play release checklist: [`docs/RELEASE_PLAY.md`](docs/RELEASE_PLAY.md) + +**Never commit** `google_oauth.env`, `android/key.properties`, or keystore files. + ## Test ```sh @@ -26,13 +86,18 @@ flutter test flutter analyze ``` -## Build Android APK +67 unit/integration tests cover auth, TOTP, local data, watermark round-trips, tracing, and sealed reports. + +## Build Android ```sh flutter build apk --release +# or for Play Store: +.\tool\build_release.ps1 ``` -The APK lands at `build/app/outputs/flutter-apk/app-release.apk`. +APK: `build/app/outputs/flutter-apk/app-release.apk` +AAB: `build/app/outputs/bundle/release/app-release.aab` > On Windows, Flutter plugin builds need **Developer Mode** enabled (`start ms-settings:developers`) so symlinks work. If a Gradle build fails with a lock error, stop other Gradle/Android Studio processes and retry. @@ -46,5 +111,26 @@ lib/ theme.dart main.dart assets/ +docs/ # Play release, privacy HTML, delete-account page +docs/screenshots/ # App store / README phone screenshots website-source/ # original React site (reference) ``` + +## Privacy + +Full policy: [`PRIVACY.md`](PRIVACY.md) · hosted copy in [`docs/privacy.html`](docs/privacy.html) +Terms: [`docs/terms.html`](docs/terms.html) + +## License & copyright + +Copyright © 2026 Wejdan Al Amri. All rights reserved. + +Source is published for transparency and portfolio use. See [`LICENSE`](LICENSE) — you may not copy, redistribute, or run a competing commercial service from this code without permission. + +## Security + +Report issues responsibly — see [`SECURITY.md`](SECURITY.md). + +## Contact + +Questions: [FocusMindDev@gmail.com](mailto:FocusMindDev@gmail.com) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..c68b0b9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,71 @@ +# Security + +We take security seriously for Signata, especially around claim keys, on-device auth, and trace tooling. + +## Reporting a vulnerability + +**Please do not** open public GitHub issues for security bugs. + +Email **FocusMindDev@gmail.com** with: + +- Description of the issue +- Steps to reproduce +- Impact (what an attacker could access or do) +- Your GitHub username (optional, for credit) + +We aim to respond within a few business days. + +## Scope + +In scope: + +- Authentication bypass, weak local crypto, or recovery-kit tampering +- Watermark / fingerprint forgery or verification bypass +- Unsafe URL handling in Trace (open redirects, unexpected fetches) +- Data leaks from optional registry integration +- Android signing or OAuth misconfiguration documented in this repo + +Out of scope: + +- Social engineering, physical device access +- Denial of service without a practical exploit path +- Issues in third-party services (Google Sign-In, Play Console) +- Attacks that require the victim to import a malicious recovery kit they did not create + +## Safe harbor + +Good-faith research that avoids privacy violations and service disruption is appreciated. + +## Secrets checklist + +**Never commit:** + +- `google_oauth.env` (use `google_oauth.example.env` only) +- `android/key.properties`, `*.jks`, `*.keystore` +- Real Google OAuth client IDs in source (build with `--dart-define-from-file` or paste at runtime in Account) + +Android SHA-1 fingerprints in docs are **not secrets** (they appear in signed APKs) but keep upload keystore passwords local only. + +CI runs a basic secret pattern scan on every push. + +## OWASP-aligned controls (Cheat Sheet Series) + +| Control | Status | Notes | +|--------|--------|-------| +| **Authentication** | Strong | PBKDF2-SHA256, TOTP 2FA, lockout, secure session storage | +| **Cryptography** | Strong | Claim keys and watermarks stay on-device; recovery kit integrity checks | +| **Input validation** | Hardened | Trace URLs validated in `lib/core/safe_url.dart` — http(s) only, no private/metadata hosts, no embedded credentials | +| **SSRF (Trace / registry)** | Hardened | Block localhost, RFC1918, link-local, and CGNAT ranges before any outbound fetch; registry base must be **https** | +| **Rate limiting** | Hardened | Trace scans capped at 40/hour per device session (`TraceRateLimiter`) | +| **Logging / errors** | Good | Neutral auth errors; remote registry failures logged without leaking secrets | +| **Secrets** | Good | OAuth and keystore material via env / dart-define only; CI secret scan | +| **Transport** | Good | Registry requires HTTPS; Trace allows public http(s) media URLs only after host validation | + +### Trace URL policy + +- Allowed: public `http://` and `https://` media or social page URLs +- Blocked: `file://`, `ftp://`, `localhost`, `.local`, `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`, `100.64.0.0/10`, URLs with userinfo + +### Optional registry + +Set `SIGNATA_REGISTRY_URL` to a **public https** endpoint. Invalid or private URLs are ignored at configure time. Claim references in remote lookups are length-capped and URL-encoded. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index bdc2799..08bb636 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,3 +1,6 @@ +import java.io.FileInputStream +import java.util.Properties + plugins { id("com.android.application") id("org.jetbrains.kotlin.android") @@ -5,6 +8,12 @@ plugins { id("dev.flutter.flutter-gradle-plugin") } +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + android { namespace = "app.signata.signata" compileSdk = 37 @@ -16,21 +25,38 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "app.signata.signata" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName } + signingConfigs { + if (keystorePropertiesFile.exists()) { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = rootProject.file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + } + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + // Prefer the upload keystore for Play; fall back to debug only for local --release. + signingConfig = if (keystorePropertiesFile.exists()) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) } } } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..2676b37 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,25 @@ +# Keep Flutter / plugin essentials; Google Sign-In + secure storage. +-keep class io.flutter.app.** { *; } +-keep class io.flutter.plugin.** { *; } +-keep class io.flutter.util.** { *; } +-keep class io.flutter.view.** { *; } +-keep class io.flutter.** { *; } +-keep class io.flutter.plugins.** { *; } + +-keep class com.google.android.gms.** { *; } +-dontwarn com.google.android.gms.** + +-keep class com.it_nomads.fluttersecurestorage.** { *; } + +# Flutter deferred-components stubs (not used; silence R8). +-dontwarn com.google.android.play.core.splitcompat.SplitCompatApplication +-dontwarn com.google.android.play.core.splitinstall.SplitInstallException +-dontwarn com.google.android.play.core.splitinstall.SplitInstallManager +-dontwarn com.google.android.play.core.splitinstall.SplitInstallManagerFactory +-dontwarn com.google.android.play.core.splitinstall.SplitInstallRequest$Builder +-dontwarn com.google.android.play.core.splitinstall.SplitInstallRequest +-dontwarn com.google.android.play.core.splitinstall.SplitInstallSessionState +-dontwarn com.google.android.play.core.splitinstall.SplitInstallStateUpdatedListener +-dontwarn com.google.android.play.core.tasks.OnFailureListener +-dontwarn com.google.android.play.core.tasks.OnSuccessListener +-dontwarn com.google.android.play.core.tasks.Task diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b2f1c46..354664a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,11 @@ + + android:icon="@mipmap/ic_launcher" + android:allowBackup="false" + android:fullBackupContent="false"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -41,5 +76,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/RELEASE_PLAY.md b/docs/RELEASE_PLAY.md new file mode 100644 index 0000000..b885799 --- /dev/null +++ b/docs/RELEASE_PLAY.md @@ -0,0 +1,73 @@ +# Play release checklist (Signata) + +## Google Sign-In (required for “Continue with Google”) + +1. Open [Google Cloud Credentials](https://console.cloud.google.com/apis/credentials). +2. Create **OAuth client → Web application**. Copy the Client ID. +3. Create **OAuth client → Android**: + - Package name: `app.signata.signata` + - Add these SHA-1 fingerprints (same client or one client each): + - Debug: `0A:3A:67:69:E8:44:A7:A5:0A:1B:FD:CD:61:A1:32:5C:2B:F6:9F:AA` + - Upload/release: `73:17:8E:4C:0D:25:57:C6:67:17:6C:C8:7F:91:C7:7B:89:0C:77:FA` +4. Copy `google_oauth.example.env` → `google_oauth.env` and set: + ``` + GOOGLE_SERVER_CLIENT_ID=YOUR_REAL_WEB_CLIENT_ID.apps.googleusercontent.com + ``` +5. After the first Play upload, open **Play Console → App integrity** and add the + **App signing key certificate SHA-1** to the Android OAuth client as well. +6. Optional for debug without rebuild: Account → Paste Web client ID. + +## Build the App Bundle + +```powershell +.\tool\build_release.ps1 +``` + +Output: `build/app/outputs/bundle/release/app-release.aab` + +Upload that AAB in Play Console → Closed testing (then Production). + +## Debug run with Google + +```powershell +.\tool\run_debug.ps1 +``` + +Uses `google_oauth.env` when present; otherwise email-only Google button stays hidden. + +## Signing + +- Upload keystore: `android/upload-keystore.jks` (local only, gitignored) +- Passwords: `android/key.properties` (local only, gitignored) +- Keep a secure backup of both — losing them blocks future updates unless you use Play App Signing recovery. + +## Store listing screenshots + +Phone captures for Play Console / README live in [`docs/screenshots/`](./screenshots/): + +- `01-home.png` — hero / brand +- `02-capabilities-image.png` — image watermarking +- `03-capabilities-audio-video.png` — audio + video +- `04-tools.png` — protect & verify hub +- `05-trace.png` — Trace / Instagram scan + +Upload these (or cropped phone-frame variants) under Play Console → Main store listing → Phone screenshots. + +## Smoke test before upload + +- [ ] Email signup → activate → Home +- [ ] Export / import recovery kit (Account) +- [ ] Image protect → verify → report export +- [ ] Trace: Check local file finds your mark +- [ ] Share image from Files into Signata → Trace check +- [ ] Social URL scan shows fallback warning +- [ ] Google Sign-In (debug + release SHA configured) +- [ ] Share intent while logged out still opens Trace after login +- [ ] Delete account wipes local data +- [ ] R8 release install: Google + share ingress still work + +## Notes + +- Trace prefers **shared/local files** over post URLs (platforms strip marks). +- Freemium Premium/ads are demo billing until Play Billing / AdMob are wired. +- `android:allowBackup="false"`; mailto queries are in the manifest. diff --git a/docs/delete-account.html b/docs/delete-account.html new file mode 100644 index 0000000..9335eed --- /dev/null +++ b/docs/delete-account.html @@ -0,0 +1,115 @@ + + + + + +Delete Your Account — Signata + + + +
+
Signata
+ +
Account
+

Delete Your Signata Account

+
This page explains how to request deletion of your Signata account and the data associated with it.
+ +
+

How to request account deletion

+
    +
  1. Send an email to FocusMindDev@gmail.com with the subject line "Signata account deletion request".
  2. +
  3. Include the email address of your Signata account (the one you registered with, or the Google account you used to sign in).
  4. +
  5. We will verify the request and confirm the deletion by reply within 30 days.
  6. +
+

Alternatively, you can sign out and uninstall the app at any time — email accounts created in Signata are stored only on your device, so removing the app's data on your device deletes them locally.

+
+ +
+

Delete some data without deleting your account

+

You can delete parts of your Signata data at any time while keeping your account:

+ +
+ +
+

What is deleted when you delete your account

+ +
+ +
+

What is kept

+ +

There is no additional retention period beyond the verification window: once confirmed, deletion is permanent and cannot be undone.

+
+ +
+

Questions

+

For any questions about deletion or our data practices, contact FocusMindDev@gmail.com or read the Signata Privacy Policy.

+
+ + +
+ + diff --git a/docs/privacy.html b/docs/privacy.html new file mode 100644 index 0000000..e3aff4e --- /dev/null +++ b/docs/privacy.html @@ -0,0 +1,124 @@ + + + + + +Privacy Policy — Signata + + + +
+
Signata
+ +
Legal
+

Privacy Policy for Signata

+
Last Updated: August 10, 2026
+ +
+

At Signata, we are fully committed to protecting the privacy of our users and securing digital content with the highest security standards. This Privacy Policy explains how we collect, use, process, and protect your personal data, media files, and the links you trace through our platform.

+ +

1. Data We Collect and Process

+

Given Signata's nature as a digital watermarking and ownership verification platform, we handle specific categories of data:

+ + +

2. On-Device Verification & Processing

+

At Signata, the security and privacy of your content are our top priorities:

+ + +

3. How We Use Your Data

+

The data we collect is strictly used for the following purposes:

+ + +

4. Third-Party Services and External Links

+ + +

5. Data Security

+

We implement industry-standard security protocols and advanced encryption (including SHA-256 hashing algorithms) to protect your data from unauthorized access, alteration, or disclosure. The binding of encrypted structural identifiers ensures that your proof of ownership remains secure and tamper-proof.

+ +

6. User Rights (Access and Deletion)

+

In accordance with applicable data protection laws, you have the right to:

+ + +

7. Governing Law and Policy Updates

+

This Privacy Policy is governed by and construed in accordance with the applicable laws of the Kingdom of Saudi Arabia, primarily the Personal Data Protection Law (PDPL). We may update this policy periodically, and we will notify you of any material changes via the app or email.

+ +

8. Contact Us

+

For any legal or technical inquiries regarding your privacy and data management, please contact us at:

+

Support Email: FocusMindDev@gmail.com

+
+ + +
+ + diff --git a/docs/screenshots/01-home.png b/docs/screenshots/01-home.png new file mode 100644 index 0000000..a7c23d5 Binary files /dev/null and b/docs/screenshots/01-home.png differ diff --git a/docs/screenshots/02-capabilities-image.png b/docs/screenshots/02-capabilities-image.png new file mode 100644 index 0000000..093d164 Binary files /dev/null and b/docs/screenshots/02-capabilities-image.png differ diff --git a/docs/screenshots/03-capabilities-audio-video.png b/docs/screenshots/03-capabilities-audio-video.png new file mode 100644 index 0000000..bc8245b Binary files /dev/null and b/docs/screenshots/03-capabilities-audio-video.png differ diff --git a/docs/screenshots/04-tools.png b/docs/screenshots/04-tools.png new file mode 100644 index 0000000..a90655b Binary files /dev/null and b/docs/screenshots/04-tools.png differ diff --git a/docs/screenshots/05-trace.png b/docs/screenshots/05-trace.png new file mode 100644 index 0000000..2774123 Binary files /dev/null and b/docs/screenshots/05-trace.png differ diff --git a/docs/store/app-icon-512.png b/docs/store/app-icon-512.png new file mode 100644 index 0000000..e6088dc Binary files /dev/null and b/docs/store/app-icon-512.png differ diff --git a/docs/store/feature-graphic-1024x500.png b/docs/store/feature-graphic-1024x500.png new file mode 100644 index 0000000..a251c64 Binary files /dev/null and b/docs/store/feature-graphic-1024x500.png differ diff --git a/docs/terms.html b/docs/terms.html new file mode 100644 index 0000000..c716ace --- /dev/null +++ b/docs/terms.html @@ -0,0 +1,88 @@ + + + + + +Terms of Use — Signata + + + +
+
Signata
+ +
Legal
+

Terms of Use

+
Last updated: August 14, 2026
+ +
+

By using Signata you agree to these terms. If you do not agree, please do not use the app.

+ +

The service

+

Signata is a digital watermarking and ownership verification tool. Protect, verify, and trace media on your device.

+ +

Your account

+

You are responsible for activity under your account, your claim key, and content you protect or trace. Do not use Signata for illegal, harmful, or infringing material.

+ +

On-device processing

+

By default, accounts, claim keys, history, and trace data stay on your device. Export a recovery kit before wiping the device.

+ +

Intellectual property

+

Signata, its design, code, and branding are owned by Wejdan Al Amri. You may not copy, scrape, or clone the app for commercial use. Source is published under our LICENSE.

+ +

Disclaimer

+

Signata is provided as-is. We do not guarantee uninterrupted service or that third-party platforms will preserve fingerprints.

+ +

Contact

+

FocusMindDev@gmail.com

+
+ + +
+ + diff --git a/google_oauth.example.env b/google_oauth.example.env index 754031e..77baea0 100644 --- a/google_oauth.example.env +++ b/google_oauth.example.env @@ -1,10 +1,17 @@ -# Copy these into your run command, or paste the Web client ID into -# lib/core/google_auth_config.dart (defaultValue). +# Copy to google_oauth.env and fill in your Web client ID, then run: +# .\tool\build_release.ps1 +# or: +# flutter run --dart-define-from-file=google_oauth.env # -# GOOGLE_SERVER_CLIENT_ID = Web application OAuth client -# (…apps.googleusercontent.com) ← required for Android -# -# GOOGLE_CLIENT_ID = optional; usually leave empty on Android +# Google Cloud Console → APIs & Services → Credentials +# 1) OAuth client type "Web application" → GOOGLE_SERVER_CLIENT_ID +# 2) OAuth client type "Android" +# Package name: app.signata.signata +# Add ALL of these SHA-1 fingerprints on the same Android client +# (or create one Android client per SHA-1): +# Debug: 0A:3A:67:69:E8:44:A7:A5:0A:1B:FD:CD:61:A1:32:5C:2B:F6:9F:AA +# Upload: 73:17:8E:4C:0D:25:57:C6:67:17:6C:C8:7F:91:C7:7B:89:0C:77:FA +# Play App Signing: copy from Play Console → App integrity (after first upload) GOOGLE_SERVER_CLIENT_ID=YOUR_WEB_CLIENT_ID.apps.googleusercontent.com GOOGLE_CLIENT_ID= diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 7f395ae..e0bf69d 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -66,5 +66,42 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + LSApplicationQueriesSchemes + + instagram + tiktok + snssdk1128 + twitter + x + https + http + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + sharemedia-app.signata.signata + + + + CFBundleDocumentTypes + + + CFBundleTypeName + Signata shared media + LSItemContentTypes + + public.data + public.image + public.movie + public.audio + public.text + public.url + + + diff --git a/lib/core/audio_watermark.dart b/lib/core/audio_watermark.dart index b788a34..0a54c4f 100644 --- a/lib/core/audio_watermark.dart +++ b/lib/core/audio_watermark.dart @@ -1,5 +1,5 @@ -/// Inaudible WAV LSB watermark — same ownership payload shape as the image -/// demo, encoded into the least-significant bit of each 16-bit PCM sample. +/// Inaudible WAV LSB watermark — ownership payload encoded into the +/// least-significant bit of each 16-bit PCM sample using scattered placement. library; import 'dart:convert'; @@ -7,7 +7,8 @@ import 'dart:typed_data'; import 'package:flutter/foundation.dart' show compute; -import 'fingerprint.dart'; +import 'claim_crypto.dart'; +import 'lsb_scatter.dart'; const String _magic = 'SGA1'; @@ -17,19 +18,31 @@ class AudioPayload { required this.asset, required this.issued, required this.signature, + this.alg, + this.kid, + this.version = 1, }); final String owner; final String asset; final String issued; final String signature; - - Map toJson() => { - 'owner': owner, - 'asset': asset, - 'issued': issued, - 'signature': signature, - }; + final String? alg; + final String? kid; + final int version; + + Map toJson() { + final map = { + 'owner': owner, + 'asset': asset, + 'issued': issued, + 'signature': signature, + }; + if (alg != null && alg!.isNotEmpty) map['alg'] = alg; + if (kid != null && kid!.isNotEmpty) map['kid'] = kid; + if (version > 1) map['v'] = version; + return map; + } static AudioPayload? tryParse(String? raw) { if (raw == null) return null; @@ -41,14 +54,36 @@ class AudioPayload { asset: map['asset'] as String? ?? '', issued: map['issued'] as String? ?? '', signature: map['signature'] as String? ?? '', + alg: map['alg'] as String?, + kid: map['kid'] as String?, + version: (map['v'] as num?)?.toInt() ?? 1, ); } catch (_) { return null; } } - bool get signatureValid => - signature == fingerprint('$owner|$asset|$issued'); + bool get signatureValid { + final legacy = ClaimCrypto.signMediaLegacy( + owner: owner, + asset: asset, + issued: issued, + ); + return signature == legacy.signature; + } + + bool isAuthentic(ClaimKey? key) => + statusWith(key) == ClaimStatus.authenticated; + + ClaimStatus statusWith(ClaimKey? key) => ClaimCrypto.evaluateMedia( + owner: owner, + asset: asset, + issued: issued, + signature: signature, + alg: alg, + kid: kid, + key: key, + ); } class AudioEmbedOutcome { @@ -60,6 +95,7 @@ class AudioEmbedOutcome { required this.bitsUsed, required this.capacityBits, required this.recoveredRaw, + required this.claimStatus, }); final Uint8List markedWav; @@ -69,6 +105,7 @@ class AudioEmbedOutcome { final int bitsUsed; final int capacityBits; final String? recoveredRaw; + final ClaimStatus claimStatus; AudioPayload? get recovered => AudioPayload.tryParse(recoveredRaw); @@ -86,15 +123,18 @@ class AudioExtractOutcome { required this.raw, required this.sampleRate, required this.channels, + this.claimStatus = ClaimStatus.missing, }); final String? raw; final int sampleRate; final int channels; + final ClaimStatus claimStatus; } class NotAWavException implements Exception { - const NotAWavException([this.message = 'That file is not a valid WAV audio file.']); + const NotAWavException( + [this.message = 'That file is not a valid WAV audio file.']); final String message; @override String toString() => message; @@ -184,7 +224,126 @@ _WavInfo _parseWav(Uint8List bytes) { ); } -({int bitsUsed, int capacityBits}) _embedBits(Uint8List pcm, String payload) { +Uint8List _magicSeedHeader() { + final header = Uint8List(8); + header.setAll(0, ascii.encode(_magic)); + return header; +} + +Uint8List _buildPacket(String payloadJson) { + final body = utf8.encode(payloadJson); + final header = ascii.encode(_magic); + final headerBlock = Uint8List(8); + headerBlock.setAll(0, header); + headerBlock[4] = (body.length >>> 24) & 0xff; + headerBlock[5] = (body.length >>> 16) & 0xff; + headerBlock[6] = (body.length >>> 8) & 0xff; + headerBlock[7] = body.length & 0xff; + + // Packet = header + redundant header + body. + final packet = Uint8List(16 + body.length); + packet.setAll(0, headerBlock); + packet.setAll(8, headerBlock); + packet.setAll(16, body); + return packet; +} + +void _writeScatteredBit( + Uint8List pcm, + LsbScatter scatter, + int bitIndex, + int value, +) { + final sampleIndex = scatter[bitIndex] * 2; + if (sampleIndex >= pcm.length) return; + pcm[sampleIndex] = (pcm[sampleIndex] & 0xfe) | (value & 1); +} + +int _readScatteredBit(Uint8List pcm, LsbScatter scatter, int bitIndex) { + final sampleIndex = scatter[bitIndex] * 2; + if (sampleIndex >= pcm.length) return 0; + return pcm[sampleIndex] & 1; +} + +Uint8List? _readScatteredBytes( + Uint8List pcm, + LsbScatter scatter, + int count, + int startBit, +) { + final out = Uint8List(count); + for (var b = 0; b < count; b++) { + var byte = 0; + for (var k = 0; k < 8; k++) { + final bit = startBit + b * 8 + k; + if (bit >= scatter.length) return null; + byte = (byte << 1) | _readScatteredBit(pcm, scatter, bit); + } + out[b] = byte; + } + return out; +} + +({int bitsUsed, int capacityBits}) _embedScattered( + Uint8List pcm, + Uint8List packet, +) { + final slotCount = pcm.length ~/ 2; + final scatter = LsbScatter.seeded( + slotCount: slotCount, + seed: LsbScatter.seedFromHeader(_magicSeedHeader()), + ); + final totalBits = packet.length * 8; + if (totalBits > slotCount) throw const AudioTooShortException(); + + for (var bit = 0; bit < totalBits; bit++) { + final byte = packet[bit >> 3]; + final value = (byte >> (7 - (bit & 7))) & 1; + _writeScatteredBit(pcm, scatter, bit, value); + } + return (bitsUsed: totalBits, capacityBits: slotCount); +} + +String? _extractScattered(Uint8List pcm) { + final slotCount = pcm.length ~/ 2; + if (slotCount < 128) return null; + final scatter = LsbScatter.seeded( + slotCount: slotCount, + seed: LsbScatter.seedFromHeader(_magicSeedHeader()), + ); + + final header0 = _readScatteredBytes(pcm, scatter, 8, 0); + final header1 = _readScatteredBytes(pcm, scatter, 8, 64); + + Uint8List? header; + if (header0 != null && + String.fromCharCodes(header0.sublist(0, 4)) == _magic) { + header = header0; + } else if (header1 != null && + String.fromCharCodes(header1.sublist(0, 4)) == _magic) { + header = header1; + } else { + return null; + } + + final length = + (header[4] << 24) | (header[5] << 16) | (header[6] << 8) | header[7]; + if (length <= 0 || length > 1000000) return null; + if (128 + length * 8 > scatter.length) return null; + + final body = _readScatteredBytes(pcm, scatter, length, 128); + if (body == null) return null; + try { + return utf8.decode(body); + } catch (_) { + return null; + } +} + +({int bitsUsed, int capacityBits}) _embedSequential( + Uint8List pcm, + String payload, +) { final body = utf8.encode(payload); final header = ascii.encode(_magic); final bytes = Uint8List(header.length + 4 + body.length); @@ -195,7 +354,6 @@ _WavInfo _parseWav(Uint8List bytes) { bytes[7] = body.length & 0xff; bytes.setAll(8, body); - // One bit per 16-bit sample (LSB of the low byte). final capacity = pcm.length ~/ 2; final totalBits = bytes.length * 8; if (totalBits > capacity) throw const AudioTooShortException(); @@ -209,7 +367,7 @@ _WavInfo _parseWav(Uint8List bytes) { return (bitsUsed: totalBits, capacityBits: capacity); } -String? _extractBits(Uint8List pcm) { +String? _extractSequential(Uint8List pcm) { Uint8List? readBytes(int count, int startBit) { final out = Uint8List(count); var bit = startBit; @@ -240,31 +398,90 @@ String? _extractBits(Uint8List pcm) { } } +String? _extractBits(Uint8List pcm) => + _extractScattered(pcm) ?? _extractSequential(pcm); + +ClaimKey? _claimKeyFromArgs(Uint8List? keyBytes, String? kid) { + if (keyBytes == null || keyBytes.length != 32) return null; + return ClaimKey(bytes: keyBytes, kid: kid ?? ClaimKey.kidFor(keyBytes)); +} + +AudioPayload _buildPayload({ + required String owner, + required String asset, + required String issued, + ClaimKey? claimKey, +}) { + final resolvedOwner = + owner.trim().isEmpty ? 'Anonymous creator' : owner.trim(); + if (claimKey != null) { + final signed = ClaimCrypto.signMedia( + claimKey, + owner: resolvedOwner, + asset: asset, + issued: issued, + ); + return AudioPayload( + owner: resolvedOwner, + asset: asset, + issued: issued, + signature: signed.signature, + alg: signed.alg, + kid: signed.kid, + version: signed.version, + ); + } + final legacy = ClaimCrypto.signMediaLegacy( + owner: resolvedOwner, + asset: asset, + issued: issued, + ); + return AudioPayload( + owner: resolvedOwner, + asset: asset, + issued: issued, + signature: legacy.signature, + alg: legacy.alg, + kid: legacy.kid, + version: legacy.version, + ); +} + AudioEmbedOutcome _embedTask( - ({Uint8List fileBytes, String owner, String fileName, String issued}) - args) { - final (:fileBytes, :owner, :fileName, :issued) = args; + ({ + Uint8List fileBytes, + String owner, + String fileName, + String issued, + Uint8List? keyBytes, + String? kid, + }) args, +) { + final (:fileBytes, :owner, :fileName, :issued, :keyBytes, :kid) = args; + final claimKey = _claimKeyFromArgs(keyBytes, kid); final wav = _parseWav(fileBytes); final marked = Uint8List.fromList(fileBytes); final pcm = marked.sublist(wav.dataOffset, wav.dataOffset + wav.dataSize); - final resolvedOwner = - owner.trim().isEmpty ? 'Anonymous creator' : owner.trim(); final asset = '$fileName·${wav.sampleRate}Hz·${wav.channels}ch·${wav.dataSize}B'; - final payload = AudioPayload( - owner: resolvedOwner, + final payload = _buildPayload( + owner: owner, asset: asset, issued: issued, - signature: fingerprint('$resolvedOwner|$asset|$issued'), + claimKey: claimKey, ); - final stats = _embedBits(pcm, jsonEncode(payload.toJson())); + final packet = _buildPacket(jsonEncode(payload.toJson())); + final stats = _embedScattered(pcm, packet); marked.setRange(wav.dataOffset, wav.dataOffset + wav.dataSize, pcm); final recovered = _extractBits( marked.sublist(wav.dataOffset, wav.dataOffset + wav.dataSize), ); + final recoveredPayload = AudioPayload.tryParse(recovered); + final claimStatus = + recoveredPayload?.statusWith(claimKey) ?? ClaimStatus.missing; return AudioEmbedOutcome( markedWav: marked, @@ -274,16 +491,24 @@ AudioEmbedOutcome _embedTask( bitsUsed: stats.bitsUsed, capacityBits: stats.capacityBits, recoveredRaw: recovered, + claimStatus: claimStatus, ); } -AudioExtractOutcome _extractTask(Uint8List fileBytes) { +AudioExtractOutcome _extractTask( + ({Uint8List fileBytes, Uint8List? keyBytes, String? kid}) args, +) { + final (:fileBytes, :keyBytes, :kid) = args; + final claimKey = _claimKeyFromArgs(keyBytes, kid); final wav = _parseWav(fileBytes); final pcm = fileBytes.sublist(wav.dataOffset, wav.dataOffset + wav.dataSize); + final raw = _extractBits(pcm); + final payload = AudioPayload.tryParse(raw); return AudioExtractOutcome( - raw: _extractBits(pcm), + raw: raw, sampleRate: wav.sampleRate, channels: wav.channels, + claimStatus: payload?.statusWith(claimKey) ?? ClaimStatus.missing, ); } @@ -291,11 +516,31 @@ Future embedAudioWatermark({ required Uint8List fileBytes, required String owner, required String fileName, + ClaimKey? claimKey, }) { final issued = DateTime.now().toUtc().toIso8601String(); - return compute(_embedTask, - (fileBytes: fileBytes, owner: owner, fileName: fileName, issued: issued)); + return compute( + _embedTask, + ( + fileBytes: fileBytes, + owner: owner, + fileName: fileName, + issued: issued, + keyBytes: claimKey?.bytes, + kid: claimKey?.kid, + ), + ); } -Future extractAudioWatermark(Uint8List fileBytes) => - compute(_extractTask, fileBytes); +Future extractAudioWatermark( + Uint8List fileBytes, { + ClaimKey? claimKey, +}) => + compute( + _extractTask, + ( + fileBytes: fileBytes, + keyBytes: claimKey?.bytes, + kid: claimKey?.kid, + ), + ); diff --git a/lib/core/auth.dart b/lib/core/auth.dart index 98c1938..de10bcd 100644 --- a/lib/core/auth.dart +++ b/lib/core/auth.dart @@ -1,22 +1,39 @@ /// Auth session for Signata. /// -/// Email accounts are stored on-device (hashed) so the login flow works without -/// a backend. Google Sign-In uses the platform SDK when configured. +/// Email accounts are stored on-device (PBKDF2-hashed) so the login flow works +/// without a backend. Google Sign-In uses the platform SDK when configured. +/// +/// Hardening: +/// - PBKDF2-HMAC-SHA256 password hashes (with migration from legacy SHA-256) +/// - Sessions kept in Flutter Secure Storage (not SharedPreferences) +/// - Password policy + failed-attempt lockout +/// - Neutral credential errors (no account enumeration) library; import 'dart:convert'; import 'dart:math'; +import 'dart:typed_data'; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:google_sign_in/google_sign_in.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:url_launcher/url_launcher.dart'; import 'google_auth_config.dart'; +import 'local_data.dart'; +import 'secure_store.dart'; +import 'totp.dart'; enum AuthProvider { email, google } +/// Thrown when password was accepted but a TOTP code is still required. +class TotpRequiredException implements Exception { + const TotpRequiredException(); + @override + String toString() => 'Authenticator code required.'; +} + class AuthUser { const AuthUser({ required this.id, @@ -24,6 +41,8 @@ class AuthUser { required this.displayName, required this.provider, this.photoUrl, + this.totpEnabled = false, + this.emailVerified = true, }); final String id; @@ -31,6 +50,8 @@ class AuthUser { final String displayName; final AuthProvider provider; final String? photoUrl; + final bool totpEnabled; + final bool emailVerified; Map toJson() => { 'id': id, @@ -38,6 +59,8 @@ class AuthUser { 'displayName': displayName, 'provider': provider.name, 'photoUrl': photoUrl, + 'totpEnabled': totpEnabled, + 'emailVerified': emailVerified, }; static AuthUser fromJson(Map json) => AuthUser( @@ -49,6 +72,21 @@ class AuthUser { orElse: () => AuthProvider.email, ), photoUrl: json['photoUrl'] as String?, + totpEnabled: json['totpEnabled'] == true, + // Missing key → already verified (pre-verification installs). + emailVerified: json.containsKey('emailVerified') + ? json['emailVerified'] == true + : true, + ); + + AuthUser copyWith({bool? totpEnabled, bool? emailVerified}) => AuthUser( + id: id, + email: email, + displayName: displayName, + provider: provider, + photoUrl: photoUrl, + totpEnabled: totpEnabled ?? this.totpEnabled, + emailVerified: emailVerified ?? this.emailVerified, ); } @@ -60,28 +98,167 @@ class AuthException implements Exception { String toString() => message; } +/// Password strength rules for on-device email accounts. +class PasswordPolicy { + static const minLength = 10; + + static const _common = { + 'password', + 'password1', + 'password123', + '1234567890', + 'qwerty1234', + 'letmein123', + 'admin12345', + 'welcome123', + 'signata123', + 'iloveyou12', + }; + + /// Returns null when [password] is acceptable, otherwise a user-facing reason. + static String? validate(String password) { + if (password.length < minLength) { + return 'Password must be at least $minLength characters.'; + } + if (!RegExp(r'[A-Za-z]').hasMatch(password)) { + return 'Password must include at least one letter.'; + } + if (!RegExp(r'[0-9]').hasMatch(password)) { + return 'Password must include at least one number.'; + } + if (RegExp(r'^\s|\s$').hasMatch(password)) { + return 'Password cannot start or end with a space.'; + } + if (_common.contains(password.toLowerCase())) { + return 'Choose a less common password.'; + } + return null; + } +} + +/// Password hashing helpers (PBKDF2-HMAC-SHA256 + legacy SHA-256 verify). +class PasswordHasher { + static const algoPbkdf2 = 'pbkdf2-sha256'; + static const algoLegacySha256 = 'sha256'; + static const iterations = 120000; + static const keyBytes = 32; + + static String hash(String password, String saltBase64) { + final salt = base64Url.decode(saltBase64); + final derived = pbkdf2HmacSha256( + password: utf8.encode(password), + salt: salt, + iterations: iterations, + keyLength: keyBytes, + ); + return base64UrlEncode(derived); + } + + static bool verify({ + required String password, + required String saltBase64, + required String expectedHash, + required String algorithm, + }) { + final computed = algorithm == algoLegacySha256 + ? _legacySha256(password, saltBase64) + : hash(password, saltBase64); + return constantTimeEquals(computed, expectedHash); + } + + static bool needsUpgrade(String algorithm) => algorithm != algoPbkdf2; + + static String _legacySha256(String password, String salt) => + sha256.convert(utf8.encode('$salt|$password')).toString(); + + /// RFC 8018 PBKDF2 with HMAC-SHA256. + static Uint8List pbkdf2HmacSha256({ + required List password, + required List salt, + required int iterations, + required int keyLength, + }) { + if (iterations < 1 || keyLength < 1) { + throw ArgumentError('Invalid PBKDF2 parameters'); + } + final hmac = Hmac(sha256, password); + const hLen = 32; + final blockCount = (keyLength + hLen - 1) ~/ hLen; + final out = BytesBuilder(copy: false); + + for (var block = 1; block <= blockCount; block++) { + final blockSalt = BytesBuilder(copy: false) + ..add(salt) + ..add([ + (block >> 24) & 0xff, + (block >> 16) & 0xff, + (block >> 8) & 0xff, + block & 0xff, + ]); + var u = hmac.convert(blockSalt.toBytes()).bytes; + final t = List.from(u); + for (var i = 1; i < iterations; i++) { + u = hmac.convert(u).bytes; + for (var j = 0; j < t.length; j++) { + t[j] ^= u[j]; + } + } + out.add(t); + } + + return Uint8List.fromList(out.toBytes().sublist(0, keyLength)); + } + + static bool constantTimeEquals(String a, String b) { + final aBytes = utf8.encode(a); + final bBytes = utf8.encode(b); + if (aBytes.length != bBytes.length) return false; + var diff = 0; + for (var i = 0; i < aBytes.length; i++) { + diff |= aBytes[i] ^ bBytes[i]; + } + return diff == 0; + } +} + class AuthService extends ChangeNotifier { AuthService._(); static final AuthService instance = AuthService._(); - static const _sessionKey = 'signata_session_v1'; + static const _sessionKey = 'signata_session_v2'; + static const _legacySessionKey = 'signata_session_v1'; static const _usersKey = 'signata_users_v1'; + static const _lockoutKey = 'signata_lockout_v1'; + static const _maxFailedAttempts = 5; + static const _lockoutBaseSeconds = 30; + static const _genericCredentialError = + 'Incorrect email or password.'; - final _secure = const FlutterSecureStorage(); + final _secure = signataSecureStorage; final _google = GoogleSignIn.instance; AuthUser? _user; bool _ready = false; bool _googleReady = false; + final _secureRandom = Random.secure(); AuthUser? get user => _user; bool get isSignedIn => _user != null; bool get isReady => _ready; + bool get isGoogleConfigured => GoogleAuthConfig.hasServerClientId; + Future initialize() async { + await GoogleAuthConfig.load(); + await _initGoogleSignIn(); + await _restoreSession(); + _ready = true; + notifyListeners(); + } + + Future _initGoogleSignIn() async { try { // Android requires the *Web* OAuth client ID as serverClientId. - // Create it in Google Cloud Console → APIs & Services → Credentials. await _google.initialize( clientId: GoogleAuthConfig.clientId.isEmpty ? null @@ -93,105 +270,396 @@ class AuthService extends ChangeNotifier { _googleReady = GoogleAuthConfig.hasServerClientId; if (!_googleReady) { debugPrint( - 'Google Sign-In: set GOOGLE_SERVER_CLIENT_ID (Web client ID) ' - 'via --dart-define or lib/core/google_auth_config.dart', + 'Google Sign-In: missing Web client ID. ' + 'Set GOOGLE_SERVER_CLIENT_ID via --dart-define / google_oauth.env.', ); } } catch (error) { debugPrint('Google Sign-In init skipped: $error'); _googleReady = false; } + } - final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getString(_sessionKey); - if (raw != null) { - try { - _user = AuthUser.fromJson(jsonDecode(raw) as Map); - } catch (_) { - await prefs.remove(_sessionKey); - } - } - _ready = true; + /// Saves a Google Web client ID and re-initializes the SDK. + Future configureGoogleServerClientId(String clientId) async { + await GoogleAuthConfig.saveServerClientId(clientId); + await _initGoogleSignIn(); notifyListeners(); + if (!_googleReady) { + throw const AuthException( + 'Google Sign-In still failed to initialize. Check the Web client ID.', + ); + } } Future signInWithEmail({ required String email, required String password, + String? totpCode, }) async { final normalized = email.trim().toLowerCase(); if (!_isValidEmail(normalized)) { throw const AuthException('Enter a valid email address.'); } - if (password.length < 6) { - throw const AuthException('Password must be at least 6 characters.'); + if (password.isEmpty) { + throw const AuthException('Enter your password.'); } + await _assertNotLockedOut(normalized); + final users = await _loadUsers(); final record = users[normalized]; if (record == null) { - throw const AuthException('No account found for that email. Create one?'); + await _registerFailedAttempt(normalized); + throw const AuthException(_genericCredentialError); } - final hash = _hashPassword(password, record['salt'] as String); - if (hash != record['hash']) { - throw const AuthException('Incorrect password.'); + + final algo = (record['algo'] as String?) ?? PasswordHasher.algoLegacySha256; + final ok = PasswordHasher.verify( + password: password, + saltBase64: record['salt'] as String, + expectedHash: record['hash'] as String, + algorithm: algo, + ); + if (!ok) { + await _registerFailedAttempt(normalized); + throw const AuthException(_genericCredentialError); + } + + final totpEnabled = record['totpEnabled'] == true; + final totpSecret = await _readTotpSecret(normalized, record); + if (totpEnabled && totpSecret != null && totpSecret.isNotEmpty) { + final code = totpCode?.trim() ?? ''; + if (code.isEmpty) { + throw const TotpRequiredException(); + } + if (!Totp.verify(totpSecret, code)) { + await _registerFailedAttempt(normalized); + throw const AuthException('Incorrect authenticator code.'); + } + } + + await _clearFailedAttempts(normalized); + + // Upgrade legacy hashes on successful sign-in. + if (PasswordHasher.needsUpgrade(algo)) { + final salt = _randomSalt(); + users[normalized] = { + ...record, + 'salt': salt, + 'algo': PasswordHasher.algoPbkdf2, + 'hash': PasswordHasher.hash(password, salt), + 'iterations': PasswordHasher.iterations, + }; + await _saveUsers(users); } await _setSession(AuthUser( id: record['id'] as String, email: normalized, - displayName: record['displayName'] as String? ?? normalized.split('@').first, + displayName: + record['displayName'] as String? ?? normalized.split('@').first, provider: AuthProvider.email, + totpEnabled: totpEnabled, + emailVerified: record.containsKey('emailVerified') + ? record['emailVerified'] == true + : true, )); } + /// Starts 2FA enrollment; returns secret + otpauth URI. Confirm with [confirmTotpSetup]. + Future<({String secret, String otpauth})> beginTotpSetup() async { + final user = _user; + if (user == null || user.provider != AuthProvider.email) { + throw const AuthException('2FA is available for email accounts only.'); + } + final secret = Totp.generateSecret(); + final otpauth = Totp.otpauthUri(secret: secret, accountName: user.email); + await _secure.write(key: _pendingTotpKey(user.email), value: secret); + return (secret: secret, otpauth: otpauth); + } + + Future confirmTotpSetup(String code) async { + final user = _user; + if (user == null || user.provider != AuthProvider.email) { + throw const AuthException('2FA is available for email accounts only.'); + } + final secret = await _secure.read(key: _pendingTotpKey(user.email)); + if (secret == null || secret.isEmpty) { + throw const AuthException('Start 2FA setup again.'); + } + if (!Totp.verify(secret, code)) { + throw const AuthException('Incorrect authenticator code. Try again.'); + } + final users = await _loadUsers(); + final record = users[user.email]; + if (record == null) { + throw const AuthException('Account not found.'); + } + users[user.email] = { + ...record, + 'totpEnabled': true, + }; + await _saveUsers(users); + await _secure.write(key: _totpSecretKey(user.email), value: secret); + await _secure.delete(key: _pendingTotpKey(user.email)); + await _setSession(user.copyWith(totpEnabled: true)); + } + + Future disableTotp({ + required String password, + required String totpCode, + }) async { + final user = _user; + if (user == null || user.provider != AuthProvider.email) { + throw const AuthException('2FA is available for email accounts only.'); + } + final users = await _loadUsers(); + final record = users[user.email]; + if (record == null) { + throw const AuthException('Account not found.'); + } + final algo = (record['algo'] as String?) ?? PasswordHasher.algoLegacySha256; + final passwordOk = PasswordHasher.verify( + password: password, + saltBase64: record['salt'] as String, + expectedHash: record['hash'] as String, + algorithm: algo, + ); + if (!passwordOk) { + throw const AuthException('Incorrect password.'); + } + final secret = await _readTotpSecret(user.email, record); + if (secret == null || !Totp.verify(secret, totpCode)) { + throw const AuthException('Incorrect authenticator code.'); + } + final updated = Map.from(record) + ..['totpEnabled'] = false + ..remove('totpSecret'); + users[user.email] = updated; + await _saveUsers(users); + await _secure.delete(key: _totpSecretKey(user.email)); + await _secure.delete(key: _pendingTotpKey(user.email)); + await _setSession(user.copyWith(totpEnabled: false)); + } + + String _pendingTotpKey(String email) => + 'signata_totp_pending_${email.toLowerCase()}'; + + String _totpSecretKey(String email) => + 'signata_totp_secret_${email.toLowerCase()}'; + + Future _readTotpSecret( + String email, + Map record, + ) async { + final isolated = await _secure.read(key: _totpSecretKey(email)); + if (isolated != null && isolated.isNotEmpty) return isolated; + // Migrate secrets that were previously stored inside the users blob. + final legacy = record['totpSecret'] as String?; + if (legacy == null || legacy.isEmpty) return null; + await _secure.write(key: _totpSecretKey(email), value: legacy); + final cleaned = Map.from(record)..remove('totpSecret'); + final users = await _loadUsers(); + users[email] = cleaned; + await _saveUsers(users); + return legacy; + } + Future signUpWithEmail({ required String name, required String email, required String password, + String? confirmPassword, }) async { final normalized = email.trim().toLowerCase(); - final display = name.trim().isEmpty - ? normalized.split('@').first - : name.trim(); + final display = + name.trim().isEmpty ? normalized.split('@').first : name.trim(); if (!_isValidEmail(normalized)) { throw const AuthException('Enter a valid email address.'); } - if (password.length < 6) { - throw const AuthException('Password must be at least 6 characters.'); + final policyError = PasswordPolicy.validate(password); + if (policyError != null) { + throw AuthException(policyError); + } + if (confirmPassword != null && confirmPassword != password) { + throw const AuthException('Passwords do not match.'); } + await _assertNotLockedOut(normalized); + final users = await _loadUsers(); if (users.containsKey(normalized)) { - throw const AuthException('An account already exists for that email.'); + // Same generic wording as sign-in to avoid confirming email existence. + throw const AuthException( + 'Could not create that account. Try signing in, or use a different email.', + ); } final salt = _randomSalt(); + final id = 'email_${_randomId()}'; users[normalized] = { - 'id': 'email_${DateTime.now().millisecondsSinceEpoch}', + 'id': id, 'displayName': display, 'salt': salt, - 'hash': _hashPassword(password, salt), + 'algo': PasswordHasher.algoPbkdf2, + 'iterations': PasswordHasher.iterations, + 'hash': PasswordHasher.hash(password, salt), + 'createdAt': DateTime.now().toUtc().toIso8601String(), + 'emailVerified': false, }; await _saveUsers(users); + await _clearFailedAttempts(normalized); await _setSession(AuthUser( - id: users[normalized]!['id'] as String, + id: id, email: normalized, displayName: display, provider: AuthProvider.email, + emailVerified: false, )); + await sendEmailVerification(); + } + + /// Creates a local activation code and optionally opens a mailto draft so the + /// user can save it. Signata does not operate a mail server — this proves + /// the user can access a mail app / copy the on-device code, not remote + /// ownership of the inbox. + /// + /// Returns the plaintext code so the UI can show it if mailto fails. + Future sendEmailVerification() async { + final user = _user; + if (user == null || user.provider != AuthProvider.email) { + throw const AuthException('Email activation is for email accounts.'); + } + if (user.emailVerified) return ''; + + final now = DateTime.now().toUtc(); + final raw = await _secure.read(key: _emailVerifyMetaKey(user.email)); + if (raw != null) { + try { + final meta = jsonDecode(raw) as Map; + final lastMs = meta['sentAt'] as int?; + if (lastMs != null) { + final elapsed = + now.difference(DateTime.fromMillisecondsSinceEpoch(lastMs, isUtc: true)); + if (elapsed.inSeconds < 45) { + throw AuthException( + 'Wait ${45 - elapsed.inSeconds}s before requesting a new code.', + ); + } + } + } catch (error) { + if (error is AuthException) rethrow; + } + } + + final code = + List.generate(6, (_) => _secureRandom.nextInt(10)).join(); + final salt = _randomSalt(); + final hash = sha256.convert(utf8.encode('$salt|$code')).toString(); + final expires = now.add(const Duration(minutes: 15)); + await _secure.write( + key: _emailVerifyKey(user.email), + value: jsonEncode({ + 'salt': salt, + 'hash': hash, + 'expires': expires.millisecondsSinceEpoch, + }), + ); + await _secure.write( + key: _emailVerifyMetaKey(user.email), + value: jsonEncode({'sentAt': now.millisecondsSinceEpoch}), + ); + + final mailUri = Uri( + scheme: 'mailto', + path: user.email, + query: _encodeMailQuery( + subject: 'Signata account activation code', + body: + 'Your Signata activation code is: $code\n\n' + 'This code was generated on your device (Signata does not send email). ' + 'It expires in 15 minutes.\n' + 'Keep this code private.', + ), + ); + try { + await launchUrl(mailUri, mode: LaunchMode.externalApplication); + } catch (_) { + // UI can display the returned code when mailto is unavailable. + } + if (kDebugMode) { + debugPrint('Signata activation code for ${user.email}: $code'); + } + return code; + } + + Future confirmEmailVerification(String code) async { + final user = _user; + if (user == null || user.provider != AuthProvider.email) { + throw const AuthException('Email activation is for email accounts.'); + } + await _assertNotLockedOut(user.email); + final cleaned = code.replaceAll(RegExp(r'\s'), ''); + if (!RegExp(r'^\d{6}$').hasMatch(cleaned)) { + throw const AuthException('Enter the 6-digit activation code.'); + } + + final raw = await _secure.read(key: _emailVerifyKey(user.email)); + if (raw == null || raw.isEmpty) { + throw const AuthException('Request a new activation code.'); + } + final payload = jsonDecode(raw) as Map; + final expiresMs = payload['expires'] as int?; + if (expiresMs == null || + DateTime.now().toUtc().isAfter( + DateTime.fromMillisecondsSinceEpoch(expiresMs, isUtc: true), + )) { + await _secure.delete(key: _emailVerifyKey(user.email)); + throw const AuthException('That code expired. Tap Resend for a new one.'); + } + final salt = payload['salt'] as String? ?? ''; + final expected = payload['hash'] as String? ?? ''; + final computed = sha256.convert(utf8.encode('$salt|$cleaned')).toString(); + if (!PasswordHasher.constantTimeEquals(computed, expected)) { + await _registerFailedAttempt(user.email); + throw const AuthException('Incorrect activation code.'); + } + + final users = await _loadUsers(); + final record = users[user.email]; + if (record == null) { + throw const AuthException('Account not found.'); + } + users[user.email] = {...record, 'emailVerified': true}; + await _saveUsers(users); + await _secure.delete(key: _emailVerifyKey(user.email)); + await _secure.delete(key: _emailVerifyMetaKey(user.email)); + await _clearFailedAttempts(user.email); + await _setSession(user.copyWith(emailVerified: true)); + } + + String _emailVerifyKey(String email) => + 'signata_email_verify_${email.toLowerCase()}'; + + String _emailVerifyMetaKey(String email) => + 'signata_email_verify_meta_${email.toLowerCase()}'; + + String _encodeMailQuery({required String subject, required String body}) { + // mailto query must be encoded manually for broad client support. + return 'subject=${Uri.encodeComponent(subject)}&body=${Uri.encodeComponent(body)}'; } Future signInWithGoogle() async { if (!GoogleAuthConfig.hasServerClientId) { throw const AuthException( - 'Google Client IDs are missing. Add your Web OAuth client ID as ' - 'GOOGLE_SERVER_CLIENT_ID (see lib/core/google_auth_config.dart), ' - 'and register an Android OAuth client with package ' - 'app.signata.signata + your SHA-1.', + 'Google Sign-In is not configured for this build. Use email instead.', ); } + if (!_googleReady) { + await _initGoogleSignIn(); + } if (!_googleReady) { throw const AuthException( 'Google Sign-In is not available on this device yet. Use email instead, or configure a Google OAuth client for the app.', @@ -217,8 +685,27 @@ class AuthService extends ChangeNotifier { if (error.code == GoogleSignInExceptionCode.canceled) { throw const AuthException('Google sign-in was cancelled.'); } + final detail = (error.description ?? error.code.name).toLowerCase(); + if (detail.contains('10') || + detail.contains('developer_error') || + detail.contains('sha') || + detail.contains('oauth') || + detail.contains('client')) { + throw const AuthException( + 'Google Sign-In config mismatch. Confirm the Web client ID, and that ' + 'your Android OAuth client uses package app.signata.signata with the ' + 'debug, upload, and Play App Signing SHA-1 fingerprints ' + '(see docs/RELEASE_PLAY.md).', + ); + } + if (detail.contains('network') || detail.contains('timeout')) { + throw const AuthException( + 'Google Sign-In needs a network connection. Try again online.', + ); + } throw AuthException( - 'Google sign-in failed. Use email for now, or add a Google OAuth client ID for this app. (${error.description ?? error.code.name})', + 'Google sign-in failed. Check OAuth setup or use email instead. ' + '(${error.description ?? error.code.name})', ); } catch (error) { if (error is AuthException) rethrow; @@ -228,20 +715,155 @@ class AuthService extends ChangeNotifier { } } + Future cancelTotpSetup() async { + final user = _user; + if (user == null || user.provider != AuthProvider.email) return; + await _secure.delete(key: _pendingTotpKey(user.email)); + } + Future signOut() async { try { if (_googleReady) await _google.signOut(); } catch (_) {} _user = null; + await _secure.delete(key: _sessionKey); final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_sessionKey); + await prefs.remove(_legacySessionKey); notifyListeners(); } + /// Deletes this on-device account and erases local Signata data for it. + Future deleteAccount({ + required String password, + String? totpCode, + }) async { + final user = _user; + if (user == null) { + throw const AuthException('Not signed in.'); + } + + if (user.provider == AuthProvider.email) { + final users = await _loadUsers(); + final record = users[user.email]; + if (record == null) { + throw const AuthException('Account not found.'); + } + final algo = + (record['algo'] as String?) ?? PasswordHasher.algoLegacySha256; + final ok = PasswordHasher.verify( + password: password, + saltBase64: record['salt'] as String, + expectedHash: record['hash'] as String, + algorithm: algo, + ); + if (!ok) { + throw const AuthException('Incorrect password.'); + } + if (record['totpEnabled'] == true) { + final secret = await _readTotpSecret(user.email, record); + final code = totpCode?.trim() ?? ''; + if (secret == null || code.isEmpty || !Totp.verify(secret, code)) { + throw const AuthException('Incorrect authenticator code.'); + } + } + users.remove(user.email); + await _saveUsers(users); + await _secure.delete(key: _totpSecretKey(user.email)); + await _secure.delete(key: _pendingTotpKey(user.email)); + await _secure.delete(key: _emailVerifyKey(user.email)); + await _secure.delete(key: _emailVerifyMetaKey(user.email)); + await _clearFailedAttempts(user.email); + } + + await wipeLocalUserData(user: user); + await signOut(); + } + + Future _restoreSession() async { + // Prefer encrypted session; migrate any leftover SharedPreferences session. + String? raw = await _secure.read(key: _sessionKey); + if (raw == null || raw.isEmpty) { + final prefs = await SharedPreferences.getInstance(); + raw = prefs.getString(_legacySessionKey); + if (raw != null) { + try { + final user = + AuthUser.fromJson(jsonDecode(raw) as Map); + await _setSession(await _rehydrateUser(user)); + await prefs.remove(_legacySessionKey); + return; + } catch (_) { + await prefs.remove(_legacySessionKey); + raw = null; + } + } + } + if (raw == null || raw.isEmpty) return; + try { + final decoded = jsonDecode(raw) as Map; + // Reject tampered / incomplete session blobs. + if (decoded['token'] is! String || + (decoded['token'] as String).length < 16) { + await _secure.delete(key: _sessionKey); + return; + } + final userJson = decoded['user']; + if (userJson is! Map) { + await _secure.delete(key: _sessionKey); + return; + } + final issuedAt = DateTime.tryParse(decoded['issuedAt'] as String? ?? ''); + if (issuedAt != null && + DateTime.now().toUtc().difference(issuedAt) > const Duration(days: 90)) { + await _secure.delete(key: _sessionKey); + return; + } + final sessionUser = + AuthUser.fromJson(Map.from(userJson)); + _user = await _rehydrateUser(sessionUser); + } catch (_) { + await _secure.delete(key: _sessionKey); + } + } + + /// Re-read authoritative flags from the users DB (don't trust session alone). + Future _rehydrateUser(AuthUser sessionUser) async { + if (sessionUser.provider != AuthProvider.email) return sessionUser; + final users = await _loadUsers(); + final record = users[sessionUser.email]; + if (record == null) { + // Stale session for a deleted account. + await _secure.delete(key: _sessionKey); + _user = null; + return sessionUser; + } + return AuthUser( + id: record['id'] as String? ?? sessionUser.id, + email: sessionUser.email, + displayName: + record['displayName'] as String? ?? sessionUser.displayName, + provider: AuthProvider.email, + photoUrl: sessionUser.photoUrl, + totpEnabled: record['totpEnabled'] == true, + emailVerified: record.containsKey('emailVerified') + ? record['emailVerified'] == true + : true, + ); + } + Future _setSession(AuthUser user) async { _user = user; + final token = _randomSalt(); + final payload = jsonEncode({ + 'token': token, + 'issuedAt': DateTime.now().toUtc().toIso8601String(), + 'user': user.toJson(), + }); + await _secure.write(key: _sessionKey, value: payload); + + // Ensure legacy plaintext session is gone. final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_sessionKey, jsonEncode(user.toJson())); + await prefs.remove(_legacySessionKey); notifyListeners(); } @@ -266,6 +888,61 @@ class AuthService extends ChangeNotifier { await _secure.write(key: _usersKey, value: jsonEncode(users)); } + Future> _loadLockout() async { + final raw = await _secure.read(key: _lockoutKey); + if (raw == null || raw.isEmpty) return {}; + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) return {}; + return Map.from(decoded); + } catch (_) { + return {}; + } + } + + Future _saveLockout(Map data) async { + await _secure.write(key: _lockoutKey, value: jsonEncode(data)); + } + + Future _assertNotLockedOut(String email) async { + final data = await _loadLockout(); + final entry = data[email]; + if (entry is! Map) return; + final untilMs = entry['until'] as int?; + if (untilMs == null) return; + final remaining = + DateTime.fromMillisecondsSinceEpoch(untilMs).difference(DateTime.now()); + if (remaining.isNegative) return; + final seconds = remaining.inSeconds + 1; + throw AuthException( + 'Too many failed attempts. Try again in ${seconds}s.', + ); + } + + Future _registerFailedAttempt(String email) async { + final data = await _loadLockout(); + final entry = Map.from( + (data[email] as Map?) ?? const {}, + ); + final attempts = ((entry['attempts'] as int?) ?? 0) + 1; + entry['attempts'] = attempts; + if (attempts >= _maxFailedAttempts) { + final streak = (attempts - _maxFailedAttempts) + 1; + final seconds = _lockoutBaseSeconds * (1 << (streak - 1).clamp(0, 4)); + entry['until'] = + DateTime.now().add(Duration(seconds: seconds)).millisecondsSinceEpoch; + } + data[email] = entry; + await _saveLockout(data); + } + + Future _clearFailedAttempts(String email) async { + final data = await _loadLockout(); + if (!data.containsKey(email)) return; + data.remove(email); + await _saveLockout(data); + } + static bool _isValidEmail(String email) => RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(email); @@ -275,6 +952,9 @@ class AuthService extends ChangeNotifier { return base64UrlEncode(bytes); } - static String _hashPassword(String password, String salt) => - sha256.convert(utf8.encode('$salt|$password')).toString(); + static String _randomId() { + final rng = Random.secure(); + final bytes = List.generate(12, (_) => rng.nextInt(256)); + return base64UrlEncode(bytes).replaceAll('=', ''); + } } diff --git a/lib/core/claim_crypto.dart b/lib/core/claim_crypto.dart new file mode 100644 index 0000000..2923730 --- /dev/null +++ b/lib/core/claim_crypto.dart @@ -0,0 +1,325 @@ +/// Account-bound claim authentication for Signata fingerprints. +/// +/// New payloads carry `alg: hmac-sha256` + `kid` + `signature` (HMAC). +/// Legacy FNV-16 payloads (no `alg`) still verify as self-consistent only. +library; + +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +import 'auth.dart'; +import 'fingerprint.dart'; +import 'secure_store.dart'; + +const String claimAlgHmac = 'hmac-sha256'; +const String claimAlgFnv16 = 'fnv16'; +const int claimPayloadVersion = 2; + +enum ClaimStatus { + /// No recoverable payload. + missing, + + /// Payload parsed but the signature/mac does not check out. + present, + + /// Legacy FNV or structural digest matches (no account authentication). + selfConsistent, + + /// HMAC verifies with the current account key. + authenticated, + + /// HMAC payload issued under a different key id. + foreignKey, +} + +class ClaimKey { + const ClaimKey({required this.bytes, required this.kid}); + + final Uint8List bytes; + final String kid; + + /// First 8 hex chars of SHA-256(key). + static String kidFor(Uint8List key) => + sha256.convert(key).toString().substring(0, 8).toUpperCase(); + + static ClaimKey generate() { + final rng = Random.secure(); + final bytes = Uint8List.fromList( + List.generate(32, (_) => rng.nextInt(256)), + ); + return ClaimKey(bytes: bytes, kid: kidFor(bytes)); + } + + /// Deterministic key for unit tests. + static ClaimKey fromSeed(String seed) { + final bytes = Uint8List.fromList(sha256.convert(utf8.encode(seed)).bytes); + return ClaimKey(bytes: bytes, kid: kidFor(bytes)); + } +} + +class SignedClaim { + const SignedClaim({ + required this.signature, + required this.alg, + required this.kid, + required this.version, + }); + + final String signature; + final String alg; + final String kid; + final int version; + + Map toFields() => { + 'alg': alg, + 'kid': kid, + 'v': version, + 'signature': signature, + }; +} + +class ClaimCrypto { + static String hmacHex(ClaimKey key, String canonical) => + Hmac(sha256, key.bytes) + .convert(utf8.encode(canonical)) + .toString() + .toUpperCase(); + + static String mediaCanonical({ + required String owner, + required String asset, + required String issued, + }) => + '$owner|$asset|$issued'; + + static String structuralCanonical({ + required String owner, + required String document, + required String issued, + required String structureCanonical, + required int originalLength, + required String identifier, + }) => + '$owner|$document|$issued|$structureCanonical|$originalLength|$identifier'; + + static SignedClaim signMedia( + ClaimKey key, { + required String owner, + required String asset, + required String issued, + }) { + final canonical = mediaCanonical(owner: owner, asset: asset, issued: issued); + return SignedClaim( + signature: hmacHex(key, canonical), + alg: claimAlgHmac, + kid: key.kid, + version: claimPayloadVersion, + ); + } + + static SignedClaim signStructural( + ClaimKey key, { + required String owner, + required String document, + required String issued, + required String structureCanonical, + required int originalLength, + required String identifier, + }) { + final canonical = structuralCanonical( + owner: owner, + document: document, + issued: issued, + structureCanonical: structureCanonical, + originalLength: originalLength, + identifier: identifier, + ); + return SignedClaim( + signature: hmacHex(key, canonical), + alg: claimAlgHmac, + kid: key.kid, + version: claimPayloadVersion, + ); + } + + /// Legacy FNV signature used when no account key is available. + static SignedClaim signMediaLegacy({ + required String owner, + required String asset, + required String issued, + }) => + SignedClaim( + signature: fingerprint(mediaCanonical( + owner: owner, + asset: asset, + issued: issued, + )), + alg: claimAlgFnv16, + kid: '', + version: 1, + ); + + static ClaimStatus evaluateMedia({ + required String owner, + required String asset, + required String issued, + required String signature, + String? alg, + String? kid, + ClaimKey? key, + }) { + final resolvedAlg = + (alg == null || alg.isEmpty) ? claimAlgFnv16 : alg; + final canonical = + mediaCanonical(owner: owner, asset: asset, issued: issued); + + if (resolvedAlg == claimAlgFnv16) { + return signature == fingerprint(canonical) + ? ClaimStatus.selfConsistent + : ClaimStatus.present; + } + + if (resolvedAlg != claimAlgHmac) return ClaimStatus.present; + if (key == null) { + return (kid != null && kid.isNotEmpty) + ? ClaimStatus.foreignKey + : ClaimStatus.present; + } + if (kid != null && kid.isNotEmpty && kid.toUpperCase() != key.kid) { + return ClaimStatus.foreignKey; + } + return hmacHex(key, canonical) == signature.toUpperCase() + ? ClaimStatus.authenticated + : ClaimStatus.present; + } + + static ClaimStatus evaluateStructural({ + required String owner, + required String document, + required String issued, + required String structureCanonical, + required int originalLength, + required String identifier, + required String recheckId, + required bool structureMatch, + String? signature, + String? alg, + String? kid, + ClaimKey? key, + }) { + final digestOk = identifier.isNotEmpty && + recheckId == identifier && + structureMatch; + if (!digestOk) { + return identifier.isEmpty ? ClaimStatus.missing : ClaimStatus.present; + } + + final resolvedAlg = + (alg == null || alg.isEmpty) ? claimAlgFnv16 : alg; + if (resolvedAlg != claimAlgHmac || signature == null || signature.isEmpty) { + return ClaimStatus.selfConsistent; + } + + if (key == null) return ClaimStatus.foreignKey; + if (kid != null && kid.isNotEmpty && kid.toUpperCase() != key.kid) { + return ClaimStatus.foreignKey; + } + + final canonical = structuralCanonical( + owner: owner, + document: document, + issued: issued, + structureCanonical: structureCanonical, + originalLength: originalLength, + identifier: identifier, + ); + return hmacHex(key, canonical) == signature.toUpperCase() + ? ClaimStatus.authenticated + : ClaimStatus.present; + } + + static String describe(ClaimStatus status) { + switch (status) { + case ClaimStatus.missing: + return 'No readable Signata fingerprint was found.'; + case ClaimStatus.present: + return 'A fingerprint was found but the claim signature is invalid.'; + case ClaimStatus.selfConsistent: + return 'Claim is self-consistent (legacy / unauthenticated).'; + case ClaimStatus.authenticated: + return 'Authenticated with your Signata key.'; + case ClaimStatus.foreignKey: + return 'Claim consistent but not signed with your Signata key.'; + } + } +} + +/// Loads or creates the per-account HMAC key in secure storage. +class AccountClaimKeys { + AccountClaimKeys._(); + + static final _secure = signataSecureStorage; + + static String _storageKey(String userId) => 'signata_claim_key_$userId'; + + static Future forUser(String userId) async { + final existing = await _secure.read(key: _storageKey(userId)); + if (existing != null && existing.isNotEmpty) { + try { + final bytes = Uint8List.fromList(base64Url.decode(existing)); + if (bytes.length == 32) { + return ClaimKey(bytes: bytes, kid: ClaimKey.kidFor(bytes)); + } + } catch (_) {} + } + final key = ClaimKey.generate(); + await _secure.write( + key: _storageKey(userId), + value: base64UrlEncode(key.bytes), + ); + return key; + } + + static Future current() async { + final user = AuthService.instance.user; + if (user == null) return null; + return forUser(user.id); + } + + /// Exports the raw claim key (base64url) for an offline recovery kit. + static Future exportCurrentKeyBase64() async { + final user = AuthService.instance.user; + if (user == null) return null; + return _secure.read(key: _storageKey(user.id)); + } + + /// Overwrites the current account claim key (e.g. after device restore). + /// Returns the key id (kid). + static Future importForCurrentUser(String base64UrlKey) async { + final user = AuthService.instance.user; + if (user == null) { + throw StateError('Not signed in.'); + } + late final Uint8List bytes; + try { + bytes = Uint8List.fromList(base64Url.decode(base64UrlKey.trim())); + } catch (_) { + throw ArgumentError('Claim key is not valid base64url.'); + } + if (bytes.length != 32) { + throw ArgumentError('Claim key must be 32 bytes.'); + } + await _secure.write( + key: _storageKey(user.id), + value: base64UrlEncode(bytes), + ); + return ClaimKey.kidFor(bytes); + } + + static Future deleteForUser(String userId) async { + await _secure.delete(key: _storageKey(userId)); + } +} diff --git a/lib/core/claim_publisher.dart b/lib/core/claim_publisher.dart new file mode 100644 index 0000000..f5ee742 --- /dev/null +++ b/lib/core/claim_publisher.dart @@ -0,0 +1,34 @@ +/// Convenience helpers to register a protected file for internet tracing. +library; + +import 'auth.dart'; +import 'claim_registry.dart'; +import 'trace_models.dart'; + +class ClaimPublisher { + ClaimPublisher._(); + + static Future publishProtected({ + required TraceMedium medium, + required String owner, + required String subject, + required String reference, + required String issued, + String? alg, + String? kid, + String? note, + }) async { + if (reference.trim().isEmpty || reference == '—') return null; + if (!AuthService.instance.isSignedIn) return null; + return ClaimRegistry.instance.publish( + medium: medium, + owner: owner, + subject: subject, + reference: reference, + issued: issued, + alg: alg, + kid: kid, + note: note, + ); + } +} diff --git a/lib/core/claim_registry.dart b/lib/core/claim_registry.dart new file mode 100644 index 0000000..b7ee933 --- /dev/null +++ b/lib/core/claim_registry.dart @@ -0,0 +1,197 @@ +/// Local + optional remote registry of published Signata claims. +/// +/// Local storage always works. When `SIGNATA_REGISTRY_URL` is set (dart-define +/// or [ClaimRegistry.configure]), claims are also POSTed so other devices can +/// look them up by fingerprint reference. +library; + +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'auth.dart'; +import 'local_data.dart'; +import 'safe_url.dart'; +import 'trace_models.dart'; + +class ClaimRegistry { + ClaimRegistry._(); + static final ClaimRegistry instance = ClaimRegistry._(); + + static const _legacyPrefsKey = 'signata_published_claims_v1'; + + /// Optional remote base URL, e.g. `https://registry.example.com`. + static String remoteBaseUrl = const String.fromEnvironment( + 'SIGNATA_REGISTRY_URL', + defaultValue: '', + ); + + static void configure({String? remoteBaseUrl}) { + if (remoteBaseUrl == null) return; + final trimmed = remoteBaseUrl.trim(); + if (trimmed.isEmpty) { + ClaimRegistry.remoteBaseUrl = ''; + return; + } + final parsed = SafeUrl.parseRegistryBase(trimmed); + if (!parsed.isOk) { + debugPrint('Invalid registry URL ignored: ${parsed.error}'); + ClaimRegistry.remoteBaseUrl = ''; + return; + } + ClaimRegistry.remoteBaseUrl = parsed.uri!.toString().replaceAll(RegExp(r'/+$'), ''); + } + + static Uri? get _remoteBaseUri { + if (!hasRemote) return null; + final parsed = SafeUrl.parseRegistryBase(remoteBaseUrl); + return parsed.isOk ? parsed.uri : null; + } + + static bool get hasRemote => remoteBaseUrl.trim().isNotEmpty; + + String get _prefsKey => userScopedKey(_legacyPrefsKey); + + Future _ensureMigrated() => + migrateLegacyPrefsKey(_legacyPrefsKey, _prefsKey); + + Future> listLocal() async { + await _ensureMigrated(); + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_prefsKey); + if (raw == null || raw.isEmpty) return const []; + try { + final list = jsonDecode(raw); + if (list is! List) return const []; + return list + .map(PublishedClaim.fromJson) + .whereType() + .toList() + ..sort((a, b) => b.publishedAt.compareTo(a.publishedAt)); + } catch (_) { + return const []; + } + } + + Future _saveLocal(List claims) async { + await _ensureMigrated(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _prefsKey, + jsonEncode(claims.map((c) => c.toJson()).toList()), + ); + } + + Future clearLocal() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_prefsKey); + } + + Future clearForUser(String userId) async { + final prefs = await SharedPreferences.getInstance(); + final safe = userId.replaceAll(RegExp(r'[^a-zA-Z0-9._-]'), '_'); + await prefs.remove('${_legacyPrefsKey}__$safe'); + } + + Future findByReference(String reference) async { + final needle = reference.trim().toUpperCase(); + if (needle.isEmpty || needle.length > 128) return null; + for (final claim in await listLocal()) { + if (claim.reference.toUpperCase() == needle) return claim; + } + final base = _remoteBaseUri; + if (base != null) { + try { + final uri = base.replace( + path: '${base.path.replaceAll(RegExp(r'/+$'), '')}/claims/${Uri.encodeComponent(needle)}', + ); + final response = await http.get(uri).timeout(const Duration(seconds: 12)); + if (response.statusCode == 200) { + final decoded = jsonDecode(response.body); + final map = decoded is Map && decoded['claim'] is Map + ? decoded['claim'] + : decoded; + return PublishedClaim.fromJson(map); + } + } catch (error) { + debugPrint('Remote claim lookup failed: $error'); + } + } + return null; + } + + Future publish({ + required TraceMedium medium, + required String owner, + required String subject, + required String reference, + required String issued, + String? alg, + String? kid, + String? note, + }) async { + final user = AuthService.instance.user; + final id = + 'claim_${DateTime.now().toUtc().millisecondsSinceEpoch}_$reference'; + var claim = PublishedClaim( + id: id, + medium: medium, + owner: owner, + subject: subject, + reference: reference, + issued: issued, + publisherId: user?.id ?? 'anonymous', + publishedAt: DateTime.now().toUtc(), + alg: alg, + kid: kid, + note: note, + ); + + final base = _remoteBaseUri; + if (base != null) { + try { + final uri = base.replace( + path: '${base.path.replaceAll(RegExp(r'/+$'), '')}/claims', + ); + final response = await http + .post( + uri, + headers: { + 'Content-Type': 'application/json', + // Optional shared secret for self-hosted registries. + if (const String.fromEnvironment('SIGNATA_REGISTRY_TOKEN') + .isNotEmpty) + 'Authorization': + 'Bearer ${const String.fromEnvironment('SIGNATA_REGISTRY_TOKEN')}', + }, + body: jsonEncode(claim.toJson()), + ) + .timeout(const Duration(seconds: 15)); + if (response.statusCode >= 200 && response.statusCode < 300) { + claim = claim.copyWith(remoteSynced: true); + } else { + debugPrint( + 'Remote publish failed: ${response.statusCode} ${response.body}', + ); + } + } catch (error) { + debugPrint('Remote publish error: $error'); + } + } + + final existing = await listLocal(); + final withoutDup = existing + .where((c) => c.reference.toUpperCase() != reference.toUpperCase()) + .toList(); + withoutDup.insert(0, claim); + await _saveLocal(withoutDup.take(200).toList()); + return claim; + } + + Future remove(String id) async { + final next = (await listLocal()).where((c) => c.id != id).toList(); + await _saveLocal(next); + } +} diff --git a/lib/core/claim_status_ui.dart b/lib/core/claim_status_ui.dart new file mode 100644 index 0000000..de58f9d --- /dev/null +++ b/lib/core/claim_status_ui.dart @@ -0,0 +1,67 @@ +import 'claim_crypto.dart'; + +/// Shared verify banner copy for tool screens. +({bool ok, String title, String subtitle}) claimBanner(ClaimStatus status) { + switch (status) { + case ClaimStatus.authenticated: + return ( + ok: true, + title: 'Authenticated with your Signata key', + subtitle: ClaimCrypto.describe(status), + ); + case ClaimStatus.selfConsistent: + return ( + ok: true, + title: 'Legacy fingerprint (unsigned)', + subtitle: + 'Structure checks out, but it is not bound to your account key.', + ); + case ClaimStatus.foreignKey: + return ( + ok: false, + title: 'Signed by another Signata key', + subtitle: ClaimCrypto.describe(status), + ); + case ClaimStatus.present: + return ( + ok: false, + title: 'Fingerprint found but invalid', + subtitle: ClaimCrypto.describe(status), + ); + case ClaimStatus.missing: + return ( + ok: false, + title: 'No fingerprint found', + subtitle: ClaimCrypto.describe(status), + ); + } +} + +/// Trace-specific banner — explains platform stripping when nothing is found. +({bool ok, String title, String subtitle}) traceResultBanner({ + required ClaimStatus status, + String? error, + String? note, + bool socialHost = false, +}) { + if (error != null && error.isNotEmpty) { + return (ok: false, title: 'Could not scan URL', subtitle: error); + } + if (status == ClaimStatus.missing) { + return ( + ok: false, + title: socialHost + ? 'No fingerprint on this post' + : 'No fingerprint found', + subtitle: note ?? + (socialHost + ? 'Social apps often recompress media and can strip hidden marks. ' + 'Try the original protected file or a direct media CDN link.' + : ClaimCrypto.describe(status)), + ); + } + return claimBanner(status); +} + +bool claimCountsAsVerified(ClaimStatus status) => + status == ClaimStatus.authenticated; diff --git a/lib/core/google_auth_config.dart b/lib/core/google_auth_config.dart index 868bb38..70d585b 100644 --- a/lib/core/google_auth_config.dart +++ b/lib/core/google_auth_config.dart @@ -3,15 +3,32 @@ /// Create these in Google Cloud Console (APIs & Services → Credentials): /// 1. OAuth client type **Web application** → use its ID as [serverClientId] /// 2. OAuth client type **Android** with package `app.signata.signata` + SHA-1 -/// (needed for Google to trust this app; you usually do NOT paste that ID here) +/// Add both the **debug** and **upload/release** SHA-1 fingerprints (and later +/// the Play App Signing SHA-1 from Play Console → App integrity). /// -/// Fill the values below, or pass them at run time: -/// `flutter run --dart-define=GOOGLE_SERVER_CLIENT_ID=....apps.googleusercontent.com` +/// Sources (first match wins): +/// 1. Runtime override saved from Account (secure storage) +/// 2. `--dart-define=GOOGLE_SERVER_CLIENT_ID=...` or `--dart-define-from-file=` library; +import 'secure_store.dart'; + class GoogleAuthConfig { + GoogleAuthConfig._(); + + static const _storageKey = 'signata_google_server_client_id'; + + /// Android package + SHA-1 fingerprints for the OAuth Android client. + static const androidPackageName = 'app.signata.signata'; + static const androidDebugSha1 = + '0A:3A:67:69:E8:44:A7:A5:0A:1B:FD:CD:61:A1:32:5C:2B:F6:9F:AA'; + + /// Upload keystore SHA-1 used to sign Play uploads (android/upload-keystore.jks). + static const androidReleaseSha1 = + '73:17:8E:4C:0D:25:57:C6:67:17:6C:C8:7F:91:C7:7B:89:0C:77:FA'; + /// Web client ID — required on Android as `serverClientId`. - static const String serverClientId = String.fromEnvironment( + static const String _defineServerClientId = String.fromEnvironment( 'GOOGLE_SERVER_CLIENT_ID', defaultValue: '', ); @@ -23,5 +40,57 @@ class GoogleAuthConfig { defaultValue: '', ); - static bool get hasServerClientId => serverClientId.trim().isNotEmpty; + static String? _runtimeServerClientId; + static bool _loaded = false; + + static final _secure = signataSecureStorage; + + static String get serverClientId { + final runtime = _runtimeServerClientId?.trim() ?? ''; + if (runtime.isNotEmpty) return runtime; + return _defineServerClientId.trim(); + } + + static bool get hasServerClientId => serverClientId.isNotEmpty; + + static bool get isFromDartDefine => + _defineServerClientId.trim().isNotEmpty && + (_runtimeServerClientId == null || _runtimeServerClientId!.trim().isEmpty); + + static bool get isLikelyClientId => + RegExp(r'^[0-9]+-[a-z0-9]+\.apps\.googleusercontent\.com$') + .hasMatch(serverClientId); + + /// Loads any previously saved Web client ID from secure storage. + static Future load() async { + if (_loaded) return; + try { + _runtimeServerClientId = await _secure.read(key: _storageKey); + } catch (_) { + _runtimeServerClientId = null; + } + _loaded = true; + } + + /// Saves a Web client ID so Google Sign-In works without rebuilding. + static Future saveServerClientId(String value) async { + final trimmed = value.trim(); + if (!_looksLikeClientId(trimmed)) { + throw ArgumentError( + 'Paste the Web client ID (…apps.googleusercontent.com).', + ); + } + _runtimeServerClientId = trimmed; + _loaded = true; + await _secure.write(key: _storageKey, value: trimmed); + } + + static Future clearServerClientId() async { + _runtimeServerClientId = null; + await _secure.delete(key: _storageKey); + } + + static bool _looksLikeClientId(String value) => + RegExp(r'^[0-9]+-[a-z0-9]+\.apps\.googleusercontent\.com$') + .hasMatch(value); } diff --git a/lib/core/history.dart b/lib/core/history.dart index c31ea41..25bba13 100644 --- a/lib/core/history.dart +++ b/lib/core/history.dart @@ -1,12 +1,14 @@ /// Local verification history — an app-only improvement over the website. /// Every embed/verify run is recorded on-device so creators can look back at -/// what they protected and when. +/// what they protected and when. Entries are scoped per signed-in user. library; import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; +import 'local_data.dart'; + class HistoryEntry { const HistoryEntry({ required this.medium, @@ -42,16 +44,17 @@ class HistoryEntry { }; static HistoryEntry? fromJson(Object? value) { - if (value is! Map) return null; + if (value is! Map) return null; + final map = Map.from(value); try { return HistoryEntry( - medium: value['medium'] as String, - action: value['action'] as String, - subject: value['subject'] as String, - owner: value['owner'] as String, - verified: value['verified'] as bool, - reference: value['reference'] as String, - at: DateTime.parse(value['at'] as String), + medium: map['medium'] as String, + action: map['action'] as String, + subject: map['subject'] as String, + owner: map['owner'] as String, + verified: map['verified'] as bool, + reference: map['reference'] as String, + at: DateTime.parse(map['at'] as String), ); } catch (_) { return null; @@ -60,10 +63,16 @@ class HistoryEntry { } class HistoryStore { - static const _key = 'signata_history_v1'; + static const _legacyKey = 'signata_history_v1'; static const _maxEntries = 100; + static String get _key => userScopedKey(_legacyKey); + + static Future _ensureMigrated() => + migrateLegacyPrefsKey(_legacyKey, _key); + static Future> load() async { + await _ensureMigrated(); final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_key); if (raw == null) return const []; @@ -80,6 +89,7 @@ class HistoryStore { } static Future add(HistoryEntry entry) async { + await _ensureMigrated(); final prefs = await SharedPreferences.getInstance(); final entries = await load(); final updated = [entry, ...entries].take(_maxEntries).toList(); @@ -92,5 +102,13 @@ class HistoryStore { static Future clear() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_key); + // Also drop the pre-scoping key so _ensureMigrated() cannot resurrect it. + await prefs.remove(_legacyKey); + } + + static Future clearForUser(String userId) async { + final prefs = await SharedPreferences.getInstance(); + final safe = userId.replaceAll(RegExp(r'[^a-zA-Z0-9._-]'), '_'); + await prefs.remove('${_legacyKey}__$safe'); } } diff --git a/lib/core/image_watermark.dart b/lib/core/image_watermark.dart index b138158..dd8b22a 100644 --- a/lib/core/image_watermark.dart +++ b/lib/core/image_watermark.dart @@ -1,9 +1,8 @@ /// LSB watermark codec — Dart port of the website's canvas-based codec. /// -/// The bit layout uses a Signata magic header (`SGN1`, 4-byte -/// big-endian length, payload bits spread over the R/G/B least significant -/// bits of each pixel in row-major order), so a PNG watermarked in the app -/// verifies on the website and vice versa. +/// Scattered LSB embedding (seeded from the magic header) with sequential +/// legacy fallback on extract. Payloads may carry HMAC claim signatures +/// (`alg: hmac-sha256`) or legacy FNV self-consistent signatures. library; import 'dart:convert'; @@ -13,7 +12,8 @@ import 'dart:typed_data'; import 'package:flutter/foundation.dart' show compute; import 'package:image/image.dart' as img; -import 'fingerprint.dart'; +import 'claim_crypto.dart'; +import 'lsb_scatter.dart'; const String _magic = 'SGN1'; @@ -24,19 +24,31 @@ class WatermarkPayload { required this.asset, required this.issued, required this.signature, + this.alg, + this.kid, + this.version = 1, }); final String owner; final String asset; final String issued; final String signature; - - Map toJson() => { - 'owner': owner, - 'asset': asset, - 'issued': issued, - 'signature': signature, - }; + final String? alg; + final String? kid; + final int version; + + Map toJson() { + final map = { + 'owner': owner, + 'asset': asset, + 'issued': issued, + 'signature': signature, + }; + if (alg != null && alg!.isNotEmpty) map['alg'] = alg; + if (kid != null && kid!.isNotEmpty) map['kid'] = kid; + if (version > 1) map['v'] = version; + return map; + } static WatermarkPayload? tryParse(String? raw) { if (raw == null) return null; @@ -48,16 +60,37 @@ class WatermarkPayload { asset: map['asset'] as String? ?? '', issued: map['issued'] as String? ?? '', signature: map['signature'] as String? ?? '', + alg: map['alg'] as String?, + kid: map['kid'] as String?, + version: (map['v'] as num?)?.toInt() ?? 1, ); } catch (_) { return null; } } - /// A payload is genuine when its signature matches the fingerprint - /// recomputed from its own claim fields. - bool get signatureValid => - signature == fingerprint('$owner|$asset|$issued'); + /// Legacy FNV self-consistency (ignores HMAC alg). + bool get signatureValid { + final legacy = ClaimCrypto.signMediaLegacy( + owner: owner, + asset: asset, + issued: issued, + ); + return signature == legacy.signature; + } + + bool isAuthentic(ClaimKey? key) => + statusWith(key) == ClaimStatus.authenticated; + + ClaimStatus statusWith(ClaimKey? key) => ClaimCrypto.evaluateMedia( + owner: owner, + asset: asset, + issued: issued, + signature: signature, + alg: alg, + kid: kid, + key: key, + ); } class EmbedOutcome { @@ -70,6 +103,8 @@ class EmbedOutcome { required this.bitsUsed, required this.capacityBits, required this.recoveredRaw, + required this.resized, + required this.claimStatus, }); final Uint8List originalPng; @@ -79,9 +114,9 @@ class EmbedOutcome { final int height; final int bitsUsed; final int capacityBits; - - /// Raw payload extracted back out of the encoded PNG (round trip). final String? recoveredRaw; + final bool resized; + final ClaimStatus claimStatus; WatermarkPayload? get recovered => WatermarkPayload.tryParse(recoveredRaw); @@ -95,15 +130,122 @@ class EmbedOutcome { } class ExtractOutcome { - const ExtractOutcome({required this.raw, required this.previewPng}); + const ExtractOutcome({ + required this.raw, + required this.previewPng, + this.claimStatus = ClaimStatus.missing, + }); final String? raw; final Uint8List previewPng; + final ClaimStatus claimStatus; +} + +class ImageTooSmallException implements Exception { + const ImageTooSmallException(); + + @override + String toString() => + 'Image is too small to carry this fingerprint. Try a larger image.'; } /* ------------------------- bit-level codec ------------------------- */ -({int bitsUsed, int capacityBits}) _embedBits(Uint8List rgba, String payload) { +Uint8List _magicSeedHeader() { + final header = Uint8List(8); + header.setAll(0, ascii.encode(_magic)); + return header; +} + +int _rgbaSlotCount(int rgbaLength) => (rgbaLength ~/ 4) * 3; + +void _writeScatteredBit( + Uint8List rgba, + LsbScatter scatter, + int bitIndex, + int value, +) { + final slot = scatter[bitIndex]; + final pixel = (slot ~/ 3) * 4 + (slot % 3); + if (pixel >= rgba.length) return; + rgba[pixel] = (rgba[pixel] & 0xfe) | (value & 1); +} + +int _readScatteredBit(Uint8List rgba, LsbScatter scatter, int bitIndex) { + final slot = scatter[bitIndex]; + final pixel = (slot ~/ 3) * 4 + (slot % 3); + if (pixel >= rgba.length) return 0; + return rgba[pixel] & 1; +} + +Uint8List? _readScatteredBytes( + Uint8List rgba, + LsbScatter scatter, + int count, + int startBit, +) { + final out = Uint8List(count); + for (var b = 0; b < count; b++) { + var byte = 0; + for (var k = 0; k < 8; k++) { + final bit = startBit + b * 8 + k; + if (bit >= scatter.length) return null; + byte = (byte << 1) | _readScatteredBit(rgba, scatter, bit); + } + out[b] = byte; + } + return out; +} + +({int bitsUsed, int capacityBits}) _embedScattered( + Uint8List rgba, + Uint8List packet, +) { + final slotCount = _rgbaSlotCount(rgba.length); + final scatter = LsbScatter.seeded( + slotCount: slotCount, + seed: LsbScatter.seedFromHeader(_magicSeedHeader()), + ); + final totalBits = packet.length * 8; + if (totalBits > slotCount) throw const ImageTooSmallException(); + + for (var bit = 0; bit < totalBits; bit++) { + final byte = packet[bit >> 3]; + final value = (byte >> (7 - (bit & 7))) & 1; + _writeScatteredBit(rgba, scatter, bit, value); + } + return (bitsUsed: totalBits, capacityBits: slotCount); +} + +String? _extractScattered(Uint8List rgba) { + final slotCount = _rgbaSlotCount(rgba.length); + if (slotCount < 64) return null; + final scatter = LsbScatter.seeded( + slotCount: slotCount, + seed: LsbScatter.seedFromHeader(_magicSeedHeader()), + ); + + final header = _readScatteredBytes(rgba, scatter, 8, 0); + if (header == null) return null; + if (String.fromCharCodes(header.sublist(0, 4)) != _magic) return null; + final length = + (header[4] << 24) | (header[5] << 16) | (header[6] << 8) | header[7]; + if (length <= 0 || length > 1000000) return null; + final bodyStartBit = 64; + if (bodyStartBit + length * 8 > scatter.length) return null; + final body = _readScatteredBytes(rgba, scatter, length, bodyStartBit); + if (body == null) return null; + try { + return utf8.decode(body); + } catch (_) { + return null; + } +} + +({int bitsUsed, int capacityBits}) _embedSequential( + Uint8List rgba, + String payload, +) { final body = utf8.encode(payload); final header = ascii.encode(_magic); final bytes = Uint8List(header.length + 4 + body.length); @@ -114,13 +256,11 @@ class ExtractOutcome { bytes[7] = body.length & 0xff; bytes.setAll(8, body); - final capacity = (rgba.length ~/ 4) * 3 ~/ 8; - if (bytes.length > capacity) { - throw const ImageTooSmallException(); - } + final capacity = _rgbaSlotCount(rgba.length); + final totalBits = bytes.length * 8; + if (bytes.length > capacity ~/ 8) throw const ImageTooSmallException(); var bit = 0; - final totalBits = bytes.length * 8; for (var i = 0; i < rgba.length && bit < totalBits; i += 4) { for (var c = 0; c < 3 && bit < totalBits; c++, bit++) { final byte = bytes[bit >> 3]; @@ -128,10 +268,10 @@ class ExtractOutcome { rgba[i + c] = (rgba[i + c] & 0xfe) | value; } } - return (bitsUsed: totalBits, capacityBits: capacity * 8); + return (bitsUsed: totalBits, capacityBits: capacity); } -String? _extractBits(Uint8List rgba) { +String? _extractSequential(Uint8List rgba) { Uint8List? readBytes(int count, int startBit) { final out = Uint8List(count); var bit = startBit; @@ -162,12 +302,24 @@ String? _extractBits(Uint8List rgba) { } } -class ImageTooSmallException implements Exception { - const ImageTooSmallException(); +String? _extractBits(Uint8List rgba) => + _extractScattered(rgba) ?? _extractSequential(rgba); - @override - String toString() => - 'Image is too small to carry this fingerprint. Try a larger image.'; +Uint8List _buildPacket(String payloadJson) { + final body = utf8.encode(payloadJson); + final header = ascii.encode(_magic); + final bytes = Uint8List(header.length + 4 + body.length); + bytes.setAll(0, header); + bytes[4] = (body.length >>> 24) & 0xff; + bytes[5] = (body.length >>> 16) & 0xff; + bytes[6] = (body.length >>> 8) & 0xff; + bytes[7] = body.length & 0xff; + bytes.setAll(8, body); + return bytes; +} + +bool _canEmbedScattered(Uint8List rgba, int packetBytes) { + return packetBytes * 8 <= _rgbaSlotCount(rgba.length); } /* --------------------------- isolate tasks --------------------------- */ @@ -177,30 +329,114 @@ Uint8List _rgbaOf(img.Image source) { return converted.getBytes(order: img.ChannelOrder.rgba); } +ClaimKey? _claimKeyFromArgs(Uint8List? keyBytes, String? kid) { + if (keyBytes == null || keyBytes.length != 32) return null; + return ClaimKey(bytes: keyBytes, kid: kid ?? ClaimKey.kidFor(keyBytes)); +} + +WatermarkPayload _buildPayload({ + required String owner, + required String asset, + required String issued, + ClaimKey? claimKey, +}) { + final resolvedOwner = + owner.trim().isEmpty ? 'Anonymous creator' : owner.trim(); + if (claimKey != null) { + final signed = ClaimCrypto.signMedia( + claimKey, + owner: resolvedOwner, + asset: asset, + issued: issued, + ); + return WatermarkPayload( + owner: resolvedOwner, + asset: asset, + issued: issued, + signature: signed.signature, + alg: signed.alg, + kid: signed.kid, + version: signed.version, + ); + } + final legacy = ClaimCrypto.signMediaLegacy( + owner: resolvedOwner, + asset: asset, + issued: issued, + ); + return WatermarkPayload( + owner: resolvedOwner, + asset: asset, + issued: issued, + signature: legacy.signature, + alg: legacy.alg, + kid: legacy.kid, + version: legacy.version, + ); +} + EmbedOutcome _embedTask( - ({Uint8List fileBytes, String owner, String fileName, String issued}) - args) { - final (:fileBytes, :owner, :fileName, :issued) = args; + ({ + Uint8List fileBytes, + String owner, + String fileName, + String issued, + Uint8List? keyBytes, + String? kid, + }) args, +) { + final (:fileBytes, :owner, :fileName, :issued, :keyBytes, :kid) = args; + final claimKey = _claimKeyFromArgs(keyBytes, kid); + var decoded = img.decodeImage(fileBytes); if (decoded == null) { throw Exception('Could not decode this image. Try a PNG, JPG or WEBP.'); } - // Match the website: bound processing to 900px on the longest edge. - final maxDim = math.max(decoded.width, decoded.height); - if (maxDim > 900) { - final scale = 900 / maxDim; - decoded = img.copyResize( - decoded, - width: math.max(1, (decoded.width * scale).round()), - height: math.max(1, (decoded.height * scale).round()), - interpolation: img.Interpolation.average, - ); + var resized = false; + img.Image working = decoded; + + String assetFor(img.Image image) => + '$fileName\u00b7${image.width}x${image.height}'; + + // Probe payload size at native resolution; resize only if capacity is insufficient. + var probePayload = _buildPayload( + owner: owner, + asset: assetFor(working), + issued: issued, + claimKey: claimKey, + ); + var probePacket = _buildPacket(jsonEncode(probePayload.toJson())); + var probeRgba = _rgbaOf(working); + + if (!_canEmbedScattered(probeRgba, probePacket.length)) { + final maxDim = math.max(working.width, working.height); + if (maxDim > 900) { + final scale = 900 / maxDim; + working = img.copyResize( + working, + width: math.max(1, (working.width * scale).round()), + height: math.max(1, (working.height * scale).round()), + interpolation: img.Interpolation.average, + ); + resized = true; + probePayload = _buildPayload( + owner: owner, + asset: assetFor(working), + issued: issued, + claimKey: claimKey, + ); + probePacket = _buildPacket(jsonEncode(probePayload.toJson())); + probeRgba = _rgbaOf(working); + } + if (!_canEmbedScattered(probeRgba, probePacket.length)) { + throw const ImageTooSmallException(); + } } - final w = decoded.width; - final h = decoded.height; - final rgba = _rgbaOf(decoded); + final w = working.width; + final h = working.height; + final rgba = probeRgba; final originalPng = img.encodePng( img.Image.fromBytes( @@ -212,17 +448,10 @@ EmbedOutcome _embedTask( ), ); - final resolvedOwner = - owner.trim().isEmpty ? 'Anonymous creator' : owner.trim(); - final asset = '$fileName\u00b7${w}x$h'; - final payload = WatermarkPayload( - owner: resolvedOwner, - asset: asset, - issued: issued, - signature: fingerprint('$resolvedOwner|$asset|$issued'), - ); + final payload = probePayload; + final packet = _buildPacket(jsonEncode(payload.toJson())); + final stats = _embedScattered(rgba, packet); - final stats = _embedBits(rgba, jsonEncode(payload.toJson())); final markedPng = img.encodePng( img.Image.fromBytes( width: w, @@ -233,9 +462,13 @@ EmbedOutcome _embedTask( ), ); - // Round trip: extract from the delivered PNG, exactly as a verifier would. final delivered = img.decodePng(markedPng); - final recovered = delivered == null ? null : _extractBits(_rgbaOf(delivered)); + final recovered = + delivered == null ? null : _extractBits(_rgbaOf(delivered)); + + final recoveredPayload = WatermarkPayload.tryParse(recovered); + final claimStatus = recoveredPayload?.statusWith(claimKey) ?? + ClaimStatus.missing; return EmbedOutcome( originalPng: originalPng, @@ -246,15 +479,25 @@ EmbedOutcome _embedTask( bitsUsed: stats.bitsUsed, capacityBits: stats.capacityBits, recoveredRaw: recovered, + resized: resized, + claimStatus: claimStatus, ); } -ExtractOutcome _extractTask(Uint8List fileBytes) { +ExtractOutcome _extractTask( + ({Uint8List fileBytes, Uint8List? keyBytes, String? kid}) args, +) { + final (:fileBytes, :keyBytes, :kid) = args; + final claimKey = _claimKeyFromArgs(keyBytes, kid); + final decoded = img.decodeImage(fileBytes); if (decoded == null) { throw Exception('Could not decode this image. Try a PNG, JPG or WEBP.'); } final raw = _extractBits(_rgbaOf(decoded)); + final payload = WatermarkPayload.tryParse(raw); + final claimStatus = + payload?.statusWith(claimKey) ?? ClaimStatus.missing; var preview = decoded; final maxDim = math.max(decoded.width, decoded.height); @@ -266,7 +509,11 @@ ExtractOutcome _extractTask(Uint8List fileBytes) { height: math.max(1, (decoded.height * scale).round()), ); } - return ExtractOutcome(raw: raw, previewPng: img.encodePng(preview)); + return ExtractOutcome( + raw: raw, + previewPng: img.encodePng(preview), + claimStatus: claimStatus, + ); } /// Embeds an ownership payload into the image and round-trip verifies it. @@ -274,12 +521,32 @@ Future embedWatermark({ required Uint8List fileBytes, required String owner, required String fileName, + ClaimKey? claimKey, }) { final issued = DateTime.now().toUtc().toIso8601String(); - return compute(_embedTask, - (fileBytes: fileBytes, owner: owner, fileName: fileName, issued: issued)); + return compute( + _embedTask, + ( + fileBytes: fileBytes, + owner: owner, + fileName: fileName, + issued: issued, + keyBytes: claimKey?.bytes, + kid: claimKey?.kid, + ), + ); } /// Extracts a watermark payload from any image file, if one is present. -Future extractWatermark(Uint8List fileBytes) => - compute(_extractTask, fileBytes); +Future extractWatermark( + Uint8List fileBytes, { + ClaimKey? claimKey, +}) => + compute( + _extractTask, + ( + fileBytes: fileBytes, + keyBytes: claimKey?.bytes, + kid: claimKey?.kid, + ), + ); diff --git a/lib/core/local_data.dart b/lib/core/local_data.dart new file mode 100644 index 0000000..e6827ac --- /dev/null +++ b/lib/core/local_data.dart @@ -0,0 +1,117 @@ +/// Per-user local data helpers and full wipe for account deletion / privacy. +library; + +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import 'auth.dart'; +import 'claim_crypto.dart'; +import 'claim_registry.dart'; +import 'history.dart'; +import 'trace_store.dart'; +import 'usage_entitlements.dart'; + +/// Prefs key scoped to the signed-in user (falls back to anonymous). +String userScopedKey(String base) { + final id = AuthService.instance.user?.id ?? 'anonymous'; + final safe = id.replaceAll(RegExp(r'[^a-zA-Z0-9._-]'), '_'); + return '${base}__$safe'; +} + +/// One-shot migration: copy legacy global [legacyKey] into the current user's +/// scoped key when the scoped key is empty, then remove the legacy key so a +/// later clear cannot be undone by remigration. +Future migrateLegacyPrefsKey(String legacyKey, String scopedKey) async { + final prefs = await SharedPreferences.getInstance(); + if (prefs.containsKey(scopedKey)) { + // Scoped data already exists — drop leftover global copy if present. + if (prefs.containsKey(legacyKey)) { + await prefs.remove(legacyKey); + } + return; + } + final raw = prefs.getString(legacyKey); + if (raw == null || raw.isEmpty) return; + await prefs.setString(scopedKey, raw); + await prefs.remove(legacyKey); +} + +/// Offline recovery kit text (claim key + account hints). Store safely offline. +Future buildRecoveryKit() async { + final user = AuthService.instance.user; + if (user == null) { + throw const AuthException('Not signed in.'); + } + final keyB64 = await AccountClaimKeys.exportCurrentKeyBase64() ?? + base64UrlEncode((await AccountClaimKeys.forUser(user.id)).bytes); + return ''' +Signata recovery kit +==================== +Generated: ${DateTime.now().toUtc().toIso8601String()} +Email: ${user.email} +Account id: ${user.id} +Provider: ${user.provider.name} + +Claim key (base64url) — required to prove old fingerprints as "yours": +$keyB64 + +Keep this file offline and private. Signata accounts are local to this device; +losing the password and this kit may permanently lock you out of authentication. +'''.trim(); +} + +/// Extracts a base64url claim key from a recovery kit or a bare key string. +String? parseClaimKeyFromRecoveryText(String raw) { + final text = raw.trim(); + if (text.isEmpty) return null; + + final labeled = RegExp( + r'Claim key \(base64url\)[^\n]*:\s*\n([A-Za-z0-9_\-=]+)', + multiLine: true, + ).firstMatch(text); + if (labeled != null) return labeled.group(1)!.trim(); + + final line = text + .split(RegExp(r'\r?\n')) + .map((l) => l.trim()) + .firstWhere( + (l) => + l.length >= 40 && + RegExp(r'^[A-Za-z0-9_\-=]+$').hasMatch(l) && + !l.contains(' '), + orElse: () => '', + ); + if (line.isNotEmpty) return line; + + if (RegExp(r'^[A-Za-z0-9_\-=]{40,}$').hasMatch(text)) return text; + return null; +} + +/// Restores the signed-in account's claim key from a recovery kit / key string. +Future importRecoveryKit(String raw) async { + final user = AuthService.instance.user; + if (user == null) { + throw const AuthException('Not signed in.'); + } + final keyB64 = parseClaimKeyFromRecoveryText(raw); + if (keyB64 == null) { + throw const AuthException( + 'Could not find a claim key in that file. Paste a Signata recovery kit.', + ); + } + final kid = await AccountClaimKeys.importForCurrentUser(keyB64); + return kid; +} + +/// Erases all on-device Signata data for [user] (or the current user). +Future wipeLocalUserData({AuthUser? user}) async { + final target = user ?? AuthService.instance.user; + if (target == null) return; + + await HistoryStore.clearForUser(target.id); + await TraceStore.instance.clearForUser(target.id); + await ClaimRegistry.instance.clearForUser(target.id); + await UsageEntitlements.instance.clearForUser(target.id); + await AccountClaimKeys.deleteForUser(target.id); +} diff --git a/lib/core/lsb_scatter.dart b/lib/core/lsb_scatter.dart new file mode 100644 index 0000000..5dc956a --- /dev/null +++ b/lib/core/lsb_scatter.dart @@ -0,0 +1,49 @@ +/// Seeded bit-position scatter for LSB carriers (image RGB / audio PCM). +library; + +import 'dart:typed_data'; + +/// xorshift32 PRNG for deterministic bit-slot permutation. +class LsbScatter { + LsbScatter._(this._slots); + + final List _slots; + + /// [slotCount] addressable LSB positions (image: pixels*3, audio: samples). + factory LsbScatter.seeded({ + required int slotCount, + required int seed, + }) { + final slots = List.generate(slotCount, (i) => i); + var state = seed == 0 ? 0xA5A5A5A5 : seed; + int next() { + state ^= state << 13; + state &= 0xFFFFFFFF; + state ^= state >> 17; + state &= 0xFFFFFFFF; + state ^= state << 5; + state &= 0xFFFFFFFF; + return state; + } + + for (var i = slots.length - 1; i > 0; i--) { + final j = next() % (i + 1); + final tmp = slots[i]; + slots[i] = slots[j]; + slots[j] = tmp; + } + return LsbScatter._(slots); + } + + static int seedFromHeader(Uint8List header8) { + var h = 0x811c9dc5; + for (final b in header8) { + h = ((h ^ b) * 0x01000193) & 0xFFFFFFFF; + } + return h == 0 ? 1 : h; + } + + int get length => _slots.length; + + int operator [](int bitIndex) => _slots[bitIndex]; +} diff --git a/lib/core/network_reachability.dart b/lib/core/network_reachability.dart new file mode 100644 index 0000000..be47ca2 --- /dev/null +++ b/lib/core/network_reachability.dart @@ -0,0 +1,14 @@ +/// Lightweight connectivity probe (no extra package). +library; + +import 'dart:io'; + +Future hasNetworkReachability() async { + try { + final result = await InternetAddress.lookup('dns.google') + .timeout(const Duration(seconds: 3)); + return result.isNotEmpty && result.first.rawAddress.isNotEmpty; + } catch (_) { + return false; + } +} diff --git a/lib/core/onboarding.dart b/lib/core/onboarding.dart new file mode 100644 index 0000000..f397212 --- /dev/null +++ b/lib/core/onboarding.dart @@ -0,0 +1,88 @@ +/// Lightweight first-run checklist flags (per signed-in user). +library; + +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'auth.dart'; + +class OnboardingFlags { + OnboardingFlags._(); + static final instance = OnboardingFlags._(); + + static const _recoveryDismissed = 'signata_onboarding_recovery_dismissed'; + static const _recoveryExported = 'signata_onboarding_recovery_exported'; + static const _checklistDismissed = 'signata_onboarding_checklist_dismissed'; + static const _protectedOnce = 'signata_onboarding_protected_once'; + static const _tracedOnce = 'signata_onboarding_traced_once'; + + String get _userSuffix { + final id = AuthService.instance.user?.id ?? 'anonymous'; + return id.replaceAll(RegExp(r'[^a-zA-Z0-9._-]'), '_'); + } + + String _key(String base) => '${base}__$_userSuffix'; + + Future shouldPromptRecoveryExport() async { + final prefs = await SharedPreferences.getInstance(); + if (prefs.getBool(_key(_recoveryDismissed)) == true) return false; + if (prefs.getBool(_recoveryDismissed) == true) return false; + return true; + } + + Future markRecoveryHandled() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_key(_recoveryDismissed), true); + } + + Future markRecoveryExported() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_key(_recoveryExported), true); + await prefs.setBool(_key(_recoveryDismissed), true); + } + + Future shouldShowChecklist() async { + final prefs = await SharedPreferences.getInstance(); + if (prefs.getBool(_key(_checklistDismissed)) == true) return false; + final progress = await checklistProgress(); + return !(progress.recovery && progress.protected && progress.traced); + } + + Future<({bool recovery, bool protected, bool traced})> checklistProgress() async { + final prefs = await SharedPreferences.getInstance(); + return ( + recovery: prefs.getBool(_key(_recoveryExported)) == true, + protected: prefs.getBool(_key(_protectedOnce)) == true, + traced: prefs.getBool(_key(_tracedOnce)) == true, + ); + } + + Future dismissChecklist() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_key(_checklistDismissed), true); + } + + Future markProtectedOnce() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_key(_protectedOnce), true); + } + + Future markTracedOnce() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_key(_tracedOnce), true); + } +} + +/// Copies [text], then clears the clipboard after [delay] (best-effort). +Future copySecretTemporarily( + String text, { + Duration delay = const Duration(seconds: 45), +}) async { + await Clipboard.setData(ClipboardData(text: text)); + Future.delayed(delay, () async { + final current = await Clipboard.getData(Clipboard.kTextPlain); + if (current?.text == text) { + await Clipboard.setData(const ClipboardData(text: '')); + } + }); +} diff --git a/lib/core/pdf_fingerprint.dart b/lib/core/pdf_fingerprint.dart index a208f9a..4bd8252 100644 --- a/lib/core/pdf_fingerprint.dart +++ b/lib/core/pdf_fingerprint.dart @@ -1,8 +1,8 @@ /// PDF structural fingerprinting — Dart port of the website prototype. /// -/// The identifier format uses a trailing Signata marker (`%%Signata:` -/// trailing marker carrying base64 JSON), so documents fingerprinted in the -/// app verify on the website and vice versa. +/// Injects a comment claim before the last `%%EOF` and appends a trailing +/// Signata marker. Payloads carry HMAC structural signatures when an account +/// key is available. library; import 'dart:convert'; @@ -12,7 +12,10 @@ import 'dart:typed_data'; import 'package:crypto/crypto.dart' as crypto; import 'package:flutter/foundation.dart' show compute; +import 'claim_crypto.dart'; + const String pdfMark = '%%Signata:'; +const String pdfCommentMark = '% Signata-Claim: '; String _bytesToText(Uint8List bytes) => latin1.decode(bytes); @@ -49,8 +52,6 @@ class PdfStructure { final bool linearized; final bool encrypted; - /// Key order matches the website's `JSON.stringify(structure)` so digests - /// computed on either side agree. Map toJson() => { 'version': version, 'bytes': bytes, @@ -79,7 +80,6 @@ class PdfStructure { } } -/// Derives a structural profile from the raw PDF text. PdfStructure analyzePdf(String text, int byteLength) { final head = text.substring(0, math.min(9, text.length)); final version = @@ -96,7 +96,6 @@ PdfStructure analyzePdf(String text, int byteLength) { ); } -/// SHA-256 hex digest (uppercase), byte-compatible with the website's digest. String pdfDigest(String input) => crypto.sha256.convert(_textToBytes(input)).toString().toUpperCase(); @@ -106,22 +105,46 @@ class PdfPayload { required this.document, required this.issued, required this.structure, - required this.identifier, + required this.contentBytes, + this.signature, + this.alg, + this.kid, + this.version = 1, + this.identifier, }); final String owner; final String document; final String issued; final PdfStructure structure; - final String identifier; - - Map toJson() => { - 'owner': owner, - 'document': document, - 'issued': issued, - 'structure': structure.toJson(), - 'identifier': identifier, - }; + final int contentBytes; + final String? signature; + final String? alg; + final String? kid; + final int version; + + /// Legacy structural digest (uppercase SHA-256 hex). + final String? identifier; + + Map toJson() { + final map = { + 'owner': owner, + 'document': document, + 'issued': issued, + 'structure': structure.toJson(), + 'contentBytes': contentBytes, + }; + if (signature != null && signature!.isNotEmpty) { + map['signature'] = signature; + } + if (alg != null && alg!.isNotEmpty) map['alg'] = alg; + if (kid != null && kid!.isNotEmpty) map['kid'] = kid; + if (version > 1) map['v'] = version; + if (identifier != null && identifier!.isNotEmpty) { + map['identifier'] = identifier; + } + return map; + } static PdfPayload? tryParse(String? raw) { if (raw == null) return null; @@ -135,20 +158,64 @@ class PdfPayload { document: map['document'] as String? ?? '', issued: map['issued'] as String? ?? '', structure: structure, - identifier: map['identifier'] as String? ?? '', + contentBytes: (map['contentBytes'] as num?)?.toInt() ?? + (map['originalLength'] as num?)?.toInt() ?? + structure.bytes, + signature: map['signature'] as String?, + alg: map['alg'] as String?, + kid: map['kid'] as String?, + version: (map['v'] as num?)?.toInt() ?? 1, + identifier: map['identifier'] as String?, ); } catch (_) { return null; } } - String digestInput(int originalTextLength) => - '$owner|$document|$issued|${structure.canonical}|$originalTextLength'; + String digestInput() => + '$owner|$document|$issued|${structure.canonical}|$contentBytes'; + + String structuralIdentifier() => identifier ?? pdfDigest(digestInput()); + + ClaimStatus statusWith(ClaimKey? key, { + required String recheckId, + required bool structureMatch, + }) => + ClaimCrypto.evaluateStructural( + owner: owner, + document: document, + issued: issued, + structureCanonical: structure.canonical, + originalLength: contentBytes, + identifier: structuralIdentifier(), + recheckId: recheckId, + structureMatch: structureMatch, + signature: signature, + alg: alg, + kid: kid, + key: key, + ); } -/// Appends the identifier after %%EOF — trailing bytes are ignored by readers. +/// Injects a comment claim before the last `%%EOF`, then appends the trailing marker. Uint8List embedPdfIdentifier(Uint8List source, String payload) { final encoded = base64Encode(utf8.encode(payload)); + final text = _bytesToText(source); + final eofIndex = text.lastIndexOf('%%EOF'); + if (eofIndex >= 0) { + final comment = '$pdfCommentMark$encoded\n'; + final commentBytes = _textToBytes(comment); + final before = source.sublist(0, eofIndex); + final after = source.sublist(eofIndex); + final withComment = Uint8List( + before.length + commentBytes.length + after.length, + ); + withComment.setAll(0, before); + withComment.setAll(before.length, commentBytes); + withComment.setAll(before.length + commentBytes.length, after); + source = withComment; + } + final tail = _textToBytes('\n$pdfMark$encoded\n%%EOF\n'); final out = Uint8List(source.length + tail.length); out.setAll(0, source); @@ -156,7 +223,7 @@ Uint8List embedPdfIdentifier(Uint8List source, String payload) { return out; } -String? extractPdfIdentifier(String text) { +String? _extractTrailingMarker(String text) { final index = text.lastIndexOf(pdfMark); if (index == -1) return null; final start = index + pdfMark.length; @@ -169,7 +236,21 @@ String? extractPdfIdentifier(String text) { } } -/* --------------------------- high-level ops --------------------------- */ +String? _extractCommentMarker(String text) { + final index = text.lastIndexOf(pdfCommentMark); + if (index == -1) return null; + final start = index + pdfCommentMark.length; + final end = text.indexOf('\n', start); + final encoded = text.substring(start, end == -1 ? text.length : end).trim(); + try { + return utf8.decode(base64Decode(encoded)); + } catch (_) { + return null; + } +} + +String? extractPdfIdentifier(String text) => + _extractTrailingMarker(text) ?? _extractCommentMarker(text); class PdfEmbedOutcome { const PdfEmbedOutcome({ @@ -180,6 +261,8 @@ class PdfEmbedOutcome { required this.recheckId, required this.structureMatch, required this.verified, + required this.claimStatus, + required this.originalLength, }); final Uint8List markedBytes; @@ -189,6 +272,8 @@ class PdfEmbedOutcome { final String recheckId; final bool structureMatch; final bool verified; + final ClaimStatus claimStatus; + final int originalLength; } class PdfVerifyOutcome { @@ -198,6 +283,8 @@ class PdfVerifyOutcome { required this.recheckId, required this.structureMatch, required this.verified, + required this.claimStatus, + required this.originalLength, }); final PdfPayload? recovered; @@ -205,6 +292,8 @@ class PdfVerifyOutcome { final String recheckId; final bool structureMatch; final bool verified; + final ClaimStatus claimStatus; + final int originalLength; } class NotAPdfException implements Exception { @@ -214,54 +303,162 @@ class NotAPdfException implements Exception { String toString() => 'That file is not a valid PDF document.'; } -PdfEmbedOutcome _pdfEmbedTask( - ({Uint8List source, String owner, String fileName, String issued}) args) { - final (:source, :owner, :fileName, :issued) = args; - final text = _bytesToText(source); - if (!text.startsWith('%PDF-')) throw const NotAPdfException(); +ClaimKey? _claimKeyFromArgs(Uint8List? keyBytes, String? kid) { + if (keyBytes == null || keyBytes.length != 32) return null; + return ClaimKey(bytes: keyBytes, kid: kid ?? ClaimKey.kidFor(keyBytes)); +} - final structure = analyzePdf(text, source.length); +PdfPayload _buildPayload({ + required String owner, + required String fileName, + required String issued, + required PdfStructure structure, + required int contentBytes, + ClaimKey? claimKey, +}) { final resolvedOwner = owner.trim().isEmpty ? 'Anonymous creator' : owner.trim(); - final identifier = pdfDigest( - '$resolvedOwner|$fileName|$issued|${structure.canonical}|${text.length}', + '$resolvedOwner|$fileName|$issued|${structure.canonical}|$contentBytes', ); - final payload = PdfPayload( + + if (claimKey != null) { + final signed = ClaimCrypto.signStructural( + claimKey, + owner: resolvedOwner, + document: fileName, + issued: issued, + structureCanonical: structure.canonical, + originalLength: contentBytes, + identifier: identifier, + ); + return PdfPayload( + owner: resolvedOwner, + document: fileName, + issued: issued, + structure: structure, + contentBytes: contentBytes, + signature: signed.signature, + alg: signed.alg, + kid: signed.kid, + version: signed.version, + identifier: identifier, + ); + } + + return PdfPayload( owner: resolvedOwner, document: fileName, issued: issued, structure: structure, + contentBytes: contentBytes, identifier: identifier, + version: 1, ); +} - final marked = embedPdfIdentifier(source, jsonEncode(payload.toJson())); +({String recheckId, bool structureMatch, ClaimStatus claimStatus, bool verified}) + _evaluatePayload( + PdfPayload payload, + Uint8List source, + ClaimKey? claimKey, +) { + final originalLength = payload.contentBytes; + if (originalLength <= 0 || originalLength > source.length) { + return ( + recheckId: '—', + structureMatch: false, + claimStatus: ClaimStatus.present, + verified: false, + ); + } - // Read back from the delivered file, exactly as a verifier would. + final originalBytes = source.sublist(0, originalLength); + final originalText = _bytesToText(originalBytes); + final structure = analyzePdf(originalText, originalLength); + final recheckId = pdfDigest(payload.digestInput()); + final structureMatch = + structure.canonical == payload.structure.canonical; + final claimStatus = payload.statusWith( + claimKey, + recheckId: recheckId, + structureMatch: structureMatch, + ); + final verified = claimStatus == ClaimStatus.authenticated || + claimStatus == ClaimStatus.selfConsistent; + + return ( + recheckId: recheckId, + structureMatch: structureMatch, + claimStatus: claimStatus, + verified: verified, + ); +} + +PdfEmbedOutcome _pdfEmbedTask( + ({ + Uint8List source, + String owner, + String fileName, + String issued, + Uint8List? keyBytes, + String? kid, + }) args, +) { + final (:source, :owner, :fileName, :issued, :keyBytes, :kid) = args; + final claimKey = _claimKeyFromArgs(keyBytes, kid); + final text = _bytesToText(source); + if (!text.startsWith('%PDF-')) throw const NotAPdfException(); + + final contentBytes = source.length; + final structure = analyzePdf(text, contentBytes); + final payload = _buildPayload( + owner: owner, + fileName: fileName, + issued: issued, + structure: structure, + contentBytes: contentBytes, + claimKey: claimKey, + ); + + final marked = embedPdfIdentifier(source, jsonEncode(payload.toJson())); final deliveredText = _bytesToText(marked); final raw = extractPdfIdentifier(deliveredText); final recovered = PdfPayload.tryParse(raw); - final recheckId = - recovered == null ? '—' : pdfDigest(recovered.digestInput(text.length)); - final structureMatch = - recovered != null && recovered.structure.canonical == structure.canonical; - final verified = recovered != null && - recheckId == recovered.identifier && - structureMatch; + if (recovered == null) { + return PdfEmbedOutcome( + markedBytes: marked, + payload: payload, + recovered: null, + raw: raw, + recheckId: '—', + structureMatch: false, + verified: false, + claimStatus: ClaimStatus.missing, + originalLength: contentBytes, + ); + } + final eval = _evaluatePayload(recovered, marked, claimKey); return PdfEmbedOutcome( markedBytes: marked, payload: payload, recovered: recovered, raw: raw, - recheckId: recheckId, - structureMatch: structureMatch, - verified: verified, + recheckId: eval.recheckId, + structureMatch: eval.structureMatch, + verified: eval.verified, + claimStatus: eval.claimStatus, + originalLength: contentBytes, ); } -PdfVerifyOutcome _pdfVerifyTask(Uint8List source) { +PdfVerifyOutcome _pdfVerifyTask( + ({Uint8List source, Uint8List? keyBytes, String? kid}) args, +) { + final (:source, :keyBytes, :kid) = args; + final claimKey = _claimKeyFromArgs(keyBytes, kid); final text = _bytesToText(source); if (!text.startsWith('%PDF-')) throw const NotAPdfException(); @@ -274,41 +471,52 @@ PdfVerifyOutcome _pdfVerifyTask(Uint8List source) { recheckId: '—', structureMatch: false, verified: false, + claimStatus: ClaimStatus.missing, + originalLength: 0, ); } - // The marker tail starts with "\n%%Signata:", so everything before it is - // the original document as it existed when the identifier was derived. - final markIndex = text.lastIndexOf(pdfMark); - final originalLength = markIndex > 0 ? markIndex - 1 : text.length; - final originalText = text.substring(0, originalLength); - - final structure = analyzePdf(originalText, originalLength); - final recheckId = pdfDigest(recovered.digestInput(originalLength)); - final structureMatch = recovered.structure.canonical == structure.canonical; - final verified = recheckId == recovered.identifier && structureMatch; - + final eval = _evaluatePayload(recovered, source, claimKey); return PdfVerifyOutcome( recovered: recovered, raw: raw, - recheckId: recheckId, - structureMatch: structureMatch, - verified: verified, + recheckId: eval.recheckId, + structureMatch: eval.structureMatch, + verified: eval.verified, + claimStatus: eval.claimStatus, + originalLength: recovered.contentBytes, ); } -/// Fingerprints [source] and round-trip verifies the delivered file. Future embedPdfFingerprint({ required Uint8List source, required String owner, required String fileName, + ClaimKey? claimKey, }) { final issued = DateTime.now().toUtc().toIso8601String(); - return compute(_pdfEmbedTask, - (source: source, owner: owner, fileName: fileName, issued: issued)); + return compute( + _pdfEmbedTask, + ( + source: source, + owner: owner, + fileName: fileName, + issued: issued, + keyBytes: claimKey?.bytes, + kid: claimKey?.kid, + ), + ); } -/// Verifies a standalone PDF: recovers the identifier, recomputes the digest -/// and structural profile from the document itself, and compares. -Future verifyPdfFingerprint(Uint8List source) => - compute(_pdfVerifyTask, source); +Future verifyPdfFingerprint( + Uint8List source, { + ClaimKey? claimKey, +}) => + compute( + _pdfVerifyTask, + ( + source: source, + keyBytes: claimKey?.bytes, + kid: claimKey?.kid, + ), + ); diff --git a/lib/core/report.dart b/lib/core/report.dart index 4054821..60fdff2 100644 --- a/lib/core/report.dart +++ b/lib/core/report.dart @@ -1,7 +1,8 @@ -/// Tamper-evident verification report — Dart port of the website's -/// `em-report.ts`. The body is canonicalised (deterministic key order), -/// hashed with SHA-256 and sealed with a keyed digest; any edit to the -/// exported JSON invalidates the seal. +/// Tamper-evident verification report. +/// +/// When an account [ClaimKey] is supplied, the seal uses HMAC with that key +/// (kid recorded in the seal). Without a key, a legacy nonce-HMAC is used and +/// marked `binding: unbound` — anyone can reseal those. library; import 'dart:convert'; @@ -10,6 +11,8 @@ import 'dart:typed_data'; import 'package:crypto/crypto.dart' as crypto; +import 'claim_crypto.dart'; + const String reportVersion = 'signata-report/1'; /// Deterministic JSON: object keys sorted recursively. @@ -34,6 +37,12 @@ String _hmacHex(String key, String text) => .toString() .toUpperCase(); +String _hmacHexBytes(Uint8List key, String text) => + crypto.Hmac(crypto.sha256, key) + .convert(utf8.encode(text)) + .toString() + .toUpperCase(); + class ReportBody { const ReportBody({ required this.medium, @@ -91,18 +100,34 @@ String _nonce() { .toUpperCase(); } -/// Builds a sealed, verifiable report from a body. -SealedReport sealReport(ReportBody body) { +/// Builds a sealed report. Prefer passing [claimKey] so the seal is +/// account-bound and not forgeable with only the report JSON. +SealedReport sealReport(ReportBody body, {ClaimKey? claimKey}) { final nonce = _nonce(); final canonical = canonicalize(body.toJson()); final bodyDigest = _sha256Hex(canonical); - final signature = - _hmacHex('$reportVersion|$nonce', '$canonical|$bodyDigest'); + final String signature; + final String binding; + String? kid; + if (claimKey != null) { + signature = _hmacHexBytes( + claimKey.bytes, + '$reportVersion|$nonce|$canonical|$bodyDigest', + ); + binding = 'account-key'; + kid = claimKey.kid; + } else { + signature = + _hmacHex('$reportVersion|$nonce', '$canonical|$bodyDigest'); + binding = 'unbound'; + } return SealedReport( body: body, seal: { 'algorithm': 'SHA-256 + HMAC-SHA-256', 'canonicalization': 'sorted-keys-json', + 'binding': binding, + if (kid != null) 'kid': kid, 'nonce': nonce, 'bodyDigest': bodyDigest, 'signature': signature, @@ -113,14 +138,29 @@ SealedReport sealReport(ReportBody body) { } /// Recomputes the seal — returns false if the body was altered. -bool verifySealedReport(SealedReport sealed) { +/// For account-bound seals, pass the same [claimKey] used to seal. +bool verifySealedReport(SealedReport sealed, {ClaimKey? claimKey}) { final canonical = canonicalize(sealed.body.toJson()); final bodyDigest = _sha256Hex(canonical); if (bodyDigest != sealed.seal['bodyDigest']) return false; - final signature = _hmacHex( - '$reportVersion|${sealed.seal['nonce']}', - '$canonical|$bodyDigest', - ); + final nonce = sealed.seal['nonce'] as String? ?? ''; + final binding = sealed.seal['binding'] as String? ?? 'unbound'; + final String signature; + if (binding == 'account-key') { + if (claimKey == null) return false; + if (sealed.seal['kid'] != null && sealed.seal['kid'] != claimKey.kid) { + return false; + } + signature = _hmacHexBytes( + claimKey.bytes, + '$reportVersion|$nonce|$canonical|$bodyDigest', + ); + } else { + signature = _hmacHex( + '$reportVersion|$nonce', + '$canonical|$bodyDigest', + ); + } return signature == sealed.seal['signature']; } diff --git a/lib/core/safe_url.dart b/lib/core/safe_url.dart new file mode 100644 index 0000000..2c55674 --- /dev/null +++ b/lib/core/safe_url.dart @@ -0,0 +1,134 @@ +/// OWASP-oriented URL guards for Trace and optional claim registry. +library; + +/// Result of validating a user-supplied http(s) URL. +class SafeUrlResult { + const SafeUrlResult.ok(this.uri) : error = null; + const SafeUrlResult.err(this.error) : uri = null; + + final Uri? uri; + final String? error; + + bool get isOk => uri != null; +} + +/// Blocks private networks, metadata endpoints, and non-http(s) schemes (SSRF). +class SafeUrl { + SafeUrl._(); + + static const blockedHostnames = { + 'localhost', + 'localhost.localdomain', + 'metadata.google.internal', + }; + + /// Public http(s) URL suitable for Trace downloads. + static SafeUrlResult parseTraceUrl(String raw) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) { + return const SafeUrlResult.err('Enter a URL.'); + } + final uri = Uri.tryParse(trimmed); + if (uri == null) { + return const SafeUrlResult.err('Enter a valid http(s) URL.'); + } + if (uri.scheme != 'http' && uri.scheme != 'https') { + return const SafeUrlResult.err('Only http and https URLs are allowed.'); + } + if (uri.userInfo.isNotEmpty) { + return const SafeUrlResult.err('URLs with embedded credentials are not allowed.'); + } + final host = uri.host.toLowerCase(); + if (host.isEmpty) { + return const SafeUrlResult.err('Enter a valid http(s) URL.'); + } + if (blockedHostnames.contains(host) || host.endsWith('.local')) { + return const SafeUrlResult.err('That URL points to a private or local host.'); + } + if (_isPrivateOrReservedHost(host)) { + return const SafeUrlResult.err('Private or internal network URLs are not allowed.'); + } + return SafeUrlResult.ok(uri); + } + + /// Remote registry base must be https and not point at private networks. + static SafeUrlResult parseRegistryBase(String raw) { + final trimmed = raw.trim().replaceAll(RegExp(r'/+$'), ''); + if (trimmed.isEmpty) { + return const SafeUrlResult.err('Registry URL is empty.'); + } + final uri = Uri.tryParse(trimmed); + if (uri == null) { + return const SafeUrlResult.err('Enter a valid registry URL.'); + } + if (uri.scheme != 'https') { + return const SafeUrlResult.err('Registry URL must use https.'); + } + if (uri.userInfo.isNotEmpty) { + return const SafeUrlResult.err('Registry URL cannot include credentials.'); + } + final host = uri.host.toLowerCase(); + if (host.isEmpty) { + return const SafeUrlResult.err('Enter a valid registry URL.'); + } + if (blockedHostnames.contains(host) || host.endsWith('.local')) { + return const SafeUrlResult.err('Registry URL cannot point to a local host.'); + } + if (_isPrivateOrReservedHost(host)) { + return const SafeUrlResult.err('Registry URL cannot point to a private network.'); + } + return SafeUrlResult.ok(uri); + } + + static bool _isPrivateOrReservedHost(String host) { + if (host == '::1' || host.startsWith('fe80:') || host.startsWith('fc') || host.startsWith('fd')) { + return true; + } + final ip = _parseIpv4(host); + if (ip == null) return false; + final a = ip[0]; + final b = ip[1]; + if (a == 127) return true; + if (a == 10) return true; + if (a == 0) return true; + if (a == 169 && b == 254) return true; + if (a == 192 && b == 168) return true; + if (a == 172 && b >= 16 && b <= 31) return true; + if (a == 100 && b >= 64 && b <= 127) return true; // CGNAT + return false; + } + + static List? _parseIpv4(String host) { + final parts = host.split('.'); + if (parts.length != 4) return null; + final out = []; + for (final part in parts) { + final n = int.tryParse(part); + if (n == null || n < 0 || n > 255) return null; + out.add(n); + } + return out; + } +} + +/// Simple in-process rate limiter for Trace scans (per device session). +class TraceRateLimiter { + TraceRateLimiter._(); + + static final TraceRateLimiter instance = TraceRateLimiter._(); + + static const maxScansPerHour = 40; + static const window = Duration(hours: 1); + + final List _events = []; + + bool allow() { + final now = DateTime.now().toUtc(); + _events.removeWhere((t) => now.difference(t) > window); + if (_events.length >= maxScansPerHour) return false; + _events.add(now); + return true; + } + + void resetForTests() => _events.clear(); +} diff --git a/lib/core/secure_store.dart b/lib/core/secure_store.dart new file mode 100644 index 0000000..432ef6e --- /dev/null +++ b/lib/core/secure_store.dart @@ -0,0 +1,12 @@ +/// Shared encrypted on-device storage for auth, claim keys, and secrets. +library; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// flutter_secure_storage 11 uses Keystore-backed AES-GCM by default on Android. +const FlutterSecureStorage signataSecureStorage = FlutterSecureStorage( + aOptions: AndroidOptions(), + iOptions: IOSOptions( + accessibility: KeychainAccessibility.first_unlock_this_device, + ), +); diff --git a/lib/core/share_ingress.dart b/lib/core/share_ingress.dart new file mode 100644 index 0000000..76a503c --- /dev/null +++ b/lib/core/share_ingress.dart @@ -0,0 +1,124 @@ +/// Incoming shares from Instagram, TikTok, X, and the system share sheet. +/// +/// [start] begins listening early (before login). Shares are buffered until +/// [attach] delivers them to [AppShell] / Trace after the user is ready. +library; + +import 'dart:async'; +import 'dart:io'; + +import 'package:receive_sharing_intent/receive_sharing_intent.dart'; + +class SharedIngressFile { + const SharedIngressFile({ + required this.path, + required this.fileName, + this.mimeType, + this.text, + }); + + final String path; + final String fileName; + final String? mimeType; + final String? text; +} + +typedef SharedIngressHandler = void Function(List files); + +/// Listens for media/text shared into Signata from other apps. +class ShareIngress { + ShareIngress._(); + static final ShareIngress instance = ShareIngress._(); + + StreamSubscription>? _sub; + SharedIngressHandler? _handler; + final List _buffer = []; + bool _started = false; + bool _initialConsumed = false; + + /// Call once from [main] so cold-start shares survive login / email activate. + void start() { + if (_started) return; + _started = true; + _sub?.cancel(); + _sub = ReceiveSharingIntent.instance.getMediaStream().listen( + _onRaw, + onError: (_) {}, + ); + ReceiveSharingIntent.instance.getInitialMedia().then((files) { + if (files.isEmpty) return; + _onRaw(files); + }).catchError((_) {}); + } + + void attach(SharedIngressHandler handler) { + _handler = handler; + if (_buffer.isEmpty) return; + final pending = List.from(_buffer); + _buffer.clear(); + _deliver(pending); + } + + void detach() { + _handler = null; + } + + void dispose() { + _sub?.cancel(); + _sub = null; + _handler = null; + _buffer.clear(); + _started = false; + _initialConsumed = false; + } + + void _onRaw(List files) { + final mapped = _map(files); + if (mapped.isEmpty) return; + if (_handler != null) { + _deliver(mapped); + } else { + _buffer + ..clear() + ..addAll(mapped); + } + } + + void _deliver(List files) { + final handler = _handler; + if (handler == null || files.isEmpty) return; + handler(files); + if (!_initialConsumed) { + _initialConsumed = true; + try { + ReceiveSharingIntent.instance.reset(); + } catch (_) {} + } + } + + List _map(List files) { + final mapped = []; + for (final file in files) { + final path = file.path; + if (path.isEmpty) continue; + if (file.type == SharedMediaType.text || + file.type == SharedMediaType.url) { + final text = file.path; + if (text.trim().isEmpty) continue; + mapped.add(SharedIngressFile( + path: '', + fileName: 'shared-link.txt', + mimeType: 'text/plain', + text: text.trim(), + )); + continue; + } + mapped.add(SharedIngressFile( + path: path, + fileName: path.split(Platform.pathSeparator).last, + mimeType: file.mimeType, + )); + } + return mapped; + } +} diff --git a/lib/core/share_utils.dart b/lib/core/share_utils.dart index 451068f..56f2dea 100644 --- a/lib/core/share_utils.dart +++ b/lib/core/share_utils.dart @@ -1,11 +1,15 @@ import 'dart:io'; import 'dart:typed_data'; +import 'package:file_picker/file_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; -/// Writes [bytes] to a temp file and opens the native share sheet, letting -/// the user save to Files, AirDrop, message, etc. +import 'social_platforms.dart'; +import 'social_protect.dart'; + +/// Writes [bytes] to a temp file and opens the native share sheet, then +/// deletes the temp file. Future shareBytes({ required Uint8List bytes, required String fileName, @@ -13,12 +17,118 @@ Future shareBytes({ String? text, }) async { final dir = await getTemporaryDirectory(); - final file = File('${dir.path}${Platform.pathSeparator}$fileName'); - await file.writeAsBytes(bytes, flush: true); - await SharePlus.instance.share( - ShareParams( - files: [XFile(file.path, mimeType: mimeType)], - text: text, - ), + final stamp = DateTime.now().microsecondsSinceEpoch; + final file = File( + '${dir.path}${Platform.pathSeparator}signata-$stamp-$fileName', ); + await file.writeAsBytes(bytes, flush: true); + try { + await SharePlus.instance.share( + ShareParams( + files: [XFile(file.path, mimeType: mimeType)], + text: text, + ), + ); + } finally { + try { + if (await file.exists()) await file.delete(); + } catch (_) {} + } +} + +/// Shares one or more fingerprinted social assets, then optionally opens the +/// target app (Instagram / TikTok / X) so the user can finish posting. +Future shareProtectedToSocial({ + required SocialPlatformInfo platform, + required List items, + String? caption, + bool openAppAfterShare = true, +}) async { + if (items.isEmpty) return; + final dir = await getTemporaryDirectory(); + final stamp = DateTime.now().microsecondsSinceEpoch; + final written = []; + final files = []; + for (final item in items) { + final path = + '${dir.path}${Platform.pathSeparator}signata-$stamp-${platform.shortLabel}-${item.fileName}'; + final file = File(path); + await file.writeAsBytes(item.bytes, flush: true); + written.add(file); + files.add(XFile(file.path, mimeType: item.mimeType, name: item.fileName)); + } + + try { + await SharePlus.instance.share( + ShareParams( + files: files, + text: caption ?? + 'Protected with Signata · ready for ${platform.label}', + subject: 'Signata · ${platform.label}', + ), + ); + + if (openAppAfterShare) { + try { + await platform.openApp(); + } catch (_) {} + } + } finally { + for (final file in written) { + try { + if (await file.exists()) await file.delete(); + } catch (_) {} + } + } +} + +/// Saves [bytes] via the native save/download dialog. +/// +/// Falls back to the Downloads (or app Documents) folder if the dialog is +/// unavailable. Returns the saved file path. +Future downloadBytes({ + required Uint8List bytes, + required String fileName, + String? dialogTitle, + List? allowedExtensions, +}) async { + final ext = _extensionOf(fileName); + + try { + final path = await FilePicker.saveFile( + dialogTitle: dialogTitle ?? 'Download fingerprinted file', + fileName: fileName, + type: ext == null ? FileType.any : FileType.custom, + allowedExtensions: ext == null ? null : (allowedExtensions ?? [ext]), + bytes: bytes, + ); + if (path != null && path.isNotEmpty) { + return path; + } + } catch (_) { + // Fall through to a direct Downloads/Documents write. + } + + final dir = await getDownloadsDirectory() ?? + await getApplicationDocumentsDirectory(); + await dir.create(recursive: true); + var target = File('${dir.path}${Platform.pathSeparator}$fileName'); + if (await target.exists()) { + final stamp = DateTime.now().millisecondsSinceEpoch; + final base = fileName.contains('.') + ? fileName.substring(0, fileName.lastIndexOf('.')) + : fileName; + final suffix = ext == null ? '' : '.$ext'; + target = File( + '${dir.path}${Platform.pathSeparator}$base-$stamp$suffix', + ); + } + await target.writeAsBytes(bytes, flush: true); + return target.path; +} + +String? _extensionOf(String fileName) { + final dot = fileName.lastIndexOf('.'); + if (dot <= 0 || dot == fileName.length - 1) return null; + return fileName.substring(dot + 1).toLowerCase(); } diff --git a/lib/core/social_platforms.dart b/lib/core/social_platforms.dart new file mode 100644 index 0000000..ab06a62 --- /dev/null +++ b/lib/core/social_platforms.dart @@ -0,0 +1,315 @@ +/// Social surfaces Signata can protect media for and scan links from. +library; + +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:url_launcher/url_launcher.dart'; + +import 'safe_url.dart'; + +enum SocialPlatform { instagram, tiktok, x, other } + +class SocialPlatformInfo { + const SocialPlatformInfo({ + required this.id, + required this.label, + required this.shortLabel, + required this.hint, + required this.androidPackages, + required this.urlSchemes, + required this.hostMatchers, + required this.accent, + required this.icon, + }); + + final SocialPlatform id; + final String label; + final String shortLabel; + final String hint; + final List androidPackages; + final List urlSchemes; + final List hostMatchers; + final int accent; // ARGB + final String icon; // material icon name key used by UI + + static const instagram = SocialPlatformInfo( + id: SocialPlatform.instagram, + label: 'Instagram', + shortLabel: 'IG', + hint: 'Best: share the Reel/photo into Signata. Post URLs often hide the real file.', + androidPackages: ['com.instagram.android'], + urlSchemes: ['instagram'], + hostMatchers: ['instagram.com', 'www.instagram.com', 'instagr.am'], + accent: 0xFFE1306C, + icon: 'photo_camera_outlined', + ); + + static const tiktok = SocialPlatformInfo( + id: SocialPlatform.tiktok, + label: 'TikTok', + shortLabel: 'TT', + hint: 'Best: share the video file into Signata. TikTok page links rarely expose media.', + androidPackages: [ + 'com.zhiliaoapp.musically', + 'com.ss.android.ugc.trill', + 'com.ss.android.ugc.tiktok', + ], + urlSchemes: ['tiktok', 'snssdk1128'], + hostMatchers: [ + 'tiktok.com', + 'www.tiktok.com', + 'vm.tiktok.com', + 'vt.tiktok.com', + 'm.tiktok.com', + ], + accent: 0xFF25F4EE, + icon: 'music_note_outlined', + ); + + static const x = SocialPlatformInfo( + id: SocialPlatform.x, + label: 'X', + shortLabel: 'X', + hint: 'Best: share the image/clip into Signata. X post links are a fallback only.', + androidPackages: ['com.twitter.android'], + urlSchemes: ['twitter', 'x'], + hostMatchers: [ + 'x.com', + 'www.x.com', + 'twitter.com', + 'www.twitter.com', + 'mobile.twitter.com', + 't.co', + ], + accent: 0xFFEFF3F4, + icon: 'tag_outlined', + ); + + static const all = [instagram, tiktok, x]; + + static SocialPlatformInfo? fromUrl(String rawUrl) { + final host = Uri.tryParse(rawUrl.trim())?.host.toLowerCase() ?? ''; + for (final platform in all) { + for (final matcher in platform.hostMatchers) { + if (host == matcher || host.endsWith('.$matcher')) { + return platform; + } + } + } + return null; + } + + Future openApp() async { + for (final scheme in urlSchemes) { + final uri = Uri.parse('$scheme://'); + if (await canLaunchUrl(uri)) { + return launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + // Fall back to the public web home. + final web = switch (id) { + SocialPlatform.instagram => Uri.parse('https://www.instagram.com/'), + SocialPlatform.tiktok => Uri.parse('https://www.tiktok.com/'), + SocialPlatform.x => Uri.parse('https://x.com/'), + SocialPlatform.other => null, + }; + if (web == null) return false; + return launchUrl(web, mode: LaunchMode.externalApplication); + } +} + +/// Tries to turn a social post page into a direct media URL via Open Graph tags. +class SocialMediaResolver { + SocialMediaResolver._(); + static final SocialMediaResolver instance = SocialMediaResolver._(); + + static const _ua = + 'Mozilla/5.0 (compatible; SignataTrace/1.0; +https://signata.app)'; + + Future resolve(String rawUrl) async { + final parsed = SafeUrl.parseTraceUrl(rawUrl); + if (!parsed.isOk) { + final platform = SocialPlatformInfo.fromUrl(rawUrl); + if (platform == null) return null; + return ResolvedSocialMedia( + platform: platform, + pageUrl: rawUrl.trim(), + mediaUrl: null, + note: parsed.error, + ); + } + final url = parsed.uri!.toString(); + final platform = SocialPlatformInfo.fromUrl(url); + if (platform == null) return null; + + try { + final response = await http + .get( + parsed.uri!, + headers: { + 'User-Agent': _ua, + 'Accept': 'text/html,application/xhtml+xml', + }, + ) + .timeout(const Duration(seconds: 25)); + if (response.statusCode < 200 || response.statusCode >= 400) { + return ResolvedSocialMedia( + platform: platform, + pageUrl: url, + mediaUrl: null, + note: + 'Could not open that ${platform.label} page (${response.statusCode}). ' + 'Share the media file into Signata instead.', + ); + } + + final html = utf8.decode(response.bodyBytes, allowMalformed: true); + final mediaUrl = _firstMatch(html, const [ + r'property="og:video:secure_url"\s+content="([^"]+)"', + r"property='og:video:secure_url'\s+content='([^']+)'", + r'property="og:video"\s+content="([^"]+)"', + r"property='og:video'\s+content='([^']+)'", + r'content="([^"]+)"\s+property="og:video"', + r'name="twitter:player:stream"\s+content="([^"]+)"', + r'property="og:image:secure_url"\s+content="([^"]+)"', + r'property="og:image"\s+content="([^"]+)"', + r"property='og:image'\s+content='([^']+)'", + r'content="([^"]+)"\s+property="og:image"', + r'name="twitter:image"\s+content="([^"]+)"', + ]) ?? + _jsonLdMedia(html); + + if (mediaUrl == null || mediaUrl.isEmpty) { + return ResolvedSocialMedia( + platform: platform, + pageUrl: url, + mediaUrl: null, + note: + '${platform.label} did not expose a public media file for this link. ' + 'In the app: Share → Signata, or save the file and Protect & post here.', + ); + } + + final absolute = _absolutize(mediaUrl, url); + final mediaParsed = SafeUrl.parseTraceUrl(absolute); + if (!mediaParsed.isOk) { + return ResolvedSocialMedia( + platform: platform, + pageUrl: url, + mediaUrl: null, + note: mediaParsed.error ?? 'Resolved media URL was blocked for security.', + ); + } + + return ResolvedSocialMedia( + platform: platform, + pageUrl: url, + mediaUrl: mediaParsed.uri!.toString(), + note: null, + ); + } catch (error) { + return ResolvedSocialMedia( + platform: platform, + pageUrl: url, + mediaUrl: null, + note: error.toString().replaceFirst('Exception: ', ''), + ); + } + } + + static String? _firstMatch(String html, List patterns) { + for (final pattern in patterns) { + final match = + RegExp(pattern, caseSensitive: false).firstMatch(html)?.group(1); + if (match != null && match.trim().isNotEmpty) { + return match.trim().replaceAll('&', '&'); + } + } + return null; + } + + static String? _jsonLdMedia(String html) { + final blocks = RegExp( + r']+type="application/ld\+json"[^>]*>([\s\S]*?)', + caseSensitive: false, + ).allMatches(html); + for (final block in blocks) { + final raw = block.group(1)?.trim(); + if (raw == null || raw.isEmpty) continue; + try { + final decoded = jsonDecode(raw); + final found = _walkForMedia(decoded); + if (found != null) return found; + } catch (_) {} + } + return null; + } + + static String? _walkForMedia(Object? node) { + if (node is Map) { + for (final key in ['contentUrl', 'url', 'image', 'thumbnailUrl']) { + final value = node[key]; + if (value is String && + (value.startsWith('http://') || value.startsWith('https://')) && + _looksLikeMedia(value)) { + return value; + } + if (value is List) { + for (final item in value) { + final nested = _walkForMedia(item); + if (nested != null) return nested; + } + } + if (value is Map) { + final nested = _walkForMedia(value); + if (nested != null) return nested; + } + } + for (final value in node.values) { + final nested = _walkForMedia(value); + if (nested != null) return nested; + } + } else if (node is List) { + for (final item in node) { + final nested = _walkForMedia(item); + if (nested != null) return nested; + } + } + return null; + } + + static bool _looksLikeMedia(String url) { + final lower = url.toLowerCase(); + return lower.contains('.mp4') || + lower.contains('.mov') || + lower.contains('.jpg') || + lower.contains('.jpeg') || + lower.contains('.png') || + lower.contains('.webp') || + lower.contains('video') || + lower.contains('image') || + lower.contains('media'); + } + + static String _absolutize(String mediaUrl, String pageUrl) { + final media = Uri.tryParse(mediaUrl); + if (media == null) return mediaUrl; + if (media.hasScheme) return mediaUrl; + return Uri.parse(pageUrl).resolveUri(media).toString(); + } +} + +class ResolvedSocialMedia { + const ResolvedSocialMedia({ + required this.platform, + required this.pageUrl, + required this.mediaUrl, + required this.note, + }); + + final SocialPlatformInfo platform; + final String pageUrl; + final String? mediaUrl; + final String? note; +} diff --git a/lib/core/social_protect.dart b/lib/core/social_protect.dart new file mode 100644 index 0000000..3df7d3c --- /dev/null +++ b/lib/core/social_protect.dart @@ -0,0 +1,203 @@ +/// Batch-fingerprint media destined for Instagram, TikTok, or X. +library; + +import 'dart:typed_data'; + +import 'audio_watermark.dart'; +import 'claim_crypto.dart'; +import 'claim_publisher.dart'; +import 'claim_status_ui.dart'; +import 'history.dart'; +import 'image_watermark.dart'; +import 'trace_models.dart'; +import 'video_fingerprint.dart'; + +class SocialProtectItem { + const SocialProtectItem({ + required this.fileName, + required this.bytes, + required this.mimeType, + required this.medium, + required this.reference, + required this.owner, + required this.subject, + required this.claimStatus, + }); + + final String fileName; + final Uint8List bytes; + final String mimeType; + final TraceMedium medium; + final String reference; + final String owner; + final String subject; + final ClaimStatus claimStatus; +} + +class SocialProtectService { + SocialProtectService._(); + static final SocialProtectService instance = SocialProtectService._(); + + Future> protectMany({ + required List<({String name, Uint8List bytes})> files, + required String owner, + bool publishClaims = true, + }) async { + final claimKey = await AccountClaimKeys.current(); + final out = []; + + for (final file in files) { + final medium = _detectMedium(file.name, file.bytes); + if (medium == TraceMedium.unknown || medium == TraceMedium.pdf) { + throw Exception( + 'Unsupported file for social posting: ${file.name}. ' + 'Use an image, video, or WAV audio clip.', + ); + } + + late final SocialProtectItem item; + switch (medium) { + case TraceMedium.image: + final outcome = await embedWatermark( + fileBytes: file.bytes, + owner: owner, + fileName: file.name, + claimKey: claimKey, + ); + item = SocialProtectItem( + fileName: _withExt(file.name, 'png'), + bytes: outcome.markedPng, + mimeType: 'image/png', + medium: TraceMedium.image, + reference: outcome.payload.signature, + owner: outcome.payload.owner, + subject: outcome.payload.asset, + claimStatus: outcome.claimStatus, + ); + case TraceMedium.video: + final outcome = await embedVideoFingerprint( + source: file.bytes, + owner: owner, + fileName: file.name, + claimKey: claimKey, + ); + item = SocialProtectItem( + fileName: file.name, + bytes: outcome.markedBytes, + mimeType: 'video/mp4', + medium: TraceMedium.video, + reference: outcome.payload.structuralIdentifier(), + owner: outcome.payload.owner, + subject: outcome.payload.document, + claimStatus: outcome.claimStatus, + ); + case TraceMedium.audio: + final outcome = await embedAudioWatermark( + fileBytes: file.bytes, + owner: owner, + fileName: file.name, + claimKey: claimKey, + ); + item = SocialProtectItem( + fileName: _withExt(file.name, 'wav'), + bytes: outcome.markedWav, + mimeType: 'audio/wav', + medium: TraceMedium.audio, + reference: outcome.payload.signature, + owner: outcome.payload.owner, + subject: outcome.payload.asset, + claimStatus: outcome.claimStatus, + ); + case TraceMedium.pdf: + case TraceMedium.unknown: + throw StateError('unreachable'); + } + + out.add(item); + await HistoryStore.add(HistoryEntry( + medium: item.medium.wire, + action: 'embed', + subject: item.fileName, + owner: item.owner, + verified: claimCountsAsVerified(item.claimStatus), + reference: item.reference, + at: DateTime.now(), + )); + + if (publishClaims) { + try { + await ClaimPublisher.publishProtected( + medium: item.medium, + owner: item.owner, + subject: item.subject, + reference: item.reference, + issued: DateTime.now().toUtc().toIso8601String(), + alg: claimKey == null ? null : 'hmac-sha256', + kid: claimKey?.kid, + note: 'Protected for social posting', + ); + } catch (_) { + // Local protect still succeeds even if publish fails. + } + } + } + + return out; + } + + static TraceMedium _detectMedium(String name, Uint8List bytes) { + final lower = name.toLowerCase(); + if (lower.endsWith('.png') || + lower.endsWith('.jpg') || + lower.endsWith('.jpeg') || + lower.endsWith('.webp') || + _isPng(bytes) || + _isJpeg(bytes)) { + return TraceMedium.image; + } + if (lower.endsWith('.mp4') || + lower.endsWith('.mov') || + lower.endsWith('.m4v') || + _isMp4(bytes)) { + return TraceMedium.video; + } + if (lower.endsWith('.wav') || _isWav(bytes)) { + return TraceMedium.audio; + } + return TraceMedium.unknown; + } + + static bool _isPng(Uint8List b) => + b.length >= 8 && + b[0] == 0x89 && + b[1] == 0x50 && + b[2] == 0x4e && + b[3] == 0x47; + + static bool _isJpeg(Uint8List b) => + b.length >= 3 && b[0] == 0xff && b[1] == 0xd8 && b[2] == 0xff; + + static bool _isWav(Uint8List b) => + b.length >= 12 && + b[0] == 0x52 && + b[1] == 0x49 && + b[2] == 0x46 && + b[3] == 0x46 && + b[8] == 0x57 && + b[9] == 0x41 && + b[10] == 0x56 && + b[11] == 0x45; + + static bool _isMp4(Uint8List b) => + b.length >= 12 && + b[4] == 0x66 && + b[5] == 0x74 && + b[6] == 0x79 && + b[7] == 0x70; + + static String _withExt(String name, String ext) { + final dot = name.lastIndexOf('.'); + final base = dot > 0 ? name.substring(0, dot) : name; + return '$base.$ext'; + } +} diff --git a/lib/core/totp.dart b/lib/core/totp.dart new file mode 100644 index 0000000..b11d67a --- /dev/null +++ b/lib/core/totp.dart @@ -0,0 +1,128 @@ +/// RFC 6238 TOTP helpers for on-device two-factor auth. +library; + +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +class Totp { + Totp._(); + + static const digits = 6; + static const periodSeconds = 30; + static const _alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + + /// Cryptographically random 20-byte secret, Base32-encoded (no padding). + static String generateSecret({int bytes = 20}) { + final random = Random.secure(); + final raw = Uint8List.fromList( + List.generate(bytes, (_) => random.nextInt(256)), + ); + return encodeBase32(raw); + } + + static String otpauthUri({ + required String secret, + required String accountName, + String issuer = 'Signata', + }) { + final label = Uri.encodeComponent('$issuer:$accountName'); + final query = { + 'secret': secret, + 'issuer': issuer, + 'algorithm': 'SHA1', + 'digits': '$digits', + 'period': '$periodSeconds', + }.entries.map((e) => '${e.key}=${Uri.encodeQueryComponent(e.value)}').join('&'); + return 'otpauth://totp/$label?$query'; + } + + static String codeAt(String secretBase32, {DateTime? at}) { + final key = decodeBase32(secretBase32); + final seconds = + (at ?? DateTime.now().toUtc()).millisecondsSinceEpoch ~/ 1000; + final counter = seconds ~/ periodSeconds; + return _hotp(key, counter); + } + + /// Accepts the current window ±1 to tolerate slight clock skew. + static bool verify( + String secretBase32, + String code, { + DateTime? at, + int window = 1, + }) { + final cleaned = code.replaceAll(RegExp(r'\s'), ''); + if (!RegExp(r'^\d{6}$').hasMatch(cleaned)) { + return false; + } + final key = decodeBase32(secretBase32); + final seconds = + (at ?? DateTime.now().toUtc()).millisecondsSinceEpoch ~/ 1000; + final counter = seconds ~/ periodSeconds; + for (var i = -window; i <= window; i++) { + if (_constantTimeEquals(_hotp(key, counter + i), cleaned)) return true; + } + return false; + } + + static String _hotp(Uint8List key, int counter) { + final data = ByteData(8)..setUint64(0, counter); + final digest = Hmac(sha1, key).convert(data.buffer.asUint8List()).bytes; + final offset = digest[digest.length - 1] & 0x0f; + final binary = ((digest[offset] & 0x7f) << 24) | + ((digest[offset + 1] & 0xff) << 16) | + ((digest[offset + 2] & 0xff) << 8) | + (digest[offset + 3] & 0xff); + final otp = binary % pow(10, digits).toInt(); + return otp.toString().padLeft(digits, '0'); + } + + static String encodeBase32(Uint8List bytes) { + final out = StringBuffer(); + var buffer = 0; + var bitsLeft = 0; + for (final b in bytes) { + buffer = (buffer << 8) | b; + bitsLeft += 8; + while (bitsLeft >= 5) { + out.write(_alphabet[(buffer >> (bitsLeft - 5)) & 31]); + bitsLeft -= 5; + } + } + if (bitsLeft > 0) { + out.write(_alphabet[(buffer << (5 - bitsLeft)) & 31]); + } + return out.toString(); + } + + static Uint8List decodeBase32(String input) { + final cleaned = input.replaceAll(RegExp(r'[\s=]'), '').toUpperCase(); + var buffer = 0; + var bitsLeft = 0; + final out = BytesBuilder(copy: false); + for (final ch in cleaned.codeUnits) { + final idx = _alphabet.codeUnits.indexOf(ch); + if (idx < 0) { + throw FormatException('Invalid Base32 character'); + } + buffer = (buffer << 5) | idx; + bitsLeft += 5; + if (bitsLeft >= 8) { + out.addByte((buffer >> (bitsLeft - 8)) & 0xff); + bitsLeft -= 8; + } + } + return out.toBytes(); + } + + static bool _constantTimeEquals(String a, String b) { + if (a.length != b.length) return false; + var diff = 0; + for (var i = 0; i < a.length; i++) { + diff |= a.codeUnitAt(i) ^ b.codeUnitAt(i); + } + return diff == 0; + } +} diff --git a/lib/core/trace_models.dart b/lib/core/trace_models.dart new file mode 100644 index 0000000..3a366d8 --- /dev/null +++ b/lib/core/trace_models.dart @@ -0,0 +1,253 @@ +/// Models for publishing claims and tracing media across the public web. +library; + +import 'claim_crypto.dart'; + +enum TraceMedium { image, audio, video, pdf, unknown } + +extension TraceMediumX on TraceMedium { + String get wire => name; + + static TraceMedium parse(String? value) { + switch (value) { + case 'image': + return TraceMedium.image; + case 'audio': + return TraceMedium.audio; + case 'video': + return TraceMedium.video; + case 'pdf': + return TraceMedium.pdf; + default: + return TraceMedium.unknown; + } + } +} + +/// A claim registered so copies found online can be linked to an owner. +class PublishedClaim { + const PublishedClaim({ + required this.id, + required this.medium, + required this.owner, + required this.subject, + required this.reference, + required this.issued, + required this.publisherId, + required this.publishedAt, + this.alg, + this.kid, + this.note, + this.remoteSynced = false, + }); + + final String id; + final TraceMedium medium; + final String owner; + final String subject; + + /// Signature or structural identifier embedded in the media. + final String reference; + final String issued; + final String publisherId; + final DateTime publishedAt; + final String? alg; + final String? kid; + final String? note; + final bool remoteSynced; + + Map toJson() => { + 'id': id, + 'medium': medium.wire, + 'owner': owner, + 'subject': subject, + 'reference': reference, + 'issued': issued, + 'publisherId': publisherId, + 'publishedAt': publishedAt.toIso8601String(), + if (alg != null) 'alg': alg, + if (kid != null) 'kid': kid, + if (note != null) 'note': note, + 'remoteSynced': remoteSynced, + }; + + static PublishedClaim? fromJson(Object? value) { + if (value is! Map) return null; + final map = Map.from(value); + try { + return PublishedClaim( + id: map['id'] as String? ?? '', + medium: TraceMediumX.parse(map['medium'] as String?), + owner: map['owner'] as String? ?? '', + subject: map['subject'] as String? ?? '', + reference: map['reference'] as String? ?? '', + issued: map['issued'] as String? ?? '', + publisherId: map['publisherId'] as String? ?? '', + publishedAt: DateTime.tryParse(map['publishedAt'] as String? ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0), + alg: map['alg'] as String?, + kid: map['kid'] as String?, + note: map['note'] as String?, + remoteSynced: map['remoteSynced'] as bool? ?? false, + ); + } catch (_) { + return null; + } + } + + PublishedClaim copyWith({bool? remoteSynced, String? note}) => PublishedClaim( + id: id, + medium: medium, + owner: owner, + subject: subject, + reference: reference, + issued: issued, + publisherId: publisherId, + publishedAt: publishedAt, + alg: alg, + kid: kid, + note: note ?? this.note, + remoteSynced: remoteSynced ?? this.remoteSynced, + ); +} + +/// A URL the user wants re-scanned for Signata fingerprints. +class WatchTarget { + const WatchTarget({ + required this.id, + required this.url, + required this.addedAt, + this.label, + this.lastScannedAt, + this.lastReference, + }); + + final String id; + final String url; + final DateTime addedAt; + final String? label; + final DateTime? lastScannedAt; + final String? lastReference; + + Map toJson() => { + 'id': id, + 'url': url, + 'addedAt': addedAt.toIso8601String(), + if (label != null) 'label': label, + if (lastScannedAt != null) + 'lastScannedAt': lastScannedAt!.toIso8601String(), + if (lastReference != null) 'lastReference': lastReference, + }; + + static WatchTarget? fromJson(Object? value) { + if (value is! Map) return null; + final map = Map.from(value); + try { + return WatchTarget( + id: map['id'] as String? ?? '', + url: map['url'] as String? ?? '', + addedAt: DateTime.tryParse(map['addedAt'] as String? ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0), + label: map['label'] as String?, + lastScannedAt: DateTime.tryParse(map['lastScannedAt'] as String? ?? ''), + lastReference: map['lastReference'] as String?, + ); + } catch (_) { + return null; + } + } + + WatchTarget copyWith({ + DateTime? lastScannedAt, + String? lastReference, + String? label, + }) => + WatchTarget( + id: id, + url: url, + addedAt: addedAt, + label: label ?? this.label, + lastScannedAt: lastScannedAt ?? this.lastScannedAt, + lastReference: lastReference ?? this.lastReference, + ); +} + +/// One online observation of a fingerprinted file. +class TraceSighting { + const TraceSighting({ + required this.id, + required this.url, + required this.medium, + required this.at, + required this.found, + required this.claimStatus, + this.owner, + this.subject, + this.reference, + this.matchedPublishedClaimId, + this.contentType, + this.error, + this.note, + }); + + final String id; + final String url; + final TraceMedium medium; + final DateTime at; + final bool found; + final ClaimStatus claimStatus; + final String? owner; + final String? subject; + final String? reference; + final String? matchedPublishedClaimId; + final String? contentType; + final String? error; + + /// Soft guidance (e.g. platform may have stripped the watermark). + final String? note; + + Map toJson() => { + 'id': id, + 'url': url, + 'medium': medium.wire, + 'at': at.toIso8601String(), + 'found': found, + 'claimStatus': claimStatus.name, + if (owner != null) 'owner': owner, + if (subject != null) 'subject': subject, + if (reference != null) 'reference': reference, + if (matchedPublishedClaimId != null) + 'matchedPublishedClaimId': matchedPublishedClaimId, + if (contentType != null) 'contentType': contentType, + if (error != null) 'error': error, + if (note != null) 'note': note, + }; + + static TraceSighting? fromJson(Object? value) { + if (value is! Map) return null; + final map = Map.from(value); + try { + return TraceSighting( + id: map['id'] as String? ?? '', + url: map['url'] as String? ?? '', + medium: TraceMediumX.parse(map['medium'] as String?), + at: DateTime.tryParse(map['at'] as String? ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0), + found: map['found'] as bool? ?? false, + claimStatus: ClaimStatus.values.firstWhere( + (s) => s.name == map['claimStatus'], + orElse: () => ClaimStatus.missing, + ), + owner: map['owner'] as String?, + subject: map['subject'] as String?, + reference: map['reference'] as String?, + matchedPublishedClaimId: map['matchedPublishedClaimId'] as String?, + contentType: map['contentType'] as String?, + error: map['error'] as String?, + note: map['note'] as String?, + ); + } catch (_) { + return null; + } + } +} diff --git a/lib/core/trace_store.dart b/lib/core/trace_store.dart new file mode 100644 index 0000000..fbc9620 --- /dev/null +++ b/lib/core/trace_store.dart @@ -0,0 +1,168 @@ +/// Persistent watchlist and sighting log for internet media tracing. +/// Data is scoped per signed-in user. +library; + +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import 'local_data.dart'; +import 'safe_url.dart'; +import 'trace_models.dart'; + +class TraceStore { + TraceStore._(); + static final TraceStore instance = TraceStore._(); + + static const _legacyWatchKey = 'signata_watch_targets_v1'; + static const _legacySightingsKey = 'signata_trace_sightings_v1'; + static const _maxSightings = 150; + static const _autoRescanCooldownKey = 'signata_trace_auto_rescan_at'; + + String get _watchKey => userScopedKey(_legacyWatchKey); + String get _sightingsKey => userScopedKey(_legacySightingsKey); + String get _autoRescanKey => userScopedKey(_autoRescanCooldownKey); + + Future _ensureMigrated() async { + await migrateLegacyPrefsKey(_legacyWatchKey, _watchKey); + await migrateLegacyPrefsKey(_legacySightingsKey, _sightingsKey); + } + + Future> listWatchTargets() async { + await _ensureMigrated(); + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_watchKey); + if (raw == null) return const []; + try { + final list = jsonDecode(raw); + if (list is! List) return const []; + return list.map(WatchTarget.fromJson).whereType().toList() + ..sort((a, b) => b.addedAt.compareTo(a.addedAt)); + } catch (_) { + return const []; + } + } + + Future _saveWatch(List items) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _watchKey, + jsonEncode(items.map((e) => e.toJson()).toList()), + ); + } + + Future addWatchTarget(String url, {String? label}) async { + await _ensureMigrated(); + final parsed = SafeUrl.parseTraceUrl(url); + if (!parsed.isOk) { + throw ArgumentError(parsed.error ?? 'Invalid URL.'); + } + final normalized = parsed.uri!.toString(); + final existing = List.from(await listWatchTargets()); + final prior = existing.where((w) => w.url == normalized).toList(); + if (prior.isNotEmpty) return prior.first; + final target = WatchTarget( + id: 'watch_${DateTime.now().millisecondsSinceEpoch}', + url: normalized, + addedAt: DateTime.now().toUtc(), + label: label, + ); + existing.insert(0, target); + await _saveWatch(existing.take(100).toList()); + return target; + } + + Future removeWatchTarget(String id) async { + final next = (await listWatchTargets()).where((w) => w.id != id).toList(); + await _saveWatch(next); + } + + Future touchWatchTarget( + String id, { + required String? reference, + }) async { + final items = await listWatchTargets(); + final next = items + .map( + (w) => w.id == id + ? w.copyWith( + lastScannedAt: DateTime.now().toUtc(), + lastReference: reference, + ) + : w, + ) + .toList(); + await _saveWatch(next); + } + + Future> listSightings() async { + await _ensureMigrated(); + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_sightingsKey); + if (raw == null) return const []; + try { + final list = jsonDecode(raw); + if (list is! List) return const []; + return list.map(TraceSighting.fromJson).whereType().toList() + ..sort((a, b) => b.at.compareTo(a.at)); + } catch (_) { + return const []; + } + } + + Future addSighting(TraceSighting sighting) async { + await _ensureMigrated(); + final items = List.from(await listSightings()); + items.insert(0, sighting); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _sightingsKey, + jsonEncode(items.take(_maxSightings).map((e) => e.toJson()).toList()), + ); + } + + Future clearSightings() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_sightingsKey); + } + + Future clearWatchlist() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_watchKey); + } + + Future clearAll() async { + await clearWatchlist(); + await clearSightings(); + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_autoRescanKey); + } + + Future clearForUser(String userId) async { + final prefs = await SharedPreferences.getInstance(); + final safe = userId.replaceAll(RegExp(r'[^a-zA-Z0-9._-]'), '_'); + await prefs.remove('${_legacyWatchKey}__$safe'); + await prefs.remove('${_legacySightingsKey}__$safe'); + await prefs.remove('${_autoRescanCooldownKey}__$safe'); + } + + /// True if an auto-rescan should run (once per 12h when online). + Future shouldAutoRescan({ + Duration cooldown = const Duration(hours: 12), + }) async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_autoRescanKey); + if (raw == null) return true; + final last = DateTime.tryParse(raw); + if (last == null) return true; + return DateTime.now().toUtc().difference(last) >= cooldown; + } + + Future markAutoRescanDone() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _autoRescanKey, + DateTime.now().toUtc().toIso8601String(), + ); + } +} diff --git a/lib/core/url_tracer.dart b/lib/core/url_tracer.dart new file mode 100644 index 0000000..2cb5f80 --- /dev/null +++ b/lib/core/url_tracer.dart @@ -0,0 +1,454 @@ +/// Fetches a public media URL and scans it for a Signata fingerprint. +library; + +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; + +import 'audio_watermark.dart'; +import 'claim_crypto.dart'; +import 'claim_registry.dart'; +import 'image_watermark.dart'; +import 'pdf_fingerprint.dart'; +import 'safe_url.dart'; +import 'social_platforms.dart'; +import 'trace_models.dart'; +import 'trace_store.dart'; +import 'video_fingerprint.dart'; + +class TraceScanResult { + const TraceScanResult({ + required this.sighting, + this.matchedClaim, + this.bytes, + }); + + final TraceSighting sighting; + final PublishedClaim? matchedClaim; + final Uint8List? bytes; +} + +class UrlTracer { + UrlTracer._(); + static final UrlTracer instance = UrlTracer._(); + + static const _maxBytes = 40 * 1024 * 1024; + + Future scanUrl( + String rawUrl, { + ClaimKey? claimKey, + bool persist = true, + bool addToWatchlist = false, + }) async { + if (!TraceRateLimiter.instance.allow()) { + throw ArgumentError( + 'Too many trace scans this hour. Try again later.', + ); + } + final parsed = SafeUrl.parseTraceUrl(rawUrl); + if (!parsed.isOk) { + throw ArgumentError(parsed.error ?? 'Invalid URL.'); + } + final url = parsed.uri!.toString(); + + var fetchUrl = url; + String? socialNote; + final social = SocialPlatformInfo.fromUrl(url); + if (social != null && !_looksLikeDirectMedia(url)) { + final resolved = await SocialMediaResolver.instance.resolve(url); + socialNote = resolved?.note; + if (resolved?.mediaUrl != null && resolved!.mediaUrl!.isNotEmpty) { + final mediaParsed = SafeUrl.parseTraceUrl(resolved.mediaUrl!); + if (mediaParsed.isOk) { + fetchUrl = mediaParsed.uri!.toString(); + } else { + socialNote = mediaParsed.error ?? + 'Resolved media URL was blocked for security.'; + } + } else if (resolved != null) { + final sighting = TraceSighting( + id: _id(), + url: url, + medium: TraceMedium.unknown, + at: DateTime.now().toUtc(), + found: false, + claimStatus: ClaimStatus.missing, + error: resolved.note ?? + '${social.label} did not expose a direct media file. ' + 'Share the image/video into Signata instead of the post link.', + note: + 'Post URLs are fragile. From ${social.label}, use Share → Signata ' + 'on the media itself, or pick your original protected export.', + ); + if (persist) await TraceStore.instance.addSighting(sighting); + if (addToWatchlist) { + final watch = await TraceStore.instance.addWatchTarget( + url, + label: social.label, + ); + await TraceStore.instance.touchWatchTarget( + watch.id, + reference: null, + ); + } + return TraceScanResult(sighting: sighting); + } + } + + Uint8List bytes; + String? contentType; + try { + final fetchUri = SafeUrl.parseTraceUrl(fetchUrl); + if (!fetchUri.isOk) { + throw Exception(fetchUri.error ?? 'Blocked URL.'); + } + final response = await http.get( + fetchUri.uri!, + headers: { + 'User-Agent': + 'Mozilla/5.0 (compatible; SignataTrace/1.0; +https://signata.app)', + 'Accept': '*/*', + }, + ).timeout( + const Duration(seconds: 45), + ); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('Download failed (${response.statusCode}).'); + } + if (response.bodyBytes.length > _maxBytes) { + throw Exception('File is larger than 40 MB; try a direct media link.'); + } + bytes = response.bodyBytes; + contentType = response.headers['content-type']; + } catch (error) { + final message = socialNote ?? + error.toString().replaceFirst('Exception: ', ''); + final sighting = TraceSighting( + id: _id(), + url: url, + medium: TraceMedium.unknown, + at: DateTime.now().toUtc(), + found: false, + claimStatus: ClaimStatus.missing, + error: message, + ); + if (persist) await TraceStore.instance.addSighting(sighting); + return TraceScanResult(sighting: sighting); + } + + final medium = _detectMedium(url: url, contentType: contentType, bytes: bytes); + return _finishScan( + sourceLabel: url, + medium: medium, + bytes: bytes, + contentType: contentType, + claimKey: claimKey, + persist: persist, + addToWatchlist: addToWatchlist, + social: social, + watchLabel: social?.label, + ); + } + + /// Scan bytes already on the device (share ingress / file picker). + /// This is the reliable Trace path — social post URLs often strip marks. + Future scanBytes({ + required Uint8List bytes, + required String fileName, + ClaimKey? claimKey, + bool persist = true, + String? contentType, + }) async { + if (bytes.isEmpty) { + throw ArgumentError('File is empty.'); + } + if (bytes.length > _maxBytes) { + throw Exception('File is larger than 40 MB.'); + } + final medium = _detectMedium( + url: fileName, + contentType: contentType, + bytes: bytes, + ); + return _finishScan( + sourceLabel: 'local://$fileName', + medium: medium, + bytes: bytes, + contentType: contentType, + claimKey: claimKey, + persist: persist, + addToWatchlist: false, + social: null, + localFile: true, + ); + } + + Future _finishScan({ + required String sourceLabel, + required TraceMedium medium, + required Uint8List bytes, + required ClaimKey? claimKey, + required bool persist, + required bool addToWatchlist, + String? contentType, + SocialPlatformInfo? social, + String? watchLabel, + bool localFile = false, + }) async { + final extracted = await _extract(medium, bytes, claimKey); + PublishedClaim? matched; + if (extracted.reference != null && extracted.reference!.isNotEmpty) { + matched = await ClaimRegistry.instance.findByReference(extracted.reference!); + } + + String? note; + if (!extracted.found) { + if (localFile) { + note = + 'No Signata fingerprint in this file. If it came from a social app, ' + 'the platform may have recompressed it. Try your original protected export.'; + } else if (social != null) { + note = + '${social.label} often recompresses uploads and strips hidden marks. ' + 'Best path: share the media file into Signata (or pick the original export) ' + 'instead of relying on the post URL.'; + } else { + note = + 'No Signata fingerprint was readable in this file. If it was ' + 're-saved, compressed, or re-encoded, the mark may be gone.'; + } + } + + final sighting = TraceSighting( + id: _id(), + url: sourceLabel, + medium: medium, + at: DateTime.now().toUtc(), + found: extracted.found, + claimStatus: extracted.status, + owner: extracted.owner ?? matched?.owner, + subject: extracted.subject ?? matched?.subject, + reference: extracted.reference ?? matched?.reference, + matchedPublishedClaimId: matched?.id, + contentType: contentType, + note: note, + ); + + if (persist) await TraceStore.instance.addSighting(sighting); + if (addToWatchlist) { + final watch = await TraceStore.instance.addWatchTarget( + sourceLabel, + label: watchLabel, + ); + await TraceStore.instance.touchWatchTarget( + watch.id, + reference: sighting.reference, + ); + } + + return TraceScanResult( + sighting: sighting, + matchedClaim: matched, + bytes: bytes, + ); + } + + Future> scanWatchlist({ClaimKey? claimKey}) async { + final targets = await TraceStore.instance.listWatchTargets(); + final out = []; + for (final target in targets) { + final result = await scanUrl( + target.url, + claimKey: claimKey, + persist: true, + ); + await TraceStore.instance.touchWatchTarget( + target.id, + reference: result.sighting.reference, + ); + out.add(result); + } + return out; + } + + static bool _looksLikeDirectMedia(String url) { + final path = Uri.tryParse(url)?.path.toLowerCase() ?? url.toLowerCase(); + return path.endsWith('.png') || + path.endsWith('.jpg') || + path.endsWith('.jpeg') || + path.endsWith('.webp') || + path.endsWith('.gif') || + path.endsWith('.mp4') || + path.endsWith('.mov') || + path.endsWith('.m4v') || + path.endsWith('.wav') || + path.endsWith('.pdf'); + } + + static String _id() => 'sight_${DateTime.now().toUtc().microsecondsSinceEpoch}'; + + static TraceMedium _detectMedium({ + required String url, + required String? contentType, + required Uint8List bytes, + }) { + final ct = (contentType ?? '').toLowerCase(); + final path = Uri.tryParse(url)?.path.toLowerCase() ?? url.toLowerCase(); + + if (ct.contains('pdf') || path.endsWith('.pdf') || _isPdf(bytes)) { + return TraceMedium.pdf; + } + if (ct.startsWith('image/') || + path.endsWith('.png') || + path.endsWith('.jpg') || + path.endsWith('.jpeg') || + path.endsWith('.webp') || + _isPng(bytes) || + _isJpeg(bytes)) { + return TraceMedium.image; + } + if (ct.contains('wav') || + ct.contains('audio/wave') || + path.endsWith('.wav') || + _isWav(bytes)) { + return TraceMedium.audio; + } + if (ct.startsWith('video/') || + path.endsWith('.mp4') || + path.endsWith('.mov') || + path.endsWith('.m4v') || + _isMp4(bytes)) { + return TraceMedium.video; + } + + // Fallbacks by sniffing when servers omit content-type. + if (_isPdf(bytes)) return TraceMedium.pdf; + if (_isPng(bytes) || _isJpeg(bytes)) return TraceMedium.image; + if (_isWav(bytes)) return TraceMedium.audio; + if (_isMp4(bytes)) return TraceMedium.video; + return TraceMedium.unknown; + } + + static bool _isPdf(Uint8List b) => + b.length >= 5 && + b[0] == 0x25 && + b[1] == 0x50 && + b[2] == 0x44 && + b[3] == 0x46; + + static bool _isPng(Uint8List b) => + b.length >= 8 && + b[0] == 0x89 && + b[1] == 0x50 && + b[2] == 0x4e && + b[3] == 0x47; + + static bool _isJpeg(Uint8List b) => + b.length >= 3 && b[0] == 0xff && b[1] == 0xd8 && b[2] == 0xff; + + static bool _isWav(Uint8List b) => + b.length >= 12 && + b[0] == 0x52 && + b[1] == 0x49 && + b[2] == 0x46 && + b[3] == 0x46 && + b[8] == 0x57 && + b[9] == 0x41 && + b[10] == 0x56 && + b[11] == 0x45; + + static bool _isMp4(Uint8List b) => + b.length >= 12 && + b[4] == 0x66 && + b[5] == 0x74 && + b[6] == 0x79 && + b[7] == 0x70; + + Future< + ({ + bool found, + ClaimStatus status, + String? owner, + String? subject, + String? reference, + })> _extract( + TraceMedium medium, + Uint8List bytes, + ClaimKey? claimKey, + ) async { + try { + switch (medium) { + case TraceMedium.image: + final outcome = await extractWatermark(bytes, claimKey: claimKey); + final payload = WatermarkPayload.tryParse(outcome.raw); + final status = outcome.claimStatus; + return ( + found: payload != null, + status: status, + owner: payload?.owner, + subject: payload?.asset, + reference: payload?.signature, + ); + case TraceMedium.audio: + final outcome = + await extractAudioWatermark(bytes, claimKey: claimKey); + final payload = AudioPayload.tryParse(outcome.raw); + return ( + found: payload != null, + status: outcome.claimStatus, + owner: payload?.owner, + subject: payload?.asset, + reference: payload?.signature, + ); + case TraceMedium.pdf: + final outcome = + await verifyPdfFingerprint(bytes, claimKey: claimKey); + return ( + found: outcome.recovered != null, + status: outcome.claimStatus, + owner: outcome.recovered?.owner, + subject: outcome.recovered?.document, + reference: outcome.recovered?.identifier ?? + outcome.recovered?.signature, + ); + case TraceMedium.video: + final outcome = + await verifyVideoFingerprint(bytes, claimKey: claimKey); + return ( + found: outcome.recovered != null, + status: outcome.claimStatus, + owner: outcome.recovered?.owner, + subject: outcome.recovered?.document, + reference: outcome.recovered?.identifier ?? + outcome.recovered?.signature, + ); + case TraceMedium.unknown: + // Try image then pdf then audio then video. + for (final probe in [ + TraceMedium.image, + TraceMedium.pdf, + TraceMedium.audio, + TraceMedium.video, + ]) { + final result = await _extract(probe, bytes, claimKey); + if (result.found) return result; + } + return ( + found: false, + status: ClaimStatus.missing, + owner: null, + subject: null, + reference: null, + ); + } + } catch (_) { + return ( + found: false, + status: ClaimStatus.missing, + owner: null, + subject: null, + reference: null, + ); + } + } +} diff --git a/lib/core/usage_entitlements.dart b/lib/core/usage_entitlements.dart new file mode 100644 index 0000000..16149f9 --- /dev/null +++ b/lib/core/usage_entitlements.dart @@ -0,0 +1,235 @@ +/// Freemium usage gate (demo build). +/// +/// Free plan: [freeProtectsPerDay] protect runs and [freeTracePerDay] Trace +/// scans per calendar day, per signed-in user. A mock rewarded ad grants +1 +/// of the blocked action for today; a mock purchase unlocks Premium, which +/// removes both limits. Counters and the premium flag are stored locally in +/// SharedPreferences, keyed by user id. +/// +/// Call [check] before work and [commit] only after success so cancel/fail +/// does not burn quota. [reserve] remains as check+commit for tests. +library; + +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import 'auth.dart'; + +/// Which gated action is being consumed. +enum UsageKind { protect, trace } + +/// Result of trying to reserve an attempt. +enum GateResult { allowed, blocked } + +class UsageSnapshot { + const UsageSnapshot({ + required this.isPremium, + required this.protectUsed, + required this.protectLimit, + required this.traceUsed, + required this.traceLimit, + }); + + final bool isPremium; + final int protectUsed; + final int protectLimit; + final int traceUsed; + final int traceLimit; + + int get protectRemaining => + isPremium ? 1 << 30 : (protectLimit - protectUsed).clamp(0, 1 << 30); + int get traceRemaining => + isPremium ? 1 << 30 : (traceLimit - traceUsed).clamp(0, 1 << 30); + + int remainingFor(UsageKind kind) => + kind == UsageKind.protect ? protectRemaining : traceRemaining; +} + +/// Seam for a real billing SDK later. The demo build just succeeds. +class BillingGateway { + const BillingGateway(); + + Future purchasePremium() async { + await Future.delayed(const Duration(milliseconds: 600)); + return true; + } +} + +/// Seam for a real rewarded-ad SDK later. The demo build just succeeds. +class RewardedAdGateway { + const RewardedAdGateway(); + + Future show() async { + await Future.delayed(const Duration(milliseconds: 1500)); + return true; + } +} + +class UsageEntitlements { + UsageEntitlements._(); + + static final instance = UsageEntitlements._(); + + static const freeProtectsPerDay = 2; + static const freeTracePerDay = 3; + + /// Demo billing is always local until Play Billing is wired. + static const bool isDemoBilling = true; + + final billing = const BillingGateway(); + final rewardedAd = const RewardedAdGateway(); + + /// Overridable in tests to simulate day rollover. + String Function() todayKey = _defaultTodayKey; + + /// Overridable in tests to avoid a signed-in user. + String Function() userId = _defaultUserId; + + static String _defaultTodayKey() { + final now = DateTime.now(); + return '${now.year.toString().padLeft(4, '0')}-' + '${now.month.toString().padLeft(2, '0')}-' + '${now.day.toString().padLeft(2, '0')}'; + } + + static String _defaultUserId() => + AuthService.instance.user?.id ?? 'anonymous'; + + String get _prefsKey => 'signata.usage.${userId()}'; + + Future> _load(SharedPreferences prefs) async { + final raw = prefs.getString(_prefsKey); + if (raw == null) return {}; + try { + final decoded = jsonDecode(raw); + return decoded is Map ? decoded : {}; + } catch (_) { + return {}; + } + } + + Future _save(SharedPreferences prefs, Map data) => + prefs.setString(_prefsKey, jsonEncode(data)); + + /// Counters for today only; stale days reset to zero implicitly. + ({int protect, int trace, int protectBonus, int traceBonus}) _todayCounters( + Map data) { + if (data['day'] != todayKey()) { + return (protect: 0, trace: 0, protectBonus: 0, traceBonus: 0); + } + int asInt(Object? v) => v is int ? v : 0; + return ( + protect: asInt(data['protect']), + trace: asInt(data['trace']), + protectBonus: asInt(data['protectBonus']), + traceBonus: asInt(data['traceBonus']), + ); + } + + Future snapshot() async { + final prefs = await SharedPreferences.getInstance(); + final data = await _load(prefs); + final c = _todayCounters(data); + return UsageSnapshot( + isPremium: data['premium'] == true, + protectUsed: c.protect, + protectLimit: freeProtectsPerDay + c.protectBonus, + traceUsed: c.trace, + traceLimit: freeTracePerDay + c.traceBonus, + ); + } + + Future get isPremium async { + final prefs = await SharedPreferences.getInstance(); + final data = await _load(prefs); + return data['premium'] == true; + } + + /// Returns whether [units] attempts of [kind] are available (no consume). + Future check(UsageKind kind, {int units = 1}) async { + if (units < 1) return GateResult.allowed; + final prefs = await SharedPreferences.getInstance(); + final data = await _load(prefs); + if (data['premium'] == true) return GateResult.allowed; + + final c = _todayCounters(data); + final used = kind == UsageKind.protect ? c.protect : c.trace; + final limit = kind == UsageKind.protect + ? freeProtectsPerDay + c.protectBonus + : freeTracePerDay + c.traceBonus; + if (used + units > limit) return GateResult.blocked; + return GateResult.allowed; + } + + /// Consumes [units] attempts after successful work. Premium is a no-op. + Future commit(UsageKind kind, {int units = 1}) async { + if (units < 1) return GateResult.allowed; + final prefs = await SharedPreferences.getInstance(); + final data = await _load(prefs); + if (data['premium'] == true) return GateResult.allowed; + + final c = _todayCounters(data); + final used = kind == UsageKind.protect ? c.protect : c.trace; + final limit = kind == UsageKind.protect + ? freeProtectsPerDay + c.protectBonus + : freeTracePerDay + c.traceBonus; + if (used + units > limit) return GateResult.blocked; + + await _save(prefs, { + 'premium': data['premium'] == true, + 'day': todayKey(), + 'protect': c.protect + (kind == UsageKind.protect ? units : 0), + 'trace': c.trace + (kind == UsageKind.trace ? units : 0), + 'protectBonus': c.protectBonus, + 'traceBonus': c.traceBonus, + }); + return GateResult.allowed; + } + + /// Check + commit in one step (tests / simple callers). + Future reserve(UsageKind kind, {int units = 1}) async { + if (await check(kind, units: units) == GateResult.blocked) { + return GateResult.blocked; + } + return commit(kind, units: units); + } + + Future reserveProtect() => reserve(UsageKind.protect); + + Future reserveTrace() => reserve(UsageKind.trace); + + /// Rewarded-ad bonus: +1 attempt of [kind] valid for today. + Future grantAdBonus({required UsageKind kind}) async { + final prefs = await SharedPreferences.getInstance(); + final data = await _load(prefs); + final c = _todayCounters(data); + await _save(prefs, { + 'premium': data['premium'] == true, + 'day': todayKey(), + 'protect': c.protect, + 'trace': c.trace, + 'protectBonus': c.protectBonus + (kind == UsageKind.protect ? 1 : 0), + 'traceBonus': c.traceBonus + (kind == UsageKind.trace ? 1 : 0), + }); + } + + Future unlockPremium() async { + final prefs = await SharedPreferences.getInstance(); + final data = await _load(prefs); + data['premium'] = true; + await _save(prefs, data); + } + + Future clearPremiumForDebug() async { + final prefs = await SharedPreferences.getInstance(); + final data = await _load(prefs); + data['premium'] = false; + await _save(prefs, data); + } + + Future clearForUser(String id) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('signata.usage.$id'); + } +} diff --git a/lib/core/video_fingerprint.dart b/lib/core/video_fingerprint.dart index 696edc3..c11af8f 100644 --- a/lib/core/video_fingerprint.dart +++ b/lib/core/video_fingerprint.dart @@ -1,5 +1,5 @@ -/// Video container fingerprinting — profiles an MP4/MOV file and hides a -/// trailing Signata identifier (same pattern as the PDF prototype). +/// Video container fingerprinting — profiles an MP4/MOV file and embeds a +/// Signata claim in a custom `uuid` box plus a legacy trailing marker. library; import 'dart:convert'; @@ -8,9 +8,14 @@ import 'dart:typed_data'; import 'package:crypto/crypto.dart' as crypto; import 'package:flutter/foundation.dart' show compute; +import 'claim_crypto.dart'; + const String videoMark = '%%SignataVideo:'; +final Uint8List signataVideoUuid = + Uint8List.fromList(ascii.encode('SignataClaimV2!!')); -String _bytesToLatin1(Uint8List bytes) => latin1.decode(bytes, allowInvalid: true); +String _bytesToLatin1(Uint8List bytes) => + latin1.decode(bytes, allowInvalid: true); Uint8List _latin1ToBytes(String text) { final out = Uint8List(text.length); @@ -71,22 +76,46 @@ class VideoPayload { required this.document, required this.issued, required this.structure, - required this.identifier, + required this.contentBytes, + this.signature, + this.alg, + this.kid, + this.version = 1, + this.identifier, }); final String owner; final String document; final String issued; final VideoStructure structure; - final String identifier; - - Map toJson() => { - 'owner': owner, - 'document': document, - 'issued': issued, - 'structure': structure.toJson(), - 'identifier': identifier, - }; + final int contentBytes; + final String? signature; + final String? alg; + final String? kid; + final int version; + + /// Legacy structural digest (uppercase SHA-256 hex). + final String? identifier; + + Map toJson() { + final map = { + 'owner': owner, + 'document': document, + 'issued': issued, + 'structure': structure.toJson(), + 'contentBytes': contentBytes, + }; + if (signature != null && signature!.isNotEmpty) { + map['signature'] = signature; + } + if (alg != null && alg!.isNotEmpty) map['alg'] = alg; + if (kid != null && kid!.isNotEmpty) map['kid'] = kid; + if (version > 1) map['v'] = version; + if (identifier != null && identifier!.isNotEmpty) { + map['identifier'] = identifier; + } + return map; + } static VideoPayload? tryParse(String? raw) { if (raw == null) return null; @@ -100,15 +129,43 @@ class VideoPayload { document: map['document'] as String? ?? '', issued: map['issued'] as String? ?? '', structure: structure, - identifier: map['identifier'] as String? ?? '', + contentBytes: (map['contentBytes'] as num?)?.toInt() ?? + (map['originalLength'] as num?)?.toInt() ?? + structure.bytes, + signature: map['signature'] as String?, + alg: map['alg'] as String?, + kid: map['kid'] as String?, + version: (map['v'] as num?)?.toInt() ?? 1, + identifier: map['identifier'] as String?, ); } catch (_) { return null; } } - String digestInput(int originalLength) => - '$owner|$document|$issued|${structure.canonical}|$originalLength'; + String digestInput() => + '$owner|$document|$issued|${structure.canonical}|$contentBytes'; + + String structuralIdentifier() => identifier ?? videoDigest(digestInput()); + + ClaimStatus statusWith(ClaimKey? key, { + required String recheckId, + required bool structureMatch, + }) => + ClaimCrypto.evaluateStructural( + owner: owner, + document: document, + issued: issued, + structureCanonical: structure.canonical, + originalLength: contentBytes, + identifier: structuralIdentifier(), + recheckId: recheckId, + structureMatch: structureMatch, + signature: signature, + alg: alg, + kid: kid, + key: key, + ); } class NotAVideoException implements Exception { @@ -125,7 +182,6 @@ String videoDigest(String input) => VideoStructure analyzeVideo(Uint8List bytes) { if (bytes.length < 12) throw const NotAVideoException(); - // ISO BMFF: [size:4][type:4]… ftyp usually first. final firstType = String.fromCharCodes(bytes.sublist(4, 8)); if (firstType != 'ftyp' && firstType != 'wide' && firstType != 'mdat') { // Some files start with a free/skip atom; still accept if we later find moov. @@ -154,7 +210,6 @@ VideoStructure analyzeVideo(Uint8List bytes) { hasMdat = true; } - // Sample descriptions / handler hints inside this atom window. final end = size == 0 ? bytes.length : (size == 1 && offset + 16 <= bytes.length) @@ -164,10 +219,14 @@ VideoStructure analyzeVideo(Uint8List bytes) { : offset + size; final windowEnd = end.clamp(0, bytes.length); final slice = _bytesToLatin1(bytes.sublist(offset, windowEnd)); - if (slice.contains('vide') || slice.contains('avc1') || slice.contains('hvc1')) { + if (slice.contains('vide') || + slice.contains('avc1') || + slice.contains('hvc1')) { hasVideo = true; } - if (slice.contains('soun') || slice.contains('mp4a') || slice.contains('opus')) { + if (slice.contains('soun') || + slice.contains('mp4a') || + slice.contains('opus')) { hasAudio = true; } @@ -190,15 +249,73 @@ VideoStructure analyzeVideo(Uint8List bytes) { ); } +Uint8List _buildUuidBox(Uint8List payloadBytes) { + final boxSize = 8 + 16 + payloadBytes.length; + final box = Uint8List(boxSize); + final header = ByteData(8); + header.setUint32(0, boxSize, Endian.big); + box.setAll(0, header.buffer.asUint8List()); + box.setAll(4, 'uuid'.codeUnits); + box.setAll(8, signataVideoUuid); + box.setAll(24, payloadBytes); + return box; +} + Uint8List embedVideoIdentifier(Uint8List source, String payload) { + final payloadBytes = utf8.encode(payload); + final uuidBox = _buildUuidBox(payloadBytes); final encoded = base64Encode(utf8.encode(payload)); final tail = _latin1ToBytes('\n$videoMark$encoded\n'); - final out = Uint8List(source.length + tail.length); + final out = Uint8List(source.length + uuidBox.length + tail.length); out.setAll(0, source); - out.setAll(source.length, tail); + out.setAll(source.length, uuidBox); + out.setAll(source.length + uuidBox.length, tail); return out; } +String? extractVideoUuidPayload(Uint8List bytes) { + var offset = 0; + while (offset + 8 <= bytes.length) { + final size = ByteData.sublistView(bytes, offset, offset + 4) + .getUint32(0, Endian.big); + final type = String.fromCharCodes(bytes.sublist(offset + 4, offset + 8)); + // Top-level boxes only need an 8-byte header; uuid payload needs 24+. + if (size != 0 && size != 1 && size < 8) break; + + final end = size == 0 + ? bytes.length + : (size == 1 && offset + 16 <= bytes.length) + ? offset + + ByteData.sublistView(bytes, offset + 8, offset + 16) + .getUint64(0, Endian.big) + : offset + size; + if (end <= offset || end > bytes.length) break; + final windowEnd = end; + + if (type == 'uuid' && offset + 24 <= windowEnd) { + final userType = bytes.sublist(offset + 8, offset + 24); + if (userType.length == signataVideoUuid.length) { + var matches = true; + for (var i = 0; i < signataVideoUuid.length; i++) { + if (userType[i] != signataVideoUuid[i]) { + matches = false; + break; + } + } + if (matches && windowEnd > offset + 24) { + try { + return utf8.decode(bytes.sublist(offset + 24, windowEnd)); + } catch (_) {} + } + } + } + + if (size == 0) break; + offset = windowEnd; + } + return null; +} + String? extractVideoIdentifier(String text) { final index = text.lastIndexOf(videoMark); if (index == -1) return null; @@ -212,6 +329,12 @@ String? extractVideoIdentifier(String text) { } } +String? extractVideoPayloadRaw(Uint8List bytes) { + final uuidPayload = extractVideoUuidPayload(bytes); + if (uuidPayload != null) return uuidPayload; + return extractVideoIdentifier(_bytesToLatin1(bytes)); +} + class VideoEmbedOutcome { const VideoEmbedOutcome({ required this.markedBytes, @@ -221,6 +344,8 @@ class VideoEmbedOutcome { required this.recheckId, required this.structureMatch, required this.verified, + required this.claimStatus, + required this.originalLength, }); final Uint8List markedBytes; @@ -230,6 +355,8 @@ class VideoEmbedOutcome { final String recheckId; final bool structureMatch; final bool verified; + final ClaimStatus claimStatus; + final int originalLength; } class VideoVerifyOutcome { @@ -239,6 +366,8 @@ class VideoVerifyOutcome { required this.recheckId, required this.structureMatch, required this.verified, + required this.claimStatus, + required this.originalLength, }); final VideoPayload? recovered; @@ -246,77 +375,184 @@ class VideoVerifyOutcome { final String recheckId; final bool structureMatch; final bool verified; + final ClaimStatus claimStatus; + final int originalLength; } -VideoEmbedOutcome _embedTask( - ({Uint8List source, String owner, String fileName, String issued}) args) { - final (:source, :owner, :fileName, :issued) = args; - final structure = analyzeVideo(source); +ClaimKey? _claimKeyFromArgs(Uint8List? keyBytes, String? kid) { + if (keyBytes == null || keyBytes.length != 32) return null; + return ClaimKey(bytes: keyBytes, kid: kid ?? ClaimKey.kidFor(keyBytes)); +} + +VideoPayload _buildPayload({ + required String owner, + required String fileName, + required String issued, + required VideoStructure structure, + required int contentBytes, + ClaimKey? claimKey, +}) { final resolvedOwner = owner.trim().isEmpty ? 'Anonymous creator' : owner.trim(); final identifier = videoDigest( - '$resolvedOwner|$fileName|$issued|${structure.canonical}|${source.length}', + '$resolvedOwner|$fileName|$issued|${structure.canonical}|$contentBytes', ); - final payload = VideoPayload( + + if (claimKey != null) { + final signed = ClaimCrypto.signStructural( + claimKey, + owner: resolvedOwner, + document: fileName, + issued: issued, + structureCanonical: structure.canonical, + originalLength: contentBytes, + identifier: identifier, + ); + return VideoPayload( + owner: resolvedOwner, + document: fileName, + issued: issued, + structure: structure, + contentBytes: contentBytes, + signature: signed.signature, + alg: signed.alg, + kid: signed.kid, + version: signed.version, + identifier: identifier, + ); + } + + return VideoPayload( owner: resolvedOwner, document: fileName, issued: issued, structure: structure, + contentBytes: contentBytes, identifier: identifier, + version: 1, ); +} - final marked = embedVideoIdentifier(source, jsonEncode(payload.toJson())); - final deliveredText = _bytesToLatin1(marked); - final raw = extractVideoIdentifier(deliveredText); +({String recheckId, bool structureMatch, ClaimStatus claimStatus, bool verified}) + _evaluatePayload( + VideoPayload payload, + Uint8List source, + ClaimKey? claimKey, +) { + final originalLength = payload.contentBytes; + if (originalLength <= 0 || originalLength > source.length) { + return ( + recheckId: '—', + structureMatch: false, + claimStatus: ClaimStatus.present, + verified: false, + ); + } + + final original = source.sublist(0, originalLength); + final structure = analyzeVideo(original); + final recheckId = videoDigest(payload.digestInput()); + final structureMatch = structure.canonical == payload.structure.canonical; + final claimStatus = payload.statusWith( + claimKey, + recheckId: recheckId, + structureMatch: structureMatch, + ); + final verified = claimStatus == ClaimStatus.authenticated || + claimStatus == ClaimStatus.selfConsistent; + + return ( + recheckId: recheckId, + structureMatch: structureMatch, + claimStatus: claimStatus, + verified: verified, + ); +} + +VideoEmbedOutcome _embedTask( + ({ + Uint8List source, + String owner, + String fileName, + String issued, + Uint8List? keyBytes, + String? kid, + }) args, +) { + final (:source, :owner, :fileName, :issued, :keyBytes, :kid) = args; + final claimKey = _claimKeyFromArgs(keyBytes, kid); + final contentBytes = source.length; + final structure = analyzeVideo(source); + final payload = _buildPayload( + owner: owner, + fileName: fileName, + issued: issued, + structure: structure, + contentBytes: contentBytes, + claimKey: claimKey, + ); + + final marked = + embedVideoIdentifier(source, jsonEncode(payload.toJson())); + final raw = extractVideoPayloadRaw(marked); final recovered = VideoPayload.tryParse(raw); - final recheckId = recovered == null - ? '—' - : videoDigest(recovered.digestInput(source.length)); - final structureMatch = - recovered != null && recovered.structure.canonical == structure.canonical; - final verified = recovered != null && - recheckId == recovered.identifier && - structureMatch; + if (recovered == null) { + return VideoEmbedOutcome( + markedBytes: marked, + payload: payload, + recovered: null, + raw: raw, + recheckId: '—', + structureMatch: false, + verified: false, + claimStatus: ClaimStatus.missing, + originalLength: contentBytes, + ); + } + + final eval = _evaluatePayload(recovered, marked, claimKey); return VideoEmbedOutcome( markedBytes: marked, payload: payload, recovered: recovered, raw: raw, - recheckId: recheckId, - structureMatch: structureMatch, - verified: verified, + recheckId: eval.recheckId, + structureMatch: eval.structureMatch, + verified: eval.verified, + claimStatus: eval.claimStatus, + originalLength: contentBytes, ); } -VideoVerifyOutcome _verifyTask(Uint8List source) { - final text = _bytesToLatin1(source); - final raw = extractVideoIdentifier(text); +VideoVerifyOutcome _verifyTask( + ({Uint8List source, Uint8List? keyBytes, String? kid}) args, +) { + final (:source, :keyBytes, :kid) = args; + final claimKey = _claimKeyFromArgs(keyBytes, kid); + final raw = extractVideoPayloadRaw(source); final recovered = VideoPayload.tryParse(raw); if (recovered == null) { - return const VideoVerifyOutcome( + return VideoVerifyOutcome( recovered: null, - raw: null, + raw: raw, recheckId: '—', structureMatch: false, verified: false, + claimStatus: ClaimStatus.missing, + originalLength: 0, ); } - final markIndex = text.lastIndexOf(videoMark); - final originalLength = markIndex > 0 ? markIndex - 1 : source.length; - final original = source.sublist(0, originalLength); - final structure = analyzeVideo(original); - final recheckId = videoDigest(recovered.digestInput(originalLength)); - final structureMatch = recovered.structure.canonical == structure.canonical; - final verified = recheckId == recovered.identifier && structureMatch; - + final eval = _evaluatePayload(recovered, source, claimKey); return VideoVerifyOutcome( recovered: recovered, raw: raw, - recheckId: recheckId, - structureMatch: structureMatch, - verified: verified, + recheckId: eval.recheckId, + structureMatch: eval.structureMatch, + verified: eval.verified, + claimStatus: eval.claimStatus, + originalLength: recovered.contentBytes, ); } @@ -324,11 +560,31 @@ Future embedVideoFingerprint({ required Uint8List source, required String owner, required String fileName, + ClaimKey? claimKey, }) { final issued = DateTime.now().toUtc().toIso8601String(); - return compute(_embedTask, - (source: source, owner: owner, fileName: fileName, issued: issued)); + return compute( + _embedTask, + ( + source: source, + owner: owner, + fileName: fileName, + issued: issued, + keyBytes: claimKey?.bytes, + kid: claimKey?.kid, + ), + ); } -Future verifyVideoFingerprint(Uint8List source) => - compute(_verifyTask, source); +Future verifyVideoFingerprint( + Uint8List source, { + ClaimKey? claimKey, +}) => + compute( + _verifyTask, + ( + source: source, + keyBytes: claimKey?.bytes, + kid: claimKey?.kid, + ), + ); diff --git a/lib/main.dart b/lib/main.dart index ce9d549..1d42184 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,8 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'core/auth.dart'; +import 'core/share_ingress.dart'; import 'screens/login_screen.dart'; import 'screens/shell.dart'; +import 'screens/verify_email_screen.dart'; import 'theme.dart'; Future main() async { @@ -16,6 +18,7 @@ Future main() async { ), ); await AuthService.instance.initialize(); + ShareIngress.instance.start(); runApp(const SignataApp()); } @@ -28,6 +31,12 @@ class SignataApp extends StatelessWidget { animation: AuthService.instance, builder: (context, _) { final auth = AuthService.instance; + final user = auth.user; + final needsEmailVerify = auth.isSignedIn && + user != null && + user.provider == AuthProvider.email && + !user.emailVerified; + return MaterialApp( title: 'Signata', debugShowCheckedModeBanner: false, @@ -38,9 +47,11 @@ class SignataApp extends StatelessWidget { child: CircularProgressIndicator(color: Color(0xFF57D9EC)), ), ) - : auth.isSignedIn - ? const AppShell() - : const LoginScreen(), + : !auth.isSignedIn + ? const LoginScreen() + : needsEmailVerify + ? const VerifyEmailScreen() + : const AppShell(), ); }, ); diff --git a/lib/screens/account_screen.dart b/lib/screens/account_screen.dart index 7a15ad6..9ab7c3e 100644 --- a/lib/screens/account_screen.dart +++ b/lib/screens/account_screen.dart @@ -1,8 +1,24 @@ +import 'dart:convert'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:qr_flutter/qr_flutter.dart'; import '../core/auth.dart'; +import '../core/claim_registry.dart'; +import '../core/google_auth_config.dart'; +import '../core/local_data.dart'; +import '../core/onboarding.dart'; +import '../core/share_utils.dart'; +import '../core/trace_store.dart'; +import '../core/usage_entitlements.dart'; import '../theme.dart'; import '../widgets/em_widgets.dart'; +import '../widgets/usage_paywall.dart'; +import 'privacy_policy_screen.dart'; +import 'terms_of_use_screen.dart'; class AccountScreen extends StatelessWidget { const AccountScreen({super.key}); @@ -62,7 +78,9 @@ class AccountScreen extends StatelessWidget { MonoLabel( user.provider == AuthProvider.google ? 'Google account' - : 'Email account', + : (user.emailVerified + ? 'Email activated' + : 'Email account'), color: EmColors.accent, ), ], @@ -72,6 +90,8 @@ class AccountScreen extends StatelessWidget { ), ), const SizedBox(height: 16), + const _TwoFactorCard(), + const SizedBox(height: 16), EmCard( padding: const EdgeInsets.all(18), child: Column( @@ -80,7 +100,7 @@ class AccountScreen extends StatelessWidget { const MonoLabel('Session'), const SizedBox(height: 10), const Text( - 'Your session stays on this device. Sign out anytime — watermarked files are not affected.', + 'Your session is stored in on-device secure storage. Sign out anytime — watermarked files are not affected.', style: TextStyle( fontSize: 13, height: 1.45, @@ -100,7 +120,837 @@ class AccountScreen extends StatelessWidget { ], ), ), + const SizedBox(height: 16), + const _PlanCard(), + const SizedBox(height: 16), + const _GoogleSignInCard(), + const SizedBox(height: 16), + const _PrivacyDataCard(), + const SizedBox(height: 16), + EmCard( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const MonoLabel('Legal'), + const SizedBox(height: 10), + const Text( + 'How Signata handles data on this device under Saudi PDPL. ' + 'Accounts and fingerprints are local unless you configure an optional registry.', + style: TextStyle( + fontSize: 13, + height: 1.45, + color: EmColors.mutedForeground), + ), + const SizedBox(height: 14), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + OutlinedButton.icon( + onPressed: () => PrivacyPolicyScreen.open(context), + icon: const Icon(Icons.privacy_tip_outlined, size: 18), + label: const Text('Privacy Policy'), + ), + OutlinedButton.icon( + onPressed: () => TermsOfUseScreen.open(context), + icon: const Icon(Icons.gavel_outlined, size: 18), + label: const Text('Terms of Use'), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 24), + Text( + '© ${DateTime.now().year} Wejdan Al Amri · Signata · All rights reserved', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 11, + height: 1.45, + color: EmColors.mutedForeground, + ), + ), ], ); } } + +/// Plan status + quota + mock premium purchase. +class _PlanCard extends StatefulWidget { + const _PlanCard(); + + @override + State<_PlanCard> createState() => _PlanCardState(); +} + +class _PlanCardState extends State<_PlanCard> { + UsageSnapshot? _snapshot; + + @override + void initState() { + super.initState(); + _refresh(); + } + + Future _refresh() async { + final snapshot = await UsageEntitlements.instance.snapshot(); + if (!mounted) return; + setState(() => _snapshot = snapshot); + } + + Future _goPremium() async { + final purchased = await purchasePremiumFlow(context); + if (purchased) await _refresh(); + } + + @override + Widget build(BuildContext context) { + final snap = _snapshot; + return EmCard( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const MonoLabel('Plan'), + const Spacer(), + if (snap != null) + MonoLabel( + snap.isPremium ? 'Premium' : 'Free', + color: snap.isPremium ? EmColors.accent : EmColors.primary, + ), + ], + ), + const SizedBox(height: 10), + if (snap == null) + const Text( + 'Loading plan…', + style: TextStyle(fontSize: 13, color: EmColors.mutedForeground), + ) + else if (snap.isPremium) + const Text( + 'Unlimited protects and Trace scans. Thanks for supporting Signata.', + style: TextStyle( + fontSize: 13, height: 1.45, color: EmColors.mutedForeground), + ) + else ...[ + Text( + '${snap.protectRemaining} of ${snap.protectLimit} free protects and ' + '${snap.traceRemaining} of ${snap.traceLimit} free Trace scans left today. ' + 'Watch an ad for extras, or go Premium for unlimited use.', + style: const TextStyle( + fontSize: 13, height: 1.45, color: EmColors.mutedForeground), + ), + const SizedBox(height: 14), + FilledButton.icon( + onPressed: _goPremium, + icon: const Icon(Icons.workspace_premium_outlined, size: 18), + label: const Text('Go Premium'), + ), + const SizedBox(height: 8), + const Text( + 'Demo billing — real Play Billing / AdMob come later.', + style: TextStyle(fontSize: 11.5, color: EmColors.mutedForeground), + ), + ], + ], + ), + ); + } +} + +class _TwoFactorCard extends StatefulWidget { + const _TwoFactorCard(); + + @override + State<_TwoFactorCard> createState() => _TwoFactorCardState(); +} + +class _TwoFactorCardState extends State<_TwoFactorCard> { + bool _busy = false; + String? _setupSecret; + String? _setupUri; + final _codeController = TextEditingController(); + final _passwordController = TextEditingController(); + + @override + void dispose() { + _codeController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _startSetup() async { + setState(() => _busy = true); + try { + final setup = await AuthService.instance.beginTotpSetup(); + if (!mounted) return; + setState(() { + _setupSecret = setup.secret; + _setupUri = setup.otpauth; + _codeController.clear(); + }); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('$error'.replaceFirst('Exception: ', ''))), + ); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _cancelSetup() async { + setState(() => _busy = true); + try { + await AuthService.instance.cancelTotpSetup(); + if (!mounted) return; + setState(() { + _setupSecret = null; + _setupUri = null; + _codeController.clear(); + }); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _confirmSetup() async { + setState(() => _busy = true); + try { + await AuthService.instance.confirmTotpSetup(_codeController.text); + if (!mounted) return; + setState(() { + _setupSecret = null; + _setupUri = null; + _codeController.clear(); + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Two-factor authentication enabled.')), + ); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('$error'.replaceFirst('Exception: ', ''))), + ); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _disable() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: EmColors.card, + title: const Text('Disable 2FA?'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Enter your password and a current authenticator code.', + style: TextStyle(height: 1.45), + ), + const SizedBox(height: 14), + TextField( + controller: _passwordController, + obscureText: true, + decoration: const InputDecoration(labelText: 'Password'), + ), + const SizedBox(height: 10), + TextField( + controller: _codeController, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(6), + ], + decoration: const InputDecoration(labelText: 'Authenticator code'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Disable'), + ), + ], + ), + ); + if (confirmed != true) return; + setState(() => _busy = true); + try { + await AuthService.instance.disableTotp( + password: _passwordController.text, + totpCode: _codeController.text, + ); + if (!mounted) return; + _passwordController.clear(); + _codeController.clear(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Two-factor authentication disabled.')), + ); + setState(() {}); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('$error'.replaceFirst('Exception: ', ''))), + ); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final user = AuthService.instance.user; + if (user == null) return const SizedBox.shrink(); + + if (user.provider == AuthProvider.google) { + return EmCard( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const MonoLabel('Two-factor auth'), + const SizedBox(height: 10), + const Text( + 'This Google account uses Google’s own 2FA settings. Manage them in your Google Account security page.', + style: TextStyle( + fontSize: 13, height: 1.45, color: EmColors.mutedForeground), + ), + ], + ), + ); + } + + final enabled = user.totpEnabled; + return EmCard( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const MonoLabel('Two-factor auth'), + const Spacer(), + MonoLabel( + enabled ? 'On' : 'Off', + color: enabled ? EmColors.accent : EmColors.mutedForeground, + ), + ], + ), + const SizedBox(height: 10), + Text( + enabled + ? 'Sign-in requires your password plus a code from an authenticator app (Google Authenticator, Authy, etc.).' + : 'Add an authenticator app for a second step after your password. Secrets stay on this device.', + style: const TextStyle( + fontSize: 13, height: 1.45, color: EmColors.mutedForeground), + ), + const SizedBox(height: 14), + if (_setupSecret != null) ...[ + const Text( + 'Scan this QR in your authenticator app (or enter the secret), then confirm the 6-digit code:', + style: TextStyle(fontSize: 13, height: 1.45), + ), + if (_setupUri != null) ...[ + const SizedBox(height: 14), + Center( + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: QrImageView( + data: _setupUri!, + size: 180, + backgroundColor: Colors.white, + ), + ), + ), + ], + const SizedBox(height: 12), + SelectableText( + _setupSecret!, + style: emMono(size: 13), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + TextButton.icon( + onPressed: () async { + await copySecretTemporarily(_setupSecret!); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Secret copied (clipboard clears in ~45s)'), + ), + ); + }, + icon: const Icon(Icons.copy, size: 16), + label: const Text('Copy secret'), + ), + if (_setupUri != null) + TextButton.icon( + onPressed: () async { + await copySecretTemporarily(_setupUri!); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Setup link copied (clears in ~45s)'), + ), + ); + }, + icon: const Icon(Icons.link, size: 16), + label: const Text('Copy setup link'), + ), + ], + ), + const SizedBox(height: 10), + TextField( + controller: _codeController, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(6), + ], + decoration: const InputDecoration( + labelText: 'Confirm code', + hintText: '123456', + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: FilledButton( + onPressed: _busy ? null : _confirmSetup, + child: const Text('Confirm & enable'), + ), + ), + const SizedBox(width: 10), + TextButton( + onPressed: _busy ? null : _cancelSetup, + child: const Text('Cancel'), + ), + ], + ), + ] else if (enabled) + OutlinedButton.icon( + onPressed: _busy ? null : _disable, + icon: const Icon(Icons.shield_outlined, size: 18), + label: const Text('Disable 2FA'), + ) + else + FilledButton.icon( + onPressed: _busy ? null : _startSetup, + icon: const Icon(Icons.phonelink_lock_outlined, size: 18), + label: const Text('Enable 2FA'), + ), + ], + ), + ); + } +} + +class _PrivacyDataCard extends StatelessWidget { + const _PrivacyDataCard(); + + Future _exportRecovery(BuildContext context) async { + try { + final kit = await buildRecoveryKit(); + final bytes = Uint8List.fromList(utf8.encode(kit)); + await shareBytes( + bytes: bytes, + fileName: 'signata-recovery-kit.txt', + mimeType: 'text/plain', + text: 'Signata recovery kit — keep offline and private.', + ); + await OnboardingFlags.instance.markRecoveryExported(); + } catch (error) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('$error'.replaceFirst('Exception: ', ''))), + ); + } + } + + Future _importRecovery(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: EmColors.card, + title: const Text('Import recovery kit?'), + content: const Text( + 'This replaces your claim key on this device with the key from the ' + 'kit. Only do this when restoring the same Signata identity.\n\n' + 'Fingerprints sealed with a different key will no longer show as ' + 'authenticated for this account.', + style: TextStyle(height: 1.45), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Choose file'), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return; + + final picked = await FilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: const ['txt', 'kit', 'text'], + ); + final file = picked?.files.firstOrNull; + if (file == null) return; + try { + final bytes = await file.readAsBytes(); + final kid = await importRecoveryKit(utf8.decode(bytes)); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Claim key restored (kid $kid).')), + ); + } catch (error) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('$error'.replaceFirst('Exception: ', ''))), + ); + } + } + + Future _clearClaims(BuildContext context) async { + final ok = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: EmColors.card, + title: const Text('Clear published claims?'), + content: const Text( + 'Removes local claim catalog entries on this device. ' + 'Watermarked files keep their embedded fingerprints.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Clear'), + ), + ], + ), + ); + if (ok != true) return; + await ClaimRegistry.instance.clearLocal(); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Published claims cleared.')), + ); + } + + Future _clearTrace(BuildContext context) async { + final ok = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: EmColors.card, + title: const Text('Clear Trace data?'), + content: const Text( + 'Removes your watchlist and sighting timeline on this device. ' + 'Protected files are not affected.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Clear'), + ), + ], + ), + ); + if (ok != true) return; + await TraceStore.instance.clearAll(); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Trace data cleared.')), + ); + } + + Future _deleteAccount(BuildContext context) async { + final user = AuthService.instance.user; + if (user == null) return; + final passwordController = TextEditingController(); + final totpController = TextEditingController(); + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: EmColors.card, + title: const Text('Delete local account?'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'This erases your Signata account record, claim key, history, ' + 'Trace data, and usage flags on this device. Watermarked files ' + 'you already exported are not deleted from storage.', + style: TextStyle(height: 1.45), + ), + if (user.provider == AuthProvider.email) ...[ + const SizedBox(height: 14), + TextField( + controller: passwordController, + obscureText: true, + decoration: const InputDecoration(labelText: 'Password'), + ), + if (user.totpEnabled) ...[ + const SizedBox(height: 10), + TextField( + controller: totpController, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(6), + ], + decoration: + const InputDecoration(labelText: 'Authenticator code'), + ), + ], + ] else + const Padding( + padding: EdgeInsets.only(top: 12), + child: Text( + 'Google sessions only need this confirmation — no password.', + style: TextStyle(fontSize: 13, color: EmColors.mutedForeground), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: EmColors.destructive, + ), + onPressed: () => Navigator.pop(context, true), + child: const Text('Delete forever'), + ), + ], + ), + ); + final password = passwordController.text; + final totp = totpController.text; + passwordController.dispose(); + totpController.dispose(); + if (confirmed != true) return; + try { + await AuthService.instance.deleteAccount( + password: password, + totpCode: totp.isEmpty ? null : totp, + ); + } catch (error) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('$error'.replaceFirst('Exception: ', ''))), + ); + } + } + + @override + Widget build(BuildContext context) { + return EmCard( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const MonoLabel('Privacy & data'), + const SizedBox(height: 10), + const Text( + 'Export a recovery kit before wiping this device. Import it after ' + 'reinstall to keep authenticating old fingerprints. Clear Trace or ' + 'claims anytime. Delete account removes local Signata identity data.', + style: TextStyle( + fontSize: 13, height: 1.45, color: EmColors.mutedForeground), + ), + const SizedBox(height: 14), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + OutlinedButton.icon( + onPressed: () => _exportRecovery(context), + icon: const Icon(Icons.key_outlined, size: 18), + label: const Text('Export recovery kit'), + ), + OutlinedButton.icon( + onPressed: () => _importRecovery(context), + icon: const Icon(Icons.upload_file_outlined, size: 18), + label: const Text('Import recovery kit'), + ), + OutlinedButton.icon( + onPressed: () => _clearTrace(context), + icon: const Icon(Icons.radar_outlined, size: 18), + label: const Text('Clear Trace data'), + ), + OutlinedButton.icon( + onPressed: () => _clearClaims(context), + icon: const Icon(Icons.inventory_2_outlined, size: 18), + label: const Text('Clear claims'), + ), + OutlinedButton.icon( + onPressed: () => _deleteAccount(context), + icon: const Icon(Icons.delete_forever_outlined, size: 18), + label: const Text('Delete account'), + style: OutlinedButton.styleFrom( + foregroundColor: EmColors.destructive, + side: BorderSide( + color: EmColors.destructive.withValues(alpha: 0.5)), + ), + ), + ], + ), + ], + ), + ); + } +} + +class _GoogleSignInCard extends StatefulWidget { + const _GoogleSignInCard(); + + @override + State<_GoogleSignInCard> createState() => _GoogleSignInCardState(); +} + +class _GoogleSignInCardState extends State<_GoogleSignInCard> { + bool _busy = false; + + Future _configure() async { + final controller = TextEditingController( + text: GoogleAuthConfig.hasServerClientId && !GoogleAuthConfig.isFromDartDefine + ? GoogleAuthConfig.serverClientId + : '', + ); + final saved = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: EmColors.card, + title: const Text('Google Web client ID'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Paste the OAuth Web application client ID from Google Cloud. ' + 'Also add Android clients with package app.signata.signata and ' + 'your SHA-1s (see docs/RELEASE_PLAY.md).', + style: TextStyle(height: 1.45), + ), + const SizedBox(height: 14), + TextField( + controller: controller, + autocorrect: false, + decoration: const InputDecoration( + labelText: 'Web client ID', + hintText: '….apps.googleusercontent.com', + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Save'), + ), + ], + ), + ); + final value = controller.text; + controller.dispose(); + if (saved != true) return; + setState(() => _busy = true); + try { + await AuthService.instance.configureGoogleServerClientId(value); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Google Sign-In configured. Sign out and try Google.'), + ), + ); + setState(() {}); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('$error'.replaceFirst('Exception: ', ''))), + ); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final configured = AuthService.instance.isGoogleConfigured; + return EmCard( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const MonoLabel('Google Sign-In'), + const Spacer(), + MonoLabel( + configured ? 'Ready' : 'Not configured', + color: configured ? EmColors.accent : EmColors.destructive, + ), + ], + ), + const SizedBox(height: 10), + Text( + configured + ? (GoogleAuthConfig.isFromDartDefine + ? 'Using the Web client ID baked into this build.' + : 'Using a Web client ID saved on this device.') + : 'Email login works without Google. To enable Continue with Google, ' + 'paste your Web client ID here or build with google_oauth.env.', + style: const TextStyle( + fontSize: 13, height: 1.45, color: EmColors.mutedForeground), + ), + if (kDebugMode || !configured) ...[ + const SizedBox(height: 14), + OutlinedButton.icon( + onPressed: _busy ? null : _configure, + icon: const Icon(Icons.vpn_key_outlined, size: 18), + label: Text(configured ? 'Update Web client ID' : 'Paste Web client ID'), + ), + ], + ], + ), + ); + } +} diff --git a/lib/screens/audio_tool_screen.dart b/lib/screens/audio_tool_screen.dart index dcd5b6e..0cc98ec 100644 --- a/lib/screens/audio_tool_screen.dart +++ b/lib/screens/audio_tool_screen.dart @@ -4,16 +4,23 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import '../core/audio_watermark.dart'; +import '../core/claim_crypto.dart'; +import '../core/claim_status_ui.dart'; import '../core/history.dart'; +import '../core/onboarding.dart'; import '../core/report.dart'; import '../core/share_utils.dart'; +import '../core/trace_models.dart'; +import '../core/usage_entitlements.dart'; import '../theme.dart'; import '../widgets/em_widgets.dart'; +import '../widgets/publish_claim_button.dart'; +import '../widgets/usage_paywall.dart'; enum AudioMode { embed, verify } class AudioToolScreen extends StatefulWidget { - const AudioToolScreen({super.key, this.defaultOwner = 'Studio Nova'}); + const AudioToolScreen({super.key, this.defaultOwner = ''}); final String defaultOwner; @@ -55,6 +62,10 @@ class _AudioToolScreenState extends State { } Future _pickAndRun() async { + if (_mode == AudioMode.embed) { + final allowed = await ensureUsage(context, UsageKind.protect); + if (!allowed || !mounted) return; + } final picked = await FilePicker.pickFiles( type: FileType.custom, allowedExtensions: const ['wav'], @@ -68,12 +79,14 @@ class _AudioToolScreenState extends State { final stopwatch = Stopwatch()..start(); try { + final claimKey = await AccountClaimKeys.current(); if (_mode == AudioMode.embed) { setState(() => _workingStep = 'Embedding inaudible fingerprint'); final outcome = await embedAudioWatermark( fileBytes: bytes, owner: _ownerController.text, fileName: file.name, + claimKey: claimKey, ); stopwatch.stop(); if (!mounted) return; @@ -87,13 +100,15 @@ class _AudioToolScreenState extends State { action: 'embed', subject: file.name, owner: outcome.payload.owner, - verified: outcome.verified, + verified: claimCountsAsVerified(outcome.claimStatus), reference: outcome.payload.signature, at: DateTime.now(), )); + await commitUsage(UsageKind.protect); + await OnboardingFlags.instance.markProtectedOnce(); } else { setState(() => _workingStep = 'Scanning audio samples'); - final outcome = await extractAudioWatermark(bytes); + final outcome = await extractAudioWatermark(bytes, claimKey: claimKey); stopwatch.stop(); final payload = AudioPayload.tryParse(outcome.raw); if (!mounted) return; @@ -108,7 +123,7 @@ class _AudioToolScreenState extends State { action: 'verify', subject: file.name, owner: payload?.owner ?? 'Unknown', - verified: payload?.signatureValid ?? false, + verified: claimCountsAsVerified(outcome.claimStatus), reference: payload?.signature ?? '—', at: DateTime.now(), )); @@ -133,16 +148,42 @@ class _AudioToolScreenState extends State { ); } + Future _downloadMarked() async { + final outcome = _embedOutcome; + if (outcome == null) return; + try { + final path = await downloadBytes( + bytes: outcome.markedWav, + fileName: 'signata-${_fileName ?? 'audio.wav'}', + dialogTitle: 'Download watermarked audio', + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Saved to $path')), + ); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + error.toString().replaceFirst('Exception: ', ''), + ), + ), + ); + } + } + Future _exportReport() async { final outcome = _embedOutcome; if (outcome == null) return; setState(() => _reportStatus = 'Sealing…'); try { + final claimKey = await AccountClaimKeys.current(); final sealed = sealReport(ReportBody( medium: 'audio', subject: _fileName ?? outcome.payload.asset, owner: outcome.payload.owner, - verified: outcome.verified, + verified: claimCountsAsVerified(outcome.claimStatus), issued: outcome.payload.issued, generated: DateTime.now().toUtc().toIso8601String(), evidence: { @@ -155,8 +196,8 @@ class _AudioToolScreenState extends State { '${outcome.bitsUsed} / ${outcome.capacityBits} bits', 'roundTripMs': _elapsedMs, }, - )); - if (!verifySealedReport(sealed)) { + ), claimKey: claimKey); + if (!verifySealedReport(sealed, claimKey: claimKey)) { throw Exception('Seal self-check failed.'); } await shareBytes( @@ -180,7 +221,7 @@ class _AudioToolScreenState extends State { tag: 'Audio watermarking', title: 'Inaudible ownership in WAV', desc: - 'Signata flips the least-significant bit of 16-bit PCM samples to carry an encrypted ownership claim — inaudible, recoverable, processed on-device.', + 'Signata flips the least-significant bit of 16-bit PCM samples to carry a signed ownership claim — inaudible, recoverable, processed on-device.', ), const SizedBox(height: 20), SegmentedButton( @@ -208,12 +249,17 @@ class _AudioToolScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_mode == AudioMode.embed) ...[ + const UsageStatusBanner(kind: UsageKind.protect), const MonoLabel('Ownership claim'), const SizedBox(height: 10), TextField( controller: _ownerController, + readOnly: true, decoration: const InputDecoration( hintText: 'Creator or studio name', + helperText: + 'Bound to your Signata account — cannot be spoofed for authentication.', + helperMaxLines: 2, ), ), const SizedBox(height: 16), @@ -223,7 +269,9 @@ class _AudioToolScreenState extends State { title: _mode == AudioMode.embed ? 'Choose a WAV to protect' : 'Choose a WAV to check', - hint: '16-bit PCM WAV — processed locally', + hint: _mode == AudioMode.embed + ? '16-bit PCM WAV only — MP3/AAC strip LSB marks' + : '16-bit PCM WAV carrying a Signata mark', buttonLabel: 'Choose audio', onPick: _pickAndRun, fileName: _fileName, @@ -241,10 +289,31 @@ class _AudioToolScreenState extends State { mono: true), ]), const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: _shareMarked, - icon: const Icon(Icons.ios_share, size: 18), - label: const Text('Share watermarked WAV'), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + FilledButton.icon( + onPressed: _downloadMarked, + icon: const Icon(Icons.download, size: 18), + label: const Text('Download'), + ), + OutlinedButton.icon( + onPressed: _shareMarked, + icon: const Icon(Icons.ios_share, size: 18), + label: const Text('Share'), + ), + ], + ), + const SizedBox(height: 10), + PublishClaimButton( + medium: TraceMedium.audio, + owner: _embedOutcome!.payload.owner, + subject: _embedOutcome!.payload.asset, + reference: _embedOutcome!.payload.signature, + issued: _embedOutcome!.payload.issued, + alg: _embedOutcome!.payload.alg, + kid: _embedOutcome!.payload.kid, ), ], ], @@ -302,21 +371,23 @@ class _AudioToolScreenState extends State { if (_mode == AudioMode.embed && _embedOutcome != null) { final outcome = _embedOutcome!; final recovered = outcome.recovered; + final banner = claimBanner(outcome.claimStatus); return [ StatusBanner( - ok: outcome.verified, - title: outcome.verified - ? 'Ownership verified' - : 'No valid watermark recovered', - subtitle: outcome.verified - ? 'Extracted signature matches the embedded fingerprint.' - : 'The payload could not be read back intact from this file.', + ok: banner.ok, + title: banner.title, + subtitle: banner.subtitle, ), const SizedBox(height: 14), DetailList(rows: [ DetailRow('Owner', recovered?.owner ?? '—'), DetailRow('Asset', recovered?.asset ?? '—', mono: true), DetailRow('Signature', recovered?.signature ?? '—', mono: true), + DetailRow( + 'Key id', + _kidLabel(recovered?.alg ?? outcome.payload.alg, + recovered?.kid ?? outcome.payload.kid), + mono: true), DetailRow('Round trip', '$_elapsedMs ms', mono: true), ]), const SizedBox(height: 14), @@ -337,18 +408,12 @@ class _AudioToolScreenState extends State { if (_mode == AudioMode.verify && _verifyOutcome != null) { final payload = _verifyPayload; - final genuine = payload != null && payload.signatureValid; + final banner = claimBanner(_verifyOutcome!.claimStatus); return [ StatusBanner( - ok: genuine, - title: genuine - ? 'Signata audio watermark verified' - : payload != null - ? 'Watermark found but signature is invalid' - : 'No watermark found', - subtitle: genuine - ? 'The recovered signature matches the ownership claim.' - : 'This clip does not carry a readable Signata audio fingerprint.', + ok: banner.ok, + title: banner.title, + subtitle: banner.subtitle, ), if (payload != null) ...[ const SizedBox(height: 14), @@ -356,6 +421,8 @@ class _AudioToolScreenState extends State { DetailRow('Owner', payload.owner), DetailRow('Asset', payload.asset, mono: true), DetailRow('Signature', payload.signature, mono: true), + DetailRow( + 'Key id', _kidLabel(payload.alg, payload.kid), mono: true), DetailRow( 'Format', '${_verifyOutcome!.sampleRate} Hz · ${_verifyOutcome!.channels} ch', @@ -375,4 +442,10 @@ class _AudioToolScreenState extends State { ), ]; } + + static String _kidLabel(String? alg, String? kid) { + if (alg == null || alg.isEmpty || alg == claimAlgFnv16) return 'legacy'; + if (kid != null && kid.isNotEmpty) return kid; + return 'legacy'; + } } diff --git a/lib/screens/history_screen.dart b/lib/screens/history_screen.dart index bee87b4..d3f951b 100644 --- a/lib/screens/history_screen.dart +++ b/lib/screens/history_screen.dart @@ -5,7 +5,9 @@ import '../theme.dart'; import '../widgets/em_widgets.dart'; class HistoryScreen extends StatefulWidget { - const HistoryScreen({super.key}); + const HistoryScreen({super.key, this.onOpenTools}); + + final VoidCallback? onOpenTools; @override State createState() => HistoryScreenState(); @@ -73,7 +75,7 @@ class HistoryScreenState extends State { children: [ const EmLogo(size: 44, showWordmark: false), const SizedBox(height: 20), - Text('No verifications yet', + Text('No activity yet', style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 8), const Text( @@ -84,6 +86,13 @@ class HistoryScreenState extends State { height: 1.5, color: EmColors.mutedForeground), ), + if (widget.onOpenTools != null) ...[ + const SizedBox(height: 18), + FilledButton( + onPressed: widget.onOpenTools, + child: const Text('Open Tools'), + ), + ], ], ), ), diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index dd006df..4c702f1 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../core/onboarding.dart'; import '../theme.dart'; import '../widgets/em_widgets.dart'; import 'tools_hub_screen.dart'; @@ -65,75 +66,83 @@ const _features = [ const _steps = [ ( '01', - 'Embed', - 'An encrypted fingerprint is encoded into the media imperceptibly, preserving the original experience.' + 'Protect', + 'Signata embeds a signed fingerprint into your media on this device — invisible, bound to your account key.' ), ( '02', - 'Register', - "Ownership metadata is stored and cryptographically bound to the creator's identity in the database." + 'Publish locally', + 'Claims stay on your phone (optional self-hosted registry later). No Signata cloud database is required.' ), ( '03', - 'Verify', - 'Any distributed copy can be scanned to extract the fingerprint and confirm authenticity and ownership.' + 'Trace & verify', + 'Scan a shared file or URL to recover the fingerprint and confirm it matches your Signata key.' ), ]; const _goals = [ 'Protect creators from unauthorized redistribution', - 'Preserve ownership information across platforms', - 'Build a reliable verification system', + 'Preserve ownership signals across social recompression when possible', + 'Keep fingerprints verifiable entirely on-device', 'Research robust watermark persistence techniques', 'Explore anti-tampering and anti-removal methods', ]; const _tech = [ - 'Python', - 'FastAPI', - 'OpenCV', - 'NumPy', - 'Cryptography', - 'PostgreSQL', + 'Flutter', + 'Dart', + 'HMAC-SHA256', + 'LSB / WAV stego', + 'On-device secure storage', + 'Android share intents', ]; const _milestones = [ ( 'Shipped in app', true, - 'On-device prototype suite', + 'On-device creator suite', [ 'Image LSB watermarking', 'Audio WAV watermarking', 'Video + PDF structural fingerprints', + 'Trace radar + account-bound claims', ] ), ( 'In progress', true, - 'Ownership verification APIs', + 'Social resilience & Trace', [ - 'Server-side registration', - 'Cross-device verification', - 'Creator identity binding', + 'Share-intent first workflows', + 'Watchlist digests', + 'Clearer platform-strip messaging', ] ), ( - 'Planned', + 'Research roadmap', false, - 'Hardening & research', + 'Optional cloud later', [ - 'Anti-tamper resilience', - 'Robustness benchmarking', - 'Production verification API', + 'Optional claim registry', + 'Cross-device sync (optional)', + 'Stronger anti-recompress marks', ] ), ]; class HomeScreen extends StatelessWidget { - const HomeScreen({super.key, required this.onOpenTool}); + const HomeScreen({ + super.key, + required this.onOpenTool, + this.onOpenAccount, + this.checklistNonce = 0, + }); final void Function(EmTool tool) onOpenTool; + final VoidCallback? onOpenAccount; + final int checklistNonce; @override Widget build(BuildContext context) { @@ -141,6 +150,11 @@ class HomeScreen extends StatelessWidget { padding: EdgeInsets.zero, children: [ _Hero(onOpenTool: onOpenTool), + _StarterChecklist( + key: ValueKey('checklist-$checklistNonce'), + onOpenTool: onOpenTool, + onOpenAccount: onOpenAccount, + ), const _StatsStrip(), const _SectionPadding(child: _Features()), _bandDecoration(const _HowItWorks()), @@ -166,6 +180,149 @@ class HomeScreen extends StatelessWidget { } } +class _StarterChecklist extends StatefulWidget { + const _StarterChecklist({ + super.key, + required this.onOpenTool, + this.onOpenAccount, + }); + + final void Function(EmTool tool) onOpenTool; + final VoidCallback? onOpenAccount; + + @override + State<_StarterChecklist> createState() => _StarterChecklistState(); +} + +class _StarterChecklistState extends State<_StarterChecklist> { + bool _loading = true; + bool _show = false; + bool _recovery = false; + bool _protected = false; + bool _traced = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final show = await OnboardingFlags.instance.shouldShowChecklist(); + final progress = await OnboardingFlags.instance.checklistProgress(); + if (!mounted) return; + setState(() { + _show = show; + _recovery = progress.recovery; + _protected = progress.protected; + _traced = progress.traced; + _loading = false; + }); + } + + @override + Widget build(BuildContext context) { + if (_loading || !_show) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 0), + child: EmCard( + borderColor: EmColors.accent.withValues(alpha: 0.35), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Expanded( + child: MonoLabel('Starter checklist', color: EmColors.accent), + ), + TextButton( + onPressed: () async { + await OnboardingFlags.instance.dismissChecklist(); + if (!mounted) return; + setState(() => _show = false); + }, + child: const Text('Dismiss'), + ), + ], + ), + const SizedBox(height: 8), + const Text( + 'Three local steps to get value from Signata quickly:', + style: TextStyle( + fontSize: 13, + height: 1.4, + color: EmColors.mutedForeground, + ), + ), + const SizedBox(height: 12), + _CheckRow( + done: _recovery, + label: 'Export a recovery kit', + action: 'Account', + onTap: widget.onOpenAccount, + ), + _CheckRow( + done: _protected, + label: 'Protect one file', + action: 'Image tool', + onTap: () => widget.onOpenTool(EmTool.image), + ), + _CheckRow( + done: _traced, + label: 'Check a file in Trace', + action: 'Trace', + onTap: () => widget.onOpenTool(EmTool.trace), + ), + ], + ), + ), + ); + } +} + +class _CheckRow extends StatelessWidget { + const _CheckRow({ + required this.done, + required this.label, + required this.action, + this.onTap, + }); + + final bool done; + final String label; + final String action; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Icon( + done ? Icons.check_circle : Icons.radio_button_unchecked, + size: 18, + color: done ? EmColors.accent : EmColors.mutedForeground, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + label, + style: TextStyle( + fontSize: 13.5, + decoration: done ? TextDecoration.lineThrough : null, + color: done ? EmColors.mutedForeground : EmColors.foreground, + ), + ), + ), + if (!done && onTap != null) + TextButton(onPressed: onTap, child: Text(action)), + ], + ), + ); + } +} + class _SectionPadding extends StatelessWidget { const _SectionPadding({required this.child}); @@ -611,17 +768,17 @@ class _Architecture extends StatelessWidget { const _Architecture(); static const _nodes = [ - ('Client', 'Upload & verify'), - ('FastAPI Backend', 'REST API layer'), - ('Watermark Engine', 'Processing core'), - ('Database', 'Ownership records'), + ('Signata app', 'Protect · Trace · Verify'), + ('On-device crypto', 'Account HMAC claim keys'), + ('Fingerprint engines', 'Image · audio · video · PDF'), + ('Local stores', 'History · radar · claims'), ]; static const _engine = [ - 'Image Processing', - 'Audio Processing', - 'PDF Fingerprinting', - 'Verification System', + 'Image LSB watermark', + 'Audio WAV fingerprint', + 'Video / PDF structure marks', + 'Trace URL + share ingress', ]; @override @@ -631,10 +788,10 @@ class _Architecture extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const SectionHeading( - tag: 'Planned architecture', - title: 'A layered watermarking pipeline', + tag: 'Architecture', + title: 'Built to run on your device', desc: - 'From client request to encrypted fingerprint to verified ownership record.', + 'Fingerprints, claim keys, and Trace history stay local. Optional remote registry is off unless you configure it.', ), const SizedBox(height: 24), for (var i = 0; i < _nodes.length; i++) ...[ @@ -900,18 +1057,10 @@ class _Footer extends StatelessWidget { const SizedBox(height: 20), const Divider(), const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '© ${DateTime.now().year} Signata', - style: const TextStyle( - fontSize: 11, color: EmColors.mutedForeground), - ), - Text('RESEARCH & PROTOTYPE PHASE', - style: emMonoLabel( - color: EmColors.mutedForeground, size: 9)), - ], + Text( + '© ${DateTime.now().year} Wejdan Al Amri · Signata · All rights reserved', + style: const TextStyle( + fontSize: 11, height: 1.45, color: EmColors.mutedForeground), ), ], ), diff --git a/lib/screens/image_tool_screen.dart b/lib/screens/image_tool_screen.dart index d8a86a7..95d5e63 100644 --- a/lib/screens/image_tool_screen.dart +++ b/lib/screens/image_tool_screen.dart @@ -3,17 +3,24 @@ import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import '../core/claim_crypto.dart'; +import '../core/claim_status_ui.dart'; import '../core/history.dart'; import '../core/image_watermark.dart'; +import '../core/onboarding.dart'; import '../core/report.dart'; import '../core/share_utils.dart'; +import '../core/trace_models.dart'; +import '../core/usage_entitlements.dart'; import '../theme.dart'; import '../widgets/em_widgets.dart'; +import '../widgets/publish_claim_button.dart'; +import '../widgets/usage_paywall.dart'; enum ToolMode { embed, verify } class ImageToolScreen extends StatefulWidget { - const ImageToolScreen({super.key, this.defaultOwner = 'Studio Nova'}); + const ImageToolScreen({super.key, this.defaultOwner = ''}); final String defaultOwner; @@ -56,6 +63,10 @@ class _ImageToolScreenState extends State { } Future _pickAndRun() async { + if (_mode == ToolMode.embed) { + final allowed = await ensureUsage(context, UsageKind.protect); + if (!allowed || !mounted) return; + } final picked = await FilePicker.pickFiles(type: FileType.image); final file = picked?.files.firstOrNull; if (file == null) return; @@ -66,12 +77,14 @@ class _ImageToolScreenState extends State { final stopwatch = Stopwatch()..start(); try { + final claimKey = await AccountClaimKeys.current(); if (_mode == ToolMode.embed) { - setState(() => _workingStep = 'Embedding encrypted fingerprint'); + setState(() => _workingStep = 'Embedding signed ownership fingerprint'); final outcome = await embedWatermark( fileBytes: bytes, owner: _ownerController.text, fileName: file.name, + claimKey: claimKey, ); stopwatch.stop(); if (!mounted) return; @@ -85,13 +98,15 @@ class _ImageToolScreenState extends State { action: 'embed', subject: file.name, owner: outcome.payload.owner, - verified: outcome.verified, + verified: claimCountsAsVerified(outcome.claimStatus), reference: outcome.payload.signature, at: DateTime.now(), )); + await commitUsage(UsageKind.protect); + await OnboardingFlags.instance.markProtectedOnce(); } else { setState(() => _workingStep = 'Scanning for a watermark'); - final outcome = await extractWatermark(bytes); + final outcome = await extractWatermark(bytes, claimKey: claimKey); stopwatch.stop(); final payload = WatermarkPayload.tryParse(outcome.raw); if (!mounted) return; @@ -106,7 +121,7 @@ class _ImageToolScreenState extends State { action: 'verify', subject: file.name, owner: payload?.owner ?? 'Unknown', - verified: payload?.signatureValid ?? false, + verified: claimCountsAsVerified(outcome.claimStatus), reference: payload?.signature ?? '—', at: DateTime.now(), )); @@ -131,17 +146,43 @@ class _ImageToolScreenState extends State { ); } + Future _downloadMarked() async { + final outcome = _embedOutcome; + if (outcome == null) return; + try { + final path = await downloadBytes( + bytes: outcome.markedPng, + fileName: 'signata-${_fileName ?? 'image'}.png', + dialogTitle: 'Download watermarked image', + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Saved to $path')), + ); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + error.toString().replaceFirst('Exception: ', ''), + ), + ), + ); + } + } + Future _exportReport() async { final outcome = _embedOutcome; if (outcome == null) return; setState(() => _reportStatus = 'Sealing…'); try { + final claimKey = await AccountClaimKeys.current(); final recovered = outcome.recovered; final sealed = sealReport(ReportBody( medium: 'image', subject: _fileName ?? outcome.payload.asset, owner: outcome.payload.owner, - verified: outcome.verified, + verified: claimCountsAsVerified(outcome.claimStatus), issued: outcome.payload.issued, generated: DateTime.now().toUtc().toIso8601String(), evidence: { @@ -155,8 +196,8 @@ class _ImageToolScreenState extends State { '${outcome.bitsUsed} / ${outcome.capacityBits} bits', 'roundTripMs': _elapsedMs, }, - )); - if (!verifySealedReport(sealed)) { + ), claimKey: claimKey); + if (!verifySealedReport(sealed, claimKey: claimKey)) { throw Exception('Seal self-check failed.'); } await shareBytes( @@ -208,12 +249,17 @@ class _ImageToolScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_mode == ToolMode.embed) ...[ + const UsageStatusBanner(kind: UsageKind.protect), const MonoLabel('Ownership claim'), const SizedBox(height: 10), TextField( controller: _ownerController, + readOnly: true, decoration: const InputDecoration( hintText: 'Creator or studio name', + helperText: + 'Bound to your Signata account — cannot be spoofed for authentication.', + helperMaxLines: 2, ), ), const SizedBox(height: 16), @@ -226,8 +272,8 @@ class _ImageToolScreenState extends State { ? 'Choose an image to protect' : 'Choose an image to check', hint: _mode == ToolMode.embed - ? 'PNG, JPG or WEBP — processed locally' - : 'Works with any PNG carrying an Signata watermark', + ? 'Prefer PNG. JPG/WEBP recompression can weaken LSB marks.' + : 'Works with any PNG carrying a Signata watermark', buttonLabel: 'Choose an image', onPick: _pickAndRun, fileName: _fileName, @@ -237,10 +283,31 @@ class _ImageToolScreenState extends State { const SizedBox(height: 16), _ImagePair(outcome: _embedOutcome!), const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: _shareMarked, - icon: const Icon(Icons.ios_share, size: 18), - label: const Text('Share watermarked PNG'), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + FilledButton.icon( + onPressed: _downloadMarked, + icon: const Icon(Icons.download, size: 18), + label: const Text('Download'), + ), + OutlinedButton.icon( + onPressed: _shareMarked, + icon: const Icon(Icons.ios_share, size: 18), + label: const Text('Share'), + ), + ], + ), + const SizedBox(height: 10), + PublishClaimButton( + medium: TraceMedium.image, + owner: _embedOutcome!.payload.owner, + subject: _embedOutcome!.payload.asset, + reference: _embedOutcome!.payload.signature, + issued: _embedOutcome!.payload.issued, + alg: _embedOutcome!.payload.alg, + kid: _embedOutcome!.payload.kid, ), ], ], @@ -257,8 +324,8 @@ class _ImageToolScreenState extends State { const SizedBox(height: 14), Text( _mode == ToolMode.embed - ? 'Prototype note: this demo uses least-significant-bit encoding for illustration. The production engine adds encryption and transform-robust embedding server-side.' - : 'Verification recomputes the fingerprint from the recovered claim, so a forged or edited payload is detected. Lossy formats (JPG) destroy LSB watermarks by design.', + ? 'Prototype note: this demo uses scattered least-significant-bit encoding signed with your account key for illustration. The production engine adds transform-robust embedding server-side.' + : 'Verification recomputes the signed ownership claim from the recovered payload, so a forged or edited fingerprint is detected. Lossy formats (JPG) destroy LSB watermarks by design.', style: const TextStyle( fontSize: 11.5, height: 1.5, @@ -299,15 +366,16 @@ class _ImageToolScreenState extends State { final embed = _embedOutcome; if (_mode == ToolMode.embed && embed != null) { final recovered = embed.recovered; + final banner = claimBanner(embed.claimStatus); + var subtitle = banner.subtitle; + if (embed.resized) { + subtitle = '$subtitle Image was resized to fit the LSB carrier.'; + } return [ StatusBanner( - ok: embed.verified, - title: embed.verified - ? 'Ownership verified' - : 'No valid watermark recovered', - subtitle: embed.verified - ? 'Extracted signature matches the embedded fingerprint.' - : 'The payload could not be read back intact from this file.', + ok: banner.ok, + title: banner.title, + subtitle: subtitle, ), const SizedBox(height: 14), DetailList(rows: [ @@ -315,6 +383,11 @@ class _ImageToolScreenState extends State { DetailRow('Asset', recovered?.asset ?? '—', mono: true), DetailRow('Issued', _formatDate(recovered?.issued)), DetailRow('Signature', recovered?.signature ?? '—', mono: true), + DetailRow( + 'Key id', + _kidLabel(recovered?.alg ?? embed.payload.alg, + recovered?.kid ?? embed.payload.kid), + mono: true), DetailRow( 'Payload density', '${embed.bitsUsed} / ${embed.capacityBits} bits', @@ -330,20 +403,12 @@ class _ImageToolScreenState extends State { final verifyPayload = _verifyPayload; if (_mode == ToolMode.verify && _verifyOutcome != null) { - final genuine = verifyPayload != null && verifyPayload.signatureValid; + final banner = claimBanner(_verifyOutcome!.claimStatus); return [ StatusBanner( - ok: genuine, - title: genuine - ? 'Signata watermark verified' - : verifyPayload != null - ? 'Watermark found but signature is invalid' - : 'No watermark found', - subtitle: genuine - ? 'The recovered signature matches the ownership claim.' - : verifyPayload != null - ? 'The payload was recovered but fails the signature recheck.' - : 'This file does not carry a readable Signata fingerprint.', + ok: banner.ok, + title: banner.title, + subtitle: banner.subtitle, ), if (verifyPayload != null) ...[ const SizedBox(height: 14), @@ -352,6 +417,10 @@ class _ImageToolScreenState extends State { DetailRow('Asset', verifyPayload.asset, mono: true), DetailRow('Issued', _formatDate(verifyPayload.issued)), DetailRow('Signature', verifyPayload.signature, mono: true), + DetailRow( + 'Key id', + _kidLabel(verifyPayload.alg, verifyPayload.kid), + mono: true), DetailRow('Scan time', '$_elapsedMs ms', mono: true), ]), const SizedBox(height: 14), @@ -395,6 +464,12 @@ class _ImageToolScreenState extends State { return '${parsed.year}-${two(parsed.month)}-${two(parsed.day)} ' '${two(parsed.hour)}:${two(parsed.minute)}'; } + + static String _kidLabel(String? alg, String? kid) { + if (alg == null || alg.isEmpty || alg == claimAlgFnv16) return 'legacy'; + if (kid != null && kid.isNotEmpty) return kid; + return 'legacy'; + } } class _ImagePair extends StatelessWidget { diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index c6c1afe..0f09cb0 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../core/auth.dart'; import '../theme.dart'; import '../widgets/em_widgets.dart'; +import 'privacy_policy_screen.dart'; +import 'terms_of_use_screen.dart'; class LoginScreen extends StatefulWidget { const LoginScreen({super.key}); @@ -15,10 +18,14 @@ class _LoginScreenState extends State { final _nameController = TextEditingController(); final _emailController = TextEditingController(); final _passwordController = TextEditingController(); + final _confirmController = TextEditingController(); + final _totpController = TextEditingController(); bool _isSignUp = false; bool _busy = false; bool _obscure = true; + bool _obscureConfirm = true; + bool _awaitingTotp = false; String? _error; @override @@ -26,9 +33,18 @@ class _LoginScreenState extends State { _nameController.dispose(); _emailController.dispose(); _passwordController.dispose(); + _confirmController.dispose(); + _totpController.dispose(); super.dispose(); } + void _clearSecrets() { + _passwordController.clear(); + _confirmController.clear(); + _totpController.clear(); + _awaitingTotp = false; + } + Future _submitEmail() async { setState(() { _busy = true; @@ -40,13 +56,23 @@ class _LoginScreenState extends State { name: _nameController.text, email: _emailController.text, password: _passwordController.text, + confirmPassword: _confirmController.text, ); + _clearSecrets(); } else { await AuthService.instance.signInWithEmail( email: _emailController.text, password: _passwordController.text, + totpCode: _awaitingTotp ? _totpController.text : null, ); + _clearSecrets(); } + } on TotpRequiredException { + if (!mounted) return; + setState(() { + _awaitingTotp = true; + _error = null; + }); } catch (error) { if (!mounted) return; setState(() => _error = error.toString().replaceFirst('Exception: ', '')); @@ -62,6 +88,7 @@ class _LoginScreenState extends State { }); try { await AuthService.instance.signInWithGoogle(); + _clearSecrets(); } catch (error) { if (!mounted) return; setState(() => _error = error.toString().replaceFirst('Exception: ', '')); @@ -73,6 +100,7 @@ class _LoginScreenState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); + return Scaffold( body: Stack( children: [ @@ -83,16 +111,14 @@ class _LoginScreenState extends State { right: 0, child: IgnorePointer( child: Container( - height: 280, + height: 320, decoration: BoxDecoration( - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: EmColors.primary.withValues(alpha: 0.2), - blurRadius: 120, - spreadRadius: 40, - ), - ], + gradient: RadialGradient( + colors: [ + EmColors.primary.withValues(alpha: 0.18), + Colors.transparent, + ], + ), ), ), ), @@ -103,147 +129,283 @@ class _LoginScreenState extends State { padding: const EdgeInsets.fromLTRB(24, 24, 24, 40), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const EmLogo(size: 36), - const SizedBox(height: 28), - const MonoLabel('Welcome'), - const SizedBox(height: 12), - Text( - _isSignUp ? 'Create your account' : 'Sign in to Signata', - style: theme.textTheme.displayMedium, - ), - const SizedBox(height: 10), - Text( - 'Protect images, audio, video, and PDFs — then verify ownership from any copy.', - style: theme.textTheme.bodyLarge?.copyWith( - color: EmColors.mutedForeground, - height: 1.5, + child: AutofillGroup( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const EmLogo(size: 36), + const SizedBox(height: 28), + const MonoLabel('Welcome'), + const SizedBox(height: 12), + Text( + _isSignUp + ? 'Create your account' + : 'Sign in to Signata', + style: theme.textTheme.displayMedium, ), - ), - const SizedBox(height: 28), - EmCard( - padding: const EdgeInsets.all(22), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - OutlinedButton.icon( - onPressed: _busy ? null : _submitGoogle, - icon: const Icon(Icons.g_mobiledata, size: 28), - label: const Text('Continue with Google'), - style: OutlinedButton.styleFrom( - minimumSize: const Size.fromHeight(50), - ), + const SizedBox(height: 10), + Text( + _isSignUp + ? 'Accounts stay on this device. Export a recovery kit from Account after signup so you can restore your claim key later.' + : 'Protect images, audio, video, and PDFs — then verify ownership from any copy.', + style: theme.textTheme.bodyLarge?.copyWith( + color: EmColors.mutedForeground, + height: 1.5, + ), + ), + if (!AuthService.instance.isGoogleConfigured) ...[ + const SizedBox(height: 10), + Text( + 'Google Sign-In is not configured in this build — use email, or paste a Web client ID under Account after signing in.', + style: theme.textTheme.bodySmall?.copyWith( + color: EmColors.mutedForeground, + height: 1.4, ), - const SizedBox(height: 18), - Row( - children: [ - const Expanded(child: Divider()), - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 12), - child: Text('OR EMAIL', - style: emMonoLabel( - color: EmColors.mutedForeground, - size: 9)), + ), + ], + const SizedBox(height: 28), + EmCard( + padding: const EdgeInsets.all(22), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (AuthService.instance.isGoogleConfigured) ...[ + OutlinedButton.icon( + onPressed: _busy ? null : _submitGoogle, + icon: const Text( + 'G', + style: TextStyle( + fontWeight: FontWeight.w800, + fontSize: 18, + ), + ), + label: const Text('Continue with Google'), + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(50), + ), + ), + const SizedBox(height: 18), + Row( + children: [ + const Expanded(child: Divider()), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12), + child: Text('OR EMAIL', + style: emMonoLabel( + color: EmColors.mutedForeground, + size: 9)), + ), + const Expanded(child: Divider()), + ], ), - const Expanded(child: Divider()), + const SizedBox(height: 18), + ], + if (_isSignUp) ...[ + TextField( + controller: _nameController, + textInputAction: TextInputAction.next, + textCapitalization: TextCapitalization.words, + autofillHints: const [AutofillHints.name], + decoration: const InputDecoration( + labelText: 'Display name', + hintText: 'Your name', + ), + ), + const SizedBox(height: 12), ], - ), - const SizedBox(height: 18), - if (_isSignUp) ...[ TextField( - controller: _nameController, + controller: _emailController, + keyboardType: TextInputType.emailAddress, textInputAction: TextInputAction.next, + autocorrect: false, + enableSuggestions: false, + autofillHints: [ + if (_isSignUp) AutofillHints.newUsername, + AutofillHints.email, + ], + inputFormatters: [ + FilteringTextInputFormatter.deny( + RegExp(r'\s')), + ], decoration: const InputDecoration( - labelText: 'Display name', - hintText: 'Studio Nova', + labelText: 'Email', + hintText: 'you@example.com', ), ), const SizedBox(height: 12), - ], - TextField( - controller: _emailController, - keyboardType: TextInputType.emailAddress, - textInputAction: TextInputAction.next, - autocorrect: false, - decoration: const InputDecoration( - labelText: 'Email', - hintText: 'you@studio.com', - ), - ), - const SizedBox(height: 12), - TextField( - controller: _passwordController, - obscureText: _obscure, - textInputAction: TextInputAction.done, - onSubmitted: (_) => _submitEmail(), - decoration: InputDecoration( - labelText: 'Password', - hintText: 'At least 6 characters', - suffixIcon: IconButton( - onPressed: () => - setState(() => _obscure = !_obscure), - icon: Icon(_obscure - ? Icons.visibility_outlined - : Icons.visibility_off_outlined), + TextField( + controller: _passwordController, + obscureText: _obscure, + textInputAction: _isSignUp + ? TextInputAction.next + : TextInputAction.done, + onSubmitted: + _isSignUp ? null : (_) => _submitEmail(), + autofillHints: [ + _isSignUp + ? AutofillHints.newPassword + : AutofillHints.password, + ], + decoration: InputDecoration( + labelText: 'Password', + hintText: _isSignUp + ? '${PasswordPolicy.minLength}+ chars, letter & number' + : 'Your password', + suffixIcon: IconButton( + onPressed: () => + setState(() => _obscure = !_obscure), + icon: Icon(_obscure + ? Icons.visibility_outlined + : Icons.visibility_off_outlined), + ), ), ), - ), - if (_error != null) ...[ - const SizedBox(height: 14), - StatusBanner( - ok: false, - title: 'Could not continue', - subtitle: _error!, + if (_isSignUp) ...[ + const SizedBox(height: 12), + TextField( + controller: _confirmController, + obscureText: _obscureConfirm, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _submitEmail(), + autofillHints: const [ + AutofillHints.newPassword + ], + decoration: InputDecoration( + labelText: 'Confirm password', + hintText: 'Re-enter password', + suffixIcon: IconButton( + onPressed: () => setState( + () => _obscureConfirm = + !_obscureConfirm), + icon: Icon(_obscureConfirm + ? Icons.visibility_outlined + : Icons.visibility_off_outlined), + ), + ), + ), + ], + if (_awaitingTotp && !_isSignUp) ...[ + const SizedBox(height: 12), + const Text( + 'Enter the 6-digit code from your authenticator app.', + style: TextStyle( + fontSize: 13, + height: 1.4, + color: EmColors.mutedForeground, + ), + ), + const SizedBox(height: 10), + TextField( + controller: _totpController, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.done, + autofillHints: const [ + AutofillHints.oneTimeCode + ], + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(6), + ], + onSubmitted: (_) => _submitEmail(), + decoration: const InputDecoration( + labelText: 'Authenticator code', + hintText: '123456', + ), + ), + ], + if (_error != null) ...[ + const SizedBox(height: 14), + StatusBanner( + ok: false, + title: 'Could not continue', + subtitle: _error!, + ), + ], + const SizedBox(height: 18), + FilledButton( + onPressed: _busy ? null : _submitEmail, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(50), + ), + child: _busy + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2), + ) + : Text(_awaitingTotp && !_isSignUp + ? 'Verify code' + : (_isSignUp + ? 'Create account' + : 'Sign in with email')), ), ], - const SizedBox(height: 18), - FilledButton( - onPressed: _busy ? null : _submitEmail, - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(50), + ), + ), + const SizedBox(height: 18), + TextButton( + onPressed: _busy + ? null + : () => setState(() { + _isSignUp = !_isSignUp; + _error = null; + _awaitingTotp = false; + _totpController.clear(); + _confirmController.clear(); + }), + child: Text( + _isSignUp + ? 'Already have an account? Sign in' + : 'New here? Create an account', + style: const TextStyle(color: EmColors.primary), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton( + onPressed: _busy + ? null + : () => PrivacyPolicyScreen.open(context), + child: const Text( + 'Privacy Policy', + style: TextStyle( + color: EmColors.mutedForeground, + fontSize: 13, + ), + ), + ), + const Text('·', + style: TextStyle( + color: EmColors.mutedForeground, + fontSize: 13)), + TextButton( + onPressed: _busy + ? null + : () => TermsOfUseScreen.open(context), + child: const Text( + 'Terms', + style: TextStyle( + color: EmColors.mutedForeground, + fontSize: 13, + ), ), - child: _busy - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2), - ) - : Text(_isSignUp - ? 'Create account' - : 'Sign in with email'), ), ], ), - ), - const SizedBox(height: 18), - TextButton( - onPressed: _busy - ? null - : () => setState(() { - _isSignUp = !_isSignUp; - _error = null; - }), - child: Text( - _isSignUp - ? 'Already have an account? Sign in' - : 'New here? Create an account', - style: const TextStyle(color: EmColors.primary), - ), - ), - const SizedBox(height: 8), - Text( - 'Email accounts stay on this device. Google uses your Google account when OAuth is configured for the app.', - textAlign: TextAlign.center, - style: theme.textTheme.bodySmall?.copyWith( - color: EmColors.mutedForeground, - height: 1.45, + const SizedBox(height: 4), + Text( + 'Email passwords stay in on-device secure storage. Google uses your Google account.', + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: EmColors.mutedForeground, + height: 1.45, + ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/screens/pdf_tool_screen.dart b/lib/screens/pdf_tool_screen.dart index 2f964a8..8e639a2 100644 --- a/lib/screens/pdf_tool_screen.dart +++ b/lib/screens/pdf_tool_screen.dart @@ -3,17 +3,24 @@ import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import '../core/claim_crypto.dart'; +import '../core/claim_status_ui.dart'; import '../core/history.dart'; +import '../core/onboarding.dart'; import '../core/pdf_fingerprint.dart'; import '../core/report.dart'; import '../core/share_utils.dart'; +import '../core/trace_models.dart'; +import '../core/usage_entitlements.dart'; import '../theme.dart'; import '../widgets/em_widgets.dart'; +import '../widgets/publish_claim_button.dart'; +import '../widgets/usage_paywall.dart'; enum PdfMode { embed, verify } class PdfToolScreen extends StatefulWidget { - const PdfToolScreen({super.key, this.defaultOwner = 'Studio Nova'}); + const PdfToolScreen({super.key, this.defaultOwner = ''}); final String defaultOwner; @@ -54,6 +61,10 @@ class _PdfToolScreenState extends State { } Future _pickAndRun() async { + if (_mode == PdfMode.embed) { + final allowed = await ensureUsage(context, UsageKind.protect); + if (!allowed || !mounted) return; + } final picked = await FilePicker.pickFiles( type: FileType.custom, allowedExtensions: const ['pdf'], @@ -67,12 +78,14 @@ class _PdfToolScreenState extends State { final stopwatch = Stopwatch()..start(); try { + final claimKey = await AccountClaimKeys.current(); if (_mode == PdfMode.embed) { setState(() => _workingStep = 'Deriving structural identifier'); final outcome = await embedPdfFingerprint( source: bytes, owner: _ownerController.text, fileName: file.name, + claimKey: claimKey, ); stopwatch.stop(); if (!mounted) return; @@ -86,13 +99,15 @@ class _PdfToolScreenState extends State { action: 'embed', subject: file.name, owner: outcome.payload.owner, - verified: outcome.verified, - reference: outcome.payload.identifier, + verified: claimCountsAsVerified(outcome.claimStatus), + reference: outcome.payload.structuralIdentifier(), at: DateTime.now(), )); + await commitUsage(UsageKind.protect); + await OnboardingFlags.instance.markProtectedOnce(); } else { setState(() => _workingStep = 'Extracting & verifying'); - final outcome = await verifyPdfFingerprint(bytes); + final outcome = await verifyPdfFingerprint(bytes, claimKey: claimKey); stopwatch.stop(); if (!mounted) return; setState(() { @@ -105,7 +120,7 @@ class _PdfToolScreenState extends State { action: 'verify', subject: file.name, owner: outcome.recovered?.owner ?? 'Unknown', - verified: outcome.verified, + verified: claimCountsAsVerified(outcome.claimStatus), reference: outcome.recovered?.identifier ?? '—', at: DateTime.now(), )); @@ -130,16 +145,42 @@ class _PdfToolScreenState extends State { ); } + Future _downloadMarked() async { + final outcome = _embedOutcome; + if (outcome == null) return; + try { + final path = await downloadBytes( + bytes: outcome.markedBytes, + fileName: 'signata-${_fileName ?? 'document.pdf'}', + dialogTitle: 'Download fingerprinted PDF', + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Saved to $path')), + ); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + error.toString().replaceFirst('Exception: ', ''), + ), + ), + ); + } + } + Future _exportReport() async { final outcome = _embedOutcome; if (outcome == null) return; setState(() => _reportStatus = 'Sealing…'); try { + final claimKey = await AccountClaimKeys.current(); final sealed = sealReport(ReportBody( medium: 'pdf', subject: outcome.recovered?.document ?? _fileName ?? 'document.pdf', owner: outcome.recovered?.owner ?? 'Unknown', - verified: outcome.verified, + verified: claimCountsAsVerified(outcome.claimStatus), issued: outcome.payload.issued, generated: DateTime.now().toUtc().toIso8601String(), evidence: { @@ -150,8 +191,8 @@ class _PdfToolScreenState extends State { 'structure': outcome.recovered?.structure.toJson(), 'roundTripMs': _elapsedMs, }, - )); - if (!verifySealedReport(sealed)) { + ), claimKey: claimKey); + if (!verifySealedReport(sealed, claimKey: claimKey)) { throw Exception('Seal self-check failed.'); } await shareBytes( @@ -211,12 +252,17 @@ class _PdfToolScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_mode == PdfMode.embed) ...[ + const UsageStatusBanner(kind: UsageKind.protect), const MonoLabel('Ownership claim'), const SizedBox(height: 10), TextField( controller: _ownerController, + readOnly: true, decoration: const InputDecoration( hintText: 'Creator or studio name', + helperText: + 'Bound to your Signata account — cannot be spoofed for authentication.', + helperMaxLines: 2, ), ), const SizedBox(height: 16), @@ -226,7 +272,9 @@ class _PdfToolScreenState extends State { title: _mode == PdfMode.embed ? 'Choose a PDF to fingerprint' : 'Choose a PDF to check', - hint: 'Processed locally — the document never leaves your device', + hint: _mode == PdfMode.embed + ? 'PDF only — print/scan or flatten may remove the fingerprint' + : 'Check the original PDF you protected', buttonLabel: 'Choose a PDF', onPick: _pickAndRun, fileName: _fileName, @@ -249,10 +297,33 @@ class _PdfToolScreenState extends State { ], if (_embedOutcome != null) ...[ const SizedBox(height: 14), - OutlinedButton.icon( - onPressed: _shareMarked, - icon: const Icon(Icons.ios_share, size: 18), - label: const Text('Share fingerprinted PDF'), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + FilledButton.icon( + onPressed: _downloadMarked, + icon: const Icon(Icons.download, size: 18), + label: const Text('Download'), + ), + OutlinedButton.icon( + onPressed: _shareMarked, + icon: const Icon(Icons.ios_share, size: 18), + label: const Text('Share'), + ), + ], + ), + const SizedBox(height: 10), + PublishClaimButton( + medium: TraceMedium.pdf, + owner: _embedOutcome!.payload.owner, + subject: _embedOutcome!.payload.document, + reference: _embedOutcome!.payload.identifier ?? + _embedOutcome!.payload.signature ?? + '', + issued: _embedOutcome!.payload.issued, + alg: _embedOutcome!.payload.alg, + kid: _embedOutcome!.payload.kid, ), ], ], @@ -268,7 +339,7 @@ class _PdfToolScreenState extends State { ..._buildReport(), const SizedBox(height: 14), const Text( - 'Prototype note: the identifier is carried in a trailing marker for illustration. The production engine distributes it across object metadata and structural entropy so it survives re-saving and editing.', + 'Prototype note: the identifier is carried in a trailing marker comment and signed with your account key for illustration. Production distributes it across object metadata and structural entropy so it survives re-saving and editing.', style: TextStyle( fontSize: 11.5, height: 1.5, @@ -309,9 +380,9 @@ class _PdfToolScreenState extends State { final recovered = _mode == PdfMode.embed ? _embedOutcome?.recovered : _verifyOutcome?.recovered; - final verified = _mode == PdfMode.embed - ? _embedOutcome?.verified - : _verifyOutcome?.verified; + final claimStatus = _mode == PdfMode.embed + ? _embedOutcome?.claimStatus + : _verifyOutcome?.claimStatus; final structureMatch = _mode == PdfMode.embed ? _embedOutcome?.structureMatch : _verifyOutcome?.structureMatch; @@ -332,15 +403,16 @@ class _PdfToolScreenState extends State { ]; } + final banner = claimBanner(claimStatus ?? ClaimStatus.missing); + final payload = _mode == PdfMode.embed + ? _embedOutcome?.payload + : _verifyOutcome?.recovered; + return [ StatusBanner( - ok: verified ?? false, - title: (verified ?? false) - ? 'Document ownership verified' - : 'No valid fingerprint found', - subtitle: (verified ?? false) - ? 'Recovered identifier matches the recomputed structural digest.' - : 'The identifier could not be recovered or did not match the structure.', + ok: banner.ok, + title: banner.title, + subtitle: banner.subtitle, ), const SizedBox(height: 14), DetailList(rows: [ @@ -353,6 +425,10 @@ class _PdfToolScreenState extends State { 'Structure match', (structureMatch ?? false) ? 'identical' : 'mismatch', mono: true), + DetailRow( + 'Key id', + _kidLabel(payload?.alg ?? recovered?.alg, payload?.kid ?? recovered?.kid), + mono: true), DetailRow('Round trip', '$_elapsedMs ms', mono: true), ]), if (_mode == PdfMode.embed) ...[ @@ -387,4 +463,10 @@ class _PdfToolScreenState extends State { return '${parsed.year}-${two(parsed.month)}-${two(parsed.day)} ' '${two(parsed.hour)}:${two(parsed.minute)}'; } + + static String _kidLabel(String? alg, String? kid) { + if (alg == null || alg.isEmpty || alg == claimAlgFnv16) return 'legacy'; + if (kid != null && kid.isNotEmpty) return kid; + return 'legacy'; + } } diff --git a/lib/screens/privacy_policy_screen.dart b/lib/screens/privacy_policy_screen.dart new file mode 100644 index 0000000..7e27872 --- /dev/null +++ b/lib/screens/privacy_policy_screen.dart @@ -0,0 +1,234 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../theme.dart'; +import '../widgets/em_widgets.dart'; + +/// In-app Privacy Policy for Signata (PDPL / Kingdom of Saudi Arabia). +class PrivacyPolicyScreen extends StatelessWidget { + const PrivacyPolicyScreen({super.key}); + + static const lastUpdated = 'August 10, 2026'; + static const supportEmail = 'FocusMindDev@gmail.com'; + + static Future open(BuildContext context) { + return Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const PrivacyPolicyScreen(), + ), + ); + } + + Future _mailSupport() async { + final uri = Uri( + scheme: 'mailto', + path: supportEmail, + queryParameters: {'subject': 'Signata privacy inquiry'}, + ); + await launchUrl(uri); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: EmColors.background, + appBar: AppBar( + title: const Text('Privacy Policy'), + backgroundColor: EmColors.background, + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 40), + children: [ + const SectionHeading( + tag: 'Legal', + title: 'Privacy Policy for Signata', + desc: 'Last updated: August 10, 2026', + ), + const SizedBox(height: 16), + EmCard( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _Intro(), + const SizedBox(height: 22), + const _Section( + number: '1', + title: 'Data We Collect and Process', + body: + 'Given Signata\'s nature as a digital watermarking and ownership verification platform, we handle specific categories of data:', + bullets: [ + 'Basic Account Data: Upon registration, we collect the necessary information to create and manage your account (e.g., name, email address, and authentication data).', + 'Media Files: This includes images (PNG, JPG), audio (WAV), video clips (MP4/MOV), and documents (PDF) that you protect or verify using the app.', + 'Metadata & Encrypted Fingerprints: We process and generate structural identifiers (such as SHA-256) and hidden encrypted fingerprints to prove content ownership.', + 'Trace Data & Public URLs: The public links you input into the "Trace" tool to scan for your content on platforms like Instagram, TikTok, and X.', + ], + ), + const _Section( + number: '2', + title: 'On-Device Verification & Processing', + body: + 'At Signata, the security and privacy of your content are our top priorities:', + bullets: [ + 'Media Protection: Embedding watermarks, audio fingerprints, and structural identifiers for videos and documents runs locally on your device.', + 'By default Signata does not upload your original media or claim keys to Signata servers. History, Trace radar, and published claims stay in on-device storage scoped to your account.', + 'If you later configure an optional self-hosted claim registry, only fingerprint metadata you choose to publish may leave the device — never your original files.', + ], + ), + const _Section( + number: '3', + title: 'How We Use Your Data', + body: + 'The data we collect is strictly used for the following purposes:', + bullets: [ + 'To enable you to embed hidden ownership fingerprints in media without altering its visual or auditory quality.', + 'To operate the Radar and Trace services, scanning public links and matching them against your registered fingerprints to detect unauthorized use.', + 'To improve our hidden fingerprint algorithms and their robustness against common modifications (such as compression, cropping, and color shifts).', + 'To provide technical support and manage your account.', + ], + ), + const _Section( + number: '4', + title: 'Third-Party Services and External Links', + body: null, + bullets: [ + 'Trace Tool: This tool interacts with the public interfaces of platforms (like Instagram, TikTok, and X) to scan the links you provide. We do not control the privacy practices of these platforms, and your interaction with them is governed by their respective privacy policies.', + 'We will never share your Trace logs, search history, or encrypted fingerprints with any third parties for marketing or advertising purposes.', + ], + ), + const _Section( + number: '5', + title: 'Data Security', + body: + 'We implement industry-standard security protocols and advanced encryption (including SHA-256 hashing algorithms) to protect your data from unauthorized access, alteration, or disclosure. The binding of encrypted structural identifiers ensures that your proof of ownership remains secure and tamper-proof.', + ), + const _Section( + number: '6', + title: 'User Rights (Access and Deletion)', + body: + 'In accordance with applicable data protection laws, you have the right to:', + bullets: [ + 'Access: View your ownership records (History) and actively tracked links (Watching) directly in the app.', + 'Deletion: Use Account → Delete account to erase your local Signata account record, claim key, history, Trace data, and usage flags on this device. This does not remove watermarks already embedded in exported files. Email FocusMindDev@gmail.com for privacy requests beyond what the app can erase locally.', + ], + ), + const _Section( + number: '7', + title: 'Governing Law and Policy Updates', + body: + 'This Privacy Policy is governed by and construed in accordance with the applicable laws of the Kingdom of Saudi Arabia, primarily the Personal Data Protection Law (PDPL). We may update this policy periodically, and we will notify you of any material changes via the app or email.', + ), + const _Section( + number: '8', + title: 'Contact Us', + body: + 'For any legal or technical inquiries regarding your privacy and data management, please contact us at:', + ), + const SizedBox(height: 8), + TextButton.icon( + onPressed: _mailSupport, + icon: const Icon(Icons.mail_outline, size: 18), + label: const Text(supportEmail), + style: TextButton.styleFrom( + foregroundColor: EmColors.primary, + padding: EdgeInsets.zero, + alignment: Alignment.centerLeft, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _Intro extends StatelessWidget { + const _Intro(); + + @override + Widget build(BuildContext context) { + return const Text( + 'At Signata, we are fully committed to protecting the privacy of our users and securing digital content with the highest security standards. This Privacy Policy explains how we collect, use, process, and protect your personal data, media files, and the links you trace through our platform.', + style: TextStyle( + fontSize: 14, + height: 1.55, + color: EmColors.mutedForeground, + ), + ); + } +} + +class _Section extends StatelessWidget { + const _Section({ + required this.number, + required this.title, + required this.body, + this.bullets, + }); + + final String number; + final String title; + final String? body; + final List? bullets; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 22), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$number. $title', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: EmColors.foreground, + height: 1.35, + ), + ), + if (body != null) ...[ + const SizedBox(height: 8), + Text( + body!, + style: const TextStyle( + fontSize: 14, + height: 1.55, + color: EmColors.mutedForeground, + ), + ), + ], + if (bullets != null) ...[ + const SizedBox(height: 10), + for (final item in bullets!) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(top: 7, right: 10), + child: Icon(Icons.circle, + size: 6, color: EmColors.primary), + ), + Expanded( + child: Text( + item, + style: const TextStyle( + fontSize: 14, + height: 1.55, + color: EmColors.mutedForeground, + ), + ), + ), + ], + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/screens/shell.dart b/lib/screens/shell.dart index 1016b83..046c759 100644 --- a/lib/screens/shell.dart +++ b/lib/screens/shell.dart @@ -1,6 +1,14 @@ +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import '../core/auth.dart'; +import '../core/local_data.dart'; +import '../core/onboarding.dart'; +import '../core/share_ingress.dart'; +import '../core/share_utils.dart'; +import '../theme.dart'; import '../widgets/em_widgets.dart'; import 'account_screen.dart'; import 'audio_tool_screen.dart'; @@ -9,6 +17,7 @@ import 'home_screen.dart'; import 'image_tool_screen.dart'; import 'pdf_tool_screen.dart'; import 'tools_hub_screen.dart'; +import 'trace_screen.dart'; import 'video_tool_screen.dart'; class AppShell extends StatefulWidget { @@ -22,11 +31,92 @@ class _AppShellState extends State { int _index = 0; EmTool? _activeTool; final _historyKey = GlobalKey(); + List _pendingShared = const []; + int _traceNonce = 0; + int _checklistNonce = 0; + bool _recoveryPromptScheduled = false; + + @override + void initState() { + super.initState(); + ShareIngress.instance.attach(_onShared); + WidgetsBinding.instance.addPostFrameCallback((_) => _maybePromptRecovery()); + } + + @override + void dispose() { + ShareIngress.instance.detach(); + super.dispose(); + } + + Future _maybePromptRecovery() async { + if (_recoveryPromptScheduled || !mounted) return; + _recoveryPromptScheduled = true; + final should = await OnboardingFlags.instance.shouldPromptRecoveryExport(); + if (!should || !mounted) return; + await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: EmColors.card, + title: const Text('Save a recovery kit'), + content: const Text( + 'Signata accounts and claim keys stay on this device. Export a ' + 'recovery kit now so you can prove old fingerprints after a reinstall.', + style: TextStyle(height: 1.45), + ), + actions: [ + TextButton( + onPressed: () async { + await OnboardingFlags.instance.markRecoveryHandled(); + if (context.mounted) Navigator.pop(context); + }, + child: const Text('Later'), + ), + FilledButton( + onPressed: () async { + Navigator.pop(context); + try { + final kit = await buildRecoveryKit(); + await shareBytes( + bytes: Uint8List.fromList(utf8.encode(kit)), + fileName: 'signata-recovery-kit.txt', + mimeType: 'text/plain', + text: 'Signata recovery kit — keep offline and private.', + ); + await OnboardingFlags.instance.markRecoveryExported(); + } catch (_) {} + }, + child: const Text('Export now'), + ), + ], + ), + ); + } + + void _onShared(List files) { + if (!mounted || files.isEmpty) return; + setState(() { + _pendingShared = files; + _traceNonce++; + _index = 1; + _activeTool = EmTool.trace; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + files.any((f) => f.path.isNotEmpty) + ? 'Shared media ready to check in Trace' + : 'Shared link ready — file share is more reliable than post URLs', + ), + ), + ); + } void _openTab(int index) { setState(() { _index = index; _activeTool = null; + if (index == 0) _checklistNonce++; }); if (index == 2) _historyKey.currentState?.refresh(); } @@ -39,7 +129,7 @@ class _AppShellState extends State { } String get _defaultOwner => - AuthService.instance.user?.displayName ?? 'Studio Nova'; + AuthService.instance.user?.displayName.trim() ?? ''; Widget _toolScreen(EmTool tool) { switch (tool) { @@ -51,6 +141,12 @@ class _AppShellState extends State { return VideoToolScreen(defaultOwner: _defaultOwner); case EmTool.pdf: return PdfToolScreen(defaultOwner: _defaultOwner); + case EmTool.trace: + final shared = _pendingShared; + return TraceScreen( + key: ValueKey('trace-$_traceNonce'), + initialSharedFiles: shared, + ); } } @@ -59,6 +155,7 @@ class _AppShellState extends State { EmTool.audio => 'Audio', EmTool.video => 'Video', EmTool.pdf => 'PDF', + EmTool.trace => 'Trace', }; @override @@ -70,7 +167,11 @@ class _AppShellState extends State { leading: inTool ? IconButton( icon: const Icon(Icons.arrow_back), - onPressed: () => setState(() => _activeTool = null), + onPressed: () => setState(() { + _activeTool = null; + _pendingShared = const []; + _checklistNonce++; + }), ) : null, title: inTool @@ -83,9 +184,16 @@ class _AppShellState extends State { : IndexedStack( index: _index, children: [ - HomeScreen(onOpenTool: _openTool), + HomeScreen( + onOpenTool: _openTool, + onOpenAccount: () => _openTab(3), + checklistNonce: _checklistNonce, + ), ToolsHubScreen(onOpenTool: _openTool), - HistoryScreen(key: _historyKey), + HistoryScreen( + key: _historyKey, + onOpenTools: () => _openTab(1), + ), const AccountScreen(), ], ), diff --git a/lib/screens/terms_of_use_screen.dart b/lib/screens/terms_of_use_screen.dart new file mode 100644 index 0000000..ae56938 --- /dev/null +++ b/lib/screens/terms_of_use_screen.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../theme.dart'; +import '../widgets/em_widgets.dart'; + +/// In-app Terms of Use for Signata. +class TermsOfUseScreen extends StatelessWidget { + const TermsOfUseScreen({super.key}); + + static const lastUpdated = 'August 14, 2026'; + static const supportEmail = 'FocusMindDev@gmail.com'; + static const licenseUrl = + 'https://github.com/WejdanBa-CS/Signata/blob/main/LICENSE'; + + static Future open(BuildContext context) { + return Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const TermsOfUseScreen(), + ), + ); + } + + Future _mailSupport() async { + final uri = Uri( + scheme: 'mailto', + path: supportEmail, + queryParameters: {'subject': 'Signata terms inquiry'}, + ); + await launchUrl(uri); + } + + Future _openLicense() async { + final uri = Uri.parse(licenseUrl); + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: EmColors.background, + appBar: AppBar( + title: const Text('Terms of Use'), + backgroundColor: EmColors.background, + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 40), + children: [ + const SectionHeading( + tag: 'Legal', + title: 'Terms of Use', + desc: 'Last updated: August 14, 2026', + ), + const SizedBox(height: 16), + EmCard( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'By using Signata you agree to these terms. If you do not agree, ' + 'please do not use the app.', + style: TextStyle( + fontSize: 14, + height: 1.55, + color: EmColors.mutedForeground, + ), + ), + const SizedBox(height: 22), + const _Section( + number: '1', + title: 'The service', + body: + 'Signata is a digital watermarking and ownership verification tool. ' + 'Protect, verify, and trace media on your device. Purchases or ' + 'distribution of protected files happen outside Signata.', + ), + const _Section( + number: '2', + title: 'Your account', + body: + 'You are responsible for activity under your account, your claim key, ' + 'and content you protect or trace. Do not use Signata for illegal, ' + 'harmful, or infringing material.', + ), + const _Section( + number: '3', + title: 'On-device processing', + body: + 'By default, accounts, claim keys, history, and trace data stay on ' + 'this device. Export a recovery kit before wiping the device. ' + 'Optional registry integrations may publish fingerprint metadata you choose.', + ), + const _Section( + number: '4', + title: 'Intellectual property', + body: + 'Signata, its design, code, and branding are owned by Wejdan Al Amri. ' + 'You may not copy, scrape, or clone the app or service for commercial use. ' + 'Source is published under the terms in our LICENSE.', + ), + TextButton.icon( + onPressed: _openLicense, + icon: const Icon(Icons.open_in_new, size: 16), + label: const Text('View LICENSE on GitHub'), + style: TextButton.styleFrom( + foregroundColor: EmColors.primary, + padding: EdgeInsets.zero, + alignment: Alignment.centerLeft, + ), + ), + const SizedBox(height: 14), + const _Section( + number: '5', + title: 'Disclaimer', + body: + 'Signata is provided as-is. We do not guarantee uninterrupted service, ' + 'that third-party platforms will preserve fingerprints, or that trace results ' + 'prove legal ownership in every jurisdiction. To the fullest extent allowed ' + 'by law, we are not liable for indirect or consequential damages arising from ' + 'use of the app.', + ), + const _Section( + number: '6', + title: 'Changes', + body: + 'We may update these terms. Continued use after changes means you accept ' + 'the updated terms.', + ), + const _Section( + number: '7', + title: 'Contact', + body: 'Questions about these terms:', + ), + TextButton.icon( + onPressed: _mailSupport, + icon: const Icon(Icons.mail_outline, size: 18), + label: const Text(supportEmail), + style: TextButton.styleFrom( + foregroundColor: EmColors.primary, + padding: EdgeInsets.zero, + alignment: Alignment.centerLeft, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _Section extends StatelessWidget { + const _Section({ + required this.number, + required this.title, + required this.body, + }); + + final String number; + final String title; + final String body; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 22), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$number. $title', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: EmColors.foreground, + height: 1.35, + ), + ), + const SizedBox(height: 8), + Text( + body, + style: const TextStyle( + fontSize: 14, + height: 1.55, + color: EmColors.mutedForeground, + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/tools_hub_screen.dart b/lib/screens/tools_hub_screen.dart index bd648ad..4dff4c9 100644 --- a/lib/screens/tools_hub_screen.dart +++ b/lib/screens/tools_hub_screen.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import '../theme.dart'; import '../widgets/em_widgets.dart'; -enum EmTool { image, audio, video, pdf } +enum EmTool { image, audio, video, pdf, trace } class ToolsHubScreen extends StatelessWidget { const ToolsHubScreen({super.key, required this.onOpenTool}); @@ -20,7 +20,7 @@ class ToolsHubScreen extends StatelessWidget { tag: 'Workspace', title: 'Protect & verify', desc: - 'Choose a medium. Everything is processed on your device — nothing leaves the phone.', + 'Protect files on-device, then check shared media or watched links for your Signata claim.', ), const SizedBox(height: 20), _ToolTile( @@ -55,9 +55,18 @@ class ToolsHubScreen extends StatelessWidget { tone: EmColors.accent, onTap: () => onOpenTool(EmTool.pdf), ), + _ToolTile( + tag: '05 / Internet', + title: 'Trace & social', + desc: + 'Check a shared file first (most reliable), fingerprint for Instagram / TikTok / X, then optionally watch post links.', + icon: Icons.radar, + tone: EmColors.primary, + onTap: () => onOpenTool(EmTool.trace), + ), const SizedBox(height: 12), Text( - 'Tip: after protecting a file, switch to Verify mode to check any copy.', + 'Tip: from Instagram / TikTok / X, share the media into Signata — not just the post URL. Platforms often strip fingerprints from recompressed uploads.', style: theme.textTheme.bodySmall ?.copyWith(color: EmColors.mutedForeground, height: 1.45), ), diff --git a/lib/screens/trace_screen.dart b/lib/screens/trace_screen.dart new file mode 100644 index 0000000..c38cb9e --- /dev/null +++ b/lib/screens/trace_screen.dart @@ -0,0 +1,1708 @@ +import 'dart:io'; +import 'dart:math' as math; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../core/auth.dart'; +import '../core/claim_crypto.dart'; +import '../core/claim_registry.dart'; +import '../core/claim_status_ui.dart'; +import '../core/network_reachability.dart'; +import '../core/onboarding.dart'; +import '../core/share_ingress.dart'; +import '../core/share_utils.dart'; +import '../core/social_platforms.dart'; +import '../core/social_protect.dart'; +import '../core/trace_models.dart'; +import '../core/trace_store.dart'; +import '../core/url_tracer.dart'; +import '../core/usage_entitlements.dart'; +import '../theme.dart'; +import '../widgets/em_widgets.dart'; +import '../widgets/usage_paywall.dart'; + +class TraceScreen extends StatefulWidget { + const TraceScreen({ + super.key, + this.initialSharedFiles = const [], + }); + + final List initialSharedFiles; + + @override + State createState() => _TraceScreenState(); +} + +class _TraceScreenState extends State + with SingleTickerProviderStateMixin, WidgetsBindingObserver { + final _urlController = TextEditingController(); + late final AnimationController _radar; + + bool _busy = false; + bool _watchAfterScan = true; + String? _error; + String? _status; + TraceScanResult? _lastResult; + SocialPlatformInfo? _selectedPlatform = SocialPlatformInfo.instagram; + List _protected = const []; + List _claims = const []; + List _watch = const []; + List _sightings = const []; + bool _didAutoRescan = false; + List? _batchResults; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _radar = AnimationController( + vsync: this, + duration: const Duration(seconds: 8), + )..repeat(); + _bootstrap(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + if (!_radar.isAnimating) _radar.repeat(); + } else { + _radar.stop(); + } + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _radar.dispose(); + _urlController.dispose(); + super.dispose(); + } + + Future _bootstrap() async { + await _reload(); + if (!mounted) return; + if (widget.initialSharedFiles.isNotEmpty) { + await _ingestShared(widget.initialSharedFiles); + return; + } + if (!_didAutoRescan && _watch.isNotEmpty) { + _didAutoRescan = true; + final online = await hasNetworkReachability(); + if (!online) { + if (!mounted) return; + setState(() => _error = + 'Offline — watchlist auto-check skipped. Rescan when you have a connection.'); + return; + } + final due = await TraceStore.instance.shouldAutoRescan(); + if (!due) return; + await _rescanAll(showDigest: true); + await TraceStore.instance.markAutoRescanDone(); + } + } + + Future _reload() async { + final claims = await ClaimRegistry.instance.listLocal(); + final watch = await TraceStore.instance.listWatchTargets(); + final sightings = await TraceStore.instance.listSightings(); + if (!mounted) return; + setState(() { + _claims = claims; + _watch = watch; + _sightings = sightings; + }); + } + + /// Split pasted text into http(s) URLs (whitespace, commas, newlines). + List _parseUrls(String raw) { + final parts = raw + .split(RegExp(r'[\s,;]+')) + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + final urls = []; + for (final part in parts) { + var candidate = part; + if (!candidate.contains('://') && + (candidate.startsWith('www.') || candidate.contains('.'))) { + candidate = 'https://$candidate'; + } + final uri = Uri.tryParse(candidate); + if (uri != null && + (uri.scheme == 'http' || uri.scheme == 'https') && + uri.host.isNotEmpty) { + urls.add(candidate); + } + } + return urls.toSet().toList(); + } + + Future _scan({ + String? url, + bool countTowardQuota = true, + }) async { + final raw = url ?? _urlController.text; + final targets = _parseUrls(raw); + if (targets.isEmpty) { + setState(() => _error = 'Paste one or more http(s) links to scan.'); + return; + } + final socialTargets = + targets.where((u) => SocialPlatformInfo.fromUrl(u) != null).toList(); + if (countTowardQuota && + socialTargets.isNotEmpty && + url == null && + mounted) { + final proceed = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: EmColors.card, + title: const Text('URL scan is a fallback'), + content: Text( + '${socialTargets.length} social link' + '${socialTargets.length == 1 ? '' : 's'} detected. ' + 'Platforms often strip fingerprints.\n\n' + 'Prefer “Check local file” or Share → Signata. Continue anyway?', + style: const TextStyle(height: 1.45), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Scan anyway'), + ), + ], + ), + ); + if (proceed != true || !mounted) return; + } + if (countTowardQuota) { + final allowed = + await ensureUsage(context, UsageKind.trace, units: targets.length); + if (!allowed || !mounted) return; + } + setState(() { + _busy = true; + _error = null; + _status = targets.length == 1 + ? 'Sweeping the open web…' + : 'Scanning ${targets.length} links…'; + _lastResult = null; + _batchResults = null; + }); + try { + final key = await AccountClaimKeys.current(); + final results = []; + var successes = 0; + for (var i = 0; i < targets.length; i++) { + if (!mounted) return; + if (targets.length > 1) { + setState(() => _status = 'Scanning ${i + 1}/${targets.length}…'); + } + final result = await UrlTracer.instance.scanUrl( + targets[i], + claimKey: key, + addToWatchlist: _watchAfterScan, + ); + results.add(result); + if (result.sighting.error == null) successes++; + } + if (successes > 0) { + await OnboardingFlags.instance.markTracedOnce(); + } + if (countTowardQuota && successes > 0) { + await commitUsage(UsageKind.trace, units: successes); + } + if (!mounted) return; + setState(() { + _lastResult = results.last; + _batchResults = results.length > 1 ? results : null; + _status = null; + final platform = SocialPlatformInfo.fromUrl(targets.first); + if (platform != null) _selectedPlatform = platform; + if (targets.length > 1) { + _urlController.text = targets.join('\n'); + } + }); + await _reload(); + if (mounted && results.length > 1) { + final hits = results.where((r) => r.sighting.found).length; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Batch done: $hits/${results.length} with fingerprints.', + ), + ), + ); + } + } catch (error) { + if (!mounted) return; + setState(() => + _error = error.toString().replaceFirst('Exception: ', '')); + } finally { + if (mounted) { + setState(() { + _busy = false; + _status = null; + }); + } + } + } + + Future _rescanAll({bool showDigest = false}) async { + if (_watch.isEmpty) return; + final priorFound = {}; + for (final s in _sightings) { + priorFound.putIfAbsent(s.url, () => s.found); + } + setState(() { + _busy = true; + _error = null; + _status = showDigest + ? 'Checking watchlist for changes…' + : 'Rescanning watchlist…'; + }); + try { + final key = await AccountClaimKeys.current(); + await UrlTracer.instance.scanWatchlist(claimKey: key); + await _reload(); + if (!mounted || !showDigest) return; + var newlyFound = 0; + var newlyMissing = 0; + for (final w in _watch) { + final prev = priorFound[w.url]; + TraceSighting? latest; + for (final s in _sightings) { + if (s.url == w.url) { + latest = s; + break; + } + } + if (prev == null || latest == null) continue; + if (!prev && latest.found) newlyFound++; + if (prev && !latest.found) newlyMissing++; + } + if (!mounted) return; + String message; + if (newlyFound == 0 && newlyMissing == 0) { + message = 'Radar quiet — no fingerprint changes on watchlist.'; + } else { + final parts = []; + if (newlyFound > 0) { + parts.add('$newlyFound newly found'); + } + if (newlyMissing > 0) { + parts.add('$newlyMissing no longer readable'); + } + message = 'Radar digest: ${parts.join(', ')}.'; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } catch (error) { + if (!mounted) return; + setState(() => + _error = error.toString().replaceFirst('Exception: ', '')); + } finally { + if (mounted) { + setState(() { + _busy = false; + _status = null; + }); + } + } + } + + Future _ingestShared(List files) async { + final media = files.where((f) => f.path.isNotEmpty).toList(); + final chunks = []; + for (final f in files) { + final text = f.text?.trim(); + if (text != null && text.isNotEmpty) chunks.add(text); + } + final urls = _parseUrls(chunks.join('\n')); + + // Media files first — reliable on-device Trace. + if (media.isNotEmpty) { + await _scanLocalPaths(media.map((f) => f.path).toList()); + if (urls.isNotEmpty && mounted) { + _urlController.text = urls.join('\n'); + final platform = SocialPlatformInfo.fromUrl(urls.first); + if (platform != null) { + setState(() => _selectedPlatform = platform); + } + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Also received link(s). File check ran first — URL scan is optional below.', + ), + ), + ); + } + return; + } + + if (urls.isEmpty) return; + _urlController.text = urls.join('\n'); + final platform = SocialPlatformInfo.fromUrl(urls.first); + if (platform != null) { + setState(() => _selectedPlatform = platform); + } + final socialOnly = urls.every((u) => SocialPlatformInfo.fromUrl(u) != null); + if (socialOnly && mounted) { + final proceed = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: EmColors.card, + title: const Text('Post links are fragile'), + content: const Text( + 'Instagram, TikTok, and X often hide or recompress media. ' + 'Checking a shared file is far more reliable.\n\n' + 'Scan these links anyway as a fallback?', + style: TextStyle(height: 1.45), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Not now'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Scan links'), + ), + ], + ), + ); + if (proceed != true || !mounted) return; + } + await _scan(url: urls.join('\n')); + } + + Future _scanLocalFromPicker() async { + final picked = await FilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: const [ + 'png', + 'jpg', + 'jpeg', + 'webp', + 'mp4', + 'mov', + 'm4v', + 'wav', + ], + ); + if (picked == null || picked.files.isEmpty || !mounted) return; + final files = <({String name, Uint8List bytes})>[]; + for (final file in picked.files) { + final bytes = await file.readAsBytes(); + files.add((name: file.name, bytes: bytes)); + } + if (!mounted) return; + await _scanLocalFiles(files); + } + + Future _scanLocalPaths(List paths) async { + final files = <({String name, Uint8List bytes})>[]; + for (final path in paths) { + final file = File(path); + if (!await file.exists()) continue; + files.add(( + name: path.split(Platform.pathSeparator).last, + bytes: await file.readAsBytes(), + )); + } + if (files.isEmpty || !mounted) return; + await _scanLocalFiles(files); + } + + Future _scanLocalFiles( + List<({String name, Uint8List bytes})> files, + ) async { + final allowed = + await ensureUsage(context, UsageKind.trace, units: files.length); + if (!allowed || !mounted) return; + setState(() { + _busy = true; + _error = null; + _status = files.length == 1 + ? 'Checking local file…' + : 'Checking ${files.length} local files…'; + _lastResult = null; + _batchResults = null; + }); + try { + final key = await AccountClaimKeys.current(); + final results = []; + var successes = 0; + for (var i = 0; i < files.length; i++) { + if (!mounted) return; + if (files.length > 1) { + setState(() => _status = 'Checking ${i + 1}/${files.length}…'); + } + final result = await UrlTracer.instance.scanBytes( + bytes: files[i].bytes, + fileName: files[i].name, + claimKey: key, + ); + results.add(result); + successes++; + } + if (successes > 0) { + await commitUsage(UsageKind.trace, units: successes); + await OnboardingFlags.instance.markTracedOnce(); + } + if (!mounted) return; + setState(() { + _lastResult = results.last; + _batchResults = results.length > 1 ? results : null; + _status = null; + }); + await _reload(); + if (mounted && results.length > 1) { + final hits = results.where((r) => r.sighting.found).length; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Local check: $hits/${results.length} with fingerprints.', + ), + ), + ); + } + } catch (error) { + if (!mounted) return; + setState(() => + _error = error.toString().replaceFirst('Exception: ', '')); + } finally { + if (mounted) { + setState(() { + _busy = false; + _status = null; + }); + } + } + } + + Future _protectFromPicker() async { + final picked = await FilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: const [ + 'png', + 'jpg', + 'jpeg', + 'webp', + 'mp4', + 'mov', + 'm4v', + 'wav', + ], + ); + if (picked == null || picked.files.isEmpty || !mounted) return; + + final files = <({String name, Uint8List bytes})>[]; + for (final file in picked.files) { + final bytes = await file.readAsBytes(); + files.add((name: file.name, bytes: bytes)); + } + if (!mounted) return; + final allowed = + await ensureUsage(context, UsageKind.protect, units: files.length); + if (!allowed || !mounted) return; + await _protectFiles(files); + } + + Future _protectFiles( + List<({String name, Uint8List bytes})> files, + ) async { + final owner = AuthService.instance.user?.displayName.trim(); + if (owner == null || owner.isEmpty) { + setState(() => _error = 'Sign in with a display name before protecting.'); + return; + } + setState(() { + _busy = true; + _error = null; + _status = 'Fingerprinting ${files.length} file' + '${files.length == 1 ? '' : 's'} for ' + '${_selectedPlatform?.label ?? 'social'}…'; + _protected = const []; + }); + try { + final items = await SocialProtectService.instance.protectMany( + files: files, + owner: owner, + ); + if (!mounted) return; + if (items.isNotEmpty) { + await commitUsage(UsageKind.protect, units: items.length); + await OnboardingFlags.instance.markProtectedOnce(); + } + setState(() { + _protected = items; + _status = null; + }); + await _reload(); + } catch (error) { + if (!mounted) return; + setState(() => + _error = error.toString().replaceFirst('Exception: ', '')); + } finally { + if (mounted) { + setState(() { + _busy = false; + _status = null; + }); + } + } + } + + Future _shareProtected() async { + final platform = _selectedPlatform; + if (platform == null || _protected.isEmpty) return; + await shareProtectedToSocial( + platform: platform, + items: _protected, + ); + } + + Future _openPlatform() async { + final platform = _selectedPlatform; + if (platform == null) return; + final ok = await platform.openApp(); + if (!ok && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Could not open ${platform.label}.')), + ); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final platform = _selectedPlatform; + + return CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), + child: _TraceHero( + radar: _radar, + busy: _busy, + foundCount: _sightings.where((s) => s.found).length, + watchCount: _watch.length, + claimCount: _claims.length, + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 22, 20, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Trace the signal', + style: theme.textTheme.displayMedium, + ), + const SizedBox(height: 8), + Text( + 'Check a shared or saved file first — that survives social recompression better than post URLs. Then fingerprint before you post, and watch links as a fallback.', + style: theme.textTheme.bodyLarge?.copyWith( + color: EmColors.mutedForeground, + height: 1.45, + ), + ), + ], + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 22, 20, 0), + child: _PlatformSelector( + selected: _selectedPlatform, + onSelected: (value) => setState(() => _selectedPlatform = value), + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 0), + child: EmCard( + glow: _busy, + borderColor: EmColors.accent.withValues(alpha: 0.4), + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const MonoLabel('1 · Check a file (best)', + color: EmColors.accent), + const SizedBox(height: 10), + const UsageStatusBanner(kind: UsageKind.trace), + const Text( + 'Share media from Instagram / TikTok / X into Signata, or pick a saved export. Local checks beat fragile post links.', + style: TextStyle( + fontSize: 13.5, + height: 1.45, + color: EmColors.mutedForeground, + ), + ), + const SizedBox(height: 14), + FilledButton.icon( + onPressed: _busy ? null : _scanLocalFromPicker, + icon: _busy + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.folder_open_rounded, size: 18), + label: Text(_busy ? 'Checking…' : 'Check local file'), + ), + ], + ), + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), + child: EmCard( + borderColor: Color(platform?.accent ?? 0xFF57D9EC) + .withValues(alpha: 0.45), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MonoLabel( + platform == null + ? '2 · Protect & post' + : '2 · Protect for ${platform.label}', + color: Color(platform?.accent ?? 0xFF57D9EC), + ), + const SizedBox(height: 10), + Text( + platform?.hint ?? + 'Fingerprint images or videos, then share into the social app.', + style: const TextStyle( + fontSize: 13.5, + height: 1.45, + color: EmColors.mutedForeground, + ), + ), + const SizedBox(height: 14), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + FilledButton.tonalIcon( + onPressed: _busy ? null : _protectFromPicker, + icon: const Icon(Icons.auto_awesome, size: 18), + label: const Text('Fingerprint media'), + ), + OutlinedButton.icon( + onPressed: _busy || _protected.isEmpty + ? null + : _shareProtected, + icon: const Icon(Icons.ios_share, size: 18), + label: Text( + _protected.isEmpty + ? 'Share to app' + : 'Share ${_protected.length} to ${platform?.shortLabel ?? 'app'}', + ), + ), + ], + ), + if (_protected.isNotEmpty) ...[ + const SizedBox(height: 14), + ..._protected.map( + (item) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Icon( + item.medium == TraceMedium.video + ? Icons.movie_outlined + : item.medium == TraceMedium.audio + ? Icons.graphic_eq + : Icons.image_outlined, + size: 18, + color: EmColors.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + item.fileName, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + Text( + item.reference.length > 10 + ? item.reference.substring(0, 10) + : item.reference, + style: emMonoLabel(size: 9), + ), + ], + ), + ), + ), + ], + const SizedBox(height: 8), + Text( + 'Tip: share the media into Signata after posting if you need to verify — not only the post URL.', + style: theme.textTheme.bodySmall?.copyWith( + color: EmColors.mutedForeground, + height: 1.4, + ), + ), + ], + ), + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), + child: EmCard( + padding: const EdgeInsets.all(12), + child: Theme( + data: theme.copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + initiallyExpanded: false, + tilePadding: const EdgeInsets.symmetric(horizontal: 8), + childrenPadding: const EdgeInsets.fromLTRB(8, 0, 8, 12), + title: MonoLabel( + platform == null + ? '3 · Scan URL (fallback)' + : '3 · Scan ${platform.label} URL (fallback)', + ), + subtitle: const Padding( + padding: EdgeInsets.only(top: 6), + child: Text( + 'Post pages often hide media or strip marks. Prefer step 1.', + style: TextStyle( + fontSize: 12.5, + color: EmColors.mutedForeground, + ), + ), + ), + children: [ + if (platform != null) + Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: _busy ? null : _openPlatform, + child: Text('Open ${platform.shortLabel}'), + ), + ), + TextField( + controller: _urlController, + keyboardType: TextInputType.multiline, + minLines: 1, + maxLines: 4, + textInputAction: TextInputAction.newline, + decoration: InputDecoration( + hintText: platform == null + ? 'Prefer direct media CDN links' + : 'Paste ${platform.label} post/media URLs', + prefixIcon: const Icon(Icons.link_rounded), + ), + ), + const SizedBox(height: 10), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + value: _watchAfterScan, + onChanged: _busy + ? null + : (v) => setState(() => _watchAfterScan = v), + title: const Text('Keep on radar'), + subtitle: const Text( + 'Free rescans when you open Trace (online, every 12h).', + style: TextStyle( + fontSize: 12, color: EmColors.mutedForeground), + ), + ), + const SizedBox(height: 6), + OutlinedButton.icon( + onPressed: _busy ? null : () => _scan(), + icon: const Icon(Icons.radar, size: 18), + label: const Text('Scan link(s) anyway'), + ), + ], + ), + ), + ), + ), + ), + if (_status != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), + child: _PulseStatus(text: _status!), + ), + ), + if (_error != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), + child: StatusBanner( + ok: false, + title: 'Trace interrupted', + subtitle: _error!, + ), + ), + ), + if (_batchResults != null && _batchResults!.length > 1) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), + child: EmCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const MonoLabel('Batch scan'), + const SizedBox(height: 8), + Text( + '${_batchResults!.where((r) => r.sighting.found).length}' + '/${_batchResults!.length} links had a readable fingerprint.', + style: const TextStyle( + fontSize: 13.5, + height: 1.4, + color: EmColors.mutedForeground, + ), + ), + ], + ), + ), + ), + ), + if (_lastResult != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), + child: _ResultCard( + result: _lastResult!, + onCheckFile: _busy ? null : _scanLocalFromPicker, + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 28, 20, 8), + child: Row( + children: [ + Expanded( + child: Text('RADAR WATCHLIST', style: emMonoLabel(size: 10)), + ), + TextButton( + onPressed: _busy || _watch.isEmpty + ? null + : () => _rescanAll(showDigest: true), + child: const Text('Rescan all'), + ), + ], + ), + ), + ), + if (_watch.isEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text( + 'No links on radar yet. Prefer checking a shared file; add URLs only as a fallback with “Keep on radar”.', + style: theme.textTheme.bodyMedium?.copyWith( + color: EmColors.mutedForeground, + ), + ), + ), + ) + else + SliverList.builder( + itemCount: _watch.length, + itemBuilder: (context, index) { + final w = _watch[index]; + final social = SocialPlatformInfo.fromUrl(w.url); + return Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 10), + child: _WatchTile( + target: w, + platform: social, + busy: _busy, + onScan: () { + _urlController.text = w.url; + _scan(url: w.url, countTowardQuota: false); + }, + onRemove: () async { + await TraceStore.instance.removeWatchTarget(w.id); + await _reload(); + }, + ), + ); + }, + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 8), + child: Text('PUBLISHED CLAIMS', style: emMonoLabel(size: 10)), + ), + ), + if (_claims.isEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text( + 'Protect media above (or in any tool) to publish claims Trace can match online.', + style: theme.textTheme.bodyMedium?.copyWith( + color: EmColors.mutedForeground, + ), + ), + ), + ) + else + SliverList.builder( + itemCount: math.min(_claims.length, 20), + itemBuilder: (context, index) => Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 10), + child: _ClaimTile(claim: _claims[index]), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 8), + child: Text('SIGHTING TIMELINE', style: emMonoLabel(size: 10)), + ), + ), + if (_sightings.isEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 40), + child: Text( + 'Sightings appear as a timeline whenever a scan finds — or misses — a fingerprint.', + style: theme.textTheme.bodyMedium?.copyWith( + color: EmColors.mutedForeground, + ), + ), + ), + ) + else + SliverList.builder( + itemCount: math.min(_sightings.length, 25), + itemBuilder: (context, index) => Padding( + padding: EdgeInsets.fromLTRB( + 20, + 0, + 20, + index == math.min(_sightings.length, 25) - 1 ? 40 : 0, + ), + child: _SightingTile( + sighting: _sightings[index], + isFirst: index == 0, + isLast: index == math.min(_sightings.length, 25) - 1, + ), + ), + ), + ], + ); + } +} + +class _TraceHero extends StatelessWidget { + const _TraceHero({ + required this.radar, + required this.busy, + required this.foundCount, + required this.watchCount, + required this.claimCount, + }); + + final AnimationController radar; + final bool busy; + final int foundCount; + final int watchCount; + final int claimCount; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 210, + child: Stack( + alignment: Alignment.center, + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(28), + gradient: RadialGradient( + center: const Alignment(0, -0.2), + radius: 1.05, + colors: [ + EmColors.primary.withValues(alpha: busy ? 0.28 : 0.16), + EmColors.accent.withValues(alpha: 0.08), + EmColors.background.withValues(alpha: 0.2), + ], + ), + border: Border.all(color: EmColors.border), + ), + ), + ), + AnimatedBuilder( + animation: radar, + builder: (context, _) => CustomPaint( + size: const Size(180, 180), + painter: _RadarPainter( + progress: radar.value, + accent: busy ? EmColors.accent : EmColors.primary, + ), + ), + ), + Positioned( + left: 18, + right: 18, + bottom: 16, + child: Row( + children: [ + _StatChip(label: 'Found', value: '$foundCount'), + const SizedBox(width: 8), + _StatChip(label: 'Watching', value: '$watchCount'), + const SizedBox(width: 8), + _StatChip(label: 'Claims', value: '$claimCount'), + ], + ), + ), + ], + ), + ); + } +} + +class _StatChip extends StatelessWidget { + const _StatChip({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: EmColors.background.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: EmColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label.toUpperCase(), + style: emMonoLabel(color: EmColors.mutedForeground, size: 8)), + const SizedBox(height: 2), + Text(value, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: EmColors.foreground)), + ], + ), + ), + ); + } +} + +class _RadarPainter extends CustomPainter { + const _RadarPainter({required this.progress, required this.accent}); + + final double progress; + final Color accent; + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = size.width * 0.42; + final ring = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.2 + ..color = EmColors.foreground.withValues(alpha: 0.12); + + for (var i = 1; i <= 3; i++) { + canvas.drawCircle(center, radius * (i / 3), ring); + } + canvas.drawLine( + Offset(center.dx - radius, center.dy), + Offset(center.dx + radius, center.dy), + ring, + ); + canvas.drawLine( + Offset(center.dx, center.dy - radius), + Offset(center.dx, center.dy + radius), + ring, + ); + + final sweep = Paint() + ..shader = SweepGradient( + startAngle: 0, + endAngle: math.pi / 2, + colors: [ + accent.withValues(alpha: 0.0), + accent.withValues(alpha: 0.55), + ], + transform: GradientRotation(progress * math.pi * 2), + ).createShader(Rect.fromCircle(center: center, radius: radius)); + canvas.drawCircle(center, radius, sweep..style = PaintingStyle.fill); + + final beam = Paint() + ..color = accent + ..strokeWidth = 2 + ..strokeCap = StrokeCap.round; + final angle = progress * math.pi * 2; + canvas.drawLine( + center, + Offset( + center.dx + math.cos(angle) * radius, + center.dy + math.sin(angle) * radius, + ), + beam, + ); + + canvas.drawCircle( + center, + 5, + Paint()..color = accent, + ); + } + + @override + bool shouldRepaint(covariant _RadarPainter oldDelegate) => + oldDelegate.progress != progress || oldDelegate.accent != accent; +} + +class _PlatformSelector extends StatelessWidget { + const _PlatformSelector({ + required this.selected, + required this.onSelected, + }); + + final SocialPlatformInfo? selected; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + for (final platform in SocialPlatformInfo.all) ...[ + if (platform != SocialPlatformInfo.all.first) const SizedBox(width: 10), + Expanded( + child: _PlatformCard( + platform: platform, + selected: selected?.id == platform.id, + onTap: () => onSelected(platform), + ), + ), + ], + ], + ); + } +} + +class _PlatformCard extends StatelessWidget { + const _PlatformCard({ + required this.platform, + required this.selected, + required this.onTap, + }); + + final SocialPlatformInfo platform; + final bool selected; + final VoidCallback onTap; + + IconData get _icon => switch (platform.id) { + SocialPlatform.instagram => Icons.photo_camera_outlined, + SocialPlatform.tiktok => Icons.music_note_outlined, + SocialPlatform.x => Icons.tag_outlined, + SocialPlatform.other => Icons.public, + }; + + @override + Widget build(BuildContext context) { + final accent = Color(platform.accent); + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(18), + child: AnimatedContainer( + duration: const Duration(milliseconds: 220), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8), + decoration: BoxDecoration( + color: selected + ? accent.withValues(alpha: 0.14) + : EmColors.card, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: selected + ? accent.withValues(alpha: 0.7) + : EmColors.border, + ), + ), + child: Column( + children: [ + Icon(_icon, + color: selected ? accent : EmColors.mutedForeground), + const SizedBox(height: 8), + Text( + platform.label, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w600, + color: selected ? EmColors.foreground : EmColors.mutedForeground, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _PulseStatus extends StatefulWidget { + const _PulseStatus({required this.text}); + + final String text; + + @override + State<_PulseStatus> createState() => _PulseStatusState(); +} + +class _PulseStatusState extends State<_PulseStatus> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(reverse: true); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: EmColors.primary + .withValues(alpha: 0.25 + _controller.value * 0.35), + ), + color: EmColors.primary.withValues(alpha: 0.08), + ), + child: Row( + children: [ + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: EmColors.primary + .withValues(alpha: 0.55 + _controller.value * 0.45), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + widget.text, + style: const TextStyle( + fontSize: 13, + color: EmColors.foreground, + ), + ), + ), + ], + ), + ); + }, + ); + } +} + +class _WatchTile extends StatelessWidget { + const _WatchTile({ + required this.target, + required this.platform, + required this.busy, + required this.onScan, + required this.onRemove, + }); + + final WatchTarget target; + final SocialPlatformInfo? platform; + final bool busy; + final VoidCallback onScan; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + final accent = Color(platform?.accent ?? 0xFF57D9EC); + return EmCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: accent, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: accent.withValues(alpha: 0.45), + blurRadius: 8, + ), + ], + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + target.label ?? platform?.label ?? 'Watch target', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + target.url, + style: const TextStyle( + fontSize: 12, + color: EmColors.mutedForeground, + ), + ), + const SizedBox(height: 8), + Text( + target.lastScannedAt == null + ? 'Awaiting first sweep' + : 'Last sweep · ${_fmt(target.lastScannedAt!)}' + '${target.lastReference == null ? '' : ' · ${target.lastReference}'}', + style: emMonoLabel(color: EmColors.mutedForeground, size: 9), + ), + const SizedBox(height: 10), + Row( + children: [ + OutlinedButton( + onPressed: busy ? null : onScan, + child: const Text('Scan'), + ), + const SizedBox(width: 8), + TextButton( + onPressed: onRemove, + child: const Text('Remove'), + ), + ], + ), + ], + ), + ); + } +} + +class _ClaimTile extends StatelessWidget { + const _ClaimTile({required this.claim}); + + final PublishedClaim claim; + + @override + Widget build(BuildContext context) { + return EmCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${claim.medium.wire.toUpperCase()} · ${claim.subject}', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 6), + Text(claim.owner, + style: const TextStyle( + fontSize: 13, color: EmColors.mutedForeground)), + const SizedBox(height: 8), + SelectableText(claim.reference, style: emMonoLabel(size: 10)), + const SizedBox(height: 6), + Text( + claim.remoteSynced + ? 'Synced to remote registry' + : 'Stored on this device' + '${ClaimRegistry.hasRemote ? ' · remote sync pending/failed' : ''}', + style: emMonoLabel(color: EmColors.mutedForeground, size: 9), + ), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: () async { + await Clipboard.setData(ClipboardData(text: claim.reference)); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Fingerprint copied')), + ); + }, + icon: const Icon(Icons.copy, size: 16), + label: const Text('Copy fingerprint'), + ), + ), + ], + ), + ); + } +} + +class _SightingTile extends StatelessWidget { + const _SightingTile({ + required this.sighting, + required this.isFirst, + required this.isLast, + }); + + final TraceSighting sighting; + final bool isFirst; + final bool isLast; + + @override + Widget build(BuildContext context) { + final color = sighting.found ? EmColors.accent : EmColors.mutedForeground; + final social = SocialPlatformInfo.fromUrl(sighting.url); + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: 22, + child: Column( + children: [ + Container( + width: 2, + height: isFirst ? 8 : 0, + color: Colors.transparent, + ), + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.2), + shape: BoxShape.circle, + border: Border.all(color: color, width: 2), + ), + ), + Expanded( + child: Container( + width: 2, + color: isLast + ? Colors.transparent + : EmColors.border, + ), + ), + ], + ), + ), + const SizedBox(width: 10), + Expanded( + child: Padding( + padding: const EdgeInsets.only(bottom: 14), + child: EmCard( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + sighting.found + ? (sighting.matchedPublishedClaimId != null + ? 'Matched your claim' + : 'Fingerprint found') + : 'No fingerprint', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: sighting.found + ? EmColors.accent + : EmColors.foreground, + ), + ), + ), + if (social != null) + Text( + social.shortLabel, + style: emMonoLabel( + color: Color(social.accent), + size: 9, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + sighting.url, + style: const TextStyle( + fontSize: 12, + color: EmColors.mutedForeground, + ), + ), + if (sighting.owner != null) ...[ + const SizedBox(height: 6), + Text('${sighting.owner} · ${sighting.subject ?? sighting.medium.wire}'), + ], + if (sighting.reference != null) ...[ + const SizedBox(height: 6), + Text(sighting.reference!, style: emMonoLabel(size: 9)), + ], + if (sighting.error != null) ...[ + const SizedBox(height: 6), + Text( + sighting.error!, + style: const TextStyle( + fontSize: 12, + color: EmColors.destructive, + ), + ), + ] else if (!sighting.found && + (sighting.note != null || social != null)) ...[ + const SizedBox(height: 6), + Text( + sighting.note ?? + (social != null + ? '${social.label} often recompresses media and can strip hidden marks.' + : 'Re-encoding may have removed the fingerprint.'), + style: const TextStyle( + fontSize: 12, + height: 1.35, + color: EmColors.mutedForeground, + ), + ), + ], + const SizedBox(height: 6), + Text( + _fmt(sighting.at), + style: emMonoLabel( + color: EmColors.mutedForeground, + size: 9, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} + +class _ResultCard extends StatelessWidget { + const _ResultCard({ + required this.result, + this.onCheckFile, + }); + + final TraceScanResult result; + final VoidCallback? onCheckFile; + + @override + Widget build(BuildContext context) { + final s = result.sighting; + final social = SocialPlatformInfo.fromUrl(s.url); + final isLocal = s.url.startsWith('local://'); + final banner = traceResultBanner( + status: s.claimStatus, + error: s.error, + note: s.note, + socialHost: social != null, + ); + return EmCard( + glow: s.found, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (social != null) ...[ + MonoLabel( + s.found ? 'Hit on ${social.label}' : 'Checked on ${social.label}', + color: Color(social.accent), + ), + const SizedBox(height: 10), + ] else if (isLocal) ...[ + const MonoLabel('Local file check', color: EmColors.accent), + const SizedBox(height: 10), + ], + StatusBanner( + ok: banner.ok, + title: banner.title, + subtitle: result.matchedClaim != null + ? '${banner.subtitle} Matched published claim “${result.matchedClaim!.subject}”.' + : banner.subtitle, + ), + if (!s.found && onCheckFile != null && !isLocal) ...[ + const SizedBox(height: 12), + FilledButton.tonalIcon( + onPressed: onCheckFile, + icon: const Icon(Icons.folder_open_rounded, size: 18), + label: const Text('Check a local / shared file instead'), + ), + ], + const SizedBox(height: 14), + DetailList(rows: [ + DetailRow( + isLocal ? 'File' : 'URL', + isLocal ? s.url.replaceFirst('local://', '') : s.url, + ), + DetailRow('Medium', s.medium.wire), + DetailRow('Owner', s.owner ?? '—'), + DetailRow('Subject', s.subject ?? '—'), + DetailRow('Fingerprint', s.reference ?? '—', mono: true), + DetailRow( + 'Catalog match', + result.matchedClaim == null ? 'none' : 'your claim', + ), + ]), + ], + ), + ); + } +} + +String _fmt(DateTime at) { + final local = at.toLocal(); + String two(int n) => n.toString().padLeft(2, '0'); + return '${local.year}-${two(local.month)}-${two(local.day)} ' + '${two(local.hour)}:${two(local.minute)}'; +} diff --git a/lib/screens/verify_email_screen.dart b/lib/screens/verify_email_screen.dart new file mode 100644 index 0000000..ae00b07 --- /dev/null +++ b/lib/screens/verify_email_screen.dart @@ -0,0 +1,238 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../core/auth.dart'; +import '../theme.dart'; +import '../widgets/em_widgets.dart'; + +/// Blocks the app until the user activates their on-device email account. +/// +/// Signata does not run a mail server — the code is generated on-device and +/// optionally drafted via mailto for the user to save. +class VerifyEmailScreen extends StatefulWidget { + const VerifyEmailScreen({super.key}); + + @override + State createState() => _VerifyEmailScreenState(); +} + +class _VerifyEmailScreenState extends State { + final _codeController = TextEditingController(); + bool _busy = false; + bool _showCode = false; + String? _error; + String? _info; + String? _plainCode; + + @override + void initState() { + super.initState(); + _info = + 'Signata creates an activation code on this device (no email server). ' + 'We can open a mailto draft so you can save it — or show the code here.'; + WidgetsBinding.instance.addPostFrameCallback((_) => _issueCode(silent: true)); + } + + @override + void dispose() { + _codeController.dispose(); + super.dispose(); + } + + Future _issueCode({bool silent = false}) async { + setState(() { + _busy = true; + _error = null; + if (!silent) _info = null; + }); + try { + final code = await AuthService.instance.sendEmailVerification(); + if (!mounted) return; + setState(() { + _plainCode = code.isEmpty ? _plainCode : code; + _info = silent + ? _info + : 'New code ready. Open your mail draft or reveal the code below.'; + }); + } catch (error) { + if (!mounted) return; + setState(() => _error = error.toString().replaceFirst('Exception: ', '')); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _confirm() async { + setState(() { + _busy = true; + _error = null; + }); + try { + await AuthService.instance.confirmEmailVerification(_codeController.text); + } catch (error) { + if (!mounted) return; + setState(() => _error = error.toString().replaceFirst('Exception: ', '')); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final email = AuthService.instance.user?.email ?? ''; + final theme = Theme.of(context); + + return Scaffold( + body: Stack( + children: [ + const GridBackdrop(spacing: 48, opacity: 0.05), + SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 40), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const EmLogo(size: 36), + const SizedBox(height: 28), + const MonoLabel('Activate account'), + const SizedBox(height: 12), + Text('Finish setup', + style: theme.textTheme.displayMedium), + const SizedBox(height: 10), + Text( + 'Enter the activation code for $email. This is a local ' + 'device step — not remote email ownership proof.', + style: theme.textTheme.bodyLarge?.copyWith( + color: EmColors.mutedForeground, + height: 1.5, + ), + ), + const SizedBox(height: 28), + EmCard( + padding: const EdgeInsets.all(22), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_info != null) ...[ + Text( + _info!, + style: const TextStyle( + fontSize: 13, + height: 1.45, + color: EmColors.mutedForeground, + ), + ), + const SizedBox(height: 16), + ], + if (_plainCode != null) ...[ + OutlinedButton.icon( + onPressed: _busy + ? null + : () => setState(() => _showCode = !_showCode), + icon: Icon( + _showCode + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + size: 18, + ), + label: Text( + _showCode ? 'Hide code' : 'Show code on device', + ), + ), + if (_showCode) ...[ + const SizedBox(height: 10), + SelectableText( + _plainCode!, + style: emMonoLabel(size: 22).copyWith( + letterSpacing: 4, + color: EmColors.primary, + ), + textAlign: TextAlign.center, + ), + TextButton( + onPressed: () async { + await Clipboard.setData( + ClipboardData(text: _plainCode!), + ); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Code copied'), + ), + ); + }, + child: const Text('Copy code'), + ), + ], + const SizedBox(height: 12), + ], + TextField( + controller: _codeController, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.done, + autofillHints: const [AutofillHints.oneTimeCode], + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(6), + ], + onSubmitted: (_) => _busy ? null : _confirm(), + decoration: const InputDecoration( + labelText: 'Activation code', + hintText: '123456', + ), + ), + if (_error != null) ...[ + const SizedBox(height: 14), + StatusBanner( + ok: false, + title: 'Could not activate', + subtitle: _error!, + ), + ], + const SizedBox(height: 18), + FilledButton( + onPressed: _busy ? null : _confirm, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(50), + ), + child: _busy + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2), + ) + : const Text('Activate & continue'), + ), + const SizedBox(height: 8), + TextButton( + onPressed: _busy ? null : () => _issueCode(), + child: const Text('New code / open mail draft'), + ), + ], + ), + ), + const SizedBox(height: 16), + TextButton( + onPressed: _busy + ? null + : () => AuthService.instance.signOut(), + child: const Text( + 'Sign out', + style: TextStyle(color: EmColors.mutedForeground), + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/video_tool_screen.dart b/lib/screens/video_tool_screen.dart index d842b89..7b59223 100644 --- a/lib/screens/video_tool_screen.dart +++ b/lib/screens/video_tool_screen.dart @@ -3,17 +3,24 @@ import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import '../core/claim_crypto.dart'; +import '../core/claim_status_ui.dart'; import '../core/history.dart'; +import '../core/onboarding.dart'; import '../core/report.dart'; import '../core/share_utils.dart'; +import '../core/trace_models.dart'; +import '../core/usage_entitlements.dart'; import '../core/video_fingerprint.dart'; import '../theme.dart'; import '../widgets/em_widgets.dart'; +import '../widgets/publish_claim_button.dart'; +import '../widgets/usage_paywall.dart'; enum VideoMode { embed, verify } class VideoToolScreen extends StatefulWidget { - const VideoToolScreen({super.key, this.defaultOwner = 'Studio Nova'}); + const VideoToolScreen({super.key, this.defaultOwner = ''}); final String defaultOwner; @@ -53,6 +60,10 @@ class _VideoToolScreenState extends State { } Future _pickAndRun() async { + if (_mode == VideoMode.embed) { + final allowed = await ensureUsage(context, UsageKind.protect); + if (!allowed || !mounted) return; + } final picked = await FilePicker.pickFiles( type: FileType.custom, allowedExtensions: const ['mp4', 'mov', 'm4v'], @@ -66,12 +77,14 @@ class _VideoToolScreenState extends State { final stopwatch = Stopwatch()..start(); try { + final claimKey = await AccountClaimKeys.current(); if (_mode == VideoMode.embed) { setState(() => _workingStep = 'Profiling container structure'); final outcome = await embedVideoFingerprint( source: bytes, owner: _ownerController.text, fileName: file.name, + claimKey: claimKey, ); stopwatch.stop(); if (!mounted) return; @@ -85,13 +98,15 @@ class _VideoToolScreenState extends State { action: 'embed', subject: file.name, owner: outcome.payload.owner, - verified: outcome.verified, - reference: outcome.payload.identifier, + verified: claimCountsAsVerified(outcome.claimStatus), + reference: outcome.payload.structuralIdentifier(), at: DateTime.now(), )); + await commitUsage(UsageKind.protect); + await OnboardingFlags.instance.markProtectedOnce(); } else { setState(() => _workingStep = 'Extracting & verifying'); - final outcome = await verifyVideoFingerprint(bytes); + final outcome = await verifyVideoFingerprint(bytes, claimKey: claimKey); stopwatch.stop(); if (!mounted) return; setState(() { @@ -104,7 +119,7 @@ class _VideoToolScreenState extends State { action: 'verify', subject: file.name, owner: outcome.recovered?.owner ?? 'Unknown', - verified: outcome.verified, + verified: claimCountsAsVerified(outcome.claimStatus), reference: outcome.recovered?.identifier ?? '—', at: DateTime.now(), )); @@ -129,16 +144,42 @@ class _VideoToolScreenState extends State { ); } + Future _downloadMarked() async { + final outcome = _embedOutcome; + if (outcome == null) return; + try { + final path = await downloadBytes( + bytes: outcome.markedBytes, + fileName: 'signata-${_fileName ?? 'video.mp4'}', + dialogTitle: 'Download fingerprinted video', + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Saved to $path')), + ); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + error.toString().replaceFirst('Exception: ', ''), + ), + ), + ); + } + } + Future _exportReport() async { final outcome = _embedOutcome; if (outcome == null) return; setState(() => _reportStatus = 'Sealing…'); try { + final claimKey = await AccountClaimKeys.current(); final sealed = sealReport(ReportBody( medium: 'video', subject: outcome.recovered?.document ?? _fileName ?? 'video.mp4', owner: outcome.recovered?.owner ?? 'Unknown', - verified: outcome.verified, + verified: claimCountsAsVerified(outcome.claimStatus), issued: outcome.payload.issued, generated: DateTime.now().toUtc().toIso8601String(), evidence: { @@ -149,8 +190,8 @@ class _VideoToolScreenState extends State { 'structure': outcome.recovered?.structure.toJson(), 'roundTripMs': _elapsedMs, }, - )); - if (!verifySealedReport(sealed)) { + ), claimKey: claimKey); + if (!verifySealedReport(sealed, claimKey: claimKey)) { throw Exception('Seal self-check failed.'); } await shareBytes( @@ -210,12 +251,17 @@ class _VideoToolScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_mode == VideoMode.embed) ...[ + const UsageStatusBanner(kind: UsageKind.protect), const MonoLabel('Ownership claim'), const SizedBox(height: 10), TextField( controller: _ownerController, + readOnly: true, decoration: const InputDecoration( hintText: 'Creator or studio name', + helperText: + 'Bound to your Signata account — cannot be spoofed for authentication.', + helperMaxLines: 2, ), ), const SizedBox(height: 16), @@ -225,7 +271,9 @@ class _VideoToolScreenState extends State { title: _mode == VideoMode.embed ? 'Choose a video to fingerprint' : 'Choose a video to check', - hint: 'MP4 / MOV / M4V — processed locally', + hint: _mode == VideoMode.embed + ? 'MP4 / MOV / M4V — re-encoding after protect can drop the mark' + : 'Check the original protected file when possible', buttonLabel: 'Choose video', onPick: _pickAndRun, fileName: _fileName, @@ -248,10 +296,33 @@ class _VideoToolScreenState extends State { ], if (_embedOutcome != null) ...[ const SizedBox(height: 14), - OutlinedButton.icon( - onPressed: _shareMarked, - icon: const Icon(Icons.ios_share, size: 18), - label: const Text('Share fingerprinted video'), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + FilledButton.icon( + onPressed: _downloadMarked, + icon: const Icon(Icons.download, size: 18), + label: const Text('Download'), + ), + OutlinedButton.icon( + onPressed: _shareMarked, + icon: const Icon(Icons.ios_share, size: 18), + label: const Text('Share'), + ), + ], + ), + const SizedBox(height: 10), + PublishClaimButton( + medium: TraceMedium.video, + owner: _embedOutcome!.payload.owner, + subject: _embedOutcome!.payload.document, + reference: _embedOutcome!.payload.identifier ?? + _embedOutcome!.payload.signature ?? + '', + issued: _embedOutcome!.payload.issued, + alg: _embedOutcome!.payload.alg, + kid: _embedOutcome!.payload.kid, ), ], ], @@ -267,7 +338,7 @@ class _VideoToolScreenState extends State { ..._buildReport(), const SizedBox(height: 14), const Text( - 'Prototype note: the identifier is carried in a trailing marker for illustration. Production will distribute ownership across sample tables and metadata boxes so it survives remuxing.', + 'Prototype note: the identifier is carried in a uuid atom and signed with your account key for illustration. Production will distribute ownership across sample tables and metadata boxes so it survives remuxing.', style: TextStyle( fontSize: 11.5, height: 1.5, @@ -309,9 +380,9 @@ class _VideoToolScreenState extends State { final recovered = _mode == VideoMode.embed ? _embedOutcome?.recovered : _verifyOutcome?.recovered; - final verified = _mode == VideoMode.embed - ? _embedOutcome?.verified - : _verifyOutcome?.verified; + final claimStatus = _mode == VideoMode.embed + ? _embedOutcome?.claimStatus + : _verifyOutcome?.claimStatus; final structureMatch = _mode == VideoMode.embed ? _embedOutcome?.structureMatch : _verifyOutcome?.structureMatch; @@ -333,15 +404,16 @@ class _VideoToolScreenState extends State { ]; } + final banner = claimBanner(claimStatus ?? ClaimStatus.missing); + final payload = _mode == VideoMode.embed + ? _embedOutcome?.payload + : _verifyOutcome?.recovered; + return [ StatusBanner( - ok: verified ?? false, - title: (verified ?? false) - ? 'Video ownership verified' - : 'No valid fingerprint found', - subtitle: (verified ?? false) - ? 'Recovered identifier matches the recomputed structural digest.' - : 'The identifier could not be recovered or did not match the structure.', + ok: banner.ok, + title: banner.title, + subtitle: banner.subtitle, ), const SizedBox(height: 14), DetailList(rows: [ @@ -353,6 +425,10 @@ class _VideoToolScreenState extends State { 'Structure match', (structureMatch ?? false) ? 'identical' : 'mismatch', mono: true), + DetailRow( + 'Key id', + _kidLabel(payload?.alg ?? recovered?.alg, payload?.kid ?? recovered?.kid), + mono: true), DetailRow('Round trip', '$_elapsedMs ms', mono: true), ]), if (_mode == VideoMode.embed) ...[ @@ -372,4 +448,10 @@ class _VideoToolScreenState extends State { PayloadViewer(title: 'Extracted identifier payload', raw: raw), ]; } + + static String _kidLabel(String? alg, String? kid) { + if (alg == null || alg.isEmpty || alg == claimAlgFnv16) return 'legacy'; + if (kid != null && kid.isNotEmpty) return kid; + return 'legacy'; + } } diff --git a/lib/widgets/em_widgets.dart b/lib/widgets/em_widgets.dart index 608843d..15fad31 100644 --- a/lib/widgets/em_widgets.dart +++ b/lib/widgets/em_widgets.dart @@ -139,24 +139,27 @@ class EmCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: padding, - decoration: BoxDecoration( - color: EmColors.card, - borderRadius: BorderRadius.circular(22), - border: Border.all(color: borderColor), - boxShadow: glow - ? [ - BoxShadow( - color: EmColors.primary.withValues(alpha: 0.25), - blurRadius: 40, - spreadRadius: -8, - ), - ] - : null, + return Material( + color: Colors.transparent, + child: Container( + width: double.infinity, + padding: padding, + decoration: BoxDecoration( + color: EmColors.card, + borderRadius: BorderRadius.circular(22), + border: Border.all(color: borderColor), + boxShadow: glow + ? [ + BoxShadow( + color: EmColors.primary.withValues(alpha: 0.25), + blurRadius: 40, + spreadRadius: -8, + ), + ] + : null, + ), + child: child, ), - child: child, ); } } diff --git a/lib/widgets/publish_claim_button.dart b/lib/widgets/publish_claim_button.dart new file mode 100644 index 0000000..a0ddea5 --- /dev/null +++ b/lib/widgets/publish_claim_button.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; + +import '../core/claim_publisher.dart'; +import '../core/trace_models.dart'; +import '../theme.dart'; + +/// One-tap publish after a successful protect, for internet tracing. +class PublishClaimButton extends StatefulWidget { + const PublishClaimButton({ + super.key, + required this.medium, + required this.owner, + required this.subject, + required this.reference, + required this.issued, + this.alg, + this.kid, + }); + + final TraceMedium medium; + final String owner; + final String subject; + final String reference; + final String issued; + final String? alg; + final String? kid; + + @override + State createState() => _PublishClaimButtonState(); +} + +class _PublishClaimButtonState extends State { + bool _busy = false; + String? _status; + + Future _publish() async { + setState(() { + _busy = true; + _status = null; + }); + try { + final claim = await ClaimPublisher.publishProtected( + medium: widget.medium, + owner: widget.owner, + subject: widget.subject, + reference: widget.reference, + issued: widget.issued, + alg: widget.alg, + kid: widget.kid, + ); + if (!mounted) return; + setState(() { + _status = claim == null + ? 'Could not publish claim.' + : claim.remoteSynced + ? 'Published & synced for web tracing' + : 'Published on this device for web tracing'; + }); + } catch (error) { + if (!mounted) return; + setState(() => + _status = error.toString().replaceFirst('Exception: ', '')); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + OutlinedButton.icon( + onPressed: _busy ? null : _publish, + icon: _busy + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.public, size: 18), + label: Text(_busy ? 'Publishing…' : 'Publish for web tracing'), + ), + if (_status != null) ...[ + const SizedBox(height: 8), + Text( + _status!.toUpperCase(), + style: emMonoLabel(color: EmColors.mutedForeground, size: 9), + ), + ], + ], + ); + } +} diff --git a/lib/widgets/usage_paywall.dart b/lib/widgets/usage_paywall.dart new file mode 100644 index 0000000..409c1df --- /dev/null +++ b/lib/widgets/usage_paywall.dart @@ -0,0 +1,246 @@ +/// Paywall dialog + quota banner for the demo freemium gate. +/// +/// [ensureUsage] checks quota (and may show the paywall) without consuming. +/// Call [commitUsage] only after the protect/Trace action succeeds. +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../core/usage_entitlements.dart'; +import '../theme.dart'; + +/// Ensure the user may attempt [units] of [kind]. Does not consume quota. +Future ensureUsage( + BuildContext context, + UsageKind kind, { + int units = 1, +}) async { + final entitlements = UsageEntitlements.instance; + if (await entitlements.check(kind, units: units) == GateResult.allowed) { + return true; + } + if (!context.mounted) return false; + + // Keep offering ads until the requested units fit, or the user cancels. + while (await entitlements.check(kind, units: units) == GateResult.blocked) { + if (!context.mounted) return false; + final choice = await showDialog<_PaywallChoice>( + context: context, + builder: (context) => _PaywallDialog(kind: kind, units: units), + ); + if (choice == null || !context.mounted) return false; + + switch (choice) { + case _PaywallChoice.watchAd: + final watched = await _showMockAd(context); + if (!watched) return false; + await entitlements.grantAdBonus(kind: kind); + case _PaywallChoice.goPremium: + final purchased = await purchasePremiumFlow(context); + if (!purchased) return false; + return true; + } + } + return true; +} + +/// Consume quota after a successful action. +Future commitUsage(UsageKind kind, {int units = 1}) => + UsageEntitlements.instance.commit(kind, units: units); + +/// Mock purchase flow, also used from the Account screen. +Future purchasePremiumFlow(BuildContext context) async { + final entitlements = UsageEntitlements.instance; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: EmColors.card, + title: const Text('Go Premium'), + content: Text( + 'Unlimited protects and Trace scans, no daily limits.\n\n' + '${UsageEntitlements.isDemoBilling ? 'Demo billing: no real payment is charged in this build.' : 'You will be charged via Google Play.'}', + style: const TextStyle(height: 1.45), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text( + UsageEntitlements.isDemoBilling + ? 'Unlock demo Premium' + : 'Buy Premium', + ), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return false; + + final ok = await _showProgress( + context, + label: UsageEntitlements.isDemoBilling + ? 'Unlocking demo Premium…' + : 'Processing purchase…', + task: entitlements.billing.purchasePremium(), + ); + if (!ok) return false; + await entitlements.unlockPremium(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + UsageEntitlements.isDemoBilling + ? 'Demo Premium unlocked — limits removed on this device.' + : 'Premium unlocked — limits removed.', + ), + ), + ); + } + return true; +} + +Future _showMockAd(BuildContext context) => _showProgress( + context, + label: 'Playing ad… (demo)', + task: UsageEntitlements.instance.rewardedAd.show(), + ); + +Future _showProgress( + BuildContext context, { + required String label, + required Future task, +}) async { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + backgroundColor: EmColors.card, + content: Row( + children: [ + const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, color: EmColors.primary), + ), + const SizedBox(width: 16), + Expanded(child: Text(label)), + ], + ), + ), + ); + final result = await task; + if (context.mounted) Navigator.of(context, rootNavigator: true).pop(); + return result; +} + +enum _PaywallChoice { watchAd, goPremium } + +class _PaywallDialog extends StatelessWidget { + const _PaywallDialog({required this.kind, this.units = 1}); + + final UsageKind kind; + final int units; + + @override + Widget build(BuildContext context) { + final what = kind == UsageKind.protect + ? 'free protects (${UsageEntitlements.freeProtectsPerDay}/day)' + : 'free Trace scans (${UsageEntitlements.freeTracePerDay}/day)'; + final need = units > 1 ? '\n\nThis action needs $units uses.' : ''; + return AlertDialog( + backgroundColor: EmColors.card, + title: const Text('Daily limit reached'), + content: Text( + 'You have used your $what.$need\n\n' + 'Watch a short demo ad for one more, or unlock demo Premium.\n\n' + 'Demo billing — nothing is charged.', + style: const TextStyle(height: 1.45), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Not now'), + ), + OutlinedButton.icon( + onPressed: () => Navigator.pop(context, _PaywallChoice.watchAd), + icon: const Icon(Icons.play_circle_outline, size: 18), + label: const Text('Watch ad'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, _PaywallChoice.goPremium), + icon: const Icon(Icons.workspace_premium_outlined, size: 18), + label: const Text('Demo Premium'), + ), + ], + ); + } +} + +/// Small quota banner for tool screens ("2 free protects left today"). +class UsageStatusBanner extends StatelessWidget { + const UsageStatusBanner({super.key, required this.kind}); + + final UsageKind kind; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: UsageEntitlements.instance.snapshot(), + builder: (context, snap) { + final data = snap.data; + if (data == null) return const SizedBox.shrink(); + + final String text; + final Color color; + if (data.isPremium) { + text = kReleaseMode && !UsageEntitlements.isDemoBilling + ? 'Premium — unlimited use' + : 'Demo Premium — unlimited on this device'; + color = EmColors.accent; + } else { + final left = data.remainingFor(kind); + final noun = kind == UsageKind.protect + ? 'free protect${left == 1 ? '' : 's'}' + : 'free Trace scan${left == 1 ? '' : 's'}'; + text = left > 0 + ? '$left $noun left today' + : 'Daily limit reached — watch a demo ad or unlock Premium'; + color = left > 0 ? EmColors.primary : EmColors.destructive; + } + + return Container( + margin: const EdgeInsets.only(bottom: 14), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withValues(alpha: 0.35)), + ), + child: Row( + children: [ + Icon( + data.isPremium + ? Icons.workspace_premium_outlined + : Icons.timelapse_outlined, + size: 16, + color: color, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: TextStyle(fontSize: 12.5, color: color), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 6abb87a..a1cb6c0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -313,7 +313,7 @@ packages: source: hosted version: "2.0.2" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -552,6 +552,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + qr_flutter: + dependency: "direct main" + description: + name: qr_flutter + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + receive_sharing_intent: + dependency: "direct main" + description: + name: receive_sharing_intent + sha256: "72f229a38f2910029c11d840770c173b27072e348ef3376130b37514bc4556f6" + url: "https://pub.dev" + source: hosted + version: "1.9.0" record_use: dependency: transitive description: @@ -693,6 +717,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 + url: "https://pub.dev" + source: hosted + version: "6.3.32" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" url_launcher_linux: dependency: transitive description: @@ -701,6 +749,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" url_launcher_platform_interface: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index c957718..21a0744 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: signata description: "Signata — invisible ownership, verifiable authenticity. Digital watermarking for images, audio, video, and PDFs." publish_to: 'none' -version: 1.0.0+1 +version: 1.2.4+10 environment: sdk: ^3.12.2 @@ -21,6 +21,10 @@ dependencies: shared_preferences: ^2.5.5 google_sign_in: ^7.2.0 flutter_secure_storage: ^11.0.0 + http: ^1.4.0 + url_launcher: ^6.3.2 + receive_sharing_intent: ^1.8.1 + qr_flutter: ^4.1.0 dev_dependencies: flutter_test: diff --git a/test/auth_test.dart b/test/auth_test.dart new file mode 100644 index 0000000..90e5390 --- /dev/null +++ b/test/auth_test.dart @@ -0,0 +1,97 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:signata/core/auth.dart'; + +void main() { + group('PasswordPolicy', () { + test('rejects short passwords', () { + expect(PasswordPolicy.validate('Ab1'), isNotNull); + expect(PasswordPolicy.validate('Abcdefg1'), isNotNull); + }); + + test('requires letter and number', () { + expect(PasswordPolicy.validate('abcdefghij'), isNotNull); + expect(PasswordPolicy.validate('1234567890'), isNotNull); + }); + + test('accepts strong enough passwords', () { + expect(PasswordPolicy.validate('Correct1horse'), isNull); + expect(PasswordPolicy.validate('signata2026X'), isNull); + }); + }); + + group('PasswordHasher', () { + test('PBKDF2 is deterministic for same inputs', () { + final salt = base64UrlEncode(List.generate(16, (i) => i + 1)); + final a = PasswordHasher.hash('Correct1horse', salt); + final b = PasswordHasher.hash('Correct1horse', salt); + expect(a, b); + expect(a, isNot(equals(PasswordHasher.hash('Correct1horsf', salt)))); + }); + + test('verify accepts matching PBKDF2 hash', () { + final salt = base64UrlEncode(List.generate(16, (i) => i + 3)); + final hash = PasswordHasher.hash('SecurePass9', salt); + expect( + PasswordHasher.verify( + password: 'SecurePass9', + saltBase64: salt, + expectedHash: hash, + algorithm: PasswordHasher.algoPbkdf2, + ), + isTrue, + ); + expect( + PasswordHasher.verify( + password: 'wrong-password', + saltBase64: salt, + expectedHash: hash, + algorithm: PasswordHasher.algoPbkdf2, + ), + isFalse, + ); + }); + + test('verify still accepts legacy SHA-256 hashes', () { + const salt = 'legacy-salt'; + const password = 'OldPass12ab'; + final expected = + sha256.convert(utf8.encode('$salt|$password')).toString(); + expect( + PasswordHasher.verify( + password: password, + saltBase64: salt, + expectedHash: expected, + algorithm: PasswordHasher.algoLegacySha256, + ), + isTrue, + ); + expect( + PasswordHasher.needsUpgrade(PasswordHasher.algoLegacySha256), + isTrue, + ); + expect( + PasswordHasher.needsUpgrade(PasswordHasher.algoPbkdf2), + isFalse, + ); + }); + + test('constantTimeEquals is length-sensitive', () { + expect(PasswordHasher.constantTimeEquals('abc', 'abc'), isTrue); + expect(PasswordHasher.constantTimeEquals('abc', 'abd'), isFalse); + expect(PasswordHasher.constantTimeEquals('abc', 'ab'), isFalse); + }); + + test('pbkdf2 produces expected key length', () { + final key = PasswordHasher.pbkdf2HmacSha256( + password: utf8.encode('password'), + salt: utf8.encode('salt'), + iterations: 1000, + keyLength: 32, + ); + expect(key.length, 32); + }); + }); +} diff --git a/test/core_test.dart b/test/core_test.dart index 153de27..adc5a84 100644 --- a/test/core_test.dart +++ b/test/core_test.dart @@ -1,20 +1,24 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; import 'package:signata/core/audio_watermark.dart'; +import 'package:signata/core/claim_crypto.dart'; import 'package:signata/core/fingerprint.dart'; import 'package:signata/core/image_watermark.dart'; import 'package:signata/core/pdf_fingerprint.dart'; import 'package:signata/core/report.dart'; import 'package:signata/core/video_fingerprint.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:image/image.dart' as img; void main() { + final alice = ClaimKey.fromSeed('alice-test-key'); + final bob = ClaimKey.fromSeed('bob-test-key'); + group('fingerprint', () { test('is deterministic and 16 hex chars', () { - final a = fingerprint('Studio Nova|photo.png·900x600|2026-01-01'); - final b = fingerprint('Studio Nova|photo.png·900x600|2026-01-01'); + final a = fingerprint('Creator|photo.png·900x600|2026-01-01'); + final b = fingerprint('Creator|photo.png·900x600|2026-01-01'); expect(a, b); expect(a, matches(RegExp(r'^[0-9A-F]{16}$'))); }); @@ -24,38 +28,106 @@ void main() { }); }); + group('claim crypto', () { + test('HMAC authenticates with the same key', () { + final signed = ClaimCrypto.signMedia( + alice, + owner: 'Alice', + asset: 'a.png·10x10', + issued: '2026-01-01T00:00:00Z', + ); + expect( + ClaimCrypto.evaluateMedia( + owner: 'Alice', + asset: 'a.png·10x10', + issued: '2026-01-01T00:00:00Z', + signature: signed.signature, + alg: signed.alg, + kid: signed.kid, + key: alice, + ), + ClaimStatus.authenticated, + ); + }); + + test('HMAC fails with a different key', () { + final signed = ClaimCrypto.signMedia( + alice, + owner: 'Alice', + asset: 'a.png·10x10', + issued: '2026-01-01T00:00:00Z', + ); + expect( + ClaimCrypto.evaluateMedia( + owner: 'Alice', + asset: 'a.png·10x10', + issued: '2026-01-01T00:00:00Z', + signature: signed.signature, + alg: signed.alg, + kid: signed.kid, + key: bob, + ), + ClaimStatus.foreignKey, + ); + }); + + test('legacy FNV is self-consistent only', () { + final signed = ClaimCrypto.signMediaLegacy( + owner: 'Alice', + asset: 'a.png·10x10', + issued: '2026-01-01T00:00:00Z', + ); + expect( + ClaimCrypto.evaluateMedia( + owner: 'Alice', + asset: 'a.png·10x10', + issued: '2026-01-01T00:00:00Z', + signature: signed.signature, + alg: null, + kid: null, + key: alice, + ), + ClaimStatus.selfConsistent, + ); + }); + }); + group('image watermark', () { - Uint8List samplePng() { - final image = img.Image(width: 120, height: 90); + Uint8List samplePng({int width = 120, int height = 90}) { + final image = img.Image(width: width, height: height); for (final p in image) { p.setRgba((p.x * 2) % 256, (p.y * 3) % 256, (p.x + p.y) % 256, 255); } return img.encodePng(image); } - test('embed round trip verifies', () async { + test('embed round trip verifies with HMAC', () async { final outcome = await embedWatermark( fileBytes: samplePng(), - owner: 'Studio Nova', + owner: 'Creator', fileName: 'sample.png', + claimKey: alice, ); expect(outcome.verified, isTrue); - expect(outcome.recovered?.owner, 'Studio Nova'); + expect(outcome.resized, isFalse); + expect(outcome.claimStatus, ClaimStatus.authenticated); + expect(outcome.recovered?.owner, 'Creator'); expect(outcome.recovered?.asset, 'sample.png\u00b7120x90'); - expect(outcome.recovered?.signatureValid, isTrue); + expect(outcome.recovered?.isAuthentic(alice), isTrue); + expect(outcome.recovered?.isAuthentic(bob), isFalse); }); - test('standalone extraction recovers a genuine payload', () async { + test('standalone extraction recovers an authenticated payload', () async { final outcome = await embedWatermark( fileBytes: samplePng(), owner: 'Creator X', fileName: 'art.png', + claimKey: alice, ); final extracted = await extractWatermark(outcome.markedPng); final payload = WatermarkPayload.tryParse(extracted.raw); expect(payload, isNotNull); - expect(payload!.owner, 'Creator X'); - expect(payload.signatureValid, isTrue); + expect(payload!.statusWith(alice), ClaimStatus.authenticated); }); test('unmarked image yields no payload', () async { @@ -72,6 +144,16 @@ void main() { })); expect(forged!.signatureValid, isFalse); }); + + test('legacy embed without key is self-consistent', () async { + final outcome = await embedWatermark( + fileBytes: samplePng(), + owner: 'Legacy', + fileName: 'old.png', + ); + expect(outcome.claimStatus, ClaimStatus.selfConsistent); + expect(outcome.recovered?.signatureValid, isTrue); + }); }); group('pdf fingerprint', () { @@ -85,15 +167,17 @@ void main() { 'startxref\n300\n%%EOF\n', )); - test('embed round trip verifies', () async { + test('embed round trip verifies with HMAC', () async { final outcome = await embedPdfFingerprint( source: samplePdf(), - owner: 'Studio Nova', + owner: 'Creator', fileName: 'doc.pdf', + claimKey: alice, ); expect(outcome.verified, isTrue); expect(outcome.structureMatch, isTrue); - expect(outcome.recovered?.owner, 'Studio Nova'); + expect(outcome.claimStatus, ClaimStatus.authenticated); + expect(outcome.recovered?.owner, 'Creator'); expect(outcome.recovered?.structure.version, '1.4'); expect(outcome.recovered?.structure.pages, 1); }); @@ -101,30 +185,55 @@ void main() { test('standalone verification of a delivered file passes', () async { final embedded = await embedPdfFingerprint( source: samplePdf(), - owner: 'Studio Nova', + owner: 'Creator', fileName: 'doc.pdf', + claimKey: alice, + ); + final verified = await verifyPdfFingerprint( + embedded.markedBytes, + claimKey: alice, ); - final verified = await verifyPdfFingerprint(embedded.markedBytes); expect(verified.verified, isTrue); + expect(verified.claimStatus, ClaimStatus.authenticated); expect(verified.recovered?.identifier, embedded.payload.identifier); }); + test('redundant comment survives stripped trailing marker', () async { + final embedded = await embedPdfFingerprint( + source: samplePdf(), + owner: 'Creator', + fileName: 'doc.pdf', + claimKey: alice, + ); + final text = latin1.decode(embedded.markedBytes); + final markIndex = text.lastIndexOf(pdfMark); + expect(markIndex, greaterThan(0)); + final withoutTrailing = + Uint8List.fromList(embedded.markedBytes.sublist(0, markIndex - 1)); + expect(latin1.decode(withoutTrailing).contains(pdfCommentMark), isTrue); + final verified = await verifyPdfFingerprint( + withoutTrailing, + claimKey: alice, + ); + expect(verified.recovered, isNotNull); + expect(verified.verified, isTrue); + expect(verified.claimStatus, ClaimStatus.authenticated); + }); + test('unmarked pdf yields no fingerprint', () async { final outcome = await verifyPdfFingerprint(samplePdf()); expect(outcome.recovered, isNull); expect(outcome.verified, isFalse); + expect(outcome.claimStatus, ClaimStatus.missing); }); test('tampered identifier fails verification', () async { final embedded = await embedPdfFingerprint( source: samplePdf(), - owner: 'Studio Nova', + owner: 'Creator', fileName: 'doc.pdf', + claimKey: alice, ); - // Forge the payload: change the owner but keep the identifier. - final text = latin1.decode(embedded.markedBytes); - final markIndex = text.lastIndexOf(pdfMark); - final original = embedded.markedBytes.sublist(0, markIndex - 1); final payload = embedded.recovered!; final forged = jsonEncode({ 'owner': 'Thief', @@ -132,10 +241,14 @@ void main() { 'issued': payload.issued, 'structure': payload.structure.toJson(), 'identifier': payload.identifier, + 'contentBytes': payload.contentBytes, + 'signature': payload.signature, + 'alg': payload.alg, + 'kid': payload.kid, + 'v': payload.version, }); - final tampered = - embedPdfIdentifier(Uint8List.fromList(original), forged); - final outcome = await verifyPdfFingerprint(tampered); + final tampered = embedPdfIdentifier(samplePdf(), forged); + final outcome = await verifyPdfFingerprint(tampered, claimKey: alice); expect(outcome.verified, isFalse); }); @@ -150,7 +263,6 @@ void main() { group('audio watermark', () { Uint8List sampleWav({int samples = 8000}) { - // Minimal 16-bit mono PCM WAV. final dataSize = samples * 2; final bytes = BytesBuilder(); void writeString(String s) => bytes.add(s.codeUnits); @@ -169,8 +281,8 @@ void main() { writeString('WAVE'); writeString('fmt '); writeUint32(16); - writeUint16(1); // PCM - writeUint16(1); // mono + writeUint16(1); + writeUint16(1); writeUint32(44100); writeUint32(44100 * 2); writeUint16(2); @@ -187,38 +299,40 @@ void main() { return bytes.toBytes(); } - test('embed round trip verifies', () async { + test('embed round trip verifies with HMAC', () async { final outcome = await embedAudioWatermark( fileBytes: sampleWav(), - owner: 'Studio Nova', + owner: 'Creator', fileName: 'clip.wav', + claimKey: alice, ); expect(outcome.verified, isTrue); - expect(outcome.recovered?.owner, 'Studio Nova'); - expect(outcome.recovered?.signatureValid, isTrue); + expect(outcome.claimStatus, ClaimStatus.authenticated); + expect(outcome.recovered?.owner, 'Creator'); + expect(outcome.recovered?.isAuthentic(alice), isTrue); }); - test('standalone extraction recovers a genuine payload', () async { + test('standalone extraction recovers an authenticated payload', () async { final embedded = await embedAudioWatermark( fileBytes: sampleWav(), owner: 'Creator X', fileName: 'voice.wav', + claimKey: alice, ); final extracted = await extractAudioWatermark(embedded.markedWav); final payload = AudioPayload.tryParse(extracted.raw); expect(payload, isNotNull); - expect(payload!.signatureValid, isTrue); + expect(payload!.statusWith(alice), ClaimStatus.authenticated); + expect(payload.statusWith(bob), ClaimStatus.foreignKey); }); }); group('video fingerprint', () { Uint8List sampleMp4() { - // Minimal ftyp + free-sized moov/mdat-like atoms for the profiler. final bytes = BytesBuilder(); void atom(String type, List body) { final size = 8 + body.length; - final header = ByteData(8) - ..setUint32(0, size, Endian.big); + final header = ByteData(8)..setUint32(0, size, Endian.big); final out = Uint8List(size); out.setAll(0, header.buffer.asUint8List()); out.setAll(4, type.codeUnits); @@ -232,34 +346,66 @@ void main() { return bytes.toBytes(); } - test('embed round trip verifies', () async { + test('embed round trip verifies with HMAC and uuid atom', () async { final outcome = await embedVideoFingerprint( source: sampleMp4(), - owner: 'Studio Nova', + owner: 'Creator', fileName: 'reel.mp4', + claimKey: alice, ); expect(outcome.verified, isTrue); expect(outcome.structureMatch, isTrue); - expect(outcome.recovered?.owner, 'Studio Nova'); + expect(outcome.claimStatus, ClaimStatus.authenticated); + expect(outcome.recovered?.owner, 'Creator'); + expect(extractVideoUuidPayload(outcome.markedBytes), isNotNull); }); test('standalone verification of a delivered file passes', () async { final embedded = await embedVideoFingerprint( source: sampleMp4(), - owner: 'Studio Nova', + owner: 'Creator', fileName: 'reel.mp4', + claimKey: alice, + ); + final verified = await verifyVideoFingerprint( + embedded.markedBytes, + claimKey: alice, ); - final verified = await verifyVideoFingerprint(embedded.markedBytes); expect(verified.verified, isTrue); + expect(verified.claimStatus, ClaimStatus.authenticated); expect(verified.recovered?.identifier, embedded.payload.identifier); }); + + test('legacy trailer still extracts when uuid box is removed', () async { + final embedded = await embedVideoFingerprint( + source: sampleMp4(), + owner: 'Creator', + fileName: 'reel.mp4', + claimKey: alice, + ); + final sourceLen = embedded.originalLength; + final trailerOnly = Uint8List.fromList([ + ...embedded.markedBytes.sublist(0, sourceLen), + ...latin1.encode( + '\n$videoMark${base64Encode(utf8.encode(embedded.raw!))}\n', + ), + ]); + expect(extractVideoUuidPayload(trailerOnly), isNull); + expect(extractVideoIdentifier(latin1.decode(trailerOnly)), isNotNull); + final verified = await verifyVideoFingerprint( + trailerOnly, + claimKey: alice, + ); + expect(verified.recovered, isNotNull); + expect(verified.verified, isTrue); + }); }); group('sealed report', () { ReportBody body() => const ReportBody( medium: 'image', subject: 'sample.png', - owner: 'Studio Nova', + owner: 'Creator', verified: true, issued: '2026-01-01T00:00:00Z', generated: '2026-01-02T00:00:00Z', @@ -269,7 +415,10 @@ void main() { test('seal verifies and has a fingerprint', () { final sealed = sealReport(body()); expect(verifySealedReport(sealed), isTrue); - expect(sealed.fingerprint, matches(RegExp(r'^[0-9A-F]{4}(-[0-9A-F]{4}){2}$'))); + expect( + sealed.fingerprint, + matches(RegExp(r'^[0-9A-F]{4}(-[0-9A-F]{4}){2}$')), + ); }); test('altered body invalidates the seal', () { @@ -291,7 +440,10 @@ void main() { test('canonicalize sorts keys recursively', () { expect( - canonicalize({'b': 1, 'a': {'d': true, 'c': null}}), + canonicalize({ + 'b': 1, + 'a': {'d': true, 'c': null} + }), '{"a":{"c":null,"d":true},"b":1}', ); }); diff --git a/test/deep_integration_test.dart b/test/deep_integration_test.dart new file mode 100644 index 0000000..9a0d873 --- /dev/null +++ b/test/deep_integration_test.dart @@ -0,0 +1,307 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:signata/core/audio_watermark.dart'; +import 'package:signata/core/auth.dart'; +import 'package:signata/core/claim_crypto.dart'; +import 'package:signata/core/claim_registry.dart'; +import 'package:signata/core/image_watermark.dart'; +import 'package:signata/core/pdf_fingerprint.dart'; +import 'package:signata/core/report.dart'; +import 'package:signata/core/social_platforms.dart'; +import 'package:signata/core/trace_models.dart'; +import 'package:signata/core/trace_store.dart'; +import 'package:signata/core/url_tracer.dart'; +import 'package:signata/core/video_fingerprint.dart'; + +Uint8List _png({int w = 48, int h = 36}) { + final image = img.Image(width: w, height: h); + for (var y = 0; y < h; y++) { + for (var x = 0; x < w; x++) { + image.setPixelRgba(x, y, (x * 7) % 255, (y * 11) % 255, 90, 255); + } + } + return Uint8List.fromList(img.encodePng(image)); +} + +Uint8List _wav({int samples = 2400}) { + final dataSize = samples * 2; + final bytes = BytesBuilder(); + void writeStr(String s) => bytes.add(s.codeUnits); + void write32(int v) { + final b = ByteData(4)..setUint32(0, v, Endian.little); + bytes.add(b.buffer.asUint8List()); + } + + void write16(int v) { + final b = ByteData(2)..setUint16(0, v, Endian.little); + bytes.add(b.buffer.asUint8List()); + } + + writeStr('RIFF'); + write32(36 + dataSize); + writeStr('WAVE'); + writeStr('fmt '); + write32(16); + write16(1); + write16(1); + write32(16000); + write32(32000); + write16(2); + write16(16); + writeStr('data'); + write32(dataSize); + for (var i = 0; i < samples; i++) { + write16(((i % 200) - 100) * 40); + } + return bytes.toBytes(); +} + +Uint8List _pdf() { + const body = ''' +%PDF-1.4 +1 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj +2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj +3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>endobj +xref +0 4 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +trailer<< /Size 4 /Root 1 0 R >> +startxref +190 +%%EOF +'''; + return Uint8List.fromList(utf8.encode(body)); +} + +Uint8List _mp4() { + final bytes = BytesBuilder(); + void atom(String type, List body) { + final size = 8 + body.length; + final header = ByteData(8)..setUint32(0, size, Endian.big); + final out = Uint8List(size); + out.setAll(0, header.buffer.asUint8List()); + out.setAll(4, type.codeUnits); + out.setAll(8, body); + bytes.add(out); + } + + atom('ftyp', [...'isom'.codeUnits, 0, 0, 0, 0, ...'isom'.codeUnits]); + atom('moov', [...'vide'.codeUnits, ...'soun'.codeUnits]); + atom('mdat', List.filled(128, 3)); + return bytes.toBytes(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final alice = ClaimKey.fromSeed('deep-alice'); + final bob = ClaimKey.fromSeed('deep-bob'); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + ClaimRegistry.configure(remoteBaseUrl: ''); + }); + + group('deep media round-trips', () { + test('image embed survives extract and rejects foreign key', () async { + final embedded = await embedWatermark( + fileBytes: _png(), + owner: 'Wejdan', + fileName: 'shot.png', + claimKey: alice, + ); + expect(embedded.claimStatus, ClaimStatus.authenticated); + + final ok = await extractWatermark(embedded.markedPng, claimKey: alice); + expect(ok.claimStatus, ClaimStatus.authenticated); + expect(WatermarkPayload.tryParse(ok.raw)?.owner, 'Wejdan'); + + final foreign = + await extractWatermark(embedded.markedPng, claimKey: bob); + expect(foreign.claimStatus, ClaimStatus.foreignKey); + }); + + test('audio embed/extract authenticates', () async { + final embedded = await embedAudioWatermark( + fileBytes: _wav(), + owner: 'Wejdan', + fileName: 'clip.wav', + claimKey: alice, + ); + expect(embedded.claimStatus, ClaimStatus.authenticated); + final extracted = + await extractAudioWatermark(embedded.markedWav, claimKey: alice); + expect(extracted.claimStatus, ClaimStatus.authenticated); + expect(AudioPayload.tryParse(extracted.raw)?.owner, 'Wejdan'); + }); + + test('pdf structural fingerprint authenticates and survives strip of trailer', + () async { + final embedded = await embedPdfFingerprint( + source: _pdf(), + owner: 'Wejdan', + fileName: 'doc.pdf', + claimKey: alice, + ); + expect(embedded.claimStatus, ClaimStatus.authenticated); + expect(embedded.verified, isTrue); + + final verified = + await verifyPdfFingerprint(embedded.markedBytes, claimKey: alice); + expect(verified.claimStatus, ClaimStatus.authenticated); + expect( + verified.recovered?.structuralIdentifier(), + embedded.payload.structuralIdentifier(), + ); + }); + + test('video uuid atom is present and verifies', () async { + final embedded = await embedVideoFingerprint( + source: _mp4(), + owner: 'Wejdan', + fileName: 'reel.mp4', + claimKey: alice, + ); + expect(embedded.claimStatus, ClaimStatus.authenticated); + expect(extractVideoUuidPayload(embedded.markedBytes), isNotNull); + + final verified = + await verifyVideoFingerprint(embedded.markedBytes, claimKey: alice); + expect(verified.claimStatus, ClaimStatus.authenticated); + expect(verified.structureMatch, isTrue); + }); + }); + + group('deep auth policy', () { + test('password policy rejects weak and accepts strong', () { + expect(PasswordPolicy.validate('short1A'), isNotNull); + expect(PasswordPolicy.validate('alllettersxx'), isNotNull); + expect(PasswordPolicy.validate('1234567890'), isNotNull); + expect(PasswordPolicy.validate('Signata2026!'), isNull); + }); + + test('pbkdf2 verify fails closed on wrong password', () { + final salt = base64UrlEncode(List.generate(16, (i) => i + 9)); + final hash = PasswordHasher.hash('CorrectHorse9', salt); + expect( + PasswordHasher.verify( + password: 'CorrectHorse9', + saltBase64: salt, + expectedHash: hash, + algorithm: PasswordHasher.algoPbkdf2, + ), + isTrue, + ); + expect( + PasswordHasher.verify( + password: 'CorrectHorse8', + saltBase64: salt, + expectedHash: hash, + algorithm: PasswordHasher.algoPbkdf2, + ), + isFalse, + ); + }); + }); + + group('deep tracing', () { + test('social hosts map correctly', () { + expect( + SocialPlatformInfo.fromUrl('https://www.instagram.com/p/x/')?.id, + SocialPlatform.instagram, + ); + expect( + SocialPlatformInfo.fromUrl('https://vm.tiktok.com/abc')?.id, + SocialPlatform.tiktok, + ); + expect( + SocialPlatformInfo.fromUrl('https://x.com/a/status/1')?.id, + SocialPlatform.x, + ); + expect(SocialPlatformInfo.fromUrl('https://cdn.example.com/a.png'), isNull); + }); + + test('publish + find claim by reference is case-insensitive', () async { + final published = await ClaimRegistry.instance.publish( + medium: TraceMedium.image, + owner: 'Wejdan', + subject: 'a.png', + reference: 'AbCdEf123456', + issued: '2026-08-09T00:00:00Z', + alg: 'hmac-sha256', + kid: alice.kid, + ); + final found = + await ClaimRegistry.instance.findByReference('abcdef123456'); + expect(found?.id, published.id); + expect(found?.owner, 'Wejdan'); + }); + + test('watchlist dedupes URL and stores sightings', () async { + final a = await TraceStore.instance.addWatchTarget( + 'https://cdn.example.com/pic.png', + label: 'Portfolio', + ); + final b = await TraceStore.instance.addWatchTarget( + 'https://cdn.example.com/pic.png', + label: 'Again', + ); + expect(a.id, b.id); + expect((await TraceStore.instance.listWatchTargets()).length, 1); + + await TraceStore.instance.addSighting(TraceSighting( + id: 's-deep', + url: a.url, + medium: TraceMedium.image, + at: DateTime.utc(2026, 8, 9), + found: true, + claimStatus: ClaimStatus.authenticated, + owner: 'Wejdan', + reference: 'REF', + )); + final sightings = await TraceStore.instance.listSightings(); + expect(sightings.first.found, isTrue); + }); + + test('url tracer rejects non-http schemes', () async { + expect( + () => UrlTracer.instance.scanUrl('file:///tmp/a.png', persist: false), + throwsA(isA()), + ); + }); + }); + + group('deep reports', () { + test('sealed report verifies and breaks on tamper', () { + final sealed = sealReport(const ReportBody( + medium: 'image', + subject: 'a.png', + owner: 'Wejdan', + verified: true, + issued: 't', + generated: 'g', + evidence: {'k': 'v'}, + )); + expect(verifySealedReport(sealed), isTrue); + final tampered = SealedReport( + body: const ReportBody( + medium: 'image', + subject: 'a.png', + owner: 'Other', + verified: true, + issued: 't', + generated: 'g', + evidence: {'k': 'v'}, + ), + seal: sealed.seal, + ); + expect(verifySealedReport(tampered), isFalse); + }); + }); +} diff --git a/test/local_data_test.dart b/test/local_data_test.dart new file mode 100644 index 0000000..a0e87a5 --- /dev/null +++ b/test/local_data_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:signata/core/local_data.dart'; + +void main() { + test('parses claim key from recovery kit text', () { + const kit = ''' +Signata recovery kit +==================== +Email: you@example.com +Account id: email_abc + +Claim key (base64url) — required to prove old fingerprints as "yours": +ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-ab + +Keep this file offline. +'''; + expect( + parseClaimKeyFromRecoveryText(kit), + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-ab', + ); + }); + + test('parses bare claim key', () { + const key = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-abcd'; + expect(parseClaimKeyFromRecoveryText(key), key); + }); + + test('rejects empty junk', () { + expect(parseClaimKeyFromRecoveryText('hello'), isNull); + }); +} diff --git a/test/safe_url_test.dart b/test/safe_url_test.dart new file mode 100644 index 0000000..0e2d845 --- /dev/null +++ b/test/safe_url_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:signata/core/claim_registry.dart'; +import 'package:signata/core/safe_url.dart'; + +void main() { + group('SafeUrl.parseTraceUrl', () { + test('accepts public https URLs', () { + final result = SafeUrl.parseTraceUrl('https://cdn.example.com/a.png'); + expect(result.isOk, isTrue); + expect(result.uri!.host, 'cdn.example.com'); + }); + + test('rejects ftp and file schemes', () { + expect(SafeUrl.parseTraceUrl('ftp://x/y.png').isOk, isFalse); + expect(SafeUrl.parseTraceUrl('file:///etc/passwd').isOk, isFalse); + }); + + test('rejects localhost and private IPs', () { + expect(SafeUrl.parseTraceUrl('http://127.0.0.1/x').isOk, isFalse); + expect(SafeUrl.parseTraceUrl('http://localhost/x').isOk, isFalse); + expect(SafeUrl.parseTraceUrl('http://192.168.1.1/x').isOk, isFalse); + expect(SafeUrl.parseTraceUrl('http://10.0.0.1/x').isOk, isFalse); + expect(SafeUrl.parseTraceUrl('http://169.254.169.254/').isOk, isFalse); + }); + + test('rejects URLs with embedded credentials', () { + expect( + SafeUrl.parseTraceUrl('https://user:pass@example.com/x').isOk, + isFalse, + ); + }); + }); + + group('SafeUrl.parseRegistryBase', () { + test('accepts public https registry', () { + final result = SafeUrl.parseRegistryBase('https://registry.example.com'); + expect(result.isOk, isTrue); + }); + + test('rejects http and private hosts', () { + expect(SafeUrl.parseRegistryBase('http://registry.example.com').isOk, isFalse); + expect(SafeUrl.parseRegistryBase('https://127.0.0.1').isOk, isFalse); + }); + }); + + group('ClaimRegistry.configure', () { + test('ignores invalid registry URLs', () { + ClaimRegistry.configure(remoteBaseUrl: 'https://127.0.0.1'); + expect(ClaimRegistry.hasRemote, isFalse); + ClaimRegistry.configure(remoteBaseUrl: 'https://registry.example.com'); + expect(ClaimRegistry.hasRemote, isTrue); + ClaimRegistry.configure(remoteBaseUrl: ''); + }); + }); + + group('TraceRateLimiter', () { + test('allows scans under the hourly cap', () { + TraceRateLimiter.instance.resetForTests(); + for (var i = 0; i < TraceRateLimiter.maxScansPerHour; i++) { + expect(TraceRateLimiter.instance.allow(), isTrue); + } + expect(TraceRateLimiter.instance.allow(), isFalse); + TraceRateLimiter.instance.resetForTests(); + }); + }); +} diff --git a/test/social_platforms_test.dart b/test/social_platforms_test.dart new file mode 100644 index 0000000..289b279 --- /dev/null +++ b/test/social_platforms_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:signata/core/social_platforms.dart'; + +void main() { + group('SocialPlatformInfo.fromUrl', () { + test('detects Instagram hosts', () { + expect( + SocialPlatformInfo.fromUrl('https://www.instagram.com/p/abc/')?.id, + SocialPlatform.instagram, + ); + expect( + SocialPlatformInfo.fromUrl('https://instagr.am/p/abc/')?.id, + SocialPlatform.instagram, + ); + }); + + test('detects TikTok hosts', () { + expect( + SocialPlatformInfo.fromUrl('https://www.tiktok.com/@user/video/1')?.id, + SocialPlatform.tiktok, + ); + expect( + SocialPlatformInfo.fromUrl('https://vm.tiktok.com/ZMabc/')?.id, + SocialPlatform.tiktok, + ); + }); + + test('detects X / Twitter hosts', () { + expect( + SocialPlatformInfo.fromUrl('https://x.com/user/status/1')?.id, + SocialPlatform.x, + ); + expect( + SocialPlatformInfo.fromUrl('https://twitter.com/user/status/1')?.id, + SocialPlatform.x, + ); + }); + + test('returns null for unrelated hosts', () { + expect( + SocialPlatformInfo.fromUrl('https://cdn.example.com/a.png'), + isNull, + ); + }); + }); + + group('SocialMediaResolver helpers', () { + test('absolutize is covered via public resolve contract shape', () { + // Smoke: platforms list stays complete for Trace UI. + expect(SocialPlatformInfo.all.map((p) => p.id).toSet(), { + SocialPlatform.instagram, + SocialPlatform.tiktok, + SocialPlatform.x, + }); + }); + }); +} diff --git a/test/totp_test.dart b/test/totp_test.dart new file mode 100644 index 0000000..01145d5 --- /dev/null +++ b/test/totp_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:signata/core/totp.dart'; + +void main() { + group('Totp', () { + test('round-trips Base32', () { + final secret = Totp.generateSecret(); + expect(secret, isNotEmpty); + expect(RegExp(r'^[A-Z2-7]+$').hasMatch(secret), isTrue); + final raw = Totp.decodeBase32(secret); + expect(Totp.encodeBase32(raw), secret); + }); + + test('verifies current code and rejects wrong code', () { + final secret = Totp.generateSecret(); + final code = Totp.codeAt(secret); + expect(code.length, 6); + expect(Totp.verify(secret, code), isTrue); + expect(Totp.verify(secret, '000000'), isFalse); + }); + + test('otpauth URI includes issuer and secret', () { + const secret = 'JBSWY3DPEHPK3PXP'; + final uri = Totp.otpauthUri(secret: secret, accountName: 'you@example.com'); + expect(uri.startsWith('otpauth://totp/'), isTrue); + expect(uri.contains('secret=$secret'), isTrue); + expect(uri.contains('Signata'), isTrue); + }); + + test('known RFC vector-style code is stable for fixed time', () { + // Secret "Hello!" -> JBSWY3DPEHPK3PXP in many examples; check determinism. + const secret = 'JBSWY3DPEHPK3PXP'; + final at = DateTime.utc(2020, 1, 1, 0, 0, 0); + final a = Totp.codeAt(secret, at: at); + final b = Totp.codeAt(secret, at: at); + expect(a, b); + expect(Totp.verify(secret, a, at: at), isTrue); + }); + }); +} diff --git a/test/trace_test.dart b/test/trace_test.dart new file mode 100644 index 0000000..1b87df1 --- /dev/null +++ b/test/trace_test.dart @@ -0,0 +1,110 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:signata/core/claim_crypto.dart'; +import 'package:signata/core/claim_registry.dart'; +import 'package:signata/core/safe_url.dart'; +import 'package:signata/core/trace_models.dart'; +import 'package:signata/core/trace_store.dart'; +import 'package:signata/core/url_tracer.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + ClaimRegistry.configure(remoteBaseUrl: ''); + TraceRateLimiter.instance.resetForTests(); + }); + + group('claim registry', () { + test('publishes and finds claims locally by reference', () async { + final published = await ClaimRegistry.instance.publish( + medium: TraceMedium.image, + owner: 'Creator', + subject: 'art.png·120x90', + reference: 'ABC123HMAC', + issued: '2026-01-01T00:00:00Z', + alg: 'hmac-sha256', + kid: 'DEADBEEF', + ); + expect(published.remoteSynced, isFalse); + final found = + await ClaimRegistry.instance.findByReference('abc123hmac'); + expect(found, isNotNull); + expect(found!.owner, 'Creator'); + expect(found.subject, 'art.png·120x90'); + }); + }); + + group('trace store', () { + test('watchlist and sightings persist', () async { + final watch = await TraceStore.instance.addWatchTarget( + 'https://cdn.example.com/a.png', + label: 'Portfolio', + ); + expect(watch.url, contains('cdn.example.com')); + final listed = await TraceStore.instance.listWatchTargets(); + expect(listed.length, 1); + + await TraceStore.instance.addSighting(TraceSighting( + id: 's1', + url: watch.url, + medium: TraceMedium.image, + at: DateTime.utc(2026, 1, 2), + found: true, + claimStatus: ClaimStatus.authenticated, + owner: 'Creator', + reference: 'ABC', + )); + final sightings = await TraceStore.instance.listSightings(); + expect(sightings.first.found, isTrue); + expect(sightings.first.claimStatus, ClaimStatus.authenticated); + }); + }); + + group('url tracer', () { + test('rejects non-http urls', () async { + expect( + () => UrlTracer.instance.scanUrl('ftp://x/y.png', persist: false), + throwsA(isA()), + ); + }); + + test('rejects private network urls', () async { + expect( + () => UrlTracer.instance.scanUrl('http://192.168.0.5/a.png', persist: false), + throwsA(isA()), + ); + expect( + () => UrlTracer.instance.scanUrl('http://127.0.0.1/a.png', persist: false), + throwsA(isA()), + ); + }); + + test('watchlist rejects blocked urls', () async { + expect( + () => TraceStore.instance.addWatchTarget('http://localhost/x'), + throwsA(isA()), + ); + }); + + test('published claim JSON round-trips', () { + final claim = PublishedClaim( + id: 'c1', + medium: TraceMedium.pdf, + owner: 'A', + subject: 'd.pdf', + reference: 'ID', + issued: 't', + publisherId: 'u', + publishedAt: DateTime.utc(2026), + ); + final again = + PublishedClaim.fromJson(jsonDecode(jsonEncode(claim.toJson()))); + expect(again?.medium, TraceMedium.pdf); + expect(again?.reference, 'ID'); + }); + }); +} diff --git a/test/usage_entitlements_test.dart b/test/usage_entitlements_test.dart new file mode 100644 index 0000000..26ecbad --- /dev/null +++ b/test/usage_entitlements_test.dart @@ -0,0 +1,112 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:signata/core/usage_entitlements.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final entitlements = UsageEntitlements.instance; + var day = '2026-08-10'; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + day = '2026-08-10'; + entitlements.todayKey = () => day; + entitlements.userId = () => 'test_user'; + }); + + test('fresh user gets freeProtects and freeTrace, then blocked', () async { + for (var i = 0; i < UsageEntitlements.freeProtectsPerDay; i++) { + expect(await entitlements.reserveProtect(), GateResult.allowed); + } + expect(await entitlements.reserveProtect(), GateResult.blocked); + + for (var i = 0; i < UsageEntitlements.freeTracePerDay; i++) { + expect(await entitlements.reserveTrace(), GateResult.allowed); + } + expect(await entitlements.reserveTrace(), GateResult.blocked); + }); + + test('check does not consume; commit does', () async { + expect(await entitlements.check(UsageKind.protect), GateResult.allowed); + expect(await entitlements.check(UsageKind.protect), GateResult.allowed); + expect(await entitlements.commit(UsageKind.protect), GateResult.allowed); + final snap = await entitlements.snapshot(); + expect(snap.protectUsed, 1); + }); + + test('ad bonus grants one extra attempt of that kind only', () async { + for (var i = 0; i < UsageEntitlements.freeProtectsPerDay; i++) { + expect(await entitlements.reserveProtect(), GateResult.allowed); + } + expect(await entitlements.reserveProtect(), GateResult.blocked); + + await entitlements.grantAdBonus(kind: UsageKind.protect); + expect(await entitlements.reserveProtect(), GateResult.allowed); + expect(await entitlements.reserveProtect(), GateResult.blocked); + + for (var i = 0; i < UsageEntitlements.freeTracePerDay; i++) { + expect(await entitlements.reserveTrace(), GateResult.allowed); + } + expect(await entitlements.reserveTrace(), GateResult.blocked); + await entitlements.grantAdBonus(kind: UsageKind.trace); + expect(await entitlements.reserveTrace(), GateResult.allowed); + }); + + test('premium bypasses both limits without consuming', () async { + await entitlements.unlockPremium(); + for (var i = 0; i < 5; i++) { + expect(await entitlements.reserveProtect(), GateResult.allowed); + expect(await entitlements.reserveTrace(), GateResult.allowed); + } + final snap = await entitlements.snapshot(); + expect(snap.isPremium, isTrue); + expect(snap.protectUsed, 0); + }); + + test('day rollover resets counters and ad bonuses', () async { + await entitlements.grantAdBonus(kind: UsageKind.protect); + for (var i = 0; i < UsageEntitlements.freeProtectsPerDay + 1; i++) { + expect(await entitlements.reserveProtect(), GateResult.allowed); + } + expect(await entitlements.reserveProtect(), GateResult.blocked); + expect(await entitlements.reserveTrace(), GateResult.allowed); + + day = '2026-08-11'; + final snap = await entitlements.snapshot(); + expect(snap.protectUsed, 0); + expect(snap.protectLimit, UsageEntitlements.freeProtectsPerDay); + expect(await entitlements.reserveProtect(), GateResult.allowed); + }); + + test('quotas are per user id', () async { + for (var i = 0; i < UsageEntitlements.freeTracePerDay; i++) { + expect(await entitlements.reserveTrace(), GateResult.allowed); + } + expect(await entitlements.reserveTrace(), GateResult.blocked); + + entitlements.userId = () => 'other_user'; + expect(await entitlements.reserveTrace(), GateResult.allowed); + }); + + test('premium survives day rollover', () async { + await entitlements.unlockPremium(); + day = '2026-08-12'; + expect(await entitlements.isPremium, isTrue); + expect(await entitlements.reserveProtect(), GateResult.allowed); + }); + + test('snapshot reports remaining correctly', () async { + var snap = await entitlements.snapshot(); + expect(snap.protectRemaining, UsageEntitlements.freeProtectsPerDay); + expect(snap.traceRemaining, UsageEntitlements.freeTracePerDay); + + await entitlements.reserveProtect(); + snap = await entitlements.snapshot(); + expect(snap.protectRemaining, UsageEntitlements.freeProtectsPerDay - 1); + expect(snap.remainingFor(UsageKind.protect), + UsageEntitlements.freeProtectsPerDay - 1); + expect(snap.remainingFor(UsageKind.trace), + UsageEntitlements.freeTracePerDay); + }); +} diff --git a/tool/build_release.ps1 b/tool/build_release.ps1 new file mode 100644 index 0000000..3d63267 --- /dev/null +++ b/tool/build_release.ps1 @@ -0,0 +1,48 @@ +# Builds a Play-ready Android App Bundle with Google Sign-In configured. +# +# Prerequisites: +# 1. Copy google_oauth.example.env → google_oauth.env and set GOOGLE_SERVER_CLIENT_ID +# 2. android/key.properties + android/upload-keystore.jks (created locally) +# 3. Google Cloud Android OAuth client with debug + release SHA-1s + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +Set-Location $root + +$envFile = Join-Path $root 'google_oauth.env' +if (-not (Test-Path $envFile)) { + Write-Host 'Missing google_oauth.env' + Write-Host 'Copy google_oauth.example.env to google_oauth.env and paste your Web client ID.' + exit 1 +} + +$content = Get-Content $envFile -Raw +if ($content -notmatch 'GOOGLE_SERVER_CLIENT_ID=.+\.apps\.googleusercontent\.com' -or + $content -match 'YOUR_WEB_CLIENT_ID') { + Write-Host 'google_oauth.env still has a placeholder Web client ID.' + Write-Host 'Paste the real Web OAuth client ID from Google Cloud Console.' + exit 1 +} + +$keyProps = Join-Path $root 'android\key.properties' +$keystore = Join-Path $root 'android\upload-keystore.jks' +if (-not (Test-Path $keyProps) -or -not (Test-Path $keystore)) { + Write-Host 'Missing release signing files (android/key.properties or upload-keystore.jks).' + exit 1 +} + +$env:Path = [System.Environment]::GetEnvironmentVariable('Path','Machine') + ';' + + [System.Environment]::GetEnvironmentVariable('Path','User') + +Write-Host 'Building release App Bundle…' +flutter build appbundle --release --dart-define-from-file=google_oauth.env + +$aab = Join-Path $root 'build\app\outputs\bundle\release\app-release.aab' +if (Test-Path $aab) { + Write-Host '' + Write-Host "Ready for Play Console: $aab" + Write-Host 'After first upload, add Play App Signing SHA-1 to the Android OAuth client.' +} else { + Write-Host 'Build finished but AAB was not found.' + exit 1 +} diff --git a/tool/registry_server.example.md b/tool/registry_server.example.md new file mode 100644 index 0000000..9d9235d --- /dev/null +++ b/tool/registry_server.example.md @@ -0,0 +1,19 @@ +# Optional Signata claim registry + +The app can sync published claims to a remote HTTP registry when launched with: + +```sh +flutter run --dart-define=SIGNATA_REGISTRY_URL=https://your-host.example +``` + +## Expected API + +### `POST /claims` +JSON body matches a published claim (`id`, `medium`, `owner`, `subject`, `reference`, `issued`, `publisherId`, `publishedAt`, optional `alg` / `kid` / `note`). + +Respond `2xx` on success. + +### `GET /claims/:reference` +Return the claim JSON (or `{ "claim": { ... } }`) for fingerprint lookup. + +Without `SIGNATA_REGISTRY_URL`, claims stay on-device and URL tracing still works by reading fingerprints embedded in the downloaded media. diff --git a/tool/run_debug.ps1 b/tool/run_debug.ps1 new file mode 100644 index 0000000..383fbb2 --- /dev/null +++ b/tool/run_debug.ps1 @@ -0,0 +1,21 @@ +# Debug-run Signata on a connected device/emulator. +# Uses google_oauth.env when present so Google Sign-In works without a release build. + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +Set-Location $root + +$env:Path = [System.Environment]::GetEnvironmentVariable('Path','Machine') + ';' + + [System.Environment]::GetEnvironmentVariable('Path','User') + +$envFile = Join-Path $root 'google_oauth.env' +$flutterArgs = @('run') +if ((Test-Path $envFile) -and + ((Get-Content $envFile -Raw) -notmatch 'YOUR_WEB_CLIENT_ID')) { + Write-Host "Using $envFile" + $flutterArgs += '--dart-define-from-file=google_oauth.env' +} else { + Write-Host 'No google_oauth.env — Google button stays hidden; email login works.' +} + +flutter @flutterArgs