Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/api/src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export async function migrate(): Promise<void> {
user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
legal_name TEXT NOT NULL,
ein TEXT,
ein_hash TEXT,
bond_id BIGINT UNIQUE NOT NULL,
stellar_address TEXT NOT NULL,
stellar_secret_encrypted TEXT,
Expand Down Expand Up @@ -318,6 +319,15 @@ export async function migrate(): Promise<void> {
VALUES (1, 'initial key version')
ON CONFLICT (key_version) DO NOTHING;

-- EIN plaintext is scheduled for AES-GCM encryption; ein_hash supports PII-safe equality lookup.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
ALTER TABLE importers ADD COLUMN IF NOT EXISTS ein_hash TEXT;
UPDATE importers
SET ein_hash = encode(sha256(ein::bytea), 'hex')
WHERE ein IS NOT NULL AND ein_hash IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_importers_ein_hash
ON importers(ein_hash) WHERE ein_hash IS NOT NULL;
Comment on lines +328 to +329

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid bricking migrations on duplicate legacy EINs

On any existing deployment that already has two importer rows with the same non-null ein (which the previous schema allowed, since importers.ein was not unique and registration only checked user_id), the backfill will assign the same ein_hash to both rows and this unique index creation will abort migrate(), preventing the service from starting. If global EIN uniqueness is required, the migration needs to detect/dedupe legacy conflicts first or defer the unique constraint until data has been cleaned.

Useful? React with 👍 / 👎.


-- EIN is now stored as AES-256-GCM JSON; migrate existing plain text at app layer
ALTER TABLE importers ADD COLUMN IF NOT EXISTS ein_encrypted TEXT;
ALTER TABLE importers ADD COLUMN IF NOT EXISTS ein_key_version INTEGER REFERENCES field_encryption_key_versions(key_version);
Expand Down
12 changes: 9 additions & 3 deletions apps/api/src/routes/importers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Router, type Request, type Response } from "express";
import { createHash } from "node:crypto";
import { Keypair } from "@stellar/stellar-sdk";
import { z } from "zod";
import { pool } from "../db.js";
Expand All @@ -21,6 +22,10 @@ const CreateImporterSchema = z.object({
initialRequiredCollateral: z.string().regex(/^\d+$/),
});

function hashEin(ein?: string): string | null {
return ein ? createHash("sha256").update(ein).digest("hex") : null;
}
Comment on lines +25 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use a keyed digest for EIN lookup

For the PII-safe lookup path, storing a plain SHA-256 of an EIN is reversible by offline enumeration if a database snapshot or read-only access leaks, because EINs have a small, fixed format. This same digest is persisted for new registrations and backfilled in the migration, so once plaintext EINs are encrypted or removed the lookup column still exposes the identifier; use a secret-keyed HMAC/pepper for deterministic equality instead.

Useful? React with 👍 / 👎.


importersRouter.post("/", async (req: Request, res: Response) => {
const user = (req as AuthedRequest).user;
if (user.role !== "importer") {
Expand All @@ -34,6 +39,7 @@ importersRouter.post("/", async (req: Request, res: Response) => {
return;
}
const { legalName, ein, bondId, initialRequiredCollateral } = parse.data;
const einHash = hashEin(ein);

const ofacClear = await screenImporterEntity(legalName, ein);
if (!ofacClear) {
Expand Down Expand Up @@ -71,10 +77,10 @@ importersRouter.post("/", async (req: Request, res: Response) => {
}

const inserted = await pool.query(
`INSERT INTO importers (user_id, legal_name, ein, bond_id, stellar_address, stellar_secret_encrypted)
VALUES ($1, $2, $3, $4, $5, $6)
`INSERT INTO importers (user_id, legal_name, ein, ein_hash, bond_id, stellar_address, stellar_secret_encrypted)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, legal_name, ein, bond_id, stellar_address, created_at`,
[user.id, legalName, ein ?? null, bondId, kp.publicKey(), kp.secret()],
[user.id, legalName, ein ?? null, einHash, bondId, kp.publicKey(), kp.secret()],
);
const importer = inserted.rows[0]!;

Expand Down
10 changes: 7 additions & 3 deletions scripts/admin.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Command } from "commander";
import { createHash } from "node:crypto";
import { pool } from "../apps/api/src/db.js";
import { contractClient, platformKeypair } from "../apps/api/src/stellar.js";
import { hashPassword } from "../apps/api/src/auth.js";
Expand Down Expand Up @@ -32,12 +33,15 @@ program
const kp = Keypair.random();
const bondId = Math.floor(Math.random() * 1000000);
const initialRequired = 0n;
const einHash = options.ein
? createHash("sha256").update(options.ein).digest("hex")
: null;

const inserted = await pool.query(
`INSERT INTO importers (user_id, legal_name, ein, bond_id, stellar_address, stellar_secret_encrypted)
VALUES ($1, $2, $3, $4, $5, $6)
`INSERT INTO importers (user_id, legal_name, ein, ein_hash, bond_id, stellar_address, stellar_secret_encrypted)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
[userId, options.company, options.ein ?? null, bondId, kp.publicKey(), kp.secret()]
[userId, options.company, options.ein ?? null, einHash, bondId, kp.publicKey(), kp.secret()]
);
const importerId = inserted.rows[0].id;

Expand Down