mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-19 11:26:37 +00:00
Initial sign endpoint working
This commit is contained in:
@@ -131,7 +131,8 @@ export enum ActionsEnum {
|
||||
viewLogs = "viewLogs",
|
||||
exportLogs = "exportLogs",
|
||||
listApprovals = "listApprovals",
|
||||
updateApprovals = "updateApprovals"
|
||||
updateApprovals = "updateApprovals",
|
||||
signSshKey = "signSshKey"
|
||||
}
|
||||
|
||||
export async function checkUserActionPermission(
|
||||
|
||||
45
server/auth/canUserAccessSiteResource.ts
Normal file
45
server/auth/canUserAccessSiteResource.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { db } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { roleSiteResources, userSiteResources } from "@server/db";
|
||||
|
||||
export async function canUserAccessSiteResource({
|
||||
userId,
|
||||
resourceId,
|
||||
roleId
|
||||
}: {
|
||||
userId: string;
|
||||
resourceId: number;
|
||||
roleId: number;
|
||||
}): Promise<boolean> {
|
||||
const roleResourceAccess = await db
|
||||
.select()
|
||||
.from(roleSiteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleSiteResources.siteResourceId, resourceId),
|
||||
eq(roleSiteResources.roleId, roleId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (roleResourceAccess.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const userResourceAccess = await db
|
||||
.select()
|
||||
.from(userSiteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(userSiteResources.userId, userId),
|
||||
eq(userSiteResources.siteResourceId, resourceId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (userResourceAccess.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -53,7 +53,9 @@ export const orgs = pgTable("orgs", {
|
||||
.default(0),
|
||||
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||
.notNull()
|
||||
.default(0)
|
||||
.default(0),
|
||||
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
||||
sshCaPublicKey: text("sshCaPublicKey") // SSH CA public key (OpenSSH format)
|
||||
});
|
||||
|
||||
export const orgDomains = pgTable("orgDomains", {
|
||||
|
||||
@@ -45,7 +45,9 @@ export const orgs = sqliteTable("orgs", {
|
||||
.default(0),
|
||||
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||
.notNull()
|
||||
.default(0)
|
||||
.default(0),
|
||||
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
||||
sshCaPublicKey: text("sshCaPublicKey") // SSH CA public key (OpenSSH format)
|
||||
});
|
||||
|
||||
export const userDomains = sqliteTable("userDomains", {
|
||||
|
||||
@@ -14,7 +14,8 @@ export enum TierFeature {
|
||||
TwoFactorEnforcement = "twoFactorEnforcement", // handle downgrade by setting to optional
|
||||
SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration
|
||||
PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration
|
||||
AutoProvisioning = "autoProvisioning" // handle downgrade by disabling auto provisioning
|
||||
AutoProvisioning = "autoProvisioning", // handle downgrade by disabling auto provisioning
|
||||
SshPam = "sshPam"
|
||||
}
|
||||
|
||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
@@ -46,5 +47,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
"tier3",
|
||||
"enterprise"
|
||||
],
|
||||
[TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"]
|
||||
[TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"],
|
||||
[TierFeature.SshPam]: ["enterprise"]
|
||||
};
|
||||
|
||||
@@ -19,6 +19,8 @@ import { FeatureId, limitsService, sandboxLimitSet } from "@server/lib/billing";
|
||||
import { createCustomer } from "#dynamic/lib/billing";
|
||||
import { usageService } from "@server/lib/billing/usageService";
|
||||
import config from "@server/lib/config";
|
||||
import { generateCA } from "@server/private/lib/sshCA";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
|
||||
export async function createUserAccountOrg(
|
||||
userId: string,
|
||||
@@ -79,6 +81,11 @@ export async function createUserAccountOrg(
|
||||
|
||||
const utilitySubnet = config.getRawConfig().orgs.utility_subnet_group;
|
||||
|
||||
// Generate SSH CA keys for the org
|
||||
const ca = generateCA(`${orgId}-ca`);
|
||||
const encryptionKey = config.getRawConfig().server.secret!;
|
||||
const encryptedCaPrivateKey = encrypt(ca.privateKeyPem, encryptionKey);
|
||||
|
||||
const newOrg = await trx
|
||||
.insert(orgs)
|
||||
.values({
|
||||
@@ -87,7 +94,9 @@ export async function createUserAccountOrg(
|
||||
// subnet
|
||||
subnet: "100.90.128.0/24", // TODO: this should not be hardcoded - or can it be the same in all orgs?
|
||||
utilitySubnet: utilitySubnet,
|
||||
createdAt: new Date().toISOString()
|
||||
createdAt: new Date().toISOString(),
|
||||
sshCaPrivateKey: encryptedCaPrivateKey,
|
||||
sshCaPublicKey: ca.publicKeyOpenSSH
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
||||
@@ -16,5 +16,6 @@ export enum OpenAPITags {
|
||||
Client = "Client",
|
||||
ApiKey = "API Key",
|
||||
Domain = "Domain",
|
||||
Blueprint = "Blueprint"
|
||||
Blueprint = "Blueprint",
|
||||
Ssh = "SSH"
|
||||
}
|
||||
|
||||
442
server/private/lib/sshCA.ts
Normal file
442
server/private/lib/sshCA.ts
Normal file
@@ -0,0 +1,442 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import * as crypto from "crypto";
|
||||
|
||||
/**
|
||||
* SSH CA "Server" - Pure TypeScript Implementation
|
||||
*
|
||||
* This module provides basic SSH Certificate Authority functionality using
|
||||
* only Node.js built-in crypto module. No external dependencies or subprocesses.
|
||||
*
|
||||
* Usage:
|
||||
* 1. generateCA() - Creates a new CA key pair, returns CA info including the
|
||||
* TrustedUserCAKeys line to add to servers
|
||||
* 2. signPublicKey() - Signs a user's public key with the CA, returns a certificate
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// SSH Wire Format Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Encode a string in SSH wire format (4-byte length prefix + data)
|
||||
*/
|
||||
function encodeString(data: Buffer | string): Buffer {
|
||||
const buf = typeof data === "string" ? Buffer.from(data, "utf8") : data;
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(buf.length, 0);
|
||||
return Buffer.concat([len, buf]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a uint32 in SSH wire format (big-endian)
|
||||
*/
|
||||
function encodeUInt32(value: number): Buffer {
|
||||
const buf = Buffer.alloc(4);
|
||||
buf.writeUInt32BE(value, 0);
|
||||
return buf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a uint64 in SSH wire format (big-endian)
|
||||
*/
|
||||
function encodeUInt64(value: bigint): Buffer {
|
||||
const buf = Buffer.alloc(8);
|
||||
buf.writeBigUInt64BE(value, 0);
|
||||
return buf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a string from SSH wire format at the given offset
|
||||
* Returns the string buffer and the new offset
|
||||
*/
|
||||
function decodeString(data: Buffer, offset: number): { value: Buffer; newOffset: number } {
|
||||
const len = data.readUInt32BE(offset);
|
||||
const value = data.subarray(offset + 4, offset + 4 + len);
|
||||
return { value, newOffset: offset + 4 + len };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SSH Public Key Parsing/Encoding
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Parse an OpenSSH public key line (e.g., "ssh-ed25519 AAAA... comment")
|
||||
*/
|
||||
function parseOpenSSHPublicKey(pubKeyLine: string): {
|
||||
keyType: string;
|
||||
keyData: Buffer;
|
||||
comment: string;
|
||||
} {
|
||||
const parts = pubKeyLine.trim().split(/\s+/);
|
||||
if (parts.length < 2) {
|
||||
throw new Error("Invalid public key format");
|
||||
}
|
||||
|
||||
const keyType = parts[0];
|
||||
const keyData = Buffer.from(parts[1], "base64");
|
||||
const comment = parts.slice(2).join(" ") || "";
|
||||
|
||||
// Verify the key type in the blob matches
|
||||
const { value: blobKeyType } = decodeString(keyData, 0);
|
||||
if (blobKeyType.toString("utf8") !== keyType) {
|
||||
throw new Error(`Key type mismatch: ${blobKeyType.toString("utf8")} vs ${keyType}`);
|
||||
}
|
||||
|
||||
return { keyType, keyData, comment };
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode an Ed25519 public key in OpenSSH format
|
||||
*/
|
||||
function encodeEd25519PublicKey(publicKey: Buffer): Buffer {
|
||||
return Buffer.concat([
|
||||
encodeString("ssh-ed25519"),
|
||||
encodeString(publicKey)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a public key blob as an OpenSSH public key line
|
||||
*/
|
||||
function formatOpenSSHPublicKey(keyBlob: Buffer, comment: string = ""): string {
|
||||
const { value: keyType } = decodeString(keyBlob, 0);
|
||||
const base64 = keyBlob.toString("base64");
|
||||
return `${keyType.toString("utf8")} ${base64}${comment ? " " + comment : ""}`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SSH Certificate Building
|
||||
// ============================================================================
|
||||
|
||||
interface CertificateOptions {
|
||||
/** Serial number for the certificate */
|
||||
serial?: bigint;
|
||||
/** Certificate type: 1 = user, 2 = host */
|
||||
certType?: number;
|
||||
/** Key ID (usually username or identifier) */
|
||||
keyId: string;
|
||||
/** List of valid principals (usernames the cert is valid for) */
|
||||
validPrincipals: string[];
|
||||
/** Valid after timestamp (seconds since epoch) */
|
||||
validAfter?: bigint;
|
||||
/** Valid before timestamp (seconds since epoch) */
|
||||
validBefore?: bigint;
|
||||
/** Critical options (usually empty for user certs) */
|
||||
criticalOptions?: Map<string, string>;
|
||||
/** Extensions to enable */
|
||||
extensions?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the extensions section of the certificate
|
||||
*/
|
||||
function buildExtensions(extensions: string[]): Buffer {
|
||||
// Extensions are a series of name-value pairs, sorted by name
|
||||
// For boolean extensions, the value is empty
|
||||
const sortedExtensions = [...extensions].sort();
|
||||
|
||||
const parts: Buffer[] = [];
|
||||
for (const ext of sortedExtensions) {
|
||||
parts.push(encodeString(ext));
|
||||
parts.push(encodeString("")); // Empty value for boolean extensions
|
||||
}
|
||||
|
||||
return encodeString(Buffer.concat(parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the critical options section
|
||||
*/
|
||||
function buildCriticalOptions(options: Map<string, string>): Buffer {
|
||||
const sortedKeys = [...options.keys()].sort();
|
||||
|
||||
const parts: Buffer[] = [];
|
||||
for (const key of sortedKeys) {
|
||||
parts.push(encodeString(key));
|
||||
parts.push(encodeString(encodeString(options.get(key)!)));
|
||||
}
|
||||
|
||||
return encodeString(Buffer.concat(parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the valid principals section
|
||||
*/
|
||||
function buildPrincipals(principals: string[]): Buffer {
|
||||
const parts: Buffer[] = [];
|
||||
for (const principal of principals) {
|
||||
parts.push(encodeString(principal));
|
||||
}
|
||||
return encodeString(Buffer.concat(parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the raw Ed25519 public key from an OpenSSH public key blob
|
||||
*/
|
||||
function extractEd25519PublicKey(keyBlob: Buffer): Buffer {
|
||||
const { newOffset } = decodeString(keyBlob, 0); // Skip key type
|
||||
const { value: publicKey } = decodeString(keyBlob, newOffset);
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CA Interface
|
||||
// ============================================================================
|
||||
|
||||
export interface CAKeyPair {
|
||||
/** CA private key in PEM format (keep this secret!) */
|
||||
privateKeyPem: string;
|
||||
/** CA public key in PEM format */
|
||||
publicKeyPem: string;
|
||||
/** CA public key in OpenSSH format (for TrustedUserCAKeys) */
|
||||
publicKeyOpenSSH: string;
|
||||
/** Raw CA public key bytes (Ed25519) */
|
||||
publicKeyRaw: Buffer;
|
||||
}
|
||||
|
||||
export interface SignedCertificate {
|
||||
/** The certificate in OpenSSH format (save as id_ed25519-cert.pub or similar) */
|
||||
certificate: string;
|
||||
/** The certificate type string */
|
||||
certType: string;
|
||||
/** Serial number */
|
||||
serial: bigint;
|
||||
/** Key ID */
|
||||
keyId: string;
|
||||
/** Valid principals */
|
||||
validPrincipals: string[];
|
||||
/** Valid from timestamp */
|
||||
validAfter: Date;
|
||||
/** Valid until timestamp */
|
||||
validBefore: Date;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate a new SSH Certificate Authority key pair.
|
||||
*
|
||||
* Returns the CA keys and the line to add to /etc/ssh/sshd_config:
|
||||
* TrustedUserCAKeys /etc/ssh/ca.pub
|
||||
*
|
||||
* Then save the publicKeyOpenSSH to /etc/ssh/ca.pub on the server.
|
||||
*
|
||||
* @param comment - Optional comment for the CA public key
|
||||
* @returns CA key pair and configuration info
|
||||
*/
|
||||
export function generateCA(comment: string = "ssh-ca"): CAKeyPair {
|
||||
// Generate Ed25519 key pair
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519", {
|
||||
publicKeyEncoding: { type: "spki", format: "pem" },
|
||||
privateKeyEncoding: { type: "pkcs8", format: "pem" }
|
||||
});
|
||||
|
||||
// Get raw public key bytes
|
||||
const pubKeyObj = crypto.createPublicKey(publicKey);
|
||||
const rawPubKey = pubKeyObj.export({ type: "spki", format: "der" });
|
||||
// Ed25519 SPKI format: 12 byte header + 32 byte key
|
||||
const ed25519PubKey = rawPubKey.subarray(rawPubKey.length - 32);
|
||||
|
||||
// Create OpenSSH format public key
|
||||
const pubKeyBlob = encodeEd25519PublicKey(ed25519PubKey);
|
||||
const publicKeyOpenSSH = formatOpenSSHPublicKey(pubKeyBlob, comment);
|
||||
|
||||
return {
|
||||
privateKeyPem: privateKey,
|
||||
publicKeyPem: publicKey,
|
||||
publicKeyOpenSSH,
|
||||
publicKeyRaw: ed25519PubKey
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get and decrypt the SSH CA keys for an organization.
|
||||
*
|
||||
* @param orgId - Organization ID
|
||||
* @param decryptionKey - Key to decrypt the CA private key (typically server.secret from config)
|
||||
* @returns CA key pair or null if not found
|
||||
*/
|
||||
export async function getOrgCAKeys(
|
||||
orgId: string,
|
||||
decryptionKey: string
|
||||
): Promise<CAKeyPair | null> {
|
||||
const { db, orgs } = await import("@server/db");
|
||||
const { eq } = await import("drizzle-orm");
|
||||
const { decrypt } = await import("@server/lib/crypto");
|
||||
|
||||
const [org] = await db
|
||||
.select({
|
||||
sshCaPrivateKey: orgs.sshCaPrivateKey,
|
||||
sshCaPublicKey: orgs.sshCaPublicKey
|
||||
})
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (!org || !org.sshCaPrivateKey || !org.sshCaPublicKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const privateKeyPem = decrypt(org.sshCaPrivateKey, decryptionKey);
|
||||
|
||||
// Extract raw public key from the OpenSSH format
|
||||
const { keyData } = parseOpenSSHPublicKey(org.sshCaPublicKey);
|
||||
const { newOffset } = decodeString(keyData, 0); // Skip key type
|
||||
const { value: publicKeyRaw } = decodeString(keyData, newOffset);
|
||||
|
||||
// Get PEM format of public key
|
||||
const pubKeyObj = crypto.createPublicKey({
|
||||
key: privateKeyPem,
|
||||
format: "pem"
|
||||
});
|
||||
const publicKeyPem = pubKeyObj.export({ type: "spki", format: "pem" }) as string;
|
||||
|
||||
return {
|
||||
privateKeyPem,
|
||||
publicKeyPem,
|
||||
publicKeyOpenSSH: org.sshCaPublicKey,
|
||||
publicKeyRaw
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a user's SSH public key with the CA, producing a certificate.
|
||||
*
|
||||
* The resulting certificate should be saved alongside the user's private key
|
||||
* with a -cert.pub suffix. For example:
|
||||
* - Private key: ~/.ssh/id_ed25519
|
||||
* - Certificate: ~/.ssh/id_ed25519-cert.pub
|
||||
*
|
||||
* @param caPrivateKeyPem - CA private key in PEM format
|
||||
* @param userPublicKeyLine - User's public key in OpenSSH format
|
||||
* @param options - Certificate options (principals, validity, etc.)
|
||||
* @returns Signed certificate
|
||||
*/
|
||||
export function signPublicKey(
|
||||
caPrivateKeyPem: string,
|
||||
userPublicKeyLine: string,
|
||||
options: CertificateOptions
|
||||
): SignedCertificate {
|
||||
// Parse the user's public key
|
||||
const { keyType, keyData } = parseOpenSSHPublicKey(userPublicKeyLine);
|
||||
|
||||
// Determine certificate type string
|
||||
let certTypeString: string;
|
||||
if (keyType === "ssh-ed25519") {
|
||||
certTypeString = "ssh-ed25519-cert-v01@openssh.com";
|
||||
} else if (keyType === "ssh-rsa") {
|
||||
certTypeString = "ssh-rsa-cert-v01@openssh.com";
|
||||
} else if (keyType === "ecdsa-sha2-nistp256") {
|
||||
certTypeString = "ecdsa-sha2-nistp256-cert-v01@openssh.com";
|
||||
} else if (keyType === "ecdsa-sha2-nistp384") {
|
||||
certTypeString = "ecdsa-sha2-nistp384-cert-v01@openssh.com";
|
||||
} else if (keyType === "ecdsa-sha2-nistp521") {
|
||||
certTypeString = "ecdsa-sha2-nistp521-cert-v01@openssh.com";
|
||||
} else {
|
||||
throw new Error(`Unsupported key type: ${keyType}`);
|
||||
}
|
||||
|
||||
// Get CA public key from private key
|
||||
const caPrivKey = crypto.createPrivateKey(caPrivateKeyPem);
|
||||
const caPubKey = crypto.createPublicKey(caPrivKey);
|
||||
const caRawPubKey = caPubKey.export({ type: "spki", format: "der" });
|
||||
const caEd25519PubKey = caRawPubKey.subarray(caRawPubKey.length - 32);
|
||||
const caPubKeyBlob = encodeEd25519PublicKey(caEd25519PubKey);
|
||||
|
||||
// Set defaults
|
||||
const serial = options.serial ?? BigInt(Date.now());
|
||||
const certType = options.certType ?? 1; // 1 = user cert
|
||||
const now = BigInt(Math.floor(Date.now() / 1000));
|
||||
const validAfter = options.validAfter ?? (now - 60n); // 1 minute ago
|
||||
const validBefore = options.validBefore ?? (now + 86400n * 365n); // 1 year from now
|
||||
|
||||
// Default extensions for user certificates
|
||||
const defaultExtensions = [
|
||||
"permit-X11-forwarding",
|
||||
"permit-agent-forwarding",
|
||||
"permit-port-forwarding",
|
||||
"permit-pty",
|
||||
"permit-user-rc"
|
||||
];
|
||||
const extensions = options.extensions ?? defaultExtensions;
|
||||
const criticalOptions = options.criticalOptions ?? new Map();
|
||||
|
||||
// Generate nonce (random bytes)
|
||||
const nonce = crypto.randomBytes(32);
|
||||
|
||||
// Extract the public key portion from the user's key blob
|
||||
// For Ed25519: skip the key type string, get the public key (already encoded)
|
||||
let userKeyPortion: Buffer;
|
||||
if (keyType === "ssh-ed25519") {
|
||||
// Skip the key type string, take the rest (which is encodeString(32-byte-key))
|
||||
const { newOffset } = decodeString(keyData, 0);
|
||||
userKeyPortion = keyData.subarray(newOffset);
|
||||
} else {
|
||||
// For other key types, extract everything after the key type
|
||||
const { newOffset } = decodeString(keyData, 0);
|
||||
userKeyPortion = keyData.subarray(newOffset);
|
||||
}
|
||||
|
||||
// Build the certificate body (to be signed)
|
||||
const certBody = Buffer.concat([
|
||||
encodeString(certTypeString),
|
||||
encodeString(nonce),
|
||||
userKeyPortion,
|
||||
encodeUInt64(serial),
|
||||
encodeUInt32(certType),
|
||||
encodeString(options.keyId),
|
||||
buildPrincipals(options.validPrincipals),
|
||||
encodeUInt64(validAfter),
|
||||
encodeUInt64(validBefore),
|
||||
buildCriticalOptions(criticalOptions),
|
||||
buildExtensions(extensions),
|
||||
encodeString(""), // reserved
|
||||
encodeString(caPubKeyBlob) // signature key (CA public key)
|
||||
]);
|
||||
|
||||
// Sign the certificate body
|
||||
const signature = crypto.sign(null, certBody, caPrivKey);
|
||||
|
||||
// Build the full signature blob (algorithm + signature)
|
||||
const signatureBlob = Buffer.concat([
|
||||
encodeString("ssh-ed25519"),
|
||||
encodeString(signature)
|
||||
]);
|
||||
|
||||
// Build complete certificate
|
||||
const certificate = Buffer.concat([
|
||||
certBody,
|
||||
encodeString(signatureBlob)
|
||||
]);
|
||||
|
||||
// Format as OpenSSH certificate line
|
||||
const certLine = `${certTypeString} ${certificate.toString("base64")} ${options.keyId}`;
|
||||
|
||||
return {
|
||||
certificate: certLine,
|
||||
certType: certTypeString,
|
||||
serial,
|
||||
keyId: options.keyId,
|
||||
validPrincipals: options.validPrincipals,
|
||||
validAfter: new Date(Number(validAfter) * 1000),
|
||||
validBefore: new Date(Number(validBefore) * 1000)
|
||||
};
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import * as logs from "#private/routers/auditLogs";
|
||||
import * as misc from "#private/routers/misc";
|
||||
import * as reKey from "#private/routers/re-key";
|
||||
import * as approval from "#private/routers/approvals";
|
||||
import * as ssh from "#private/routers/ssh";
|
||||
|
||||
import {
|
||||
verifyOrgAccess,
|
||||
@@ -506,3 +507,14 @@ authenticated.put(
|
||||
verifyUserHasAction(ActionsEnum.reGenerateSecret),
|
||||
reKey.reGenerateExitNodeSecret
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/ssh/sign-key",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.sshPam),
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
// verifyUserHasAction(ActionsEnum.signSshKey),
|
||||
logActionAudit(ActionsEnum.signSshKey),
|
||||
ssh.signSshKey
|
||||
);
|
||||
|
||||
14
server/private/routers/ssh/index.ts
Normal file
14
server/private/routers/ssh/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
export * from "./signSshKey";
|
||||
265
server/private/routers/ssh/signSshKey.ts
Normal file
265
server/private/routers/ssh/signSshKey.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, orgs, siteResources } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { eq, or } from "drizzle-orm";
|
||||
import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource";
|
||||
import { signPublicKey, getOrgCAKeys } from "#private/lib/sshCA";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty()
|
||||
});
|
||||
|
||||
const bodySchema = z
|
||||
.strictObject({
|
||||
publicKey: z.string().nonempty(),
|
||||
resourceId: z.number().int().positive().optional(),
|
||||
niceId: z.string().nonempty().optional(),
|
||||
alias: z.string().nonempty().optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
const fields = [data.resourceId, data.niceId, data.alias];
|
||||
const definedFields = fields.filter((field) => field !== undefined);
|
||||
return definedFields.length === 1;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Exactly one of resourceId, niceId, or alias must be provided"
|
||||
}
|
||||
);
|
||||
|
||||
export type SignSshKeyResponse = {
|
||||
certificate: string;
|
||||
sshUsername: string;
|
||||
sshHost: string;
|
||||
resourceId: number;
|
||||
keyId: string;
|
||||
validPrincipals: string[];
|
||||
validAfter: string;
|
||||
validBefore: string;
|
||||
expiresIn: number;
|
||||
};
|
||||
|
||||
// registry.registerPath({
|
||||
// method: "post",
|
||||
// path: "/org/{orgId}/ssh/sign-key",
|
||||
// description: "Sign an SSH public key for access to a resource.",
|
||||
// tags: [OpenAPITags.Org, OpenAPITags.Ssh],
|
||||
// request: {
|
||||
// params: paramsSchema,
|
||||
// body: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: bodySchema
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
// responses: {}
|
||||
// });
|
||||
|
||||
export async function signSshKey(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
const { publicKey, resourceId, niceId, alias } = parsedBody.data;
|
||||
const userId = req.user?.userId;
|
||||
const roleId = req.userOrgRoleId!;
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
// Get and decrypt the org's CA keys
|
||||
const caKeys = await getOrgCAKeys(
|
||||
orgId,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
|
||||
if (!caKeys) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
"SSH CA not configured for this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the resource exists and belongs to the org
|
||||
// Build the where clause dynamically based on which field is provided
|
||||
let whereClause;
|
||||
if (resourceId !== undefined) {
|
||||
whereClause = eq(siteResources.siteResourceId, resourceId);
|
||||
} else if (niceId !== undefined) {
|
||||
whereClause = eq(siteResources.niceId, niceId);
|
||||
} else if (alias !== undefined) {
|
||||
whereClause = eq(siteResources.alias, alias);
|
||||
} else {
|
||||
// This should never happen due to the schema validation, but TypeScript doesn't know that
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"One of resourceId, niceId, or alias must be provided"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [resource] = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(whereClause)
|
||||
.limit(1);
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (resource.orgId !== orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Resource does not belong to the specified organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the user has access to the resource
|
||||
const hasAccess = await canUserAccessSiteResource({
|
||||
userId: userId,
|
||||
resourceId: resource.siteResourceId,
|
||||
roleId: roleId
|
||||
});
|
||||
|
||||
if (!hasAccess) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have access to this resource"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let usernameToUse;
|
||||
if (req.user?.email) {
|
||||
// Extract username from email (first part before @)
|
||||
usernameToUse = req.user?.email.split("@")[0];
|
||||
if (!usernameToUse) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Unable to extract username from email"
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (req.user?.username) {
|
||||
usernameToUse = req.user.username;
|
||||
// We need to clean out any spaces or special characters from the username to ensure it's valid for SSH certificates
|
||||
usernameToUse = usernameToUse.replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
if (!usernameToUse) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Username is not valid for SSH certificate"
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"User does not have a valid email or username for SSH certificate"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Sign the public key
|
||||
const now = BigInt(Math.floor(Date.now() / 1000));
|
||||
// only valid for 5 minutes
|
||||
const validFor = 300n;
|
||||
|
||||
const cert = signPublicKey(caKeys.privateKeyPem, publicKey, {
|
||||
keyId: `${usernameToUse}@${orgId}`,
|
||||
validPrincipals: [usernameToUse, resource.niceId],
|
||||
validAfter: now - 60n, // Start 1 min ago for clock skew
|
||||
validBefore: now + validFor
|
||||
});
|
||||
|
||||
const expiresIn = Number(validFor); // seconds
|
||||
|
||||
return response<SignSshKeyResponse>(res, {
|
||||
data: {
|
||||
certificate: cert.certificate,
|
||||
sshUsername: usernameToUse,
|
||||
sshHost: resource.niceId,
|
||||
resourceId: resource.siteResourceId,
|
||||
keyId: cert.keyId,
|
||||
validPrincipals: cert.validPrincipals,
|
||||
validAfter: cert.validAfter.toISOString(),
|
||||
validBefore: cert.validBefore.toISOString(),
|
||||
expiresIn
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "SSH key signed successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error signing SSH key:", error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"An error occurred while signing the SSH key"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ import { FeatureId } from "@server/lib/billing";
|
||||
import { build } from "@server/build";
|
||||
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
|
||||
import { doCidrsOverlap } from "@server/lib/ip";
|
||||
import { generateCA } from "@server/private/lib/sshCA";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
|
||||
const createOrgSchema = z.strictObject({
|
||||
orgId: z.string(),
|
||||
@@ -143,6 +145,11 @@ export async function createOrg(
|
||||
.from(domains)
|
||||
.where(eq(domains.configManaged, true));
|
||||
|
||||
// Generate SSH CA keys for the org
|
||||
const ca = generateCA(`${orgId}-ca`);
|
||||
const encryptionKey = config.getRawConfig().server.secret!;
|
||||
const encryptedCaPrivateKey = encrypt(ca.privateKeyPem, encryptionKey);
|
||||
|
||||
const newOrg = await trx
|
||||
.insert(orgs)
|
||||
.values({
|
||||
@@ -150,7 +157,9 @@ export async function createOrg(
|
||||
name,
|
||||
subnet,
|
||||
utilitySubnet,
|
||||
createdAt: new Date().toISOString()
|
||||
createdAt: new Date().toISOString(),
|
||||
sshCaPrivateKey: encryptedCaPrivateKey,
|
||||
sshCaPublicKey: ca.publicKeyOpenSSH
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user