Skip to content

Commit

Permalink
fix wap provisioning profile generation + ms-mdm wip
Browse files Browse the repository at this point in the history
  • Loading branch information
oscartbeaumont committed Oct 18, 2024
1 parent d24b71b commit 2ad161d
Show file tree
Hide file tree
Showing 45 changed files with 1,463 additions and 164 deletions.
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"editor.defaultFormatter": "biomejs.biome"
}
52 changes: 52 additions & 0 deletions apps/api/src/authority/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { getObject } from "~/aws/s3";
import { env } from "~/env";
import { identityCertificate, identityPrivateKey } from "~/win/common";

// TODO: For better self-hosting we are probs gonna wanna have R2 as our main storage and replicate to S3 where required.
// Why S3? Because API Gateway handles TLS terminations and it requires the certificate pool to be in S3.

export const TRUSTSTORE_BUCKET_REGION = "us-east-1";
export const TRUSTSTORE_ACTIVE_AUTHORITY = "authority";

// Get the public and private keypair for the active MDM authority certificates used for issuing new client certificates.
export async function getMDMAuthority() {
// if (!env.TRUSTSTORE_BUCKET) return undefined;

// const activeAuthority = await getObject(
// env.TRUSTSTORE_BUCKET,
// TRUSTSTORE_BUCKET_REGION,
// TRUSTSTORE_ACTIVE_AUTHORITY,
// {
// // This is okay. Search for the `REF[0]` comment for explanation.
// // @ts-expect-error // TODO: Fix this type error
// cf: {
// // Cache for 1 day
// cacheTtl: 24 * 60 * 60,
// cacheEverything: true,
// },
// },
// );
// let activeAuthorityRaw: string;
// if (activeAuthority.status === 404) {
// activeAuthorityRaw = await (await import("./issue")).issueAuthority("");
// } else if (!activeAuthority.ok)
// throw new Error(
// `Failed to get '${TRUSTSTORE_ACTIVE_AUTHORITY}' from bucket '${env.TRUSTSTORE_BUCKET}' with status ${activeAuthority.statusText}: ${await activeAuthority.text()}`,
// );
// else activeAuthorityRaw = await activeAuthority.text();

// const parts = activeAuthorityRaw.split("\n---\n");
// if (parts.length !== 2) throw new Error("Authority file is malformed");

const { pki } = (await import("node-forge")).default;

// return [
// pki.certificateFromPem(parts[0]!),
// pki.privateKeyFromPem(parts[1]!),
// ] as const;

return [
pki.certificateFromPem(identityCertificate),
pki.privateKeyFromPem(identityPrivateKey),
];
}
133 changes: 133 additions & 0 deletions apps/api/src/authority/issue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { updateDomainName } from "~/aws/apiGateway";
import { putObject } from "~/aws/s3";
import { env } from "~/env";
import { TRUSTSTORE_ACTIVE_AUTHORITY, TRUSTSTORE_BUCKET_REGION } from ".";

export const TRUSTSTORE_POOL = "truststore.pem";

export async function issueAuthority(existingTruststore: string | undefined) {
// It's intended that the caller handle this properly
if (!env.TRUSTSTORE_BUCKET)
throw new Error(
"Attempted to issue authority and 'TRUSTSTORE_BUCKET' not set. This should be unreachable!",
);

console.log("Issueing a new MDM authority certificate");

const { asn1, md, pki } = (await import("node-forge")).default;

const keys = pki.rsa.generateKeyPair(4096);

const cert = pki.createCertificate();
cert.publicKey = keys.publicKey;
cert.serialNumber = "01";
cert.validity.notBefore = new Date();
cert.validity.notAfter = new Date();
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1);
const attrs = [
{ name: "commonName", value: "Mattrax Device Authority" },
{ name: "organizationName", value: "Mattrax Inc." },
];
cert.setSubject(attrs);
cert.setIssuer(attrs);

cert.setExtensions([
{
name: "basicConstraints",
critical: true,
cA: true,
},
{
name: "keyUsage",
critical: true,
keyCertSign: true,
cRLSign: true,
},
]);

cert.sign(keys.privateKey, md.sha256.create());

const certPem = pki.certificateToPem(cert);
const keyPem = pki.privateKeyToPem(keys.privateKey);
const activeAuthority = `${certPem}\n---\n${keyPem}`;
const date = new Date();

// If we start issuing an authority on the minute boundary we could end up with two authorities being issued at the same time (one for each minute),
// as the running on this function for the first minute will overlap into the second minute.
// This will wait up to 10 seconds if needed to ensure we are not running near the minute boundary. It will suck for UX but this should be a very rare case to hit.
// This acts as a mitigation but if this function takes more than 10 seconds it's still *technically* possible for a race condition.
// In reality this is stupidly unlikely and is an acceptable risk for now.
if (date.getSeconds() > 50)
await new Promise((resolve) =>
setTimeout(resolve, (60 - date.getSeconds()) * 1000),
);

// We do this first to ensure we always have a proper backup of *any* issued authority
// It's possible we generate this authority and fail to switch to it as the active one
// and that's fine, we just can't afford the inverse of setting an active authority and not having a backup!
await putObject(
env.TRUSTSTORE_BUCKET,
TRUSTSTORE_BUCKET_REGION,
`history/${constructKey(date)}`,
activeAuthority,
{
headers: {
// Due to the fact the key is keyed to the current minute of the day, and we prevent a put if the key already exists,
// we can be pretty certain we aren't going to be issuing two authorities at the same time. Refer to the comment above for more info about the minute boundary.
"If-None-Match": "*",
"Cache-Control": "private, max-age=604800, immutable",
},
},
);

// We ensure we update the truststore before we set this identity as the active one to ensure no disruption to the service.
const truststorePoolPut = await putObject(
env.TRUSTSTORE_BUCKET,
TRUSTSTORE_BUCKET_REGION,
TRUSTSTORE_POOL,
`${existingTruststore ? `${existingTruststore}\n` : ""}${certPem}`,
);
console.log(truststorePoolPut.headers); // TODO
const truststoreVersion = truststorePoolPut.headers.get("x-amz-version-id");
if (!truststoreVersion) throw new Error("Failed to get truststore version");

// REF[0]
// It's okay to cache the active authority, but it's *never* okay to cache the truststore pool.
// When the active authority changes, it's fine if device certificates remain being issued with the old authority for a short period of time.
// However, if the truststore doesn't reflect the active authority being used, clients will be unable to communicate with the management server until the cache expires.

// Technically these can be done at the same time but API gateway takes a bit to pick up the S3 change so this helps to delay it.
await putObject(
env.TRUSTSTORE_BUCKET,
TRUSTSTORE_BUCKET_REGION,
TRUSTSTORE_ACTIVE_AUTHORITY,
activeAuthority,
{
headers: {
"Cache-Control": `private, max-age=${24 * 60 * 60}`,
},
},
);

if (env.API_GATEWAY_DOMAIN && env.TRUSTSTORE_BUCKET)
await updateDomainName(env.API_GATEWAY_DOMAIN!, "us-east-1", {
domainNameConfigurations: [
{
endpointType: "REGIONAL",
certificateArn: env.CERTIFICATE_ARN!,
securityPolicy: "TLS_1_2",
},
],
mutualTlsAuthentication: {
truststoreUri: `s3://${env.TRUSTSTORE_BUCKET}/${TRUSTSTORE_POOL}`,
truststoreVersion,
},
});

console.log("Successfully issued a new MDM authority certificate");
return activeAuthority;
}

function constructKey(date: Date) {
return `${date.getFullYear().toFixed(0).padStart(4, "0")}-${(date.getMonth() + 1).toFixed(0).padStart(2, "0")}-${date.getDate().toFixed(0).padStart(2, "0")}T${date.getHours().toFixed(0).padStart(2, "0")}:${date.getMinutes().toFixed(0).padStart(2, "0")}`;
}
27 changes: 27 additions & 0 deletions apps/api/src/aws/apiGateway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { aws } from ".";

// TODO
export async function updateDomainName(
domain: string,
region: string,
body: any,
) {
if (!aws.client)
throw new Error("Attempted updateDomainName without valid AWS credentials");
const resp = await aws.client.fetch(
`https://apigateway.${region}.amazonaws.com/v2/domainnames/${domain}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
},
);
if (!resp.ok)
throw new Error(
`Failed to update '${domain}' with status ${resp.statusText}: ${await resp.text()}`,
);
// TODO
console.log(await resp.text());
}
17 changes: 2 additions & 15 deletions apps/api/src/emails.ts → apps/api/src/aws/emails.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,6 @@
import type { RequestSchema } from "@mattrax/email";
import { AwsClient } from "aws4fetch";
import { env, withEnv } from "~/env";

const aws = withEnv(() => {
let client: AwsClient | undefined;

if (env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY)
client = new AwsClient({
region: "us-east-1",
accessKeyId: env.AWS_ACCESS_KEY_ID,
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
});

return { client };
});
import { env } from "~/env";
import { aws } from ".";

export async function sendEmail(args: RequestSchema) {
if (env.FROM_ADDRESS === "console") {
Expand Down
14 changes: 14 additions & 0 deletions apps/api/src/aws/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { AwsClient } from "aws4fetch";
import { env, withEnv } from "~/env";

export const aws = withEnv(() => {
let client: AwsClient | undefined;

if (env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY)
client = new AwsClient({
accessKeyId: env.AWS_ACCESS_KEY_ID,
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
});

return { client };
});
42 changes: 42 additions & 0 deletions apps/api/src/aws/s3.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { aws } from ".";

// Get an object from an S3 bucket
// *Note* all error handling is left to the caller as it's very context specific.
export async function getObject(
bucketName: string,
region: string,
key: string,
params?: RequestInit,
) {
if (!aws.client)
throw new Error("Attempted getObject without valid AWS credentials");
return await aws.client.fetch(
`https://${bucketName}.s3.${region}.amazonaws.com/${key}`,
params,
);
}

// Put an object into an S3 bucket
export async function putObject(
bucketName: string,
region: string,
key: string,
body: BodyInit,
params?: RequestInit,
) {
if (!aws.client)
throw new Error("Attempted getObject without valid AWS credentials");
const resp = await aws.client.fetch(
`https://${bucketName}.s3.${region}.amazonaws.com/${key}`,
{
method: "PUT",
body,
...params,
},
);
if (!resp.ok)
throw new Error(
`Failed to put to bucket '${bucketName}' object '${key}': ${resp.statusText}`,
);
return resp;
}
6 changes: 1 addition & 5 deletions apps/api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,6 @@ export const env = withEnv((env) => {
AXIOM_DATASET: z.string().optional(),
AXIOM_API_TOKEN: z.string().optional(),

// API gateway
API_GATEWAY_ARN: z.string().optional(),
TRUSTSTORE_BUCKET: z.string().optional(),

// Discord webhooks
WAITLIST_DISCORD_WEBHOOK_URL: z.string().optional(),
FEEDBACK_DISCORD_WEBHOOK_URL: z.string().optional(),
Expand All @@ -52,7 +48,7 @@ export const env = withEnv((env) => {
VITE_PROD_ORIGIN: z.string(),
},
runtimeEnv,
skipValidation: env.SKIP_ENV_VALIDATION === "true",
// skipValidation: env.SKIP_ENV_VALIDATION === "true",
emptyStringAsUndefined: true,
});
});
Expand Down
17 changes: 12 additions & 5 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { HTTPException } from "hono/http-exception";
import { logger } from "hono/logger";
import type { BlankEnv, BlankInput } from "hono/types";
import { provideRequestEvent } from "solid-js/web/storage";
import { getMDMAuthority } from "./authority";
import { createTRPCContext, router } from "./trpc";
import { waitlistRouter } from "./waitlist";
import { enrollmentServerRouter, managementServerRouter } from "./win";
Expand All @@ -24,7 +25,7 @@ declare module "solid-js/web" {
}
}

const GIT_SHA = (import.meta.env as any).GIT_SHA || "unknown";
const GIT_SHA = (import.meta.env as any)?.GIT_SHA || "unknown";

const app = new Hono()
.onError((err, c) => {
Expand All @@ -39,7 +40,7 @@ const app = new Hono()
hono: c,
request: c.req.raw,
waitUntil: c.executionCtx.waitUntil as any,
env: { ...(c.env as any), ...process.env },
env: c.env as any,
// SS's stuff is still being injected via Vite.
locals: {},
response: c.res,
Expand All @@ -52,6 +53,12 @@ const app = new Hono()
if (import.meta.env.DEV) app.use(logger());

app
// TODO: Remove thi
.get("/api/todo", async (c) => {
console.log(await getMDMAuthority());

return c.json({ todo: "todo" });
})
.get("/api/__version", (c) => c.json(GIT_SHA))
.route("/api/waitlist", waitlistRouter)
.all("/api/trpc", async (c) => {
Expand Down Expand Up @@ -116,7 +123,7 @@ console.log = (...args) => {
};
const _error = console.error;
console.error = (...args) => {
_warn(...args);
_error(...args);
trace.getActiveSpan()?.addEvent("error", { args });
};
const _warn = console.warn;
Expand All @@ -132,8 +139,8 @@ console.trace = (...args) => {

export default {
fetch: async (request: Request, env: Env, ctx: ExecutionContext) => {
// biome-ignore lint/style/noParameterAssign:
if (!env) env = process.env;
// biome-ignore lint/style/noParameterAssign: In dev you get `ctx` as `env` type thing so we override.
if (import.meta.env.DEV) env = process.env;
if (!ctx)
// biome-ignore lint/style/noParameterAssign:
ctx = {
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/trpc/routers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import {
createSession,
logout,
} from "~/auth";
import { sendEmail } from "~/aws/emails";
import { accountLoginCodes, accounts, db, tenants } from "~/db";
import { sendEmail } from "~/emails";
import { env } from "~/env";
import { authedProcedure, createTRPCRouter, publicProcedure } from "../helpers";
import { sendDiscordMessage } from "./meta";
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/trpc/routers/tenant/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { waitUntil } from "@mattrax/trpc-server-function/server";
import { TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { sendEmail } from "~/aws/emails";
import { accounts, tenantInvites, tenantMembers, tenants } from "~/db";
import { sendEmail } from "~/emails";
import { env } from "~/env";
import {
createTRPCRouter,
Expand Down
Loading

0 comments on commit 2ad161d

Please sign in to comment.