Initial Upload
Some checks failed
CI / Lint & Typecheck (push) Has been cancelled
CI / Test (routes) (push) Has been cancelled
CI / Test (security) (push) Has been cancelled
CI / Test (services) (push) Has been cancelled
CI / Test (unit) (push) Has been cancelled
CI / Test (integration) (push) Has been cancelled
CI / Test Coverage (push) Has been cancelled
CI / Build (push) Has been cancelled

This commit is contained in:
2025-12-17 12:32:50 +13:00
commit 3015f48118
471 changed files with 141143 additions and 0 deletions

View File

@@ -0,0 +1,203 @@
/**
* Push Notification Payload Encryption/Decryption
*
* Uses AES-256-GCM (Authenticated Encryption with Associated Data)
* for secure push notification payloads.
*
* Security properties:
* - Confidentiality: Only the intended device can read the payload
* - Integrity: Tampered payloads are detected and rejected
* - Per-device keys: Each device has a unique derived key
*/
import crypto from 'react-native-quick-crypto';
import * as SecureStore from 'expo-secure-store';
import type { EncryptedPushPayload, NotificationEventType } from '@tracearr/shared';
// Storage key for the per-device encryption secret
const DEVICE_SECRET_KEY = 'tracearr_device_secret';
// AES-256-GCM parameters
const ALGORITHM = 'aes-256-gcm';
const KEY_LENGTH = 32; // 256 bits
const IV_LENGTH = 12; // 96 bits (recommended for GCM)
const SALT_LENGTH = 16; // 128 bits (NIST recommended minimum)
const AUTH_TAG_LENGTH = 16; // 128 bits
/**
* Decrypted push payload structure
*/
export interface DecryptedPayload {
type: NotificationEventType | 'data_sync';
title?: string;
body?: string;
data?: Record<string, unknown>;
[key: string]: unknown;
}
/**
* Generate or retrieve the device-specific encryption secret
* This secret is used along with the server's key to derive the encryption key
*/
export async function getDeviceSecret(): Promise<string> {
let secret = await SecureStore.getItemAsync(DEVICE_SECRET_KEY);
if (!secret) {
// Generate a new 32-byte random secret
const randomBytes = crypto.randomBytes(32);
secret = Buffer.from(randomBytes).toString('base64');
await SecureStore.setItemAsync(DEVICE_SECRET_KEY, secret);
console.log('[Crypto] Generated new device secret');
}
return secret;
}
/**
* Derive the encryption key using PBKDF2
*
* The key is derived from:
* - Device secret (stored locally)
* - Server key identifier (sent with encrypted payload)
*
* This ensures each device has a unique key.
*/
export async function deriveKey(
deviceSecret: string,
salt: Buffer
): Promise<Buffer> {
// Use PBKDF2 with 100,000 iterations for key derivation
// Note: react-native-quick-crypto uses uppercase hash names
const key = crypto.pbkdf2Sync(
deviceSecret,
salt,
100000,
KEY_LENGTH,
'SHA-256'
);
return Buffer.from(key);
}
/**
* Decrypt an encrypted push notification payload
*/
export async function decryptPushPayload(
encrypted: EncryptedPushPayload
): Promise<DecryptedPayload> {
// Validate version
if (encrypted.v !== 1) {
throw new Error(`Unsupported encryption version: ${encrypted.v}`);
}
try {
// Get device secret
const deviceSecret = await getDeviceSecret();
// Decode Base64 values
const iv = Buffer.from(encrypted.iv, 'base64');
const salt = Buffer.from(encrypted.salt, 'base64');
const ciphertext = Buffer.from(encrypted.ct, 'base64');
const authTag = Buffer.from(encrypted.tag, 'base64');
// Validate lengths
if (iv.length !== IV_LENGTH) {
throw new Error(`Invalid IV length: ${iv.length}`);
}
if (salt.length !== SALT_LENGTH) {
throw new Error(`Invalid salt length: ${salt.length}`);
}
if (authTag.length !== AUTH_TAG_LENGTH) {
throw new Error(`Invalid auth tag length: ${authTag.length}`);
}
// Derive key using the separate salt from payload
const key = await deriveKey(deviceSecret, salt);
// Create decipher
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) as ReturnType<typeof crypto.createDecipheriv> & {
setAuthTag: (tag: Buffer) => void;
};
decipher.setAuthTag(authTag);
// Decrypt
const decrypted = Buffer.concat([
decipher.update(ciphertext),
decipher.final(),
]);
// Parse JSON
const payload = JSON.parse(decrypted.toString('utf8')) as DecryptedPayload;
return payload;
} catch (error) {
console.error('[Crypto] Decryption failed:', error);
throw new Error('Failed to decrypt push payload');
}
}
/**
* Encrypt data for testing purposes (client-side encryption)
* This is primarily used for development/testing.
* In production, encryption happens on the server.
*/
export async function encryptData(
data: Record<string, unknown>
): Promise<EncryptedPushPayload> {
try {
const deviceSecret = await getDeviceSecret();
// Generate random IV and salt separately (NIST: salt should be at least 128 bits)
const iv = crypto.randomBytes(IV_LENGTH);
const salt = crypto.randomBytes(SALT_LENGTH);
// Derive key using proper random salt (wrap in Buffer for type compatibility)
const key = await deriveKey(deviceSecret, Buffer.from(salt));
// Create cipher
const cipher = crypto.createCipheriv(ALGORITHM, key, iv) as ReturnType<typeof crypto.createCipheriv> & {
getAuthTag: () => Buffer;
};
// Encrypt
const plaintext = Buffer.from(JSON.stringify(data), 'utf8');
const encrypted = Buffer.concat([
cipher.update(plaintext),
cipher.final(),
]);
// Get auth tag
const authTag = cipher.getAuthTag();
return {
v: 1,
iv: Buffer.from(iv).toString('base64'),
salt: Buffer.from(salt).toString('base64'),
ct: encrypted.toString('base64'),
tag: authTag.toString('base64'),
};
} catch (error) {
console.error('[Crypto] Encryption failed:', error);
throw new Error('Failed to encrypt data');
}
}
/**
* Check if encryption is available on this device
*/
export function isEncryptionAvailable(): boolean {
try {
// Test if crypto functions are available
const testBytes = crypto.randomBytes(16);
return testBytes.length === 16;
} catch {
return false;
}
}
/**
* Clear the device secret (on logout/unpair)
*/
export async function clearDeviceSecret(): Promise<void> {
await SecureStore.deleteItemAsync(DEVICE_SECRET_KEY);
console.log('[Crypto] Cleared device secret');
}