critical priority high complexity infrastructure pending infrastructure specialist Tier 3

Acceptance Criteria

FieldEncryptionUtils exposes getOrgKey(String orgId, String keyVersion) returning the raw key bytes as a Uint8List; the key is fetched from Supabase Vault via a Supabase RPC function and is never transmitted as a plaintext column value
Keys are cached in a private in-memory Map<String, Uint8List> keyed by '{orgId}:{keyVersion}'; a second call with the same orgId and keyVersion returns the cached value without a network request
If the EncryptedFieldStub.keyReference from task-004 carries a keyVersion that differs from the currently cached version for that org, the cache entry is invalidated and re-fetched before decryption proceeds
decryptField(EncryptedFieldStub stub, Uint8List ciphertext) returns a DecryptedField containing the plaintext String; it internally calls getOrgKey with the stub's keyReference and keyVersion
encryptField(String orgId, String plaintext) fetches the latest key version for the org, encrypts the plaintext, and returns an EncryptedPayload containing ciphertext, keyReference, and keyVersion
The in-memory cache is cleared on user sign-out — the utility must subscribe to or be notified of auth state changes to wipe the cache
If Supabase Vault RPC returns an error (key not found, permission denied), getOrgKey throws a typed KeyRetrievalException with an error code; it must not return null or a zero-byte array
The utility is registered as a Riverpod Provider and is destroyed (cache wiped) when the provider is disposed (e.g., on sign-out scope disposal)
All cryptographic operations use AES-256-GCM; no custom or deprecated cipher modes are permitted
Key bytes are never written to disk, logs, crash reports, or Flutter's debug console — assert with a lint comment

Technical Requirements

frameworks
Flutter
Riverpod
Dart (pointycastle or flutter_secure_storage for crypto primitives)
apis
Supabase Vault RPC (e.g., supabase.rpc('get_org_encryption_key', params: {...}))
Supabase Auth (for sign-out detection)
data models
EncryptedFieldStub (from task-004)
DecryptedField
EncryptedPayload
KeyRetrievalException
performance requirements
Cache hit for key retrieval must return in under 1ms (pure in-memory lookup)
Cache miss (network fetch) must complete within 2 seconds on a standard mobile connection
Decryption of a single field must complete within 50ms including key retrieval from cache
security requirements
AES-256-GCM must be used for all field encryption/decryption — no ECB, CBC, or other modes
Each encryptField call must generate a unique random 96-bit (12-byte) GCM nonce; the nonce is stored alongside the ciphertext in EncryptedPayload
Key bytes must be stored only in Dart heap memory (Map in a Riverpod Provider) — never in SharedPreferences, flutter_secure_storage, or any disk-backed store
The Supabase RPC for key retrieval must require an authenticated session and enforce org-scoped access via RLS or function-level permission check
On any authentication state change to 'signed out', the entire in-memory key cache must be zeroed and cleared before the Dart GC runs (use fillRange with 0 on the Uint8List before removing from map)
KeyRetrievalException must not include the raw error message from Supabase in user-facing error text — log it at debug level only with --verbose equivalent flag

Execution Context

Execution Tier
Tier 3

Tier 3 - 413 tasks

Can start after Tier 2 completes

Implementation Notes

Use Dart's `pointycastle` package for AES-256-GCM; it is pure Dart and does not require platform channels. Implement the cache as a `final Map _keyCache = {}` inside the Riverpod Provider's state class, not as a static field, so it is automatically scoped to the provider's lifetime. For sign-out detection, inject a `Stream` from Supabase Auth and listen inside the Provider's build method; on AuthChangeEvent.signedOut, iterate the cache and zero each Uint8List with `key.fillRange(0, key.length, 0)` then call `_keyCache.clear()`. For the Supabase Vault RPC: the database function should accept org_id and key_version, verify the caller's JWT claims match the org_id via `auth.uid()`, and return the key bytes as a base64 string — decode to Uint8List on the client.

Never log the decoded bytes; only log that a key was fetched for '{orgId}:{keyVersion}' at debug level. Implement a KeyVersion value type to prevent orgId/keyVersion argument order mix-ups.

Testing Requirements

Unit tests with a mocked Supabase RPC client: (1) first call fetches from RPC and caches; (2) second call with same orgId+keyVersion returns cached value without RPC call; (3) call with new keyVersion invalidates cache and re-fetches; (4) RPC error throws KeyRetrievalException; (5) decryptField produces expected plaintext from known ciphertext+key test vector; (6) encryptField produces ciphertext that decryptField can reverse; (7) signing out clears the cache (verify RPC is called again on next getOrgKey after sign-out). Security test: assert that the Uint8List is zeroed (all bytes == 0) after cache clear. Integration test against a real Supabase Vault instance (in a sandboxed test project): verify cross-org key access is blocked. flutter_test is sufficient for all unit tests; no widget test required.

Component
Field Encryption Utilities
infrastructure high
Epic Risks (3)
high impact medium prob security

Blindeforbundet's encryption key retrieval mechanism may not be finalised at implementation time, or session key availability via Supabase RLS may be inconsistent, causing decryption failures that expose masked placeholders to users and degrade the experience.

Mitigation & Contingency

Mitigation: Agree with Blindeforbundet on key storage and retrieval contract before implementation starts. Prototype key retrieval in a spike against the staging Supabase instance and validate the full decrypt/verify cycle with real test data before committing to the implementation.

Contingency: Implement a fallback that shows a 'field temporarily unavailable' state with a retry affordance. Log decryption failures server-side for audit. Escalate to Blindeforbundet stakeholders to unblock key management before the service tier epic begins.

medium impact medium prob technical

NHF contacts may belong to up to 5 chapters, each governed by separate RLS policies. A coordinator's chapter scope may not cover all affiliations, causing partial profile reads or silent data omissions that are difficult to detect in tests.

Mitigation & Contingency

Mitigation: Map all RLS policy combinations for multi-chapter contacts early. Write integration tests that create contacts with 5 affiliations and query them from coordinators with varying chapter scopes. Use Supabase's RLS test utilities to verify row visibility per role.

Contingency: Add an explicit 'affiliation partially visible' state in the repository response model so the UI can communicate scope limitations to the coordinator rather than silently showing incomplete data.

low impact medium prob scope

Organisation-specific validation rules (e.g., NHF chapter limit, Blindeforbundet encrypted field edit flow) may expand in scope during implementation as edge cases are discovered, causing the validator to grow beyond the planned complexity.

Mitigation & Contingency

Mitigation: Define the complete validation rule set with product and org stakeholders before coding begins. Document each rule with its source organisation and acceptance test. Use a rule registry pattern so new rules can be added without modifying core validator logic.

Contingency: Timebox validator enhancements to 2 hours per additional rule. Defer non-blocking rules to a follow-on maintenance task rather than blocking the epic delivery.