Skip to main content

How We Protect Your Data

We built 2ATracker with the understanding that your firearms data is some of the most sensitive information you own. Here's exactly how we protect it.

Zero-Knowledge Encryption

Your data is protected by a key hierarchy: a random 256-bit User Data Key (UDK) encrypts your data via AES-256-GCM. The UDK is wrapped by a Key Encryption Key derived from your password using Argon2id (memory-hard, GPU/ASIC resistant).

This means:

  • We cannot read your data. Even with full database access, your encrypted fields are indecipherable without your password.
  • Each user has their own unique encryption salt — no two users share a key.
  • Password changes are instant — only the key wrapper changes, not your data.
  • A recovery key (shown once at setup) can restore access if you forget your password.

Encrypted fields include:

  • Serial numbers (firearms & suppressors)
  • NFA trust names
  • Purchase prices and costs
  • Shooting session locations
  • All notes and personal text fields
  • Session malfunction reports
  • Fingerprint (.eft) files — encrypted server-side with per-user UDK
  • FFL customer biometric metadata — ditto, auto-purged per retention policy
KEK = Argon2id(password, salt, t=3, m=64MB, p=4)
UDK = AES-256-GCM(KEK).unwrap(wrapped_key)
ciphertext = AES-256-GCM(UDK).encrypt(plaintext)
NFA Document Encryption

Form 4 and Form 1 PDFs (which contain SSNs, addresses, and ATF stamps) are encrypted at rest using AES-256-GCM, the same cipher protecting all your other data.

  • Documents are encrypted immediately after upload
  • OCR processing happens before encryption, then the plaintext is discarded
  • Decryption happens on-the-fly only when you download
  • Backups are GPG-encrypted before upload to cloud storage
Data Isolation

Every piece of data is tied to your account. No user can see another user's:

  • Firearms, suppressors, or parts
  • Serial numbers or NFA documents
  • Ammo inventory or shooting sessions
  • Uploaded photos

Sharing is opt-in — you explicitly choose which firearms to share and with whom.

Infrastructure Security
  • HTTPS everywhere — TLS 1.2/1.3, HSTS with preload
  • Privacy-first analytics — self-hosted Umami (open-source, cookieless, GDPR-compliant). No Google Analytics, no ad trackers, no third-party scripts (except Bootstrap CDN with integrity verification)
  • Content Security Policy — strict CSP with per-request nonces blocks XSS and script injection
  • Encrypted backups — daily GPG-encrypted backups to isolated cloud storage
  • Container hardening — runs as non-root, dropped capabilities, no privilege escalation
  • Rate limiting — brute-force protection on all authentication endpoints
  • CSRF protection — every form submission verified with per-session tokens
  • Secure cookies — HttpOnly, Secure, SameSite=Lax, 8-hour expiry
  • Security headers — X-Frame-Options DENY, X-Content-Type-Options, HSTS with preload
  • Server-side sessions — session data stored in Valkey (Redis-compatible), not in browser cookies
  • Audit logging — every document access is logged with timestamp and IP
  • Account deletion — you can permanently delete all your data at any time
Quantum-Resistant by Design

AES-256 encryption — approved by NIST (FIPS 197) and required by the NSA's CNSA 2.0 suite for protecting classified data in the post-quantum era. Every layer of our encryption stack is resistant to both classical and quantum computing attacks:

AES-256-GCM (Data Encryption)
256-bit key provides 128-bit effective security even against Grover's quantum search algorithm — well beyond the threshold of any foreseeable quantum computer. CNSA 2.0 requires AES-256 for all classification levels (NIST IR 8547).
Argon2id (Key Derivation)
Memory-hard KDF with 64 MB memory cost. Quantum computers cannot bypass the memory-hardness requirement, making brute-force attacks infeasible even with quantum speedup.
Symmetric-Key Only
Your data is encrypted entirely with symmetric-key cryptography — no RSA or ECDH in the encryption path. Keys are derived from your password, not exchanged. WebAuthn (FIDO2) security keys use asymmetric crypto for authentication only, not data encryption.
GPG AES-256 (Backup Encryption)
Database backups are encrypted with AES-256 via GPG before upload to cloud storage. Same quantum-resistant standard across every layer.
What We Can't See
Encrypted (we CANNOT read):
  • Firearm names, makes, models, calibers
  • Suppressor names, makes, models, calibers
  • Serial numbers (firearms & suppressors)
  • NFA trust names
  • NFA documents (Form 4 / Form 1 PDFs)
  • All purchase prices and costs
  • Ammo calibers, brands, names, bullet types
  • Shooting session locations
  • Drill types and session notes
  • All notes and personal text fields
  • Malfunction reports
  • Session photos and backup archives
Obfuscated (opaque to database):
  • Platform, type, status → stored as opaque integer codes
  • Ammo round counts → integers only
  • Session dates → no context without encrypted fields
  • Username and email → needed for authentication

Structural fields are stored as meaningless integer codes. A raw database dump shows platform=4, status=1 — not "glock" or "active."

Open source commitment: Our security model is transparent by design. We believe the best security is security you can verify, not security you have to trust.

Crypto Design Doc engineer-grade

A verifiable, implementation-accurate description of 2ATracker's cryptographic stack. Every constant below is sourced directly from app/utils/crypto.py. If anything here diverges from the code, the code is the ground truth — please file an issue.

Versions: cryptography==46.0.6, argon2-cffi==25.1.0. Standards: RFC 9106 (Argon2), NIST SP 800-38D (GCM), FIPS 197 (AES), RFC 5869 (HKDF).

The diagram below shows how a user's password becomes ciphertext. The server sees the password exactly once (at login, in memory) — it is never written to disk or logs, and is discarded after KEK derivation.

  Password (user-supplied)                user-specific 32-byte salt
      │                                            │
      └──────────────┬─────────────────────────────┘
                     ▼
          ┌──────────────────────────┐
          │       Argon2id KDF       │   m=64 MiB (65536 KiB)
          │      (RFC 9106, type=id) │   t=3 iterations
          │                          │   p=4 lanes
          └──────────────┬───────────┘   hash_len=32 bytes
                         ▼
                KEK (32 bytes, raw)
                         │
                         ▼
          ┌──────────────────────────┐
          │ AES-256-GCM unwrap(UDK)  │   AAD = "2at:v3:keywrap:<user_id>:udk"
          │   wrapped blob (61 B):   │   nonce = 12 bytes (CSPRNG, per-wrap)
          │   0x03 ‖ nonce ‖ ct+tag  │   tag   = 16 bytes
          └──────────────┬───────────┘
                         ▼
                UDK (32 bytes, random, per-user)
                         │
                         ▼
          ┌──────────────────────────┐
          │ AES-256-GCM encrypt()    │   AAD = "2at:v3:field:<table>:<col>:<user_id>"
          │                          │   nonce = 12 bytes (CSPRNG, per-record)
          │                          │   tag   = 16 bytes
          └──────────────┬───────────┘
                         ▼
       "v3:" ‖ base64url( nonce ‖ ciphertext ‖ tag )
                         │
                         ▼
                    PostgreSQL row

Notable properties:

  • Password never touches disk. Only the KEK-wrapped UDK is persisted.
  • Password change = instant. Only the 61-byte wrapped UDK is re-written; user data is untouched.
  • UDK lives only in the authenticated session (Valkey, TLS, at-rest encrypted) and is flushed on logout.
  • Recovery key path: HKDF-SHA256("2at-recovery-kek") derives an alternate KEK that also wraps the UDK, giving users a password-reset escape hatch without operator involvement.

Argon2id (KDF)
Chosen over PBKDF2, bcrypt, and scrypt because it is the only memory-hard KDF standardized by the IETF (RFC 9106) and recommended by OWASP 2024. Memory hardness makes GPU/FPGA/ASIC attack hardware economically unviable: an attacker must spend on RAM, not just on ALUs. The id variant hybridizes data-independent and data-dependent addressing to resist both side-channel and time-memory trade-off attacks.
Competitor context: tools using raw PBKDF2 are roughly 1000× cheaper to brute-force per candidate password at current GPU prices.
AES-256-GCM (AEAD)
Authenticated Encryption with Associated Data per NIST SP 800-38D. Provides both confidentiality and integrity in a single pass; tampering with any byte of ciphertext or AAD causes decryption to fail with an InvalidTag exception — no silent corruption. Chosen over AES-CBC (malleable, padding-oracle prone) and raw ChaCha20 (no AEAD) because it runs on AES-NI hardware with multi-GB/s throughput on every production CPU we deploy to (Graviton3 / x86-64).
12-byte random nonces
Generated fresh per encryption via os.urandom(12) (CSPRNG). NIST SP 800-38D §8.2.2 explicitly permits random 96-bit nonces. Birthday bound collision probability remains below 2−32 until ~232 encryptions under the same key — far beyond any realistic user data volume.
AAD v3 format
Every field-level ciphertext is bound to its (table, column, user_id) context via AAD: 2at:v3:field:<table>:<column>:<user_id>. This prevents a class of attacks where a DB-level attacker copies ciphertext from column A into column B (say, pasting a suppressor serial into a firearm row) in the hope that the server will decrypt it in a different UI context. With AAD binding, such a swap produces an InvalidTag on read. Key-wrap operations use an analogous scheme: 2at:v3:keywrap:<user_id>:<purpose>.
HKDF-SHA256 (recovery key)
Recovery keys carry 192 bits of entropy (secrets.token_bytes(24)) and are printed once at signup. HKDF provides domain-separated KEK derivation (info=b"2at-recovery-kek") per RFC 5869. The recovery KEK independently wraps the UDK, so losing a password does not lose data; losing both password and recovery key means the data is cryptographically unrecoverable by design.

We explicitly enumerate what the design defends against and what it does not. Truth-in-marketing matters here.

In-scope (defended against)
  • Full database compromise (stolen dump, rogue DBA, SQL injection read) — encrypted fields are ciphertext only; no plaintext serial/make/model/caliber exists in the database.
  • Stolen RDS snapshots / S3 backups — backups are GPG AES-256 encrypted; RDS is encrypted at rest with AWS KMS.
  • Compromised application server (read-only) — the server does not persist KEKs or UDKs. A memory dump during an active session exposes only the currently logged-in users' UDKs, not other users'.
  • Field-swap / row-swap attacks — AAD binding causes GCM tag verification to fail if ciphertext is moved between fields, rows, or users.
  • MITM on the wire — TLS 1.2/1.3 at the ALB, HSTS with preload, secure cookies.
  • Offline password cracking of the KEK-wrap — Argon2id with m=64 MiB makes distributed brute-force expensive enough that even weak but non-trivial passwords remain safe for realistic attacker budgets.
  • Weak password reuse (credential stuffing) — HIBP breach check at signup rejects known-pwned passwords.
Out of scope (NOT defended against)
  • Compromised user browser or endpoint (malware, keylogger, malicious extension) — if the attacker controls the user's machine, they see plaintext as the user does. No server-side cryptography can fix this.
  • Compromised application server (active RCE) — an attacker with code execution can log passwords as users authenticate going forward. Already-stolen database rows remain protected, but a live RCE is effectively "over the shoulder."
  • Physical device theft after the user has unlocked — an unlocked, logged-in session exposes decrypted data. Session expiry and re-auth for sensitive actions mitigate but do not eliminate this.
  • User reuses a weak password across sites and it appears in a breach corpus with our email — MFA reduces blast radius; HIBP check helps at signup.
  • User loses both password AND recovery key — data is cryptographically lost. This is a design trade-off, not a bug.
  • Legal compulsion to modify the application to exfiltrate user input — a hostile code push could log passwords before key derivation. We mitigate with signed releases, audit logs, and our public source commitment, but we cannot cryptographically prevent future-you from shipping bad code.

We are not a nation-state target. Our design makes a database breach a non-event for encrypted fields, and raises the cost of attacks that require controlling the runtime. It does not claim magic.

All values below are constants in app/utils/crypto.py.

ParameterValueSource
Argon2id (KEK derivation)
Varianttype=Type.IDRFC 9106 §3.1
Memory cost (m)65536 KiB = 64 MiBARGON2_MEMORY_COST
Time cost (t)3 iterationsARGON2_TIME_COST
Parallelism (p)4 lanesARGON2_PARALLELISM
Output length32 bytes (256 bits)ARGON2_HASH_LEN
Salt length32 bytes, per-user, randomgenerate_salt() via os.urandom(32)
AES-256-GCM (data & key-wrap)
Key size256 bitsFIPS 197
Nonce size12 bytes (96 bits)NIST SP 800-38D §8.2
Nonce sourceos.urandom(12) (CSPRNG)per-encryption, never reused
Auth tag16 bytes (128 bits)NIST SP 800-38D §5.2.1.2
Implementationcryptography.hazmat.primitives.ciphers.aead.AESGCMcryptography==46.0.6
AAD format (v3)
Field AAD2at:v3:field:<table>:<column>:<user_id>_build_field_aad()
Key-wrap AAD2at:v3:keywrap:<user_id>:<purpose>build_keywrap_aad()
File AAD2at:v3:file:<purpose>build_file_aad()
EncodingUTF-8 bytesall AAD strings
Ciphertext on-the-wire
Field formatv3:base64url(nonce ‖ ct ‖ tag)encrypt_value()
Wrapped UDK format0x03 ‖ nonce(12) ‖ ct+tag(48) = 61 byteswrap_key()
File format"2AT\x03" ‖ nonce(12) ‖ ct ‖ tagencrypt_file_bytes()
Recovery key
Entropy192 bits (secrets.token_bytes(24))generate_recovery_key()
Display format6 × 6-char base32 groups36 chars + 5 dashes
KDFHKDF-SHA256, info=b"2at-recovery-kek"RFC 5869

Every field marked ENC uses encrypted_string_property or encrypted_float_property with the user's UDK and the AAD format shown above. Fields marked plain are left queryable (indexes, sorting, counts, date ranges) and are not considered sensitive on their own.

Model (__tablename__) Encrypted fields Plaintext metadata (queryable)
user ENC beneficiary_name, beneficiary_email, beneficiary_phone, beneficiary_notes id, username, email (for login/MFA), tier, flags, session_version
firearm ENC serial_number, name, make, model, caliber, purchase_price, estimated_value, notes firearm_type (opaque int code), platform (opaque int code), purchase_date, last_appraised
firearm_part ENC category, brand, model, price, notes status (opaque int code), install_date, photo_url
suppressor ENC serial_number, trust_name, name, make, model, caliber, purchase_price, estimated_value, notes status (opaque int code), form4_submitted, form4_approved, submit_date, approval_date, control_number
ammo ENC caliber, brand, name, bullet_type, cost_per_round, notes grain_weight, round_count, purchase_date
shooting_session ENC location, drill_type, notes date, created_at
session_firearm_ammo ENC malfunctions, notes round_count, foreign keys (session/firearm/ammo/suppressor)
item_photo ENC caption filename, original_name, uploaded_at (file bytes encrypted at rest)

Structural enums (platform, type, status) are stored as opaque integer codes via _obfuscated_property. A raw database dump shows platform=4, status=1, not "glock" or "active".

We welcome security research. Please report vulnerabilities privately so we can fix and coordinate disclosure.

Contact
security@2atracker.com
PGP key
Available on request from the contact address above. Publishing a stable long-lived PGP key is on our roadmap.
Scope
*.2atracker.com production, the open-source application code, and the Terraform infrastructure in infra/aws/. Out of scope: social engineering of staff, physical attacks, DoS/volumetric tests, and third-party services (Stripe, AWS, Umami, CDN).
Safe harbor
Good-faith research that complies with this policy will not be pursued legally. Please avoid accessing, modifying, or exfiltrating other users' data; use only accounts you own or have permission to test.
Bounty
We do not currently run a paid bounty program. We offer public credit, written thanks, and (for significant findings) swag and free Pro-tier accounts. This will evolve as the company grows.
SLA
Acknowledgement within 72 hours. Triage within 7 days. Fix timeline proportional to severity.

If you believe you've found an active compromise of user data, please mark your message URGENT in the subject line.

The claims on this page are mechanical consequences of a single file. Read it:

  • app/utils/crypto.py — every KDF, cipher, nonce, and AAD constant
  • app/models.py — every use of encrypted_string_property / encrypted_float_property
  • app/auth/routes.py — login, KEK derivation, UDK unwrap, session storage
  • tests/test_crypto*.py — round-trip tests, AAD-swap tests, version-migration tests

Quick grep to verify:

# confirm Argon2id parameters
grep -E "ARGON2_(TIME|MEMORY|PARALLELISM|HASH)_" app/utils/crypto.py

# confirm 12-byte GCM nonce
grep "os.urandom(12)" app/utils/crypto.py

# confirm AAD format
grep "2at:v3:" app/utils/crypto.py

# list every encrypted field
grep -E "encrypted_(string|float)_property" app/models.py

If you find a discrepancy between the code and this page, that's a bug. Please report it via the disclosure channel above.