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:
- An operator calls
remittance_nft::mint(env, to, recipient_email, amount) with a plaintext contact string.
- The contract writes
recipient_email into persistent storage and calls env.events().publish((symbol_short!("minted"),), recipient_email).
backend/src/services/eventIndexer.ts polls getEvents, inserts the value into recipients.recipient_email, and logs indexed remit for <email> at level info.
- Horizon serves that event permanently; any caller reading the contract event stream recovers the address-to-contact mapping with no expiry.
- 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.
- 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
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.
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.ymlSeverity: 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_nftcontract at mint. Soroban events are durable and publicly readable through Horizon andgetEvents; 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:
remittance_nft::mint(env, to, recipient_email, amount)with a plaintext contact string.recipient_emailinto persistent storage and callsenv.events().publish((symbol_short!("minted"),), recipient_email).backend/src/services/eventIndexer.tspollsgetEvents, inserts the value intorecipients.recipient_email, and logsindexed remit for <email>at levelinfo.backend/src/services/defaultChecker.tsemits an SSE frame carrying the recipient contact to the browser, where proxies and the network tab capture it.Core Component Invariants & Code Paths
Smart Contract Infrastructure. In the
remittance_nftcrate, replace the plaintext recipient argument withrecipient_commitment: BytesN<32>, computed off-chain asSHA-256(preimage || salt). Signature becomesmint(env, to: Address, recipient_commitment: BytesN<32>, amount: i128). Persist the commitment in a persistent map keyedRecipientCommitment(token_id)with TTL bumps on access. Emit only the commitment:env.events().publish((symbol_short!("minted"), token_id), recipient_commitment). AddNftError::CommitmentMalformedandNftError::CommitmentMissing; reject any input whose length is not 32 bytes. Keepoverflow-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.tsimplementing 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. Migrationbackend/migrations/NNNN_pii_field_encryption.sqlconvertsrecipientscontact columns torecipient_email_ct,recipient_phone_ct,recipient_name_ct(bytea), addsdek_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 tablepii_access_log(id, actor, record_id, field, reason, request_id, created_at).eventIndexer.tsstoresrecipient_commitmentand joins the preimage from the encrypted store, never logging it. Configure pinoredactpaths forrecipient_email,recipient_phone,recipient_name, andauthorization;defaultChecker.tsSSE frames carryrecord_idplus a masked value only. Env vars:PII_KEK_ID,PII_KMS_ENDPOINT,PII_KEK_REGION,LOG_REDACTION=strict; none prefixedNEXT_PUBLIC_. Invariant: no path serializes decrypted PII to a logger, SSE frame, or event, and every decrypt produces apii_access_logrow.Frontend Client. Next.js App Router renders masked values by default through a
maskRecipientutility (m***@d***.com,+xx...NN). Reveal-on-authorization uses route handlerfrontend/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 QueryuseRevealmutation runs withstaleTime: 0andgcTime: 0; the revealed value lives in component state and clears on unmount. The SSE consumer rendersrecord_idplus 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 thebackendjob that runs a secret and PII-in-logs scan asserting logger output contains none of the configured PII fields, and extendmigration-check(servicepostgres:16) to assert the plaintext columns are absent after migration.docker-compose.ymlinjects KMS and KEK credentials through environment, not baked images.deploy-staging.ymlgates promotion on the redaction test. Invariant: CI fails if any layer reintroduces a plaintext PII column, log field, or event value.Verification & Acceptance Criteria
cargo buildviacontracts/Cargo.toml,npm run buildinbackend/,npm run buildinfrontend/.mintrejects a non-32-byte commitment withNftError::CommitmentMalformedand that the emitted event payload isBytesN<32>only.piiCryptoencrypt/decrypt round trip and DEK unwrap.postgres:16service confirmsrecipientscontact columns arebytea, no plaintext column remains, and a decrypt writes apii_access_logrow.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.docker-composeconfirms commitment-only propagation from contract event througheventIndexer.tsand SSE to the frontend.getEventsdump showing the commitment payload,psql \d recipientsoutput, a redacted log excerpt, and apii_access_logsample row.Suggested Execution Path
Phase 1 - Contracts (12-16h). Rewrite
remittance_nftmint, storage map, and event topics to userecipient_commitment; addNftErrorvariants and length validation; writecargo 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.tsenvelope encryption, thebyteamigration with backfill,pii_access_log, indexer and default-checker changes, and pino redaction. Exit check:migration-checkpasses onpostgres:16and the log-capture test reports zero PII.Phase 3 - Frontend (12-18h). Add the
maskRecipientutility, the reveal route handler anduseRevealmutation 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, gatedeploy-staging.yml, and land the end-to-end container test. Exit check: full CI green and commitment-only propagation confirmed end to end.