handshakes

This commit is contained in:
DecDuck
2024-10-08 18:08:34 +11:00
parent 7523e536b5
commit 2b4382d013
8 changed files with 130 additions and 13 deletions

View File

@ -0,0 +1,19 @@
/*
Warnings:
- The primary key for the `Client` table will be changed. If it partially fails, the table could be left without primary key constraint.
- You are about to drop the column `sharedToken` on the `Client` table. All the data in the column will be lost.
- The required column `id` was added to the `Client` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
- Added the required column `lastConnected` to the `Client` table without a default value. This is not possible if the table is not empty.
- Added the required column `name` to the `Client` table without a default value. This is not possible if the table is not empty.
- Added the required column `platform` to the `Client` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Client" DROP CONSTRAINT "Client_pkey",
DROP COLUMN "sharedToken",
ADD COLUMN "id" TEXT NOT NULL,
ADD COLUMN "lastConnected" TIMESTAMP(3) NOT NULL,
ADD COLUMN "name" TEXT NOT NULL,
ADD COLUMN "platform" TEXT NOT NULL,
ADD CONSTRAINT "Client_pkey" PRIMARY KEY ("id");

View File

@ -43,15 +43,16 @@ enum ClientCapabilities {
// References a device // References a device
model Client { model Client {
sharedToken String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
endpoint String endpoint String
capabilities ClientCapabilities[] capabilities ClientCapabilities[]
name String name String
platform String platform String
lastConnected DateTime
} }
enum MetadataSource { enum MetadataSource {

View File

@ -12,7 +12,7 @@ export default defineEventHandler(async (h3) => {
statusMessage: "Provide client ID in request params as 'id'", statusMessage: "Provide client ID in request params as 'id'",
}); });
const data = await clientHandler.fetchInitiateClientMetadata( const data = await clientHandler.fetchClientMetadata(
providedClientId providedClientId
); );
if (!data) if (!data)

View File

@ -7,7 +7,7 @@ export default defineEventHandler(async (h3) => {
const body = await readBody(h3); const body = await readBody(h3);
const clientId = await body.id; const clientId = await body.id;
const data = await clientHandler.fetchInitiateClientMetadata(clientId); const data = await clientHandler.fetchClientMetadata(clientId);
if (!data) if (!data)
throw createError({ throw createError({
statusCode: 400, statusCode: 400,

View File

@ -1,3 +1,46 @@
export default defineEventHandler((h3) => { import clientHandler from "~/server/internal/clients/handler";
import { useGlobalCertificateAuthority } from "~/server/plugins/ca";
}) export default defineEventHandler(async (h3) => {
const body = await readBody(h3);
const clientId = body.clientId;
const token = body.token;
if (!clientId || !token)
throw createError({
statusCode: 400,
statusMessage: "Missing token or client ID from body",
});
const metadata = await clientHandler.fetchClient(clientId);
if (!metadata)
throw createError({
statusCode: 403,
statusMessage: "Invalid client ID",
});
if (!metadata.authToken || !metadata.userId)
throw createError({
statusCode: 400,
statusMessage: "Un-authorized client ID",
});
if (metadata.authToken !== token)
throw createError({
statusCode: 403,
statusMessage: "Invalid token",
});
const ca = useGlobalCertificateAuthority();
const bundle = await ca.generateClientCertificate(
clientId,
metadata.data.name
);
const client = await clientHandler.finialiseClient(clientId);
await ca.storeClientCertificate(clientId, bundle);
return {
private: bundle.priv,
public: bundle.pub,
certificate: bundle.cert,
id: client.id,
};
});

View File

@ -17,7 +17,7 @@ Client makes request: `POST /api/v1/client/handshake` with the token recieved in
The server uses it's CA to generate a public-private key pair, the CN of the client ID. It then sends that pair, plus the CA's public key, to the client, which stores it all. The server uses it's CA to generate a public-private key pair, the CN of the client ID. It then sends that pair, plus the CA's public key, to the client, which stores it all.
The certificate lasts for a year, and is rotated when it has 3 months or less left on it's expiry. *The certificate lasts for a year, and is rotated when it has 3 months or less left on it's expiry.*
## 4.a Client requests one-time device endpoint ## 4.a Client requests one-time device endpoint
The client generates a nonce and signs it with their private key. This is then attached to any device-related request. The client generates a nonce and signs it with their private key. This is then attached to any device-related request.

View File

@ -31,4 +31,26 @@ export class CertificateAuthority {
} }
return new CertificateAuthority(store, root); return new CertificateAuthority(store, root);
} }
async generateClientCertificate(clientId: string, clientName: string) {
const caCertificate = await this.certificateStore.fetch("ca");
if (!caCertificate)
throw new Error("Certificate authority not initialised");
const [priv, pub, cert] = droplet.generateClientCertificate(
clientId,
clientName,
caCertificate.cert,
caCertificate.priv
);
const certBundle: CertificateBundle = {
priv,
pub,
cert,
};
return certBundle;
}
async storeClientCertificate(clientId: string, bundle: CertificateBundle) {
await this.certificateStore.store(`client:${clientId}`, bundle);
}
} }

View File

@ -1,4 +1,6 @@
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { CertificateBundle } from "./ca";
import prisma from "../db/database";
export interface ClientMetadata { export interface ClientMetadata {
name: string; name: string;
@ -29,10 +31,14 @@ export class ClientHandler {
return clientId; return clientId;
} }
async fetchInitiateClientMetadata(clientId: string) { async fetchClientMetadata(clientId: string) {
return (await this.fetchClient(clientId))?.data;
}
async fetchClient(clientId: string) {
const entry = this.temporaryClientTable[clientId]; const entry = this.temporaryClientTable[clientId];
if (!entry) return undefined; if (!entry) return undefined;
return entry.data; return entry;
} }
async attachUserId(clientId: string, userId: string) { async attachUserId(clientId: string, userId: string) {
@ -50,6 +56,32 @@ export class ClientHandler {
return token; return token;
} }
async fetchClientMetadataByToken(token: string) {
return Object.entries(this.temporaryClientTable)
.map((e) => Object.assign(e[1], { id: e[0] }))
.find((e) => e.authToken === token);
}
async finialiseClient(id: string) {
const metadata = this.temporaryClientTable[id];
if (!metadata) throw new Error("Invalid client ID");
if (!metadata.userId) throw new Error("Un-authorized client ID");
return await prisma.client.create({
data: {
id: id,
userId: metadata.userId,
endpoint: "",
capabilities: [],
name: metadata.data.name,
platform: metadata.data.platform,
lastConnected: new Date(),
},
});
}
} }
export const clientHandler = new ClientHandler(); export const clientHandler = new ClientHandler();