Skip to content

[Cross-Layer] Field-Level Encryption and PII Redaction Across Contract Events, Backend Storage, and Frontend Reveal Paths #1385

Description

@ogazboiz

Contributor coordination and questions: https://t.me/+DOylgFv1jyJlNzM0

Task Classification: Security hardening of a data-protection defect spanning storage, telemetry, and on-chain event surfaces
Affected Layers: contracts, backend, frontend, root
Affected Paths: contracts/remittance_nft/, backend/src/services/, backend/migrations/, frontend/app/, .github/workflows/ci.yml, docker-compose.yml
Severity: High
Estimated Window: 48-72 hours

Technical Context & Monorepo Integration Failure

Recipient personally identifiable information (email, phone, legal name) is persisted as plaintext columns in PostgreSQL, echoed into structured logs by the indexer and default-checker services, and passed as an event data value by the remittance_nft contract at mint. Soroban events are durable and publicly readable through Horizon and getEvents; once a contact string is published as event data it cannot be revoked. A fix confined to one layer does not close the exposure: encrypting the database leaves the on-chain copy intact, redacting logs leaves both the chain and the database columns readable, and rewriting the contract to store a commitment forces the backend to hold the preimage under key management and the frontend to mask and gate reveal. The four layers must change together.

Concrete drift walkthrough:

  1. An operator calls remittance_nft::mint(env, to, recipient_email, amount) with a plaintext contact string.
  2. The contract writes recipient_email into persistent storage and calls env.events().publish((symbol_short!("minted"),), recipient_email).
  3. backend/src/services/eventIndexer.ts polls getEvents, inserts the value into recipients.recipient_email, and logs indexed remit for <email> at level info.
  4. Horizon serves that event permanently; any caller reading the contract event stream recovers the address-to-contact mapping with no expiry.
  5. On a missed repayment, backend/src/services/defaultChecker.ts emits an SSE frame carrying the recipient contact to the browser, where proxies and the network tab capture it.
  6. Plaintext PII now exists in three durable surfaces (chain, database, logs) with no revocation path.

Core Component Invariants & Code Paths

Smart Contract Infrastructure. In the remittance_nft crate, replace the plaintext recipient argument with recipient_commitment: BytesN<32>, computed off-chain as SHA-256(preimage || salt). Signature becomes mint(env, to: Address, recipient_commitment: BytesN<32>, amount: i128). Persist the commitment in a persistent map keyed RecipientCommitment(token_id) with TTL bumps on access. Emit only the commitment: env.events().publish((symbol_short!("minted"), token_id), recipient_commitment). Add NftError::CommitmentMalformed and NftError::CommitmentMissing; reject any input whose length is not 32 bytes. Keep overflow-checks = true. Invariant: no entrypoint accepts or emits a value from which recipient contact data is derivable without the off-chain salt and preimage.

Backend/API Layer. Add backend/src/services/piiCrypto.ts implementing envelope encryption. encryptField(plaintext) returns { ciphertext, gcm_nonce, dek_wrapped, dek_kek_id } using AES-256-GCM with a per-record data key wrapped by a key-encryption key resolved from KMS. decryptField(record_id, field, actor, reason) unwraps the DEK, decrypts, and writes one audit row before returning. Migration backend/migrations/NNNN_pii_field_encryption.sql converts recipients contact columns to recipient_email_ct, recipient_phone_ct, recipient_name_ct (bytea), adds dek_wrapped bytea, dek_kek_id text, gcm_nonce bytea, backfills, then drops the plaintext columns; the down migration reverses the schema only and cannot restore plaintext. Add table pii_access_log(id, actor, record_id, field, reason, request_id, created_at). eventIndexer.ts stores recipient_commitment and joins the preimage from the encrypted store, never logging it. Configure pino redact paths for recipient_email, recipient_phone, recipient_name, and authorization; defaultChecker.ts SSE frames carry record_id plus a masked value only. Env vars: PII_KEK_ID, PII_KMS_ENDPOINT, PII_KEK_REGION, LOG_REDACTION=strict; none prefixed NEXT_PUBLIC_. Invariant: no path serializes decrypted PII to a logger, SSE frame, or event, and every decrypt produces a pii_access_log row.

Frontend Client. Next.js App Router renders masked values by default through a maskRecipient utility (m***@d***.com, +xx...NN). Reveal-on-authorization uses route handler frontend/app/api/recipients/[id]/reveal/route.ts, which checks the session role via httpOnly cookie and calls the backend decrypt endpoint for a single field. A TanStack Query useReveal mutation runs with staleTime: 0 and gcTime: 0; the revealed value lives in component state and clears on unmount. The SSE consumer renders record_id plus masked text and never expects plaintext. Invariant: plaintext PII exists client-side only transiently after an explicit authorized reveal, never in the query cache, localStorage, or a URL.

Root Configuration & Orchestration. In .github/workflows/ci.yml, add a step in the backend job that runs a secret and PII-in-logs scan asserting logger output contains none of the configured PII fields, and extend migration-check (service postgres:16) to assert the plaintext columns are absent after migration. docker-compose.yml injects KMS and KEK credentials through environment, not baked images. deploy-staging.yml gates promotion on the redaction test. Invariant: CI fails if any layer reintroduces a plaintext PII column, log field, or event value.

Verification & Acceptance Criteria

  • Root-initiated build passes: cargo build via contracts/Cargo.toml, npm run build in backend/, npm run build in frontend/.
  • Contract unit tests prove mint rejects a non-32-byte commitment with NftError::CommitmentMalformed and that the emitted event payload is BytesN<32> only.
  • Backend unit test covers a piiCrypto encrypt/decrypt round trip and DEK unwrap.
  • Backend integration test against the postgres:16 service confirms recipients contact columns are bytea, no plaintext column remains, and a decrypt writes a pii_access_log row.
  • Log-capture test over a mint plus default flow asserts zero plaintext PII in pino output.
  • Frontend test:e2e (Playwright) proves default view is masked, reveal requires an authorized session, and the revealed value is absent from the query cache and DOM after unmount.
  • End-to-end container test via docker-compose confirms commitment-only propagation from contract event through eventIndexer.ts and SSE to the frontend.
  • PR attachments: a getEvents dump showing the commitment payload, psql \d recipients output, a redacted log excerpt, and a pii_access_log sample row.

Suggested Execution Path

Phase 1 - Contracts (12-16h). Rewrite remittance_nft mint, storage map, and event topics to use recipient_commitment; add NftError variants and length validation; write cargo test. Exit check: contract tests green and an event dump shows a commitment-only payload.

Phase 2 - Backend crypto and migration (16-22h). Implement piiCrypto.ts envelope encryption, the bytea migration with backfill, pii_access_log, indexer and default-checker changes, and pino redaction. Exit check: migration-check passes on postgres:16 and the log-capture test reports zero PII.

Phase 3 - Frontend (12-18h). Add the maskRecipient utility, the reveal route handler and useReveal mutation with no caching, and the masked SSE consumer. Exit check: Playwright reveal-authorization and no-cache tests pass.

Phase 4 - Root (8-16h). Add the CI leak and redaction steps, wire KMS environment into docker-compose.yml, gate deploy-staging.yml, and land the end-to-end container test. Exit check: full CI green and commitment-only propagation confirmed end to end.

Metadata

Metadata

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial Campaign | FWC26Campaign: Official Campaign | FWC26architectureArchitectural changesbackendIssues related to backend developmentcontractsIssues related to smart contractsfrontendIssues related to frontend developmentrustPull requests that update rust codesecuritySecurity related issues

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions