Self-hosting is hereRead more →
← Back to blog

How ArmoryHub encrypts your data (Zero-Knowledge E2E Encryption)

ArmoryHub is built on a zero-knowledge architecture. That means your data is encrypted on your device before it ever leaves your browser. Our servers only ever see encrypted blobs. We couldn't read your data if we tried.

Here's how the key hierarchy works. When you create your account, you set a PIN. That PIN is run through PBKDF2-HMAC-SHA256 with 310,000 iterations and a random 256-bit salt. This produces a PIN-derived key. That's the key that protects your master key.

// Key Derivation (PIN → PIN-derived key)
// PBKDF2-HMAC-SHA256 with 310,000 iterations
// Turns your 6-digit PIN into an AES-256 key
const pinKey = await crypto.subtle.deriveKey(
  {
    name: 'PBKDF2',
    salt: saltBuffer,        // 256-bit random salt
    iterations: 310_000,     // OWASP 2023 recommendation
    hash: 'SHA-256',
  },
  keyMaterial,               // PIN as raw key material
  { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']
)

Your actual master key is a randomly generated AES-256 key. This is the key that encrypts and decrypts all your data. It's encrypted by the PIN-derived key and stored locally in IndexedDB on your device. We never see it in plaintext. We only store the salts needed to re-derive the PIN key on our servers — not the key itself.

// Master Key Generation
// Random AES-256 key — this encrypts all your data
const masterKey = await crypto.subtle.generateKey(
  { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']
)

// Encrypted by PIN-derived key, stored locally
// Only the salts are stored on our servers — never the key

Optional: High-Entropy Passphrase

Your PIN provides real encryption — but a 6-digit PIN has roughly 20 bits of entropy. In the extremely unlikely event of a full offline database breach (compromising both Supabase and the AWS infrastructure it runs on), a determined attacker with direct access to the encrypted payload could feasibly brute-force it, even with 310,000-iteration PBKDF2 slowing each guess.

For users who want defense against that worst case, ArmoryHub offers an optional passphrase. The passphrase acts as a Cloud-Side Shield. While your local device keeps a copy of the master key wrapped by your PIN for quick access, the version synced to our servers is wrapped by the passphrase instead. This ensures that even a total breach of our infrastructure leaves an attacker facing a high-entropy barrier that is mathematically impossible to crack.

When enabled, the passphrase is run through PBKDF2-HMAC-SHA256 at 600,000 iterations with its own independent 256-bit salt, producing a passphrase-derived key that wraps the cloud-side copy of your master key.

// Passphrase Key Derivation (Passphrase → Passphrase-derived key)
// PBKDF2-HMAC-SHA256 with 600,000 iterations
// Independent salt, independent rate-limit counters
const passphraseKey = await crypto.subtle.deriveKey(
  {
    name: 'PBKDF2',
    salt: passphraseSaltBuffer,  // Independent 256-bit random salt
    iterations: 600_000,         // Nearly 2x PIN iterations
    hash: 'SHA-256',
  },
  keyMaterial,                   // Passphrase as raw key material
  { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']
)

A strong passphrase pushes entropy into the 80+ bit range, making an offline attack effectively impossible regardless of the attacker's resources or time horizon. Day-to-day, nothing changes — you still unlock with your PIN. The passphrase is only needed when setting up a new device or after a session reset.

As with your PIN, there is no reset, no backdoor, no support override. Forgetting your passphrase means permanent, irreversible loss of your vault. This is by design — it's the property that makes it work.

When you save a firearm, accessory, range trip, or anything else — the record is serialized to JSON, encrypted with AES-256-GCM using a fresh 12-byte nonce, and sent to our database as a binary blob. The format is: nonce (12 bytes) || ciphertext || auth tag (16 bytes). Photos and documents go through the same encryption before being uploaded to storage.

// Encrypting a Record (AES-256-GCM)
// Every record gets a fresh 12-byte nonce
const nonce = crypto.getRandomValues(new Uint8Array(12))

const ciphertext = await crypto.subtle.encrypt(
  { name: 'AES-GCM', iv: nonce },
  masterKey,
  plaintext
)

// Stored as: nonce (12 bytes) || ciphertext || auth tag (16 bytes)
const encrypted = new Uint8Array(12 + ciphertext.byteLength)
encrypted.set(nonce)
encrypted.set(new Uint8Array(ciphertext), 12)

On our end, your database row looks like this: a user ID, timestamps, and a BYTEA column full of encrypted bytes. That's it. No field names, no values, no metadata about what's inside. Just an opaque payload that only your device can unlock.

// What We Store
// This is all that reaches our database:
await supabase.from('firearms').insert({
  id: uuid,
  user_id: user.id,
  payload: encryptedBytes,  // opaque BYTEA blob
  is_deleted: false,
  created_at: timestamp,
  updated_at: timestamp,
})

// No field names. No values. No metadata.
// Just an encrypted blob only your device can read.

Here's what an encrypted blob looks like:

`\xf72c5ce1202335bfb7997b81d9ad1170cd35fda55016745790be8b03403ee7fb74ceb80e70376391b636fd43a25d878aea05a1dc2d1ea20738c32c0270c60ef40d026b2e339fac1bb415912888317bbb132a609b294e98305b895d58abfe077f8d7b71d203591b064a8d628cccc77fa3e86778055c251a6172445f063cbbbe9314b97377a15e934f41d4144e28151c7c11d70241faaef86739c18f129e2d84ed17206f4108072e7e9424a98b84186cbaf28bbeb3ba6f14fcb059e5940ccef7374038c3c6b96d8d29b4dc3f5ec1d5000f40f40b53f3d95ede0a00ab545cdf915ee8ac62d7d4dd5f04568896b1b64c49aa0e17aa01262af0e624e62c97d40cf78e39f1343c0b5a2ccb47c0f7859ec5deede60100facbd09c0656919022d3d468f31b44eb56ba9a29d42e2b23d774ffc479f49090c2e474d6fc42bc77c8dc35f3894db3f1de7ef8d0031aa5c967495729`

When you unlock the app with your PIN, the PIN-derived key decrypts the master key in memory. The master key is held only for your active session and is wiped when you lock the app or after a period of inactivity.

Multi-device sync works by storing the encrypted master key alongside the salts in our database. Without a passphrase, the cloud copy is wrapped by your PIN-derived key. With a passphrase enabled, the cloud copy is wrapped by your passphrase-derived key instead. When you log in on a new device, you enter your passphrase (if set) and your PIN. The same key derivation runs, decrypts the master key, and it's re-wrapped locally by your PIN for daily use. No plaintext keys ever touch our servers.

What happens when you delete your account? First, all uploaded files (photos, documents, thumbnails) are permanently removed from storage. Then every row across all 25+ entity tables is hard-deleted from the database. Not soft-deleted, not archived — removed.

// Account Deletion
// Delete all storage files (photos, documents)
for (const bucket of ['photos', 'documents']) {
  const { data: files } = await supabase.storage
    .from(bucket).list(user.id)
  if (files?.length) {
    await supabase.storage.from(bucket)
      .remove(files.map(f => `${user.id}/${f.name}`))
  }
}

// Hard-delete every row across all entity tables
for (const table of entityTables) {
  await supabase.from(table).delete().eq('user_id', user.id)
}

Then, the encryption metadata (salts — including passphrase salts if applicable) is deleted from our servers and the master key is wiped from your device. Even if a data fragment somehow survived somewhere, without the master key it's just random bytes. AES-256-GCM guarantees that.

// Destroy encryption key + metadata
// No key = no way to read anything, ever
await clearStoredKey()
await deleteCloudEncryptionMetadata()

Infrastructure & Brute Force Protection

Your encrypted data is hosted on Supabase, built on AWS infrastructure. Supabase is SOC 2 Type II compliant — their security controls are independently audited and verified, covering data handling, access controls, and operational security.

An attacker would first need to compromise your email and password to authenticate with Supabase — without a valid session, the database returns nothing. Even then, they'd face your encryption PIN (and passphrase, if enabled), which are completely separate credentials.

The app enforces progressive lockouts on failed PIN attempts (server-side, NOT client-side):

  • 3 failed attempts → 30 second lockout
  • 5 failed attempts → 2 minute lockout
  • 7 failed attempts → 5 minute lockout
  • 10 failed attempts → 15 minute lockout
  • 15 failed attempts → 30 minute lockout
  • 20 failed attempts → 1 hour lockout

Passphrase verification uses its own independent rate-limit counters with the same progressive lockout schedule. An attacker can't burn through PIN attempts and passphrase attempts simultaneously — each credential has its own lockout state.

Combined with PBKDF2's 310,000 iterations on the PIN (and 600,000 on the passphrase) — which deliberately makes each derivation computationally expensive — brute forcing through the app is effectively a non-starter. An attacker would need to breach three independent layers: your account credentials, your encryption PIN, and a 310,000-iteration key derivation per guess. With a passphrase enabled, that becomes four layers and 600,000 iterations per passphrase guess on top.

Even in a more severe scenario — if an attacker gains physical access to your device, they would need to brute-force your PIN-derived key (310,000 PBKDF2 iterations per guess against a 6-digit PIN). However, if they obtain encrypted key material from our servers, they would face the much higher barrier of your passphrase-derived key — 600,000 PBKDF2 iterations per guess against 80+ bits of entropy. That's computationally infeasible.

For context, a 6-digit PIN has 1,000,000 possible combinations. PBKDF2 at 310,000 iterations makes each guess deliberately expensive, which significantly slows offline brute-force attempts — though, as with any short numeric PIN, stronger PIN choices provide materially better protection. A strong passphrase eliminates this concern entirely by moving the entropy floor well beyond what any brute-force attack can reach.

In practice, reaching this point is highly unlikely. It would require a serious breach of Supabase's SOC 2 Type II audited infrastructure or a compromise of your own device — both of which sit behind multiple layers of security controls and monitoring.

In other words, an attacker would need to defeat several independent layers before they even get the opportunity to attempt an offline attack against your encrypted data — and with a passphrase enabled, that attack is a dead end.

TL;DR — we encrypt everything on your device, store only encrypted blobs, never hold your key, and when you delete your account we destroy both the data and the means to decrypt it. For users who want maximum protection, the optional passphrase adds a layer that makes offline brute-force mathematically infeasible. Your collection, your data, your control.

Note: code snippets are simplified and trimmed compared to actual implementation for readability