Fork of github.com/did-method-plc/did-method-plc

Enforce new operation constraints (#47)

* add additional constraints on new ops

* prevent dupe akas

* tidy

* enforce ops limits

* tidy

Changed files
+177 -57
packages
-8
packages/lib/src/data.ts
···
import * as t from './types'
import {
assureValidCreationOp,
-
assureValidOp,
assureValidSig,
normalizeOp,
} from './operations'
···
ops: t.IndexedOperation[],
proposed: t.CompatibleOpOrTombstone,
): Promise<{ nullified: CID[]; prev: CID | null }> => {
-
if (check.is(proposed, t.def.createOpV1)) {
-
const normalized = normalizeOp(proposed)
-
await assureValidOp(normalized)
-
} else {
-
await assureValidOp(proposed)
-
}
-
// special case if account creation
if (ops.length === 0) {
await assureValidCreationOp(did, proposed)
···
import * as t from './types'
import {
assureValidCreationOp,
assureValidSig,
normalizeOp,
} from './operations'
···
ops: t.IndexedOperation[],
proposed: t.CompatibleOpOrTombstone,
): Promise<{ nullified: CID[]; prev: CID | null }> => {
// special case if account creation
if (ops.length === 0) {
await assureValidCreationOp(did, proposed)
+1 -25
packages/lib/src/operations.ts
···
import * as cbor from '@ipld/dag-cbor'
import { CID } from 'multiformats/cid'
import * as uint8arrays from 'uint8arrays'
-
import { Keypair, parseDidKey, sha256, verifySignature } from '@atproto/crypto'
import { check, cidForCbor } from '@atproto/common'
import * as t from './types'
import {
···
ImproperOperationError,
InvalidSignatureError,
MisorderedOperationError,
-
UnsupportedKeyError,
} from './error'
export const didForCreateOp = async (op: t.CompatibleOp) => {
···
// Verifying operations/signatures
// ---------------------------
-
export const assureValidOp = async (op: t.OpOrTombstone) => {
-
if (check.is(op, t.def.tombstone)) {
-
return true
-
}
-
// ensure we support the op's keys
-
const keys = [...Object.values(op.verificationMethods), ...op.rotationKeys]
-
await Promise.all(
-
keys.map(async (k) => {
-
try {
-
parseDidKey(k)
-
} catch (err) {
-
throw new UnsupportedKeyError(k, err)
-
}
-
}),
-
)
-
if (op.rotationKeys.length > 5) {
-
throw new ImproperOperationError('too many rotation keys', op)
-
} else if (op.rotationKeys.length < 1) {
-
throw new ImproperOperationError('need at least one rotation key', op)
-
}
-
}
-
export const assureValidCreationOp = async (
did: string,
op: t.CompatibleOpOrTombstone,
···
throw new MisorderedOperationError()
}
const normalized = normalizeOp(op)
-
await assureValidOp(normalized)
await assureValidSig(normalized.rotationKeys, op)
const expectedDid = await didForCreateOp(op)
if (expectedDid !== did) {
···
import * as cbor from '@ipld/dag-cbor'
import { CID } from 'multiformats/cid'
import * as uint8arrays from 'uint8arrays'
+
import { Keypair, sha256, verifySignature } from '@atproto/crypto'
import { check, cidForCbor } from '@atproto/common'
import * as t from './types'
import {
···
ImproperOperationError,
InvalidSignatureError,
MisorderedOperationError,
} from './error'
export const didForCreateOp = async (op: t.CompatibleOp) => {
···
// Verifying operations/signatures
// ---------------------------
export const assureValidCreationOp = async (
did: string,
op: t.CompatibleOpOrTombstone,
···
throw new MisorderedOperationError()
}
const normalized = normalizeOp(op)
await assureValidSig(normalized.rotationKeys, op)
const expectedDid = await didForCreateOp(op)
if (expectedDid !== did) {
+18 -14
packages/lib/src/types.ts
···
const createOpV1 = unsignedCreateOpV1.extend({ sig: z.string() })
export type CreateOpV1 = z.infer<typeof createOpV1>
-
const unsignedOperation = z.object({
-
type: z.literal('plc_operation'),
-
rotationKeys: z.array(z.string()),
-
verificationMethods: z.record(z.string()),
-
alsoKnownAs: z.array(z.string()),
-
services: z.record(service),
-
prev: z.string().nullable(),
-
})
export type UnsignedOperation = z.infer<typeof unsignedOperation>
-
const operation = unsignedOperation.extend({ sig: z.string() })
export type Operation = z.infer<typeof operation>
-
const unsignedTombstone = z.object({
-
type: z.literal('plc_tombstone'),
-
prev: z.string(),
-
})
export type UnsignedTombstone = z.infer<typeof unsignedTombstone>
-
const tombstone = unsignedTombstone.extend({ sig: z.string() })
export type Tombstone = z.infer<typeof tombstone>
const opOrTombstone = z.union([operation, tombstone])
···
const createOpV1 = unsignedCreateOpV1.extend({ sig: z.string() })
export type CreateOpV1 = z.infer<typeof createOpV1>
+
const unsignedOperation = z
+
.object({
+
type: z.literal('plc_operation'),
+
rotationKeys: z.array(z.string()),
+
verificationMethods: z.record(z.string()),
+
alsoKnownAs: z.array(z.string()),
+
services: z.record(service),
+
prev: z.string().nullable(),
+
})
+
.strict()
export type UnsignedOperation = z.infer<typeof unsignedOperation>
+
const operation = unsignedOperation.extend({ sig: z.string() }).strict()
export type Operation = z.infer<typeof operation>
+
const unsignedTombstone = z
+
.object({
+
type: z.literal('plc_tombstone'),
+
prev: z.string(),
+
})
+
.strict()
export type UnsignedTombstone = z.infer<typeof unsignedTombstone>
+
const tombstone = unsignedTombstone.extend({ sig: z.string() }).strict()
export type Tombstone = z.infer<typeof tombstone>
const opOrTombstone = z.union([operation, tombstone])
+147
packages/server/src/constraints.ts
···
···
+
import { DAY, HOUR, cborEncode, check } from '@atproto/common'
+
import * as plc from '@did-plc/lib'
+
import { ServerError } from './error'
+
import { parseDidKey } from '@atproto/crypto'
+
+
const MAX_OP_BYTES = 4000
+
const MAX_AKA_ENTRIES = 10
+
const MAX_AKA_LENGTH = 256
+
const MAX_ROTATION_ENTRIES = 10
+
const MAX_SERVICE_ENTRIES = 10
+
const MAX_SERVICE_TYPE_LENGTH = 256
+
const MAX_SERVICE_ENDPOINT_LENGTH = 512
+
const MAX_ID_LENGTH = 32
+
+
export function assertValidIncomingOp(
+
op: unknown,
+
): asserts op is plc.OpOrTombstone {
+
const byteLength = cborEncode(op).byteLength
+
if (byteLength > MAX_OP_BYTES) {
+
throw new ServerError(
+
400,
+
`Operation too large (${MAX_OP_BYTES} bytes maximum in cbor encoding)`,
+
)
+
}
+
if (!check.is(op, plc.def.opOrTombstone)) {
+
throw new ServerError(400, `Not a valid operation: ${JSON.stringify(op)}`)
+
}
+
if (op.type === 'plc_tombstone') {
+
return
+
}
+
if (op.alsoKnownAs.length > MAX_AKA_ENTRIES) {
+
throw new ServerError(
+
400,
+
`To many alsoKnownAs entries (max ${MAX_AKA_ENTRIES})`,
+
)
+
}
+
const akaDupe: Record<string, boolean> = {}
+
for (const aka of op.alsoKnownAs) {
+
if (aka.length > MAX_AKA_LENGTH) {
+
throw new ServerError(
+
400,
+
`alsoKnownAs entry too long (max ${MAX_AKA_LENGTH}): ${aka}`,
+
)
+
}
+
if (akaDupe[aka]) {
+
throw new ServerError(400, `duplicate alsoKnownAs entry: ${aka}`)
+
} else {
+
akaDupe[aka] = true
+
}
+
}
+
if (op.rotationKeys.length > MAX_ROTATION_ENTRIES) {
+
throw new ServerError(
+
400,
+
`Too many rotationKey entries (max ${MAX_ROTATION_ENTRIES})`,
+
)
+
}
+
for (const key of op.rotationKeys) {
+
try {
+
parseDidKey(key)
+
} catch (err) {
+
throw new ServerError(400, `Invalid rotationKey: ${key}`)
+
}
+
}
+
const serviceEntries = Object.entries(op.services)
+
if (serviceEntries.length > MAX_SERVICE_ENTRIES) {
+
throw new ServerError(
+
400,
+
`To many service entries (max ${MAX_SERVICE_ENTRIES})`,
+
)
+
}
+
for (const [id, service] of serviceEntries) {
+
if (id.length > MAX_ID_LENGTH) {
+
throw new ServerError(
+
400,
+
`Service id too long (max ${MAX_ID_LENGTH}): ${id}`,
+
)
+
}
+
if (service.type.length > MAX_SERVICE_TYPE_LENGTH) {
+
throw new ServerError(
+
400,
+
`Service type too long (max ${MAX_SERVICE_TYPE_LENGTH})`,
+
)
+
}
+
if (service.endpoint.length > MAX_SERVICE_ENDPOINT_LENGTH) {
+
throw new ServerError(
+
400,
+
`Service endpoint too long (max ${MAX_SERVICE_ENDPOINT_LENGTH})`,
+
)
+
}
+
}
+
const verifyMethods = Object.entries(op.verificationMethods)
+
for (const [id, key] of verifyMethods) {
+
if (id.length > MAX_ID_LENGTH) {
+
throw new ServerError(
+
400,
+
`Verification Method id too long (max ${MAX_ID_LENGTH}): ${id}`,
+
)
+
}
+
try {
+
parseDidKey(key)
+
} catch (err) {
+
throw new ServerError(400, `Invalid verificationMethod key: ${key}`)
+
}
+
}
+
}
+
+
const HOUR_LIMIT = 10
+
const DAY_LIMIT = 30
+
const WEEK_LIMIT = 100
+
+
export const enforceOpsRateLimit = (ops: plc.IndexedOperation[]) => {
+
const hourAgo = new Date(Date.now() - HOUR)
+
const dayAgo = new Date(Date.now() - DAY)
+
const weekAgo = new Date(Date.now() - DAY * 7)
+
let withinHour = 0
+
let withinDay = 0
+
let withinWeek = 0
+
for (const op of ops) {
+
if (op.createdAt > weekAgo) {
+
withinWeek++
+
if (withinWeek >= WEEK_LIMIT) {
+
throw new ServerError(
+
400,
+
`To many operations within last week (max ${WEEK_LIMIT})`,
+
)
+
}
+
}
+
if (op.createdAt > dayAgo) {
+
withinDay++
+
if (withinDay >= DAY_LIMIT) {
+
throw new ServerError(
+
400,
+
`To many operations within last day (max ${DAY_LIMIT})`,
+
)
+
}
+
}
+
if (op.createdAt > hourAgo) {
+
withinHour++
+
if (withinHour >= HOUR_LIMIT) {
+
throw new ServerError(
+
400,
+
`To many operations within last hour (max ${HOUR_LIMIT})`,
+
)
+
}
+
}
+
}
+
}
+6
packages/server/src/db/index.ts
···
import * as migrations from '../migrations'
import { DatabaseSchema, PlcDatabase } from './types'
import MockDatabase from './mock'
export * from './mock'
export * from './types'
···
const ops = await this.indexedOpsForDid(did)
// throws if invalid
const { nullified, prev } = await plc.assureValidNextOp(did, ops, proposed)
const cid = await cidForCbor(proposed)
await this.db.transaction().execute(async (tx) => {
···
import * as migrations from '../migrations'
import { DatabaseSchema, PlcDatabase } from './types'
import MockDatabase from './mock'
+
import { enforceOpsRateLimit } from '../constraints'
export * from './mock'
export * from './types'
···
const ops = await this.indexedOpsForDid(did)
// throws if invalid
const { nullified, prev } = await plc.assureValidNextOp(did, ops, proposed)
+
// do not enforce rate limits on recovery operations to prevent DDOS by a bad actor
+
if (nullified.length === 0) {
+
enforceOpsRateLimit(ops)
+
}
+
const cid = await cidForCbor(proposed)
await this.db.transaction().execute(async (tx) => {
+2 -8
packages/server/src/routes.ts
···
import express from 'express'
-
import { cborEncode, check } from '@atproto/common'
import * as plc from '@did-plc/lib'
import { ServerError } from './error'
import { AppContext } from './context'
export const createRouter = (ctx: AppContext): express.Router => {
const router = express.Router()
···
router.post('/:did', async function (req, res) {
const { did } = req.params
const op = req.body
-
const byteLength = cborEncode(op).byteLength
-
if (byteLength > 7500) {
-
throw new ServerError(400, 'Operation too large')
-
}
-
if (!check.is(op, plc.def.compatibleOpOrTombstone)) {
-
throw new ServerError(400, `Not a valid operation: ${JSON.stringify(op)}`)
-
}
await ctx.db.validateAndAddOp(did, op)
res.sendStatus(200)
})
···
import express from 'express'
import * as plc from '@did-plc/lib'
import { ServerError } from './error'
import { AppContext } from './context'
+
import { assertValidIncomingOp } from './constraints'
export const createRouter = (ctx: AppContext): express.Router => {
const router = express.Router()
···
router.post('/:did', async function (req, res) {
const { did } = req.params
const op = req.body
+
assertValidIncomingOp(op)
await ctx.db.validateAndAddOp(did, op)
res.sendStatus(200)
})
+3 -2
packages/server/tests/server.test.ts
···
}
})
-
it('still allows create v1s', async () => {
const createV1 = await plc.deprecatedSignCreate(
{
type: 'create',
···
signingKey,
)
const did = await didForCreateOp(createV1)
-
await client.sendOperation(did, createV1 as any)
})
it('healthcheck succeeds when database is available.', async () => {
···
}
})
+
it('disallows create v1s', async () => {
const createV1 = await plc.deprecatedSignCreate(
{
type: 'create',
···
signingKey,
)
const did = await didForCreateOp(createV1)
+
const attempt = client.sendOperation(did, createV1 as any)
+
await expect(attempt).rejects.toThrow()
})
it('healthcheck succeeds when database is available.', async () => {