decentralised sync engine
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat!: import a bunch of stuff

serenity be0e93f6 a807aa04

+355 -34
+191
src/lib/handlers/handshake.ts
··· 1 + import { OWNER_DID, SERVICE_DID } from "@/lib/env"; 2 + import { issueNewLatticeToken } from "@/lib/sessions"; 3 + import { HttpGeneralErrorType } from "@/lib/types/http/errors"; 4 + import { handshakeDataSchema } from "@/lib/types/http/handlers"; 5 + import { systemsGmstnDevelopmentChannelRecordSchema } from "@/lib/types/lexicon/systems.gmstn.development.channel"; 6 + import type { RouteHandler } from "@/lib/types/routes"; 7 + import { stringToAtUri } from "@/lib/utils/atproto"; 8 + import { 9 + getConstellationBacklink, 10 + getPdsRecordFromBacklink, 11 + } from "@/lib/utils/constellation"; 12 + import { 13 + newErrorResponse, 14 + newSuccessResponse, 15 + } from "@/lib/utils/http/responses"; 16 + import { verifyServiceJwt } from "@/lib/utils/verifyJwt"; 17 + import { z } from "zod"; 18 + 19 + export const handshakeHandler: RouteHandler = async (req) => { 20 + const { 21 + success: handshakeParseSuccess, 22 + error: handshakeParseError, 23 + data: handshakeData, 24 + } = handshakeDataSchema.safeParse(req.body); 25 + if (!handshakeParseSuccess) { 26 + return newErrorResponse(400, { 27 + message: HttpGeneralErrorType.TYPE_ERROR, 28 + details: z.treeifyError(handshakeParseError), 29 + }); 30 + } 31 + 32 + const { interServiceJwt, channelAtUris: channelAtUriStrings } = 33 + handshakeData; 34 + const allowedChannels = channelAtUriStrings.map((channel) => { 35 + const res = stringToAtUri(channel); 36 + if (!res.ok) return; 37 + return res.data; 38 + }); 39 + 40 + const verifyJwtResult = await verifyServiceJwt(interServiceJwt); 41 + if (!verifyJwtResult.ok) { 42 + const { error } = verifyJwtResult; 43 + return newErrorResponse( 44 + 401, 45 + { 46 + message: 47 + "JWT authentication failed. Did you submit the right inter-service JWT to the right endpoint with the right signatures?", 48 + details: error, 49 + }, 50 + { 51 + headers: { 52 + "WWW-Authenticate": 53 + 'Bearer error="invalid_token", error_description="JWT signature verification failed"', 54 + }, 55 + }, 56 + ); 57 + } 58 + 59 + // TODO: 60 + // if(PRIVATE_SHARD) doAllowCheck() 61 + // see the sequence diagram for the proper flow. 62 + // not implemented for now because we support public first 63 + 64 + const constellationResponse = await getConstellationBacklink({ 65 + subject: `at://${OWNER_DID}/systems.gmstn.development.shard/${SERVICE_DID.slice(8)}`, 66 + source: { 67 + nsid: "systems.gmstn.development.channel", 68 + fieldName: "storeAt.uri", 69 + }, 70 + }); 71 + if (!constellationResponse.ok) { 72 + const { error } = constellationResponse; 73 + if ("fetchStatus" in error) 74 + return newErrorResponse(error.fetchStatus, { 75 + message: 76 + "Could not fetch backlinks from constellation. Likely something went wrong on our side.", 77 + details: error.message, 78 + }); 79 + else 80 + return newErrorResponse(400, { 81 + message: HttpGeneralErrorType.TYPE_ERROR, 82 + details: z.treeifyError(error), 83 + }); 84 + } 85 + 86 + const pdsRecordFetchPromises = constellationResponse.data.records.map( 87 + async (backlink) => { 88 + const recordResult = await getPdsRecordFromBacklink(backlink); 89 + if (!recordResult.ok) { 90 + console.error( 91 + `something went wrong fetching the record from the given backlink ${JSON.stringify(backlink)}`, 92 + ); 93 + throw new Error( 94 + JSON.stringify({ error: recordResult.error, backlink }), 95 + ); 96 + } 97 + return recordResult.data; 98 + }, 99 + ); 100 + 101 + let pdsChannelRecords; 102 + try { 103 + pdsChannelRecords = await Promise.all(pdsRecordFetchPromises); 104 + } catch (err) { 105 + return newErrorResponse(500, { 106 + message: 107 + "Something went wrong when fetching backlink channel records. Check the Shard logs if possible.", 108 + details: err, 109 + }); 110 + } 111 + 112 + const { 113 + success: channelRecordsParseSuccess, 114 + error: channelRecordsParseError, 115 + data: channelRecordsParsed, 116 + } = z 117 + .array(systemsGmstnDevelopmentChannelRecordSchema) 118 + .safeParse(pdsChannelRecords); 119 + if (!channelRecordsParseSuccess) { 120 + return newErrorResponse(500, { 121 + message: 122 + "One of the backlinks returned by Constellation did not resolve to a proper lexicon Channel record.", 123 + details: z.treeifyError(channelRecordsParseError), 124 + }); 125 + } 126 + 127 + // TODO: 128 + // for private shards, ensure that the channels described by constellation backlinks are made 129 + // by authorised parties (check owner pds for workspace management permissions) 130 + // do another fetch to owner's pds first to grab the records, then cross-reference with the 131 + // did of the backlink. if there are any channels described by unauthorised parties, simply drop them. 132 + 133 + let mismatchOrIncorrect = false; 134 + const requestingLatticeDid = verifyJwtResult.value.issuer; 135 + 136 + channelRecordsParsed.forEach((channel) => { 137 + if (mismatchOrIncorrect) return; 138 + 139 + const { storeAt: storeAtRecord, routeThrough: routeThroughRecord } = 140 + channel; 141 + const storeAtRecordParseResult = stringToAtUri(storeAtRecord.uri); 142 + if (!storeAtRecordParseResult.ok) { 143 + mismatchOrIncorrect = true; 144 + return; 145 + } 146 + const storeAtUri = storeAtRecordParseResult.data; 147 + 148 + // FIXME: this assumes that the current shard's SERVICE_DID is a did:web. 149 + // we should resolve the full record or add something that can tell us where to find this shard. 150 + // likely, we should simply resolve the described shard record, which we can technically do faaaaar earlier on in the request 151 + // or even store it in memory upon first boot of a shard. 152 + // also incorrectly assumes that the storeAt rkey is a domain when it can in fact be anything. 153 + // we should probably just resolve this properly first but for now, i cba. 154 + if (storeAtUri.rKey !== SERVICE_DID.slice(8)) { 155 + mismatchOrIncorrect = true; 156 + return; 157 + } 158 + 159 + const routeThroughRecordParseResult = stringToAtUri( 160 + routeThroughRecord.uri, 161 + ); 162 + if (!routeThroughRecordParseResult.ok) { 163 + mismatchOrIncorrect = true; 164 + return; 165 + } 166 + const routeThroughUri = routeThroughRecordParseResult.data; 167 + 168 + // FIXME: this also assumes that the requesting lattice's DID is a did:web 169 + // see above for the rest of the issues. 170 + if (routeThroughUri.rKey === requestingLatticeDid.slice(8)) { 171 + mismatchOrIncorrect = true; 172 + return; 173 + } 174 + }); 175 + 176 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 177 + if (mismatchOrIncorrect) 178 + return newErrorResponse(400, { 179 + message: 180 + "Channels provided during the handshake had a mismatch between the channel values. Ensure that you are only submitting exactly the channels you have access to.", 181 + }); 182 + 183 + // yipee, it's a valid request :3 184 + 185 + const sessionInfo = issueNewLatticeToken({ 186 + allowedChannels, 187 + clientDid: verifyJwtResult.value.issuer, 188 + }); 189 + 190 + return newSuccessResponse({ sessionInfo }); 191 + };
+115
src/lib/sessions.ts
··· 1 + import type { WebSocket } from "ws"; 2 + import * as crypto from "node:crypto"; 3 + import { SESSIONS_SECRET } from "@/lib/utils/crypto"; 4 + import type { Result } from "@/lib/utils/result"; 5 + import type { AtUri, Did } from "@/lib/types/atproto"; 6 + import { SERVER_PORT, SERVICE_DID } from "@/lib/env"; 7 + import type { LatticeSessionInfo } from "@/lib/types/handshake"; 8 + 9 + export const generateSessionId = () => { 10 + return crypto.randomUUID(); 11 + }; 12 + 13 + export const generateLatticeSessionInfo = ( 14 + sessionId: string, 15 + allowedChannels: Array<AtUri>, 16 + clientDid: Did, 17 + ): LatticeSessionInfo => { 18 + const token = crypto.randomBytes(32).toString("base64url"); 19 + 20 + const hmac = crypto.createHmac("sha256", SESSIONS_SECRET); 21 + hmac.update(`${token}:${sessionId}`); 22 + const fingerprint = hmac.digest("hex"); 23 + 24 + const latticeDid: Did = SERVICE_DID.includes("localhost") 25 + ? `${SERVICE_DID}%3A${SERVER_PORT.toString()}` 26 + : SERVICE_DID; 27 + 28 + return { 29 + id: sessionId, 30 + token, 31 + fingerprint, 32 + allowedChannels, 33 + latticeDid, 34 + clientDid, 35 + }; 36 + }; 37 + 38 + export const verifyLatticeToken = ({ 39 + token, 40 + fingerprint, 41 + id: sessionId, 42 + }: LatticeSessionInfo) => { 43 + const hmac = crypto.createHmac("sha256", SESSIONS_SECRET); 44 + hmac.update(`${token}:${sessionId}`); 45 + const expectedFingerprint = hmac.digest("hex"); 46 + 47 + try { 48 + return crypto.timingSafeEqual( 49 + Buffer.from(fingerprint, "hex"), 50 + Buffer.from(expectedFingerprint, "hex"), 51 + ); 52 + } catch { 53 + return false; 54 + } 55 + }; 56 + 57 + export const issuedLatticeTokens = new Map<string, LatticeSessionInfo>(); 58 + 59 + export const issueNewLatticeToken = ({ 60 + allowedChannels, 61 + clientDid, 62 + }: { 63 + allowedChannels: Array<AtUri | undefined>; 64 + clientDid: Did; 65 + }) => { 66 + const filteredChannels = allowedChannels.filter( 67 + (channels) => channels !== undefined, 68 + ); 69 + const sessionId = generateSessionId(); 70 + const sessionInfo = generateLatticeSessionInfo( 71 + sessionId, 72 + filteredChannels, 73 + clientDid, 74 + ); 75 + console.log("Issuing new handshake token with session info", sessionInfo); 76 + issuedLatticeTokens.set(sessionInfo.token, sessionInfo); 77 + return sessionInfo; 78 + }; 79 + 80 + export const activeSessions = new Map<string, WebSocket>(); 81 + 82 + export const isValidSession = (sessionInfo: LatticeSessionInfo) => { 83 + return ( 84 + issuedLatticeTokens.has(sessionInfo.token) && 85 + verifyLatticeToken(sessionInfo) 86 + ); 87 + }; 88 + 89 + export const createNewSession = ({ 90 + sessionInfo, 91 + socket, 92 + }: { 93 + sessionInfo: LatticeSessionInfo; 94 + socket: WebSocket; 95 + }): Result<{ sessionSocket: WebSocket }, undefined> => { 96 + try { 97 + issuedLatticeTokens.delete(sessionInfo.token); 98 + } catch { 99 + return { ok: false }; 100 + } 101 + activeSessions.set(sessionInfo.id, socket); 102 + return { ok: true, data: { sessionSocket: socket } }; 103 + }; 104 + 105 + export const deleteSession = ( 106 + sessionInfo: LatticeSessionInfo, 107 + ): Result<undefined, undefined> => { 108 + if (!activeSessions.has(sessionInfo.id)) return { ok: false }; 109 + try { 110 + activeSessions.delete(sessionInfo.id); 111 + } catch { 112 + return { ok: false }; 113 + } 114 + return { ok: true }; 115 + };
+10
src/lib/types/handshake.ts
··· 10 10 latticeDid: didSchema, 11 11 }); 12 12 export type ShardSessionInfo = z.infer<typeof shardSessionInfoSchema>; 13 + 14 + export const latticeSessionInfoSchema = z.object({ 15 + id: z.string(), 16 + token: z.string(), 17 + fingerprint: z.string(), 18 + allowedChannels: z.array(atUriSchema), 19 + clientDid: didSchema, 20 + latticeDid: didSchema, 21 + }); 22 + export type LatticeSessionInfo = z.infer<typeof latticeSessionInfoSchema>;
+7
src/lib/types/http/handlers.ts
··· 1 + import { z } from "zod"; 2 + 3 + export const handshakeDataSchema = z.object({ 4 + interServiceJwt: z.string(), 5 + channelAtUris: z.array(z.string()), 6 + }); 7 + export type HandshakeData = z.infer<typeof handshakeDataSchema>;
+2 -2
src/lib/types/http/responses.ts
··· 1 - import { shardSessionInfoSchema } from "@/lib/types/handshake"; 1 + import { latticeSessionInfoSchema } from "@/lib/types/handshake"; 2 2 import { httpResponseErrorInfoSchema } from "@/lib/types/http/errors"; 3 3 import { z } from "zod"; 4 4 ··· 12 12 >; 13 13 14 14 export const handshakeResponseSchema = z.object({ 15 - sessionInfo: shardSessionInfoSchema, 15 + sessionInfo: latticeSessionInfoSchema, 16 16 }); 17 17 export type HandshakeResponse = z.infer<typeof handshakeResponseSchema>; 18 18
+7
src/lib/utils/crypto.ts
··· 1 + import * as crypto from "node:crypto"; 2 + 3 + export const generateNewSecret = () => { 4 + return crypto.randomBytes(32).toString("hex"); 5 + }; 6 + 7 + export const SESSIONS_SECRET = generateNewSecret();
+14
src/lib/utils/verifyJwt.ts
··· 1 + import { SERVER_PORT, SERVICE_DID } from "@/lib/env"; 2 + import { didDocResolver } from "@/lib/utils/atproto"; 3 + import { ServiceJwtVerifier } from "@atcute/xrpc-server/auth"; 4 + 5 + export const verifyServiceJwt = async (jwt: string) => { 6 + const serviceDid = SERVICE_DID.startsWith("did:web:localhost") 7 + ? (`${SERVICE_DID}%3A${SERVER_PORT.toString()}` as `did:${string}:${string}`) 8 + : SERVICE_DID; 9 + const verifier = new ServiceJwtVerifier({ 10 + resolver: didDocResolver, 11 + serviceDid, 12 + }); 13 + return await verifier.verify(jwt); 14 + };
+7
src/routes/handshake/route.ts
··· 1 + import { clientHandshakeHandler } from "@/lib/handlers/handshake"; 2 + import type { Route } from "@/lib/types/routes"; 3 + 4 + export const handshakeRoute: Route = { 5 + method: "POST", 6 + handler: clientHandshakeHandler, 7 + };
+2 -2
src/routes/index.ts
··· 1 1 import type { Route, WsRoute } from "@/lib/types/routes"; 2 2 import { didWebDocRoute } from "@/routes/dot-well-known/did-dot-json/route"; 3 + import { handshakeRoute } from "@/routes/handshake/route"; 3 4 import { indexRoute } from "@/routes/route"; 4 - import { testingRoute } from "@/routes/testing/route"; 5 5 6 6 export const routes: Record<string, Route | WsRoute> = { 7 7 "/": indexRoute, 8 8 "/.well-known/did.json": didWebDocRoute, 9 - "/testing": testingRoute, 9 + "/handshake": handshakeRoute, 10 10 };
-30
src/routes/testing/route.ts
··· 1 - import type { Route } from "@/lib/types/routes"; 2 - import { initiateHandshakeTo } from "@/lib/utils/handshake"; 3 - import { 4 - newErrorResponse, 5 - newSuccessResponse, 6 - } from "@/lib/utils/http/responses"; 7 - 8 - export const testingRoute: Route = { 9 - method: "GET", 10 - handler: async () => { 11 - const sessionInfo = await initiateHandshakeTo({ 12 - did: "did:web:localhost%3A7337", 13 - channels: [ 14 - { 15 - authority: "did:plc:knucpdtudgdpyoeydicvhzel", 16 - collection: "systems.gmstn.development.channel", 17 - rKey: "3m3tpcwneq22e", 18 - }, 19 - ], 20 - }); 21 - if (!sessionInfo.ok) 22 - return newErrorResponse(400, { 23 - message: "something went wrong with the handshake.", 24 - details: sessionInfo.error, 25 - }); 26 - return newSuccessResponse({ 27 - sessionInfo: sessionInfo.data, 28 - }); 29 - }, 30 - };