From 6a26396b03ae3038e389417f83315e5bfe92f7ce Mon Sep 17 00:00:00 2001 From: dholms Date: Thu, 29 Feb 2024 11:46:00 -0600 Subject: [PATCH 01/26] tidy bsky auth --- packages/bsky/src/auth-verifier.ts | 24 ++++++++++++------------ packages/bsky/src/index.ts | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/bsky/src/auth-verifier.ts b/packages/bsky/src/auth-verifier.ts index 7798efa99b2..69335772390 100644 --- a/packages/bsky/src/auth-verifier.ts +++ b/packages/bsky/src/auth-verifier.ts @@ -45,9 +45,9 @@ type RoleOutput = { } } -type AdminServiceOutput = { +type ModServiceOutput = { credentials: { - type: 'admin_service' + type: 'mod_service' aud: string iss: string } @@ -55,18 +55,18 @@ type AdminServiceOutput = { export type AuthVerifierOpts = { ownDid: string - adminDid: string + modServiceDid: string adminPasses: string[] } export class AuthVerifier { public ownDid: string - public adminDid: string + public modServiceDid: string private adminPasses: Set constructor(public dataplane: DataPlaneClient, opts: AuthVerifierOpts) { this.ownDid = opts.ownDid - this.adminDid = opts.adminDid + this.modServiceDid = opts.modServiceDid this.adminPasses = new Set(opts.adminPasses) } @@ -159,17 +159,17 @@ export class AuthVerifier { } } - adminService = async (reqCtx: ReqCtx): Promise => { + adminService = async (reqCtx: ReqCtx): Promise => { const { iss, aud } = await this.verifyServiceJwt(reqCtx, { aud: this.ownDid, - iss: [this.adminDid], + iss: [this.modServiceDid], }) - return { credentials: { type: 'admin_service', aud, iss } } + return { credentials: { type: 'mod_service', aud, iss } } } roleOrAdminService = async ( reqCtx: ReqCtx, - ): Promise => { + ): Promise => { if (isBearerToken(reqCtx.req)) { return this.adminService(reqCtx) } else { @@ -236,16 +236,16 @@ export class AuthVerifier { } parseCreds( - creds: StandardOutput | RoleOutput | AdminServiceOutput | NullOutput, + creds: StandardOutput | RoleOutput | ModServiceOutput | NullOutput, ) { const viewer = creds.credentials.type === 'standard' ? creds.credentials.iss : null const canViewTakedowns = (creds.credentials.type === 'role' && creds.credentials.admin) || - creds.credentials.type === 'admin_service' + creds.credentials.type === 'mod_service' const canPerformTakedown = (creds.credentials.type === 'role' && creds.credentials.admin) || - creds.credentials.type === 'admin_service' + creds.credentials.type === 'mod_service' return { viewer, canViewTakedowns, diff --git a/packages/bsky/src/index.ts b/packages/bsky/src/index.ts index a968d85b584..b11fb42a453 100644 --- a/packages/bsky/src/index.ts +++ b/packages/bsky/src/index.ts @@ -100,7 +100,7 @@ export class BskyAppView { const authVerifier = new AuthVerifier(dataplane, { ownDid: config.serverDid, - adminDid: config.modServiceDid, + modServiceDid: config.modServiceDid, adminPasses: config.adminPasswords, }) From 125f721e851190427c1f6d7f2d9d76cbed5c9a88 Mon Sep 17 00:00:00 2001 From: dholms Date: Thu, 29 Feb 2024 11:46:15 -0600 Subject: [PATCH 02/26] hook up new auth verifier --- packages/ozone/src/auth-verifier.ts | 202 ++++++++++++++++++++++++++++ packages/ozone/src/config/config.ts | 12 ++ packages/ozone/src/config/env.ts | 6 +- packages/ozone/src/context.ts | 43 ++---- 4 files changed, 233 insertions(+), 30 deletions(-) create mode 100644 packages/ozone/src/auth-verifier.ts diff --git a/packages/ozone/src/auth-verifier.ts b/packages/ozone/src/auth-verifier.ts new file mode 100644 index 00000000000..a2fe5955f51 --- /dev/null +++ b/packages/ozone/src/auth-verifier.ts @@ -0,0 +1,202 @@ +import express from 'express' +import * as ui8 from 'uint8arrays' +import { IdResolver } from '@atproto/identity' +import { AuthRequiredError, verifyJwt } from '@atproto/xrpc-server' + +type ReqCtx = { + req: express.Request +} + +type RoleOutput = { + credentials: { + type: 'role' + isModerator: boolean + isTriage: true + } +} + +type ModeratorOutput = { + credentials: { + type: 'moderator' + aud: string + iss: string + isModerator: boolean + isTriage: true + } +} + +type StandardOutput = { + credentials: { + type: 'standard' + aud: string + iss: string + isModerator: boolean + isTriage: boolean + } +} + +type NullOutput = { + credentials: { + type: 'null' + iss: null + } +} + +export type AuthVerifierOpts = { + serviceDid: string + moderators: string[] + triage: string[] + adminPassword: string + moderatorPassword: string + triagePassword: string +} + +export class AuthVerifier { + serviceDid: string + moderators: string[] + triage: string[] + private adminPassword: string + private moderatorPassword: string + private triagePassword: string + + constructor(public idResolver: IdResolver, opts: AuthVerifierOpts) { + this.serviceDid = opts.serviceDid + this.moderators = opts.moderators + this.triage = opts.triage + this.adminPassword = opts.adminPassword + this.moderatorPassword = opts.moderatorPassword + this.triagePassword = opts.triagePassword + } + + modOrRole = async (reqCtx: ReqCtx): Promise => { + if (isBearerToken(reqCtx.req)) { + return this.moderator(reqCtx) + } else { + return this.role(reqCtx) + } + } + + moderator = async (reqCtx: ReqCtx): Promise => { + const creds = await this.standard(reqCtx) + if (!creds.credentials.isTriage) { + throw new AuthRequiredError('not a moderator account') + } + return { + credentials: { + ...creds.credentials, + type: 'moderator', + isTriage: true, + }, + } + } + + standard = async (reqCtx: ReqCtx): Promise => { + const getSigningKey = async ( + did: string, + forceRefresh: boolean, + ): Promise => { + const atprotoData = await this.idResolver.did.resolveAtprotoData( + did, + forceRefresh, + ) + return atprotoData.signingKey + } + + const jwtStr = getJwtStrFromReq(reqCtx.req) + if (!jwtStr) { + throw new AuthRequiredError('missing jwt', 'MissingJwt') + } + const payload = await verifyJwt(jwtStr, this.serviceDid, getSigningKey) + const iss = payload.iss + const isModerator = this.moderators.includes(iss) + const isTriage = isModerator || this.triage.includes(iss) + return { + credentials: { + type: 'standard', + iss, + aud: payload.aud, + isModerator, + isTriage, + }, + } + } + + standardOptional = async ( + reqCtx: ReqCtx, + ): Promise => { + if (isBearerToken(reqCtx.req)) { + return this.standard(reqCtx) + } + return this.nullCreds() + } + + role = async (reqCtx: ReqCtx): Promise => { + const parsed = parseBasicAuth(reqCtx.req.headers.authorization ?? '') + const { username, password } = parsed ?? {} + if (username === 'admin') { + throw new AuthRequiredError() + } + if (password === this.triagePassword) { + return { + credentials: { + type: 'role', + isModerator: false, + isTriage: true, + }, + } + } else if ( + password === this.moderatorPassword || + password === this.adminPassword + ) { + return { + credentials: { + type: 'role', + isModerator: true, + isTriage: true, + }, + } + } else { + throw new AuthRequiredError() + } + } + + nullCreds(): NullOutput { + return { + credentials: { + type: 'null', + iss: null, + }, + } + } +} + +const BEARER = 'Bearer ' +const BASIC = 'Basic ' + +const isBearerToken = (req: express.Request): boolean => { + return req.headers.authorization?.startsWith(BEARER) ?? false +} + +export const getJwtStrFromReq = (req: express.Request): string | null => { + const { authorization } = req.headers + if (!authorization?.startsWith(BEARER)) { + return null + } + return authorization.slice(BEARER.length).trim() +} + +export const parseBasicAuth = ( + token: string, +): { username: string; password: string } | null => { + if (!token.startsWith(BASIC)) return null + const b64 = token.slice(BASIC.length) + let parsed: string[] + try { + parsed = ui8.toString(ui8.fromString(b64, 'base64pad'), 'utf8').split(':') + } catch (err) { + return null + } + const [username, password] = parsed + if (!username || !password) return null + return { username, password } +} diff --git a/packages/ozone/src/config/config.ts b/packages/ozone/src/config/config.ts index 32ed8ba5cb5..3f9c38f96c8 100644 --- a/packages/ozone/src/config/config.ts +++ b/packages/ozone/src/config/config.ts @@ -43,12 +43,18 @@ export const envToCfg = (env: OzoneEnvironment): OzoneConfig => { plcUrl: env.didPlcUrl, } + const accessCfg: OzoneConfig['access'] = { + moderators: env.moderatorDids, + triage: env.triageDids, + } + return { service: serviceCfg, db: dbCfg, appview: appviewCfg, pds: pdsCfg, identity: identityCfg, + access: accessCfg, } } @@ -58,6 +64,7 @@ export type OzoneConfig = { appview: AppviewConfig pds: PdsConfig | null identity: IdentityConfig + access: AccessConfig } export type ServiceConfig = { @@ -88,3 +95,8 @@ export type PdsConfig = { export type IdentityConfig = { plcUrl: string } + +export type AccessConfig = { + moderators: string[] + triage: string[] +} diff --git a/packages/ozone/src/config/env.ts b/packages/ozone/src/config/env.ts index b0ad10074eb..fcb6eb31d26 100644 --- a/packages/ozone/src/config/env.ts +++ b/packages/ozone/src/config/env.ts @@ -1,4 +1,4 @@ -import { envInt, envStr } from '@atproto/common' +import { envInt, envList, envStr } from '@atproto/common' export const readEnv = (): OzoneEnvironment => { return { @@ -17,6 +17,8 @@ export const readEnv = (): OzoneEnvironment => { dbPoolMaxUses: envInt('OZONE_DB_POOL_MAX_USES'), dbPoolIdleTimeoutMs: envInt('OZONE_DB_POOL_IDLE_TIMEOUT_MS'), didPlcUrl: envStr('OZONE_DID_PLC_URL'), + moderatorDids: envList('OZONE_MODERATOR_DIDS'), + triageDids: envList('OZONE_TRIAGE_DIDS'), adminPassword: envStr('OZONE_ADMIN_PASSWORD'), moderatorPassword: envStr('OZONE_MODERATOR_PASSWORD'), triagePassword: envStr('OZONE_TRIAGE_PASSWORD'), @@ -40,6 +42,8 @@ export type OzoneEnvironment = { dbPoolMaxUses?: number dbPoolIdleTimeoutMs?: number didPlcUrl?: string + moderatorDids: string[] + triageDids: string[] adminPassword?: string moderatorPassword?: string triagePassword?: string diff --git a/packages/ozone/src/context.ts b/packages/ozone/src/context.ts index 00ef4bd71ba..726a500ea38 100644 --- a/packages/ozone/src/context.ts +++ b/packages/ozone/src/context.ts @@ -6,7 +6,6 @@ import { createServiceAuthHeaders } from '@atproto/xrpc-server' import { Database } from './db' import { OzoneConfig, OzoneSecrets } from './config' import { ModerationService, ModerationServiceCreator } from './mod-service' -import * as auth from './auth' import { BackgroundQueue } from './background' import assert from 'assert' import { EventPusher } from './daemon' @@ -15,6 +14,7 @@ import { CommunicationTemplateService, CommunicationTemplateServiceCreator, } from './communication-service/template' +import { AuthVerifier } from './auth-verifier' export type AppContextOptions = { db: Database @@ -27,6 +27,7 @@ export type AppContextOptions = { idResolver: IdResolver backgroundQueue: BackgroundQueue sequencer: Sequencer + authVerifier: AuthVerifier } export class AppContext { @@ -81,6 +82,15 @@ export class AppContext { const sequencer = new Sequencer(db) + const authVerifier = new AuthVerifier(idResolver, { + serviceDid: cfg.service.did, + moderators: cfg.access.moderators, + triage: cfg.access.triage, + adminPassword: secrets.adminPassword, + moderatorPassword: secrets.moderatorPassword, + triagePassword: secrets.triagePassword, + }) + return new AppContext( { db, @@ -93,6 +103,7 @@ export class AppContext { idResolver, backgroundQueue, sequencer, + authVerifier, ...(overrides ?? {}), }, secrets, @@ -151,34 +162,8 @@ export class AppContext { return this.opts.sequencer } - get authVerifier() { - return auth.authVerifier(this.idResolver, { aud: this.cfg.service.did }) - } - - get authVerifierAnyAudience() { - return auth.authVerifier(this.idResolver, { aud: null }) - } - - get authOptionalVerifierAnyAudience() { - return auth.authOptionalVerifier(this.idResolver, { aud: null }) - } - - get authOptionalVerifier() { - return auth.authOptionalVerifier(this.idResolver, { - aud: this.cfg.service.did, - }) - } - - get authOptionalAccessOrRoleVerifier() { - return auth.authOptionalAccessOrRoleVerifier( - this.idResolver, - this.secrets, - this.cfg.service.did, - ) - } - - get roleVerifier() { - return auth.roleVerifier(this.secrets) + get authVerifier(): AuthVerifier { + return this.opts.authVerifier } async serviceAuthHeaders(aud: string) { From 36da1d90d48736afe93645c433dcf669a9bc74c4 Mon Sep 17 00:00:00 2001 From: dholms Date: Thu, 29 Feb 2024 12:12:15 -0600 Subject: [PATCH 03/26] update auth throughout ozone --- packages/dev-env/src/ozone.ts | 3 + .../api/admin/createCommunicationTemplate.ts | 4 +- .../api/admin/deleteCommunicationTemplate.ts | 4 +- .../src/api/admin/emitModerationEvent.ts | 8 +- .../ozone/src/api/admin/getModerationEvent.ts | 2 +- packages/ozone/src/api/admin/getRecord.ts | 4 +- packages/ozone/src/api/admin/getRepo.ts | 4 +- .../api/admin/listCommunicationTemplates.ts | 4 +- .../src/api/admin/queryModerationEvents.ts | 2 +- .../src/api/admin/queryModerationStatuses.ts | 2 +- packages/ozone/src/api/admin/searchRepos.ts | 2 +- .../api/admin/updateCommunicationTemplate.ts | 4 +- packages/ozone/src/api/index.ts | 2 + .../ozone/src/api/moderation/createReport.ts | 4 +- packages/ozone/src/api/proxied.ts | 74 +++++++++ packages/ozone/src/api/temp/fetchLabels.ts | 4 +- packages/ozone/src/auth-verifier.ts | 60 ++++--- packages/ozone/src/auth.ts | 147 ------------------ packages/ozone/src/config/config.ts | 2 + packages/ozone/src/config/env.ts | 2 + packages/ozone/src/context.ts | 1 + 21 files changed, 146 insertions(+), 193 deletions(-) create mode 100644 packages/ozone/src/api/proxied.ts delete mode 100644 packages/ozone/src/auth.ts diff --git a/packages/dev-env/src/ozone.ts b/packages/dev-env/src/ozone.ts index 4988a888f61..b14fe308fc2 100644 --- a/packages/dev-env/src/ozone.ts +++ b/packages/dev-env/src/ozone.ts @@ -44,6 +44,9 @@ export class TestOzone { adminPassword: ADMIN_PASSWORD, moderatorPassword: MOD_PASSWORD, triagePassword: TRIAGE_PASSWORD, + adminDids: [], + moderatorDids: [], + triageDids: [], } // Separate migration db in case migration changes some connection state that we need in the tests, e.g. "alter database ... set ..." diff --git a/packages/ozone/src/api/admin/createCommunicationTemplate.ts b/packages/ozone/src/api/admin/createCommunicationTemplate.ts index 94d0f4325d9..f05db2d71f2 100644 --- a/packages/ozone/src/api/admin/createCommunicationTemplate.ts +++ b/packages/ozone/src/api/admin/createCommunicationTemplate.ts @@ -4,13 +4,13 @@ import AppContext from '../../context' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.createCommunicationTemplate({ - auth: ctx.roleVerifier, + auth: ctx.authVerifier.modOrRole, handler: async ({ input, auth }) => { const access = auth.credentials const db = ctx.db const { createdBy, ...template } = input.body - if (!access.admin) { + if (!access.isAdmin) { throw new AuthRequiredError( 'Must be an admin to create a communication template', ) diff --git a/packages/ozone/src/api/admin/deleteCommunicationTemplate.ts b/packages/ozone/src/api/admin/deleteCommunicationTemplate.ts index cb72bb46216..b70028e710d 100644 --- a/packages/ozone/src/api/admin/deleteCommunicationTemplate.ts +++ b/packages/ozone/src/api/admin/deleteCommunicationTemplate.ts @@ -4,13 +4,13 @@ import AppContext from '../../context' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.deleteCommunicationTemplate({ - auth: ctx.roleVerifier, + auth: ctx.authVerifier.modOrRole, handler: async ({ input, auth }) => { const access = auth.credentials const db = ctx.db const { id } = input.body - if (!access.admin) { + if (!access.isAdmin) { throw new AuthRequiredError( 'Must be an admin to delete a communication template', ) diff --git a/packages/ozone/src/api/admin/emitModerationEvent.ts b/packages/ozone/src/api/admin/emitModerationEvent.ts index ef4c5fd2822..473269bffde 100644 --- a/packages/ozone/src/api/admin/emitModerationEvent.ts +++ b/packages/ozone/src/api/admin/emitModerationEvent.ts @@ -11,7 +11,7 @@ import { ModerationLangService } from '../../mod-service/lang' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.emitModerationEvent({ - auth: ctx.roleVerifier, + auth: ctx.authVerifier.modOrRole, handler: async ({ input, auth }) => { const access = auth.credentials const db = ctx.db @@ -29,7 +29,7 @@ export default function (server: Server, ctx: AppContext) { // if less than moderator access then can only take ack and escalation actions if (isTakedownEvent || isReverseTakedownEvent) { - if (!access.moderator) { + if (!access.isModerator) { throw new AuthRequiredError( 'Must be a full moderator to take this type of action', ) @@ -37,7 +37,7 @@ export default function (server: Server, ctx: AppContext) { // Non admins should not be able to take down feed generators if ( - !access.admin && + !access.isAdmin && subject.recordPath?.includes('app.bsky.feed.generator/') ) { throw new AuthRequiredError( @@ -46,7 +46,7 @@ export default function (server: Server, ctx: AppContext) { } } // if less than moderator access then can not apply labels - if (!access.moderator && isLabelEvent) { + if (!access.isModerator && isLabelEvent) { throw new AuthRequiredError('Must be a full moderator to label content') } diff --git a/packages/ozone/src/api/admin/getModerationEvent.ts b/packages/ozone/src/api/admin/getModerationEvent.ts index e02757c79a3..fc6b433789e 100644 --- a/packages/ozone/src/api/admin/getModerationEvent.ts +++ b/packages/ozone/src/api/admin/getModerationEvent.ts @@ -3,7 +3,7 @@ import AppContext from '../../context' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getModerationEvent({ - auth: ctx.roleVerifier, + auth: ctx.authVerifier.modOrRole, handler: async ({ params }) => { const { id } = params const db = ctx.db diff --git a/packages/ozone/src/api/admin/getRecord.ts b/packages/ozone/src/api/admin/getRecord.ts index fbe9a1229d5..061fc87a0d6 100644 --- a/packages/ozone/src/api/admin/getRecord.ts +++ b/packages/ozone/src/api/admin/getRecord.ts @@ -6,7 +6,7 @@ import { AtUri } from '@atproto/syntax' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getRecord({ - auth: ctx.roleVerifier, + auth: ctx.authVerifier.modOrRole, handler: async ({ params, auth }) => { const db = ctx.db @@ -22,7 +22,7 @@ export default function (server: Server, ctx: AppContext) { record.repo = addAccountInfoToRepoView( record.repo, accountInfo, - auth.credentials.moderator, + auth.credentials.isModerator, ) return { diff --git a/packages/ozone/src/api/admin/getRepo.ts b/packages/ozone/src/api/admin/getRepo.ts index 5da30f24524..bd0c03c13c6 100644 --- a/packages/ozone/src/api/admin/getRepo.ts +++ b/packages/ozone/src/api/admin/getRepo.ts @@ -5,7 +5,7 @@ import { addAccountInfoToRepoViewDetail, getPdsAccountInfo } from './util' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getRepo({ - auth: ctx.roleVerifier, + auth: ctx.authVerifier.modOrRole, handler: async ({ params, auth }) => { const { did } = params const db = ctx.db @@ -20,7 +20,7 @@ export default function (server: Server, ctx: AppContext) { const repo = addAccountInfoToRepoViewDetail( partialRepo, accountInfo, - auth.credentials.moderator, + auth.credentials.isModerator, ) return { encoding: 'application/json', diff --git a/packages/ozone/src/api/admin/listCommunicationTemplates.ts b/packages/ozone/src/api/admin/listCommunicationTemplates.ts index b2b4d191b39..d8a88947895 100644 --- a/packages/ozone/src/api/admin/listCommunicationTemplates.ts +++ b/packages/ozone/src/api/admin/listCommunicationTemplates.ts @@ -4,12 +4,12 @@ import AppContext from '../../context' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.listCommunicationTemplates({ - auth: ctx.roleVerifier, + auth: ctx.authVerifier.modOrRole, handler: async ({ auth }) => { const access = auth.credentials const db = ctx.db - if (!access.moderator) { + if (!access.isModerator) { throw new AuthRequiredError( 'Must be a full moderator to view list of communication template', ) diff --git a/packages/ozone/src/api/admin/queryModerationEvents.ts b/packages/ozone/src/api/admin/queryModerationEvents.ts index 670cda96cbc..959ee2dcd37 100644 --- a/packages/ozone/src/api/admin/queryModerationEvents.ts +++ b/packages/ozone/src/api/admin/queryModerationEvents.ts @@ -4,7 +4,7 @@ import { getEventType } from '../moderation/util' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.queryModerationEvents({ - auth: ctx.roleVerifier, + auth: ctx.authVerifier.modOrRole, handler: async ({ params }) => { const { subject, diff --git a/packages/ozone/src/api/admin/queryModerationStatuses.ts b/packages/ozone/src/api/admin/queryModerationStatuses.ts index fc491339ffa..2c4e0d0dd10 100644 --- a/packages/ozone/src/api/admin/queryModerationStatuses.ts +++ b/packages/ozone/src/api/admin/queryModerationStatuses.ts @@ -4,7 +4,7 @@ import { getReviewState } from '../moderation/util' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.queryModerationStatuses({ - auth: ctx.roleVerifier, + auth: ctx.authVerifier.modOrRole, handler: async ({ params }) => { const { subject, diff --git a/packages/ozone/src/api/admin/searchRepos.ts b/packages/ozone/src/api/admin/searchRepos.ts index fcdfc1b6d85..6026a5ccdc9 100644 --- a/packages/ozone/src/api/admin/searchRepos.ts +++ b/packages/ozone/src/api/admin/searchRepos.ts @@ -4,7 +4,7 @@ import { mapDefined } from '@atproto/common' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.searchRepos({ - auth: ctx.roleVerifier, + auth: ctx.authVerifier.modOrRole, handler: async ({ params }) => { const modService = ctx.modService(ctx.db) diff --git a/packages/ozone/src/api/admin/updateCommunicationTemplate.ts b/packages/ozone/src/api/admin/updateCommunicationTemplate.ts index 3800748fc30..1b1b124e7f9 100644 --- a/packages/ozone/src/api/admin/updateCommunicationTemplate.ts +++ b/packages/ozone/src/api/admin/updateCommunicationTemplate.ts @@ -4,13 +4,13 @@ import AppContext from '../../context' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.updateCommunicationTemplate({ - auth: ctx.roleVerifier, + auth: ctx.authVerifier.modOrRole, handler: async ({ input, auth }) => { const access = auth.credentials const db = ctx.db const { id, updatedBy, ...template } = input.body - if (!access.admin) { + if (!access.isAdmin) { throw new AuthRequiredError( 'Must be an admin to update a communication template', ) diff --git a/packages/ozone/src/api/index.ts b/packages/ozone/src/api/index.ts index 54e52ffe292..c0bb0649f71 100644 --- a/packages/ozone/src/api/index.ts +++ b/packages/ozone/src/api/index.ts @@ -15,6 +15,7 @@ import createCommunicationTemplate from './admin/createCommunicationTemplate' import updateCommunicationTemplate from './admin/updateCommunicationTemplate' import deleteCommunicationTemplate from './admin/deleteCommunicationTemplate' import listCommunicationTemplates from './admin/listCommunicationTemplates' +import proxied from './proxied' export * as health from './health' @@ -36,5 +37,6 @@ export default function (server: Server, ctx: AppContext) { createCommunicationTemplate(server, ctx) updateCommunicationTemplate(server, ctx) deleteCommunicationTemplate(server, ctx) + proxied(server, ctx) return server } diff --git a/packages/ozone/src/api/moderation/createReport.ts b/packages/ozone/src/api/moderation/createReport.ts index e87b957e8d0..3abf5080e7d 100644 --- a/packages/ozone/src/api/moderation/createReport.ts +++ b/packages/ozone/src/api/moderation/createReport.ts @@ -9,10 +9,10 @@ import { ModerationLangService } from '../../mod-service/lang' export default function (server: Server, ctx: AppContext) { server.com.atproto.moderation.createReport({ // @TODO anonymous reports w/ optional auth are a temporary measure - auth: ctx.authOptionalAccessOrRoleVerifier, + auth: ctx.authVerifier.standardOptionalOrRole, handler: async ({ input, auth }) => { const requester = - 'did' in auth.credentials ? auth.credentials.did : ctx.cfg.service.did + 'iss' in auth.credentials ? auth.credentials.iss : ctx.cfg.service.did const { reasonType, reason } = input.body const subject = subjectFromInput(input.body.subject) diff --git a/packages/ozone/src/api/proxied.ts b/packages/ozone/src/api/proxied.ts new file mode 100644 index 00000000000..b7eef005650 --- /dev/null +++ b/packages/ozone/src/api/proxied.ts @@ -0,0 +1,74 @@ +import { Server } from '../lexicon' +import AppContext from '../context' + +export default function (server: Server, ctx: AppContext) { + server.app.bsky.actor.getProfile({ + auth: ctx.authVerifier.modOrRole, + handler: async (request) => { + const res = await ctx.appviewAgent.api.app.bsky.actor.getProfile( + request.params, + await ctx.appviewAuth(), + ) + return { + encoding: 'application/json', + body: res.data, + } + }, + }) + + server.app.bsky.feed.getAuthorFeed({ + auth: ctx.authVerifier.modOrRole, + handler: async (request) => { + const res = await ctx.appviewAgent.api.app.bsky.feed.getAuthorFeed( + request.params, + await ctx.appviewAuth(), + ) + return { + encoding: 'application/json', + body: res.data, + } + }, + }) + + server.app.bsky.feed.getPostThread({ + auth: ctx.authVerifier.modOrRole, + handler: async (request) => { + const res = await ctx.appviewAgent.api.app.bsky.feed.getPostThread( + request.params, + await ctx.appviewAuth(), + ) + return { + encoding: 'application/json', + body: res.data, + } + }, + }) + + server.app.bsky.graph.getFollows({ + auth: ctx.authVerifier.modOrRole, + handler: async (request) => { + const res = await ctx.appviewAgent.api.app.bsky.graph.getFollows( + request.params, + await ctx.appviewAuth(), + ) + return { + encoding: 'application/json', + body: res.data, + } + }, + }) + + server.app.bsky.graph.getFollowers({ + auth: ctx.authVerifier.modOrRole, + handler: async (request) => { + const res = await ctx.appviewAgent.api.app.bsky.graph.getFollowers( + request.params, + await ctx.appviewAuth(), + ) + return { + encoding: 'application/json', + body: res.data, + } + }, + }) +} diff --git a/packages/ozone/src/api/temp/fetchLabels.ts b/packages/ozone/src/api/temp/fetchLabels.ts index fd0331487d1..890fafdf95c 100644 --- a/packages/ozone/src/api/temp/fetchLabels.ts +++ b/packages/ozone/src/api/temp/fetchLabels.ts @@ -8,13 +8,13 @@ import { export default function (server: Server, ctx: AppContext) { server.com.atproto.temp.fetchLabels({ - auth: ctx.authOptionalAccessOrRoleVerifier, + auth: ctx.authVerifier.standardOptionalOrRole, handler: async ({ auth, params }) => { const { limit } = params const since = params.since !== undefined ? new Date(params.since).toISOString() : '' const includeUnspeccedTakedowns = - auth.credentials.type === 'role' && auth.credentials.admin + auth.credentials.type === 'null' ? false : auth.credentials.isAdmin const labelRes = await ctx.db.db .selectFrom('label') .selectAll() diff --git a/packages/ozone/src/auth-verifier.ts b/packages/ozone/src/auth-verifier.ts index a2fe5955f51..8ea3c58c59c 100644 --- a/packages/ozone/src/auth-verifier.ts +++ b/packages/ozone/src/auth-verifier.ts @@ -10,6 +10,7 @@ type ReqCtx = { type RoleOutput = { credentials: { type: 'role' + isAdmin: boolean isModerator: boolean isTriage: true } @@ -20,6 +21,7 @@ type ModeratorOutput = { type: 'moderator' aud: string iss: string + isAdmin: boolean isModerator: boolean isTriage: true } @@ -30,6 +32,7 @@ type StandardOutput = { type: 'standard' aud: string iss: string + isAdmin: boolean isModerator: boolean isTriage: boolean } @@ -44,6 +47,7 @@ type NullOutput = { export type AuthVerifierOpts = { serviceDid: string + admins: string[] moderators: string[] triage: string[] adminPassword: string @@ -53,6 +57,7 @@ export type AuthVerifierOpts = { export class AuthVerifier { serviceDid: string + admins: string[] moderators: string[] triage: string[] private adminPassword: string @@ -61,6 +66,7 @@ export class AuthVerifier { constructor(public idResolver: IdResolver, opts: AuthVerifierOpts) { this.serviceDid = opts.serviceDid + this.admins = opts.admins this.moderators = opts.moderators this.triage = opts.triage this.adminPassword = opts.adminPassword @@ -108,13 +114,15 @@ export class AuthVerifier { } const payload = await verifyJwt(jwtStr, this.serviceDid, getSigningKey) const iss = payload.iss - const isModerator = this.moderators.includes(iss) + const isAdmin = this.admins.includes(iss) + const isModerator = isAdmin || this.moderators.includes(iss) const isTriage = isModerator || this.triage.includes(iss) return { credentials: { type: 'standard', iss, aud: payload.aud, + isAdmin, isModerator, isTriage, }, @@ -130,34 +138,38 @@ export class AuthVerifier { return this.nullCreds() } + standardOptionalOrRole = async ( + reqCtx: ReqCtx, + ): Promise => { + if (isBearerToken(reqCtx.req)) { + return this.standard(reqCtx) + } else if (isBasicToken(reqCtx.req)) { + return this.role(reqCtx) + } else { + return this.nullCreds() + } + } + role = async (reqCtx: ReqCtx): Promise => { const parsed = parseBasicAuth(reqCtx.req.headers.authorization ?? '') const { username, password } = parsed ?? {} - if (username === 'admin') { + if (username !== 'admin') { throw new AuthRequiredError() } - if (password === this.triagePassword) { - return { - credentials: { - type: 'role', - isModerator: false, - isTriage: true, - }, - } - } else if ( - password === this.moderatorPassword || - password === this.adminPassword - ) { - return { - credentials: { - type: 'role', - isModerator: true, - isTriage: true, - }, - } - } else { + const isAdmin = password === this.adminPassword + const isModerator = isAdmin || password === this.moderatorPassword + const isTriage = isModerator || password === this.triagePassword + if (!isTriage) { throw new AuthRequiredError() } + return { + credentials: { + type: 'role', + isAdmin, + isModerator, + isTriage, + }, + } } nullCreds(): NullOutput { @@ -177,6 +189,10 @@ const isBearerToken = (req: express.Request): boolean => { return req.headers.authorization?.startsWith(BEARER) ?? false } +const isBasicToken = (req: express.Request): boolean => { + return req.headers.authorization?.startsWith(BASIC) ?? false +} + export const getJwtStrFromReq = (req: express.Request): string | null => { const { authorization } = req.headers if (!authorization?.startsWith(BEARER)) { diff --git a/packages/ozone/src/auth.ts b/packages/ozone/src/auth.ts deleted file mode 100644 index e996f068c49..00000000000 --- a/packages/ozone/src/auth.ts +++ /dev/null @@ -1,147 +0,0 @@ -import express from 'express' -import * as uint8arrays from 'uint8arrays' -import { AuthRequiredError, verifyJwt } from '@atproto/xrpc-server' -import { IdResolver } from '@atproto/identity' -import { OzoneSecrets } from './config' - -const BASIC = 'Basic ' -const BEARER = 'Bearer ' - -export const authVerifier = ( - idResolver: IdResolver, - opts: { aud: string | null }, -) => { - const getSigningKey = async ( - did: string, - forceRefresh: boolean, - ): Promise => { - const atprotoData = await idResolver.did.resolveAtprotoData( - did, - forceRefresh, - ) - return atprotoData.signingKey - } - - return async (reqCtx: { req: express.Request; res: express.Response }) => { - const jwtStr = getJwtStrFromReq(reqCtx.req) - if (!jwtStr) { - throw new AuthRequiredError('missing jwt', 'MissingJwt') - } - const payload = await verifyJwt(jwtStr, opts.aud, getSigningKey) - return { credentials: { did: payload.iss }, artifacts: { aud: opts.aud } } - } -} - -export const authOptionalVerifier = ( - idResolver: IdResolver, - opts: { aud: string | null }, -) => { - const verifyAccess = authVerifier(idResolver, opts) - return async (reqCtx: { req: express.Request; res: express.Response }) => { - if (!reqCtx.req.headers.authorization) { - return { credentials: { did: null } } - } - return verifyAccess(reqCtx) - } -} - -export const authOptionalAccessOrRoleVerifier = ( - idResolver: IdResolver, - secrets: OzoneSecrets, - serverDid: string, -) => { - const verifyAccess = authVerifier(idResolver, { aud: serverDid }) - const verifyRole = roleVerifier(secrets) - return async (ctx: { req: express.Request; res: express.Response }) => { - const defaultUnAuthorizedCredentials = { - credentials: { did: null, type: 'unauthed' as const }, - } - if (!ctx.req.headers.authorization) { - return defaultUnAuthorizedCredentials - } - // For non-admin tokens, we don't want to consider alternative verifiers and let it fail if it fails - const isRoleAuthToken = ctx.req.headers.authorization?.startsWith(BASIC) - if (isRoleAuthToken) { - const result = await verifyRole(ctx) - return { - ...result, - credentials: { - type: 'role' as const, - ...result.credentials, - }, - } - } - const result = await verifyAccess(ctx) - return { - ...result, - credentials: { - type: 'access' as const, - ...result.credentials, - }, - } - } -} - -export const roleVerifier = - (secrets: OzoneSecrets) => - async (reqCtx: { req: express.Request; res: express.Response }) => { - const credentials = getRoleCredentials(secrets, reqCtx.req) - if (!credentials.valid) { - throw new AuthRequiredError() - } - return { credentials } - } - -export const getRoleCredentials = ( - secrets: OzoneSecrets, - req: express.Request, -) => { - const parsed = parseBasicAuth(req.headers.authorization || '') - const { username, password } = parsed ?? {} - if (username === 'admin' && password === secrets.triagePassword) { - return { valid: true, admin: false, moderator: false, triage: true } - } - if (username === 'admin' && password === secrets.moderatorPassword) { - return { valid: true, admin: false, moderator: true, triage: true } - } - if (username === 'admin' && password === secrets.adminPassword) { - return { valid: true, admin: true, moderator: true, triage: true } - } - return { valid: false, admin: false, moderator: false, triage: false } -} - -export const parseBasicAuth = ( - token: string, -): { username: string; password: string } | null => { - if (!token.startsWith(BASIC)) return null - const b64 = token.slice(BASIC.length) - let parsed: string[] - try { - parsed = uint8arrays - .toString(uint8arrays.fromString(b64, 'base64pad'), 'utf8') - .split(':') - } catch (err) { - return null - } - const [username, password] = parsed - if (!username || !password) return null - return { username, password } -} - -export const buildBasicAuth = (username: string, password: string): string => { - return ( - BASIC + - uint8arrays.toString( - uint8arrays.fromString(`${username}:${password}`, 'utf8'), - 'base64pad', - ) - ) -} - -export const getJwtStrFromReq = (req: express.Request): string | null => { - const { authorization } = req.headers - if (!authorization?.startsWith(BEARER)) { - return null - } - return authorization.slice(BEARER.length).trim() -} diff --git a/packages/ozone/src/config/config.ts b/packages/ozone/src/config/config.ts index 3f9c38f96c8..e9ffc942b99 100644 --- a/packages/ozone/src/config/config.ts +++ b/packages/ozone/src/config/config.ts @@ -44,6 +44,7 @@ export const envToCfg = (env: OzoneEnvironment): OzoneConfig => { } const accessCfg: OzoneConfig['access'] = { + admins: env.adminDids, moderators: env.moderatorDids, triage: env.triageDids, } @@ -97,6 +98,7 @@ export type IdentityConfig = { } export type AccessConfig = { + admins: string[] moderators: string[] triage: string[] } diff --git a/packages/ozone/src/config/env.ts b/packages/ozone/src/config/env.ts index fcb6eb31d26..c0e99a4625b 100644 --- a/packages/ozone/src/config/env.ts +++ b/packages/ozone/src/config/env.ts @@ -17,6 +17,7 @@ export const readEnv = (): OzoneEnvironment => { dbPoolMaxUses: envInt('OZONE_DB_POOL_MAX_USES'), dbPoolIdleTimeoutMs: envInt('OZONE_DB_POOL_IDLE_TIMEOUT_MS'), didPlcUrl: envStr('OZONE_DID_PLC_URL'), + adminDids: envList('OZONE_ADMIN_DIDS'), moderatorDids: envList('OZONE_MODERATOR_DIDS'), triageDids: envList('OZONE_TRIAGE_DIDS'), adminPassword: envStr('OZONE_ADMIN_PASSWORD'), @@ -42,6 +43,7 @@ export type OzoneEnvironment = { dbPoolMaxUses?: number dbPoolIdleTimeoutMs?: number didPlcUrl?: string + adminDids: string[] moderatorDids: string[] triageDids: string[] adminPassword?: string diff --git a/packages/ozone/src/context.ts b/packages/ozone/src/context.ts index 726a500ea38..75de011b4bb 100644 --- a/packages/ozone/src/context.ts +++ b/packages/ozone/src/context.ts @@ -84,6 +84,7 @@ export class AppContext { const authVerifier = new AuthVerifier(idResolver, { serviceDid: cfg.service.did, + admins: cfg.access.admins, moderators: cfg.access.moderators, triage: cfg.access.triage, adminPassword: secrets.adminPassword, From 32b3de8556fb1641fff12ada398da913c5ce171f Mon Sep 17 00:00:00 2001 From: dholms Date: Thu, 29 Feb 2024 12:38:25 -0600 Subject: [PATCH 04/26] handle mod signing keys --- .../api/com/atproto/admin/getAccountInfos.ts | 2 +- .../api/com/atproto/admin/getSubjectStatus.ts | 2 +- .../com/atproto/admin/updateSubjectStatus.ts | 2 +- packages/bsky/src/auth-verifier.ts | 39 ++++++++++++++----- packages/common-web/src/did-doc.ts | 21 ++++++++-- .../api/com/atproto/admin/getAccountInfo.ts | 2 +- .../api/com/atproto/admin/getSubjectStatus.ts | 2 +- .../com/atproto/admin/updateSubjectStatus.ts | 2 +- packages/pds/src/auth-verifier.ts | 37 +++++++++++------- packages/xrpc-server/src/auth.ts | 2 +- 10 files changed, 78 insertions(+), 33 deletions(-) diff --git a/packages/bsky/src/api/com/atproto/admin/getAccountInfos.ts b/packages/bsky/src/api/com/atproto/admin/getAccountInfos.ts index e01be2d6383..cee84d53177 100644 --- a/packages/bsky/src/api/com/atproto/admin/getAccountInfos.ts +++ b/packages/bsky/src/api/com/atproto/admin/getAccountInfos.ts @@ -5,7 +5,7 @@ import { INVALID_HANDLE } from '@atproto/syntax' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getAccountInfos({ - auth: ctx.authVerifier.roleOrAdminService, + auth: ctx.authVerifier.roleOrModService, handler: async ({ params }) => { const { dids } = params const actors = await ctx.hydrator.actor.getActors(dids, true) diff --git a/packages/bsky/src/api/com/atproto/admin/getSubjectStatus.ts b/packages/bsky/src/api/com/atproto/admin/getSubjectStatus.ts index 2ca7bcdc2c9..3be4c1c0185 100644 --- a/packages/bsky/src/api/com/atproto/admin/getSubjectStatus.ts +++ b/packages/bsky/src/api/com/atproto/admin/getSubjectStatus.ts @@ -5,7 +5,7 @@ import { OutputSchema } from '../../../../lexicon/types/com/atproto/admin/getSub export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getSubjectStatus({ - auth: ctx.authVerifier.roleOrAdminService, + auth: ctx.authVerifier.roleOrModService, handler: async ({ params }) => { const { did, uri, blob } = params diff --git a/packages/bsky/src/api/com/atproto/admin/updateSubjectStatus.ts b/packages/bsky/src/api/com/atproto/admin/updateSubjectStatus.ts index bb78832aa93..8256efbe7e5 100644 --- a/packages/bsky/src/api/com/atproto/admin/updateSubjectStatus.ts +++ b/packages/bsky/src/api/com/atproto/admin/updateSubjectStatus.ts @@ -10,7 +10,7 @@ import { isMain as isStrongRef } from '../../../../lexicon/types/com/atproto/rep export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.updateSubjectStatus({ - auth: ctx.authVerifier.roleOrAdminService, + auth: ctx.authVerifier.roleOrModService, handler: async ({ input, auth }) => { const { canPerformTakedown } = ctx.authVerifier.parseCreds(auth) if (!canPerformTakedown) { diff --git a/packages/bsky/src/auth-verifier.ts b/packages/bsky/src/auth-verifier.ts index 69335772390..02d89680636 100644 --- a/packages/bsky/src/auth-verifier.ts +++ b/packages/bsky/src/auth-verifier.ts @@ -83,13 +83,21 @@ export class AuthVerifier { if (!this.parseRoleCreds(ctx.req).admin) { throw new AuthRequiredError('bad credentials') } - return { credentials: { type: 'standard', iss, aud } } + return { + credentials: { type: 'standard', iss, aud }, + } } const { iss, aud } = await this.verifyServiceJwt(ctx, { aud: this.ownDid, iss: null, }) - return { credentials: { type: 'standard', iss, aud } } + return { + credentials: { + type: 'standard', + iss, + aud, + }, + } } standardOptional = async ( @@ -159,19 +167,19 @@ export class AuthVerifier { } } - adminService = async (reqCtx: ReqCtx): Promise => { + modService = async (reqCtx: ReqCtx): Promise => { const { iss, aud } = await this.verifyServiceJwt(reqCtx, { aud: this.ownDid, - iss: [this.modServiceDid], + iss: [this.modServiceDid, `${this.modServiceDid}#atproto-mod`], }) return { credentials: { type: 'mod_service', aud, iss } } } - roleOrAdminService = async ( + roleOrModService = async ( reqCtx: ReqCtx, ): Promise => { if (isBearerToken(reqCtx.req)) { - return this.adminService(reqCtx) + return this.modService(reqCtx) } else { return this.role(reqCtx) } @@ -195,12 +203,13 @@ export class AuthVerifier { opts: { aud: string | null; iss: string[] | null }, ) { const getSigningKey = async ( - did: string, + iss: string, _forceRefresh: boolean, // @TODO consider propagating to dataplane ): Promise => { - if (opts.iss !== null && !opts.iss.includes(did)) { + if (opts.iss !== null && !opts.iss.includes(iss)) { throw new AuthRequiredError('Untrusted issuer', 'UntrustedIss') } + const [did, serviceId] = iss.split('#') let identity: GetIdentityByDidResponse try { identity = await this.dataplane.getIdentityByDid({ did }) @@ -211,7 +220,8 @@ export class AuthVerifier { throw err } const keys = unpackIdentityKeys(identity.keys) - const didKey = getKeyAsDidKey(keys, { id: 'atproto' }) + const keyId = serviceId === 'atproto-mod' ? 'atproto-mod-key' : 'atproto' + const didKey = getKeyAsDidKey(keys, { id: keyId }) if (!didKey) { throw new AuthRequiredError('missing or bad key') } @@ -226,6 +236,12 @@ export class AuthVerifier { return { iss: payload.iss, aud: payload.aud } } + isModService(iss: string): boolean { + return [this.modServiceDid, `${this.modServiceDid}#atproto-mod`].includes( + iss, + ) + } + nullCreds(): NullOutput { return { credentials: { @@ -242,10 +258,13 @@ export class AuthVerifier { creds.credentials.type === 'standard' ? creds.credentials.iss : null const canViewTakedowns = (creds.credentials.type === 'role' && creds.credentials.admin) || - creds.credentials.type === 'mod_service' + creds.credentials.type === 'mod_service' || + (creds.credentials.type === 'standard' && + this.isModService(creds.credentials.iss)) const canPerformTakedown = (creds.credentials.type === 'role' && creds.credentials.admin) || creds.credentials.type === 'mod_service' + return { viewer, canViewTakedowns, diff --git a/packages/common-web/src/did-doc.ts b/packages/common-web/src/did-doc.ts index 541e10d0937..1b84c1787a4 100644 --- a/packages/common-web/src/did-doc.ts +++ b/packages/common-web/src/did-doc.ts @@ -27,6 +27,13 @@ export const getHandle = (doc: DidDocument): string | undefined => { // @NOTE we parse to type/publicKeyMultibase to avoid the dependency on @atproto/crypto export const getSigningKey = ( doc: DidDocument, +): { type: string; publicKeyMultibase: string } | undefined => { + return getVerificationMaterial(doc, 'atproto') +} + +export const getVerificationMaterial = ( + doc: DidDocument, + keyId: string, ): { type: string; publicKeyMultibase: string } | undefined => { const did = getDid(doc) let keys = doc.verificationMethod @@ -36,7 +43,7 @@ export const getSigningKey = ( keys = [keys] } const found = keys.find( - (key) => key.id === '#atproto' || key.id === `${did}#atproto`, + (key) => key.id === `#${keyId}` || key.id === `${did}#${keyId}`, ) if (!found?.publicKeyMultibase) return undefined return { @@ -44,12 +51,20 @@ export const getSigningKey = ( publicKeyMultibase: found.publicKeyMultibase, } } -export const getSigningDidKey = (doc: DidDocument): string | undefined => { - const parsed = getSigningKey(doc) + +export const getDidKeyForId = ( + doc: DidDocument, + keyId: string, +): string | undefined => { + const parsed = getVerificationMaterial(doc, keyId) if (!parsed) return return `did:key:${parsed.publicKeyMultibase}` } +export const getSigningDidKey = (doc: DidDocument): string | undefined => { + return getDidKeyForId(doc, 'atproto') +} + export const getPdsEndpoint = (doc: DidDocument): string | undefined => { return getServiceEndpoint(doc, { id: '#atproto_pds', diff --git a/packages/pds/src/api/com/atproto/admin/getAccountInfo.ts b/packages/pds/src/api/com/atproto/admin/getAccountInfo.ts index e258f9714b2..7269bc0dfa9 100644 --- a/packages/pds/src/api/com/atproto/admin/getAccountInfo.ts +++ b/packages/pds/src/api/com/atproto/admin/getAccountInfo.ts @@ -5,7 +5,7 @@ import { INVALID_HANDLE } from '@atproto/syntax' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getAccountInfo({ - auth: ctx.authVerifier.roleOrAdminService, + auth: ctx.authVerifier.roleOrModService, handler: async ({ params }) => { const [account, invites, invitedBy] = await Promise.all([ ctx.accountManager.getAccount(params.did, { diff --git a/packages/pds/src/api/com/atproto/admin/getSubjectStatus.ts b/packages/pds/src/api/com/atproto/admin/getSubjectStatus.ts index 767714cec36..505e3dde4e9 100644 --- a/packages/pds/src/api/com/atproto/admin/getSubjectStatus.ts +++ b/packages/pds/src/api/com/atproto/admin/getSubjectStatus.ts @@ -7,7 +7,7 @@ import { OutputSchema } from '../../../../lexicon/types/com/atproto/admin/getSub export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getSubjectStatus({ - auth: ctx.authVerifier.roleOrAdminService, + auth: ctx.authVerifier.roleOrModService, handler: async ({ params }) => { const { did, uri, blob } = params let body: OutputSchema | null = null diff --git a/packages/pds/src/api/com/atproto/admin/updateSubjectStatus.ts b/packages/pds/src/api/com/atproto/admin/updateSubjectStatus.ts index 29991da2b2c..018d447d6eb 100644 --- a/packages/pds/src/api/com/atproto/admin/updateSubjectStatus.ts +++ b/packages/pds/src/api/com/atproto/admin/updateSubjectStatus.ts @@ -11,7 +11,7 @@ import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.updateSubjectStatus({ - auth: ctx.authVerifier.roleOrAdminService, + auth: ctx.authVerifier.roleOrModService, handler: async ({ input, auth }) => { // if less than moderator access then cannot perform a takedown if (auth.credentials.type === 'role' && !auth.credentials.moderator) { diff --git a/packages/pds/src/auth-verifier.ts b/packages/pds/src/auth-verifier.ts index 668791c187f..410b254d5b6 100644 --- a/packages/pds/src/auth-verifier.ts +++ b/packages/pds/src/auth-verifier.ts @@ -12,6 +12,7 @@ import * as jose from 'jose' import KeyEncoder from 'key-encoder' import { AccountManager } from './account-manager' import { softDeleted } from './db' +import { getDidKeyForId } from '@atproto/common' type ReqCtx = { req: express.Request @@ -44,9 +45,9 @@ type RoleOutput = { } } -type AdminServiceOutput = { +type ModServiceOutput = { credentials: { - type: 'service' + type: 'mod_service' aud: string iss: string } @@ -97,7 +98,7 @@ export type AuthVerifierOpts = { dids: { pds: string entryway?: string - admin?: string + modService?: string } } @@ -253,28 +254,28 @@ export class AuthVerifier { } } - adminService = async (reqCtx: ReqCtx): Promise => { - if (!this.dids.admin) { + modService = async (reqCtx: ReqCtx): Promise => { + if (!this.dids.modService) { throw new AuthRequiredError('Untrusted issuer', 'UntrustedIss') } const payload = await this.verifyServiceJwt(reqCtx, { aud: this.dids.entryway ?? this.dids.pds, - iss: [this.dids.admin], + iss: [this.dids.modService, `${this.dids.modService}#atproto-mod`], }) return { credentials: { - type: 'service', + type: 'mod_service', aud: payload.aud, iss: payload.iss, }, } } - roleOrAdminService = async ( + roleOrModService = async ( reqCtx: ReqCtx, - ): Promise => { + ): Promise => { if (isBearerToken(reqCtx.req)) { - return this.adminService(reqCtx) + return this.modService(reqCtx) } else { return this.role(reqCtx) } @@ -337,13 +338,23 @@ export class AuthVerifier { opts: { aud: string | null; iss: string[] | null }, ) { const getSigningKey = async ( - did: string, + iss: string, forceRefresh: boolean, ): Promise => { - if (opts.iss !== null && !opts.iss.includes(did)) { + if (opts.iss !== null && !opts.iss.includes(iss)) { throw new AuthRequiredError('Untrusted issuer', 'UntrustedIss') } - return this.idResolver.did.resolveAtprotoKey(did, forceRefresh) + const [did, serviceId] = iss.split('#') + const didDoc = await this.idResolver.did.resolve(did, forceRefresh) + if (!didDoc) { + throw new AuthRequiredError('could not resolve iss did') + } + const keyId = serviceId === 'atproto-mod' ? 'atproto-mod-key' : 'atproto' + const didKey = getDidKeyForId(didDoc, keyId) + if (!didKey) { + throw new AuthRequiredError('missing or bad key in did doc') + } + return didKey } const jwtStr = bearerTokenFromReq(reqCtx.req) diff --git a/packages/xrpc-server/src/auth.ts b/packages/xrpc-server/src/auth.ts index db6471aa23e..32373248f51 100644 --- a/packages/xrpc-server/src/auth.ts +++ b/packages/xrpc-server/src/auth.ts @@ -48,7 +48,7 @@ const jsonToB64Url = (json: Record): string => { export const verifyJwt = async ( jwtStr: string, ownDid: string | null, // null indicates to skip the audience check - getSigningKey: (did: string, forceRefresh: boolean) => Promise, + getSigningKey: (iss: string, forceRefresh: boolean) => Promise, ): Promise => { const parts = jwtStr.split('.') if (parts.length !== 3) { From 199b754fbcf2f902633ce100a9b8af382b5d4a12 Mon Sep 17 00:00:00 2001 From: dholms Date: Thu, 29 Feb 2024 16:12:31 -0600 Subject: [PATCH 05/26] fix bad var --- packages/pds/src/context.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index a9fe996056f..4f852af1497 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -199,7 +199,7 @@ export class AppContext { dids: { pds: cfg.service.did, entryway: cfg.entryway?.did, - admin: cfg.modService?.did, + modService: cfg.modService?.did, }, }) From cb53fdca79fca0c9789b1eefe170540538abde21 Mon Sep 17 00:00:00 2001 From: dholms Date: Thu, 29 Feb 2024 17:55:55 -0600 Subject: [PATCH 06/26] fix key parsing in pds --- packages/common-web/src/did-doc.ts | 11 ++--------- packages/pds/src/auth-verifier.ts | 10 +++++++--- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/common-web/src/did-doc.ts b/packages/common-web/src/did-doc.ts index 1b84c1787a4..715c364b3ca 100644 --- a/packages/common-web/src/did-doc.ts +++ b/packages/common-web/src/did-doc.ts @@ -52,19 +52,12 @@ export const getVerificationMaterial = ( } } -export const getDidKeyForId = ( - doc: DidDocument, - keyId: string, -): string | undefined => { - const parsed = getVerificationMaterial(doc, keyId) +export const getSigningDidKey = (doc: DidDocument): string | undefined => { + const parsed = getSigningKey(doc) if (!parsed) return return `did:key:${parsed.publicKeyMultibase}` } -export const getSigningDidKey = (doc: DidDocument): string | undefined => { - return getDidKeyForId(doc, 'atproto') -} - export const getPdsEndpoint = (doc: DidDocument): string | undefined => { return getServiceEndpoint(doc, { id: '#atproto_pds', diff --git a/packages/pds/src/auth-verifier.ts b/packages/pds/src/auth-verifier.ts index 410b254d5b6..ea2ebeb868d 100644 --- a/packages/pds/src/auth-verifier.ts +++ b/packages/pds/src/auth-verifier.ts @@ -5,14 +5,14 @@ import { InvalidRequestError, verifyJwt as verifyServiceJwt, } from '@atproto/xrpc-server' -import { IdResolver } from '@atproto/identity' +import { IdResolver, getDidKeyFromMultibase } from '@atproto/identity' import * as ui8 from 'uint8arrays' import express from 'express' import * as jose from 'jose' import KeyEncoder from 'key-encoder' import { AccountManager } from './account-manager' import { softDeleted } from './db' -import { getDidKeyForId } from '@atproto/common' +import { getVerificationMaterial } from '@atproto/common' type ReqCtx = { req: express.Request @@ -350,7 +350,11 @@ export class AuthVerifier { throw new AuthRequiredError('could not resolve iss did') } const keyId = serviceId === 'atproto-mod' ? 'atproto-mod-key' : 'atproto' - const didKey = getDidKeyForId(didDoc, keyId) + const parsedKey = getVerificationMaterial(didDoc, keyId) + if (!parsedKey) { + throw new AuthRequiredError('missing or bad key in did doc') + } + const didKey = getDidKeyFromMultibase(parsedKey) if (!didKey) { throw new AuthRequiredError('missing or bad key in did doc') } From e3bfb17202edce5e16b5025f435cf90645e86a69 Mon Sep 17 00:00:00 2001 From: dholms Date: Thu, 29 Feb 2024 19:24:06 -0600 Subject: [PATCH 07/26] fix admin auth test --- packages/bsky/src/auth-verifier.ts | 7 ++-- packages/ozone/src/api/well-known.ts | 4 +-- packages/pds/src/auth-verifier.ts | 5 ++- packages/pds/tests/admin-auth.test.ts | 49 +++++++++++++++++++-------- 4 files changed, 41 insertions(+), 24 deletions(-) diff --git a/packages/bsky/src/auth-verifier.ts b/packages/bsky/src/auth-verifier.ts index 02d89680636..4c5311d73fe 100644 --- a/packages/bsky/src/auth-verifier.ts +++ b/packages/bsky/src/auth-verifier.ts @@ -170,7 +170,7 @@ export class AuthVerifier { modService = async (reqCtx: ReqCtx): Promise => { const { iss, aud } = await this.verifyServiceJwt(reqCtx, { aud: this.ownDid, - iss: [this.modServiceDid, `${this.modServiceDid}#atproto-mod`], + iss: [this.modServiceDid, `${this.modServiceDid}#atproto_mod`], }) return { credentials: { type: 'mod_service', aud, iss } } } @@ -209,7 +209,7 @@ export class AuthVerifier { if (opts.iss !== null && !opts.iss.includes(iss)) { throw new AuthRequiredError('Untrusted issuer', 'UntrustedIss') } - const [did, serviceId] = iss.split('#') + const [did, keyId = 'atproto'] = iss.split('#') let identity: GetIdentityByDidResponse try { identity = await this.dataplane.getIdentityByDid({ did }) @@ -220,7 +220,6 @@ export class AuthVerifier { throw err } const keys = unpackIdentityKeys(identity.keys) - const keyId = serviceId === 'atproto-mod' ? 'atproto-mod-key' : 'atproto' const didKey = getKeyAsDidKey(keys, { id: keyId }) if (!didKey) { throw new AuthRequiredError('missing or bad key') @@ -237,7 +236,7 @@ export class AuthVerifier { } isModService(iss: string): boolean { - return [this.modServiceDid, `${this.modServiceDid}#atproto-mod`].includes( + return [this.modServiceDid, `${this.modServiceDid}#atproto_mod`].includes( iss, ) } diff --git a/packages/ozone/src/api/well-known.ts b/packages/ozone/src/api/well-known.ts index 9cbfa9efe53..29a9f6d3636 100644 --- a/packages/ozone/src/api/well-known.ts +++ b/packages/ozone/src/api/well-known.ts @@ -15,7 +15,7 @@ export const createRouter = (ctx: AppContext): express.Router => { id: ctx.cfg.service.did, verificationMethod: [ { - id: `${ctx.cfg.service.did}#atproto`, + id: `${ctx.cfg.service.did}#atproto_mod`, type: 'Multikey', controller: ctx.cfg.service.did, publicKeyMultibase: ctx.signingKey.did().replace('did:key:', ''), @@ -23,7 +23,7 @@ export const createRouter = (ctx: AppContext): express.Router => { ], service: [ { - id: '#atproto_mod', + id: '#atproto_mod_srvc', type: 'AtprotoModerationService', serviceEndpoint: `https://${hostname}`, }, diff --git a/packages/pds/src/auth-verifier.ts b/packages/pds/src/auth-verifier.ts index ea2ebeb868d..5f1b11fd29c 100644 --- a/packages/pds/src/auth-verifier.ts +++ b/packages/pds/src/auth-verifier.ts @@ -260,7 +260,7 @@ export class AuthVerifier { } const payload = await this.verifyServiceJwt(reqCtx, { aud: this.dids.entryway ?? this.dids.pds, - iss: [this.dids.modService, `${this.dids.modService}#atproto-mod`], + iss: [this.dids.modService, `${this.dids.modService}#atproto_mod`], }) return { credentials: { @@ -344,12 +344,11 @@ export class AuthVerifier { if (opts.iss !== null && !opts.iss.includes(iss)) { throw new AuthRequiredError('Untrusted issuer', 'UntrustedIss') } - const [did, serviceId] = iss.split('#') + const [did, keyId = 'atproto'] = iss.split('#') const didDoc = await this.idResolver.did.resolve(did, forceRefresh) if (!didDoc) { throw new AuthRequiredError('could not resolve iss did') } - const keyId = serviceId === 'atproto-mod' ? 'atproto-mod-key' : 'atproto' const parsedKey = getVerificationMaterial(didDoc, keyId) if (!parsedKey) { throw new AuthRequiredError('missing or bad key in did doc') diff --git a/packages/pds/tests/admin-auth.test.ts b/packages/pds/tests/admin-auth.test.ts index dffd9261874..191f7a5daf4 100644 --- a/packages/pds/tests/admin-auth.test.ts +++ b/packages/pds/tests/admin-auth.test.ts @@ -1,7 +1,8 @@ import { TestNetworkNoAppView, SeedClient } from '@atproto/dev-env' import AtpAgent from '@atproto/api' -import { Secp256k1Keypair } from '@atproto/crypto' +import { Keypair, Secp256k1Keypair } from '@atproto/crypto' import { createServiceAuthHeaders } from '@atproto/xrpc-server' +import * as plc from '@did-plc/lib' import usersSeed from './seeds/users' import { RepoRef } from '../src/lexicon/types/com/atproto/admin/defs' @@ -12,32 +13,50 @@ describe('admin auth', () => { let repoSubject: RepoRef - const modServiceDid = 'did:example:mod' - const altModDid = 'did:example:alt' + let modServiceDid: string + let altModDid: string let modServiceKey: Secp256k1Keypair let pdsDid: string + const opAndDid = async (handle: string, key: Keypair) => { + const op = await plc.signOperation( + { + type: 'plc_operation', + alsoKnownAs: [handle], + verificationMethods: { + atproto: key.did(), + }, + rotationKeys: [key.did()], + services: {}, + prev: null, + }, + key, + ) + const did = await plc.didForCreateOp(op) + return { op, did } + } + beforeAll(async () => { + // kinda goofy but we need to know the dids before creating the testnet for the PDS's config + modServiceKey = await Secp256k1Keypair.create() + const modServiceInfo = await opAndDid('mod.test', modServiceKey) + const altModInfo = await opAndDid('alt-mod.test', modServiceKey) + modServiceDid = modServiceInfo.did + altModDid = altModInfo.did + network = await TestNetworkNoAppView.create({ dbPostgresSchema: 'pds_admin_auth', pds: { - modServiceDid, + modServiceDid: modServiceInfo.did, + modServiceUrl: 'https://mod.invalid', }, }) pdsDid = network.pds.ctx.cfg.service.did - modServiceKey = await Secp256k1Keypair.create() - const origResolve = network.pds.ctx.idResolver.did.resolveAtprotoKey - network.pds.ctx.idResolver.did.resolveAtprotoKey = async ( - did: string, - forceRefresh?: boolean, - ) => { - if (did === modServiceDid || did === altModDid) { - return modServiceKey.did() - } - return origResolve(did, forceRefresh) - } + const plcClient = network.plc.getClient() + await plcClient.sendOperation(modServiceInfo.did, modServiceInfo.op) + await plcClient.sendOperation(altModInfo.did, altModInfo.op) agent = network.pds.getClient() sc = network.getSeedClient() From a642063979df3e9d10f8e0cdc250fd9132046084 Mon Sep 17 00:00:00 2001 From: dholms Date: Thu, 29 Feb 2024 19:24:41 -0600 Subject: [PATCH 08/26] rename test --- .../pds/tests/{admin-auth.test.ts => moderator-auth.test.ts} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename packages/pds/tests/{admin-auth.test.ts => moderator-auth.test.ts} (98%) diff --git a/packages/pds/tests/admin-auth.test.ts b/packages/pds/tests/moderator-auth.test.ts similarity index 98% rename from packages/pds/tests/admin-auth.test.ts rename to packages/pds/tests/moderator-auth.test.ts index 191f7a5daf4..0b89b4f0fd7 100644 --- a/packages/pds/tests/admin-auth.test.ts +++ b/packages/pds/tests/moderator-auth.test.ts @@ -6,7 +6,7 @@ import * as plc from '@did-plc/lib' import usersSeed from './seeds/users' import { RepoRef } from '../src/lexicon/types/com/atproto/admin/defs' -describe('admin auth', () => { +describe('moderator auth', () => { let network: TestNetworkNoAppView let agent: AtpAgent let sc: SeedClient @@ -45,7 +45,7 @@ describe('admin auth', () => { altModDid = altModInfo.did network = await TestNetworkNoAppView.create({ - dbPostgresSchema: 'pds_admin_auth', + dbPostgresSchema: 'pds_moderator_auth', pds: { modServiceDid: modServiceInfo.did, modServiceUrl: 'https://mod.invalid', From f7ef546f7b9f51d4af4e2c3ef58eb50da8f80214 Mon Sep 17 00:00:00 2001 From: dholms Date: Mon, 4 Mar 2024 16:05:53 -0600 Subject: [PATCH 09/26] update did doc id values --- packages/bsky/src/auth-verifier.ts | 13 ++++++++----- packages/ozone/src/api/well-known.ts | 6 +++--- packages/pds/src/auth-verifier.ts | 6 ++++-- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/bsky/src/auth-verifier.ts b/packages/bsky/src/auth-verifier.ts index 4c5311d73fe..03766c55151 100644 --- a/packages/bsky/src/auth-verifier.ts +++ b/packages/bsky/src/auth-verifier.ts @@ -170,7 +170,7 @@ export class AuthVerifier { modService = async (reqCtx: ReqCtx): Promise => { const { iss, aud } = await this.verifyServiceJwt(reqCtx, { aud: this.ownDid, - iss: [this.modServiceDid, `${this.modServiceDid}#atproto_mod`], + iss: [this.modServiceDid, `${this.modServiceDid}#atproto_labeler`], }) return { credentials: { type: 'mod_service', aud, iss } } } @@ -209,7 +209,9 @@ export class AuthVerifier { if (opts.iss !== null && !opts.iss.includes(iss)) { throw new AuthRequiredError('Untrusted issuer', 'UntrustedIss') } - const [did, keyId = 'atproto'] = iss.split('#') + const [did, serviceId] = iss.split('#') + const keyId = + serviceId === 'atproto-labeler' ? 'atproto-label' : 'atproto' let identity: GetIdentityByDidResponse try { identity = await this.dataplane.getIdentityByDid({ did }) @@ -236,9 +238,10 @@ export class AuthVerifier { } isModService(iss: string): boolean { - return [this.modServiceDid, `${this.modServiceDid}#atproto_mod`].includes( - iss, - ) + return [ + this.modServiceDid, + `${this.modServiceDid}#atproto_labeler`, + ].includes(iss) } nullCreds(): NullOutput { diff --git a/packages/ozone/src/api/well-known.ts b/packages/ozone/src/api/well-known.ts index 29a9f6d3636..17ee9ad3752 100644 --- a/packages/ozone/src/api/well-known.ts +++ b/packages/ozone/src/api/well-known.ts @@ -15,7 +15,7 @@ export const createRouter = (ctx: AppContext): express.Router => { id: ctx.cfg.service.did, verificationMethod: [ { - id: `${ctx.cfg.service.did}#atproto_mod`, + id: `${ctx.cfg.service.did}#atproto_label`, type: 'Multikey', controller: ctx.cfg.service.did, publicKeyMultibase: ctx.signingKey.did().replace('did:key:', ''), @@ -23,8 +23,8 @@ export const createRouter = (ctx: AppContext): express.Router => { ], service: [ { - id: '#atproto_mod_srvc', - type: 'AtprotoModerationService', + id: '#atproto_labeler', + type: 'AtprotoLabeler', serviceEndpoint: `https://${hostname}`, }, ], diff --git a/packages/pds/src/auth-verifier.ts b/packages/pds/src/auth-verifier.ts index 5f1b11fd29c..6625ea9629c 100644 --- a/packages/pds/src/auth-verifier.ts +++ b/packages/pds/src/auth-verifier.ts @@ -260,7 +260,7 @@ export class AuthVerifier { } const payload = await this.verifyServiceJwt(reqCtx, { aud: this.dids.entryway ?? this.dids.pds, - iss: [this.dids.modService, `${this.dids.modService}#atproto_mod`], + iss: [this.dids.modService, `${this.dids.modService}#atproto_labeler`], }) return { credentials: { @@ -344,7 +344,9 @@ export class AuthVerifier { if (opts.iss !== null && !opts.iss.includes(iss)) { throw new AuthRequiredError('Untrusted issuer', 'UntrustedIss') } - const [did, keyId = 'atproto'] = iss.split('#') + const [did, serviceId] = iss.split('#') + const keyId = + serviceId === 'atproto_labeler' ? 'atproto_label' : 'atproto' const didDoc = await this.idResolver.did.resolve(did, forceRefresh) if (!didDoc) { throw new AuthRequiredError('could not resolve iss did') From 0482a92fe46f301560d898977509694deef52e3a Mon Sep 17 00:00:00 2001 From: dholms Date: Mon, 4 Mar 2024 16:13:34 -0600 Subject: [PATCH 10/26] null creds string -> `none` --- packages/bsky/src/auth-verifier.ts | 4 ++-- packages/ozone/src/auth-verifier.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/bsky/src/auth-verifier.ts b/packages/bsky/src/auth-verifier.ts index 03766c55151..b305aad1ef1 100644 --- a/packages/bsky/src/auth-verifier.ts +++ b/packages/bsky/src/auth-verifier.ts @@ -25,7 +25,7 @@ export enum RoleStatus { type NullOutput = { credentials: { - type: 'null' + type: 'none' iss: null } } @@ -247,7 +247,7 @@ export class AuthVerifier { nullCreds(): NullOutput { return { credentials: { - type: 'null', + type: 'none', iss: null, }, } diff --git a/packages/ozone/src/auth-verifier.ts b/packages/ozone/src/auth-verifier.ts index 8ea3c58c59c..48ca241e6ef 100644 --- a/packages/ozone/src/auth-verifier.ts +++ b/packages/ozone/src/auth-verifier.ts @@ -40,7 +40,7 @@ type StandardOutput = { type NullOutput = { credentials: { - type: 'null' + type: 'none' iss: null } } @@ -175,7 +175,7 @@ export class AuthVerifier { nullCreds(): NullOutput { return { credentials: { - type: 'null', + type: 'none', iss: null, }, } From 5df31de214d873d0fb40cd7fb42d130a8b046e04 Mon Sep 17 00:00:00 2001 From: dholms Date: Mon, 4 Mar 2024 18:49:03 -0600 Subject: [PATCH 11/26] fix fetchLabels auth check --- packages/ozone/src/api/temp/fetchLabels.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ozone/src/api/temp/fetchLabels.ts b/packages/ozone/src/api/temp/fetchLabels.ts index 890fafdf95c..b2fbfbb846a 100644 --- a/packages/ozone/src/api/temp/fetchLabels.ts +++ b/packages/ozone/src/api/temp/fetchLabels.ts @@ -14,7 +14,7 @@ export default function (server: Server, ctx: AppContext) { const since = params.since !== undefined ? new Date(params.since).toISOString() : '' const includeUnspeccedTakedowns = - auth.credentials.type === 'null' ? false : auth.credentials.isAdmin + auth.credentials.type === 'none' ? false : auth.credentials.isAdmin const labelRes = await ctx.db.db .selectFrom('label') .selectAll() From dd891d4608a0fa28144ffcc786339365af40f490 Mon Sep 17 00:00:00 2001 From: Foysal Ahamed Date: Tue, 5 Mar 2024 18:01:05 +0000 Subject: [PATCH 12/26] :sparkles: Add a couple more proxied requests that we use in ozone ui --- packages/ozone/src/api/proxied.ts | 42 +++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/ozone/src/api/proxied.ts b/packages/ozone/src/api/proxied.ts index b7eef005650..a2d5040989e 100644 --- a/packages/ozone/src/api/proxied.ts +++ b/packages/ozone/src/api/proxied.ts @@ -16,6 +16,20 @@ export default function (server: Server, ctx: AppContext) { }, }) + server.app.bsky.actor.getProfiles({ + auth: ctx.authVerifier.modOrRole, + handler: async (request) => { + const res = await ctx.appviewAgent.api.app.bsky.actor.getProfiles( + request.params, + await ctx.appviewAuth(), + ) + return { + encoding: 'application/json', + body: res.data, + } + }, + }) + server.app.bsky.feed.getAuthorFeed({ auth: ctx.authVerifier.modOrRole, handler: async (request) => { @@ -44,6 +58,20 @@ export default function (server: Server, ctx: AppContext) { }, }) + server.app.bsky.feed.getFeedGenerator({ + auth: ctx.authVerifier.modOrRole, + handler: async (request) => { + const res = await ctx.appviewAgent.api.app.bsky.feed.getFeedGenerator( + request.params, + await ctx.appviewAuth(), + ) + return { + encoding: 'application/json', + body: res.data, + } + }, + }) + server.app.bsky.graph.getFollows({ auth: ctx.authVerifier.modOrRole, handler: async (request) => { @@ -71,4 +99,18 @@ export default function (server: Server, ctx: AppContext) { } }, }) + + server.app.bsky.graph.getList({ + auth: ctx.authVerifier.modOrRole, + handler: async (request) => { + const res = await ctx.appviewAgent.api.app.bsky.graph.getList( + request.params, + await ctx.appviewAuth(), + ) + return { + encoding: 'application/json', + body: res.data, + } + }, + }) } From 2ca4feebba7fb36ab95fb6f0701be896a62d153f Mon Sep 17 00:00:00 2001 From: Jake Gold <52801504+Jacob2161@users.noreply.github.com> Date: Thu, 29 Feb 2024 14:16:50 -0800 Subject: [PATCH 13/26] Add runit to the services/bsky Dockerfile (#2254) add runit to the services/bsky Dockerfile --- services/bsky/Dockerfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/services/bsky/Dockerfile b/services/bsky/Dockerfile index 84422945ae0..a15e6aa2ee7 100644 --- a/services/bsky/Dockerfile +++ b/services/bsky/Dockerfile @@ -35,7 +35,10 @@ WORKDIR services/bsky # Uses assets from build stage to reduce build size FROM node:20.11-alpine -RUN apk add --update dumb-init +# dumb-init is used to handle signals properly. +# runit is installed so it can be (optionally) used for logging via svlogd. +RUN apk add --update dumb-init runit + # Avoid zombie processes, handle signal forwarding ENTRYPOINT ["dumb-init", "--"] From 6ba5f6c67fb7fc8a8fd5b3fc4f1fbc84638f21b3 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Fri, 1 Mar 2024 12:44:52 -0600 Subject: [PATCH 14/26] Improve tag detection (#2260) * Allow tags to lead with and contain only numbers * Break tags on other whitespace characters * Export regexes from rich text detection * Add test * Add test * Disallow number-only tags * Avoid combining enclosing screen chars * Allow full-width number sign * Clarify tests * Fix punctuation edge case * Reorder * Simplify, add another test * Another test, comment --- .changeset/chatty-cows-kick.md | 5 +++ .changeset/lovely-pandas-pretend.md | 5 +++ .changeset/quick-ducks-joke.md | 5 +++ packages/api/src/index.ts | 1 + packages/api/src/rich-text/detection.ts | 18 ++++++++--- packages/api/src/rich-text/util.ts | 11 +++++++ .../api/tests/rich-text-detection.test.ts | 31 +++++++++++++++++-- 7 files changed, 68 insertions(+), 8 deletions(-) create mode 100644 .changeset/chatty-cows-kick.md create mode 100644 .changeset/lovely-pandas-pretend.md create mode 100644 .changeset/quick-ducks-joke.md create mode 100644 packages/api/src/rich-text/util.ts diff --git a/.changeset/chatty-cows-kick.md b/.changeset/chatty-cows-kick.md new file mode 100644 index 00000000000..76bd82f015d --- /dev/null +++ b/.changeset/chatty-cows-kick.md @@ -0,0 +1,5 @@ +--- +'@atproto/api': patch +--- + +Export regex from rich text detection diff --git a/.changeset/lovely-pandas-pretend.md b/.changeset/lovely-pandas-pretend.md new file mode 100644 index 00000000000..3a75be2877b --- /dev/null +++ b/.changeset/lovely-pandas-pretend.md @@ -0,0 +1,5 @@ +--- +'@atproto/api': patch +--- + +Disallow rare unicode whitespace characters from tags diff --git a/.changeset/quick-ducks-joke.md b/.changeset/quick-ducks-joke.md new file mode 100644 index 00000000000..923b2fe2fe0 --- /dev/null +++ b/.changeset/quick-ducks-joke.md @@ -0,0 +1,5 @@ +--- +'@atproto/api': patch +--- + +Allow tags to lead with numbers diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 9e407142aba..87cf1ccf01a 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -14,6 +14,7 @@ export * from './agent' export * from './rich-text/rich-text' export * from './rich-text/sanitization' export * from './rich-text/unicode' +export * from './rich-text/util' export * from './moderation' export * from './moderation/types' export { LABELS } from './moderation/const/labels' diff --git a/packages/api/src/rich-text/detection.ts b/packages/api/src/rich-text/detection.ts index 7b5444a68a5..22c5db1b087 100644 --- a/packages/api/src/rich-text/detection.ts +++ b/packages/api/src/rich-text/detection.ts @@ -1,6 +1,12 @@ import TLDs from 'tlds' import { AppBskyRichtextFacet } from '../client' import { UnicodeString } from './unicode' +import { + URL_REGEX, + MENTION_REGEX, + TAG_REGEX, + TRAILING_PUNCTUATION_REGEX, +} from './util' export type Facet = AppBskyRichtextFacet.Main @@ -9,7 +15,7 @@ export function detectFacets(text: UnicodeString): Facet[] | undefined { const facets: Facet[] = [] { // mentions - const re = /(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)/g + const re = MENTION_REGEX while ((match = re.exec(text.utf16))) { if (!isValidDomain(match[3]) && !match[3].endsWith('.test')) { continue // probably not a handle @@ -33,8 +39,7 @@ export function detectFacets(text: UnicodeString): Facet[] | undefined { } { // links - const re = - /(^|\s|\()((https?:\/\/[\S]+)|((?[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim + const re = URL_REGEX while ((match = re.exec(text.utf16))) { let uri = match[2] if (!uri.startsWith('http')) { @@ -70,11 +75,14 @@ export function detectFacets(text: UnicodeString): Facet[] | undefined { } } { - const re = /(^|\s)#((?!\ufe0f)[^\d\s]\S*)(?=\s)?/g + const re = TAG_REGEX while ((match = re.exec(text.utf16))) { let [, leading, tag] = match - tag = tag.trim().replace(/\p{P}+$/gu, '') // strip ending punctuation + if (!tag) continue + + // strip ending punctuation and any spaces + tag = tag.trim().replace(TRAILING_PUNCTUATION_REGEX, '') if (tag.length === 0 || tag.length > 64) continue diff --git a/packages/api/src/rich-text/util.ts b/packages/api/src/rich-text/util.ts new file mode 100644 index 00000000000..ab50c66212d --- /dev/null +++ b/packages/api/src/rich-text/util.ts @@ -0,0 +1,11 @@ +export const MENTION_REGEX = /(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)/g +export const URL_REGEX = + /(^|\s|\()((https?:\/\/[\S]+)|((?[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim +export const TRAILING_PUNCTUATION_REGEX = /\p{P}+$/gu + +/** + * `\ufe0f` emoji modifier + * `\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2` zero-width spaces (likely incomplete) + */ +export const TAG_REGEX = + /(^|\s)[##]((?!\ufe0f)[^\s\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2]*[^\d\s\p{P}\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2]+[^\s\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2]*)?/gu diff --git a/packages/api/tests/rich-text-detection.test.ts b/packages/api/tests/rich-text-detection.test.ts index 0eafb65a3b1..084b5440a48 100644 --- a/packages/api/tests/rich-text-detection.test.ts +++ b/packages/api/tests/rich-text-detection.test.ts @@ -218,7 +218,7 @@ describe('detectFacets', () => { } }) - it('correctly detects tags inline', async () => { + describe('correctly detects tags inline', () => { const inputs: [ string, string[], @@ -234,11 +234,13 @@ describe('detectFacets', () => { ], ], ['#1', [], []], + ['#1a', ['1a'], [{ byteStart: 0, byteEnd: 3 }]], ['#tag', ['tag'], [{ byteStart: 0, byteEnd: 4 }]], ['body #tag', ['tag'], [{ byteStart: 5, byteEnd: 9 }]], ['#tag body', ['tag'], [{ byteStart: 0, byteEnd: 4 }]], ['body #tag body', ['tag'], [{ byteStart: 5, byteEnd: 9 }]], ['body #1', [], []], + ['body #1a', ['1a'], [{ byteStart: 5, byteEnd: 8 }]], ['body #a1', ['a1'], [{ byteStart: 5, byteEnd: 8 }]], ['#', [], []], ['#?', [], []], @@ -254,12 +256,18 @@ describe('detectFacets', () => { [], [], ], + [ + 'body #thisisa64characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!', + ['thisisa64characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'], + [{ byteStart: 5, byteEnd: 70 }], + ], [ 'its a #double#rainbow', ['double#rainbow'], [{ byteStart: 6, byteEnd: 21 }], ], ['##hashash', ['#hashash'], [{ byteStart: 0, byteEnd: 9 }]], + ['##', [], []], ['some #n0n3s@n5e!', ['n0n3s@n5e'], [{ byteStart: 5, byteEnd: 15 }]], [ 'works #with,punctuation', @@ -319,9 +327,26 @@ describe('detectFacets', () => { }, ], ], + ['no match (\\u200B): #​', [], []], + ['no match (\\u200Ba): #​a', [], []], + ['match (a\\u200Bb): #a​b', ['a'], [{ byteStart: 18, byteEnd: 20 }]], + ['match (ab\\u200B): #ab​', ['ab'], [{ byteStart: 18, byteEnd: 21 }]], + ['no match (\\u20e2tag): #⃢tag', [], []], + ['no match (a\\u20e2b): #a⃢b', ['a'], [{ byteStart: 21, byteEnd: 23 }]], + [ + 'match full width number sign (tag): #tag', + ['tag'], + [{ byteStart: 36, byteEnd: 42 }], + ], + [ + 'match full width number sign (tag): ##️⃣tag', + ['#️⃣tag'], + [{ byteStart: 36, byteEnd: 49 }], + ], + ['no match 1?: #1?', [], []], ] - for (const [input, tags, indices] of inputs) { + it.each(inputs)('%s', async (input, tags, indices) => { const rt = new RichText({ text: input }) await rt.detectFacets(agent) @@ -340,7 +365,7 @@ describe('detectFacets', () => { expect(detectedTags).toEqual(tags) expect(detectedIndices).toEqual(indices) - } + }) }) }) From 9b2500e895f1109f42efcf7d456f4cf606e50901 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 1 Mar 2024 13:03:04 -0600 Subject: [PATCH 15/26] Version packages (#2261) Co-authored-by: github-actions[bot] --- .changeset/chatty-cows-kick.md | 5 ----- .changeset/lovely-pandas-pretend.md | 5 ----- .changeset/quick-ducks-joke.md | 5 ----- packages/api/CHANGELOG.md | 10 ++++++++++ packages/api/package.json | 2 +- packages/bsky/CHANGELOG.md | 7 +++++++ packages/bsky/package.json | 2 +- packages/dev-env/CHANGELOG.md | 10 ++++++++++ packages/dev-env/package.json | 2 +- packages/ozone/CHANGELOG.md | 7 +++++++ packages/ozone/package.json | 2 +- packages/pds/CHANGELOG.md | 7 +++++++ packages/pds/package.json | 2 +- 13 files changed, 46 insertions(+), 20 deletions(-) delete mode 100644 .changeset/chatty-cows-kick.md delete mode 100644 .changeset/lovely-pandas-pretend.md delete mode 100644 .changeset/quick-ducks-joke.md diff --git a/.changeset/chatty-cows-kick.md b/.changeset/chatty-cows-kick.md deleted file mode 100644 index 76bd82f015d..00000000000 --- a/.changeset/chatty-cows-kick.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@atproto/api': patch ---- - -Export regex from rich text detection diff --git a/.changeset/lovely-pandas-pretend.md b/.changeset/lovely-pandas-pretend.md deleted file mode 100644 index 3a75be2877b..00000000000 --- a/.changeset/lovely-pandas-pretend.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@atproto/api': patch ---- - -Disallow rare unicode whitespace characters from tags diff --git a/.changeset/quick-ducks-joke.md b/.changeset/quick-ducks-joke.md deleted file mode 100644 index 923b2fe2fe0..00000000000 --- a/.changeset/quick-ducks-joke.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@atproto/api': patch ---- - -Allow tags to lead with numbers diff --git a/packages/api/CHANGELOG.md b/packages/api/CHANGELOG.md index 48e4edc018b..a5bac9a4eaa 100644 --- a/packages/api/CHANGELOG.md +++ b/packages/api/CHANGELOG.md @@ -1,5 +1,15 @@ # @atproto/api +## 0.10.4 + +### Patch Changes + +- [#2260](https://github.com/bluesky-social/atproto/pull/2260) [`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Export regex from rich text detection + +- [#2260](https://github.com/bluesky-social/atproto/pull/2260) [`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Disallow rare unicode whitespace characters from tags + +- [#2260](https://github.com/bluesky-social/atproto/pull/2260) [`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Allow tags to lead with numbers + ## 0.10.3 ### Patch Changes diff --git a/packages/api/package.json b/packages/api/package.json index 4f07d5a01a1..dbb18da1786 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/api", - "version": "0.10.3", + "version": "0.10.4", "license": "MIT", "description": "Client library for atproto and Bluesky", "keywords": [ diff --git a/packages/bsky/CHANGELOG.md b/packages/bsky/CHANGELOG.md index 27155388ef9..102c40050eb 100644 --- a/packages/bsky/CHANGELOG.md +++ b/packages/bsky/CHANGELOG.md @@ -1,5 +1,12 @@ # @atproto/bsky +## 0.0.36 + +### Patch Changes + +- Updated dependencies [[`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f), [`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f), [`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f)]: + - @atproto/api@0.10.4 + ## 0.0.35 ### Patch Changes diff --git a/packages/bsky/package.json b/packages/bsky/package.json index 8ffa5e0911f..dd081d3c209 100644 --- a/packages/bsky/package.json +++ b/packages/bsky/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/bsky", - "version": "0.0.35", + "version": "0.0.36", "license": "MIT", "description": "Reference implementation of app.bsky App View (Bluesky API)", "keywords": [ diff --git a/packages/dev-env/CHANGELOG.md b/packages/dev-env/CHANGELOG.md index 814f89dd619..15a6ad3171f 100644 --- a/packages/dev-env/CHANGELOG.md +++ b/packages/dev-env/CHANGELOG.md @@ -1,5 +1,15 @@ # @atproto/dev-env +## 0.2.36 + +### Patch Changes + +- Updated dependencies [[`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f), [`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f), [`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f)]: + - @atproto/api@0.10.4 + - @atproto/bsky@0.0.36 + - @atproto/ozone@0.0.15 + - @atproto/pds@0.4.4 + ## 0.2.35 ### Patch Changes diff --git a/packages/dev-env/package.json b/packages/dev-env/package.json index 5924c18bfd0..46899cdb763 100644 --- a/packages/dev-env/package.json +++ b/packages/dev-env/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/dev-env", - "version": "0.2.35", + "version": "0.2.36", "license": "MIT", "description": "Local development environment helper for atproto development", "keywords": [ diff --git a/packages/ozone/CHANGELOG.md b/packages/ozone/CHANGELOG.md index 7f27390ce91..0953945b4b2 100644 --- a/packages/ozone/CHANGELOG.md +++ b/packages/ozone/CHANGELOG.md @@ -1,5 +1,12 @@ # @atproto/ozone +## 0.0.15 + +### Patch Changes + +- Updated dependencies [[`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f), [`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f), [`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f)]: + - @atproto/api@0.10.4 + ## 0.0.14 ### Patch Changes diff --git a/packages/ozone/package.json b/packages/ozone/package.json index 3a3d3a690ff..3840f4b49ed 100644 --- a/packages/ozone/package.json +++ b/packages/ozone/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/ozone", - "version": "0.0.14", + "version": "0.0.15", "license": "MIT", "description": "Backend service for moderating the Bluesky network.", "keywords": [ diff --git a/packages/pds/CHANGELOG.md b/packages/pds/CHANGELOG.md index 3a323988102..1a3e87286f3 100644 --- a/packages/pds/CHANGELOG.md +++ b/packages/pds/CHANGELOG.md @@ -1,5 +1,12 @@ # @atproto/pds +## 0.4.4 + +### Patch Changes + +- Updated dependencies [[`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f), [`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f), [`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f)]: + - @atproto/api@0.10.4 + ## 0.4.3 ### Patch Changes diff --git a/packages/pds/package.json b/packages/pds/package.json index f345bbcbf63..062578aa531 100644 --- a/packages/pds/package.json +++ b/packages/pds/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/pds", - "version": "0.4.3", + "version": "0.4.4", "license": "MIT", "description": "Reference implementation of atproto Personal Data Server (PDS)", "keywords": [ From c76fd03caa1eed36d9d26feba1271521c51629a4 Mon Sep 17 00:00:00 2001 From: Foysal Ahamed Date: Mon, 4 Mar 2024 17:31:50 +0100 Subject: [PATCH 16/26] :bug: Increment attempt count after each attempt to push ozone event (#2239) --- packages/ozone/src/daemon/event-pusher.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ozone/src/daemon/event-pusher.ts b/packages/ozone/src/daemon/event-pusher.ts index faaee4529ed..01570595c0d 100644 --- a/packages/ozone/src/daemon/event-pusher.ts +++ b/packages/ozone/src/daemon/event-pusher.ts @@ -205,7 +205,7 @@ export class EventPusher { ? { confirmedAt: new Date() } : { lastAttempted: new Date(), - attempts: evt.attempts ?? 0 + 1, + attempts: (evt.attempts ?? 0) + 1, }, ) .where('subjectDid', '=', evt.subjectDid) @@ -244,7 +244,7 @@ export class EventPusher { ? { confirmedAt: new Date() } : { lastAttempted: new Date(), - attempts: evt.attempts ?? 0 + 1, + attempts: (evt.attempts ?? 0) + 1, }, ) .where('subjectUri', '=', evt.subjectUri) @@ -284,7 +284,7 @@ export class EventPusher { ? { confirmedAt: new Date() } : { lastAttempted: new Date(), - attempts: evt.attempts ?? 0 + 1, + attempts: (evt.attempts ?? 0) + 1, }, ) .where('subjectDid', '=', evt.subjectDid) From 87f00f21938655322be58ab66a838dd9edc22bc6 Mon Sep 17 00:00:00 2001 From: devin ivy Date: Tue, 5 Mar 2024 14:27:25 -0500 Subject: [PATCH 17/26] Ozone delegates email sending to actor's pds (#2272) * ozone delegates email sending to user's pds * lexicon: add content field to mod email event * test email sending via mod event --- lexicons/com/atproto/admin/defs.json | 4 ++ packages/api/src/client/lexicons.ts | 4 ++ .../client/types/com/atproto/admin/defs.ts | 2 + packages/bsky/src/lexicon/lexicons.ts | 4 ++ .../lexicon/types/com/atproto/admin/defs.ts | 2 + packages/dev-env/src/ozone.ts | 1 + .../src/api/admin/emitModerationEvent.ts | 19 +++++ packages/ozone/src/config/config.ts | 2 + packages/ozone/src/config/env.ts | 4 +- packages/ozone/src/context.ts | 14 ++-- packages/ozone/src/daemon/context.ts | 14 ++-- packages/ozone/src/lexicon/lexicons.ts | 4 ++ .../lexicon/types/com/atproto/admin/defs.ts | 2 + packages/ozone/src/mod-service/index.ts | 71 +++++++++++++++++-- packages/ozone/src/mod-service/views.ts | 16 ++--- .../ozone/tests/moderation-events.test.ts | 49 +++++++++++++ .../src/api/com/atproto/admin/sendEmail.ts | 44 ++++-------- packages/pds/src/lexicon/lexicons.ts | 4 ++ .../lexicon/types/com/atproto/admin/defs.ts | 2 + 19 files changed, 205 insertions(+), 57 deletions(-) diff --git a/lexicons/com/atproto/admin/defs.json b/lexicons/com/atproto/admin/defs.json index e1315eb7473..d2056f77cf8 100644 --- a/lexicons/com/atproto/admin/defs.json +++ b/lexicons/com/atproto/admin/defs.json @@ -585,6 +585,10 @@ "type": "string", "description": "The subject line of the email sent to the user." }, + "content": { + "type": "string", + "description": "The content of the email sent to the user." + }, "comment": { "type": "string", "description": "Additional comment about the outgoing comm." diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 14e4c1cb81e..72ac23230f4 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -897,6 +897,10 @@ export const schemaDict = { type: 'string', description: 'The subject line of the email sent to the user.', }, + content: { + type: 'string', + description: 'The content of the email sent to the user.', + }, comment: { type: 'string', description: 'Additional comment about the outgoing comm.', diff --git a/packages/api/src/client/types/com/atproto/admin/defs.ts b/packages/api/src/client/types/com/atproto/admin/defs.ts index 4e3d35a869f..2de71524005 100644 --- a/packages/api/src/client/types/com/atproto/admin/defs.ts +++ b/packages/api/src/client/types/com/atproto/admin/defs.ts @@ -704,6 +704,8 @@ export function validateModEventUnmute(v: unknown): ValidationResult { export interface ModEventEmail { /** The subject line of the email sent to the user. */ subjectLine: string + /** The content of the email sent to the user. */ + content?: string /** Additional comment about the outgoing comm. */ comment?: string [k: string]: unknown diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index 14e4c1cb81e..72ac23230f4 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -897,6 +897,10 @@ export const schemaDict = { type: 'string', description: 'The subject line of the email sent to the user.', }, + content: { + type: 'string', + description: 'The content of the email sent to the user.', + }, comment: { type: 'string', description: 'Additional comment about the outgoing comm.', diff --git a/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts b/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts index a713a635635..acfda37abbc 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts @@ -704,6 +704,8 @@ export function validateModEventUnmute(v: unknown): ValidationResult { export interface ModEventEmail { /** The subject line of the email sent to the user. */ subjectLine: string + /** The content of the email sent to the user. */ + content?: string /** Additional comment about the outgoing comm. */ comment?: string [k: string]: unknown diff --git a/packages/dev-env/src/ozone.ts b/packages/dev-env/src/ozone.ts index 0e0578c7935..278ad8d2b2b 100644 --- a/packages/dev-env/src/ozone.ts +++ b/packages/dev-env/src/ozone.ts @@ -34,6 +34,7 @@ export class TestOzone { const port = config.port || (await getPort()) const url = `http://localhost:${port}` const env: ozone.OzoneEnvironment = { + devMode: true, version: '0.0.0', port, didPlcUrl: config.plcUrl, diff --git a/packages/ozone/src/api/admin/emitModerationEvent.ts b/packages/ozone/src/api/admin/emitModerationEvent.ts index 473269bffde..f1bdbab3462 100644 --- a/packages/ozone/src/api/admin/emitModerationEvent.ts +++ b/packages/ozone/src/api/admin/emitModerationEvent.ts @@ -2,12 +2,14 @@ import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../lexicon' import AppContext from '../../context' import { + isModEventEmail, isModEventLabel, isModEventReverseTakedown, isModEventTakedown, } from '../../lexicon/types/com/atproto/admin/defs' import { subjectFromInput } from '../../mod-service/subject' import { ModerationLangService } from '../../mod-service/lang' +import { retryHttp } from '../../util' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.emitModerationEvent({ @@ -75,6 +77,23 @@ export default function (server: Server, ctx: AppContext) { } } + if (isModEventEmail(event) && event.content) { + // sending email prior to logging the event to avoid a long transaction below + if (!subject.isRepo()) { + throw new InvalidRequestError( + 'Email can only be sent to a repo subject', + ) + } + const { content, subjectLine } = event + await retryHttp(() => + ctx.modService(db).sendEmail({ + subject: subjectLine, + content, + recipientDid: subject.did, + }), + ) + } + const moderationEvent = await db.transaction(async (dbTxn) => { const moderationTxn = ctx.modService(dbTxn) diff --git a/packages/ozone/src/config/config.ts b/packages/ozone/src/config/config.ts index 54abb29e21e..3a3cc62f663 100644 --- a/packages/ozone/src/config/config.ts +++ b/packages/ozone/src/config/config.ts @@ -13,6 +13,7 @@ export const envToCfg = (env: OzoneEnvironment): OzoneConfig => { publicUrl: env.publicUrl, did: env.serverDid, version: env.version, + devMode: env.devMode, } assert(env.dbPostgresUrl) @@ -79,6 +80,7 @@ export type ServiceConfig = { publicUrl: string did: string version?: string + devMode?: boolean } export type DatabaseConfig = { diff --git a/packages/ozone/src/config/env.ts b/packages/ozone/src/config/env.ts index e86b520815d..554a0f38ba9 100644 --- a/packages/ozone/src/config/env.ts +++ b/packages/ozone/src/config/env.ts @@ -1,8 +1,9 @@ -import { envInt, envList, envStr } from '@atproto/common' +import { envBool, envInt, envList, envStr } from '@atproto/common' export const readEnv = (): OzoneEnvironment => { return { nodeEnv: envStr('NODE_ENV'), + devMode: envBool('OZONE_DEV_MODE'), version: envStr('OZONE_VERSION'), port: envInt('OZONE_PORT'), publicUrl: envStr('OZONE_PUBLIC_URL'), @@ -30,6 +31,7 @@ export const readEnv = (): OzoneEnvironment => { export type OzoneEnvironment = { nodeEnv?: string + devMode?: boolean version?: string port?: number publicUrl?: string diff --git a/packages/ozone/src/context.ts b/packages/ozone/src/context.ts index e9332620993..db8d123d5e0 100644 --- a/packages/ozone/src/context.ts +++ b/packages/ozone/src/context.ts @@ -59,8 +59,6 @@ export class AppContext { aud, keypair: signingKey, }) - const appviewAuth = async () => - cfg.appview.did ? createAuthHeaders(cfg.appview.did) : undefined const backgroundQueue = new BackgroundQueue(db) const eventPusher = new EventPusher(db, createAuthHeaders, { @@ -68,11 +66,17 @@ export class AppContext { pds: cfg.pds ?? undefined, }) + const idResolver = new IdResolver({ + plcUrl: cfg.identity.plcUrl, + }) + const modService = ModerationService.creator( + cfg, backgroundQueue, + idResolver, eventPusher, appviewAgent, - appviewAuth, + createAuthHeaders, cfg.service.did, overrides?.imgInvalidator, cfg.cdn.paths, @@ -80,10 +84,6 @@ export class AppContext { const communicationTemplateService = CommunicationTemplateService.creator() - const idResolver = new IdResolver({ - plcUrl: cfg.identity.plcUrl, - }) - const sequencer = new Sequencer(db) const authVerifier = new AuthVerifier(idResolver, { diff --git a/packages/ozone/src/daemon/context.ts b/packages/ozone/src/daemon/context.ts index 5af19d89bc4..3ed0596c2ed 100644 --- a/packages/ozone/src/daemon/context.ts +++ b/packages/ozone/src/daemon/context.ts @@ -7,6 +7,7 @@ import { EventPusher } from './event-pusher' import { EventReverser } from './event-reverser' import { ModerationService, ModerationServiceCreator } from '../mod-service' import { BackgroundQueue } from '../background' +import { IdResolver } from '@atproto/identity' export type DaemonContextOptions = { db: Database @@ -39,21 +40,26 @@ export class DaemonContext { keypair: signingKey, }) - const appviewAuth = async () => - cfg.appview.did ? createAuthHeaders(cfg.appview.did) : undefined - const eventPusher = new EventPusher(db, createAuthHeaders, { appview: cfg.appview, pds: cfg.pds ?? undefined, }) + const backgroundQueue = new BackgroundQueue(db) + const idResolver = new IdResolver({ + plcUrl: cfg.identity.plcUrl, + }) + const modService = ModerationService.creator( + cfg, backgroundQueue, + idResolver, eventPusher, appviewAgent, - appviewAuth, + createAuthHeaders, cfg.service.did, ) + const eventReverser = new EventReverser(db, modService) return new DaemonContext({ diff --git a/packages/ozone/src/lexicon/lexicons.ts b/packages/ozone/src/lexicon/lexicons.ts index 14e4c1cb81e..72ac23230f4 100644 --- a/packages/ozone/src/lexicon/lexicons.ts +++ b/packages/ozone/src/lexicon/lexicons.ts @@ -897,6 +897,10 @@ export const schemaDict = { type: 'string', description: 'The subject line of the email sent to the user.', }, + content: { + type: 'string', + description: 'The content of the email sent to the user.', + }, comment: { type: 'string', description: 'Additional comment about the outgoing comm.', diff --git a/packages/ozone/src/lexicon/types/com/atproto/admin/defs.ts b/packages/ozone/src/lexicon/types/com/atproto/admin/defs.ts index a713a635635..acfda37abbc 100644 --- a/packages/ozone/src/lexicon/types/com/atproto/admin/defs.ts +++ b/packages/ozone/src/lexicon/types/com/atproto/admin/defs.ts @@ -704,6 +704,8 @@ export function validateModEventUnmute(v: unknown): ValidationResult { export interface ModEventEmail { /** The subject line of the email sent to the user. */ subjectLine: string + /** The content of the email sent to the user. */ + content?: string /** Additional comment about the outgoing comm. */ comment?: string [k: string]: unknown diff --git a/packages/ozone/src/mod-service/index.ts b/packages/ozone/src/mod-service/index.ts index d0690fce57a..1ca793d5601 100644 --- a/packages/ozone/src/mod-service/index.ts +++ b/packages/ozone/src/mod-service/index.ts @@ -1,9 +1,13 @@ +import net from 'node:net' +import { Insertable, sql } from 'kysely' import { CID } from 'multiformats/cid' import { AtUri, INVALID_HANDLE } from '@atproto/syntax' import { InvalidRequestError } from '@atproto/xrpc-server' import { addHoursToDate } from '@atproto/common' +import { IdResolver } from '@atproto/identity' +import AtpAgent from '@atproto/api' import { Database } from '../db' -import { AppviewAuth, ModerationViews } from './views' +import { AuthHeaders, ModerationViews } from './views' import { Main as StrongRef } from '../lexicon/types/com/atproto/repo/strongRef' import { isModEventComment, @@ -30,9 +34,7 @@ import { } from './types' import { ModerationEvent } from '../db/schema/moderation_event' import { StatusKeyset, TimeIdKeyset, paginate } from '../db/pagination' -import AtpAgent from '@atproto/api' import { Label } from '../lexicon/types/com/atproto/label/defs' -import { Insertable, sql } from 'kysely' import { ModSubject, RecordSubject, @@ -46,26 +48,31 @@ import { BackgroundQueue } from '../background' import { EventPusher } from '../daemon' import { ImageInvalidator } from '../image-invalidator' import { httpLogger as log } from '../logger' +import { OzoneConfig } from '../config' export type ModerationServiceCreator = (db: Database) => ModerationService export class ModerationService { constructor( public db: Database, + public cfg: OzoneConfig, public backgroundQueue: BackgroundQueue, + public idResolver: IdResolver, public eventPusher: EventPusher, public appviewAgent: AtpAgent, - private appviewAuth: AppviewAuth, + private createAuthHeaders: (aud: string) => Promise, public serverDid: string, public imgInvalidator?: ImageInvalidator, public cdnPaths?: string[], ) {} static creator( + cfg: OzoneConfig, backgroundQueue: BackgroundQueue, + idResolver: IdResolver, eventPusher: EventPusher, appviewAgent: AtpAgent, - appviewAuth: AppviewAuth, + createAuthHeaders: (aud: string) => Promise, serverDid: string, imgInvalidator?: ImageInvalidator, cdnPaths?: string[], @@ -73,17 +80,21 @@ export class ModerationService { return (db: Database) => new ModerationService( db, + cfg, backgroundQueue, + idResolver, eventPusher, appviewAgent, - appviewAuth, + createAuthHeaders, serverDid, imgInvalidator, cdnPaths, ) } - views = new ModerationViews(this.db, this.appviewAgent, this.appviewAuth) + views = new ModerationViews(this.db, this.appviewAgent, () => + this.createAuthHeaders(this.cfg.appview.did), + ) async getEvent(id: number): Promise { return await this.db.db @@ -291,6 +302,9 @@ export class ModerationService { if (isModEventEmail(event)) { meta.subjectLine = event.subjectLine + if (event.content) { + meta.content = event.content + } } const subjectInfo = subject.info() @@ -903,6 +917,49 @@ export class ModerationService { ) .execute() } + + async sendEmail(opts: { + content: string + recipientDid: string + subject: string + }) { + const { subject, content, recipientDid } = opts + const { pds } = await this.idResolver.did.resolveAtprotoData(recipientDid) + const url = new URL(pds) + if (!this.cfg.service.devMode && !isSafeUrl(url)) { + throw new InvalidRequestError('Invalid pds service in DID doc') + } + const agent = new AtpAgent({ service: url }) + const { data: serverInfo } = + await agent.api.com.atproto.server.describeServer() + if (serverInfo.did !== `did:web:${url.hostname}`) { + // @TODO do bidirectional check once implemented. in the meantime, + // matching did to hostname we're talking to is pretty good. + throw new InvalidRequestError('Invalid pds service in DID doc') + } + const { data: delivery } = await agent.api.com.atproto.admin.sendEmail( + { + subject, + content, + recipientDid, + senderDid: this.cfg.service.did, + }, + { + encoding: 'application/json', + ...(await this.createAuthHeaders(serverInfo.did)), + }, + ) + if (!delivery.sent) { + throw new InvalidRequestError('Email was accepted but not sent') + } + } +} + +const isSafeUrl = (url: URL) => { + if (url.protocol !== 'https:') return false + if (!url.hostname || url.hostname === 'localhost') return false + if (net.isIP(url.hostname) === 0) return false + return true } const TAKEDOWNS = ['pds_takedown' as const, 'appview_takedown' as const] diff --git a/packages/ozone/src/mod-service/views.ts b/packages/ozone/src/mod-service/views.ts index f1188968cbe..498091a8bd0 100644 --- a/packages/ozone/src/mod-service/views.ts +++ b/packages/ozone/src/mod-service/views.ts @@ -26,20 +26,17 @@ import { REASONOTHER } from '../lexicon/types/com/atproto/moderation/defs' import { subjectFromEventRow, subjectFromStatusRow } from './subject' import { formatLabel } from './util' -export type AppviewAuth = () => Promise< - | { - headers: { - authorization: string - } - } - | undefined -> +export type AuthHeaders = { + headers: { + authorization: string + } +} export class ModerationViews { constructor( private db: Database, private appviewAgent: AtpAgent, - private appviewAuth: AppviewAuth, + private appviewAuth: () => Promise, ) {} async getAccoutInfosByDid(dids: string[]): Promise> { @@ -154,6 +151,7 @@ export class ModerationViews { eventView.event = { ...eventView.event, subjectLine: event.meta?.subjectLine ?? '', + content: event.meta?.content, } } diff --git a/packages/ozone/tests/moderation-events.test.ts b/packages/ozone/tests/moderation-events.test.ts index fbe571a8172..1212a6049e6 100644 --- a/packages/ozone/tests/moderation-events.test.ts +++ b/packages/ozone/tests/moderation-events.test.ts @@ -1,4 +1,6 @@ import assert from 'node:assert' +import EventEmitter, { once } from 'node:events' +import Mail from 'nodemailer/lib/mailer' import { TestNetwork, SeedClient, basicSeed } from '@atproto/dev-env' import AtpAgent, { ComAtprotoAdminDefs, @@ -422,4 +424,51 @@ describe('moderation-events', () => { }) }) }) + + describe('email event', () => { + let sendMailOriginal + const mailCatcher = new EventEmitter() + const getMailFrom = async (promise): Promise => { + const result = await Promise.all([once(mailCatcher, 'mail'), promise]) + return result[0][0] + } + + beforeAll(() => { + const mailer = network.pds.ctx.moderationMailer + // Catch emails for use in tests + sendMailOriginal = mailer.transporter.sendMail + mailer.transporter.sendMail = async (opts) => { + const result = await sendMailOriginal.call(mailer.transporter, opts) + mailCatcher.emit('mail', opts) + return result + } + }) + + afterAll(() => { + network.pds.ctx.moderationMailer.transporter.sendMail = sendMailOriginal + }) + + it('sends email via pds.', async () => { + const mail = await getMailFrom( + emitModerationEvent({ + event: { + $type: 'com.atproto.admin.defs#modEventEmail', + comment: 'Reaching out to Alice', + subjectLine: 'Hello', + content: 'Hey Alice, how are you?', + }, + subject: { + $type: 'com.atproto.admin.defs#repoRef', + did: sc.dids.alice, + }, + createdBy: sc.dids.bob, + }), + ) + expect(mail).toEqual({ + to: 'alice@test.com', + subject: 'Hello', + html: 'Hey Alice, how are you?', + }) + }) + }) }) diff --git a/packages/pds/src/api/com/atproto/admin/sendEmail.ts b/packages/pds/src/api/com/atproto/admin/sendEmail.ts index e23d6bea5c1..f6d8cce8d19 100644 --- a/packages/pds/src/api/com/atproto/admin/sendEmail.ts +++ b/packages/pds/src/api/com/atproto/admin/sendEmail.ts @@ -1,23 +1,23 @@ +import assert from 'node:assert' import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, resultPassthru } from '../../../proxy' +import { resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.sendEmail({ - auth: ctx.authVerifier.role, - handler: async ({ req, input, auth }) => { - if (!auth.credentials.admin && !auth.credentials.moderator) { + auth: ctx.authVerifier.roleOrAdminService, + handler: async ({ input, auth }) => { + if (auth.credentials.type === 'role' && !auth.credentials.moderator) { throw new AuthRequiredError('Insufficient privileges') } const { content, recipientDid, - senderDid, - subject = 'Message from Bluesky moderator', - comment, + subject = 'Message via your PDS', } = input.body + const account = await ctx.accountManager.getAccount(recipientDid, { includeDeactivated: true, includeTakenDown: true, @@ -27,11 +27,15 @@ export default function (server: Server, ctx: AppContext) { } if (ctx.entrywayAgent) { + assert(ctx.cfg.entryway) return resultPassthru( - await ctx.entrywayAgent.com.atproto.admin.sendEmail( - input.body, - authPassthru(req, true), - ), + await ctx.entrywayAgent.com.atproto.admin.sendEmail(input.body, { + encoding: 'application/json', + ...(await ctx.serviceAuthHeaders( + recipientDid, + ctx.cfg.entryway?.did, + )), + }), ) } @@ -44,24 +48,6 @@ export default function (server: Server, ctx: AppContext) { { subject, to: account.email }, ) - if (ctx.moderationAgent) { - await ctx.moderationAgent.api.com.atproto.admin.emitModerationEvent( - { - event: { - $type: 'com.atproto.admin.defs#modEventEmail', - subjectLine: subject, - comment, - }, - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: recipientDid, - }, - createdBy: senderDid, - }, - { ...authPassthru(req), encoding: 'application/json' }, - ) - } - return { encoding: 'application/json', body: { sent: true }, diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 14e4c1cb81e..72ac23230f4 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -897,6 +897,10 @@ export const schemaDict = { type: 'string', description: 'The subject line of the email sent to the user.', }, + content: { + type: 'string', + description: 'The content of the email sent to the user.', + }, comment: { type: 'string', description: 'Additional comment about the outgoing comm.', diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts b/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts index a713a635635..acfda37abbc 100644 --- a/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts +++ b/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts @@ -704,6 +704,8 @@ export function validateModEventUnmute(v: unknown): ValidationResult { export interface ModEventEmail { /** The subject line of the email sent to the user. */ subjectLine: string + /** The content of the email sent to the user. */ + content?: string /** Additional comment about the outgoing comm. */ comment?: string [k: string]: unknown From 8341c7a10b53c5d3fa477199152486e62a37e241 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 5 Mar 2024 13:34:49 -0600 Subject: [PATCH 18/26] fix auth verifier method --- packages/pds/src/api/com/atproto/admin/sendEmail.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pds/src/api/com/atproto/admin/sendEmail.ts b/packages/pds/src/api/com/atproto/admin/sendEmail.ts index f6d8cce8d19..6e30159c204 100644 --- a/packages/pds/src/api/com/atproto/admin/sendEmail.ts +++ b/packages/pds/src/api/com/atproto/admin/sendEmail.ts @@ -6,7 +6,7 @@ import { resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.sendEmail({ - auth: ctx.authVerifier.roleOrAdminService, + auth: ctx.authVerifier.roleOrModService, handler: async ({ input, auth }) => { if (auth.credentials.type === 'role' && !auth.credentials.moderator) { throw new AuthRequiredError('Insufficient privileges') From 037f163cdb9dffb418a8363b3c7d3551fd2eebf4 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 5 Mar 2024 15:32:30 -0600 Subject: [PATCH 19/26] better error handling for get account infos --- packages/ozone/src/mod-service/views.ts | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/ozone/src/mod-service/views.ts b/packages/ozone/src/mod-service/views.ts index 498091a8bd0..f65fd68b480 100644 --- a/packages/ozone/src/mod-service/views.ts +++ b/packages/ozone/src/mod-service/views.ts @@ -25,6 +25,7 @@ import { import { REASONOTHER } from '../lexicon/types/com/atproto/moderation/defs' import { subjectFromEventRow, subjectFromStatusRow } from './subject' import { formatLabel } from './util' +import { httpLogger as log } from '../logger' export type AuthHeaders = { headers: { @@ -43,15 +44,20 @@ export class ModerationViews { if (dids.length === 0) return new Map() const auth = await this.appviewAuth() if (!auth) return new Map() - const res = await this.appviewAgent.api.com.atproto.admin.getAccountInfos( - { - dids: dedupeStrs(dids), - }, - auth, - ) - return res.data.infos.reduce((acc, cur) => { - return acc.set(cur.did, cur) - }, new Map()) + try { + const res = await this.appviewAgent.api.com.atproto.admin.getAccountInfos( + { + dids: dedupeStrs(dids), + }, + auth, + ) + return res.data.infos.reduce((acc, cur) => { + return acc.set(cur.did, cur) + }, new Map()) + } catch (err) { + log.error({ err, dids }, 'failed to resolve account infos from appview') + return new Map() + } } async repos(dids: string[]): Promise> { From fc1c40dff8bb14d8a6cfbba7a0b18a8efaa76057 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 5 Mar 2024 15:33:12 -0600 Subject: [PATCH 20/26] fix labeler service id --- packages/bsky/src/auth-verifier.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bsky/src/auth-verifier.ts b/packages/bsky/src/auth-verifier.ts index b305aad1ef1..2936b1cd28b 100644 --- a/packages/bsky/src/auth-verifier.ts +++ b/packages/bsky/src/auth-verifier.ts @@ -211,7 +211,7 @@ export class AuthVerifier { } const [did, serviceId] = iss.split('#') const keyId = - serviceId === 'atproto-labeler' ? 'atproto-label' : 'atproto' + serviceId === 'atproto_labeler' ? 'atproto_label' : 'atproto' let identity: GetIdentityByDidResponse try { identity = await this.dataplane.getIdentityByDid({ did }) From 5e1c5fd7d3d7acebbf5c03d9a765f6f45082469c Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 5 Mar 2024 15:50:18 -0600 Subject: [PATCH 21/26] fix iss on auth headers --- packages/ozone/src/context.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ozone/src/context.ts b/packages/ozone/src/context.ts index db8d123d5e0..e7168881bac 100644 --- a/packages/ozone/src/context.ts +++ b/packages/ozone/src/context.ts @@ -55,7 +55,7 @@ export class AppContext { const createAuthHeaders = (aud: string) => createServiceAuthHeaders({ - iss: cfg.service.did, + iss: `${cfg.service.did}#atproto_labeler`, aud, keypair: signingKey, }) From 82acea2356fc21a7cb180d9813b6bf7014a92448 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 5 Mar 2024 15:58:58 -0600 Subject: [PATCH 22/26] fix dev-env ozone did --- packages/dev-env/src/ozone.ts | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/dev-env/src/ozone.ts b/packages/dev-env/src/ozone.ts index 278ad8d2b2b..27e240b0518 100644 --- a/packages/dev-env/src/ozone.ts +++ b/packages/dev-env/src/ozone.ts @@ -3,7 +3,7 @@ import * as ui8 from 'uint8arrays' import * as ozone from '@atproto/ozone' import { AtpAgent } from '@atproto/api' import { Secp256k1Keypair } from '@atproto/crypto' -import { Client as PlcClient } from '@did-plc/lib' +import * as plc from '@did-plc/lib' import { OzoneConfig } from './types' import { ADMIN_PASSWORD, MOD_PASSWORD, TRIAGE_PASSWORD } from './const' @@ -21,18 +21,32 @@ export class TestOzone { const signingKeyHex = ui8.toString(await serviceKeypair.export(), 'hex') let serverDid = config.serverDid if (!serverDid) { - const plcClient = new PlcClient(config.plcUrl) - serverDid = await plcClient.createDid({ - signingKey: serviceKeypair.did(), - rotationKeys: [serviceKeypair.did()], - handle: 'ozone.test', - pds: `https://pds.invalid`, - signer: serviceKeypair, - }) + const plcClient = new plc.Client(config.plcUrl) + const plcOp = await plc.signOperation( + { + type: 'plc_operation', + alsoKnownAs: [], + rotationKeys: [serviceKeypair.did()], + verificationMethods: { + atproto_label: serviceKeypair.did(), + }, + services: { + atproto_labeler: { + type: 'AtprotoLabeler', + endpoint: 'https://ozone.public.url', + }, + }, + prev: null, + }, + serviceKeypair, + ) + serverDid = await plc.didForCreateOp(plcOp) + await plcClient.sendOperation(serverDid, plcOp) } const port = config.port || (await getPort()) const url = `http://localhost:${port}` + const env: ozone.OzoneEnvironment = { devMode: true, version: '0.0.0', From 4c7db5cc644a91f092cccbc95d895e9080100106 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 5 Mar 2024 16:15:10 -0600 Subject: [PATCH 23/26] fix tests & another jwt issuer --- packages/dev-env/src/network.ts | 11 ++----- packages/dev-env/src/ozone.ts | 52 +++++++++++++++++++-------------- packages/ozone/src/context.ts | 2 +- 3 files changed, 33 insertions(+), 32 deletions(-) diff --git a/packages/dev-env/src/network.ts b/packages/dev-env/src/network.ts index c90e2c181f5..06fbd780060 100644 --- a/packages/dev-env/src/network.ts +++ b/packages/dev-env/src/network.ts @@ -3,12 +3,11 @@ import * as uint8arrays from 'uint8arrays' import getPort from 'get-port' import { wait } from '@atproto/common-web' import { createServiceJwt } from '@atproto/xrpc-server' -import { Client as PlcClient } from '@did-plc/lib' import { TestServerParams } from './types' import { TestPlc } from './plc' import { TestPds } from './pds' import { TestBsky } from './bsky' -import { TestOzone } from './ozone' +import { TestOzone, createOzoneDid } from './ozone' import { mockNetworkUtilities } from './util' import { TestNetworkNoAppView } from './network-no-appview' import { Secp256k1Keypair } from '@atproto/crypto' @@ -43,13 +42,7 @@ export class TestNetwork extends TestNetworkNoAppView { const ozonePort = params.ozone?.port ?? (await getPort()) const ozoneKey = await Secp256k1Keypair.create({ exportable: true }) - const ozoneDid = await new PlcClient(plc.url).createDid({ - signingKey: ozoneKey.did(), - rotationKeys: [ozoneKey.did()], - handle: 'ozone.test', - pds: `http://pds.invalid`, - signer: ozoneKey, - }) + const ozoneDid = await createOzoneDid(plc.url, ozoneKey) const bsky = await TestBsky.create({ port: bskyPort, diff --git a/packages/dev-env/src/ozone.ts b/packages/dev-env/src/ozone.ts index 27e240b0518..d06e45eba13 100644 --- a/packages/dev-env/src/ozone.ts +++ b/packages/dev-env/src/ozone.ts @@ -2,7 +2,7 @@ import getPort from 'get-port' import * as ui8 from 'uint8arrays' import * as ozone from '@atproto/ozone' import { AtpAgent } from '@atproto/api' -import { Secp256k1Keypair } from '@atproto/crypto' +import { Keypair, Secp256k1Keypair } from '@atproto/crypto' import * as plc from '@did-plc/lib' import { OzoneConfig } from './types' import { ADMIN_PASSWORD, MOD_PASSWORD, TRIAGE_PASSWORD } from './const' @@ -21,27 +21,7 @@ export class TestOzone { const signingKeyHex = ui8.toString(await serviceKeypair.export(), 'hex') let serverDid = config.serverDid if (!serverDid) { - const plcClient = new plc.Client(config.plcUrl) - const plcOp = await plc.signOperation( - { - type: 'plc_operation', - alsoKnownAs: [], - rotationKeys: [serviceKeypair.did()], - verificationMethods: { - atproto_label: serviceKeypair.did(), - }, - services: { - atproto_labeler: { - type: 'AtprotoLabeler', - endpoint: 'https://ozone.public.url', - }, - }, - prev: null, - }, - serviceKeypair, - ) - serverDid = await plc.didForCreateOp(plcOp) - await plcClient.sendOperation(serverDid, plcOp) + serverDid = await createOzoneDid(config.plcUrl, serviceKeypair) } const port = config.port || (await getPort()) @@ -130,3 +110,31 @@ export class TestOzone { await this.server.destroy() } } + +export const createOzoneDid = async ( + plcUrl: string, + keypair: Keypair, +): Promise => { + const plcClient = new plc.Client(plcUrl) + const plcOp = await plc.signOperation( + { + type: 'plc_operation', + alsoKnownAs: [], + rotationKeys: [keypair.did()], + verificationMethods: { + atproto_label: keypair.did(), + }, + services: { + atproto_labeler: { + type: 'AtprotoLabeler', + endpoint: 'https://ozone.public.url', + }, + }, + prev: null, + }, + keypair, + ) + const did = await plc.didForCreateOp(plcOp) + await plcClient.sendOperation(did, plcOp) + return did +} diff --git a/packages/ozone/src/context.ts b/packages/ozone/src/context.ts index e7168881bac..5205e54f848 100644 --- a/packages/ozone/src/context.ts +++ b/packages/ozone/src/context.ts @@ -172,7 +172,7 @@ export class AppContext { } async serviceAuthHeaders(aud: string) { - const iss = this.cfg.service.did + const iss = `${this.cfg.service.did}#atproto_labeler` return createServiceAuthHeaders({ iss, aud, From 81f9d693afcd7e354f84c2d3254353bed84dbb43 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Tue, 5 Mar 2024 17:55:34 -0500 Subject: [PATCH 24/26] ozone: fix ip check --- packages/ozone/src/mod-service/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ozone/src/mod-service/index.ts b/packages/ozone/src/mod-service/index.ts index 1ca793d5601..dba57baa7b9 100644 --- a/packages/ozone/src/mod-service/index.ts +++ b/packages/ozone/src/mod-service/index.ts @@ -958,7 +958,7 @@ export class ModerationService { const isSafeUrl = (url: URL) => { if (url.protocol !== 'https:') return false if (!url.hostname || url.hostname === 'localhost') return false - if (net.isIP(url.hostname) === 0) return false + if (net.isIP(url.hostname) !== 0) return false return true } From 7be84452c3bd6021a77a81b7b5cff9cf5656afb8 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 5 Mar 2024 17:14:31 -0600 Subject: [PATCH 25/26] fix aud check on pds mod service auth --- packages/pds/src/auth-verifier.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/pds/src/auth-verifier.ts b/packages/pds/src/auth-verifier.ts index 6625ea9629c..05a61ecdf5b 100644 --- a/packages/pds/src/auth-verifier.ts +++ b/packages/pds/src/auth-verifier.ts @@ -259,9 +259,20 @@ export class AuthVerifier { throw new AuthRequiredError('Untrusted issuer', 'UntrustedIss') } const payload = await this.verifyServiceJwt(reqCtx, { - aud: this.dids.entryway ?? this.dids.pds, + aud: null, iss: [this.dids.modService, `${this.dids.modService}#atproto_labeler`], }) + if ( + payload.aud !== this.dids.pds && + (!this.dids.entryway || payload.aud !== this.dids.entryway) + ) { + throw new AuthRequiredError( + 'jwt audience does not match service did', + 'BadJwtAudience', + ) + } + + payload.aud return { credentials: { type: 'mod_service', From 592518c63b0696aa70578c0316b510b1242ea739 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 5 Mar 2024 17:16:55 -0600 Subject: [PATCH 26/26] tidy --- packages/pds/src/auth-verifier.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/pds/src/auth-verifier.ts b/packages/pds/src/auth-verifier.ts index 05a61ecdf5b..e12e0e8acfe 100644 --- a/packages/pds/src/auth-verifier.ts +++ b/packages/pds/src/auth-verifier.ts @@ -271,8 +271,6 @@ export class AuthVerifier { 'BadJwtAudience', ) } - - payload.aud return { credentials: { type: 'mod_service',