Skip to content

Commit

Permalink
Device authority
Browse files Browse the repository at this point in the history
  • Loading branch information
oscartbeaumont committed Oct 21, 2024
1 parent 2a972f0 commit 94beb72
Show file tree
Hide file tree
Showing 9 changed files with 177 additions and 223 deletions.
141 changes: 98 additions & 43 deletions apps/api/src/authority/index.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,107 @@
import { getObject } from "~/aws/s3";
import { env } from "~/env";
import { identityCertificate, identityPrivateKey } from "~/win/common";
import { gt, lt } from "drizzle-orm";
import type { pki } from "node-forge";
import { db, deviceAuthorities } from "~/db";

// 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.
// We cache between invocations of the code
let certCache:
| {
publicKey: string;
privateKey: string;
cachedAt: Date;
}
| undefined = undefined;
let truststores:
| {
certs: pki.Certificate[];
cachedAt: Date;
}
| undefined = undefined;

export const TRUSTSTORE_BUCKET_REGION = "us-east-1";
export const TRUSTSTORE_ACTIVE_AUTHORITY = "authority";
const cacheValidityPeriod = 15 * 60 * 1000; // 15 minutes

// 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");

export async function getActiveAuthority(shouldRenew = false) {
const { pki } = (await import("node-forge")).default;

// return [
// pki.certificateFromPem(parts[0]!),
// pki.privateKeyFromPem(parts[1]!),
// ] as const;
if (
!certCache ||
// Refresh the local cache if it's older than 15 minutes
(certCache.cachedAt.getTime() - new Date().getTime()) / 1000 / 60 > 15
) {
let [result] = await db
.select({
publicKey: deviceAuthorities.publicKey,
privateKey: deviceAuthorities.privateKey,
expiresAt: deviceAuthorities.expiresAt,
})
.from(deviceAuthorities)
// We want to grab the latest certificate
.orderBy(deviceAuthorities.createdAt)
// but we avoid using any certs issued for a while to ensure it has time to propagate
// because we don't wanna sign the device cert and then the device talks with a server without the new cert yet.
.where(
gt(
deviceAuthorities.expiresAt,
new Date(new Date().getTime() - cacheValidityPeriod * 2),
),
)
.limit(1); // TODO: Filter out new certs for a while to ensure they can progate once issued

if (!result) result = await (await import("./issue")).issueAuthority();

// If the authority is 15 days from expiring and asked renew it
if (
// We explicitly require this so that we don't run into race conditions if many devices are trying to enroll at once.
// The expectation is this is only set in a cron job.
shouldRenew &&
(result.expiresAt.getTime() - new Date().getTime()) /
1000 /
60 /
60 /
24 <
15
) {
console.log(
`Detected authority certificate is about to expire at ${result.expiresAt.toString()}, renewing`,
);
result = await (await import("./issue")).issueAuthority();
}

certCache = {
publicKey: result.publicKey,
privateKey: result.privateKey,
cachedAt: new Date(),
};
}

return [
pki.certificateFromPem(identityCertificate),
pki.privateKeyFromPem(identityPrivateKey),
];
pki.certificateFromPem(certCache.publicKey),
pki.privateKeyFromPem(certCache.privateKey),
] as const;
}

// Get all of the public keys for active MDM authority certificates
export async function getAuthorityTruststore() {
if (
!truststores ||
// Refresh the local cache if it's older than 15 minutes
truststores.cachedAt.getTime() - new Date().getTime() > cacheValidityPeriod
) {
const { pki } = (await import("node-forge")).default;

const result = await db
.select({
publicKey: deviceAuthorities.publicKey,
})
.from(deviceAuthorities)
// We only want to trust certificates that are still valid
.where(gt(deviceAuthorities.expiresAt, new Date()));

truststores = {
certs: result.map((v) => pki.certificateFromPem(v.publicKey)),
cachedAt: new Date(),
};
}

return truststores.certs;
}
110 changes: 17 additions & 93 deletions apps/api/src/authority/issue.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,9 @@
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!",
);
import { db, deviceAuthorities } from "~/db";

export async function issueAuthority() {
console.log("Issueing a new MDM authority certificate");

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

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

Expand Down Expand Up @@ -47,87 +36,22 @@ export async function issueAuthority(existingTruststore: string | undefined) {

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}`,
},
},
);
const publicKey = pki.certificateToPem(cert);
const privateKey = pki.privateKeyToPem(keys.privateKey);
const expiresAt = cert.validity.notAfter;

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,
},
});
await db.insert(deviceAuthorities).values({
publicKey,
privateKey,
createdAt: cert.validity.notBefore,
expiresAt,
});

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")}`;
return {
publicKey,
privateKey,
expiresAt,
};
}
9 changes: 9 additions & 0 deletions apps/api/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,3 +221,12 @@ export const deviceActions = mysqlTable(
pk: primaryKey({ columns: [table.action, table.devicePk] }),
}),
);

// The certificate authorities are used to sign the device certificates.
export const deviceAuthorities = mysqlTable("device_authority", {
id: serial("id").primaryKey(),
publicKey: varchar("public", { length: 5048 }).notNull(),
privateKey: varchar("private", { length: 5048 }).notNull(),
createdAt: timestamp("created_at").notNull(),
expiresAt: timestamp("expires_at").notNull(),
});
20 changes: 13 additions & 7 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ 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 { getActiveAuthority } from "./authority";
import { createTRPCContext, router } from "./trpc";
import { waitlistRouter } from "./waitlist";
import { enrollmentServerRouter, managementServerRouter } from "./win";
import { env } from "./env";

declare module "solid-js/web" {
interface RequestEvent {
Expand Down Expand Up @@ -53,14 +54,19 @@ 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)
.get("/api/__cron", async (c) => {
if (c.req.query("secret") !== env.INTERNAL_SECRET) {
c.status(403);
return c.json({ error: "Forbidden" });
}

// TODO: Hook this up to a proper Cloudflare CRON
await getActiveAuthority(true);

return c.text("ok");
})
.all("/api/trpc", async (c) => {
const opts: TrpcServerFunctionOpts = await c.req.json();
const result = await trpcServerFunction({
Expand Down
51 changes: 0 additions & 51 deletions apps/api/src/win/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,54 +3,3 @@ export const d = new TextDecoder();

// microsoftDeviceIDExtension contains the OID for the Microsoft certificate extension which includes the MDM DeviceID
export const microsoftDeviceIDExtension = "1.3.6.1.4.1.311.66.1.0";

// TODO: Remove these
export const identityCertificate = `-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBAgIJF1U3FWYXGZFwMA0GCSqGSIb3DQEBCwUAMDUxITAfBgNV
BAMTGE1hdHRyYXggRGV2aWNlIEF1dGhvcml0eTEQMA4GA1UEChMHTWF0dHJheDAe
Fw0yNDA5MjIwNDA4NTBaFw0yNTA5MjIwNDA4NTBaMDUxITAfBgNVBAMTGE1hdHRy
YXggRGV2aWNlIEF1dGhvcml0eTEQMA4GA1UEChMHTWF0dHJheDCCASIwDQYJKoZI
hvcNAQEBBQADggEPADCCAQoCggEBAMYODsbdMa0h/TG1qqnywiKvsXHNcB2YgQQo
fJ67y3O35YpVq5UBpXLWCYRwCZPq7cQn3lwJeiMG/57F8OfPIQEid9LchU2OxrM3
BfG33VOt/Uhzqkl4+5i/vse9Cq6H9VRRV4hLHc2kLIhZoafASFDWp3kZ5MQlO6Mc
cyXRMByqx8NJXVusFtuxi0MJh+w7idzHlvX7SurCuxH2yLh7Tdb62EN5AAgE8nvp
BoUL0tv7f97PbcIT5VdM6Plkj1OkOESmfvhbBJKLHzVBd5EDfxCDpB6gNIDxz5G+
E9qeV84l4DiRSzvDJ8QOYFi5Nz/I666LDE67fzFYL0oQnrKygCkCAwEAAaNwMG4w
DAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCAvQwOwYDVR0lBDQwMgYIKwYBBQUH
AwEGCCsGAQUFBwMCBggrBgEFBQcDAwYIKwYBBQUHAwQGCCsGAQUFBwMIMBEGCWCG
SAGG+EIBAQQEAwIA9zANBgkqhkiG9w0BAQsFAAOCAQEATiPSXBqef4q65dFtA65A
hqpQLxWmPNUg/mfpLp1SNG6SryEotow26jzrr7GPdbKZOr+rN/ATML8qSprOnIRp
Ybpbfw/Kj6MiwomThck0z3BrRUihVfmgE7xSVF9ddldvuD2YFNmKNuFwmoXWOyl+
DDr+5Lhd/uNxWG94Zl79o56CBVHSXR0xoCg34fbm6raTXMFgrz4cKbXuujAtWamn
uZWaf8POwnnXvSxadhiP1gBIjadTRcDhNz0yaapInTd0MTJRNDNU32pJuS5rqyis
Pyn1uevw3zXQo30G+7EpEXfHTu36ZXaofRzP8JBFy6emfaaxhbbr6bFz7jCuCZH2
7A==
-----END CERTIFICATE-----`;

export const identityPrivateKey = `-----BEGIN RSA PRIVATE KEY-----
MIIEogIBAAKCAQEAxg4Oxt0xrSH9MbWqqfLCIq+xcc1wHZiBBCh8nrvLc7flilWr
lQGlctYJhHAJk+rtxCfeXAl6Iwb/nsXw588hASJ30tyFTY7GszcF8bfdU639SHOq
SXj7mL++x70Krof1VFFXiEsdzaQsiFmhp8BIUNaneRnkxCU7oxxzJdEwHKrHw0ld
W6wW27GLQwmH7DuJ3MeW9ftK6sK7EfbIuHtN1vrYQ3kACATye+kGhQvS2/t/3s9t
whPlV0zo+WSPU6Q4RKZ++FsEkosfNUF3kQN/EIOkHqA0gPHPkb4T2p5XziXgOJFL
O8MnxA5gWLk3P8jrrosMTrt/MVgvShCesrKAKQIDAQABAoIBABcfaUl9pdj2Jsi8
0lLi3xg8XanBD8m+xjJJdUo1d+J32f5Rc/WysmGivEv4Zh/dPdrowjDJ4PbxsFqV
tsNjlvF6WBW3zf7g8FEYV5Lx0c7dGXzx1xPaonoiCcipSRPZFvL8B62HgpBhjlrx
ZvqOLAs3EmiktaUSSUEI92cXAghYkeJ8K5ZY/FW/f5rvpWcmNT6xgAnmZwXTCK1M
+YIiUsOoGvhhAGqTy34mgKz3Wz2XQvFlkYHyQsDs+rwhjUJ07DU8QRQbFRAiZFz+
C25BImF9wqDGA3NvK186gLgKhIanyVI3yGpYeLkdvbMZijoYvs0L5QpWF5szO248
KPiqr5ECgYEA5Ft7EI6TQNL4xHEGk7PnsZwbfH61kS/cqWOyGTjpTK+bj9Ti73JT
yh/nQtoAODfZ9JtBMq4uQhmR3O64R2rOR1NEqkcaUf/yrOXRJy9uOKzqWcUuYqr1
1UEIn8r2Rm8EFURl/mI4H4X72xOM3/l/o44wAeucXz4f23OpChALMpECgYEA3geK
gvgYtWvgRksiPy94UDg/15YtUPciQpGG5rV0uUhMnQB7HOHRl8bIwONTKfTLSs9f
Ro65nNxztHc8FlMM7qzNhH+HWM9deyJx5tHGK1rdnsvdyiRsMbVeCJAtesNISXvL
0r6a04bBx6s4d4YGVewYksI0c7kxRZb07xUZkBkCgYBlRxVbkIBKfccLCL3MADxA
D2Y5XtEUJVAOaELy4MTH2BJ8RgSoAeKbgG7GvXzfchXeYIUX/xxRAJoqjE16jyoR
hCKuCn7n40Yz3HFYmbaeuEHvsn4SEJSbEg7LH+796fq7m+xIWDNf98JttUwDgdpU
JZmxIFfn/duPLsrHxbnRoQKBgBnroXUGx8OuU2GBdf8QaKhc2L8vbhzsrRg+axRW
DMlwslkF7FmD13czos45+8SDKpSSPxo6oVq5tdxUqzQj//eBPwD/7mok01IDxG5h
ARSgqAzY2gy2Udc/yDmRs22IjNDfXf09eU/GhKrtx0rU37p6NKg1efAkp6brJ68d
tH1JAoGAWvMZui+oa18Ywt4aEs8Kcxx0vV8/bsuyR10tt8pAk1gbXiZJAdKN1MR5
evvP3sALp8D0SHDH7ZXfzBlpM5x/dKpeO1DIOulLwJokoCxfxsO0PdyPwjc3jf1d
EngVLjaGOxQV5YQBI54HI15P2/rBOz+9Os7puPnJVjjL2p3QURU=
-----END RSA PRIVATE KEY-----`;
Loading

0 comments on commit 94beb72

Please sign in to comment.