Skip to content
Merged
Show file tree
Hide file tree
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
8 changes: 7 additions & 1 deletion deployment/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,15 @@ DATABASE_URL=postgres://user:password@your-postgres-host:5432/techulus
# Authentication
BETTER_AUTH_SECRET=your-secret-key-here

# Encryption (32 bytes as 64-character hex string)
# Encryption: use a local 32-byte key by default.
ENCRYPTION_KEY=your-64-character-hex-string

# AWS KMS BYOK alternative (requires an AWS role; remove ENCRYPTION_KEY for a
# fresh installation). Keep ENCRYPTION_KEY for the first restart only when
# migrating existing encrypted data.
# ENCRYPTION_KMS_KEY_ARN=arn:aws:kms:us-east-1:123456789012:key/your-key-id
# AWS_REGION=us-east-1

# Victoria Logs Authentication
VL_USERNAME=admin
VL_PASSWORD=your-secure-logs-password
Expand Down
33 changes: 31 additions & 2 deletions deployment/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,36 @@ configure_interactive() {
done

BETTER_AUTH_SECRET="$(openssl rand -hex 32)"
ENCRYPTION_KEY="$(openssl rand -hex 32)"

echo ""
log_info "Secret encryption configuration"
echo -e " ${BOLD}1)${NC} Local encryption key"
echo -e " ${BOLD}2)${NC} Customer-managed AWS KMS key (AWS-hosted control plane)"
echo ""

local encryption_choice
while true; do
read -rp "$(echo -e "${CYAN}Choose encryption option [1]: ${NC}")" encryption_choice
encryption_choice="${encryption_choice:-1}"
case "$encryption_choice" in
1)
ENCRYPTION_SETTINGS="ENCRYPTION_KEY=$(openssl rand -hex 32)"
break
;;
2)
prompt_value ENCRYPTION_KMS_KEY_ARN "Enter the full AWS KMS key ARN"
prompt_value AWS_REGION "Enter the AWS region containing the KMS key"
ENCRYPTION_SETTINGS="ENCRYPTION_KMS_KEY_ARN=${ENCRYPTION_KMS_KEY_ARN}
AWS_REGION=${AWS_REGION}"
log_info "The web service must have an AWS role granting access to this KMS key."
break
;;
*)
log_warn "Please enter 1 or 2"
;;
esac
done

VL_USERNAME="admin"
VL_PASSWORD="$(openssl rand -hex 16)"
VM_USERNAME="admin"
Expand Down Expand Up @@ -367,7 +396,7 @@ ACME_EMAIL=${ACME_EMAIL}
DATABASE_URL=${DATABASE_URL}

BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
ENCRYPTION_KEY=${ENCRYPTION_KEY}
${ENCRYPTION_SETTINGS}

VL_USERNAME=${VL_USERNAME}
VL_PASSWORD=${VL_PASSWORD}
Expand Down
116 changes: 115 additions & 1 deletion docs/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,121 @@ use the common commands below when investigating a self-hosted service.
| `ACME_EMAIL` | Email for Let's Encrypt certificates |
| `DATABASE_URL` | PostgreSQL connection string (e.g., `postgres://user:pass@postgres:5432/techulus`) |
| `BETTER_AUTH_SECRET` | Secret key for authentication |
| `ENCRYPTION_KEY` | 32 bytes as a 64-character hex string |
| `ENCRYPTION_KEY` | 32 bytes as a 64-character hex string. Required unless you configure AWS KMS BYOK. |

### AWS KMS BYOK

AWS KMS BYOK is available for AWS-hosted dedicated control planes. The control plane can run in your AWS account or in the Techulus AWS account. Attach an EC2 instance profile or ECS task role to the web service. Do not store static AWS credentials in `.env`.

Set these variables instead of `ENCRYPTION_KEY` for a new installation:

```env
ENCRYPTION_KMS_KEY_ARN=arn:aws:kms:us-east-1:123456789012:key/your-key-id
AWS_REGION=us-east-1
```

The role needs `kms:GenerateDataKey`, `kms:Encrypt`, `kms:Decrypt`, and `kms:DescribeKey` for the configured key. Restrict the cryptographic operations to the role, the exact key, and this encryption context:

```json
{
"StringEquals": {
"kms:EncryptionContext:techulus:purpose": "service-secret-dek"
}
}
```

#### Control plane in the Techulus AWS account

You can keep the KMS key and compute agents in your AWS account while running the dedicated control plane in the Techulus AWS account. Grant the Techulus control-plane role direct cross-account access to the KMS key. The application uses that role through the standard AWS credential chain. It does not call AWS STS `AssumeRole`.

Merge statements like these into the KMS key policy in your AWS account. Replace the account IDs and role name. `DescribeKey` is separate because that operation does not accept an encryption context:

```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowTechulusControlPlaneDescribe",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::111122223333:role/techulus-control-plane-customer-name"
},
"Action": "kms:DescribeKey",
"Resource": "*"
},
{
"Sid": "AllowTechulusControlPlaneCrypto",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::111122223333:role/techulus-control-plane-customer-name"
},
"Action": [
"kms:GenerateDataKey",
"kms:Encrypt",
"kms:Decrypt"
],
"Resource": "*",
"Condition": {
"StringEquals": {
"kms:EncryptionContext:techulus:purpose": "service-secret-dek"
}
}
}
]
}
```

The Techulus control-plane role also needs this identity policy. Set `Resource` to the full ARN of your KMS key:

```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DescribeCustomerEncryptionKey",
"Effect": "Allow",
"Action": "kms:DescribeKey",
"Resource": "arn:aws:kms:us-east-1:444455556666:key/your-key-id"
},
{
"Sid": "UseCustomerEncryptionKey",
"Effect": "Allow",
"Action": [
"kms:GenerateDataKey",
"kms:Encrypt",
"kms:Decrypt"
],
"Resource": "arn:aws:kms:us-east-1:444455556666:key/your-key-id",
"Condition": {
"StringEquals": {
"kms:EncryptionContext:techulus:purpose": "service-secret-dek"
}
}
}
]
}
```

Configure `ENCRYPTION_KMS_KEY_ARN` with the customer-account key ARN and set `AWS_REGION` to that key's region. Your compute agents do not need AWS credentials or KMS permissions. They continue receiving the data encryption key during registration.

You can revoke future cold-start unwraps by removing the Techulus role from the key policy or disabling the key. This does not erase a key already cached by a running control-plane process or persisted by an existing agent.

The first secret operation generates a 32-byte data encryption key. The control plane stores only its KMS-wrapped form in PostgreSQL. Each web process unwraps and caches the key when first needed. The secret ciphertext format and agent behavior remain unchanged.

To migrate an existing installation:

1. Back up PostgreSQL and `.env`.
2. Add `ENCRYPTION_KMS_KEY_ARN` and `AWS_REGION`. Keep the existing `ENCRYPTION_KEY` temporarily.
3. Restart the web service and verify that you can reveal an existing secret and register an agent.
4. Remove `ENCRYPTION_KEY` and restart all web replicas.

When a wrapped key already exists, any remaining `ENCRYPTION_KEY` must contain the same key bytes. The control plane fails closed if they differ. Do not add a newly generated `ENCRYPTION_KEY` to an existing KMS installation.

Do not change `ENCRYPTION_KMS_KEY_ARN` after initialization. Automatic rotation of the same KMS key is transparent. Moving to another KMS key requires a reviewed rewrap procedure.

If KMS is unavailable, cold-started web processes return a service-unavailable response for secret operations and agent registration. Other control-plane features remain available. Warm processes and existing agents can continue using keys already held in memory or local agent configuration.

Back up both PostgreSQL and continued access to the KMS key. Deleting the KMS key can make a database restore unrecoverable. Disabling the key blocks future unwraps, but it does not erase keys already cached by running processes or registered agents.

### Control plane deployment

Expand Down
8 changes: 7 additions & 1 deletion web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,15 @@ DATABASE_URL=postgres://techulus:password@localhost:5432/techulus
BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_SECRET=your-secret-key-here

# Encryption (32 bytes as 64-character hex string)
# Encryption: use a local 32-byte key by default.
ENCRYPTION_KEY=your-64-character-hex-string

# AWS KMS BYOK alternative (requires an AWS role; remove ENCRYPTION_KEY for a
# fresh installation). Keep ENCRYPTION_KEY for the first restart only when
# migrating existing encrypted data.
# ENCRYPTION_KMS_KEY_ARN=arn:aws:kms:us-east-1:123456789012:key/your-key-id
# AWS_REGION=us-east-1

# Public URL
APP_URL=http://localhost:3000

Expand Down
10 changes: 9 additions & 1 deletion web/SELF-HOSTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,15 @@ mutable tags such as `latest` or `tip`.
| `POSTGRES_DB` | PostgreSQL database name |
| `DATABASE_URL` | Full connection string (e.g., `postgres://user:pass@postgres:5432/db`) |
| `BETTER_AUTH_SECRET` | Secret key for authentication |
| `ENCRYPTION_KEY` | 32 bytes as 64-character hex string |
| `ENCRYPTION_KEY` | 32 bytes as 64-character hex string. Required unless AWS KMS BYOK is configured. |
| `ENCRYPTION_KMS_KEY_ARN` | Optional full ARN of a symmetric AWS KMS key. Enables BYOK. |
| `AWS_REGION` | Required with `ENCRYPTION_KMS_KEY_ARN`. |

For KMS BYOK, run the dedicated control plane in AWS with an instance profile or task role. The role needs `kms:GenerateDataKey`, `kms:Encrypt`, `kms:Decrypt`, and `kms:DescribeKey`. Do not put static AWS credentials in `.env`.

The KMS key can be in another AWS account. For direct cross-account access, grant the control-plane role access in both the customer's KMS key policy and the role's identity policy. Scope cryptographic operations to the exact key and the `techulus:purpose=service-secret-dek` encryption context. The application uses its existing role credentials and does not call STS `AssumeRole`. Compute agents need no KMS permissions. See the installation documentation for policy examples.

On a fresh KMS installation, omit `ENCRYPTION_KEY`. To migrate existing data, configure KMS while retaining `ENCRYPTION_KEY` for one restart. Verify an existing secret, then remove the raw key and restart every web replica. If a wrapped key already exists, a remaining `ENCRYPTION_KEY` must match it or encryption operations fail closed. The wrapped data encryption key is stored in PostgreSQL, so database recovery also requires access to the same KMS key.

### Victoria Logs

Expand Down
4 changes: 2 additions & 2 deletions web/actions/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,14 @@ export async function createSecretsBatch(
if (existingSecret) {
toUpdate.push({
id: existingSecret.id,
encryptedValue: encryptSecret(item.value),
encryptedValue: await encryptSecret(item.value),
});
} else {
toInsert.push({
id: randomUUID(),
serviceId,
key: item.key,
encryptedValue: encryptSecret(item.value),
encryptedValue: await encryptSecret(item.value),
});
}
}
Expand Down
12 changes: 10 additions & 2 deletions web/app/api/services/[id]/secrets/[secretId]/reveal/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { db } from "@/db";
import { secrets } from "@/db/schema";
import { requireRequestDeveloperRole } from "@/lib/api-auth";
import { decryptSecret } from "@/lib/crypto";
import { EncryptionKeyUnavailableError } from "@/lib/kms";

export async function POST(
request: Request,
Expand All @@ -28,7 +29,7 @@ export async function POST(
}

try {
const decryptedValue = decryptSecret(secret[0].encryptedValue);
const decryptedValue = await decryptSecret(secret[0].encryptedValue);

return new Response(JSON.stringify({ value: decryptedValue }), {
status: 200,
Expand All @@ -40,7 +41,14 @@ export async function POST(
Vary: "Authorization",
},
});
} catch {
} catch (error) {
if (error instanceof EncryptionKeyUnavailableError) {
console.error("Secret encryption key unavailable:", error);
return Response.json(
{ error: "Secret encryption service unavailable" },
{ status: 503 },
);
}
return Response.json(
{ error: "Failed to decrypt secret" },
{ status: 500 },
Expand Down
31 changes: 28 additions & 3 deletions web/app/api/v1/agent/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
import { servers } from "@/db/schema";
import { HOUR_IN_MILLISECONDS, subtractMilliseconds } from "@/lib/date";
import { EncryptionKeyUnavailableError, resolveEncryptionKey } from "@/lib/kms";
import { agentRegisterSchema } from "@/lib/schemas";
import { formatZodErrors } from "@/lib/utils";
import { assignSubnet } from "@/lib/wireguard";
Expand Down Expand Up @@ -56,9 +57,12 @@ export async function POST(request: NextRequest) {
);
}

const encryptionKeyBuffer = await resolveEncryptionKey();
const encryptionKey = encryptionKeyBuffer.toString("hex");

const { subnetId, wireguardIp } = await assignSubnet();

await db
const claimedServers = await db
.update(servers)
.set({
wireguardPublicKey,
Expand All @@ -72,13 +76,28 @@ export async function POST(request: NextRequest) {
status: "online",
lastHeartbeat: now,
})
.where(eq(servers.id, server.id));
.where(
and(
eq(servers.id, server.id),
eq(servers.agentToken, token),
isNull(servers.tokenUsedAt),
gt(servers.tokenCreatedAt, expiryThreshold),
),
)
.returning({ id: servers.id });

if (claimedServers.length === 0) {
return NextResponse.json(
{ error: "Invalid, expired, or already used token" },
{ status: 401 },
);
}

return NextResponse.json({
serverId: server.id,
subnetId,
wireguardIp,
encryptionKey: process.env.ENCRYPTION_KEY,
encryptionKey,
loggingEndpoint: process.env.VICTORIA_LOGS_URL ?? null,
metricsEndpoint: process.env.VICTORIA_METRICS_URL ?? null,
registryUrl: process.env.REGISTRY_URL ?? null,
Expand All @@ -88,6 +107,12 @@ export async function POST(request: NextRequest) {
});
} catch (error) {
console.error("Agent registration error:", error);
if (error instanceof EncryptionKeyUnavailableError) {
return NextResponse.json(
{ error: "Secret encryption service unavailable" },
{ status: 503 },
);
}
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
Expand Down
23 changes: 8 additions & 15 deletions web/lib/crypto.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,18 @@
import {
randomBytes,
verify,
createPublicKey,
createCipheriv,
createDecipheriv,
createPublicKey,
randomBytes,
verify,
} from "node:crypto";
import { resolveEncryptionKey } from "@/lib/kms";

const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;

function getEncryptionKey(): Buffer {
const key = process.env.ENCRYPTION_KEY;
if (!key || key.length !== 64) {
throw new Error("ENCRYPTION_KEY must be 64 hex characters (32 bytes)");
}
return Buffer.from(key, "hex");
}

export function encryptSecret(plaintext: string): string {
const key = getEncryptionKey();
export async function encryptSecret(plaintext: string): Promise<string> {
const key = await resolveEncryptionKey();
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);

Expand All @@ -32,8 +25,8 @@ export function encryptSecret(plaintext: string): string {
return Buffer.concat([iv, authTag, encrypted]).toString("base64");
}

export function decryptSecret(encryptedBase64: string): string {
const key = getEncryptionKey();
export async function decryptSecret(encryptedBase64: string): Promise<string> {
const key = await resolveEncryptionKey();
const data = Buffer.from(encryptedBase64, "base64");

const iv = data.subarray(0, IV_LENGTH);
Expand Down
Loading
Loading