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

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #2 from bluesky-social/tombstones

Tombstones

authored by

Daniel Holmgren and committed by
GitHub
0e771943 8465c6e3

+178 -36
+27 -6
packages/lib/src/data.ts
··· 16 16 export const assureValidNextOp = async ( 17 17 did: string, 18 18 ops: t.IndexedOperation[], 19 - proposed: t.Operation, 19 + proposed: t.OpOrTombstone, 20 20 ): Promise<{ nullified: CID[]; prev: CID | null }> => { 21 21 await assureValidOp(proposed) 22 22 ··· 41 41 const nullified = ops.slice(indexOfPrev + 1) 42 42 const lastOp = opsInHistory.at(-1) 43 43 if (!lastOp) { 44 + throw new MisorderedOperationError() 45 + } 46 + if (check.is(lastOp.operation, t.def.tombstone)) { 44 47 throw new MisorderedOperationError() 45 48 } 46 49 const lastOpNormalized = normalizeOp(lastOp.operation) ··· 79 82 80 83 export const validateOperationLog = async ( 81 84 did: string, 82 - ops: t.CompatibleOp[], 83 - ): Promise<t.DocumentData> => { 85 + ops: t.CompatibleOpOrTombstone[], 86 + ): Promise<t.DocumentData | null> => { 84 87 // make sure they're all validly formatted operations 85 88 const [first, ...rest] = ops 86 89 if (!check.is(first, t.def.compatibleOp)) { 87 90 throw new ImproperOperationError('incorrect structure', first) 88 91 } 89 92 for (const op of rest) { 90 - if (!check.is(op, t.def.operation)) { 93 + if (!check.is(op, t.def.opOrTombstone)) { 91 94 throw new ImproperOperationError('incorrect structure', op) 92 95 } 93 96 } ··· 96 99 let doc = await assureValidCreationOp(did, first) 97 100 let prev = await cidForCbor(first) 98 101 99 - for (const op of rest) { 102 + for (let i = 0; i < rest.length; i++) { 103 + const op = rest[i] 100 104 if (!op.prev || !CID.parse(op.prev).equals(prev)) { 101 105 throw new MisorderedOperationError() 102 106 } 103 - 104 107 await assureValidSig(doc.rotationKeys, op) 108 + if (check.is(op, t.def.tombstone)) { 109 + if (i === rest.length - 1) { 110 + return null 111 + } else { 112 + throw new MisorderedOperationError() 113 + } 114 + } 105 115 const { signingKey, rotationKeys, handles, services } = op 106 116 doc = { did, signingKey, rotationKeys, handles, services } 107 117 prev = await cidForCbor(op) ··· 109 119 110 120 return doc 111 121 } 122 + 123 + export const getLastOpWithCid = async ( 124 + ops: t.CompatibleOpOrTombstone[], 125 + ): Promise<{ op: t.CompatibleOpOrTombstone; cid: CID }> => { 126 + const op = ops.at(-1) 127 + if (!op) { 128 + throw new Error('log is empty') 129 + } 130 + const cid = await cidForCbor(op) 131 + return { op, cid } 132 + }
+39 -16
packages/lib/src/operations.ts
··· 1 1 import * as cbor from '@ipld/dag-cbor' 2 + import { CID } from 'multiformats/cid' 2 3 import * as uint8arrays from 'uint8arrays' 3 4 import { Keypair, parseDidKey, sha256, verifySignature } from '@atproto/crypto' 4 - import * as t from './types' 5 5 import { check } from '@atproto/common' 6 + import * as t from './types' 6 7 import { 7 8 GenesisHashError, 8 9 ImproperlyFormattedDidError, 9 10 ImproperOperationError, 10 11 InvalidSignatureError, 12 + MisorderedOperationError, 11 13 UnsupportedKeyError, 12 14 } from './error' 13 15 ··· 18 20 return `did:plc:${truncated}` 19 21 } 20 22 23 + export const addSignature = async <T extends Record<string, unknown>>( 24 + object: T, 25 + key: Keypair, 26 + ): Promise<T & { sig: string }> => { 27 + const data = new Uint8Array(cbor.encode(object)) 28 + const sig = await key.sign(data) 29 + return { 30 + ...object, 31 + sig: uint8arrays.toString(sig, 'base64url'), 32 + } 33 + } 34 + 21 35 export const signOperation = async ( 22 36 op: t.UnsignedOperation, 23 37 signingKey: Keypair, 24 38 ): Promise<t.Operation> => { 25 - const data = new Uint8Array(cbor.encode(op)) 26 - const sig = await signingKey.sign(data) 27 - return { 28 - ...op, 29 - sig: uint8arrays.toString(sig, 'base64url'), 30 - } 39 + return addSignature(op, signingKey) 40 + } 41 + 42 + export const signTombstone = async ( 43 + prev: CID, 44 + key: Keypair, 45 + ): Promise<t.Tombstone> => { 46 + return addSignature( 47 + { 48 + tombstone: true, 49 + prev: prev.toString(), 50 + }, 51 + key, 52 + ) 31 53 } 32 54 33 55 export const deprecatedSignCreate = async ( 34 56 op: t.UnsignedCreateOpV1, 35 57 signingKey: Keypair, 36 58 ): Promise<t.CreateOpV1> => { 37 - const data = new Uint8Array(cbor.encode(op)) 38 - const sig = await signingKey.sign(data) 39 - return { 40 - ...op, 41 - sig: uint8arrays.toString(sig, 'base64url'), 42 - } 59 + return addSignature(op, signingKey) 43 60 } 44 61 45 62 export const normalizeOp = (op: t.CompatibleOp): t.Operation => { ··· 58 75 } 59 76 } 60 77 61 - export const assureValidOp = async (op: t.Operation) => { 78 + export const assureValidOp = async (op: t.OpOrTombstone) => { 79 + if (check.is(op, t.def.tombstone)) { 80 + return true 81 + } 62 82 // ensure we support the op's keys 63 83 const keys = [op.signingKey, ...op.rotationKeys] 64 84 await Promise.all( ··· 79 99 80 100 export const assureValidCreationOp = async ( 81 101 did: string, 82 - op: t.CompatibleOp, 102 + op: t.CompatibleOpOrTombstone, 83 103 ): Promise<t.DocumentData> => { 104 + if (check.is(op, t.def.tombstone)) { 105 + throw new MisorderedOperationError() 106 + } 84 107 const normalized = normalizeOp(op) 85 108 await assureValidOp(normalized) 86 109 await assureValidSig(normalized.rotationKeys, op) ··· 101 124 102 125 export const assureValidSig = async ( 103 126 allowedDids: string[], 104 - op: t.CompatibleOp, 127 + op: t.CompatibleOpOrTombstone, 105 128 ): Promise<string> => { 106 129 const { sig, ...opData } = op 107 130 const sigBytes = uint8arrays.fromString(sig, 'base64url')
+17 -1
packages/lib/src/types.ts
··· 44 44 const operation = unsignedOperation.extend({ sig: z.string() }) 45 45 export type Operation = z.infer<typeof operation> 46 46 47 + const unsignedTombstone = z.object({ 48 + tombstone: z.literal(true), 49 + prev: z.string(), 50 + }) 51 + export type UnsignedTombstone = z.infer<typeof unsignedTombstone> 52 + const tombstone = unsignedTombstone.extend({ sig: z.string() }) 53 + export type Tombstone = z.infer<typeof tombstone> 54 + 55 + const opOrTombstone = z.union([operation, tombstone]) 56 + export type OpOrTombstone = z.infer<typeof opOrTombstone> 47 57 const compatibleOp = z.union([createOpV1, operation]) 48 58 export type CompatibleOp = z.infer<typeof compatibleOp> 59 + const compatibleOpOrTombstone = z.union([createOpV1, operation, tombstone]) 60 + export type CompatibleOpOrTombstone = z.infer<typeof compatibleOpOrTombstone> 61 + 49 62 export const indexedOperation = z.object({ 50 63 did: z.string(), 51 - operation: compatibleOp, 64 + operation: compatibleOpOrTombstone, 52 65 cid: cid, 53 66 nullified: z.boolean(), 54 67 createdAt: z.date(), ··· 85 98 createOpV1, 86 99 unsignedOperation, 87 100 operation, 101 + tombstone, 102 + opOrTombstone, 88 103 compatibleOp, 104 + compatibleOpOrTombstone, 89 105 didDocument, 90 106 }
+38
packages/lib/tests/data.test.ts
··· 72 72 it('parses an operation log with no updates', async () => { 73 73 const doc = await data.validateOperationLog(did, ops) 74 74 75 + if (!doc) { 76 + throw new Error('expected doc') 77 + } 75 78 expect(doc.did).toEqual(did) 76 79 expect(doc.signingKey).toEqual(signingKey.did()) 77 80 expect(doc.rotationKeys).toEqual([rotationKey1.did(), rotationKey2.did()]) ··· 85 88 ops.push(op) 86 89 87 90 const doc = await data.validateOperationLog(did, ops) 91 + if (!doc) { 92 + throw new Error('expected doc') 93 + } 88 94 expect(doc.did).toEqual(did) 89 95 expect(doc.signingKey).toEqual(signingKey.did()) 90 96 expect(doc.rotationKeys).toEqual([rotationKey1.did(), rotationKey2.did()]) ··· 105 111 ops.push(op) 106 112 107 113 const doc = await data.validateOperationLog(did, ops) 114 + if (!doc) { 115 + throw new Error('expected doc') 116 + } 108 117 expect(doc.did).toEqual(did) 109 118 expect(doc.signingKey).toEqual(signingKey.did()) 110 119 expect(doc.rotationKeys).toEqual([rotationKey1.did(), rotationKey2.did()]) ··· 125 134 signingKey = newSigningKey 126 135 127 136 const doc = await data.validateOperationLog(did, ops) 137 + if (!doc) { 138 + throw new Error('expected doc') 139 + } 128 140 expect(doc.did).toEqual(did) 129 141 expect(doc.signingKey).toEqual(signingKey.did()) 130 142 expect(doc.rotationKeys).toEqual([rotationKey1.did(), rotationKey2.did()]) ··· 146 158 rotationKey1 = newRotationKey 147 159 148 160 const doc = await data.validateOperationLog(did, ops) 161 + if (!doc) { 162 + throw new Error('expected doc') 163 + } 164 + 149 165 expect(doc.did).toEqual(did) 150 166 expect(doc.signingKey).toEqual(signingKey.did()) 151 167 expect(doc.rotationKeys).toEqual([rotationKey1.did(), rotationKey2.did()]) ··· 188 204 ops.push(op) 189 205 handle = newHandle 190 206 const doc = await data.validateOperationLog(did, ops) 207 + if (!doc) { 208 + throw new Error('expected doc') 209 + } 191 210 expect(doc.did).toEqual(did) 192 211 expect(doc.signingKey).toEqual(signingKey.did()) 193 212 expect(doc.rotationKeys).toEqual([rotationKey1.did(), rotationKey2.did()]) ··· 195 214 expect(doc.services).toEqual({ atpPds }) 196 215 }) 197 216 217 + it('allows tombstoning a DID', async () => { 218 + const last = await data.getLastOpWithCid(ops) 219 + const op = await operations.signTombstone(last.cid, rotationKey1) 220 + const doc = await data.validateOperationLog(did, [...ops, op]) 221 + expect(doc).toBe(null) 222 + }) 223 + 198 224 it('requires operations to be in order', async () => { 199 225 const prev = await cidForCbor(ops[ops.length - 2]) 200 226 const op = await makeNextOp( ··· 220 246 expect(data.validateOperationLog(did, [...ops, op])).rejects.toThrow( 221 247 MisorderedOperationError, 222 248 ) 249 + }) 250 + 251 + it('does not allow a tombstone in the middle of the log', async () => { 252 + const prev = await cidForCbor(ops[ops.length - 2]) 253 + const tombstone = await operations.signTombstone(prev, rotationKey1) 254 + expect( 255 + data.validateOperationLog(did, [ 256 + ...ops.slice(0, ops.length - 1), 257 + tombstone, 258 + ops[ops.length - 1], 259 + ]), 260 + ).rejects.toThrow(MisorderedOperationError) 223 261 }) 224 262 225 263 it('requires that the did is the hash of the genesis op', async () => {
+43 -10
packages/lib/tests/recovery.test.ts
··· 26 26 rotationKey3 = await EcdsaKeypair.create() 27 27 }) 28 28 29 + const formatIndexed = async ( 30 + op: t.Operation, 31 + ): Promise<t.IndexedOperation> => { 32 + const cid = await cidForCbor(op) 33 + 34 + return { 35 + did, 36 + operation: op, 37 + cid, 38 + nullified: false, 39 + createdAt: new Date(), 40 + } 41 + } 42 + 29 43 const signOpForKeys = async ( 30 44 keys: Keypair[], 31 45 prev: CID | null, ··· 45 59 }, 46 60 signer, 47 61 ) 48 - 49 - const cid = await cidForCbor(op) 50 - 51 - const indexed = { 52 - did, 53 - operation: op, 54 - cid, 55 - nullified: false, 56 - createdAt: new Date(), 57 - } 62 + const indexed = await formatIndexed(op) 58 63 return { op, indexed } 59 64 } 60 65 ··· 153 158 await expect( 154 159 data.assureValidNextOp(did, timeOutOps, rotateBack.op), 155 160 ).rejects.toThrow(LateRecoveryError) 161 + }) 162 + 163 + it('allows recovery from a tombstoned DID', async () => { 164 + const tombstone = await operations.signTombstone(createCid, rotationKey2) 165 + const cid = await cidForCbor(tombstone) 166 + const tombstoneOps = [ 167 + log[0], 168 + { 169 + did, 170 + operation: tombstone, 171 + cid, 172 + nullified: false, 173 + createdAt: new Date(), 174 + }, 175 + ] 176 + const rotateBack = await signOpForKeys( 177 + [rotationKey1], 178 + createCid, 179 + rotationKey1, 180 + ) 181 + const result = await data.assureValidNextOp( 182 + did, 183 + tombstoneOps, 184 + rotateBack.op, 185 + ) 186 + expect(result.nullified.length).toBe(1) 187 + expect(result.nullified[0].equals(cid)) 188 + expect(result.prev?.equals(createCid)) 156 189 }) 157 190 })
+8 -3
packages/server/src/db.ts
··· 2 2 import SqliteDB from 'better-sqlite3' 3 3 import { Pool as PgPool, types as pgTypes } from 'pg' 4 4 import { CID } from 'multiformats/cid' 5 - import { cidForCbor } from '@atproto/common' 5 + import { cidForCbor, check } from '@atproto/common' 6 6 import * as plc from '@did-plc/lib' 7 7 import { ServerError } from './error' 8 8 import * as migrations from './migrations' ··· 150 150 return found ? CID.parse(found.cid) : null 151 151 } 152 152 153 - async opsForDid(did: string): Promise<plc.Operation[]> { 153 + async opsForDid(did: string): Promise<plc.OpOrTombstone[]> { 154 154 const ops = await this._opsForDid(did) 155 - return ops.map((op) => plc.normalizeOp(op.operation)) 155 + return ops.map((op) => { 156 + if (check.is(op.operation, plc.def.createOpV1)) { 157 + return plc.normalizeOp(op.operation) 158 + } 159 + return op.operation 160 + }) 156 161 } 157 162 158 163 async _opsForDid(did: string): Promise<plc.IndexedOperation[]> {
+6
packages/server/src/routes.ts
··· 40 40 throw new ServerError(404, `DID not registered: ${did}`) 41 41 } 42 42 const data = await plc.validateOperationLog(did, log) 43 + if (data === null) { 44 + throw new ServerError(404, `DID not available: ${did}`) 45 + } 43 46 const doc = await plc.formatDidDoc(data) 44 47 res.type('application/did+ld+json') 45 48 res.send(JSON.stringify(doc)) ··· 53 56 throw new ServerError(404, `DID not registered: ${did}`) 54 57 } 55 58 const data = await plc.validateOperationLog(did, log) 59 + if (data === null) { 60 + throw new ServerError(404, `DID not available: ${did}`) 61 + } 56 62 res.json(data) 57 63 }) 58 64