@@ -333,14 +333,14 @@ router.get(
|
||||
if (credential.key) {
|
||||
(output as any).key = credential.key;
|
||||
}
|
||||
if (credential.privateKey) {
|
||||
(output as any).privateKey = credential.privateKey;
|
||||
if (credential.private_key) {
|
||||
(output as any).privateKey = credential.private_key;
|
||||
}
|
||||
if (credential.publicKey) {
|
||||
(output as any).publicKey = credential.publicKey;
|
||||
if (credential.public_key) {
|
||||
(output as any).publicKey = credential.public_key;
|
||||
}
|
||||
if (credential.keyPassword) {
|
||||
(output as any).keyPassword = credential.keyPassword;
|
||||
if (credential.key_password) {
|
||||
(output as any).keyPassword = credential.key_password;
|
||||
}
|
||||
|
||||
res.json(output);
|
||||
@@ -605,15 +605,19 @@ router.post(
|
||||
}
|
||||
|
||||
try {
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, parseInt(credentialId)),
|
||||
eq(sshCredentials.userId, userId),
|
||||
const credentials = await SimpleDBOps.select(
|
||||
db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, parseInt(credentialId)),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
),
|
||||
);
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (credentials.length === 0) {
|
||||
return res.status(404).json({ error: "Credential not found" });
|
||||
@@ -626,7 +630,7 @@ router.post(
|
||||
.set({
|
||||
credentialId: parseInt(credentialId),
|
||||
username: credential.username,
|
||||
authType: credential.authType,
|
||||
authType: credential.auth_type || credential.authType,
|
||||
password: null,
|
||||
key: null,
|
||||
keyPassword: null,
|
||||
@@ -715,15 +719,15 @@ function formatCredentialOutput(credential: any): any {
|
||||
? credential.tags.split(",").filter(Boolean)
|
||||
: []
|
||||
: [],
|
||||
authType: credential.authType,
|
||||
authType: credential.authType || credential.auth_type,
|
||||
username: credential.username,
|
||||
publicKey: credential.publicKey,
|
||||
keyType: credential.keyType,
|
||||
detectedKeyType: credential.detectedKeyType,
|
||||
usageCount: credential.usageCount || 0,
|
||||
lastUsed: credential.lastUsed,
|
||||
createdAt: credential.createdAt,
|
||||
updatedAt: credential.updatedAt,
|
||||
publicKey: credential.public_key || credential.publicKey,
|
||||
keyType: credential.key_type || credential.keyType,
|
||||
detectedKeyType: credential.detected_key_type || credential.detectedKeyType,
|
||||
usageCount: credential.usage_count || credential.usageCount || 0,
|
||||
lastUsed: credential.last_used || credential.lastUsed,
|
||||
createdAt: credential.created_at || credential.createdAt,
|
||||
updatedAt: credential.updated_at || credential.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1551,14 +1555,15 @@ router.post(
|
||||
if (hostCredential && hostCredential.length > 0) {
|
||||
const cred = hostCredential[0];
|
||||
|
||||
hostConfig.authType = cred.authType;
|
||||
hostConfig.authType = cred.auth_type || cred.authType;
|
||||
hostConfig.username = cred.username;
|
||||
|
||||
if (cred.authType === "password") {
|
||||
if ((cred.auth_type || cred.authType) === "password") {
|
||||
hostConfig.password = cred.password;
|
||||
} else if (cred.authType === "key") {
|
||||
hostConfig.privateKey = cred.privateKey || cred.key;
|
||||
hostConfig.keyPassword = cred.keyPassword;
|
||||
} else if ((cred.auth_type || cred.authType) === "key") {
|
||||
hostConfig.privateKey =
|
||||
cred.private_key || cred.privateKey || cred.key;
|
||||
hostConfig.keyPassword = cred.key_password || cred.keyPassword;
|
||||
}
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
|
||||
@@ -472,7 +472,6 @@ router.put(
|
||||
}
|
||||
sshDataObj.password = null;
|
||||
} else {
|
||||
// For credential auth
|
||||
sshDataObj.password = null;
|
||||
sshDataObj.key = null;
|
||||
sshDataObj.keyPassword = null;
|
||||
@@ -670,6 +669,83 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
// Route: Export SSH host with decrypted credentials (requires data access)
|
||||
// GET /ssh/db/host/:id/export
|
||||
router.get(
|
||||
"/db/host/:id/export",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const hostId = req.params.id;
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId) {
|
||||
return res.status(400).json({ error: "Invalid userId or hostId" });
|
||||
}
|
||||
|
||||
try {
|
||||
const hosts = await SimpleDBOps.select(
|
||||
db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(
|
||||
and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)),
|
||||
),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (hosts.length === 0) {
|
||||
return res.status(404).json({ error: "SSH host not found" });
|
||||
}
|
||||
|
||||
const host = hosts[0];
|
||||
|
||||
const resolvedHost = (await resolveHostCredentials(host)) || host;
|
||||
|
||||
const exportData = {
|
||||
name: resolvedHost.name,
|
||||
ip: resolvedHost.ip,
|
||||
port: resolvedHost.port,
|
||||
username: resolvedHost.username,
|
||||
authType: resolvedHost.authType,
|
||||
password: resolvedHost.password || null,
|
||||
key: resolvedHost.key || null,
|
||||
keyPassword: resolvedHost.keyPassword || null,
|
||||
keyType: resolvedHost.keyType || null,
|
||||
folder: resolvedHost.folder,
|
||||
tags:
|
||||
typeof resolvedHost.tags === "string"
|
||||
? resolvedHost.tags.split(",").filter(Boolean)
|
||||
: resolvedHost.tags || [],
|
||||
pin: !!resolvedHost.pin,
|
||||
enableTerminal: !!resolvedHost.enableTerminal,
|
||||
enableTunnel: !!resolvedHost.enableTunnel,
|
||||
enableFileManager: !!resolvedHost.enableFileManager,
|
||||
defaultPath: resolvedHost.defaultPath,
|
||||
tunnelConnections: resolvedHost.tunnelConnections
|
||||
? JSON.parse(resolvedHost.tunnelConnections)
|
||||
: [],
|
||||
};
|
||||
|
||||
sshLogger.success("Host exported with decrypted credentials", {
|
||||
operation: "host_export",
|
||||
hostId: parseInt(hostId),
|
||||
userId,
|
||||
});
|
||||
|
||||
res.json(exportData);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to export SSH host", err, {
|
||||
operation: "host_export",
|
||||
hostId: parseInt(hostId),
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to export SSH host" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Route: Delete SSH host by id (requires JWT)
|
||||
// DELETE /ssh/host/:id
|
||||
router.delete(
|
||||
@@ -1136,26 +1212,30 @@ router.delete(
|
||||
async function resolveHostCredentials(host: any): Promise<any> {
|
||||
try {
|
||||
if (host.credentialId && host.userId) {
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, host.credentialId),
|
||||
eq(sshCredentials.userId, host.userId),
|
||||
const credentials = await SimpleDBOps.select(
|
||||
db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, host.credentialId),
|
||||
eq(sshCredentials.userId, host.userId),
|
||||
),
|
||||
),
|
||||
);
|
||||
"ssh_credentials",
|
||||
host.userId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
return {
|
||||
...host,
|
||||
username: credential.username,
|
||||
authType: credential.authType,
|
||||
authType: credential.auth_type || credential.authType,
|
||||
password: credential.password,
|
||||
key: credential.key,
|
||||
keyPassword: credential.keyPassword,
|
||||
keyType: credential.keyType,
|
||||
keyPassword: credential.key_password || credential.keyPassword,
|
||||
keyType: credential.key_type || credential.keyType,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1214,7 +1294,6 @@ router.put(
|
||||
)
|
||||
.returning();
|
||||
|
||||
// Trigger database save after folder rename
|
||||
DatabaseSaveTrigger.triggerSave("folder_rename");
|
||||
|
||||
res.json({
|
||||
|
||||
@@ -1317,6 +1317,43 @@ router.post("/complete-reset", async (req, res) => {
|
||||
.set({ password_hash })
|
||||
.where(eq(users.username, username));
|
||||
|
||||
try {
|
||||
await authManager.registerUser(userId, newPassword);
|
||||
authManager.logoutUser(userId);
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
totp_enabled: false,
|
||||
totp_secret: null,
|
||||
totp_backup_codes: null,
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
authLogger.warn(
|
||||
`Password reset completed for user: ${username}. Existing encrypted data is now inaccessible and will need to be re-entered.`,
|
||||
{
|
||||
operation: "password_reset_data_inaccessible",
|
||||
userId,
|
||||
username,
|
||||
},
|
||||
);
|
||||
} catch (encryptionError) {
|
||||
authLogger.error(
|
||||
"Failed to re-encrypt user data after password reset",
|
||||
encryptionError,
|
||||
{
|
||||
operation: "password_reset_encryption_failed",
|
||||
userId,
|
||||
username,
|
||||
},
|
||||
);
|
||||
return res.status(500).json({
|
||||
error:
|
||||
"Password reset completed but user data encryption failed. Please contact administrator.",
|
||||
});
|
||||
}
|
||||
|
||||
authLogger.success(`Password successfully reset for user: ${username}`);
|
||||
|
||||
db.$client
|
||||
@@ -1495,6 +1532,22 @@ router.post("/totp/verify-login", async (req, res) => {
|
||||
"totp_secret",
|
||||
);
|
||||
|
||||
if (!totpSecret) {
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
totp_enabled: false,
|
||||
totp_secret: null,
|
||||
totp_backup_codes: null,
|
||||
})
|
||||
.where(eq(users.id, userRecord.id));
|
||||
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"TOTP has been disabled due to password reset. Please set up TOTP again.",
|
||||
});
|
||||
}
|
||||
|
||||
const verified = speakeasy.totp.verify({
|
||||
secret: totpSecret,
|
||||
encoding: "base32",
|
||||
|
||||
@@ -202,9 +202,10 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
const credential = credentials[0];
|
||||
resolvedCredentials = {
|
||||
password: credential.password,
|
||||
sshKey: credential.privateKey || credential.key,
|
||||
keyPassword: credential.keyPassword,
|
||||
authType: credential.authType,
|
||||
sshKey:
|
||||
credential.private_key || credential.privateKey || credential.key,
|
||||
keyPassword: credential.key_password || credential.keyPassword,
|
||||
authType: credential.auth_type || credential.authType,
|
||||
};
|
||||
} else {
|
||||
fileLogger.warn(`No credentials found for host ${hostId}`, {
|
||||
|
||||
@@ -280,10 +280,8 @@ const app = express();
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
// Allow requests with no origin (like mobile apps or curl requests)
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
// Allow localhost and 127.0.0.1 for development
|
||||
const allowedOrigins = [
|
||||
"http://localhost:5173",
|
||||
"http://localhost:3000",
|
||||
@@ -291,22 +289,18 @@ app.use(
|
||||
"http://127.0.0.1:3000",
|
||||
];
|
||||
|
||||
// Allow any HTTPS origin (production deployments)
|
||||
if (origin.startsWith("https://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
// Allow any HTTP origin for self-hosted scenarios
|
||||
if (origin.startsWith("http://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
// Check against allowed development origins
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
// Reject other origins
|
||||
callback(new Error("Not allowed by CORS"));
|
||||
},
|
||||
credentials: true,
|
||||
@@ -322,7 +316,6 @@ app.use(
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
|
||||
// Add authentication middleware - Linus principle: eliminate special cases
|
||||
app.use(authManager.createAuthMiddleware());
|
||||
|
||||
const hostStatuses: Map<number, StatusEntry> = new Map();
|
||||
@@ -363,7 +356,6 @@ async function fetchHostById(
|
||||
userId: string,
|
||||
): Promise<SSHHostWithCredentials | undefined> {
|
||||
try {
|
||||
// Check if user data is unlocked before attempting to fetch
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
statsLogger.debug("User data locked - cannot fetch host", {
|
||||
operation: "fetchHostById_data_locked",
|
||||
@@ -446,7 +438,7 @@ async function resolveHostCredentials(
|
||||
const credential = credentials[0];
|
||||
baseHost.credentialId = credential.id;
|
||||
baseHost.username = credential.username;
|
||||
baseHost.authType = credential.authType;
|
||||
baseHost.authType = credential.auth_type || credential.authType;
|
||||
|
||||
if (credential.password) {
|
||||
baseHost.password = credential.password;
|
||||
@@ -454,11 +446,12 @@ async function resolveHostCredentials(
|
||||
if (credential.key) {
|
||||
baseHost.key = credential.key;
|
||||
}
|
||||
if (credential.keyPassword) {
|
||||
baseHost.keyPassword = credential.keyPassword;
|
||||
if (credential.key_password || credential.keyPassword) {
|
||||
baseHost.keyPassword =
|
||||
credential.key_password || credential.keyPassword;
|
||||
}
|
||||
if (credential.keyType) {
|
||||
baseHost.keyType = credential.keyType;
|
||||
if (credential.key_type || credential.keyType) {
|
||||
baseHost.keyType = credential.key_type || credential.keyType;
|
||||
}
|
||||
} else {
|
||||
addLegacyCredentials(baseHost, host);
|
||||
@@ -750,6 +743,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||
let diskPercent: number | null = null;
|
||||
let usedHuman: string | null = null;
|
||||
let totalHuman: string | null = null;
|
||||
let availableHuman: string | null = null;
|
||||
try {
|
||||
const [diskOutHuman, diskOutBytes] = await Promise.all([
|
||||
execCommand(client, "df -h -P / | tail -n +2"),
|
||||
@@ -773,6 +767,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||
if (humanParts.length >= 6 && bytesParts.length >= 6) {
|
||||
totalHuman = humanParts[1] || null;
|
||||
usedHuman = humanParts[2] || null;
|
||||
availableHuman = humanParts[3] || null;
|
||||
|
||||
const totalBytes = Number(bytesParts[1]);
|
||||
const usedBytes = Number(bytesParts[2]);
|
||||
@@ -796,6 +791,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||
diskPercent = null;
|
||||
usedHuman = null;
|
||||
totalHuman = null;
|
||||
availableHuman = null;
|
||||
}
|
||||
|
||||
const result = {
|
||||
@@ -805,7 +801,12 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
|
||||
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null,
|
||||
},
|
||||
disk: { percent: toFixedNum(diskPercent, 0), usedHuman, totalHuman },
|
||||
disk: {
|
||||
percent: toFixedNum(diskPercent, 0),
|
||||
usedHuman,
|
||||
totalHuman,
|
||||
availableHuman,
|
||||
},
|
||||
};
|
||||
|
||||
metricsCache.set(host.id, result);
|
||||
@@ -887,7 +888,6 @@ async function pollStatusesOnce(userId?: string): Promise<void> {
|
||||
app.get("/status", async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
|
||||
// Check if user data is unlocked
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
@@ -909,7 +909,6 @@ app.get("/status/:id", validateHostId, async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const userId = (req as any).userId;
|
||||
|
||||
// Check if user data is unlocked
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
@@ -941,7 +940,6 @@ app.get("/status/:id", validateHostId, async (req, res) => {
|
||||
app.post("/refresh", async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
|
||||
// Check if user data is unlocked
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
@@ -957,7 +955,6 @@ app.get("/metrics/:id", validateHostId, async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const userId = (req as any).userId;
|
||||
|
||||
// Check if user data is unlocked
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
|
||||
@@ -239,7 +239,16 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
} else if (data.startsWith("\x1b")) {
|
||||
sshStream.write(data);
|
||||
} else {
|
||||
sshStream.write(Buffer.from(data, "utf8"));
|
||||
try {
|
||||
sshStream.write(Buffer.from(data, "utf8"));
|
||||
} catch (error) {
|
||||
sshLogger.error("Error writing input to SSH stream", error, {
|
||||
operation: "ssh_input_encoding",
|
||||
userId,
|
||||
dataLength: data.length,
|
||||
});
|
||||
sshStream.write(Buffer.from(data, "latin1"));
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -367,10 +376,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
const credential = credentials[0];
|
||||
resolvedCredentials = {
|
||||
password: credential.password,
|
||||
key: credential.privateKey || credential.key,
|
||||
keyPassword: credential.keyPassword,
|
||||
keyType: credential.keyType,
|
||||
authType: credential.authType,
|
||||
key:
|
||||
credential.private_key || credential.privateKey || credential.key,
|
||||
keyPassword: credential.key_password || credential.keyPassword,
|
||||
keyType: credential.key_type || credential.keyType,
|
||||
authType: credential.auth_type || credential.authType,
|
||||
};
|
||||
} else {
|
||||
sshLogger.warn(`No credentials found for host ${id}`, {
|
||||
@@ -427,7 +437,22 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
sshStream = stream;
|
||||
|
||||
stream.on("data", (data: Buffer) => {
|
||||
ws.send(JSON.stringify({ type: "data", data: data.toString() }));
|
||||
try {
|
||||
const utf8String = data.toString("utf-8");
|
||||
ws.send(JSON.stringify({ type: "data", data: utf8String }));
|
||||
} catch (error) {
|
||||
sshLogger.error("Error encoding terminal data", error, {
|
||||
operation: "terminal_data_encoding",
|
||||
hostId: id,
|
||||
dataLength: data.length,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "data",
|
||||
data: data.toString("latin1"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", () => {
|
||||
|
||||
@@ -512,10 +512,11 @@ async function connectSSHTunnel(
|
||||
const credential = credentials[0];
|
||||
resolvedSourceCredentials = {
|
||||
password: credential.password,
|
||||
sshKey: credential.privateKey || credential.key,
|
||||
keyPassword: credential.keyPassword,
|
||||
keyType: credential.keyType,
|
||||
authMethod: credential.authType,
|
||||
sshKey:
|
||||
credential.private_key || credential.privateKey || credential.key,
|
||||
keyPassword: credential.key_password || credential.keyPassword,
|
||||
keyType: credential.key_type || credential.keyType,
|
||||
authMethod: credential.auth_type || credential.authType,
|
||||
};
|
||||
} else {
|
||||
}
|
||||
@@ -591,10 +592,11 @@ async function connectSSHTunnel(
|
||||
const credential = credentials[0];
|
||||
resolvedEndpointCredentials = {
|
||||
password: credential.password,
|
||||
sshKey: credential.privateKey || credential.key,
|
||||
keyPassword: credential.keyPassword,
|
||||
keyType: credential.keyType,
|
||||
authMethod: credential.authType,
|
||||
sshKey:
|
||||
credential.private_key || credential.privateKey || credential.key,
|
||||
keyPassword: credential.key_password || credential.keyPassword,
|
||||
keyType: credential.key_type || credential.keyType,
|
||||
authMethod: credential.auth_type || credential.authType,
|
||||
};
|
||||
} else {
|
||||
tunnelLogger.warn("No endpoint credentials found in database", {
|
||||
@@ -1025,10 +1027,11 @@ async function killRemoteTunnelByMarker(
|
||||
const credential = credentials[0];
|
||||
resolvedSourceCredentials = {
|
||||
password: credential.password,
|
||||
sshKey: credential.privateKey || credential.key,
|
||||
keyPassword: credential.keyPassword,
|
||||
keyType: credential.keyType,
|
||||
authMethod: credential.authType,
|
||||
sshKey:
|
||||
credential.private_key || credential.privateKey || credential.key,
|
||||
keyPassword: credential.key_password || credential.keyPassword,
|
||||
keyType: credential.key_type || credential.keyType,
|
||||
authMethod: credential.auth_type || credential.authType,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -147,7 +147,7 @@ class DataCrypto {
|
||||
if (needsUpdate) {
|
||||
const updateQuery = `
|
||||
UPDATE ssh_credentials
|
||||
SET password = ?, key = ?, key_password = ?, private_key = ?, updated_at = CURRENT_TIMESTAMP
|
||||
SET password = ?, key = ?, key_password = ?, private_key = ?, public_key = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`;
|
||||
db.prepare(updateQuery).run(
|
||||
@@ -155,6 +155,7 @@ class DataCrypto {
|
||||
updatedRecord.key || null,
|
||||
updatedRecord.key_password || null,
|
||||
updatedRecord.private_key || null,
|
||||
updatedRecord.public_key || null,
|
||||
record.id,
|
||||
);
|
||||
|
||||
@@ -216,6 +217,165 @@ class DataCrypto {
|
||||
return this.userCrypto.getUserDataKey(userId);
|
||||
}
|
||||
|
||||
static async reencryptUserDataAfterPasswordReset(
|
||||
userId: string,
|
||||
newUserDataKey: Buffer,
|
||||
db: any,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
reencryptedTables: string[];
|
||||
reencryptedFieldsCount: number;
|
||||
errors: string[];
|
||||
}> {
|
||||
const result = {
|
||||
success: false,
|
||||
reencryptedTables: [] as string[],
|
||||
reencryptedFieldsCount: 0,
|
||||
errors: [] as string[],
|
||||
};
|
||||
|
||||
try {
|
||||
const tablesToReencrypt = [
|
||||
{ table: "ssh_data", fields: ["password", "key", "key_password"] },
|
||||
{
|
||||
table: "ssh_credentials",
|
||||
fields: [
|
||||
"password",
|
||||
"private_key",
|
||||
"key_password",
|
||||
"key",
|
||||
"public_key",
|
||||
],
|
||||
},
|
||||
{
|
||||
table: "users",
|
||||
fields: [
|
||||
"client_secret",
|
||||
"totp_secret",
|
||||
"totp_backup_codes",
|
||||
"oidc_identifier",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const { table, fields } of tablesToReencrypt) {
|
||||
try {
|
||||
const records = db
|
||||
.prepare(`SELECT * FROM ${table} WHERE user_id = ?`)
|
||||
.all(userId);
|
||||
|
||||
for (const record of records) {
|
||||
const recordId = record.id.toString();
|
||||
let needsUpdate = false;
|
||||
const updatedRecord = { ...record };
|
||||
|
||||
for (const fieldName of fields) {
|
||||
const fieldValue = record[fieldName];
|
||||
|
||||
if (fieldValue && fieldValue.trim() !== "") {
|
||||
try {
|
||||
const reencryptedValue = FieldCrypto.encryptField(
|
||||
fieldValue,
|
||||
newUserDataKey,
|
||||
recordId,
|
||||
fieldName,
|
||||
);
|
||||
|
||||
updatedRecord[fieldName] = reencryptedValue;
|
||||
needsUpdate = true;
|
||||
result.reencryptedFieldsCount++;
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to re-encrypt ${fieldName} for ${table} record ${recordId}: ${error instanceof Error ? error.message : "Unknown error"}`;
|
||||
result.errors.push(errorMsg);
|
||||
databaseLogger.warn(
|
||||
"Field re-encryption failed during password reset",
|
||||
{
|
||||
operation: "password_reset_reencrypt_failed",
|
||||
userId,
|
||||
table,
|
||||
recordId,
|
||||
fieldName,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unknown error",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
const updateFields = fields.filter(
|
||||
(field) => updatedRecord[field] !== record[field],
|
||||
);
|
||||
if (updateFields.length > 0) {
|
||||
const updateQuery = `UPDATE ${table} SET ${updateFields.map((f) => `${f} = ?`).join(", ")}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`;
|
||||
const updateValues = updateFields.map(
|
||||
(field) => updatedRecord[field],
|
||||
);
|
||||
updateValues.push(record.id);
|
||||
|
||||
db.prepare(updateQuery).run(...updateValues);
|
||||
|
||||
if (!result.reencryptedTables.includes(table)) {
|
||||
result.reencryptedTables.push(table);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (tableError) {
|
||||
const errorMsg = `Failed to re-encrypt table ${table}: ${tableError instanceof Error ? tableError.message : "Unknown error"}`;
|
||||
result.errors.push(errorMsg);
|
||||
databaseLogger.error(
|
||||
"Table re-encryption failed during password reset",
|
||||
tableError,
|
||||
{
|
||||
operation: "password_reset_table_reencrypt_failed",
|
||||
userId,
|
||||
table,
|
||||
error:
|
||||
tableError instanceof Error
|
||||
? tableError.message
|
||||
: "Unknown error",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
result.success = result.errors.length === 0;
|
||||
|
||||
databaseLogger.info(
|
||||
"User data re-encryption completed after password reset",
|
||||
{
|
||||
operation: "password_reset_reencrypt_completed",
|
||||
userId,
|
||||
success: result.success,
|
||||
reencryptedTables: result.reencryptedTables,
|
||||
reencryptedFieldsCount: result.reencryptedFieldsCount,
|
||||
errorsCount: result.errors.length,
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"User data re-encryption failed after password reset",
|
||||
error,
|
||||
{
|
||||
operation: "password_reset_reencrypt_failed",
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
);
|
||||
|
||||
result.errors.push(
|
||||
`Critical error during re-encryption: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
static validateUserAccess(userId: string): Buffer {
|
||||
const userDataKey = this.getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
|
||||
@@ -22,13 +22,13 @@ class FieldCrypto {
|
||||
"totp_backup_codes",
|
||||
"oidc_identifier",
|
||||
]),
|
||||
ssh_data: new Set(["password", "key", "keyPassword"]),
|
||||
ssh_data: new Set(["password", "key", "key_password"]),
|
||||
ssh_credentials: new Set([
|
||||
"password",
|
||||
"privateKey",
|
||||
"keyPassword",
|
||||
"private_key",
|
||||
"key_password",
|
||||
"key",
|
||||
"publicKey",
|
||||
"public_key",
|
||||
]),
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@ import { FieldCrypto } from "./field-crypto.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
export class LazyFieldEncryption {
|
||||
private static readonly LEGACY_FIELD_NAME_MAP: Record<string, string> = {
|
||||
key_password: "keyPassword",
|
||||
private_key: "privateKey",
|
||||
public_key: "publicKey",
|
||||
};
|
||||
|
||||
static isPlaintextField(value: string): boolean {
|
||||
if (!value) return false;
|
||||
|
||||
@@ -44,6 +50,35 @@ export class LazyFieldEncryption {
|
||||
);
|
||||
return decrypted;
|
||||
} catch (error) {
|
||||
const legacyFieldName = this.LEGACY_FIELD_NAME_MAP[fieldName];
|
||||
if (legacyFieldName) {
|
||||
try {
|
||||
const decrypted = FieldCrypto.decryptField(
|
||||
fieldValue,
|
||||
userKEK,
|
||||
recordId,
|
||||
legacyFieldName,
|
||||
);
|
||||
return decrypted;
|
||||
} catch (legacyError) {}
|
||||
}
|
||||
|
||||
const sensitiveFields = [
|
||||
"totp_secret",
|
||||
"totp_backup_codes",
|
||||
"password",
|
||||
"key",
|
||||
"key_password",
|
||||
"private_key",
|
||||
"public_key",
|
||||
"client_secret",
|
||||
"oidc_identifier",
|
||||
];
|
||||
|
||||
if (sensitiveFields.includes(fieldName)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
databaseLogger.error("Failed to decrypt field", error, {
|
||||
operation: "lazy_encryption_decrypt_failed",
|
||||
recordId,
|
||||
@@ -60,9 +95,13 @@ export class LazyFieldEncryption {
|
||||
userKEK: Buffer,
|
||||
recordId: string,
|
||||
fieldName: string,
|
||||
): { encrypted: string; wasPlaintext: boolean } {
|
||||
): {
|
||||
encrypted: string;
|
||||
wasPlaintext: boolean;
|
||||
wasLegacyEncryption: boolean;
|
||||
} {
|
||||
if (!fieldValue) {
|
||||
return { encrypted: "", wasPlaintext: false };
|
||||
return { encrypted: "", wasPlaintext: false, wasLegacyEncryption: false };
|
||||
}
|
||||
|
||||
if (this.isPlaintextField(fieldValue)) {
|
||||
@@ -74,7 +113,7 @@ export class LazyFieldEncryption {
|
||||
fieldName,
|
||||
);
|
||||
|
||||
return { encrypted, wasPlaintext: true };
|
||||
return { encrypted, wasPlaintext: true, wasLegacyEncryption: false };
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to encrypt plaintext field", error, {
|
||||
operation: "lazy_encryption_migrate_failed",
|
||||
@@ -85,7 +124,42 @@ export class LazyFieldEncryption {
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
return { encrypted: fieldValue, wasPlaintext: false };
|
||||
try {
|
||||
FieldCrypto.decryptField(fieldValue, userKEK, recordId, fieldName);
|
||||
return {
|
||||
encrypted: fieldValue,
|
||||
wasPlaintext: false,
|
||||
wasLegacyEncryption: false,
|
||||
};
|
||||
} catch (error) {
|
||||
const legacyFieldName = this.LEGACY_FIELD_NAME_MAP[fieldName];
|
||||
if (legacyFieldName) {
|
||||
try {
|
||||
const decrypted = FieldCrypto.decryptField(
|
||||
fieldValue,
|
||||
userKEK,
|
||||
recordId,
|
||||
legacyFieldName,
|
||||
);
|
||||
const reencrypted = FieldCrypto.encryptField(
|
||||
decrypted,
|
||||
userKEK,
|
||||
recordId,
|
||||
fieldName,
|
||||
);
|
||||
return {
|
||||
encrypted: reencrypted,
|
||||
wasPlaintext: false,
|
||||
wasLegacyEncryption: true,
|
||||
};
|
||||
} catch (legacyError) {}
|
||||
}
|
||||
return {
|
||||
encrypted: fieldValue,
|
||||
wasPlaintext: false,
|
||||
wasLegacyEncryption: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,18 +180,21 @@ export class LazyFieldEncryption {
|
||||
for (const fieldName of sensitiveFields) {
|
||||
const fieldValue = record[fieldName];
|
||||
|
||||
if (fieldValue && this.isPlaintextField(fieldValue)) {
|
||||
if (fieldValue) {
|
||||
try {
|
||||
const { encrypted } = this.migrateFieldToEncrypted(
|
||||
fieldValue,
|
||||
userKEK,
|
||||
recordId,
|
||||
fieldName,
|
||||
);
|
||||
const { encrypted, wasPlaintext, wasLegacyEncryption } =
|
||||
this.migrateFieldToEncrypted(
|
||||
fieldValue,
|
||||
userKEK,
|
||||
recordId,
|
||||
fieldName,
|
||||
);
|
||||
|
||||
updatedRecord[fieldName] = encrypted;
|
||||
migratedFields.push(fieldName);
|
||||
needsUpdate = true;
|
||||
if (wasPlaintext || wasLegacyEncryption) {
|
||||
updatedRecord[fieldName] = encrypted;
|
||||
migratedFields.push(fieldName);
|
||||
needsUpdate = true;
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to migrate record field", error, {
|
||||
operation: "lazy_encryption_record_field_failed",
|
||||
@@ -134,13 +211,53 @@ export class LazyFieldEncryption {
|
||||
static getSensitiveFieldsForTable(tableName: string): string[] {
|
||||
const sensitiveFieldsMap: Record<string, string[]> = {
|
||||
ssh_data: ["password", "key", "key_password"],
|
||||
ssh_credentials: ["password", "key", "key_password", "private_key"],
|
||||
ssh_credentials: [
|
||||
"password",
|
||||
"key",
|
||||
"key_password",
|
||||
"private_key",
|
||||
"public_key",
|
||||
],
|
||||
users: ["totp_secret", "totp_backup_codes"],
|
||||
};
|
||||
|
||||
return sensitiveFieldsMap[tableName] || [];
|
||||
}
|
||||
|
||||
static fieldNeedsMigration(
|
||||
fieldValue: string,
|
||||
userKEK: Buffer,
|
||||
recordId: string,
|
||||
fieldName: string,
|
||||
): boolean {
|
||||
if (!fieldValue) return false;
|
||||
|
||||
if (this.isPlaintextField(fieldValue)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
FieldCrypto.decryptField(fieldValue, userKEK, recordId, fieldName);
|
||||
return false;
|
||||
} catch (error) {
|
||||
const legacyFieldName = this.LEGACY_FIELD_NAME_MAP[fieldName];
|
||||
if (legacyFieldName) {
|
||||
try {
|
||||
FieldCrypto.decryptField(
|
||||
fieldValue,
|
||||
userKEK,
|
||||
recordId,
|
||||
legacyFieldName,
|
||||
);
|
||||
return true;
|
||||
} catch (legacyError) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static async checkUserNeedsMigration(
|
||||
userId: string,
|
||||
userKEK: Buffer,
|
||||
@@ -169,7 +286,15 @@ export class LazyFieldEncryption {
|
||||
const hostPlaintextFields: string[] = [];
|
||||
|
||||
for (const field of sensitiveFields) {
|
||||
if (host[field] && this.isPlaintextField(host[field])) {
|
||||
if (
|
||||
host[field] &&
|
||||
this.fieldNeedsMigration(
|
||||
host[field],
|
||||
userKEK,
|
||||
host.id.toString(),
|
||||
field,
|
||||
)
|
||||
) {
|
||||
hostPlaintextFields.push(field);
|
||||
needsMigration = true;
|
||||
}
|
||||
@@ -193,7 +318,15 @@ export class LazyFieldEncryption {
|
||||
const credentialPlaintextFields: string[] = [];
|
||||
|
||||
for (const field of sensitiveFields) {
|
||||
if (credential[field] && this.isPlaintextField(credential[field])) {
|
||||
if (
|
||||
credential[field] &&
|
||||
this.fieldNeedsMigration(
|
||||
credential[field],
|
||||
userKEK,
|
||||
credential.id.toString(),
|
||||
field,
|
||||
)
|
||||
) {
|
||||
credentialPlaintextFields.push(field);
|
||||
needsMigration = true;
|
||||
}
|
||||
@@ -214,7 +347,10 @@ export class LazyFieldEncryption {
|
||||
const userPlaintextFields: string[] = [];
|
||||
|
||||
for (const field of sensitiveFields) {
|
||||
if (user[field] && this.isPlaintextField(user[field])) {
|
||||
if (
|
||||
user[field] &&
|
||||
this.fieldNeedsMigration(user[field], userKEK, userId, field)
|
||||
) {
|
||||
userPlaintextFields.push(field);
|
||||
needsMigration = true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user