Skip to main content

Encryption & Security Overview

HAWKI operates a client-first encryption model: all personally identifiable message content and cryptographic keys are encrypted in the browser before they reach the server. The server stores ciphertext blobs and distributes salts — it never holds plaintext keys or decrypted content.

This article covers the cryptographic architecture and the classes that implement it on the backend. For the browser-side implementation of deriveKey(), symmetric helpers, and the KeychainHandle store, see the frontend Encryption page.


Three-Tier Cryptographic System​

HAWKI uses three complementary encryption modes, each chosen for its role in the data flow.

Tier 1 — Symmetric (AES-256-GCM)​

Used for bulk data: room messages, AI-conversation messages, and the keychain itself.

  • Algorithm: AES-256-GCM with a random 12-byte IV per operation.

  • Authentication tag verifies ciphertext integrity on every decrypt.

  • Wire format serialised by SymmetricCryptoValue:

    base64(iv) | base64(tag) | base64(ciphertext)

    The three segments are joined by the | character. Both the backend AsSymmetricCryptoValueCast Eloquent cast and the frontend symmetric.ts helpers read and write this exact format.

Tier 2 — Asymmetric (RSA-OAEP-4096)​

Used for key distribution: encrypting a symmetric key so that only one specific recipient can unwrap it.

  • Algorithm: RSA-OAEP with a 4096-bit keypair. Each user holds one keypair.
  • Public keys are stored in the user_keychain_values table (type public_key) and on the users record.
  • Private keys never leave the browser in plaintext; the server stores only the passkey-encrypted blob.

Tier 3 — Hybrid (AES key + RSA wrapping)​

Used for large data that only the server needs to decrypt — for example, ext-app secrets that the server must later retrieve. A random AES key encrypts the payload; that AES key is then wrapped with an RSA public key.

Wire format serialised by HybridCryptoValue:

base64(encryptedAesKey) | base64(symmetricPayload)

The AsHybridCryptoValueCast Eloquent cast and the backend HybridCrypto class both expect this layout.


SaltProvider​

App\Services\Encryption\SaltProvider is the single source for all server-side salts. The frontend fetches salts through the connection bootstrap payload (the crypto_salt block inside hawki-core config) — there is no dedicated salt HTTP endpoint.

SaltType enum​

Enum caseEnv variablePurpose
USERDATAUSERDATA_ENCRYPTION_SALTKey derivation for user keychain data
INVITATIONINVITATION_SALTInvitation payload encryption
AIAI_CRYPTO_SALTAI message key derivation (room AI layer)
PASSKEYPASSKEY_SALTPasskey/backup derivation
BACKUPBACKUP_SALTPasskey backup storage

Call SaltProvider::getSalt(SaltType $type) or the typed convenience methods (getSaltForUserDataEncryption(), getSaltForPasskey(), etc.) to retrieve a Salt value object.

Configuration​

Salts are read from config/encryption.php, which maps each SaltType value to the corresponding env variable:

'salts' => [
'USERDATA_ENCRYPTION_SALT' => env('USERDATA_ENCRYPTION_SALT', null),
'INVITATION_SALT' => env('INVITATION_SALT', null),
'AI_CRYPTO_SALT' => env('AI_CRYPTO_SALT', null),
'PASSKEY_SALT' => env('PASSKEY_SALT', null),
'BACKUP_SALT' => env('BACKUP_SALT', null),
]
Production salts must be pre-configured

When a salt env variable is missing or empty, SaltProvider falls back to hash('sha256', $appKey . 'semi_static_salt' . hash('sha256', $type->value)). This fallback is development-only — it is tied to APP_KEY and predictable to anyone who obtains it.

All five salt variables (USERDATA_ENCRYPTION_SALT, INVITATION_SALT, AI_CRYPTO_SALT, PASSKEY_SALT, BACKUP_SALT) must be set to random, independent values in _docker_production/.env before the very first php artisan migrate run. Re-seeding salts after migration will invalidate every existing encrypted record in the database.


Model Attribute Casts​

HAWKI provides three Eloquent casts that transparently encrypt and decrypt model attributes using the wire formats above:

Cast classHandlesNotes
AsSymmetricCryptoValueCastSymmetricCryptoValueUsed on UserKeychainValue.value, Message.content, etc.
AsAsymmetricPublicKeyCastRSA public key stringsNormalises PEM / base64 public key formats
AsHybridCryptoValueCastHybridCryptoValueUsed on ext-app secret columns

All three are consumed by AbstractCastableObject-derived value objects. See 100-Architecture/200-Utilities.md for the full cast system description.


SSRF Protection​

All outbound HTTP calls from the backend must use Http::getSsrfSafe() (registered by SsrfSafeGetterMacro). The macro validates every URL and every redirect hop against a public-IP allowlist, blocking requests to internal subnets. Full documentation is in Infrastructure → index.


Passkey UX Settings​

Two config/hawki.php keys under security.passkey control passkey UX on the frontend. Set them via .env:

Env variableDefaultEffect
APP_SECURITY_PASSKEY_ALLOW_PASTEtrueAllow pasting into the passkey input field
APP_SECURITY_PASSKEY_CHAR_LIMITATION(none)Maximum character count for the passkey input

CSRF Tokens​

CSRF tokens are automatically included in every response as the X-HAWKI-CSRF-TOKEN header. The Svelte frontend reads this header on each response and refreshes its stored token, so the standard Laravel CSRF protection applies to all state-mutating requests without any manual token management by contributors.