Skip to content
digital-greasePublic

About

No description, website, or topics provided.

Resources

Stars

41 stars

Watchers

2 watching

Forks

Repository files navigation

Signet

CI Android Build iOS Build CodeQL

Buy Me A Coffee

Cryptographic multi-factor authentication for human relationships, not accounts.

When someone who sounds like your mother calls in a panic asking for bail money, Signet lets you verify it's actually her. Each paired contact generates a rotating 4-word phrase that only the real person's phone can produce; you ask her to read her phrase aloud and type what you hear. Works over any channel — voice call, video call, text, email, in person.

The threat: voice and video deepfakes targeting families for financial fraud; vishing attacks that use scraped biographical data to impersonate people you know. The defense: a shared secret that was seeded device-to-device in person, and that no amount of AI voice cloning can recover.

Status

v0.5.0 beta · Android · available on F-Droid and GitHub Releases. See Development roadmap for what shipped and what's still ahead.

v0.5.0 adds word languages: the verify words and the challenge grid can be English, French or Chinese (Mandarin, beta), chosen per contact, and the app itself now speaks French and Traditional Chinese too. Both phones need 0.4.0 or later to pair, and 0.5.0 to use French or Chinese words. See CHANGELOG.md for the full history.

Built on Flutter for future cross-platform support; iOS is generated but not tested in this release. TestFlight is staged separately.

Install

Signet runs on Android 9 or newer. Pick one of the options below and stick with it (see Switching install sources for why).

Option 1: F-Droid (recommended for most people)

F-Droid is a free app store for open-source Android apps. It builds Signet from the public source code and installs updates for you. If you don't have it yet, follow F-Droid's own install guide.

Once F-Droid is installed, either:

then tap Install.

Updates show up in F-Droid's Updates tab. New releases usually reach F-Droid a few days after they appear on GitHub, because F-Droid rebuilds and checks each one first.

Option 2: Obtainium (fastest updates, straight from GitHub)

Obtainium installs apps directly from their GitHub release page and keeps them updated. Choose this if you want new versions the day they are released.

If you don't have it yet, follow Obtainium's own install instructions. Once it's installed:

  1. Open Obtainium and tap Add App.
  2. In the App Source URL box, paste: https://github.com/digital-grease/signet
  3. Tap Add, then Install. If Android asks, allow Obtainium to install apps.

Obtainium checks for new releases in the background and notifies you when one is ready.

Option 3: Download the APK directly

  1. On your phone, open the latest release.
  2. Under Assets, tap signet-vX.Y.Z.apk to download it. (Ignore the .aab, .zip, and .sha256 files.)
  3. Open the downloaded file and tap Install. If Android asks, allow your browser to install apps.

You will not get automatic updates this way; check back for new releases, or use Obtainium instead. To confirm the download wasn't tampered with, compare its SHA-256 hash against the matching .sha256 file on the release page.

Google Play

Signet is in Google Play closed testing, which is invite-only for now. A public Play Store listing is planned for v1.0.

Switching install sources

F-Droid signs the apps it builds with its own key, while the GitHub release APK (used by Obtainium and direct download) is signed with the developer's key. Android will not install one over the other. To switch, you have to uninstall first, and uninstalling deletes your pairings.

If you need to switch, first make a backup: Settings → Back up all relationships. A backup has two parts that you keep apart: a PACKAGE file (save it somewhere off the phone, such as your computer or a USB stick) and 8 words (write them on paper). Then uninstall, install from the new source, tap Restore from backup on the home screen, load the PACKAGE file and type the 8 words. Otherwise you will need to pair again in person with each contact.

iPhone

Not available yet. iOS builds exist but are untested; TestFlight is planned.

How it works

Two phones pair in person by exchanging QR codes: one phone first commits to its ephemeral X25519 public key, the other shows its key, then the first reveals its key. Each device derives the same shared secret via Diffie–Hellman. Both devices then display an identical 4-word confirmation phrase bound to that secret and both keys: a visual check that the pairing wasn't intercepted. Because of the commitment, an interceptor gets one blind guess at the phrase per attempt, provided the two people actually compare the phrases. (Long-distance pairing compares 8 words instead.) Once confirmed, the shared secret lives in the Android Keystore (hardware-backed when available) and is used to generate a rotating 4 BIP-39 words every 30 seconds (HKDF-SHA-256, domain-separated from the pair-time phrase, ±1 window tolerance for clock drift).

To verify a caller later: open Signet, tap the contact, ask them to read their 4 current words aloud. Type what you hear into the 4-slot input (BIP-39 autocomplete: two letters narrows to a chip you can tap). ✅ green banner = verified, ❌ red banner = not verified. On ❌ the input clears and you can retry immediately.

Why words, not digits?

The original design called for an 8-digit TOTP code, but 8 digits don't survive a stressed voice channel — "74" vs. "47" under a bad connection is how grandma gets scammed. BIP-39 was specifically designed to transfer high-entropy secrets cleanly over voice (4 words ≈ 44 bits vs. ~27 for 8 digits, and the words are phonetically distinct by construction). Using the same wordlist we already embed for pair-time verification gives Signet a coherent visual/verbal language and makes the ±1 window tolerance do real work via a binary ✅/❌ instead of eyeball-comparing two 8-digit strings.

Why the two sides see different words

A naïve rotating-code design would give both paired devices the same 4 words each window — but then an attacker can say "grandma, before we talk, read me your words so I know it's really you," parrot them back, and pass the verify. Signet binds each rotating code to a direction: at pair time each device independently derives a role (a or b) from the byte-lexicographic ordering of the two X25519 public keys. The HKDF info string is role-suffixed, so the A→B words and the B→A words for any given window are different. "Show my 4 words" renders your role; the verify input checks against the other role. Reflecting the verifier's own displayed words back fails immediately. See lib/core/crypto/pair_role.dart and the reflection-attack test in test/crypto/totp_words_test.dart.

Word languages

The words can be English, French or Chinese (Mandarin, in simplified or traditional characters; marked beta because no native speaker has checked the list yet). Each word is an 11-bit index, and a language only spells it, so the security is the same in every language. Each contact has its own word language: you choose it when you pair (preselected to your phone's default, chosen at first run), and you and the other person must choose the same one for each other. A backup carries each contact's language. If someone reads you the right words in the wrong language, verify still says NOT VERIFIED and adds a note that the two phones' languages differ. Each word is marked with its language for screen readers (VoiceOver uses this; Android's TalkBack reads words in the app's language, see docs/LIMITATIONS.md L-FEAT-3). See docs/THREAT_MODEL.md §3.2a.

Properties

  • No server, no cloud, no account. The app literally has no INTERNET permission in its manifest. There is no backend to subpoena, compromise, or shut down.
  • No telemetry, no analytics, no ads. This is a trust product. Not now, not ever.
  • Hardware-backed secrets. Shared secrets are held in Android Keystore behind AES-GCM, StrongBox-backed on devices that support it.
  • Offline by construction. Airplane mode does not affect any flow.
  • Role-asymmetric rotating code. The two sides of a pair see different 4 words per window, bound to a per-device role derived at pair time. Reflecting the verifier's displayed words back fails by construction — see "Why the two sides see different words".
  • RFC-validated crypto. X25519 against RFC 7748 §6.1; HKDF-SHA-256 via the audited cryptography package. BIP-39 wordlist embedded in-tree. All reference vectors pass. The pure-Dart RFC-6238 TOTP implementation is retained in-tree as a reference (validated against RFC 6238 Appendix B SHA-256 vectors) but not on any live code path — the rotating verifier is 4-word, not 8-digit.

Building

Requirements

  • Flutter 3.47.5 (pinned in .github/workflows/release.yml; F-Droid's prebuild extracts it from there)
  • JDK 17 or newer to run Gradle (Android Studio's bundled JDK works), plus a JDK 21: the build compiles with a JDK 21 toolchain, the F-Droid buildserver's JDK. Gradle finds an installed JDK 21 or one in ~/.gradle/jdks. CI and release builds run on JDK 21; running Gradle on newer JDKs (up to 26) also works.
  • Android toolchain: SDK Platform 34+, Command-line Tools, and licenses accepted
  • An Android 9+ device or emulator (the minimum SDK is API 28 — StrongBox availability)

Check your environment:

flutter doctor

The Android toolchain row must be [✓]. Chrome / web / iOS / macOS rows can be ignored — Signet does not target those platforms at present.

Build and run

git clone <this-repo>
cd signet
flutter pub get
flutter run -d <device-or-emulator-id>

First build downloads the Android Gradle Plugin and the NDK; subsequent builds are fast.

Release build

flutter build apk --release

The APK lands at build/app/outputs/flutter-apk/app-release.apk.

Signing:

If android/key.properties is absent, the release build falls back to the debug signing config. That's fine for flutter run --release locally, but do not distribute debug-signed APKs — other builds signed with a different debug key can't update-over-top, and the debug key is inherently not identifiable as yours.

To produce a distributable release build:

  1. Generate a keystore (one time, back it up):

    keytool -genkey -v \
      -keystore ~/keystores/signet-release.jks \
      -keyalg RSA -keysize 4096 -validity 10000 \
      -alias signet
  2. Copy the template and fill in your values:

    cp android/key.properties.example android/key.properties

    Edit android/key.properties — storeFile is the absolute path to the .jks you just generated; the other fields match what you entered at keytool time. The file is gitignored; the example template is committed.

  3. Build:

    flutter build apk --release
  4. Verify the signature (optional):

    keytool -printcert -jarfile build/app/outputs/flutter-apk/app-release.apk

Losing the keystore is catastrophic — you will never again be able to ship an update that installs over an existing install. Back up the .jks file on offline media, store the passwords somewhere you can actually find them, and consider enrolling in Play App Signing when you distribute via Play (which takes upload-key rotation pressure off).

Testing

flutter test            # all unit and widget tests
flutter analyze         # static analysis against strict lints

The test suite is 369 tests across crypto, storage, pairing state, transport package, verify screen (including video-mode liveness), backup import/export, and widget layers. The crypto modules are pure-Dart pure functions validated against RFC reference vectors and are safe to port elsewhere.

On-device tests in integration_test/ (secure storage on the real Keystore, including a plaintext-leak scan; the secure clipboard; restore a backup and verify through the whole app) run on Android emulators nightly and before every release; see docs/CI.md. Two-phone pairing, release (R8) builds and upgrades from an older Signet stay manual and live in the pre-release checklist.

Project layout

lib/
├── main.dart              # entrypoint
├── app.dart               # MaterialApp + go_router
├── core/
│   ├── crypto/            # pure-Dart TOTP-words, X25519 ECDH, 4-word phrase,
│   │                      # per-role derivation, secret-derived liveness
│   │                      # action, transport-package AEAD, BIP-39 wordlist
│   ├── storage/           # flutter_secure_storage wrapper (multi-peer v2)
│   ├── models/            # Relationship metadata (no secret on model)
│   ├── prefs/             # non-secret app flags (onboarding, etc.)
│   ├── theme/             # SignetTokens + signetTheme (operator language)
│   └── providers.dart     # Riverpod wiring
├── features/
│   ├── home/              # home list — empty / paired-list (long-press menu)
│   ├── onboarding/        # first-run 3-slide walkthrough
│   ├── pairing/           # in-person (start/exchange/confirm/complete) +
│   │                      # long-distance (transport-in/transport-out) +
│   │                      # rekey (reuses exchange/confirm via controller)
│   ├── inspect/           # binding-phrase re-check, backup export/import,
│   │                      # bulk-backup export, challenge-response grid +
│   │                      # printable PDF card
│   ├── verify/            # type-and-verify input + show-my-words fallback;
│   │                      # VIDEO CALL // toggle folds secret-derived
│   │                      # liveness-action prompt into the same screen
│   ├── settings/          # bulk-backup entry, secret-haptics toggle, etc.
│   ├── about/             # version + license + source + donate links
│   └── help/              # in-app FAQ
└── shared/widgets/        # BigButton, WordsDisplay, SecureScreen

Security notes

What this defends against

  • Real-time voice deepfakes (ElevenLabs-class synthesis)
  • Real-time video deepfakes in calls
  • Pre-recorded deepfake voicemails
  • Vishing using socially-engineered biographical knowledge
  • Compromised messaging accounts where the attacker has chat history but not the paired device
  • SIM swaps (the secret is device-local, not phone-number-bound)

What it does not defend against

  • Physical device compromise (attacker has the unlocked phone)
  • Shoulder surfing (attacker visually captures the code on screen)
  • Coercion of the real person (they produce a valid code under duress)
  • A paired attacker — if you paired with the wrong person to start, Signet has no way to know

Storage

The v0.1 secure storage configuration uses the plugin's standard AndroidOptions:

  • Master AES key wrapped via RSA/ECB/OAEPwithSHA-256andMGF1Padding in Android Keystore
  • Stored data encrypted via AES/GCM/NoPadding
  • resetOnError: false — transient failures surface rather than silently wiping a user's pairing
  • StrongBox is requested; the plugin falls back to TEE-backed Keystore on devices that lack it

The stronger AndroidOptions.biometric() path (AES-GCM for both key wrap and data, StrongBox-preferred) is deferred because flutter_secure_storage v10.0.0 has a first-run algorithm-migration bug under that constructor. Both paths are hardware-backed; the security difference is negligible at the threat-model level of this app.

QR payload

In-person pairing exchanges three QR codes in the format signet:p2:<kind>:<base64url>: a commitment to the first phone's key (C), the second phone's X25519 public key (K), then the first phone's key and nonce (R), which must match the commitment. The prefix keeps a stray URL or other QR from being read as a pairing code, and a code from an older or newer Signet says which phone needs an update. Full byte layouts, including the long-distance package and both pair-time phrases, are in docs/WIRE_FORMAT.md.

Development roadmap

Ordered roughly by shipping impact.

v0.1 (this release)

  • In-person QR pairing (two scans, symmetric flow)
  • 4-BIP-39-words role-asymmetric type-and-verify with ±1 window tolerance + show-my-own-words fallback
  • Hardware-backed secure storage (AES-GCM on a RSA-OAEP-wrapped master key in Android Keystore, StrongBox when available)
  • One relationship per device
  • FLAG_SECURE on sensitive screens (blocks screenshots + recording + recent-apps thumbnail)
  • First-run onboarding + post-pair practice-verify nudge
  • Per-relationship silent-haptics toggle (for journalist / activist audience)
  • Unpair undo window; inline label editing; binding-phrase re-check; WHAT SHOULD I DO? education on ❌

v0.2 — multi-peer + unified transport package + recovery

Shipped in v0.2.0 (Google Play Internal Testing, 2026-04). See .devloop/archive/ for the full plan trail.

  • ✅ Multiple paired contacts with a real v1 → v2 storage migration (no wipe). Home is a ListView; FAB + long-press menus drive per-peer actions.
  • ✅ Unified transport-package primitive that services two use cases from one wire format (signet:tp1:..., AES-256-GCM keyed by HKDF-SHA-256 of an 8-word BIP-39 PAKE secret):
    • ✅ Long-distance pairing for journalists / activists / distributed teams: encrypted LDP package delivered over any channel the two parties already trust (paper courier, encrypted email, Signal attachment, prior-in-person fact). PAKE secret communicated on a different trusted channel — never over a fresh voice call, which would re-introduce the voice-channel threat Signet exists to defend against. See .devloop/spikes/transport-package.md for the crypto rationale.
    • ✅ Lost-phone recovery as "transport-to-self": export an LPR package (QR + selectable text) with an independently-stored PAKE secret, re-import on the new device, pairing materializes with the original shared secret preserved. Covers the 2-3-year phone-lifecycle problem that otherwise forces repair-in-person every device change.
  • ✅ In-person rekey preserving Relationship.id + label but rotating the shared secret. Both devices present, QR exchange, pair-time phrase confirmation — flow mirrors the initial pair.
  • ✅ Challenge-response wordlist mode — an 8×8 grid (64 cells × 3 BIP-39 words per answer; 33 bits of entropy per query) derived from the shared secret via HKDF. Both devices have the digital grid; either side can print a paper card via the Print action in the app. Used as a fallback when the responder has no phone but can speak. See .devloop/spikes/challenge-response.md for the derivation + threat-model write-up.
  • ✅ Local-file backup export + import — the LPR package can be shipped to/from any file (Files app, encrypted note, USB stick, etc.) via the platform share sheet and a file picker. Complements the QR / copy-paste / paper paths.

v0.3.0 — secret-derived liveness + bulk backup

  • ✅ Secret-derived liveness for video-mode verify (supersedes the earlier prompt-only variant). The expected physical action is now derived from the shared secret via HKDF-SHA-256 (role-asymmetric, matching the rotating words) instead of locally minted from Random.secure(). Folded into the existing verify screen as a VIDEO CALL // toggle: pass = words-✅ AND counterparty-physical-action-observed. Combined attack probability for a realtime voice+video deepfake without the secret drops from ~100% to 1/2⁴⁷ per 30s window. See .devloop/spikes/secret-derived-liveness.md.
  • ✅ Bulk backup — one file, one 8-word PAKE, every paired relationship. Settings → Back up all relationships exports a single encrypted bundle that restores every pairing on a new phone in one step instead of running the per-relationship export N times. Import auto-dispatches on the payload-type byte, so the paste flow is unchanged. See .devloop/spikes/bulk-backup.md for the threat-model + design rationale.

v0.3.1 – v0.3.3 — F-Droid compliance + maintenance

  • ✅ FOSS-only QR scanner. Replaced mobile_scanner (which bundles Google ML Kit + Play Services as proprietary native code) with flutter_zxing (MIT, ZXing C++ via FFI). F-Droid Inclusion Policy item 5 forbids proprietary Google libraries; this swap unblocked the F-Droid submission. APK shrinks 73.1 MB → 64.2 MB.
  • ✅ ABI-split builds for F-Droid. Per-architecture APKs (armeabi-v7a, arm64-v8a, x86_64) via a versionCodeOverride in android/app/build.gradle.kts. F-Droid users now download a CPU-specific binary ~3× smaller than the universal AAB. Play AAB unchanged.
  • ✅ F-Droid metadata moved to Fastlane layout at fastlane/metadata/android/en-US/. F-Droid auto-imports description/screenshots/changelogs from this canonical path on every release tag; no fdroiddata content MR needed for future releases.
  • ✅ Dynamic Flutter version pinning for the F-Droid prebuild: the Android build step in .github/workflows/release.yml carries the canonical flutter-version: 'X.Y.Z' literal; F-Droid's prebuild extracts it via the regex from fdroiddata/templates/build-flutter.yml. Single source of truth for Flutter version.
  • ⏳ Liveness prompts — camera-integrated variant. App auto-detects fingers / motion on the video feed. Multi-month research project with real accuracy risk; explicitly deferred to a later plan.

v0.3.4 — in-app crash detection + log-to-issue shipping

  • ✅ In-app crash reporter. When the app crashes, a next-launch modal dialog offers to file a GitHub issue with a pre-filled crash_report.yml form (device + OS + app version + scrubbed stack trace). Three actions: FILE ISSUE (OS-browser handoff via url_launcher), COPY LOG (clipboard only), DISMISS.
  • ✅ Deny-by-default scrubber. Mechanical patterns (signet:tp1: wire, hex≥16, base64url≥16, BIP-39 4-tuple + 8-tuple clusters) enforced at a 100% pass-or-fail-CI bar. Judgment patterns (pair labels in toString dumps, user-input quoted strings) at ≥95%. The Kotlin reference impl in digital-grease/fauxx is allow-by-default; Signet needs the inversion because a scrubber miss could leak a shared secret.
  • ✅ AES-256-GCM at rest. Sentinel file encrypted under a key in the Android Keystore / iOS Keychain. Layered so a scrubber regression doesn't surface plaintext on disk.
  • ✅ Zero in-process network. No INTERNET permission added to the Android manifest; the "File issue" button hands off to the OS browser. Full four-layer defense documented at docs/THREAT_MODEL.md §3.6.
  • ✅ Fixed: in-person pairing deadlock (#1). Derivation gated on both didShowQr AND a recorded counterparty key; both devices now reach verification independently after completing both scan + show steps.

v0.5.0 — word languages

  • ✅ French words for any contact, chosen at first run and when pairing, carried in backups (transport-package extension 0x01), switchable on the pairing confirm screens, read by screen readers in French, printed on the challenge-grid card with an embedded font.
  • ✅ Chinese (Mandarin) words from a Signet-curated list (zh-1, beta), typed in characters or pinyin, shown in simplified or traditional characters with a pinyin or Zhuyin reading line, read by screen readers in Mandarin, printed on the challenge-grid card; and the Traditional Chinese interface.

v1.0

  • Duress codes (pending an abuse-analysis pass)
  • Small-org shared verification (group-key via MLS-equivalent, not hand-rolled)
  • External security audit
  • F-Droid verified reproducible builds (F-Droid rebuilds each release, compares it with our signed APK and ships ours if they match)
  • iOS parity + on-device plaintext-leak re-audit against Keychain
  • Play Store / App Store release

Known limitations

  • Android only. iOS builds but is untested.
  • StrongBox best-effort. Some OEMs disable StrongBox even on API 28+ devices; detection / explicit attestation is v1.0 work.
  • iOS screenshot block not implemented. On Android, FLAG_SECURE blocks screenshots + screen recording + the recent-apps thumbnail on the Verify and pair-QR screens. iOS needs a different mechanism (swap UI on applicationWillResignActive) — deferred to post-v1.0 iOS parity.
  • No duress codes. Deferred pending misuse analysis.
  • Release signing is opt-in. Without android/key.properties, release builds fall back to the debug key and are not distributable — see "Release build" for the signing setup.
  • Scanner has no explicit "permission permanently denied" recovery. The in-app message is friendly but does not offer a deep link to system settings.

Pre-release manual QA

Before any real-world distribution, validate on physical hardware:

  1. Full two-device pair + verify roundtrip. Use two Android 9+ phones. Both should derive identical 4-word pair-time phrases; on the Verify screen, typing one phone's current 4 rotating words into the other's 4-slot input should produce a ✅ banner.
  2. Inspect /data/data/dev.digitalgrease.signet/shared_prefs/ after pairing. Confirm the shared secret never appears in plaintext.
  3. Accessibility: complete the pair flow using only TalkBack (screen reader).
  4. Large text: set the system font size to max; re-walk the paired state, Verify screen, and Pair-confirm screen (these were not reachable during v0.1 emulator-only QA).
  5. Clock drift: on two paired phones, set one clock ±30s. The displayed codes on both should still match via the verifier's ±1-window tolerance.
  6. Offline: enable airplane mode on both phones; the full pair + verify loop should work unchanged.
  7. Uninstall: after unpair-and-uninstall, confirm no app data survives in /data/data/dev.digitalgrease.signet/.

Contributing

Signet is solo-maintained beta software. Issues and PRs are welcome, but please understand that the threat model and UX constraints (the "grandma test" in particular) are design constraints, not suggestions — PRs that add network calls, analytics, or account systems will not be merged under any circumstances.

License

AGPL-3.0. See LICENSE.

The printed challenge-grid card embeds a subset of Noto Sans, built by tool/fonts/subset_noto.sh, and subsets of Noto Sans CJK SC and TC, built by tool/fonts/subset_noto_cjk.py (all SIL Open Font License 1.1, assets/fonts/OFL.txt).

This is user-sovereign software. The license reflects that.

About

No description, website, or topics provided.

Resources

Stars

41 stars

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages