- Connection test updates the configuration health state without exposing the password.
+ Add a route, then use each row's quick verification modal to send a test email.
@@ -508,14 +589,15 @@ export function EmailConfigurationPage() {
{
setEditingConfiguration(item);
setSheetOpen(true);
}}
onDelete={handleDelete}
onTestConnection={handleTestConnection}
- onSendTest={handleSendTest}
+ onSendTest={(item) => {
+ setQuickVerificationConfiguration(item);
+ }}
isDeleting={deleteMutation.isPending && deleteMutation.variables === configuration.id}
isTesting={
testConnectionMutation.isPending &&
@@ -672,6 +754,19 @@ export function EmailConfigurationPage() {
+
+ {
+ if (!open) {
+ setQuickVerificationConfiguration(null);
+ }
+ }}
+ onSubmit={handleSendTest}
+ isSending={sendTestEmailMutation.isPending}
+ />
);
}
diff --git a/src/features/foundation/secrets/server/crypto.ts b/src/features/foundation/secrets/server/crypto.ts
index 876a9d2..eee1c7b 100644
--- a/src/features/foundation/secrets/server/crypto.ts
+++ b/src/features/foundation/secrets/server/crypto.ts
@@ -1,42 +1,53 @@
-import 'server-only';
+import "server-only";
-import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
+import {
+ createCipheriv,
+ createDecipheriv,
+ createHash,
+ randomBytes,
+} from "node:crypto";
-const ENCRYPTION_PREFIX = 'enc:v1';
-const ENCRYPTION_ENV_KEY = 'EMAIL_CONFIG_ENCRYPTION_KEY';
+const ENCRYPTION_PREFIX = "enc:v1";
+const ENCRYPTION_ENV_KEY = "EMAIL_CONFIG_ENCRYPTION_KEY";
const MIN_KEY_BYTES = 32;
function decodeConfiguredKey(rawValue: string): Buffer {
const value = rawValue.trim();
if (/^[0-9a-fA-F]+$/.test(value) && value.length % 2 === 0) {
- return Buffer.from(value, 'hex');
+ return Buffer.from(value, "hex");
}
try {
- const base64Buffer = Buffer.from(value, 'base64');
- if (base64Buffer.length > 0 && base64Buffer.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')) {
+ const base64Buffer = Buffer.from(value, "base64");
+ if (
+ base64Buffer.length > 0 &&
+ base64Buffer.toString("base64").replace(/=+$/, "") ===
+ value.replace(/=+$/, "")
+ ) {
return base64Buffer;
}
} catch {
// Fall back to UTF-8 below.
}
- return Buffer.from(value, 'utf8');
+ return Buffer.from(value, "utf8");
}
function getEncryptionKeyMaterial(): Buffer {
- const configured = process.env[ENCRYPTION_ENV_KEY]?.trim() ?? '';
+ const configured = process.env[ENCRYPTION_ENV_KEY]?.trim() ?? "";
if (!configured) {
throw new Error(`${ENCRYPTION_ENV_KEY} is not configured.`);
}
const keyMaterial = decodeConfiguredKey(configured);
if (keyMaterial.byteLength < MIN_KEY_BYTES) {
- throw new Error(`${ENCRYPTION_ENV_KEY} must provide at least ${MIN_KEY_BYTES} bytes of key material.`);
+ throw new Error(
+ `${ENCRYPTION_ENV_KEY} must provide at least ${MIN_KEY_BYTES} bytes of key material.`,
+ );
}
- return createHash('sha256').update(keyMaterial).digest();
+ return createHash("sha256").update(keyMaterial).digest();
}
export function assertSecretsEncryptionReady(): void {
@@ -46,37 +57,49 @@ export function assertSecretsEncryptionReady(): void {
export function encryptSecret(plainText: string): string {
const normalized = plainText.trim();
if (!normalized) {
- throw new Error('Secret value is required for encryption.');
+ throw new Error("Secret value is required for encryption.");
}
const key = getEncryptionKeyMaterial();
const iv = randomBytes(12);
- const cipher = createCipheriv('aes-256-gcm', key, iv);
- const encrypted = Buffer.concat([cipher.update(normalized, 'utf8'), cipher.final()]);
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
+ const encrypted = Buffer.concat([
+ cipher.update(normalized, "utf8"),
+ cipher.final(),
+ ]);
const tag = cipher.getAuthTag();
return [
ENCRYPTION_PREFIX,
- iv.toString('base64'),
- tag.toString('base64'),
- encrypted.toString('base64'),
- ].join(':');
+ iv.toString("base64"),
+ tag.toString("base64"),
+ encrypted.toString("base64"),
+ ].join(":");
}
export function decryptSecret(cipherText: string): string {
- const [prefix, ivPart, tagPart, encryptedPart] = cipherText.split(':');
- if (prefix !== ENCRYPTION_PREFIX || !ivPart || !tagPart || !encryptedPart) {
- throw new Error('Encrypted secret format is invalid.');
+ if (!cipherText.startsWith(`${ENCRYPTION_PREFIX}:`)) {
+ throw new Error("Encrypted secret format is invalid.");
+ }
+
+ const payload = cipherText.slice(ENCRYPTION_PREFIX.length + 1);
+ const [ivPart, tagPart, encryptedPart, ...extraParts] = payload.split(":");
+ if (!ivPart || !tagPart || !encryptedPart || extraParts.length > 0) {
+ throw new Error("Encrypted secret format is invalid.");
}
const key = getEncryptionKeyMaterial();
- const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivPart, 'base64'));
- decipher.setAuthTag(Buffer.from(tagPart, 'base64'));
+ const decipher = createDecipheriv(
+ "aes-256-gcm",
+ key,
+ Buffer.from(ivPart, "base64"),
+ );
+ decipher.setAuthTag(Buffer.from(tagPart, "base64"));
const plain = Buffer.concat([
- decipher.update(Buffer.from(encryptedPart, 'base64')),
+ decipher.update(Buffer.from(encryptedPart, "base64")),
decipher.final(),
]);
- return plain.toString('utf8');
+ return plain.toString("utf8");
}
diff --git a/src/tests/setup/secret-crypto.test.ts b/src/tests/setup/secret-crypto.test.ts
new file mode 100644
index 0000000..fa8f12d
--- /dev/null
+++ b/src/tests/setup/secret-crypto.test.ts
@@ -0,0 +1,37 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {
+ decryptSecret,
+ encryptSecret,
+} from '../../features/foundation/secrets/server/crypto.ts';
+
+const previousKey = process.env.EMAIL_CONFIG_ENCRYPTION_KEY;
+
+test.before(() => {
+ process.env.EMAIL_CONFIG_ENCRYPTION_KEY =
+ '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
+});
+
+test.after(() => {
+ if (typeof previousKey === 'string') {
+ process.env.EMAIL_CONFIG_ENCRYPTION_KEY = previousKey;
+ return;
+ }
+
+ delete process.env.EMAIL_CONFIG_ENCRYPTION_KEY;
+});
+
+test('encryptSecret output decrypts back to the original value', () => {
+ const encrypted = encryptSecret('smtp-password');
+
+ assert.match(encrypted, /^enc:v1:/);
+ assert.equal(decryptSecret(encrypted), 'smtp-password');
+});
+
+test('decryptSecret rejects malformed ciphertext', () => {
+ assert.throws(
+ () => decryptSecret('enc:v1:missing-parts'),
+ /Encrypted secret format is invalid\./,
+ );
+});