Skip to content

Commit 557987e

Browse files
authored
feat: implement PII encryption with AES-256-GCM and key rotation (#447)
* feat: implement PII encryption with AES-256-GCM and key rotation - Added field-level AES-256-GCM encryption for PII fields (email, name, phoneNumber, address, etc.) - Key management with automatic 90-day rotation via HKDF key derivation - Blind indexing for searchable encrypted fields using HMAC-SHA256 with trigram tokenization - PII access audit logging integrated with existing tamper-evident audit chain - Data masking for non-production environments (email, phone, general PII) - Compliance reporting with encryption status, key management, and access summaries - Upgraded SecretsVault from base64 obfuscation to AES-256-GCM encryption at rest * feat: implement batch subscription operations for bulk management Add batch create from CSV/JSON, batch update with filtering, batch cancel with reason collection, and batch charge for manual billing runs. - contracts/batch: Added CancelReason enum, BatchFilter struct, enhanced result types with skipped_operations tracking - app/services/batchTransactionService.ts: Full rewrite with 4 operation types, CSV parsers, chunked processing, idempotent retry with backoff, result export (CSV/JSON), history persistence, per-item status tracking - app/stores/batchStore.ts: Zustand store with draft management, CSV loading per operation type, execute/retry, export helpers - app/screens/BatchOperationsScreen.tsx: Full UI with operation selector, CSV input, update params/filter modals, cancel reason picker, progress bar, per-item results with status coloring, export buttons, retry failed, history modal - src/screens/ImportScreen.tsx: Added batch operations shortcut banner - src/navigation: Added BatchOperations route to SettingsStack - Updated useBatchTransactions hook and batchStore tests for new API Edge cases handled: partial batch failure, idempotent retry of failed items, large batch memory management via chunked processing (default 50, max 200) * feat: implement contract-level subscription tax calculation and remittance - Add tax types to contracts/types (TaxJurisdiction, TaxExemption, TaxRemittanceReport, NexusRegion, TaxRateChangeEvent) - Add 12 new StorageKey variants for tax state persistence - Enhance invoice contract with full tax system: - Multi-jurisdiction tax lookup (set_tax_jurisdiction/get_tax_jurisdiction_by_location) - Tax exemption lifecycle (register/validate/revoke) with certificate validation - Digital goods classification and taxability rules - Mid-cycle tax rate change proration (calculate_prorated_tax) - Nexus determination with economic thresholds per jurisdiction - Tax remittance report generation and submission - Add 10 contract test cases covering invoice generation, tax exemption, proration, nexus, and remittance - Update frontend types with DigitalGoodsCategory, TaxRemittanceReport, NexusRegion, MidCycleTaxChange * feat: implement subscription tax calculation and remittance system - Add TaxType, TaxJurisdiction, TaxRateEntry, CustomerTaxStatus, DigitalGoodsClass, TaxRemittanceLineItem types - Add new StorageKey variants: TaxRateEntry, CustomerTaxStatus, TaxRemittanceLine, DigitalGoodsClass, TaxRateChangeLogByJdx - Fix StorageKey variant name length limits (ProxyPrevImplCount, TaxRemittanceReportByJdx) - Enhance invoice contract with multi-jurisdiction tax lookup with fallback chain - Add tax-exempt customer handling with certificate validation and expiry checks - Implement mid-cycle tax rate change proration for existing subscriptions - Add reverse-charge flagging and nexus threshold determination - Add per-invoice/per-jurisdiction remittance line tracking - Add 11 new contract functions and 10 comprehensive test cases - Update subscription contract with new generate_invoice signature - Create TaxService backend with built-in jurisdiction rates, caching, exemption validation, nexus checks, digital goods rules, and remittance report generation - Create taxTypes.ts with full TypeScript type definitions - Extend invoiceStore with tax state management (rates, exemptions, remittance lines, reports, mid-cycle changes) - Add backend TaxService tests covering lookup, exemption, calculation, nexus, and reporting - Update Invoice TypeScript types with TaxJurisdiction, CustomerTaxStatus, TaxRemittanceReport, MidCycleTaxChange and helper utilities - Extend Invoice interface with taxJurisdiction, isTaxExempt, and reverseCharge fields * fix: add @react-native/jest-preset dependency for jest-expo compatibility
1 parent b1a6128 commit 557987e

11 files changed

Lines changed: 1359 additions & 60 deletions

File tree

backend/secrets/SecretsVault.ts

Lines changed: 77 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
11
import AsyncStorage from '@react-native-async-storage/async-storage';
2+
import {
3+
createCipheriv,
4+
createDecipheriv,
5+
createHmac,
6+
randomBytes,
7+
timingSafeEqual,
8+
} from 'crypto';
29

310
// ---------------------------------------------------------------------------
411
// Types
@@ -12,16 +19,16 @@ export interface SecretMetadata {
1219
version: number;
1320
createdAt: number;
1421
rotatedAt: number | null;
15-
/** Rotation interval in ms; null = no auto-rotation */
1622
rotationIntervalMs: number | null;
17-
/** Whether this secret has been soft-deleted */
1823
deleted: boolean;
1924
}
2025

2126
export interface SecretEntry {
2227
meta: SecretMetadata;
23-
/** Obfuscated value stored in AsyncStorage (base64) */
24-
value: string;
28+
ciphertext: string;
29+
iv: string;
30+
authTag: string;
31+
algorithm: 'aes-256-gcm';
2532
}
2633

2734
export interface AuditEvent {
@@ -48,18 +55,69 @@ const VAULT_PREFIX = '@subtrackr:secrets:';
4855
const AUDIT_KEY = '@subtrackr:secrets:audit';
4956
const INDEX_KEY = '@subtrackr:secrets:index';
5057
const MAX_AUDIT_EVENTS = 1000;
58+
const ALGORITHM = 'aes-256-gcm';
59+
const IV_LENGTH = 16;
60+
const TAG_LENGTH = 16;
61+
const VAULT_MASTER_KEY_KEY = '@subtrackr:secrets:vault_key';
62+
const HMAC_ALGORITHM = 'sha256';
5163

5264
// ---------------------------------------------------------------------------
53-
// Minimal obfuscation (base64) — keeps values out of plain-text logs.
54-
// For production-grade encryption, replace with expo-crypto AES-GCM.
65+
// AES-256-GCM encryption for secrets at rest
5566
// ---------------------------------------------------------------------------
5667

57-
function encode(value: string): string {
58-
return Buffer.from(value, 'utf8').toString('base64');
68+
async function getOrCreateMasterKey(): Promise<Buffer> {
69+
const existing = await AsyncStorage.getItem(VAULT_MASTER_KEY_KEY);
70+
if (existing) return Buffer.from(existing, 'base64');
71+
const key = randomBytes(32);
72+
await AsyncStorage.setItem(VAULT_MASTER_KEY_KEY, key.toString('base64'));
73+
return key;
5974
}
6075

61-
function decode(encoded: string): string {
62-
return Buffer.from(encoded, 'base64').toString('utf8');
76+
function deriveVaultKey(masterKey: Buffer): { encKey: Buffer; hmacKey: Buffer } {
77+
const hmac1 = createHmac(HMAC_ALGORITHM, masterKey);
78+
hmac1.update('vault-encryption');
79+
const encKey = hmac1.digest();
80+
81+
const hmac2 = createHmac(HMAC_ALGORITHM, masterKey);
82+
hmac2.update('vault-integrity');
83+
const hmacKey = hmac2.digest();
84+
85+
return { encKey, hmacKey };
86+
}
87+
88+
async function encrypt(value: string): Promise<{ ciphertext: string; iv: string; authTag: string }> {
89+
const masterKey = await getOrCreateMasterKey();
90+
const { encKey } = deriveVaultKey(masterKey);
91+
const iv = randomBytes(IV_LENGTH);
92+
93+
const cipher = createCipheriv(ALGORITHM, encKey, iv, { authTagLength: TAG_LENGTH });
94+
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
95+
const authTag = cipher.getAuthTag();
96+
97+
return {
98+
ciphertext: encrypted.toString('base64'),
99+
iv: iv.toString('base64'),
100+
authTag: authTag.toString('base64'),
101+
};
102+
}
103+
104+
async function decrypt(
105+
ciphertext: string,
106+
iv: string,
107+
authTag: string
108+
): Promise<string> {
109+
const masterKey = await getOrCreateMasterKey();
110+
const { encKey } = deriveVaultKey(masterKey);
111+
112+
const ivBuf = Buffer.from(iv, 'base64');
113+
const authTagBuf = Buffer.from(authTag, 'base64');
114+
const ciphertextBuf = Buffer.from(ciphertext, 'base64');
115+
116+
const decipher = createDecipheriv(ALGORITHM, encKey, ivBuf, { authTagLength: TAG_LENGTH });
117+
decipher.setAuthTag(authTagBuf);
118+
const decrypted = Buffer.concat([decipher.update(ciphertextBuf), decipher.final()]);
119+
120+
return decrypted.toString('utf8');
63121
}
64122

65123
function storageKey(key: string, env: Environment): string {
@@ -98,7 +156,14 @@ export class SecretsVault {
98156
deleted: false,
99157
};
100158

101-
const entry: SecretEntry = { meta, value: encode(value) };
159+
const { ciphertext, iv, authTag } = await encrypt(value);
160+
const entry: SecretEntry = {
161+
meta,
162+
ciphertext,
163+
iv,
164+
authTag,
165+
algorithm: ALGORITHM,
166+
};
102167
await AsyncStorage.setItem(storageKey(key, env), JSON.stringify(entry));
103168
await this._updateIndex(meta);
104169
await this._audit({ action: version > 1 ? 'rotate' : 'set', key, env, success: true });
@@ -119,7 +184,7 @@ export class SecretsVault {
119184
return null;
120185
}
121186
await this._audit({ action: 'get', key, env: resolvedEnv, success: true });
122-
return decode(entry.value);
187+
return decrypt(entry.ciphertext, entry.iv, entry.authTag);
123188
}
124189

125190
// ── Rotation ──────────────────────────────────────────────────────────────
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import {
2+
encryptField,
3+
decryptField,
4+
generateBlindIndexTokens,
5+
searchBlindIndex,
6+
maskField,
7+
maskObject,
8+
generateKey,
9+
generateEncryptionKey,
10+
isPiiField,
11+
getPiiFields,
12+
reEncryptField,
13+
} from '../encryption';
14+
15+
describe('Encryption Service', () => {
16+
const masterKey = generateKey();
17+
18+
describe('generateKey', () => {
19+
it('generates a 32-byte key', () => {
20+
const key = generateKey();
21+
expect(key).toBeInstanceOf(Buffer);
22+
expect(key.length).toBe(32);
23+
});
24+
25+
it('generates unique keys each time', () => {
26+
const key1 = generateKey();
27+
const key2 = generateKey();
28+
expect(key1.toString('hex')).not.toBe(key2.toString('hex'));
29+
});
30+
});
31+
32+
describe('generateEncryptionKey', () => {
33+
it('creates a key with id, version, and expiry', () => {
34+
const key = generateEncryptionKey(masterKey, 1);
35+
expect(key.id).toBeTruthy();
36+
expect(key.version).toBe(1);
37+
expect(key.key.length).toBe(32);
38+
expect(key.createdAt).toBeLessThanOrEqual(Date.now());
39+
expect(key.expiresAt).toBeGreaterThan(Date.now());
40+
});
41+
42+
it('generates deterministic keys from the same master key and version', () => {
43+
const key1 = generateEncryptionKey(masterKey, 1);
44+
const key2 = generateEncryptionKey(masterKey, 1);
45+
expect(key1.key.toString('hex')).toBe(key2.key.toString('hex'));
46+
});
47+
48+
it('generates different keys for different versions', () => {
49+
const key1 = generateEncryptionKey(masterKey, 1);
50+
const key2 = generateEncryptionKey(masterKey, 2);
51+
expect(key1.key.toString('hex')).not.toBe(key2.key.toString('hex'));
52+
});
53+
});
54+
55+
describe('encryptField / decryptField', () => {
56+
let key: ReturnType<typeof generateEncryptionKey>;
57+
58+
beforeEach(() => {
59+
key = generateEncryptionKey(generateKey(), 1);
60+
});
61+
62+
it('encrypts and decrypts a plaintext value', () => {
63+
const plaintext = 'user@example.com';
64+
const encrypted = encryptField(plaintext, key);
65+
expect(encrypted.ciphertext).toBeTruthy();
66+
expect(encrypted.iv).toBeTruthy();
67+
expect(encrypted.authTag).toBeTruthy();
68+
expect(encrypted.algorithm).toBe('aes-256-gcm');
69+
70+
const decrypted = decryptField(encrypted, key);
71+
expect(decrypted.value).toBe(plaintext);
72+
});
73+
74+
it('handles empty strings', () => {
75+
const encrypted = encryptField('', key);
76+
expect(encrypted.ciphertext).toBe('');
77+
const decrypted = decryptField(encrypted, key);
78+
expect(decrypted.value).toBe('');
79+
});
80+
81+
it('produces different ciphertext for the same plaintext (random IV)', () => {
82+
const encrypted1 = encryptField('test', key);
83+
const encrypted2 = encryptField('test', key);
84+
expect(encrypted1.ciphertext).not.toBe(encrypted2.ciphertext);
85+
});
86+
87+
it('throws when decrypting with wrong key', () => {
88+
const encrypted = encryptField('secret', key);
89+
const wrongKey = generateEncryptionKey(generateKey(), 99);
90+
expect(() => decryptField(encrypted, wrongKey)).toThrow();
91+
});
92+
93+
it('includes keyId in encrypted field', () => {
94+
const encrypted = encryptField('data', key);
95+
expect(encrypted.keyId).toBe(key.id);
96+
});
97+
});
98+
99+
describe('reEncryptField', () => {
100+
it('re-encrypts with a new key', () => {
101+
const oldKey = generateEncryptionKey(generateKey(), 1);
102+
const newKey = generateEncryptionKey(generateKey(), 2);
103+
const encrypted = encryptField('sensitive data', oldKey);
104+
const reEncrypted = reEncryptField(encrypted, newKey, oldKey);
105+
expect(reEncrypted.keyId).toBe(newKey.id);
106+
const decrypted = decryptField(reEncrypted, newKey);
107+
expect(decrypted.value).toBe('sensitive data');
108+
});
109+
});
110+
111+
describe('blind indexing', () => {
112+
const indexKey = generateKey();
113+
114+
it('generates blind index tokens for a value', () => {
115+
const idx = generateBlindIndexTokens('email', 'user@example.com', indexKey);
116+
expect(idx.field).toBe('email');
117+
expect(idx.tokens.length).toBeGreaterThan(0);
118+
});
119+
120+
it('returns empty tokens for empty value', () => {
121+
const idx = generateBlindIndexTokens('email', '', indexKey);
122+
expect(idx.tokens).toHaveLength(0);
123+
});
124+
125+
it('searchBlindIndex finds matching value', () => {
126+
const idx = generateBlindIndexTokens('name', 'John Doe', indexKey);
127+
expect(searchBlindIndex('John Doe', idx, indexKey)).toBe(true);
128+
expect(searchBlindIndex('john', idx, indexKey)).toBe(true);
129+
expect(searchBlindIndex('doe', idx, indexKey)).toBe(true);
130+
});
131+
132+
it('searchBlindIndex does not match unrelated value', () => {
133+
const idx = generateBlindIndexTokens('name', 'John Doe', indexKey);
134+
expect(searchBlindIndex('Jane', idx, indexKey)).toBe(false);
135+
expect(searchBlindIndex('xyz', idx, indexKey)).toBe(false);
136+
});
137+
138+
it('blind index is deterministic for same inputs', () => {
139+
const idx1 = generateBlindIndexTokens('email', 'user@example.com', indexKey);
140+
const idx2 = generateBlindIndexTokens('email', 'user@example.com', indexKey);
141+
expect(idx1.tokens).toEqual(idx2.tokens);
142+
});
143+
144+
it('different index keys produce different tokens', () => {
145+
const key1 = generateKey();
146+
const key2 = generateKey();
147+
const idx1 = generateBlindIndexTokens('email', 'test@test.com', key1);
148+
const idx2 = generateBlindIndexTokens('email', 'test@test.com', key2);
149+
expect(idx1.tokens).not.toEqual(idx2.tokens);
150+
});
151+
});
152+
153+
describe('isPiiField', () => {
154+
it('identifies known PII fields', () => {
155+
expect(isPiiField('email')).toBe(true);
156+
expect(isPiiField('name')).toBe(true);
157+
expect(isPiiField('phoneNumber')).toBe(true);
158+
expect(isPiiField('address')).toBe(true);
159+
});
160+
161+
it('returns false for non-PII fields', () => {
162+
expect(isPiiField('id')).toBe(false);
163+
expect(isPiiField('price')).toBe(false);
164+
expect(isPiiField('category')).toBe(false);
165+
});
166+
});
167+
168+
describe('getPiiFields', () => {
169+
it('returns all known PII fields', () => {
170+
const fields = getPiiFields();
171+
expect(fields).toContain('email');
172+
expect(fields).toContain('name');
173+
expect(fields).toContain('phoneNumber');
174+
});
175+
});
176+
177+
describe('maskField', () => {
178+
const originalEnv = process.env['APP_ENV'];
179+
180+
afterEach(() => {
181+
process.env['APP_ENV'] = originalEnv;
182+
});
183+
184+
it('masks email in non-production', () => {
185+
process.env['APP_ENV'] = 'development';
186+
const masked = maskField('john.doe@example.com', 'email');
187+
expect(masked).not.toBe('john.doe@example.com');
188+
expect(masked).toContain('@');
189+
});
190+
191+
it('does not mask in production', () => {
192+
process.env['APP_ENV'] = 'production';
193+
const masked = maskField('john.doe@example.com', 'email');
194+
expect(masked).toBe('john.doe@example.com');
195+
});
196+
197+
it('masks phone number showing last 4 digits', () => {
198+
process.env['APP_ENV'] = 'development';
199+
const masked = maskField('555-123-4567', 'phoneNumber');
200+
expect(masked).toContain('4567');
201+
expect(masked).not.toContain('123');
202+
});
203+
204+
it('masks short strings completely', () => {
205+
process.env['APP_ENV'] = 'development';
206+
const masked = maskField('ab', 'name');
207+
expect(masked).toBe('**');
208+
});
209+
210+
it('handles empty string', () => {
211+
process.env['APP_ENV'] = 'development';
212+
expect(maskField('', 'name')).toBe('');
213+
});
214+
});
215+
216+
describe('maskObject', () => {
217+
const originalEnv = process.env['APP_ENV'];
218+
219+
afterEach(() => {
220+
process.env['APP_ENV'] = originalEnv;
221+
});
222+
223+
it('masks PII fields in an object', () => {
224+
process.env['APP_ENV'] = 'development';
225+
const obj = { email: 'test@test.com', name: 'John', price: 10, id: '123' };
226+
const masked = maskObject(obj);
227+
expect(masked.email).not.toBe('test@test.com');
228+
expect(masked.name).not.toBe('John');
229+
expect(masked.price).toBe(10);
230+
expect(masked.id).toBe('123');
231+
});
232+
233+
it('does not mask in production', () => {
234+
process.env['APP_ENV'] = 'production';
235+
const obj = { email: 'test@test.com', name: 'John' };
236+
const masked = maskObject(obj);
237+
expect(masked.email).toBe('test@test.com');
238+
expect(masked.name).toBe('John');
239+
});
240+
});
241+
});

0 commit comments

Comments
 (0)