From 8464617b5f13cea9aa12a85d04118f0a96430487 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Wed, 24 Jan 2024 23:31:11 -0500 Subject: [PATCH 1/4] update bsky proto w/ identity methods --- packages/bsky/proto/bsky.proto | 29 +++ packages/bsky/src/proto/bsky_connect.ts | 24 +++ packages/bsky/src/proto/bsky_pb.ts | 268 ++++++++++++++++++++++++ 3 files changed, 321 insertions(+) diff --git a/packages/bsky/proto/bsky.proto b/packages/bsky/proto/bsky.proto index a02aca73aff..b9d0c4f055f 100644 --- a/packages/bsky/proto/bsky.proto +++ b/packages/bsky/proto/bsky.proto @@ -851,6 +851,31 @@ message GetLatestRevResponse { string rev = 1; } + +message GetIdentityByDidRequest { + string did = 1; +} +message GetIdentityByDidResponse { + string did = 1; + string handle = 2; + bytes keys = 3; + bytes services = 4; + google.protobuf.Timestamp updated = 5; +} + +message GetIdentityByHandleRequest { + string handle = 1; +} +message GetIdentityByHandleResponse { + string handle = 1; + string did = 2; + bytes keys = 3; + bytes services = 4; + google.protobuf.Timestamp updated = 5; +} + + + // // Moderation // @@ -1003,6 +1028,10 @@ service Service { rpc GetRecordTakedown(GetRecordTakedownRequest) returns (GetRecordTakedownResponse); rpc GetActorTakedown(GetActorTakedownRequest) returns (GetActorTakedownResponse); + // Identity + rpc GetIdentityByDid(GetIdentityByDidRequest) returns (GetIdentityByDidResponse); + rpc GetIdentityByHandle(GetIdentityByHandleRequest) returns (GetIdentityByHandleResponse); + // Ping rpc Ping(PingRequest) returns (PingResponse); diff --git a/packages/bsky/src/proto/bsky_connect.ts b/packages/bsky/src/proto/bsky_connect.ts index 53a98c3828f..1dbea106631 100644 --- a/packages/bsky/src/proto/bsky_connect.ts +++ b/packages/bsky/src/proto/bsky_connect.ts @@ -68,6 +68,10 @@ import { GetFollowsResponse, GetFollowSuggestionsRequest, GetFollowSuggestionsResponse, + GetIdentityByDidRequest, + GetIdentityByDidResponse, + GetIdentityByHandleRequest, + GetIdentityByHandleResponse, GetInteractionCountsRequest, GetInteractionCountsResponse, GetLabelsRequest, @@ -758,6 +762,26 @@ export const Service = { O: GetActorTakedownResponse, kind: MethodKind.Unary, }, + /** + * Identity + * + * @generated from rpc bsky.Service.GetIdentityByDid + */ + getIdentityByDid: { + name: 'GetIdentityByDid', + I: GetIdentityByDidRequest, + O: GetIdentityByDidResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc bsky.Service.GetIdentityByHandle + */ + getIdentityByHandle: { + name: 'GetIdentityByHandle', + I: GetIdentityByHandleRequest, + O: GetIdentityByHandleResponse, + kind: MethodKind.Unary, + }, /** * Ping * diff --git a/packages/bsky/src/proto/bsky_pb.ts b/packages/bsky/src/proto/bsky_pb.ts index 6051d85f635..02c1c890ee8 100644 --- a/packages/bsky/src/proto/bsky_pb.ts +++ b/packages/bsky/src/proto/bsky_pb.ts @@ -8406,6 +8406,274 @@ export class GetLatestRevResponse extends Message { } } +/** + * @generated from message bsky.GetIdentityByDidRequest + */ +export class GetIdentityByDidRequest extends Message { + /** + * @generated from field: string did = 1; + */ + did = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetIdentityByDidRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetIdentityByDidRequest { + return new GetIdentityByDidRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetIdentityByDidRequest { + return new GetIdentityByDidRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetIdentityByDidRequest { + return new GetIdentityByDidRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetIdentityByDidRequest + | PlainMessage + | undefined, + b: + | GetIdentityByDidRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetIdentityByDidRequest, a, b) + } +} + +/** + * @generated from message bsky.GetIdentityByDidResponse + */ +export class GetIdentityByDidResponse extends Message { + /** + * @generated from field: string did = 1; + */ + did = '' + + /** + * @generated from field: string handle = 2; + */ + handle = '' + + /** + * @generated from field: bytes keys = 3; + */ + keys = new Uint8Array(0) + + /** + * @generated from field: bytes services = 4; + */ + services = new Uint8Array(0) + + /** + * @generated from field: google.protobuf.Timestamp updated = 5; + */ + updated?: Timestamp + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetIdentityByDidResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'handle', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 3, name: 'keys', kind: 'scalar', T: 12 /* ScalarType.BYTES */ }, + { no: 4, name: 'services', kind: 'scalar', T: 12 /* ScalarType.BYTES */ }, + { no: 5, name: 'updated', kind: 'message', T: Timestamp }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetIdentityByDidResponse { + return new GetIdentityByDidResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetIdentityByDidResponse { + return new GetIdentityByDidResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetIdentityByDidResponse { + return new GetIdentityByDidResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetIdentityByDidResponse + | PlainMessage + | undefined, + b: + | GetIdentityByDidResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetIdentityByDidResponse, a, b) + } +} + +/** + * @generated from message bsky.GetIdentityByHandleRequest + */ +export class GetIdentityByHandleRequest extends Message { + /** + * @generated from field: string handle = 1; + */ + handle = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetIdentityByHandleRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'handle', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetIdentityByHandleRequest { + return new GetIdentityByHandleRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetIdentityByHandleRequest { + return new GetIdentityByHandleRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetIdentityByHandleRequest { + return new GetIdentityByHandleRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetIdentityByHandleRequest + | PlainMessage + | undefined, + b: + | GetIdentityByHandleRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetIdentityByHandleRequest, a, b) + } +} + +/** + * @generated from message bsky.GetIdentityByHandleResponse + */ +export class GetIdentityByHandleResponse extends Message { + /** + * @generated from field: string handle = 1; + */ + handle = '' + + /** + * @generated from field: string did = 2; + */ + did = '' + + /** + * @generated from field: bytes keys = 3; + */ + keys = new Uint8Array(0) + + /** + * @generated from field: bytes services = 4; + */ + services = new Uint8Array(0) + + /** + * @generated from field: google.protobuf.Timestamp updated = 5; + */ + updated?: Timestamp + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetIdentityByHandleResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'handle', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 3, name: 'keys', kind: 'scalar', T: 12 /* ScalarType.BYTES */ }, + { no: 4, name: 'services', kind: 'scalar', T: 12 /* ScalarType.BYTES */ }, + { no: 5, name: 'updated', kind: 'message', T: Timestamp }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetIdentityByHandleResponse { + return new GetIdentityByHandleResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetIdentityByHandleResponse { + return new GetIdentityByHandleResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetIdentityByHandleResponse { + return new GetIdentityByHandleResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetIdentityByHandleResponse + | PlainMessage + | undefined, + b: + | GetIdentityByHandleResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetIdentityByHandleResponse, a, b) + } +} + /** * @generated from message bsky.GetBlobTakedownRequest */ From cc1027e2ec99c0b1001e592686f6be832d13a6fc Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Thu, 25 Jan 2024 00:33:53 -0500 Subject: [PATCH 2/4] setup identity endpoints on mock dataplane --- packages/bsky/src/data-plane/server/index.ts | 9 ++- .../src/data-plane/server/routes/identity.ts | 59 +++++++++++++++++++ .../src/data-plane/server/routes/index.ts | 57 +++++++++--------- packages/dev-env/src/bsky.ts | 6 +- 4 files changed, 101 insertions(+), 30 deletions(-) create mode 100644 packages/bsky/src/data-plane/server/routes/identity.ts diff --git a/packages/bsky/src/data-plane/server/index.ts b/packages/bsky/src/data-plane/server/index.ts index a488b045d0d..fb730a177ba 100644 --- a/packages/bsky/src/data-plane/server/index.ts +++ b/packages/bsky/src/data-plane/server/index.ts @@ -4,6 +4,7 @@ import express from 'express' import { expressConnectMiddleware } from '@connectrpc/connect-express' import createRoutes from './routes' import { Database } from './db' +import { IdResolver, MemoryCache } from '@atproto/identity' export { DidSqlCache } from './did-cache' export { RepoSubscription } from './subscription' @@ -11,9 +12,13 @@ export { RepoSubscription } from './subscription' export class DataPlaneServer { constructor(public server: http.Server) {} - static async create(db: Database, port: number) { + static async create(db: Database, port: number, plcUrl?: string) { const app = express() - const routes = createRoutes(db) + const idResolver = new IdResolver({ + plcUrl, + didCache: new MemoryCache(), + }) + const routes = createRoutes(db, idResolver) app.use(expressConnectMiddleware({ routes })) const server = app.listen(port) await events.once(server, 'listening') diff --git a/packages/bsky/src/data-plane/server/routes/identity.ts b/packages/bsky/src/data-plane/server/routes/identity.ts new file mode 100644 index 00000000000..fb8dffc096c --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/identity.ts @@ -0,0 +1,59 @@ +import { Code, ConnectError, ServiceImpl } from '@connectrpc/connect' +import { Service } from '../../../proto/bsky_connect' +import { Database } from '../db' +import { DidDocument, IdResolver, getDid, getHandle } from '@atproto/identity' +import { Timestamp } from '@bufbuild/protobuf' + +export default ( + _db: Database, + idResolver: IdResolver, +): Partial> => ({ + async getIdentityByDid(req) { + const doc = await idResolver.did.resolve(req.did) + if (!doc) { + throw new ConnectError('identity not found', Code.NotFound) + } + return getResultFromDoc(doc) + }, + + async getIdentityByHandle(req) { + const did = await idResolver.handle.resolve(req.handle) + if (!did) { + throw new ConnectError('identity not found', Code.NotFound) + } + const doc = await idResolver.did.resolve(did) + if (!doc || did !== getDid(doc)) { + throw new ConnectError('identity not found', Code.NotFound) + } + return getResultFromDoc(doc) + }, +}) + +const getResultFromDoc = (doc: DidDocument) => { + const keys: Record = {} + doc.verificationMethod?.forEach((method) => { + const id = method.id.split('#').at(1) + if (!id) return + keys[id] = { + Type: method.type, + PublicKeyMultibase: method.publicKeyMultibase || '', + } + }) + const services: Record = {} + doc.service?.forEach((service) => { + const id = service.id.split('#').at(1) + if (!id) return + if (typeof service.serviceEndpoint !== 'string') return + services[id] = { + Type: service.type, + URL: service.serviceEndpoint, + } + }) + return { + did: getDid(doc), + handle: getHandle(doc), + keys: Buffer.from(JSON.stringify(keys)), + services: Buffer.from(JSON.stringify(services)), + updated: Timestamp.fromDate(new Date()), + } +} diff --git a/packages/bsky/src/data-plane/server/routes/index.ts b/packages/bsky/src/data-plane/server/routes/index.ts index b4dee889eff..6169a48a28d 100644 --- a/packages/bsky/src/data-plane/server/routes/index.ts +++ b/packages/bsky/src/data-plane/server/routes/index.ts @@ -1,10 +1,11 @@ import { ConnectRouter } from '@connectrpc/connect' +import { IdResolver } from '@atproto/identity' import { Service } from '../../../proto/bsky_connect' - import blocks from './blocks' import feedGens from './feed-gens' import feeds from './feeds' import follows from './follows' +import identity from './identity' import interactions from './interactions' import labels from './labels' import likes from './likes' @@ -23,30 +24,32 @@ import sync from './sync' import threads from './threads' import { Database } from '../db' -export default (db: Database) => (router: ConnectRouter) => - router.service(Service, { - ...blocks(db), - ...feedGens(db), - ...feeds(db), - ...follows(db), - ...interactions(db), - ...labels(db), - ...likes(db), - ...lists(db), - ...moderation(db), - ...mutes(db), - ...notifs(db), - ...posts(db), - ...profile(db), - ...records(db), - ...relationships(db), - ...reposts(db), - ...search(db), - ...suggestions(db), - ...sync(db), - ...threads(db), +export default (db: Database, idResolver: IdResolver) => + (router: ConnectRouter) => + router.service(Service, { + ...blocks(db), + ...feedGens(db), + ...feeds(db), + ...follows(db), + ...identity(db, idResolver), + ...interactions(db), + ...labels(db), + ...likes(db), + ...lists(db), + ...moderation(db), + ...mutes(db), + ...notifs(db), + ...posts(db), + ...profile(db), + ...records(db), + ...relationships(db), + ...reposts(db), + ...search(db), + ...suggestions(db), + ...sync(db), + ...threads(db), - async ping() { - return {} - }, - }) + async ping() { + return {} + }, + }) diff --git a/packages/dev-env/src/bsky.ts b/packages/dev-env/src/bsky.ts index 3ca7142e967..614dbf60899 100644 --- a/packages/dev-env/src/bsky.ts +++ b/packages/dev-env/src/bsky.ts @@ -42,7 +42,11 @@ export class TestBsky { }) const dataplanePort = await getPort() - const dataplane = await bsky.DataPlaneServer.create(db, dataplanePort) + const dataplane = await bsky.DataPlaneServer.create( + db, + dataplanePort, + cfg.plcUrl, + ) const bsyncPort = await getPort() const bsync = await bsky.MockBsync.create(db, bsyncPort) From b9aa60d52a693cb83d71c2bf1516c43d8701af6f Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Thu, 25 Jan 2024 14:09:52 -0500 Subject: [PATCH 3/4] move from idresolver to dataplane for identity lookups on appview --- .../bsky/src/api/app/bsky/feed/getFeed.ts | 29 ++-- .../src/api/app/bsky/feed/getFeedGenerator.ts | 33 +++-- packages/bsky/src/api/blob-resolver.ts | 25 +++- packages/bsky/src/auth-verifier.ts | 29 +++- packages/bsky/src/context.ts | 5 - packages/bsky/src/data-plane/client.ts | 57 +++++++- .../bsky/src/data-plane/server/did-cache.ts | 110 --------------- packages/bsky/src/data-plane/server/index.ts | 11 +- packages/bsky/src/index.ts | 22 ++- packages/bsky/tests/admin/admin-auth.test.ts | 29 ++-- packages/bsky/tests/auth.test.ts | 3 +- packages/bsky/tests/blob-resolver.test.ts | 16 ++- .../bsky/tests/data-plane/did-cache.test.ts | 128 ------------------ .../data-plane/handle-invalidation.test.ts | 6 +- packages/dev-env/src/bsky.ts | 5 +- packages/dev-env/src/util.ts | 1 + packages/identity/src/did/atproto-data.ts | 6 + 17 files changed, 201 insertions(+), 314 deletions(-) delete mode 100644 packages/bsky/src/data-plane/server/did-cache.ts delete mode 100644 packages/bsky/tests/data-plane/did-cache.test.ts diff --git a/packages/bsky/src/api/app/bsky/feed/getFeed.ts b/packages/bsky/src/api/app/bsky/feed/getFeed.ts index d997397e9a1..27da13439ab 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeed.ts @@ -6,11 +6,6 @@ import { serverTimingHeader, } from '@atproto/xrpc-server' import { ResponseType, XRPCError } from '@atproto/xrpc' -import { - DidDocument, - PoorlyFormattedDidDocumentError, - getFeedGen, -} from '@atproto/identity' import { AtpAgent, AppBskyFeedGetFeedSkeleton } from '@atproto/api' import { noUndefinedVals } from '@atproto/common' import { QueryParams as GetFeedParams } from '../../../../lexicon/types/app/bsky/feed/getFeed' @@ -26,6 +21,13 @@ import { createPipeline, } from '../../../../pipeline' import { FeedItem } from '../../../../hydration/feed' +import { GetIdentityByDidResponse } from '../../../../proto/bsky_pb' +import { + Code, + getServiceEndpoint, + isDataplaneError, + unpackIdentityServices, +} from '../../../../data-plane' export default function (server: Server, ctx: AppContext) { const getFeed = createPipeline( @@ -157,20 +159,21 @@ const skeletonFromFeedGen = async ( throw new InvalidRequestError('could not find feed') } - let resolved: DidDocument | null + let identity: GetIdentityByDidResponse try { - resolved = await ctx.idResolver.did.resolve(feedDid) + identity = await ctx.dataplane.getIdentityByDid({ did: feedDid }) } catch (err) { - if (err instanceof PoorlyFormattedDidDocumentError) { - throw new InvalidRequestError(`invalid did document: ${feedDid}`) + if (isDataplaneError(err, Code.NotFound)) { + throw new InvalidRequestError(`could not resolve identity: ${feedDid}`) } throw err } - if (!resolved) { - throw new InvalidRequestError(`could not resolve did document: ${feedDid}`) - } - const fgEndpoint = getFeedGen(resolved) + const services = unpackIdentityServices(identity.services) + const fgEndpoint = getServiceEndpoint(services, { + id: 'bsky_fg', + type: 'BskyFeedGenerator', + }) if (!fgEndpoint) { throw new InvalidRequestError( `invalid feed generator service details in did document: ${feedDid}`, diff --git a/packages/bsky/src/api/app/bsky/feed/getFeedGenerator.ts b/packages/bsky/src/api/app/bsky/feed/getFeedGenerator.ts index 7c0ff2a7e13..57f86af9a28 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeedGenerator.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeedGenerator.ts @@ -1,11 +1,13 @@ import { InvalidRequestError } from '@atproto/xrpc-server' -import { - DidDocument, - PoorlyFormattedDidDocumentError, - getFeedGen, -} from '@atproto/identity' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { GetIdentityByDidResponse } from '../../../../proto/bsky_pb' +import { + Code, + getServiceEndpoint, + isDataplaneError, + unpackIdentityServices, +} from '../../../../data-plane' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getFeedGenerator({ @@ -21,22 +23,23 @@ export default function (server: Server, ctx: AppContext) { } const feedDid = feedInfo.record.did - let resolved: DidDocument | null + let identity: GetIdentityByDidResponse try { - resolved = await ctx.idResolver.did.resolve(feedDid) + identity = await ctx.dataplane.getIdentityByDid({ did: feedDid }) } catch (err) { - if (err instanceof PoorlyFormattedDidDocumentError) { - throw new InvalidRequestError(`invalid did document: ${feedDid}`) + if (isDataplaneError(err, Code.NotFound)) { + throw new InvalidRequestError( + `could not resolve identity: ${feedDid}`, + ) } throw err } - if (!resolved) { - throw new InvalidRequestError( - `could not resolve did document: ${feedDid}`, - ) - } - const fgEndpoint = getFeedGen(resolved) + const services = unpackIdentityServices(identity.services) + const fgEndpoint = getServiceEndpoint(services, { + id: 'bsky_fg', + type: 'BskyFeedGenerator', + }) if (!fgEndpoint) { throw new InvalidRequestError( `invalid feed generator service details in did document: ${feedDid}`, diff --git a/packages/bsky/src/api/blob-resolver.ts b/packages/bsky/src/api/blob-resolver.ts index 8256025962d..1a2d1ee560d 100644 --- a/packages/bsky/src/api/blob-resolver.ts +++ b/packages/bsky/src/api/blob-resolver.ts @@ -9,6 +9,12 @@ import { DidNotFoundError } from '@atproto/identity' import AppContext from '../context' import { httpLogger as log } from '../logger' import { retryHttp } from '../util/retry' +import { + Code, + getServiceEndpoint, + isDataplaneError, + unpackIdentityServices, +} from '../data-plane' // Resolve and verify blob from its origin host @@ -77,10 +83,25 @@ export const createRouter = (ctx: AppContext): express.Router => { export async function resolveBlob(ctx: AppContext, did: string, cid: CID) { const cidStr = cid.toString() - const [{ pds }, { takenDown }] = await Promise.all([ - ctx.idResolver.did.resolveAtprotoData(did), + const [identity, { takenDown }] = await Promise.all([ + ctx.dataplane.getIdentityByDid({ did }).catch((err) => { + if (isDataplaneError(err, Code.NotFound)) { + return undefined + } + throw err + }), ctx.dataplane.getBlobTakedown({ did, cid: cid.toString() }), ]) + const services = identity && unpackIdentityServices(identity.services) + const pds = + services && + getServiceEndpoint(services, { + id: 'atproto_pds', + type: 'AtprotoPersonalDataServer', + }) + if (!pds) { + throw createError(404, 'Origin not found') + } if (takenDown) { throw createError(404, 'Blob not found') } diff --git a/packages/bsky/src/auth-verifier.ts b/packages/bsky/src/auth-verifier.ts index 5a2bf753072..5de16432c88 100644 --- a/packages/bsky/src/auth-verifier.ts +++ b/packages/bsky/src/auth-verifier.ts @@ -2,9 +2,16 @@ import { AuthRequiredError, verifyJwt as verifyServiceJwt, } from '@atproto/xrpc-server' -import { IdResolver } from '@atproto/identity' import * as ui8 from 'uint8arrays' import express from 'express' +import { + Code, + DataPlaneClient, + getKeyAsDidKey, + isDataplaneError, + unpackIdentityKeys, +} from './data-plane' +import { GetIdentityByDidResponse } from './proto/bsky_pb' type ReqCtx = { req: express.Request @@ -63,7 +70,7 @@ export class AuthVerifier { public ownDid: string public adminDid: string - constructor(public idResolver: IdResolver, opts: AuthVerifierOpts) { + constructor(public dataplane: DataPlaneClient, opts: AuthVerifierOpts) { this._adminPass = opts.adminPass this._moderatorPass = opts.moderatorPass this._triagePass = opts.triagePass @@ -191,12 +198,26 @@ export class AuthVerifier { ) { const getSigningKey = async ( did: string, - forceRefresh: boolean, + _forceRefresh: boolean, // @TODO consider propagating to dataplane ): Promise => { if (opts.iss !== null && !opts.iss.includes(did)) { throw new AuthRequiredError('Untrusted issuer', 'UntrustedIss') } - return this.idResolver.did.resolveAtprotoKey(did, forceRefresh) + let identity: GetIdentityByDidResponse + try { + identity = await this.dataplane.getIdentityByDid({ did }) + } catch (err) { + if (isDataplaneError(err, Code.NotFound)) { + throw new AuthRequiredError('identity unknown') + } + throw err + } + const keys = unpackIdentityKeys(identity.keys) + const didKey = getKeyAsDidKey(keys, { id: 'atproto' }) + if (!didKey) { + throw new AuthRequiredError('missing or bad key') + } + return didKey } const jwtStr = bearerTokenFromReq(reqCtx.req) diff --git a/packages/bsky/src/context.ts b/packages/bsky/src/context.ts index 8c36862715e..cedd9f0a5de 100644 --- a/packages/bsky/src/context.ts +++ b/packages/bsky/src/context.ts @@ -22,7 +22,6 @@ export class AppContext { views: Views signingKey: Keypair idResolver: IdResolver - didCache?: DidCache bsyncClient: BsyncClient courierClient: CourierClient algos: MountedAlgos @@ -62,10 +61,6 @@ export class AppContext { return this.opts.idResolver } - get didCache(): DidCache | undefined { - return this.opts.didCache - } - get bsyncClient(): BsyncClient { return this.opts.bsyncClient } diff --git a/packages/bsky/src/data-plane/client.ts b/packages/bsky/src/data-plane/client.ts index cc03d90851e..4d48bf25a60 100644 --- a/packages/bsky/src/data-plane/client.ts +++ b/packages/bsky/src/data-plane/client.ts @@ -1,6 +1,6 @@ import assert from 'node:assert' import { randomInt } from 'node:crypto' -import { Service } from '../proto/bsky_connect' +import * as ui8 from 'uint8arrays' import { Code, ConnectError, @@ -9,6 +9,8 @@ import { makeAnyClient, } from '@connectrpc/connect' import { createConnectTransport } from '@connectrpc/connect-node' +import { getDidKeyFromMultibase } from '@atproto/identity' +import { Service } from '../proto/bsky_connect' export type DataPlaneClient = PromiseClient type HttpVersion = '1.1' | '2' @@ -69,3 +71,56 @@ const randomElement = (arr: T[]): T | undefined => { if (arr.length === 0) return return arr[randomInt(arr.length)] } + +export const unpackIdentityServices = (servicesBytes: Uint8Array) => { + const servicesStr = ui8.toString(servicesBytes, 'utf8') + if (!servicesStr) return {} + return JSON.parse(servicesStr) as UnpackedServices +} + +export const unpackIdentityKeys = (keysBytes: Uint8Array) => { + const keysStr = ui8.toString(keysBytes, 'utf8') + if (!keysStr) return {} + return JSON.parse(keysStr) as UnpackedKeys +} + +export const getServiceEndpoint = ( + services: UnpackedServices, + opts: { id: string; type: string }, +) => { + const endpoint = + services[opts.id] && + services[opts.id].Type === opts.type && + validateUrl(services[opts.id].URL) + return endpoint || undefined +} + +export const getKeyAsDidKey = (keys: UnpackedKeys, opts: { id: string }) => { + const key = + keys[opts.id] && + getDidKeyFromMultibase({ + type: keys[opts.id].Type, + publicKeyMultibase: keys[opts.id].PublicKeyMultibase, + }) + return key || undefined +} + +type UnpackedServices = Record + +type UnpackedKeys = Record + +const validateUrl = (urlStr: string): string | undefined => { + let url + try { + url = new URL(urlStr) + } catch { + return undefined + } + if (!['http:', 'https:'].includes(url.protocol)) { + return undefined + } else if (!url.hostname) { + return undefined + } else { + return urlStr + } +} diff --git a/packages/bsky/src/data-plane/server/did-cache.ts b/packages/bsky/src/data-plane/server/did-cache.ts deleted file mode 100644 index 2ffe3b5aa69..00000000000 --- a/packages/bsky/src/data-plane/server/did-cache.ts +++ /dev/null @@ -1,110 +0,0 @@ -import PQueue from 'p-queue' -import { CacheResult, DidCache, DidDocument } from '@atproto/identity' -import { Database } from './db' -import { excluded } from './db/util' -import { dbLogger } from '../../logger' - -export class DidSqlCache implements DidCache { - public pQueue: PQueue | null //null during teardown - - constructor( - // @TODO perhaps could use both primary and non-primary. not high enough - // throughput to matter right now. also may just move this over to redis before long! - public db: Database, - public staleTTL: number, - public maxTTL: number, - ) { - this.pQueue = new PQueue() - } - - async cacheDid( - did: string, - doc: DidDocument, - prevResult?: CacheResult, - ): Promise { - if (prevResult) { - await this.db.db - .updateTable('did_cache') - .set({ doc, updatedAt: Date.now() }) - .where('did', '=', did) - .where('updatedAt', '=', prevResult.updatedAt) - .execute() - } else { - await this.db.db - .insertInto('did_cache') - .values({ did, doc, updatedAt: Date.now() }) - .onConflict((oc) => - oc.column('did').doUpdateSet({ - doc: excluded(this.db.db, 'doc'), - updatedAt: excluded(this.db.db, 'updatedAt'), - }), - ) - .executeTakeFirst() - } - } - - async refreshCache( - did: string, - getDoc: () => Promise, - prevResult?: CacheResult, - ): Promise { - this.pQueue?.add(async () => { - try { - const doc = await getDoc() - if (doc) { - await this.cacheDid(did, doc, prevResult) - } else { - await this.clearEntry(did) - } - } catch (err) { - dbLogger.error({ did, err }, 'refreshing did cache failed') - } - }) - } - - async checkCache(did: string): Promise { - const res = await this.db.db - .selectFrom('did_cache') - .where('did', '=', did) - .selectAll() - .executeTakeFirst() - if (!res) return null - - const now = Date.now() - const updatedAt = new Date(res.updatedAt).getTime() - const expired = now > updatedAt + this.maxTTL - const stale = now > updatedAt + this.staleTTL - return { - doc: res.doc, - updatedAt, - did, - stale, - expired, - } - } - - async clearEntry(did: string): Promise { - await this.db.db - .deleteFrom('did_cache') - .where('did', '=', did) - .executeTakeFirst() - } - - async clear(): Promise { - await this.db.db.deleteFrom('did_cache').execute() - } - - async processAll() { - await this.pQueue?.onIdle() - } - - async destroy() { - const pQueue = this.pQueue - this.pQueue = null - pQueue?.pause() - pQueue?.clear() - await pQueue?.onIdle() - } -} - -export default DidSqlCache diff --git a/packages/bsky/src/data-plane/server/index.ts b/packages/bsky/src/data-plane/server/index.ts index fb730a177ba..f925de83c48 100644 --- a/packages/bsky/src/data-plane/server/index.ts +++ b/packages/bsky/src/data-plane/server/index.ts @@ -6,23 +6,20 @@ import createRoutes from './routes' import { Database } from './db' import { IdResolver, MemoryCache } from '@atproto/identity' -export { DidSqlCache } from './did-cache' export { RepoSubscription } from './subscription' export class DataPlaneServer { - constructor(public server: http.Server) {} + constructor(public server: http.Server, public idResolver: IdResolver) {} static async create(db: Database, port: number, plcUrl?: string) { const app = express() - const idResolver = new IdResolver({ - plcUrl, - didCache: new MemoryCache(), - }) + const didCache = new MemoryCache() + const idResolver = new IdResolver({ plcUrl, didCache }) const routes = createRoutes(db, idResolver) app.use(expressConnectMiddleware({ routes })) const server = app.listen(port) await events.once(server, 'listening') - return new DataPlaneServer(server) + return new DataPlaneServer(server, idResolver) } async destroy() { diff --git a/packages/bsky/src/index.ts b/packages/bsky/src/index.ts index 53e52c7bdb8..8bac7d2b7f5 100644 --- a/packages/bsky/src/index.ts +++ b/packages/bsky/src/index.ts @@ -47,29 +47,20 @@ export class BskyAppView { static create(opts: { config: ServerConfig signingKey: Keypair - didCache?: DidCache algos?: MountedAlgos }): BskyAppView { - const { config, signingKey, didCache, algos = {} } = opts + const { config, signingKey, algos = {} } = opts const app = express() app.use(cors()) app.use(loggerMiddleware) app.use(compression()) + // used solely for handle resolution: identity lookups occur on dataplane const idResolver = new IdResolver({ plcUrl: config.didPlcUrl, - didCache, backupNameservers: config.handleResolveNameservers, }) - const authVerifier = new AuthVerifier(idResolver, { - ownDid: config.serverDid, - adminDid: config.modServiceDid, - adminPass: config.adminPassword, - moderatorPass: config.moderatorPassword, - triagePass: config.triagePassword, - }) - const imgUriBuilder = new ImageUriBuilder( config.imgUriEndpoint || `${config.publicUrl}/img`, ) @@ -111,6 +102,14 @@ export class BskyAppView { : [], }) + const authVerifier = new AuthVerifier(dataplane, { + ownDid: config.serverDid, + adminDid: config.modServiceDid, + adminPass: config.adminPassword, + moderatorPass: config.moderatorPassword, + triagePass: config.triagePassword, + }) + const ctx = new AppContext({ cfg: config, dataplane, @@ -119,7 +118,6 @@ export class BskyAppView { views, signingKey, idResolver, - didCache, bsyncClient, courierClient, authVerifier, diff --git a/packages/bsky/tests/admin/admin-auth.test.ts b/packages/bsky/tests/admin/admin-auth.test.ts index ff00d0906b0..cb13b58897a 100644 --- a/packages/bsky/tests/admin/admin-auth.test.ts +++ b/packages/bsky/tests/admin/admin-auth.test.ts @@ -27,15 +27,30 @@ describe('admin auth', () => { bskyDid = network.bsky.ctx.cfg.serverDid modServiceKey = await Secp256k1Keypair.create() - const origResolve = network.bsky.ctx.idResolver.did.resolveAtprotoKey - network.bsky.ctx.idResolver.did.resolveAtprotoKey = async ( + const origResolve = network.bsky.dataplane.idResolver.did.resolve + network.bsky.dataplane.idResolver.did.resolve = async function ( did: string, forceRefresh?: boolean, - ) => { + ) { if (did === modServiceDid || did === altModDid) { - return modServiceKey.did() + return { + '@context': [ + 'https://www.w3.org/ns/did/v1', + 'https://w3id.org/security/multikey/v1', + 'https://w3id.org/security/suites/secp256k1-2019/v1', + ], + id: did, + verificationMethod: [ + { + id: `${did}#atproto`, + type: 'Multikey', + controller: did, + publicKeyMultibase: modServiceKey.did().replace('did:key:', ''), + }, + ], + } } - return origResolve(did, forceRefresh) + return origResolve.call(this, did, forceRefresh) } agent = network.bsky.getClient() @@ -70,9 +85,7 @@ describe('admin auth', () => { ) const res = await agent.api.com.atproto.admin.getSubjectStatus( - { - did: repoSubject.did, - }, + { did: repoSubject.did }, headers, ) expect(res.data.subject.did).toBe(repoSubject.did) diff --git a/packages/bsky/tests/auth.test.ts b/packages/bsky/tests/auth.test.ts index e08049fa84c..d0903174a2b 100644 --- a/packages/bsky/tests/auth.test.ts +++ b/packages/bsky/tests/auth.test.ts @@ -22,7 +22,8 @@ describe('auth', () => { await network.close() }) - it('handles signing key change for service auth.', async () => { + // @TODO invalidations do not originate from appview frontends: requires identity event on the repo stream. + it.skip('handles signing key change for service auth.', async () => { const issuer = sc.dids.alice const attemptWithKey = async (keypair: Keypair) => { const jwt = await createServiceJwt({ diff --git a/packages/bsky/tests/blob-resolver.test.ts b/packages/bsky/tests/blob-resolver.test.ts index 585c9638f60..985f347f7c2 100644 --- a/packages/bsky/tests/blob-resolver.test.ts +++ b/packages/bsky/tests/blob-resolver.test.ts @@ -1,6 +1,6 @@ import axios, { AxiosInstance } from 'axios' import { CID } from 'multiformats/cid' -import { verifyCidForBytes } from '@atproto/common' +import { cidForCbor, verifyCidForBytes } from '@atproto/common' import { TestNetwork, basicSeed } from '@atproto/dev-env' import { randomBytes } from '@atproto/crypto' @@ -44,8 +44,9 @@ describe('blob resolver', () => { }) it('404s on missing blob.', async () => { + const badCid = await cidForCbor({ unknown: true }) const { data, status } = await client.get( - `/blob/did:plc:unknown/${fileCid.toString()}`, + `/blob/${fileDid}/${badCid.toString()}`, ) expect(status).toEqual(404) expect(data).toEqual({ @@ -54,6 +55,17 @@ describe('blob resolver', () => { }) }) + it('404s on missing identity.', async () => { + const { data, status } = await client.get( + `/blob/did:plc:unknown/${fileCid.toString()}`, + ) + expect(status).toEqual(404) + expect(data).toEqual({ + error: 'NotFoundError', + message: 'Origin not found', + }) + }) + it('400s on invalid did.', async () => { const { data, status } = await client.get( `/blob/did::/${fileCid.toString()}`, diff --git a/packages/bsky/tests/data-plane/did-cache.test.ts b/packages/bsky/tests/data-plane/did-cache.test.ts deleted file mode 100644 index e9364761419..00000000000 --- a/packages/bsky/tests/data-plane/did-cache.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { wait } from '@atproto/common' -import { IdResolver } from '@atproto/identity' -import { TestNetwork, SeedClient, usersSeed } from '@atproto/dev-env' -import { DidSqlCache } from '../../src' - -describe('did cache', () => { - let network: TestNetwork - let sc: SeedClient - let idResolver: IdResolver - let didCache: DidSqlCache - - let alice: string - let bob: string - let carol: string - let dan: string - - beforeAll(async () => { - network = await TestNetwork.create({ - dbPostgresSchema: 'bsky_did_cache', - }) - idResolver = network.bsky.ctx.idResolver - didCache = network.bsky.ctx.didCache as DidSqlCache - sc = network.getSeedClient() - await usersSeed(sc) - await network.processAll() - alice = sc.dids.alice - bob = sc.dids.bob - carol = sc.dids.carol - dan = sc.dids.dan - }) - - afterAll(async () => { - await network.close() - }) - - it('caches dids on lookup', async () => { - await didCache.processAll() - const docs = await Promise.all([ - idResolver.did.cache?.checkCache(alice), - idResolver.did.cache?.checkCache(bob), - idResolver.did.cache?.checkCache(carol), - idResolver.did.cache?.checkCache(dan), - ]) - expect(docs.length).toBe(4) - expect(docs[0]?.doc.id).toEqual(alice) - expect(docs[1]?.doc.id).toEqual(bob) - expect(docs[2]?.doc.id).toEqual(carol) - expect(docs[3]?.doc.id).toEqual(dan) - }) - - it('clears cache and repopulates', async () => { - await idResolver.did.cache?.clear() - const docsCleared = await Promise.all([ - idResolver.did.cache?.checkCache(alice), - idResolver.did.cache?.checkCache(bob), - idResolver.did.cache?.checkCache(carol), - idResolver.did.cache?.checkCache(dan), - ]) - expect(docsCleared).toEqual([null, null, null, null]) - - await Promise.all([ - idResolver.did.resolve(alice), - idResolver.did.resolve(bob), - idResolver.did.resolve(carol), - idResolver.did.resolve(dan), - ]) - await didCache.processAll() - - const docs = await Promise.all([ - idResolver.did.cache?.checkCache(alice), - idResolver.did.cache?.checkCache(bob), - idResolver.did.cache?.checkCache(carol), - idResolver.did.cache?.checkCache(dan), - ]) - expect(docs.length).toBe(4) - expect(docs[0]?.doc.id).toEqual(alice) - expect(docs[1]?.doc.id).toEqual(bob) - expect(docs[2]?.doc.id).toEqual(carol) - expect(docs[3]?.doc.id).toEqual(dan) - }) - - it('accurately reports expired dids & refreshes the cache', async () => { - const didCache = new DidSqlCache(network.bsky.db, 1, 60000) - const shortCacheResolver = new IdResolver({ - plcUrl: network.bsky.ctx.cfg.didPlcUrl, - didCache, - }) - const doc = await shortCacheResolver.did.resolve(alice) - await didCache.processAll() - // let's mess with alice's doc so we know what we're getting - await didCache.cacheDid(alice, { ...doc, id: 'did:example:alice' }) - await wait(5) - - // first check the cache & see that we have the stale value - const cached = await shortCacheResolver.did.cache?.checkCache(alice) - expect(cached?.stale).toBe(true) - expect(cached?.doc.id).toEqual('did:example:alice') - // see that the resolver gives us the stale value while it revalidates - const staleGet = await shortCacheResolver.did.resolve(alice) - expect(staleGet?.id).toEqual('did:example:alice') - await didCache.processAll() - - // since it revalidated, ensure we have the new value - const updatedCache = await shortCacheResolver.did.cache?.checkCache(alice) - expect(updatedCache?.doc.id).toEqual(alice) - const updatedGet = await shortCacheResolver.did.resolve(alice) - expect(updatedGet?.id).toEqual(alice) - await didCache.destroy() - }) - - it('does not return expired dids & refreshes the cache', async () => { - const didCache = new DidSqlCache(network.bsky.db, 0, 1) - const shortExpireResolver = new IdResolver({ - plcUrl: network.bsky.ctx.cfg.didPlcUrl, - didCache, - }) - const doc = await shortExpireResolver.did.resolve(alice) - await didCache.processAll() - - // again, we mess with the cached doc so we get something different - await didCache.cacheDid(alice, { ...doc, id: 'did:example:alice' }) - await wait(5) - - // see that the resolver does not return expired value & instead force refreshes - const staleGet = await shortExpireResolver.did.resolve(alice) - expect(staleGet?.id).toEqual(alice) - }) -}) diff --git a/packages/bsky/tests/data-plane/handle-invalidation.test.ts b/packages/bsky/tests/data-plane/handle-invalidation.test.ts index cd281976e24..8469a8507ef 100644 --- a/packages/bsky/tests/data-plane/handle-invalidation.test.ts +++ b/packages/bsky/tests/data-plane/handle-invalidation.test.ts @@ -25,8 +25,10 @@ describe('handle invalidation', () => { alice = sc.dids.alice bob = sc.dids.bob - const origResolve = network.bsky.ctx.idResolver.handle.resolve - network.bsky.ctx.idResolver.handle.resolve = async (handle: string) => { + const origResolve = network.bsky.dataplane.idResolver.handle.resolve + network.bsky.dataplane.idResolver.handle.resolve = async ( + handle: string, + ) => { if (mockHandles[handle] === null) { return undefined } else if (mockHandles[handle]) { diff --git a/packages/dev-env/src/bsky.ts b/packages/dev-env/src/bsky.ts index 614dbf60899..db4ef098a63 100644 --- a/packages/dev-env/src/bsky.ts +++ b/packages/dev-env/src/bsky.ts @@ -83,12 +83,9 @@ export class TestBsky { } await migrationDb.close() - const didCache = new bsky.DidSqlCache(db, HOUR, DAY) - // api server const server = bsky.BskyAppView.create({ config, - didCache, signingKey: serviceKeypair, algos: cfg.algos, }) @@ -96,7 +93,7 @@ export class TestBsky { const sub = new bsky.RepoSubscription({ service: cfg.repoProvider, db, - idResolver: server.ctx.idResolver, + idResolver: dataplane.idResolver, background: new BackgroundQueue(db), }) diff --git a/packages/dev-env/src/util.ts b/packages/dev-env/src/util.ts index 2cfbd6dcbd7..679ca89c7a8 100644 --- a/packages/dev-env/src/util.ts +++ b/packages/dev-env/src/util.ts @@ -7,6 +7,7 @@ export const mockNetworkUtilities = (pds: TestPds, bsky?: TestBsky) => { mockResolvers(pds.ctx.idResolver, pds) if (bsky) { mockResolvers(bsky.ctx.idResolver, pds) + mockResolvers(bsky.dataplane.idResolver, pds) } } diff --git a/packages/identity/src/did/atproto-data.ts b/packages/identity/src/did/atproto-data.ts index c03f76ef598..c0cd9829739 100644 --- a/packages/identity/src/did/atproto-data.ts +++ b/packages/identity/src/did/atproto-data.ts @@ -20,7 +20,13 @@ export { export const getKey = (doc: DidDocument): string | undefined => { const key = getSigningKey(doc) if (!key) return undefined + return getDidKeyFromMultibase(key) +} +export const getDidKeyFromMultibase = (key: { + type: string + publicKeyMultibase: string +}): string | undefined => { const keyBytes = crypto.multibaseToBytes(key.publicKeyMultibase) let didKey: string | undefined = undefined if (key.type === 'EcdsaSecp256r1VerificationKey2019') { From 893db6a9a3402efaad0696943b38d28e3788cd79 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Thu, 25 Jan 2024 14:17:00 -0500 Subject: [PATCH 4/4] tidy --- packages/bsky/src/context.ts | 2 +- packages/bsky/src/index.ts | 2 +- packages/dev-env/src/bsky.ts | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/bsky/src/context.ts b/packages/bsky/src/context.ts index cedd9f0a5de..01670f6e19e 100644 --- a/packages/bsky/src/context.ts +++ b/packages/bsky/src/context.ts @@ -1,5 +1,5 @@ import * as plc from '@did-plc/lib' -import { DidCache, IdResolver } from '@atproto/identity' +import { IdResolver } from '@atproto/identity' import AtpAgent from '@atproto/api' import { Keypair } from '@atproto/crypto' import { createServiceJwt } from '@atproto/xrpc-server' diff --git a/packages/bsky/src/index.ts b/packages/bsky/src/index.ts index 8bac7d2b7f5..5f09ae63ce1 100644 --- a/packages/bsky/src/index.ts +++ b/packages/bsky/src/index.ts @@ -6,7 +6,7 @@ import { createHttpTerminator, HttpTerminator } from 'http-terminator' import cors from 'cors' import compression from 'compression' import AtpAgent from '@atproto/api' -import { DidCache, IdResolver } from '@atproto/identity' +import { IdResolver } from '@atproto/identity' import API, { health, wellKnown, blobResolver } from './api' import * as error from './error' import { loggerMiddleware } from './logger' diff --git a/packages/dev-env/src/bsky.ts b/packages/dev-env/src/bsky.ts index db4ef098a63..a86b124fe9d 100644 --- a/packages/dev-env/src/bsky.ts +++ b/packages/dev-env/src/bsky.ts @@ -1,7 +1,6 @@ import getPort from 'get-port' import * as ui8 from 'uint8arrays' import * as bsky from '@atproto/bsky' -import { DAY, HOUR } from '@atproto/common-web' import { AtpAgent } from '@atproto/api' import { Secp256k1Keypair } from '@atproto/crypto' import { Client as PlcClient } from '@did-plc/lib'