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

Merge pull request #2 from bluesky-social/tombstones

Tombstones

Changed files
+178 -36
packages
+27 -6
packages/lib/src/data.ts
···
export const assureValidNextOp = async (
did: string,
ops: t.IndexedOperation[],
-
proposed: t.Operation,
+
proposed: t.OpOrTombstone,
): Promise<{ nullified: CID[]; prev: CID | null }> => {
await assureValidOp(proposed)
···
const nullified = ops.slice(indexOfPrev + 1)
const lastOp = opsInHistory.at(-1)
if (!lastOp) {
+
throw new MisorderedOperationError()
+
}
+
if (check.is(lastOp.operation, t.def.tombstone)) {
throw new MisorderedOperationError()
}
const lastOpNormalized = normalizeOp(lastOp.operation)
···
export const validateOperationLog = async (
did: string,
-
ops: t.CompatibleOp[],
-
): Promise<t.DocumentData> => {
+
ops: t.CompatibleOpOrTombstone[],
+
): Promise<t.DocumentData | null> => {
// make sure they're all validly formatted operations
const [first, ...rest] = ops
if (!check.is(first, t.def.compatibleOp)) {
throw new ImproperOperationError('incorrect structure', first)
}
for (const op of rest) {
-
if (!check.is(op, t.def.operation)) {
+
if (!check.is(op, t.def.opOrTombstone)) {
throw new ImproperOperationError('incorrect structure', op)
}
}
···
let doc = await assureValidCreationOp(did, first)
let prev = await cidForCbor(first)
-
for (const op of rest) {
+
for (let i = 0; i < rest.length; i++) {
+
const op = rest[i]
if (!op.prev || !CID.parse(op.prev).equals(prev)) {
throw new MisorderedOperationError()
}
-
await assureValidSig(doc.rotationKeys, op)
+
if (check.is(op, t.def.tombstone)) {
+
if (i === rest.length - 1) {
+
return null
+
} else {
+
throw new MisorderedOperationError()
+
}
+
}
const { signingKey, rotationKeys, handles, services } = op
doc = { did, signingKey, rotationKeys, handles, services }
prev = await cidForCbor(op)
···
return doc
}
+
+
export const getLastOpWithCid = async (
+
ops: t.CompatibleOpOrTombstone[],
+
): Promise<{ op: t.CompatibleOpOrTombstone; cid: CID }> => {
+
const op = ops.at(-1)
+
if (!op) {
+
throw new Error('log is empty')
+
}
+
const cid = await cidForCbor(op)
+
return { op, cid }
+
}
+39 -16
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 * as t from './types'
import { check } from '@atproto/common'
+
import * as t from './types'
import {
GenesisHashError,
ImproperlyFormattedDidError,
ImproperOperationError,
InvalidSignatureError,
+
MisorderedOperationError,
UnsupportedKeyError,
} from './error'
···
return `did:plc:${truncated}`
}
+
export const addSignature = async <T extends Record<string, unknown>>(
+
object: T,
+
key: Keypair,
+
): Promise<T & { sig: string }> => {
+
const data = new Uint8Array(cbor.encode(object))
+
const sig = await key.sign(data)
+
return {
+
...object,
+
sig: uint8arrays.toString(sig, 'base64url'),
+
}
+
}
+
export const signOperation = async (
op: t.UnsignedOperation,
signingKey: Keypair,
): Promise<t.Operation> => {
-
const data = new Uint8Array(cbor.encode(op))
-
const sig = await signingKey.sign(data)
-
return {
-
...op,
-
sig: uint8arrays.toString(sig, 'base64url'),
-
}
+
return addSignature(op, signingKey)
+
}
+
+
export const signTombstone = async (
+
prev: CID,
+
key: Keypair,
+
): Promise<t.Tombstone> => {
+
return addSignature(
+
{
+
tombstone: true,
+
prev: prev.toString(),
+
},
+
key,
+
)
}
export const deprecatedSignCreate = async (
op: t.UnsignedCreateOpV1,
signingKey: Keypair,
): Promise<t.CreateOpV1> => {
-
const data = new Uint8Array(cbor.encode(op))
-
const sig = await signingKey.sign(data)
-
return {
-
...op,
-
sig: uint8arrays.toString(sig, 'base64url'),
-
}
+
return addSignature(op, signingKey)
}
export const normalizeOp = (op: t.CompatibleOp): t.Operation => {
···
}
}
-
export const assureValidOp = async (op: t.Operation) => {
+
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 = [op.signingKey, ...op.rotationKeys]
await Promise.all(
···
export const assureValidCreationOp = async (
did: string,
-
op: t.CompatibleOp,
+
op: t.CompatibleOpOrTombstone,
): Promise<t.DocumentData> => {
+
if (check.is(op, t.def.tombstone)) {
+
throw new MisorderedOperationError()
+
}
const normalized = normalizeOp(op)
await assureValidOp(normalized)
await assureValidSig(normalized.rotationKeys, op)
···
export const assureValidSig = async (
allowedDids: string[],
-
op: t.CompatibleOp,
+
op: t.CompatibleOpOrTombstone,
): Promise<string> => {
const { sig, ...opData } = op
const sigBytes = uint8arrays.fromString(sig, 'base64url')
+17 -1
packages/lib/src/types.ts
···
const operation = unsignedOperation.extend({ sig: z.string() })
export type Operation = z.infer<typeof operation>
+
const unsignedTombstone = z.object({
+
tombstone: z.literal(true),
+
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])
+
export type OpOrTombstone = z.infer<typeof opOrTombstone>
const compatibleOp = z.union([createOpV1, operation])
export type CompatibleOp = z.infer<typeof compatibleOp>
+
const compatibleOpOrTombstone = z.union([createOpV1, operation, tombstone])
+
export type CompatibleOpOrTombstone = z.infer<typeof compatibleOpOrTombstone>
+
export const indexedOperation = z.object({
did: z.string(),
-
operation: compatibleOp,
+
operation: compatibleOpOrTombstone,
cid: cid,
nullified: z.boolean(),
createdAt: z.date(),
···
createOpV1,
unsignedOperation,
operation,
+
tombstone,
+
opOrTombstone,
compatibleOp,
+
compatibleOpOrTombstone,
didDocument,
}
+38
packages/lib/tests/data.test.ts
···
it('parses an operation log with no updates', async () => {
const doc = await data.validateOperationLog(did, ops)
+
if (!doc) {
+
throw new Error('expected doc')
+
}
expect(doc.did).toEqual(did)
expect(doc.signingKey).toEqual(signingKey.did())
expect(doc.rotationKeys).toEqual([rotationKey1.did(), rotationKey2.did()])
···
ops.push(op)
const doc = await data.validateOperationLog(did, ops)
+
if (!doc) {
+
throw new Error('expected doc')
+
}
expect(doc.did).toEqual(did)
expect(doc.signingKey).toEqual(signingKey.did())
expect(doc.rotationKeys).toEqual([rotationKey1.did(), rotationKey2.did()])
···
ops.push(op)
const doc = await data.validateOperationLog(did, ops)
+
if (!doc) {
+
throw new Error('expected doc')
+
}
expect(doc.did).toEqual(did)
expect(doc.signingKey).toEqual(signingKey.did())
expect(doc.rotationKeys).toEqual([rotationKey1.did(), rotationKey2.did()])
···
signingKey = newSigningKey
const doc = await data.validateOperationLog(did, ops)
+
if (!doc) {
+
throw new Error('expected doc')
+
}
expect(doc.did).toEqual(did)
expect(doc.signingKey).toEqual(signingKey.did())
expect(doc.rotationKeys).toEqual([rotationKey1.did(), rotationKey2.did()])
···
rotationKey1 = newRotationKey
const doc = await data.validateOperationLog(did, ops)
+
if (!doc) {
+
throw new Error('expected doc')
+
}
+
expect(doc.did).toEqual(did)
expect(doc.signingKey).toEqual(signingKey.did())
expect(doc.rotationKeys).toEqual([rotationKey1.did(), rotationKey2.did()])
···
ops.push(op)
handle = newHandle
const doc = await data.validateOperationLog(did, ops)
+
if (!doc) {
+
throw new Error('expected doc')
+
}
expect(doc.did).toEqual(did)
expect(doc.signingKey).toEqual(signingKey.did())
expect(doc.rotationKeys).toEqual([rotationKey1.did(), rotationKey2.did()])
···
expect(doc.services).toEqual({ atpPds })
})
+
it('allows tombstoning a DID', async () => {
+
const last = await data.getLastOpWithCid(ops)
+
const op = await operations.signTombstone(last.cid, rotationKey1)
+
const doc = await data.validateOperationLog(did, [...ops, op])
+
expect(doc).toBe(null)
+
})
+
it('requires operations to be in order', async () => {
const prev = await cidForCbor(ops[ops.length - 2])
const op = await makeNextOp(
···
expect(data.validateOperationLog(did, [...ops, op])).rejects.toThrow(
MisorderedOperationError,
)
+
})
+
+
it('does not allow a tombstone in the middle of the log', async () => {
+
const prev = await cidForCbor(ops[ops.length - 2])
+
const tombstone = await operations.signTombstone(prev, rotationKey1)
+
expect(
+
data.validateOperationLog(did, [
+
...ops.slice(0, ops.length - 1),
+
tombstone,
+
ops[ops.length - 1],
+
]),
+
).rejects.toThrow(MisorderedOperationError)
})
it('requires that the did is the hash of the genesis op', async () => {
+43 -10
packages/lib/tests/recovery.test.ts
···
rotationKey3 = await EcdsaKeypair.create()
})
+
const formatIndexed = async (
+
op: t.Operation,
+
): Promise<t.IndexedOperation> => {
+
const cid = await cidForCbor(op)
+
+
return {
+
did,
+
operation: op,
+
cid,
+
nullified: false,
+
createdAt: new Date(),
+
}
+
}
+
const signOpForKeys = async (
keys: Keypair[],
prev: CID | null,
···
},
signer,
)
-
-
const cid = await cidForCbor(op)
-
-
const indexed = {
-
did,
-
operation: op,
-
cid,
-
nullified: false,
-
createdAt: new Date(),
-
}
+
const indexed = await formatIndexed(op)
return { op, indexed }
}
···
await expect(
data.assureValidNextOp(did, timeOutOps, rotateBack.op),
).rejects.toThrow(LateRecoveryError)
+
})
+
+
it('allows recovery from a tombstoned DID', async () => {
+
const tombstone = await operations.signTombstone(createCid, rotationKey2)
+
const cid = await cidForCbor(tombstone)
+
const tombstoneOps = [
+
log[0],
+
{
+
did,
+
operation: tombstone,
+
cid,
+
nullified: false,
+
createdAt: new Date(),
+
},
+
]
+
const rotateBack = await signOpForKeys(
+
[rotationKey1],
+
createCid,
+
rotationKey1,
+
)
+
const result = await data.assureValidNextOp(
+
did,
+
tombstoneOps,
+
rotateBack.op,
+
)
+
expect(result.nullified.length).toBe(1)
+
expect(result.nullified[0].equals(cid))
+
expect(result.prev?.equals(createCid))
})
})
+8 -3
packages/server/src/db.ts
···
import SqliteDB from 'better-sqlite3'
import { Pool as PgPool, types as pgTypes } from 'pg'
import { CID } from 'multiformats/cid'
-
import { cidForCbor } from '@atproto/common'
+
import { cidForCbor, check } from '@atproto/common'
import * as plc from '@did-plc/lib'
import { ServerError } from './error'
import * as migrations from './migrations'
···
return found ? CID.parse(found.cid) : null
}
-
async opsForDid(did: string): Promise<plc.Operation[]> {
+
async opsForDid(did: string): Promise<plc.OpOrTombstone[]> {
const ops = await this._opsForDid(did)
-
return ops.map((op) => plc.normalizeOp(op.operation))
+
return ops.map((op) => {
+
if (check.is(op.operation, plc.def.createOpV1)) {
+
return plc.normalizeOp(op.operation)
+
}
+
return op.operation
+
})
}
async _opsForDid(did: string): Promise<plc.IndexedOperation[]> {
+6
packages/server/src/routes.ts
···
throw new ServerError(404, `DID not registered: ${did}`)
}
const data = await plc.validateOperationLog(did, log)
+
if (data === null) {
+
throw new ServerError(404, `DID not available: ${did}`)
+
}
const doc = await plc.formatDidDoc(data)
res.type('application/did+ld+json')
res.send(JSON.stringify(doc))
···
throw new ServerError(404, `DID not registered: ${did}`)
}
const data = await plc.validateOperationLog(did, log)
+
if (data === null) {
+
throw new ServerError(404, `DID not available: ${did}`)
+
}
res.json(data)
})