Skip to content
Open
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
73 changes: 61 additions & 12 deletions src/infra/device-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,63 @@ import fs from "node:fs";
import path from "node:path";
import { STATE_DIR } from "../config/paths.js";

// Security: Function to safely set file permissions for sensitive data
function setSecureFilePermissions(filePath: string): void {
try {
fs.chmodSync(filePath, 0o600);
} catch (error) {
const errorMessage = `Failed to set secure permissions (0600) on device identity file: ${error instanceof Error ? error.message : String(error)}`;
console.error(`[SECURITY] ${errorMessage}`);
throw new Error(`Security violation: ${errorMessage}`);
}
}

// Security: Write file with secure permissions from creation (prevents race conditions)
function writeSecureFile(filePath: string, data: string): void {
// Set restrictive umask before file operations to ensure secure default permissions
const oldUmask = process.umask(0o077); // Only owner can access newly created files

try {
// Write file with explicit secure mode
fs.writeFileSync(filePath, data, { mode: 0o600 });
// Verify permissions were actually set correctly
verifySecurePermissions(filePath);
} finally {
// Always restore original umask
process.umask(oldUmask);
}
}

// Security: Verify file has secure permissions
function verifySecurePermissions(filePath: string): void {
try {
// Use lstatSync to not follow symlinks (prevents symlink attacks)
const stats = fs.lstatSync(filePath);

// Security check: ensure it's not a symlink
if (stats.isSymbolicLink()) {
const errorMessage = `Security violation: device identity file is a symbolic link: ${filePath}`;
console.error(`[SECURITY] ${errorMessage}`);
throw new Error(errorMessage);
}

const permissions = stats.mode & 0o777;

// File should be readable/writable by owner only (0600)
if (permissions !== 0o600) {
const errorMessage = `Insecure permissions detected on device identity file: ${permissions.toString(8)} (expected 0600). Fix with: chmod 600 "${filePath}"`;
console.error(`[SECURITY] ${errorMessage}`);
throw new Error(`Security violation: ${errorMessage}`);
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
// File doesn't exist, which is expected in some cases
return;
}
throw error;
}
}

export type DeviceIdentity = {
deviceId: string;
publicKeyPem: string;
Expand Down Expand Up @@ -64,6 +121,8 @@ function generateIdentity(): DeviceIdentity {
export function loadOrCreateDeviceIdentity(filePath: string = DEFAULT_FILE): DeviceIdentity {
try {
if (fs.existsSync(filePath)) {
// Security: Verify existing identity file has secure permissions
verifySecurePermissions(filePath);
const raw = fs.readFileSync(filePath, "utf8");
const parsed = JSON.parse(raw) as StoredIdentity;
if (
Expand All @@ -78,12 +137,7 @@ export function loadOrCreateDeviceIdentity(filePath: string = DEFAULT_FILE): Dev
...parsed,
deviceId: derivedId,
};
fs.writeFileSync(filePath, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 });
try {
fs.chmodSync(filePath, 0o600);
} catch {
// best-effort
}
writeSecureFile(filePath, `${JSON.stringify(updated, null, 2)}\n`);
return {
deviceId: derivedId,
publicKeyPem: parsed.publicKeyPem,
Expand All @@ -110,12 +164,7 @@ export function loadOrCreateDeviceIdentity(filePath: string = DEFAULT_FILE): Dev
privateKeyPem: identity.privateKeyPem,
createdAtMs: Date.now(),
};
fs.writeFileSync(filePath, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 });
try {
fs.chmodSync(filePath, 0o600);
} catch {
// best-effort
}
writeSecureFile(filePath, `${JSON.stringify(stored, null, 2)}\n`);
return identity;
}

Expand Down