···11+import { CID } from 'multiformats/cid'
22+import { check, cidForCbor, HOUR } from '@atproto/common'
33+import * as t from './types'
44+import {
55+ assureValidCreationOp,
66+ assureValidOp,
77+ assureValidSig,
88+ normalizeOp,
99+} from './operations'
1010+import {
1111+ ImproperOperationError,
1212+ LateRecoveryError,
1313+ MisorderedOperationError,
1414+} from './error'
1515+1616+export const assureValidNextOp = async (
1717+ did: string,
1818+ ops: t.IndexedOperation[],
1919+ proposed: t.Operation,
2020+): Promise<{ nullified: CID[]; prev: CID | null }> => {
2121+ await assureValidOp(proposed)
2222+2323+ // special case if account creation
2424+ if (ops.length === 0) {
2525+ await assureValidCreationOp(did, proposed)
2626+ return { nullified: [], prev: null }
2727+ }
2828+2929+ const proposedPrev = proposed.prev ? CID.parse(proposed.prev) : undefined
3030+ if (!proposedPrev) {
3131+ throw new MisorderedOperationError()
3232+ }
3333+3434+ const indexOfPrev = ops.findIndex((op) => proposedPrev.equals(op.cid))
3535+ if (indexOfPrev < 0) {
3636+ throw new MisorderedOperationError()
3737+ }
3838+3939+ // if we are forking history, these are the ops still in the proposed canonical history
4040+ const opsInHistory = ops.slice(0, indexOfPrev + 1)
4141+ const nullified = ops.slice(indexOfPrev + 1)
4242+ const lastOp = opsInHistory.at(-1)
4343+ if (!lastOp) {
4444+ throw new MisorderedOperationError()
4545+ }
4646+ const lastOpNormalized = normalizeOp(lastOp.operation)
4747+ const firstNullified = nullified[0]
4848+4949+ // if this does not involve nullification
5050+ if (!firstNullified) {
5151+ await assureValidSig(lastOpNormalized.rotationKeys, proposed)
5252+ return { nullified: [], prev: proposedPrev }
5353+ }
5454+5555+ const disputedSigner = await assureValidSig(
5656+ lastOpNormalized.rotationKeys,
5757+ firstNullified.operation,
5858+ )
5959+6060+ const indexOfSigner = lastOpNormalized.rotationKeys.indexOf(disputedSigner)
6161+ const morePowerfulKeys = lastOpNormalized.rotationKeys.slice(0, indexOfSigner)
6262+6363+ await assureValidSig(morePowerfulKeys, proposed)
6464+6565+ // recovery key gets a 72hr window to do historical re-wrties
6666+ if (nullified.length > 0) {
6767+ const RECOVERY_WINDOW = 72 * HOUR
6868+ const timeLapsed = Date.now() - firstNullified.createdAt.getTime()
6969+ if (timeLapsed > RECOVERY_WINDOW) {
7070+ throw new LateRecoveryError(timeLapsed)
7171+ }
7272+ }
7373+7474+ return {
7575+ nullified: nullified.map((op) => op.cid),
7676+ prev: proposedPrev,
7777+ }
7878+}
7979+8080+export const validateOperationLog = async (
8181+ did: string,
8282+ ops: t.CompatibleOp[],
8383+): Promise<t.DocumentData> => {
8484+ // make sure they're all validly formatted operations
8585+ const [first, ...rest] = ops
8686+ if (!check.is(first, t.def.compatibleOp)) {
8787+ throw new ImproperOperationError('incorrect structure', first)
8888+ }
8989+ for (const op of rest) {
9090+ if (!check.is(op, t.def.operation)) {
9191+ throw new ImproperOperationError('incorrect structure', op)
9292+ }
9393+ }
9494+9595+ // ensure the first op is a valid & signed create operation
9696+ let doc = await assureValidCreationOp(did, first)
9797+ let prev = await cidForCbor(first)
9898+9999+ for (const op of rest) {
100100+ if (!op.prev || !CID.parse(op.prev).equals(prev)) {
101101+ throw new MisorderedOperationError()
102102+ }
103103+104104+ await assureValidSig(doc.rotationKeys, op)
105105+ const { signingKey, rotationKeys, handles, services } = op
106106+ doc = { did, signingKey, rotationKeys, handles, services }
107107+ prev = await cidForCbor(op)
108108+ }
109109+110110+ return doc
111111+}
+26-186
packages/lib/src/document.ts
···11-import { CID } from 'multiformats/cid'
21import * as uint8arrays from 'uint8arrays'
33-import * as cbor from '@ipld/dag-cbor'
44-import { check, cidForCbor } from '@atproto/common'
52import * as crypto from '@atproto/crypto'
63import * as t from './types'
77-88-// @TODO fix this
99-class ServerError extends Error {
1010- constructor(public code: number, msg: string) {
1111- super(msg)
1212- }
1313-}
1414-1515-export const assureValidNextOp = async (
1616- did: string,
1717- ops: t.IndexedOperation[],
1818- proposed: t.Operation,
1919-): Promise<{ nullified: CID[]; prev: CID | null }> => {
2020- // special case if account creation
2121- if (ops.length === 0) {
2222- if (!check.is(proposed, t.def.createOp)) {
2323- throw new ServerError(400, 'Expected first operation to be `create`')
2424- }
2525- await assureValidCreationOp(did, proposed)
2626- return { nullified: [], prev: null }
2727- }
2828-2929- // ensure we support the proposed key type
3030- if (
3131- check.is(proposed, t.def.rotateSigningKeyOp) ||
3232- check.is(proposed, t.def.rotateRecoveryKeyOp)
3333- ) {
3434- await crypto.parseDidKey(proposed.key)
3535- }
3636-3737- const proposedPrev = proposed.prev ? CID.parse(proposed.prev) : undefined
3838- if (!proposedPrev) {
3939- throw new ServerError(400, `Invalid prev on operation: ${proposed.prev}`)
4040- }
4141-4242- const indexOfPrev = ops.findIndex((op) => proposedPrev.equals(op.cid))
4343- if (indexOfPrev < 0) {
4444- throw new ServerError(409, 'Operations not correctly ordered')
4545- }
4646-4747- // if we are forking history, these are the ops still in the proposed canonical history
4848- const opsInHistory = ops.slice(0, indexOfPrev + 1)
4949- const nullified = ops.slice(indexOfPrev + 1)
5050-5151- const doc = await validateOperationLog(
5252- did,
5353- opsInHistory.map((op) => op.operation),
5454- )
5555- const allowedKeys =
5656- nullified.length === 0
5757- ? [doc.signingKey, doc.recoveryKey]
5858- : [doc.recoveryKey] // only the recovery key is allowed to do historical re-writes
5959-6060- await assureValidSig(allowedKeys, proposed)
6161-6262- // recovery key gets a 72hr window to do historical re-wrties
6363- if (nullified.length > 0) {
6464- const RECOVERY_WINDOW = 1000 * 60 * 60 * 72
6565- const firstNullfied = nullified[0]
6666- const timeLapsed = Date.now() - firstNullfied.createdAt.getTime()
6767- if (timeLapsed > RECOVERY_WINDOW) {
6868- throw new ServerError(
6969- 400,
7070- 'Recovery operation occured outside of the allowed 72 hr recovery window',
7171- )
7272- }
7373- }
7474-7575- return {
7676- nullified: nullified.map((op) => op.cid),
7777- prev: proposedPrev,
7878- }
7979-}
8080-8181-export const validateOperationLog = async (
8282- did: string,
8383- ops: t.Operation[],
8484-): Promise<t.DocumentData> => {
8585- // make sure they're all validly formatted operations
8686- for (const op of ops) {
8787- if (!check.is(op, t.def.operation)) {
8888- throw new ServerError(400, `Improperly formatted operation: ${op}`)
8989- }
9090- }
9191-9292- // ensure the first op is a valid & signed create operation
9393- const [first, ...rest] = ops
9494- if (!check.is(first, t.def.createOp)) {
9595- throw new ServerError(400, 'Expected first operation to be `create`')
9696- }
9797- await assureValidCreationOp(did, first)
9898-9999- // iterate through operations to reconstruct the current state of the document
100100- const doc: t.DocumentData = {
101101- did,
102102- signingKey: first.signingKey,
103103- recoveryKey: first.recoveryKey,
104104- handle: first.handle,
105105- atpPds: first.service,
106106- }
107107- let prev = await cidForCbor(first)
108108-109109- for (const op of rest) {
110110- if (!op.prev || !CID.parse(op.prev).equals(prev)) {
111111- throw new ServerError(400, 'Operations not correctly ordered')
112112- }
113113-114114- await assureValidSig([doc.signingKey, doc.recoveryKey], op)
115115- if (check.is(op, t.def.createOp)) {
116116- throw new ServerError(400, 'Unexpected `create` after DID genesis')
117117- } else if (check.is(op, t.def.rotateSigningKeyOp)) {
118118- doc.signingKey = op.key
119119- } else if (check.is(op, t.def.rotateRecoveryKeyOp)) {
120120- doc.recoveryKey = op.key
121121- } else if (check.is(op, t.def.updateHandleOp)) {
122122- doc.handle = op.handle
123123- } else if (check.is(op, t.def.updateAtpPdsOp)) {
124124- doc.atpPds = op.service
125125- } else {
126126- throw new ServerError(400, `Unknown operation: ${JSON.stringify(op)}`)
127127- }
128128- prev = await cidForCbor(op)
129129- }
130130-131131- return doc
132132-}
133133-134134-export const hashAndFindDid = async (op: t.CreateOp, truncate = 24) => {
135135- const hashOfGenesis = await crypto.sha256(cbor.encode(op))
136136- const hashB32 = uint8arrays.toString(hashOfGenesis, 'base32')
137137- const truncated = hashB32.slice(0, truncate)
138138- return `did:plc:${truncated}`
139139-}
140140-141141-export const assureValidCreationOp = async (did: string, op: t.CreateOp) => {
142142- await assureValidSig([op.signingKey], op)
143143- const expectedDid = await hashAndFindDid(op, 64)
144144- if (!expectedDid.startsWith(did)) {
145145- throw new ServerError(
146146- 400,
147147- `Hash of genesis operation does not match DID identifier: ${expectedDid}`,
148148- )
149149- }
150150-}
151151-152152-export const assureValidSig = async (
153153- allowedDids: string[],
154154- op: t.Operation,
155155-) => {
156156- const { sig, ...opData } = op
157157- const sigBytes = uint8arrays.fromString(sig, 'base64url')
158158- const dataBytes = new Uint8Array(cbor.encode(opData))
159159- let isValid = true
160160- for (const did of allowedDids) {
161161- isValid = await crypto.verifySignature(did, dataBytes, sigBytes)
162162- if (isValid) return
163163- }
164164- throw new ServerError(400, `Invalid signature on op: ${JSON.stringify(op)}`)
165165-}
44+import { UnsupportedKeyError } from './error'
55+import { ParsedDidKey } from '@atproto/crypto'
16661677export const formatDidDoc = (data: t.DocumentData): t.DidDocument => {
1688 const context = ['https://www.w3.org/ns/did/v1']
169917010 const signingKeyInfo = formatKeyAndContext(data.signingKey)
171171- const recoveryKeyInfo = formatKeyAndContext(data.recoveryKey)
172172- const verificationMethods = [signingKeyInfo, recoveryKeyInfo]
173173- verificationMethods.forEach((method) => {
174174- if (!context.includes(method.context)) {
175175- context.push(method.context)
176176- }
177177- })
1111+ if (!context.includes(signingKeyInfo.context)) {
1212+ context.push(signingKeyInfo.context)
1313+ }
1414+1515+ const alsoKnownAs = data.handles.map((h) => ensureHttpPrefix(h))
1616+ const services: Service[] = []
1717+ if (data.services.atpPds) {
1818+ services.push({
1919+ id: `#atpPds`,
2020+ type: 'AtpPersonalDataServer',
2121+ serviceEndpoint: ensureHttpPrefix(data.services.atpPds),
2222+ })
2323+ }
1782417925 return {
18026 '@context': context,
18127 id: data.did,
182182- alsoKnownAs: [ensureHttpPrefix(data.handle)],
2828+ alsoKnownAs: alsoKnownAs,
18329 verificationMethod: [
18430 {
18531 id: `#signingKey`,
···18733 controller: data.did,
18834 publicKeyMultibase: signingKeyInfo.publicKeyMultibase,
18935 },
190190- {
191191- id: `#recoveryKey`,
192192- type: recoveryKeyInfo.type,
193193- controller: data.did,
194194- publicKeyMultibase: recoveryKeyInfo.publicKeyMultibase,
195195- },
19636 ],
19737 assertionMethod: [`#signingKey`],
19838 capabilityInvocation: [`#signingKey`],
19939 capabilityDelegation: [`#signingKey`],
200200- service: [
201201- {
202202- id: `#atpPds`,
203203- type: 'AtpPersonalDataServer',
204204- serviceEndpoint: ensureHttpPrefix(data.atpPds),
205205- },
206206- ],
4040+ service: services,
20741 }
4242+}
4343+4444+type Service = {
4545+ id: string
4646+ type: string
4747+ serviceEndpoint: string
20848}
2094921050type KeyAndContext = {
···21454}
2155521656const formatKeyAndContext = (key: string): KeyAndContext => {
217217- let keyInfo
5757+ let keyInfo: ParsedDidKey
21858 try {
21959 keyInfo = crypto.parseDidKey(key)
22060 } catch (err) {
221221- throw new ServerError(400, `Could not parse did:key: ${err}`)
6161+ throw new UnsupportedKeyError(key, err)
22262 }
22363 const { jwtAlg, keyBytes } = keyInfo
22464···23575 publicKeyMultibase: `z${uint8arrays.toString(keyBytes, 'base58btc')}`,
23676 }
23777 }
238238- throw new ServerError(400, `Unsupported key type: ${jwtAlg}`)
7878+ throw new UnsupportedKeyError(key, `Unsupported key type: ${jwtAlg}`)
23979}
2408024181export const ensureHttpPrefix = (str: string): string => {
+58
packages/lib/src/error.ts
···11+export class PlcError extends Error {
22+ plcError = true
33+ constructor(msg: string) {
44+ super(msg)
55+ }
66+77+ static is(obj: unknown): obj is PlcError {
88+ if (obj && typeof obj === 'object' && obj['plcError'] === true) {
99+ return true
1010+ }
1111+ return false
1212+ }
1313+}
1414+export class ImproperOperationError extends PlcError {
1515+ constructor(public reason: string, public op: unknown) {
1616+ super(`Improperly formatted operation, ${reason}: ${op}`)
1717+ }
1818+}
1919+2020+export class MisorderedOperationError extends PlcError {
2121+ constructor() {
2222+ super('Operations not correctly ordered')
2323+ }
2424+}
2525+2626+export class LateRecoveryError extends PlcError {
2727+ constructor(public timeLapsed: number) {
2828+ super(
2929+ `Recovery operation occured outside of the allowed 72 hr recovery window. Time lapsed: ${timeLapsed}`,
3030+ )
3131+ }
3232+}
3333+3434+export class GenesisHashError extends PlcError {
3535+ constructor(public expected: string) {
3636+ super(
3737+ `Hash of genesis operation does not match DID identifier: ${expected}`,
3838+ )
3939+ }
4040+}
4141+4242+export class InvalidSignatureError extends PlcError {
4343+ constructor(public op: unknown) {
4444+ super(`Invalid signature on op: ${JSON.stringify(op)}`)
4545+ }
4646+}
4747+4848+export class UnsupportedKeyError extends PlcError {
4949+ constructor(public key: string, public err: unknown) {
5050+ super(`Unsupported key type ${key}: ${err}`)
5151+ }
5252+}
5353+5454+export class ImproperlyFormattedDidError extends PlcError {
5555+ constructor(public reason: string) {
5656+ super(`Improperly formatted did: ${reason}`)
5757+ }
5858+}
+5-3
packages/lib/src/index.ts
···11-export * as document from './document'
22-export * as operations from './operations'
33-export * from './types'
41export * from './client'
22+export * from './data'
33+export * from './document'
44+export * from './error'
55+export * from './operations'
66+export * from './types'