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
+
+
+
+
+
+
+
+
+| 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
+
+ - Send an email to FocusMindDev@gmail.com with the subject line "Signata account deletion request".
+ - Include the email address of your Signata account (the one you registered with, or the Google account you used to sign in).
+ - We will verify the request and confirm the deletion by reply within 30 days.
+
+
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:
+
+ - In the app: open History → Clear history to erase all locally stored protection and verification records, or remove individual watchlist links and published claims from the Trace screen.
+ - By email: send a request to FocusMindDev@gmail.com with the subject "Signata data deletion request", stating your account email and which data you want removed (for example, trace logs or fingerprint records). We confirm within 30 days, and your account stays active.
+
+
+
+
+
What is deleted when you delete your account
+
+ - Account data — your name, email address, and authentication data.
+ - Encrypted fingerprints and ownership logs associated with your account on our systems.
+ - Trace logs and watchlist entries linked to your account.
+
+
+
+
+
What is kept
+
+ - Watermarks already embedded in your exported files cannot be removed remotely — they live inside files you created and shared. Deleting your account severs the link between those watermarks and your account records.
+ - Legal compliance records, if any, may be retained only as long as required by applicable law (Kingdom of Saudi Arabia, PDPL), after which they are erased.
+
+
There is no additional retention period beyond the verification window: once confirmed, deletion is permanent and cannot be undone.
+
+
+
+
+
+
+
+
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:
+
+ - 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: The processes of embedding hidden watermarks, generating audio fingerprints, and extracting structural identifiers (SHA-256) for videos and documents are performed locally on your device (on-device) whenever possible.
+ - We do not upload, store, or keep copies of your original files (images, audio, video, or PDFs) on our servers without your explicit consent, ensuring you retain exclusive ownership of your content.
+ - The data stored on our servers is strictly limited to encrypted fingerprints and ownership logs associated with your account, enabling you to verify content authenticity on demand.
+
+
+
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 through your account dashboard.
+ - Deletion: You have the right to request the complete deletion of your account. This action will permanently erase all your encrypted fingerprints and trace logs from our servers. (Note: Deleting your account will not remove the hidden watermarks already embedded in your exported files, but it will sever the link between those watermarks and your account records.)
+
+
+
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