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 #1 from bluesky-social/refactor

Update data model & operations

authored by

Daniel Holmgren and committed by
GitHub
8465c6e3 a3163aa4

+1069 -780
+37 -83
packages/lib/src/client.ts
··· 1 + import { cidForCbor } from '@atproto/common' 2 + import { Keypair } from '@atproto/crypto' 1 3 import axios from 'axios' 2 - import { CID } from 'multiformats/cid' 3 - import { Keypair } from '@atproto/crypto' 4 - import { check, cidForCbor } from '@atproto/common' 5 - import * as operations from './operations' 4 + import { didForCreateOp, signOperation } from './operations' 6 5 import * as t from './types' 7 6 8 7 export class Client { ··· 27 26 return `${this.url}/${encodeURIComponent(did)}` 28 27 } 29 28 30 - async sendOperation(did: string, op: t.Operation) { 31 - await axios.post(this.postOpUrl(did), op) 32 - } 33 - 34 - async formatCreateOp( 35 - signingKey: Keypair, 36 - recoveryKey: string, 37 - handle: string, 38 - service: string, 39 - ): Promise<{ op: t.CreateOp; did: string }> { 40 - const op = await operations.create(signingKey, recoveryKey, handle, service) 41 - if (!check.is(op, t.def.createOp)) { 42 - throw new Error('Not a valid create operation') 43 - } 44 - const did = await operations.didForCreateOp(op) 45 - return { did, op } 46 - } 47 - 48 - async createDid( 49 - signingKey: Keypair, 50 - recoveryKey: string, 51 - handle: string, 52 - service: string, 53 - ): Promise<string> { 54 - const { op, did } = await this.formatCreateOp( 55 - signingKey, 56 - recoveryKey, 57 - handle, 58 - service, 59 - ) 60 - await this.sendOperation(did, op) 61 - return did 62 - } 63 - 64 - async getPrev(did): Promise<CID> { 65 - const log = await this.getOperationLog(did) 66 - if (log.length === 0) { 67 - throw new Error(`Could not make update: DID does not exist: ${did}`) 68 - } 69 - return cidForCbor(log[log.length - 1]) 70 - } 71 - 72 - async rotateSigningKey( 73 - did: string, 74 - newKey: string, 75 - signingKey: Keypair, 76 - prev?: CID, 77 - ) { 78 - prev = prev ? prev : await this.getPrev(did) 79 - const op = await operations.rotateSigningKey( 80 - newKey, 81 - prev.toString(), 82 - signingKey, 83 - ) 84 - await this.sendOperation(did, op) 29 + async getLastOp(did: string): Promise<t.Operation> { 30 + const res = await axios.get(`${this.url}/last/${encodeURIComponent(did)}`) 31 + return res.data 85 32 } 86 33 87 - async rotateRecoveryKey( 34 + async applyPartialOp( 88 35 did: string, 89 - newKey: string, 90 - signingKey: Keypair, 91 - prev?: CID, 36 + delta: Partial<t.UnsignedOperation>, 37 + key: Keypair, 92 38 ) { 93 - prev = prev ? prev : await this.getPrev(did) 94 - const op = await operations.rotateRecoveryKey( 95 - newKey, 96 - prev.toString(), 97 - signingKey, 39 + const lastOp = await this.getLastOp(did) 40 + const prev = await cidForCbor(lastOp) 41 + const { signingKey, rotationKeys, handles, services } = lastOp 42 + const op = await signOperation( 43 + { 44 + signingKey, 45 + rotationKeys, 46 + handles, 47 + services, 48 + prev: prev.toString(), 49 + ...delta, 50 + }, 51 + key, 98 52 ) 99 53 await this.sendOperation(did, op) 100 54 } 101 55 102 - async updateHandle(did: string, handle: string, signingKey: Keypair) { 103 - const prev = await this.getPrev(did) 104 - const op = await operations.updateHandle( 105 - handle, 106 - prev.toString(), 107 - signingKey, 56 + async create( 57 + op: Omit<t.UnsignedOperation, 'prev'>, 58 + key: Keypair, 59 + ): Promise<string> { 60 + const createOp = await signOperation( 61 + { 62 + ...op, 63 + prev: null, 64 + }, 65 + key, 108 66 ) 109 - await this.sendOperation(did, op) 67 + const did = await didForCreateOp(createOp) 68 + await this.sendOperation(did, createOp) 69 + return did 110 70 } 111 71 112 - async updateAtpPds(did: string, service: string, signingKey: Keypair) { 113 - const prev = await this.getPrev(did) 114 - const op = await operations.updateAtpPds( 115 - service, 116 - prev.toString(), 117 - signingKey, 118 - ) 119 - await this.sendOperation(did, op) 72 + async sendOperation(did: string, op: t.Operation) { 73 + await axios.post(this.postOpUrl(did), op) 120 74 } 121 75 122 76 async health() {
+111
packages/lib/src/data.ts
··· 1 + import { CID } from 'multiformats/cid' 2 + import { check, cidForCbor, HOUR } from '@atproto/common' 3 + import * as t from './types' 4 + import { 5 + assureValidCreationOp, 6 + assureValidOp, 7 + assureValidSig, 8 + normalizeOp, 9 + } from './operations' 10 + import { 11 + ImproperOperationError, 12 + LateRecoveryError, 13 + MisorderedOperationError, 14 + } from './error' 15 + 16 + export const assureValidNextOp = async ( 17 + did: string, 18 + ops: t.IndexedOperation[], 19 + proposed: t.Operation, 20 + ): Promise<{ nullified: CID[]; prev: CID | null }> => { 21 + await assureValidOp(proposed) 22 + 23 + // special case if account creation 24 + if (ops.length === 0) { 25 + await assureValidCreationOp(did, proposed) 26 + return { nullified: [], prev: null } 27 + } 28 + 29 + const proposedPrev = proposed.prev ? CID.parse(proposed.prev) : undefined 30 + if (!proposedPrev) { 31 + throw new MisorderedOperationError() 32 + } 33 + 34 + const indexOfPrev = ops.findIndex((op) => proposedPrev.equals(op.cid)) 35 + if (indexOfPrev < 0) { 36 + throw new MisorderedOperationError() 37 + } 38 + 39 + // if we are forking history, these are the ops still in the proposed canonical history 40 + const opsInHistory = ops.slice(0, indexOfPrev + 1) 41 + const nullified = ops.slice(indexOfPrev + 1) 42 + const lastOp = opsInHistory.at(-1) 43 + if (!lastOp) { 44 + throw new MisorderedOperationError() 45 + } 46 + const lastOpNormalized = normalizeOp(lastOp.operation) 47 + const firstNullified = nullified[0] 48 + 49 + // if this does not involve nullification 50 + if (!firstNullified) { 51 + await assureValidSig(lastOpNormalized.rotationKeys, proposed) 52 + return { nullified: [], prev: proposedPrev } 53 + } 54 + 55 + const disputedSigner = await assureValidSig( 56 + lastOpNormalized.rotationKeys, 57 + firstNullified.operation, 58 + ) 59 + 60 + const indexOfSigner = lastOpNormalized.rotationKeys.indexOf(disputedSigner) 61 + const morePowerfulKeys = lastOpNormalized.rotationKeys.slice(0, indexOfSigner) 62 + 63 + await assureValidSig(morePowerfulKeys, proposed) 64 + 65 + // recovery key gets a 72hr window to do historical re-wrties 66 + if (nullified.length > 0) { 67 + const RECOVERY_WINDOW = 72 * HOUR 68 + const timeLapsed = Date.now() - firstNullified.createdAt.getTime() 69 + if (timeLapsed > RECOVERY_WINDOW) { 70 + throw new LateRecoveryError(timeLapsed) 71 + } 72 + } 73 + 74 + return { 75 + nullified: nullified.map((op) => op.cid), 76 + prev: proposedPrev, 77 + } 78 + } 79 + 80 + export const validateOperationLog = async ( 81 + did: string, 82 + ops: t.CompatibleOp[], 83 + ): Promise<t.DocumentData> => { 84 + // make sure they're all validly formatted operations 85 + const [first, ...rest] = ops 86 + if (!check.is(first, t.def.compatibleOp)) { 87 + throw new ImproperOperationError('incorrect structure', first) 88 + } 89 + for (const op of rest) { 90 + if (!check.is(op, t.def.operation)) { 91 + throw new ImproperOperationError('incorrect structure', op) 92 + } 93 + } 94 + 95 + // ensure the first op is a valid & signed create operation 96 + let doc = await assureValidCreationOp(did, first) 97 + let prev = await cidForCbor(first) 98 + 99 + for (const op of rest) { 100 + if (!op.prev || !CID.parse(op.prev).equals(prev)) { 101 + throw new MisorderedOperationError() 102 + } 103 + 104 + await assureValidSig(doc.rotationKeys, op) 105 + const { signingKey, rotationKeys, handles, services } = op 106 + doc = { did, signingKey, rotationKeys, handles, services } 107 + prev = await cidForCbor(op) 108 + } 109 + 110 + return doc 111 + }
+26 -186
packages/lib/src/document.ts
··· 1 - import { CID } from 'multiformats/cid' 2 1 import * as uint8arrays from 'uint8arrays' 3 - import * as cbor from '@ipld/dag-cbor' 4 - import { check, cidForCbor } from '@atproto/common' 5 2 import * as crypto from '@atproto/crypto' 6 3 import * as t from './types' 7 - 8 - // @TODO fix this 9 - class ServerError extends Error { 10 - constructor(public code: number, msg: string) { 11 - super(msg) 12 - } 13 - } 14 - 15 - export const assureValidNextOp = async ( 16 - did: string, 17 - ops: t.IndexedOperation[], 18 - proposed: t.Operation, 19 - ): Promise<{ nullified: CID[]; prev: CID | null }> => { 20 - // special case if account creation 21 - if (ops.length === 0) { 22 - if (!check.is(proposed, t.def.createOp)) { 23 - throw new ServerError(400, 'Expected first operation to be `create`') 24 - } 25 - await assureValidCreationOp(did, proposed) 26 - return { nullified: [], prev: null } 27 - } 28 - 29 - // ensure we support the proposed key type 30 - if ( 31 - check.is(proposed, t.def.rotateSigningKeyOp) || 32 - check.is(proposed, t.def.rotateRecoveryKeyOp) 33 - ) { 34 - await crypto.parseDidKey(proposed.key) 35 - } 36 - 37 - const proposedPrev = proposed.prev ? CID.parse(proposed.prev) : undefined 38 - if (!proposedPrev) { 39 - throw new ServerError(400, `Invalid prev on operation: ${proposed.prev}`) 40 - } 41 - 42 - const indexOfPrev = ops.findIndex((op) => proposedPrev.equals(op.cid)) 43 - if (indexOfPrev < 0) { 44 - throw new ServerError(409, 'Operations not correctly ordered') 45 - } 46 - 47 - // if we are forking history, these are the ops still in the proposed canonical history 48 - const opsInHistory = ops.slice(0, indexOfPrev + 1) 49 - const nullified = ops.slice(indexOfPrev + 1) 50 - 51 - const doc = await validateOperationLog( 52 - did, 53 - opsInHistory.map((op) => op.operation), 54 - ) 55 - const allowedKeys = 56 - nullified.length === 0 57 - ? [doc.signingKey, doc.recoveryKey] 58 - : [doc.recoveryKey] // only the recovery key is allowed to do historical re-writes 59 - 60 - await assureValidSig(allowedKeys, proposed) 61 - 62 - // recovery key gets a 72hr window to do historical re-wrties 63 - if (nullified.length > 0) { 64 - const RECOVERY_WINDOW = 1000 * 60 * 60 * 72 65 - const firstNullfied = nullified[0] 66 - const timeLapsed = Date.now() - firstNullfied.createdAt.getTime() 67 - if (timeLapsed > RECOVERY_WINDOW) { 68 - throw new ServerError( 69 - 400, 70 - 'Recovery operation occured outside of the allowed 72 hr recovery window', 71 - ) 72 - } 73 - } 74 - 75 - return { 76 - nullified: nullified.map((op) => op.cid), 77 - prev: proposedPrev, 78 - } 79 - } 80 - 81 - export const validateOperationLog = async ( 82 - did: string, 83 - ops: t.Operation[], 84 - ): Promise<t.DocumentData> => { 85 - // make sure they're all validly formatted operations 86 - for (const op of ops) { 87 - if (!check.is(op, t.def.operation)) { 88 - throw new ServerError(400, `Improperly formatted operation: ${op}`) 89 - } 90 - } 91 - 92 - // ensure the first op is a valid & signed create operation 93 - const [first, ...rest] = ops 94 - if (!check.is(first, t.def.createOp)) { 95 - throw new ServerError(400, 'Expected first operation to be `create`') 96 - } 97 - await assureValidCreationOp(did, first) 98 - 99 - // iterate through operations to reconstruct the current state of the document 100 - const doc: t.DocumentData = { 101 - did, 102 - signingKey: first.signingKey, 103 - recoveryKey: first.recoveryKey, 104 - handle: first.handle, 105 - atpPds: first.service, 106 - } 107 - let prev = await cidForCbor(first) 108 - 109 - for (const op of rest) { 110 - if (!op.prev || !CID.parse(op.prev).equals(prev)) { 111 - throw new ServerError(400, 'Operations not correctly ordered') 112 - } 113 - 114 - await assureValidSig([doc.signingKey, doc.recoveryKey], op) 115 - if (check.is(op, t.def.createOp)) { 116 - throw new ServerError(400, 'Unexpected `create` after DID genesis') 117 - } else if (check.is(op, t.def.rotateSigningKeyOp)) { 118 - doc.signingKey = op.key 119 - } else if (check.is(op, t.def.rotateRecoveryKeyOp)) { 120 - doc.recoveryKey = op.key 121 - } else if (check.is(op, t.def.updateHandleOp)) { 122 - doc.handle = op.handle 123 - } else if (check.is(op, t.def.updateAtpPdsOp)) { 124 - doc.atpPds = op.service 125 - } else { 126 - throw new ServerError(400, `Unknown operation: ${JSON.stringify(op)}`) 127 - } 128 - prev = await cidForCbor(op) 129 - } 130 - 131 - return doc 132 - } 133 - 134 - export const hashAndFindDid = async (op: t.CreateOp, truncate = 24) => { 135 - const hashOfGenesis = await crypto.sha256(cbor.encode(op)) 136 - const hashB32 = uint8arrays.toString(hashOfGenesis, 'base32') 137 - const truncated = hashB32.slice(0, truncate) 138 - return `did:plc:${truncated}` 139 - } 140 - 141 - export const assureValidCreationOp = async (did: string, op: t.CreateOp) => { 142 - await assureValidSig([op.signingKey], op) 143 - const expectedDid = await hashAndFindDid(op, 64) 144 - if (!expectedDid.startsWith(did)) { 145 - throw new ServerError( 146 - 400, 147 - `Hash of genesis operation does not match DID identifier: ${expectedDid}`, 148 - ) 149 - } 150 - } 151 - 152 - export const assureValidSig = async ( 153 - allowedDids: string[], 154 - op: t.Operation, 155 - ) => { 156 - const { sig, ...opData } = op 157 - const sigBytes = uint8arrays.fromString(sig, 'base64url') 158 - const dataBytes = new Uint8Array(cbor.encode(opData)) 159 - let isValid = true 160 - for (const did of allowedDids) { 161 - isValid = await crypto.verifySignature(did, dataBytes, sigBytes) 162 - if (isValid) return 163 - } 164 - throw new ServerError(400, `Invalid signature on op: ${JSON.stringify(op)}`) 165 - } 4 + import { UnsupportedKeyError } from './error' 5 + import { ParsedDidKey } from '@atproto/crypto' 166 6 167 7 export const formatDidDoc = (data: t.DocumentData): t.DidDocument => { 168 8 const context = ['https://www.w3.org/ns/did/v1'] 169 9 170 10 const signingKeyInfo = formatKeyAndContext(data.signingKey) 171 - const recoveryKeyInfo = formatKeyAndContext(data.recoveryKey) 172 - const verificationMethods = [signingKeyInfo, recoveryKeyInfo] 173 - verificationMethods.forEach((method) => { 174 - if (!context.includes(method.context)) { 175 - context.push(method.context) 176 - } 177 - }) 11 + if (!context.includes(signingKeyInfo.context)) { 12 + context.push(signingKeyInfo.context) 13 + } 14 + 15 + const alsoKnownAs = data.handles.map((h) => ensureHttpPrefix(h)) 16 + const services: Service[] = [] 17 + if (data.services.atpPds) { 18 + services.push({ 19 + id: `#atpPds`, 20 + type: 'AtpPersonalDataServer', 21 + serviceEndpoint: ensureHttpPrefix(data.services.atpPds), 22 + }) 23 + } 178 24 179 25 return { 180 26 '@context': context, 181 27 id: data.did, 182 - alsoKnownAs: [ensureHttpPrefix(data.handle)], 28 + alsoKnownAs: alsoKnownAs, 183 29 verificationMethod: [ 184 30 { 185 31 id: `#signingKey`, ··· 187 33 controller: data.did, 188 34 publicKeyMultibase: signingKeyInfo.publicKeyMultibase, 189 35 }, 190 - { 191 - id: `#recoveryKey`, 192 - type: recoveryKeyInfo.type, 193 - controller: data.did, 194 - publicKeyMultibase: recoveryKeyInfo.publicKeyMultibase, 195 - }, 196 36 ], 197 37 assertionMethod: [`#signingKey`], 198 38 capabilityInvocation: [`#signingKey`], 199 39 capabilityDelegation: [`#signingKey`], 200 - service: [ 201 - { 202 - id: `#atpPds`, 203 - type: 'AtpPersonalDataServer', 204 - serviceEndpoint: ensureHttpPrefix(data.atpPds), 205 - }, 206 - ], 40 + service: services, 207 41 } 42 + } 43 + 44 + type Service = { 45 + id: string 46 + type: string 47 + serviceEndpoint: string 208 48 } 209 49 210 50 type KeyAndContext = { ··· 214 54 } 215 55 216 56 const formatKeyAndContext = (key: string): KeyAndContext => { 217 - let keyInfo 57 + let keyInfo: ParsedDidKey 218 58 try { 219 59 keyInfo = crypto.parseDidKey(key) 220 60 } catch (err) { 221 - throw new ServerError(400, `Could not parse did:key: ${err}`) 61 + throw new UnsupportedKeyError(key, err) 222 62 } 223 63 const { jwtAlg, keyBytes } = keyInfo 224 64 ··· 235 75 publicKeyMultibase: `z${uint8arrays.toString(keyBytes, 'base58btc')}`, 236 76 } 237 77 } 238 - throw new ServerError(400, `Unsupported key type: ${jwtAlg}`) 78 + throw new UnsupportedKeyError(key, `Unsupported key type: ${jwtAlg}`) 239 79 } 240 80 241 81 export const ensureHttpPrefix = (str: string): string => {
+58
packages/lib/src/error.ts
··· 1 + export class PlcError extends Error { 2 + plcError = true 3 + constructor(msg: string) { 4 + super(msg) 5 + } 6 + 7 + static is(obj: unknown): obj is PlcError { 8 + if (obj && typeof obj === 'object' && obj['plcError'] === true) { 9 + return true 10 + } 11 + return false 12 + } 13 + } 14 + export class ImproperOperationError extends PlcError { 15 + constructor(public reason: string, public op: unknown) { 16 + super(`Improperly formatted operation, ${reason}: ${op}`) 17 + } 18 + } 19 + 20 + export class MisorderedOperationError extends PlcError { 21 + constructor() { 22 + super('Operations not correctly ordered') 23 + } 24 + } 25 + 26 + export class LateRecoveryError extends PlcError { 27 + constructor(public timeLapsed: number) { 28 + super( 29 + `Recovery operation occured outside of the allowed 72 hr recovery window. Time lapsed: ${timeLapsed}`, 30 + ) 31 + } 32 + } 33 + 34 + export class GenesisHashError extends PlcError { 35 + constructor(public expected: string) { 36 + super( 37 + `Hash of genesis operation does not match DID identifier: ${expected}`, 38 + ) 39 + } 40 + } 41 + 42 + export class InvalidSignatureError extends PlcError { 43 + constructor(public op: unknown) { 44 + super(`Invalid signature on op: ${JSON.stringify(op)}`) 45 + } 46 + } 47 + 48 + export class UnsupportedKeyError extends PlcError { 49 + constructor(public key: string, public err: unknown) { 50 + super(`Unsupported key type ${key}: ${err}`) 51 + } 52 + } 53 + 54 + export class ImproperlyFormattedDidError extends PlcError { 55 + constructor(public reason: string) { 56 + super(`Improperly formatted did: ${reason}`) 57 + } 58 + }
+5 -3
packages/lib/src/index.ts
··· 1 - export * as document from './document' 2 - export * as operations from './operations' 3 - export * from './types' 4 1 export * from './client' 2 + export * from './data' 3 + export * from './document' 4 + export * from './error' 5 + export * from './operations' 6 + export * from './types'
+79 -56
packages/lib/src/operations.ts
··· 1 1 import * as cbor from '@ipld/dag-cbor' 2 2 import * as uint8arrays from 'uint8arrays' 3 - import { Keypair, sha256 } from '@atproto/crypto' 3 + import { Keypair, parseDidKey, sha256, verifySignature } from '@atproto/crypto' 4 4 import * as t from './types' 5 + import { check } from '@atproto/common' 6 + import { 7 + GenesisHashError, 8 + ImproperlyFormattedDidError, 9 + ImproperOperationError, 10 + InvalidSignatureError, 11 + UnsupportedKeyError, 12 + } from './error' 5 13 6 - export const didForCreateOp = async (op: t.CreateOp, truncate = 24) => { 14 + export const didForCreateOp = async (op: t.CompatibleOp, truncate = 24) => { 7 15 const hashOfGenesis = await sha256(cbor.encode(op)) 8 16 const hashB32 = uint8arrays.toString(hashOfGenesis, 'base32') 9 17 const truncated = hashB32.slice(0, truncate) ··· 22 30 } 23 31 } 24 32 25 - export const create = async ( 33 + export const deprecatedSignCreate = async ( 34 + op: t.UnsignedCreateOpV1, 26 35 signingKey: Keypair, 27 - recoveryKey: string, 28 - handle: string, 29 - service: string, 30 - ): Promise<t.CreateOp> => { 31 - const op: t.UnsignedCreateOp = { 32 - type: 'create', 33 - signingKey: signingKey.did(), 34 - recoveryKey, 35 - handle, 36 - service, 37 - prev: null, 36 + ): 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'), 38 42 } 39 - const signed = await signOperation(op, signingKey) 40 - return signed as t.CreateOp 41 43 } 42 44 43 - export const rotateSigningKey = async ( 44 - newKey: string, 45 - prev: string, 46 - signingKey: Keypair, 47 - ): Promise<t.Operation> => { 48 - const op: t.UnsignedRotateSigningKeyOp = { 49 - type: 'rotate_signing_key', 50 - key: newKey, 51 - prev, 45 + export const normalizeOp = (op: t.CompatibleOp): t.Operation => { 46 + if (check.is(op, t.def.operation)) { 47 + return op 52 48 } 53 - return signOperation(op, signingKey) 49 + return { 50 + signingKey: op.signingKey, 51 + rotationKeys: [op.recoveryKey, op.signingKey], 52 + handles: [op.handle], 53 + services: { 54 + atpPds: op.service, 55 + }, 56 + prev: op.prev, 57 + sig: op.sig, 58 + } 54 59 } 55 60 56 - export const rotateRecoveryKey = async ( 57 - newKey: string, 58 - prev: string, 59 - signingKey: Keypair, 60 - ): Promise<t.Operation> => { 61 - const op: t.UnsignedRotateRecoveryKeyOp = { 62 - type: 'rotate_recovery_key', 63 - key: newKey, 64 - prev, 61 + export const assureValidOp = async (op: t.Operation) => { 62 + // ensure we support the op's keys 63 + const keys = [op.signingKey, ...op.rotationKeys] 64 + await Promise.all( 65 + keys.map(async (k) => { 66 + try { 67 + parseDidKey(k) 68 + } catch (err) { 69 + throw new UnsupportedKeyError(k, err) 70 + } 71 + }), 72 + ) 73 + if (op.rotationKeys.length > 5) { 74 + throw new ImproperOperationError('too many rotation keys', op) 75 + } else if (op.rotationKeys.length < 1) { 76 + throw new ImproperOperationError('need at least one rotation key', op) 65 77 } 66 - return signOperation(op, signingKey) 67 78 } 68 79 69 - export const updateHandle = async ( 70 - handle: string, 71 - prev: string, 72 - signingKey: Keypair, 73 - ): Promise<t.Operation> => { 74 - const op: t.UnsignedUpdateHandleOp = { 75 - type: 'update_handle', 76 - handle, 77 - prev, 80 + export const assureValidCreationOp = async ( 81 + did: string, 82 + op: t.CompatibleOp, 83 + ): Promise<t.DocumentData> => { 84 + const normalized = normalizeOp(op) 85 + await assureValidOp(normalized) 86 + await assureValidSig(normalized.rotationKeys, op) 87 + const expectedDid = await didForCreateOp(op, 64) 88 + // id must be >=24 chars & prefix is 8chars 89 + if (did.length < 32) { 90 + throw new ImproperlyFormattedDidError('too short') 91 + } 92 + if (!expectedDid.startsWith(did)) { 93 + throw new GenesisHashError(expectedDid) 94 + } 95 + if (op.prev !== null) { 96 + throw new ImproperOperationError('expected null prev on create', op) 78 97 } 79 - return signOperation(op, signingKey) 98 + const { signingKey, rotationKeys, handles, services } = normalized 99 + return { did, signingKey, rotationKeys, handles, services } 80 100 } 81 101 82 - export const updateAtpPds = async ( 83 - service: string, 84 - prev: string, 85 - signingKey: Keypair, 86 - ): Promise<t.Operation> => { 87 - const op: t.UnsignedUpdateAtpPdsOp = { 88 - type: 'update_atp_pds', 89 - service, 90 - prev, 102 + export const assureValidSig = async ( 103 + allowedDids: string[], 104 + op: t.CompatibleOp, 105 + ): Promise<string> => { 106 + const { sig, ...opData } = op 107 + const sigBytes = uint8arrays.fromString(sig, 'base64url') 108 + const dataBytes = new Uint8Array(cbor.encode(opData)) 109 + for (const did of allowedDids) { 110 + const isValid = await verifySignature(did, dataBytes, sigBytes) 111 + if (isValid) { 112 + return did 113 + } 91 114 } 92 - return signOperation(op, signingKey) 115 + throw new InvalidSignatureError(op) 93 116 }
+25 -88
packages/lib/src/types.ts
··· 11 11 const documentData = z.object({ 12 12 did: z.string(), 13 13 signingKey: z.string(), 14 - recoveryKey: z.string(), 15 - handle: z.string(), 16 - atpPds: z.string(), 14 + rotationKeys: z.array(z.string()), 15 + handles: z.array(z.string()), 16 + services: z.object({ 17 + atpPds: z.string().optional(), 18 + }), 17 19 }) 18 20 export type DocumentData = z.infer<typeof documentData> 19 21 20 - const unsignedCreateOp = z.object({ 22 + const unsignedCreateOpV1 = z.object({ 21 23 type: z.literal('create'), 22 24 signingKey: z.string(), 23 25 recoveryKey: z.string(), ··· 25 27 service: z.string(), 26 28 prev: z.null(), 27 29 }) 28 - export type UnsignedCreateOp = z.infer<typeof unsignedCreateOp> 29 - const createOp = unsignedCreateOp.extend({ sig: z.string() }) 30 - export type CreateOp = z.infer<typeof createOp> 30 + export type UnsignedCreateOpV1 = z.infer<typeof unsignedCreateOpV1> 31 + const createOpV1 = unsignedCreateOpV1.extend({ sig: z.string() }) 32 + export type CreateOpV1 = z.infer<typeof createOpV1> 31 33 32 - const unsignedRotateSigningKeyOp = z.object({ 33 - type: z.literal('rotate_signing_key'), 34 - key: z.string(), 35 - prev: z.string(), 34 + const unsignedOperation = z.object({ 35 + signingKey: z.string(), 36 + rotationKeys: z.array(z.string()), 37 + handles: z.array(z.string()), 38 + services: z.object({ 39 + atpPds: z.string().optional(), 40 + }), 41 + prev: z.string().nullable(), 36 42 }) 37 - export type UnsignedRotateSigningKeyOp = z.infer< 38 - typeof unsignedRotateSigningKeyOp 39 - > 40 - const rotateSigningKeyOp = unsignedRotateSigningKeyOp.extend({ 41 - sig: z.string(), 42 - }) 43 - export type RotateSigningKeyOp = z.infer<typeof rotateSigningKeyOp> 44 - 45 - const unsignedRotateRecoveryKeyOp = z.object({ 46 - type: z.literal('rotate_recovery_key'), 47 - key: z.string(), 48 - prev: z.string(), 49 - }) 50 - export type UnsignedRotateRecoveryKeyOp = z.infer< 51 - typeof unsignedRotateRecoveryKeyOp 52 - > 53 - const rotateRecoveryKeyOp = unsignedRotateRecoveryKeyOp.extend({ 54 - sig: z.string(), 55 - }) 56 - export type RotateRecoveryKeyOp = z.infer<typeof rotateRecoveryKeyOp> 57 - 58 - const unsignedUpdateHandleOp = z.object({ 59 - type: z.literal('update_handle'), 60 - handle: z.string(), 61 - prev: z.string(), 62 - }) 63 - export type UnsignedUpdateHandleOp = z.infer<typeof unsignedUpdateHandleOp> 64 - const updateHandleOp = unsignedUpdateHandleOp.extend({ 65 - sig: z.string(), 66 - }) 67 - export type UpdateHandleOp = z.infer<typeof updateHandleOp> 68 - 69 - const unsignedUpdateAtpPdsOp = z.object({ 70 - type: z.literal('update_atp_pds'), 71 - service: z.string(), 72 - prev: z.string(), 73 - }) 74 - export type UnsignedUpdateAtpPdsOp = z.infer<typeof unsignedUpdateAtpPdsOp> 75 - const updateAtpPdsOp = unsignedUpdateAtpPdsOp.extend({ 76 - sig: z.string(), 77 - }) 78 - export type UpdateAtpPdsOp = z.infer<typeof updateAtpPdsOp> 79 - 80 - const updateOperation = z.union([ 81 - rotateSigningKeyOp, 82 - rotateRecoveryKeyOp, 83 - updateHandleOp, 84 - updateAtpPdsOp, 85 - ]) 86 - export type UpdateOperation = z.infer<typeof updateOperation> 87 - 88 - const operation = z.union([createOp, updateOperation]) 43 + export type UnsignedOperation = z.infer<typeof unsignedOperation> 44 + const operation = unsignedOperation.extend({ sig: z.string() }) 89 45 export type Operation = z.infer<typeof operation> 90 46 91 - const unsignedUpdateOperation = z.union([ 92 - unsignedRotateSigningKeyOp, 93 - unsignedRotateRecoveryKeyOp, 94 - unsignedUpdateHandleOp, 95 - unsignedUpdateAtpPdsOp, 96 - ]) 97 - export type UnsignedUpdateOperation = z.infer<typeof unsignedUpdateOperation> 98 - const unsignedOperation = z.union([unsignedCreateOp, unsignedUpdateOperation]) 99 - export type UnsignedOperation = z.infer<typeof unsignedOperation> 100 - 47 + const compatibleOp = z.union([createOpV1, operation]) 48 + export type CompatibleOp = z.infer<typeof compatibleOp> 101 49 export const indexedOperation = z.object({ 102 50 did: z.string(), 103 - operation: operation, 51 + operation: compatibleOp, 104 52 cid: cid, 105 53 nullified: z.boolean(), 106 54 createdAt: z.date(), ··· 134 82 135 83 export const def = { 136 84 documentData, 137 - unsignedCreateOp, 138 - createOp, 139 - unsignedRotateSigningKeyOp, 140 - rotateSigningKeyOp, 141 - unsignedRotateRecoveryKeyOp, 142 - rotateRecoveryKeyOp, 143 - unsignedUpdateHandleOp, 144 - updateHandleOp, 145 - unsignedUpdateAtpPdsOp, 146 - updateAtpPdsOp, 147 - updateOperation, 85 + createOpV1, 86 + unsignedOperation, 148 87 operation, 149 - unsignedUpdateOperation, 150 - unsignedOperation, 151 - indexedOperation, 88 + compatibleOp, 152 89 didDocument, 153 90 }
+83
packages/lib/tests/compatibility.test.ts
··· 1 + import { cidForCbor, DAY } from '@atproto/common' 2 + import { Secp256k1Keypair } from '@atproto/crypto' 3 + import { 4 + assureValidNextOp, 5 + CreateOpV1, 6 + deprecatedSignCreate, 7 + didForCreateOp, 8 + normalizeOp, 9 + signOperation, 10 + validateOperationLog, 11 + } from '../src' 12 + 13 + describe('compatibility', () => { 14 + let signingKey: Secp256k1Keypair 15 + let recoveryKey: Secp256k1Keypair 16 + const handle = 'alice.test' 17 + const service = 'https://example.com' 18 + let did: string 19 + 20 + let legacyOp: CreateOpV1 21 + 22 + beforeAll(async () => { 23 + signingKey = await Secp256k1Keypair.create() 24 + recoveryKey = await Secp256k1Keypair.create() 25 + }) 26 + 27 + it('normalizes legacy create ops', async () => { 28 + legacyOp = await deprecatedSignCreate( 29 + { 30 + type: 'create', 31 + signingKey: signingKey.did(), 32 + recoveryKey: recoveryKey.did(), 33 + handle, 34 + service, 35 + prev: null, 36 + }, 37 + signingKey, 38 + ) 39 + 40 + did = await didForCreateOp(legacyOp) 41 + 42 + const normalized = normalizeOp(legacyOp) 43 + expect(normalized).toEqual({ 44 + signingKey: signingKey.did(), 45 + rotationKeys: [recoveryKey.did(), signingKey.did()], 46 + handles: [handle], 47 + services: { 48 + atpPds: service, 49 + }, 50 + prev: null, 51 + sig: legacyOp.sig, 52 + }) 53 + }) 54 + 55 + it('validates a log with a legacy create op', async () => { 56 + const legacyCid = await cidForCbor(legacyOp) 57 + const newSigner = await Secp256k1Keypair.create() 58 + const newRotater = await Secp256k1Keypair.create() 59 + const nextOp = await signOperation( 60 + { 61 + signingKey: newSigner.did(), 62 + rotationKeys: [newRotater.did()], 63 + handles: [handle], 64 + services: { atpPds: service }, 65 + prev: legacyCid.toString(), 66 + }, 67 + signingKey, 68 + ) 69 + await validateOperationLog(did, [legacyOp, nextOp]) 70 + 71 + const indexedLegacy = { 72 + did, 73 + operation: legacyOp, 74 + cid: legacyCid, 75 + nullified: false, 76 + createdAt: new Date(Date.now() - 7 * DAY), 77 + } 78 + 79 + const result = await assureValidNextOp(did, [indexedLegacy], nextOp) 80 + expect(result.nullified.length).toBe(0) 81 + expect(result.prev?.equals(legacyCid)) 82 + }) 83 + })
+239
packages/lib/tests/data.test.ts
··· 1 + import { check, cidForCbor } from '@atproto/common' 2 + import { EcdsaKeypair, Keypair, Secp256k1Keypair } from '@atproto/crypto' 3 + import { 4 + GenesisHashError, 5 + ImproperOperationError, 6 + InvalidSignatureError, 7 + MisorderedOperationError, 8 + } from '../src' 9 + import * as data from '../src/data' 10 + import * as operations from '../src/operations' 11 + import * as t from '../src/types' 12 + 13 + describe('plc did data', () => { 14 + const ops: t.Operation[] = [] 15 + 16 + let signingKey: Secp256k1Keypair 17 + let rotationKey1: Secp256k1Keypair 18 + let rotationKey2: EcdsaKeypair 19 + let did: string 20 + let handle = 'alice.example.com' 21 + let atpPds = 'https://example.com' 22 + 23 + let oldRotationKey1: Secp256k1Keypair 24 + 25 + beforeAll(async () => { 26 + signingKey = await Secp256k1Keypair.create() 27 + rotationKey1 = await Secp256k1Keypair.create() 28 + rotationKey2 = await EcdsaKeypair.create() 29 + }) 30 + 31 + const makeNextOp = async ( 32 + changes: Partial<t.Operation>, 33 + key: Keypair, 34 + ): Promise<t.Operation> => { 35 + const lastOp = ops.at(-1) 36 + if (!lastOp) { 37 + throw new Error('expected an op on log') 38 + } 39 + const prev = await cidForCbor(lastOp) 40 + return operations.signOperation( 41 + { 42 + signingKey: lastOp.signingKey, 43 + rotationKeys: lastOp.rotationKeys, 44 + handles: lastOp.handles, 45 + services: lastOp.services, 46 + prev: prev.toString(), 47 + ...changes, 48 + }, 49 + key, 50 + ) 51 + } 52 + 53 + it('creates a valid create op', async () => { 54 + const createOp = await operations.signOperation( 55 + { 56 + signingKey: signingKey.did(), 57 + rotationKeys: [rotationKey1.did(), rotationKey2.did()], 58 + handles: [handle], 59 + services: { 60 + atpPds, 61 + }, 62 + prev: null, 63 + }, 64 + rotationKey1, 65 + ) 66 + const isValid = check.is(createOp, t.def.operation) 67 + expect(isValid).toBeTruthy() 68 + ops.push(createOp) 69 + did = await operations.didForCreateOp(createOp) 70 + }) 71 + 72 + it('parses an operation log with no updates', async () => { 73 + const doc = await data.validateOperationLog(did, ops) 74 + 75 + expect(doc.did).toEqual(did) 76 + expect(doc.signingKey).toEqual(signingKey.did()) 77 + expect(doc.rotationKeys).toEqual([rotationKey1.did(), rotationKey2.did()]) 78 + expect(doc.handles).toEqual([handle]) 79 + expect(doc.services).toEqual({ atpPds }) 80 + }) 81 + 82 + it('updates handle', async () => { 83 + handle = 'ali.example2.com' 84 + const op = await makeNextOp({ handles: [handle] }, rotationKey1) 85 + ops.push(op) 86 + 87 + const doc = await data.validateOperationLog(did, ops) 88 + expect(doc.did).toEqual(did) 89 + expect(doc.signingKey).toEqual(signingKey.did()) 90 + expect(doc.rotationKeys).toEqual([rotationKey1.did(), rotationKey2.did()]) 91 + expect(doc.handles).toEqual([handle]) 92 + expect(doc.services).toEqual({ atpPds }) 93 + }) 94 + 95 + it('updates atpPds', async () => { 96 + atpPds = 'https://example2.com' 97 + const op = await makeNextOp( 98 + { 99 + services: { 100 + atpPds, 101 + }, 102 + }, 103 + rotationKey1, 104 + ) 105 + ops.push(op) 106 + 107 + const doc = await data.validateOperationLog(did, ops) 108 + expect(doc.did).toEqual(did) 109 + expect(doc.signingKey).toEqual(signingKey.did()) 110 + expect(doc.rotationKeys).toEqual([rotationKey1.did(), rotationKey2.did()]) 111 + expect(doc.handles).toEqual([handle]) 112 + expect(doc.services).toEqual({ atpPds }) 113 + }) 114 + 115 + it('rotates signingKey', async () => { 116 + const newSigningKey = await Secp256k1Keypair.create() 117 + const op = await makeNextOp( 118 + { 119 + signingKey: newSigningKey.did(), 120 + }, 121 + rotationKey1, 122 + ) 123 + ops.push(op) 124 + 125 + signingKey = newSigningKey 126 + 127 + const doc = await data.validateOperationLog(did, ops) 128 + expect(doc.did).toEqual(did) 129 + expect(doc.signingKey).toEqual(signingKey.did()) 130 + expect(doc.rotationKeys).toEqual([rotationKey1.did(), rotationKey2.did()]) 131 + expect(doc.handles).toEqual([handle]) 132 + expect(doc.services).toEqual({ atpPds }) 133 + }) 134 + 135 + it('rotates rotation keys', async () => { 136 + const newRotationKey = await Secp256k1Keypair.create() 137 + const op = await makeNextOp( 138 + { 139 + rotationKeys: [newRotationKey.did(), rotationKey2.did()], 140 + }, 141 + rotationKey1, 142 + ) 143 + ops.push(op) 144 + 145 + oldRotationKey1 = rotationKey1 146 + rotationKey1 = newRotationKey 147 + 148 + const doc = await data.validateOperationLog(did, ops) 149 + expect(doc.did).toEqual(did) 150 + expect(doc.signingKey).toEqual(signingKey.did()) 151 + expect(doc.rotationKeys).toEqual([rotationKey1.did(), rotationKey2.did()]) 152 + expect(doc.handles).toEqual([handle]) 153 + expect(doc.services).toEqual({ atpPds }) 154 + }) 155 + 156 + it('no longer allows operations from old rotation key', async () => { 157 + const op = await makeNextOp( 158 + { 159 + handles: ['bob'], 160 + }, 161 + oldRotationKey1, 162 + ) 163 + expect(data.validateOperationLog(did, [...ops, op])).rejects.toThrow( 164 + InvalidSignatureError, 165 + ) 166 + }) 167 + 168 + it('does not allow operations from the signingKey', async () => { 169 + const op = await makeNextOp( 170 + { 171 + handles: ['bob'], 172 + }, 173 + signingKey, 174 + ) 175 + expect(data.validateOperationLog(did, [...ops, op])).rejects.toThrow( 176 + InvalidSignatureError, 177 + ) 178 + }) 179 + 180 + it('allows for operations from either rotation key', async () => { 181 + const newHandle = 'ali.example.com' 182 + const op = await makeNextOp( 183 + { 184 + handles: [newHandle], 185 + }, 186 + rotationKey2, 187 + ) 188 + ops.push(op) 189 + handle = newHandle 190 + const doc = await data.validateOperationLog(did, ops) 191 + expect(doc.did).toEqual(did) 192 + expect(doc.signingKey).toEqual(signingKey.did()) 193 + expect(doc.rotationKeys).toEqual([rotationKey1.did(), rotationKey2.did()]) 194 + expect(doc.handles).toEqual([handle]) 195 + expect(doc.services).toEqual({ atpPds }) 196 + }) 197 + 198 + it('requires operations to be in order', async () => { 199 + const prev = await cidForCbor(ops[ops.length - 2]) 200 + const op = await makeNextOp( 201 + { 202 + handles: ['bob.test'], 203 + prev: prev.toString(), 204 + }, 205 + rotationKey1, 206 + ) 207 + expect(data.validateOperationLog(did, [...ops, op])).rejects.toThrow( 208 + MisorderedOperationError, 209 + ) 210 + }) 211 + 212 + it('does not allow a create operation in the middle of the log', async () => { 213 + const op = await makeNextOp( 214 + { 215 + handles: ['bob.test'], 216 + prev: null, 217 + }, 218 + rotationKey1, 219 + ) 220 + expect(data.validateOperationLog(did, [...ops, op])).rejects.toThrow( 221 + MisorderedOperationError, 222 + ) 223 + }) 224 + 225 + it('requires that the did is the hash of the genesis op', async () => { 226 + const rest = ops.slice(1) 227 + expect(data.validateOperationLog(did, rest)).rejects.toThrow( 228 + GenesisHashError, 229 + ) 230 + }) 231 + 232 + it('requires that the log starts with a create op (no prev)', async () => { 233 + const rest = ops.slice(1) 234 + const expectedDid = await operations.didForCreateOp(rest[0]) 235 + expect(data.validateOperationLog(expectedDid, rest)).rejects.toThrow( 236 + ImproperOperationError, 237 + ) 238 + }) 239 + })
+70 -255
packages/lib/tests/document.test.ts
··· 1 - import { check, cidForCbor } from '@atproto/common' 1 + import * as uint8arrays from 'uint8arrays' 2 2 import { EcdsaKeypair, parseDidKey, Secp256k1Keypair } from '@atproto/crypto' 3 - import * as uint8arrays from 'uint8arrays' 4 3 import * as document from '../src/document' 5 - import * as operations from '../src/operations' 6 4 import * as t from '../src/types' 7 5 8 - describe('plc DID document', () => { 9 - const ops: t.Operation[] = [] 10 - 11 - let signingKey: EcdsaKeypair 12 - let recoveryKey: Secp256k1Keypair 13 - let did: string 14 - let handle = 'alice.example.com' 15 - let atpPds = 'https://example.com' 16 - 17 - let oldSigningKey: EcdsaKeypair 18 - let oldRecoveryKey: Secp256k1Keypair 19 - 20 - beforeAll(async () => { 21 - signingKey = await EcdsaKeypair.create() 22 - recoveryKey = await Secp256k1Keypair.create() 23 - }) 24 - 25 - it('creates a valid create op', async () => { 26 - const createOp = await operations.create( 27 - signingKey, 28 - recoveryKey.did(), 29 - handle, 30 - atpPds, 31 - ) 32 - const isValid = check.is(createOp, t.def.createOp) 33 - expect(isValid).toBeTruthy() 34 - ops.push(createOp) 35 - did = await operations.didForCreateOp(createOp) 36 - }) 37 - 38 - it('parses an operation log with no updates', async () => { 39 - const doc = await document.validateOperationLog(did, ops) 40 - 41 - expect(doc.did).toEqual(did) 42 - expect(doc.signingKey).toEqual(signingKey.did()) 43 - expect(doc.recoveryKey).toEqual(recoveryKey.did()) 44 - expect(doc.handle).toEqual(handle) 45 - expect(doc.atpPds).toEqual(atpPds) 46 - }) 47 - 48 - it('allows for updating handle', async () => { 49 - handle = 'ali.example2.com' 50 - const prev = await cidForCbor(ops[ops.length - 1]) 51 - const op = await operations.updateHandle( 52 - handle, 53 - prev.toString(), 54 - signingKey, 55 - ) 56 - ops.push(op) 57 - 58 - const doc = await document.validateOperationLog(did, ops) 59 - expect(doc.did).toEqual(did) 60 - expect(doc.signingKey).toEqual(signingKey.did()) 61 - expect(doc.recoveryKey).toEqual(recoveryKey.did()) 62 - expect(doc.handle).toEqual(handle) 63 - expect(doc.atpPds).toEqual(atpPds) 64 - }) 65 - 66 - it('allows for updating atpPds', async () => { 67 - atpPds = 'https://example2.com' 68 - const prev = await cidForCbor(ops[ops.length - 1]) 69 - const op = await operations.updateAtpPds( 70 - atpPds, 71 - prev.toString(), 72 - signingKey, 73 - ) 74 - ops.push(op) 75 - 76 - const doc = await document.validateOperationLog(did, ops) 77 - expect(doc.did).toEqual(did) 78 - expect(doc.signingKey).toEqual(signingKey.did()) 79 - expect(doc.recoveryKey).toEqual(recoveryKey.did()) 80 - expect(doc.handle).toEqual(handle) 81 - expect(doc.atpPds).toEqual(atpPds) 82 - }) 83 - 84 - it('allows for rotating signingKey', async () => { 85 - const newSigningKey = await EcdsaKeypair.create() 86 - const prev = await cidForCbor(ops[ops.length - 1]) 87 - const op = await operations.rotateSigningKey( 88 - newSigningKey.did(), 89 - prev.toString(), 90 - signingKey, 91 - ) 92 - ops.push(op) 93 - oldSigningKey = signingKey 94 - signingKey = newSigningKey 95 - 96 - const doc = await document.validateOperationLog(did, ops) 97 - expect(doc.did).toEqual(did) 98 - expect(doc.signingKey).toEqual(signingKey.did()) 99 - expect(doc.recoveryKey).toEqual(recoveryKey.did()) 100 - expect(doc.handle).toEqual(handle) 101 - expect(doc.atpPds).toEqual(atpPds) 102 - }) 103 - 104 - it('no longer allows operations from old signing key', async () => { 105 - const prev = await cidForCbor(ops[ops.length - 1]) 106 - const op = await operations.updateHandle( 107 - 'bob', 108 - prev.toString(), 109 - oldSigningKey, 110 - ) 111 - expect(document.validateOperationLog(did, [...ops, op])).rejects.toThrow() 112 - }) 113 - 114 - it('allows for rotating recoveryKey', async () => { 115 - const newRecoveryKey = await Secp256k1Keypair.create() 116 - const prev = await cidForCbor(ops[ops.length - 1]) 117 - const op = await operations.rotateRecoveryKey( 118 - newRecoveryKey.did(), 119 - prev.toString(), 120 - signingKey, 121 - ) 122 - ops.push(op) 123 - oldRecoveryKey = recoveryKey 124 - recoveryKey = newRecoveryKey 125 - 126 - const doc = await document.validateOperationLog(did, ops) 127 - expect(doc.did).toEqual(did) 128 - expect(doc.signingKey).toEqual(signingKey.did()) 129 - expect(doc.recoveryKey).toEqual(recoveryKey.did()) 130 - expect(doc.handle).toEqual(handle) 131 - expect(doc.atpPds).toEqual(atpPds) 132 - }) 133 - 134 - it('no longer allows operations from old recovery key', async () => { 135 - const prev = await cidForCbor(ops[ops.length - 1]) 136 - const op = await operations.updateHandle( 137 - 'bob', 138 - prev.toString(), 139 - oldRecoveryKey, 140 - ) 141 - expect(document.validateOperationLog(did, [...ops, op])).rejects.toThrow() 142 - }) 143 - 144 - it('it allows recovery key to rotate signing key', async () => { 145 - const newKey = await EcdsaKeypair.create() 146 - const prev = await cidForCbor(ops[ops.length - 1]) 147 - const op = await operations.rotateSigningKey( 148 - newKey.did(), 149 - prev.toString(), 150 - recoveryKey, 151 - ) 152 - ops.push(op) 153 - signingKey = newKey 154 - const doc = await document.validateOperationLog(did, ops) 155 - expect(doc.signingKey).toEqual(newKey.did()) 156 - }) 157 - 158 - it('it allows recovery key to rotate recovery key', async () => { 159 - const newKey = await Secp256k1Keypair.create() 160 - const prev = await cidForCbor(ops[ops.length - 1]) 161 - const op = await operations.rotateRecoveryKey( 162 - newKey.did(), 163 - prev.toString(), 164 - recoveryKey, 165 - ) 166 - ops.push(op) 167 - recoveryKey = newKey 168 - const doc = await document.validateOperationLog(did, ops) 169 - expect(doc.recoveryKey).toEqual(newKey.did()) 170 - }) 171 - 172 - it('it allows recovery key to update handle', async () => { 173 - handle = 'ally.example3.com' 174 - const prev = await cidForCbor(ops[ops.length - 1]) 175 - const op = await operations.updateHandle( 176 - handle, 177 - prev.toString(), 178 - recoveryKey, 179 - ) 180 - ops.push(op) 181 - const doc = await document.validateOperationLog(did, ops) 182 - expect(doc.handle).toEqual(handle) 183 - }) 184 - 185 - it('it allows recovery key to update atpPds', async () => { 186 - atpPds = 'https://example3.com' 187 - const prev = await cidForCbor(ops[ops.length - 1]) 188 - const op = await operations.updateAtpPds( 189 - atpPds, 190 - prev.toString(), 191 - recoveryKey, 192 - ) 193 - ops.push(op) 194 - const doc = await document.validateOperationLog(did, ops) 195 - expect(doc.atpPds).toEqual(atpPds) 196 - }) 197 - 198 - it('requires operations to be in order', async () => { 199 - const prev = await cidForCbor(ops[ops.length - 2]) 200 - const op = await operations.updateAtpPds( 201 - 'foobar.com', 202 - prev.toString(), 203 - signingKey, 204 - ) 205 - expect(document.validateOperationLog(did, [...ops, op])).rejects.toThrow() 206 - }) 207 - 208 - it('does not allow a create operation in the middle of the log', async () => { 209 - const op = await operations.create( 210 - signingKey, 211 - recoveryKey.did(), 212 - handle, 213 - atpPds, 214 - ) 215 - expect(document.validateOperationLog(did, [...ops, op])).rejects.toThrow() 216 - }) 217 - 218 - it('requires that the log start with a create operation', async () => { 219 - const rest = ops.slice(1) 220 - expect(document.validateOperationLog(did, rest)).rejects.toThrow() 221 - }) 222 - 6 + describe('document', () => { 223 7 it('formats a valid DID document', async () => { 224 - const data = await document.validateOperationLog(did, ops) 8 + const signingKey = await Secp256k1Keypair.create() 9 + const rotate1 = await Secp256k1Keypair.create() 10 + const rotate2 = await EcdsaKeypair.create() 11 + const handles = ['alice.test', 'bob.test'] 12 + const atpPds = 'https://example.com' 13 + const data: t.DocumentData = { 14 + did: 'did:example:alice', 15 + signingKey: signingKey.did(), 16 + rotationKeys: [rotate1.did(), rotate2.did()], 17 + handles, 18 + services: { 19 + atpPds, 20 + }, 21 + } 225 22 const doc = await document.formatDidDoc(data) 226 23 expect(doc['@context']).toEqual([ 227 24 'https://www.w3.org/ns/did/v1', 228 - 'https://w3id.org/security/suites/ecdsa-2019/v1', 229 25 'https://w3id.org/security/suites/secp256k1-2019/v1', 230 26 ]) 231 - expect(doc.id).toEqual(did) 232 - expect(doc.alsoKnownAs).toEqual([`https://${handle}`]) 233 - 234 - expect(doc.verificationMethod.length).toBe(2) 27 + expect(doc.id).toEqual(data.did) 28 + const formattedHandles = handles.map((h) => `https://${h}`) 29 + expect(doc.alsoKnownAs).toEqual(formattedHandles) 30 + expect(doc.verificationMethod.length).toBe(1) 235 31 expect(doc.verificationMethod[0].id).toEqual('#signingKey') 236 32 expect(doc.verificationMethod[0].type).toEqual( 237 - 'EcdsaSecp256r1VerificationKey2019', 33 + 'EcdsaSecp256k1VerificationKey2019', 238 34 ) 239 - expect(doc.verificationMethod[0].controller).toEqual(did) 35 + expect(doc.verificationMethod[0].controller).toEqual(data.did) 240 36 const parsedSigningKey = parseDidKey(signingKey.did()) 241 37 const signingKeyMultibase = 242 38 'z' + uint8arrays.toString(parsedSigningKey.keyBytes, 'base58btc') 243 39 expect(doc.verificationMethod[0].publicKeyMultibase).toEqual( 244 40 signingKeyMultibase, 245 41 ) 246 - expect(doc.verificationMethod[1].id).toEqual('#recoveryKey') 247 - expect(doc.verificationMethod[1].type).toEqual( 248 - 'EcdsaSecp256k1VerificationKey2019', 249 - ) 250 - expect(doc.verificationMethod[1].controller).toEqual(did) 251 - const parsedRecoveryKey = parseDidKey(recoveryKey.did()) 252 - const recoveryKeyMultibase = 253 - 'z' + uint8arrays.toString(parsedRecoveryKey.keyBytes, 'base58btc') 254 - expect(doc.verificationMethod[1].publicKeyMultibase).toEqual( 255 - recoveryKeyMultibase, 256 - ) 257 - 258 42 expect(doc.assertionMethod).toEqual(['#signingKey']) 259 43 expect(doc.capabilityInvocation).toEqual(['#signingKey']) 260 44 expect(doc.capabilityDelegation).toEqual(['#signingKey']) ··· 264 48 expect(doc.service[0].serviceEndpoint).toEqual(atpPds) 265 49 }) 266 50 267 - it('formats a valid DID document regardless of leading https://', async () => { 268 - handle = 'https://alice.example.com' 269 - const prev = await cidForCbor(ops[ops.length - 1]) 270 - const op1 = await operations.updateHandle( 271 - handle, 272 - prev.toString(), 273 - signingKey, 51 + it('handles P-256 keys', async () => { 52 + const signingKey = await EcdsaKeypair.create() 53 + const rotate1 = await Secp256k1Keypair.create() 54 + const rotate2 = await EcdsaKeypair.create() 55 + const handles = ['alice.test', 'bob.test'] 56 + const atpPds = 'https://example.com' 57 + const data: t.DocumentData = { 58 + did: 'did:example:alice', 59 + signingKey: signingKey.did(), 60 + rotationKeys: [rotate1.did(), rotate2.did()], 61 + handles, 62 + services: { 63 + atpPds, 64 + }, 65 + } 66 + const doc = await document.formatDidDoc(data) 67 + expect(doc.verificationMethod.length).toBe(1) 68 + expect(doc['@context']).toEqual([ 69 + 'https://www.w3.org/ns/did/v1', 70 + 'https://w3id.org/security/suites/ecdsa-2019/v1', 71 + ]) 72 + expect(doc.verificationMethod[0].id).toEqual('#signingKey') 73 + expect(doc.verificationMethod[0].type).toEqual( 74 + 'EcdsaSecp256r1VerificationKey2019', 274 75 ) 275 - atpPds = 'example.com' 276 - const prev2 = await cidForCbor(op1) 277 - const op2 = await operations.updateAtpPds( 278 - atpPds, 279 - prev2.toString(), 280 - signingKey, 76 + expect(doc.verificationMethod[0].controller).toEqual(data.did) 77 + const parsedSigningKey = parseDidKey(signingKey.did()) 78 + const signingKeyMultibase = 79 + 'z' + uint8arrays.toString(parsedSigningKey.keyBytes, 'base58btc') 80 + expect(doc.verificationMethod[0].publicKeyMultibase).toEqual( 81 + signingKeyMultibase, 281 82 ) 282 - ops.push(op1) 283 - ops.push(op2) 284 - const data = await document.validateOperationLog(did, ops) 83 + }) 84 + 85 + it('formats a valid DID document regardless of leading https://', async () => { 86 + const signingKey = await Secp256k1Keypair.create() 87 + const rotate1 = await Secp256k1Keypair.create() 88 + const rotate2 = await EcdsaKeypair.create() 89 + const handles = ['https://alice.test', 'bob.test'] 90 + const atpPds = 'example.com' 91 + const data: t.DocumentData = { 92 + did: 'did:example:alice', 93 + signingKey: signingKey.did(), 94 + rotationKeys: [rotate1.did(), rotate2.did()], 95 + handles, 96 + services: { 97 + atpPds, 98 + }, 99 + } 285 100 const doc = await document.formatDidDoc(data) 286 - expect(doc.alsoKnownAs).toEqual([handle]) 101 + expect(doc.alsoKnownAs).toEqual(['https://alice.test', 'https://bob.test']) 287 102 expect(doc.service[0].serviceEndpoint).toEqual(`https://${atpPds}`) 288 103 }) 289 104 })
+157
packages/lib/tests/recovery.test.ts
··· 1 + import { cidForCbor, DAY, HOUR } from '@atproto/common' 2 + import { EcdsaKeypair, Keypair, Secp256k1Keypair } from '@atproto/crypto' 3 + import { CID } from 'multiformats/cid' 4 + import { InvalidSignatureError, LateRecoveryError } from '../src' 5 + import * as data from '../src/data' 6 + import * as operations from '../src/operations' 7 + import * as t from '../src/types' 8 + 9 + describe('plc recovery', () => { 10 + let signingKey: Secp256k1Keypair 11 + let rotationKey1: Secp256k1Keypair 12 + let rotationKey2: EcdsaKeypair 13 + let rotationKey3: EcdsaKeypair 14 + let did: string 15 + const handle = 'alice.example.com' 16 + const atpPds = 'https://example.com' 17 + 18 + let log: t.IndexedOperation[] = [] 19 + 20 + let createCid: CID 21 + 22 + beforeAll(async () => { 23 + signingKey = await Secp256k1Keypair.create() 24 + rotationKey1 = await Secp256k1Keypair.create() 25 + rotationKey2 = await EcdsaKeypair.create() 26 + rotationKey3 = await EcdsaKeypair.create() 27 + }) 28 + 29 + const signOpForKeys = async ( 30 + keys: Keypair[], 31 + prev: CID | null, 32 + signer: Keypair, 33 + otherChanges: Partial<t.Operation> = {}, 34 + ) => { 35 + const op = await operations.signOperation( 36 + { 37 + signingKey: signingKey.did(), 38 + rotationKeys: keys.map((k) => k.did()), 39 + handles: [handle], 40 + services: { 41 + atpPds, 42 + }, 43 + prev: prev ? prev.toString() : null, 44 + ...otherChanges, 45 + }, 46 + signer, 47 + ) 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 + } 58 + return { op, indexed } 59 + } 60 + 61 + it('creates an op log with rotation', async () => { 62 + const create = await signOpForKeys( 63 + [rotationKey1, rotationKey2, rotationKey3], 64 + null, 65 + rotationKey1, 66 + ) 67 + createCid = create.indexed.cid 68 + 69 + log.push({ 70 + ...create.indexed, 71 + createdAt: new Date(Date.now() - 7 * DAY), 72 + }) 73 + 74 + // key 3 tries to usurp control 75 + const rotate = await signOpForKeys([rotationKey3], createCid, rotationKey3) 76 + 77 + log.push({ 78 + ...rotate.indexed, 79 + createdAt: new Date(Date.now() - DAY), 80 + }) 81 + 82 + // and does some additional ops 83 + const another = await signOpForKeys( 84 + [rotationKey3], 85 + rotate.indexed.cid, 86 + rotationKey3, 87 + { handles: ['newhandle.test'] }, 88 + ) 89 + 90 + log.push({ 91 + ...another.indexed, 92 + createdAt: new Date(Date.now() - HOUR), 93 + }) 94 + }) 95 + 96 + it('allows a rotation key with higher authority to rewrite history', async () => { 97 + // key 2 asserts control over key 3 98 + const rotate = await signOpForKeys([rotationKey2], createCid, rotationKey2) 99 + 100 + const res = await data.assureValidNextOp(did, log, rotate.op) 101 + expect(res.nullified.length).toBe(2) 102 + expect(res.nullified[0].equals(log[1].cid)) 103 + expect(res.nullified[1].equals(log[2].cid)) 104 + expect(res.prev?.equals(createCid)) 105 + 106 + log = [log[0], rotate.indexed] 107 + }) 108 + 109 + it('does not allow the lower authority key to take control back', async () => { 110 + const rotate = await signOpForKeys([rotationKey3], createCid, rotationKey3) 111 + await expect(data.assureValidNextOp(did, log, rotate.op)).rejects.toThrow( 112 + InvalidSignatureError, 113 + ) 114 + }) 115 + 116 + it('allows a rotation key with even higher authority to rewrite history', async () => { 117 + const rotate = await signOpForKeys([rotationKey1], createCid, rotationKey1) 118 + 119 + const res = await data.assureValidNextOp(did, log, rotate.op) 120 + expect(res.nullified.length).toBe(1) 121 + expect(res.nullified[0].equals(log[1].cid)) 122 + expect(res.prev?.equals(createCid)) 123 + 124 + log = [log[0], rotate.indexed] 125 + }) 126 + 127 + it('does not allow the either invalidated key to take control back', async () => { 128 + const rotate1 = await signOpForKeys([rotationKey3], createCid, rotationKey3) 129 + await expect(data.assureValidNextOp(did, log, rotate1.op)).rejects.toThrow( 130 + InvalidSignatureError, 131 + ) 132 + 133 + const rotate2 = await signOpForKeys([rotationKey2], createCid, rotationKey2) 134 + await expect(data.assureValidNextOp(did, log, rotate2.op)).rejects.toThrow( 135 + InvalidSignatureError, 136 + ) 137 + }) 138 + 139 + it('does not allow recovery outside of 72 hrs', async () => { 140 + const rotate = await signOpForKeys([rotationKey3], createCid, rotationKey3) 141 + const timeOutOps = [ 142 + log[0], 143 + { 144 + ...rotate.indexed, 145 + createdAt: new Date(Date.now() - 4 * DAY), 146 + }, 147 + ] 148 + const rotateBack = await signOpForKeys( 149 + [rotationKey2], 150 + createCid, 151 + rotationKey2, 152 + ) 153 + await expect( 154 + data.assureValidNextOp(did, timeOutOps, rotateBack.op), 155 + ).rejects.toThrow(LateRecoveryError) 156 + }) 157 + })
+1 -1
packages/server/package.json
··· 6 6 "scripts": { 7 7 "start": "node dist/bin.js", 8 8 "test": "jest", 9 - "test:pg": "../../pg/with-test-db.sh jest", 9 + "test:pg": "./pg/with-test-db.sh jest", 10 10 "test:log": "cat test.log | pino-pretty", 11 11 "prettier": "prettier --check src/", 12 12 "prettier:fix": "prettier --write src/",
+28 -6
packages/server/src/db.ts
··· 86 86 async validateAndAddOp(did: string, proposed: plc.Operation): Promise<void> { 87 87 const ops = await this._opsForDid(did) 88 88 // throws if invalid 89 - const { nullified, prev } = await plc.document.assureValidNextOp( 90 - did, 91 - ops, 92 - proposed, 93 - ) 89 + const { nullified, prev } = await plc.assureValidNextOp(did, ops, proposed) 94 90 const cid = await cidForCbor(proposed) 95 91 96 92 await this.db ··· 156 152 157 153 async opsForDid(did: string): Promise<plc.Operation[]> { 158 154 const ops = await this._opsForDid(did) 159 - return ops.map((op) => op.operation) 155 + return ops.map((op) => plc.normalizeOp(op.operation)) 160 156 } 161 157 162 158 async _opsForDid(did: string): Promise<plc.IndexedOperation[]> { ··· 176 172 createdAt: new Date(row.createdAt), 177 173 })) 178 174 } 175 + 176 + async fullExport(): Promise<Record<string, OpLogExport>> { 177 + const res = await this.db 178 + .selectFrom('operations') 179 + .selectAll() 180 + .orderBy('did') 181 + .orderBy('createdAt') 182 + .execute() 183 + return res.reduce((acc, cur) => { 184 + acc[cur.did] ??= [] 185 + acc[cur.did].push({ 186 + op: JSON.parse(cur.operation), 187 + nullified: cur.nullified === 1, 188 + createdAt: cur.createdAt, 189 + }) 190 + return acc 191 + }, {} as Record<string, OpLogExport>) 192 + } 179 193 } 180 194 181 195 export default Database ··· 193 207 interface DatabaseSchema { 194 208 operations: OperationsTable 195 209 } 210 + 211 + type OpLogExport = OpExport[] 212 + 213 + type OpExport = { 214 + op: Record<string, unknown> 215 + nullified: boolean 216 + createdAt: string 217 + }
+10
packages/server/src/error.ts
··· 1 + import { PlcError } from '@did-plc/lib' 1 2 import { ErrorRequestHandler } from 'express' 2 3 3 4 export const handler: ErrorRequestHandler = (err, req, res, next) => { 5 + // normalize our PLC errors to server errors 6 + if (PlcError.is(err)) { 7 + err = ServerError.fromPlcError(err) 8 + } 9 + 4 10 req.log.info( 5 11 err, 6 12 ServerError.is(err) ··· 31 37 typeof (obj as Record<string, unknown>).message === 'string' && 32 38 typeof (obj as Record<string, unknown>).status === 'number' 33 39 ) 40 + } 41 + 42 + static fromPlcError(err: PlcError): ServerError { 43 + return new ServerError(400, err.message) 34 44 } 35 45 }
+13 -2
packages/server/src/logger.ts
··· 1 1 import pino from 'pino' 2 2 import pinoHttp from 'pino-http' 3 3 4 - // @TODO fix this up 5 - export const logger = pino() 4 + const enabledEnv = process.env.LOG_ENABLED 5 + const enabled = 6 + enabledEnv === 'true' || enabledEnv === 't' || enabledEnv === '1' 7 + const level = process.env.LOG_LEVEL || 'info' 8 + 9 + const config = { 10 + enabled, 11 + level, 12 + } 13 + 14 + const logger = process.env.LOG_DESTINATION 15 + ? pino(config, pino.destination(process.env.LOG_DESTINATION)) 16 + : pino(config) 6 17 7 18 export const loggerMiddleware = pinoHttp({ 8 19 logger,
-50
packages/server/src/package.json
··· 1 - { 2 - "name": "@did-plc/server", 3 - "version": "0.0.1", 4 - "main": "src/index.ts", 5 - "license": "MIT", 6 - "scripts": { 7 - "start": "node dist/bin.js", 8 - "test": "jest", 9 - "test:pg": "../pg/with-test-db.sh jest", 10 - "test:log": "cat test.log | pino-pretty", 11 - "prettier": "prettier --check src/", 12 - "prettier:fix": "prettier --write src/", 13 - "lint": "eslint . --ext .ts,.tsx", 14 - "lint:fix": "yarn lint --fix", 15 - "verify": "run-p prettier lint", 16 - "verify:fix": "yarn prettier:fix && yarn lint:fix", 17 - "build": "node ./build.js", 18 - "postbuild": "tsc --build tsconfig.build.json", 19 - "low": "node dist/scripts/low_pid.js", 20 - "migration:create": "ts-node ./bin/migration-create.ts", 21 - "did:create": "ts-node ./bin/did-create.ts", 22 - "update-main-to-dist": "node ./update-pkg.js --update-main-to-dist", 23 - "update-main-to-src": "node ./update-pkg.js --update-main-to-src", 24 - "prepublish": "npm run update-main-to-dist", 25 - "postpublish": "npm run update-main-to-src" 26 - }, 27 - "dependencies": { 28 - "@atproto/common": "*", 29 - "@atproto/crypto": "*", 30 - "@ipld/dag-cbor": "^7.0.3", 31 - "async-mutex": "^0.4.0", 32 - "axios": "^0.27.2", 33 - "better-sqlite3": "^7.6.2", 34 - "cors": "^2.8.5", 35 - "dotenv": "^16.0.2", 36 - "express": "^4.17.2", 37 - "express-async-errors": "^3.1.1", 38 - "http-terminator": "^3.2.0", 39 - "kysely": "^0.22.0", 40 - "pg": "^8.8.0", 41 - "pino": "^8.6.1", 42 - "pino-http": "^8.2.1", 43 - "uint8arrays": "3.0.0", 44 - "zod": "^3.14.2" 45 - }, 46 - "devDependencies": { 47 - "@types/pg": "^8.6.5", 48 - "eslint-plugin-prettier": "^4.2.1" 49 - } 50 - }
+29 -8
packages/server/src/routes.ts
··· 19 19 res.send({ version }) 20 20 }) 21 21 22 + // @TODO paginate & test this 23 + router.get('/export', async function (req, res) { 24 + const fullExport = await ctx.db.fullExport() 25 + res.setHeader('content-type', 'application/jsonlines') 26 + res.status(200) 27 + for (const [did, ops] of Object.entries(fullExport)) { 28 + const line = JSON.stringify({ did, ops }) 29 + res.write(line) 30 + res.write('\n') 31 + } 32 + res.end() 33 + }) 34 + 22 35 // Get data for a DID document 23 36 router.get('/:did', async function (req, res) { 24 37 const { did } = req.params ··· 26 39 if (log.length === 0) { 27 40 throw new ServerError(404, `DID not registered: ${did}`) 28 41 } 29 - const data = await plc.document.validateOperationLog(did, log) 30 - const doc = await plc.document.formatDidDoc(data) 42 + const data = await plc.validateOperationLog(did, log) 43 + const doc = await plc.formatDidDoc(data) 31 44 res.type('application/did+ld+json') 32 45 res.send(JSON.stringify(doc)) 33 46 }) ··· 39 52 if (log.length === 0) { 40 53 throw new ServerError(404, `DID not registered: ${did}`) 41 54 } 42 - const data = await plc.document.validateOperationLog(did, log) 43 - res.send(data) 55 + const data = await plc.validateOperationLog(did, log) 56 + res.json(data) 44 57 }) 45 58 46 59 // Get operation log for a DID ··· 50 63 if (log.length === 0) { 51 64 throw new ServerError(404, `DID not registered: ${did}`) 52 65 } 53 - res.send({ log }) 66 + res.json({ log }) 67 + }) 68 + 69 + // Get the most recent operation in the log for a DID 70 + router.get('/last/:did', async function (req, res) { 71 + const { did } = req.params 72 + const log = await ctx.db.opsForDid(did) 73 + const curr = log.at(-1) 74 + if (!curr) { 75 + throw new ServerError(404, `DID not registered: ${did}`) 76 + } 77 + res.json(curr) 54 78 }) 55 79 56 80 // Update or create a DID doc ··· 59 83 const op = req.body 60 84 if (!check.is(op, plc.def.operation)) { 61 85 throw new ServerError(400, `Not a valid operation: ${JSON.stringify(op)}`) 62 - } 63 - if (op.type !== 'create') { 64 - throw new Error('All ops apart from `create` are temporarily disabled') 65 86 } 66 87 await ctx.db.validateAndAddOp(did, op) 67 88 res.sendStatus(200)
+1 -1
packages/server/test.env
··· 1 - LOG_ENABLED=false 1 + LOG_ENABLED=true 2 2 LOG_DESTINATION=test.log
+97 -41
packages/server/tests/server.test.ts
··· 1 1 import { EcdsaKeypair } from '@atproto/crypto' 2 - import { Client, document } from '@did-plc/lib' 2 + import * as plc from '@did-plc/lib' 3 3 import { CloseFn, runTestServer } from './_util' 4 4 import { cidForCbor } from '@atproto/common' 5 5 import { AxiosError } from 'axios' 6 6 import { Database } from '../src' 7 + import { signOperation } from '@did-plc/lib' 7 8 8 9 describe('PLC server', () => { 9 10 let handle = 'alice.example.com' ··· 11 12 12 13 let close: CloseFn 13 14 let db: Database 14 - let client: Client 15 + let client: plc.Client 15 16 16 17 let signingKey: EcdsaKeypair 17 - let recoveryKey: EcdsaKeypair 18 + let rotationKey1: EcdsaKeypair 19 + let rotationKey2: EcdsaKeypair 18 20 19 21 let did: string 20 22 ··· 25 27 26 28 db = server.ctx.db 27 29 close = server.close 28 - client = new Client(server.url) 30 + client = new plc.Client(server.url) 29 31 signingKey = await EcdsaKeypair.create() 30 - recoveryKey = await EcdsaKeypair.create() 32 + rotationKey1 = await EcdsaKeypair.create() 33 + rotationKey2 = await EcdsaKeypair.create() 31 34 }) 32 35 33 36 afterAll(async () => { ··· 37 40 }) 38 41 39 42 it('registers a did', async () => { 40 - did = await client.createDid(signingKey, recoveryKey.did(), handle, atpPds) 43 + did = await client.create( 44 + { 45 + signingKey: signingKey.did(), 46 + rotationKeys: [rotationKey1.did(), rotationKey2.did()], 47 + handles: [handle], 48 + services: { 49 + atpPds, 50 + }, 51 + }, 52 + rotationKey1, 53 + ) 41 54 }) 42 55 43 56 it('retrieves did doc data', async () => { 44 57 const doc = await client.getDocumentData(did) 45 58 expect(doc.did).toEqual(did) 46 59 expect(doc.signingKey).toEqual(signingKey.did()) 47 - expect(doc.recoveryKey).toEqual(recoveryKey.did()) 48 - expect(doc.handle).toEqual(handle) 49 - expect(doc.atpPds).toEqual(atpPds) 60 + expect(doc.rotationKeys).toEqual([rotationKey1.did(), rotationKey2.did()]) 61 + expect(doc.handles).toEqual([handle]) 62 + expect(doc.services).toEqual({ atpPds }) 50 63 }) 51 64 52 - return 53 - 54 65 it('can perform some updates', async () => { 55 - const newSigningKey = await EcdsaKeypair.create() 56 - const newRecoveryKey = await EcdsaKeypair.create() 66 + const newRotationKey = await EcdsaKeypair.create() 67 + signingKey = await EcdsaKeypair.create() 68 + handle = 'ali.example2.com' 69 + atpPds = 'example2.com' 57 70 58 - await client.rotateSigningKey(did, newSigningKey.did(), signingKey) 59 - signingKey = newSigningKey 71 + await client.applyPartialOp( 72 + did, 73 + { signingKey: signingKey.did() }, 74 + rotationKey1, 75 + ) 60 76 61 - await client.rotateRecoveryKey(did, newRecoveryKey.did(), signingKey) 62 - recoveryKey = newRecoveryKey 63 - 64 - handle = 'ali.example2.com' 65 - await client.updateHandle(did, handle, signingKey) 77 + await client.applyPartialOp( 78 + did, 79 + { rotationKeys: [newRotationKey.did(), rotationKey2.did()] }, 80 + rotationKey1, 81 + ) 82 + rotationKey1 = newRotationKey 66 83 67 - atpPds = 'example2.com' 68 - await client.updateAtpPds(did, atpPds, signingKey) 84 + await client.applyPartialOp(did, { handles: [handle] }, rotationKey1) 85 + await client.applyPartialOp(did, { services: { atpPds } }, rotationKey1) 69 86 70 87 const doc = await client.getDocumentData(did) 71 88 expect(doc.did).toEqual(did) 72 89 expect(doc.signingKey).toEqual(signingKey.did()) 73 - expect(doc.recoveryKey).toEqual(recoveryKey.did()) 74 - expect(doc.handle).toEqual(handle) 75 - expect(doc.atpPds).toEqual(atpPds) 90 + expect(doc.rotationKeys).toEqual([rotationKey1.did(), rotationKey2.did()]) 91 + expect(doc.handles).toEqual([handle]) 92 + expect(doc.services).toEqual({ atpPds }) 76 93 }) 77 94 78 95 it('does not allow key types that we do not support', async () => { ··· 80 97 const newSigningKey = 81 98 'did:key:z6MkjwbBXZnFqL8su24wGL2Fdjti6GSLv9SWdYGswfazUPm9' 82 99 83 - const promise = client.rotateSigningKey(did, newSigningKey, signingKey) 100 + const promise = client.applyPartialOp( 101 + did, 102 + { signingKey: newSigningKey }, 103 + rotationKey1, 104 + ) 84 105 await expect(promise).rejects.toThrow(AxiosError) 85 106 }) 86 107 87 108 it('retrieves the operation log', async () => { 88 109 const doc = await client.getDocumentData(did) 89 110 const ops = await client.getOperationLog(did) 90 - const computedDoc = await document.validateOperationLog(did, ops) 111 + const computedDoc = await plc.validateOperationLog(did, ops) 91 112 expect(computedDoc).toEqual(doc) 92 113 }) 93 114 94 115 it('rejects on bad updates', async () => { 95 116 const newKey = await EcdsaKeypair.create() 96 - const operation = client.rotateRecoveryKey(did, newKey.did(), newKey) 117 + const operation = client.applyPartialOp( 118 + did, 119 + { signingKey: newKey.did() }, 120 + newKey, 121 + ) 97 122 await expect(operation).rejects.toThrow() 98 123 }) 99 124 100 125 it('allows for recovery through a forked history', async () => { 101 126 const attackerKey = await EcdsaKeypair.create() 102 - await client.rotateSigningKey(did, attackerKey.did(), signingKey) 103 - await client.rotateRecoveryKey(did, attackerKey.did(), attackerKey) 127 + await client.applyPartialOp( 128 + did, 129 + { signingKey: attackerKey.did(), rotationKeys: [attackerKey.did()] }, 130 + rotationKey2, 131 + ) 104 132 105 133 const newKey = await EcdsaKeypair.create() 106 134 const ops = await client.getOperationLog(did) 107 - const forkPoint = ops[ops.length - 3] 135 + const forkPoint = ops.at(-2) 136 + if (!forkPoint) { 137 + throw new Error('Could not find fork point') 138 + } 108 139 const forkCid = await cidForCbor(forkPoint) 109 - await client.rotateSigningKey(did, newKey.did(), recoveryKey, forkCid) 110 - signingKey = newKey 140 + const op = await signOperation( 141 + { 142 + signingKey: signingKey.did(), 143 + rotationKeys: [newKey.did()], 144 + handles: forkPoint.handles, 145 + services: forkPoint.services, 146 + prev: forkCid.toString(), 147 + }, 148 + rotationKey1, 149 + ) 150 + await client.sendOperation(did, op) 151 + 152 + rotationKey1 = newKey 111 153 112 154 const doc = await client.getDocumentData(did) 113 155 expect(doc.did).toEqual(did) 114 156 expect(doc.signingKey).toEqual(signingKey.did()) 115 - expect(doc.recoveryKey).toEqual(recoveryKey.did()) 116 - expect(doc.handle).toEqual(handle) 117 - expect(doc.atpPds).toEqual(atpPds) 157 + expect(doc.rotationKeys).toEqual([newKey.did()]) 158 + expect(doc.handles).toEqual([handle]) 159 + expect(doc.services).toEqual({ atpPds }) 118 160 }) 119 161 120 162 it('retrieves the did doc', async () => { 121 163 const data = await client.getDocumentData(did) 122 164 const doc = await client.getDocument(did) 123 - expect(doc).toEqual(document.formatDidDoc(data)) 165 + expect(doc).toEqual(plc.formatDidDoc(data)) 124 166 }) 125 167 126 168 it('handles concurrent requests to many docs', async () => { ··· 131 173 } 132 174 await Promise.all( 133 175 keys.map(async (key, index) => { 134 - await client.createDid(key, key.did(), `user${index}`, `example.com`) 176 + await client.create( 177 + { 178 + signingKey: key.did(), 179 + rotationKeys: [key.did()], 180 + handles: [`user${index}`], 181 + services: { 182 + atpPds: `example.com`, 183 + }, 184 + }, 185 + key, 186 + ) 135 187 }), 136 188 ) 137 189 }) ··· 142 194 for (let i = 0; i < COUNT; i++) { 143 195 keys.push(await EcdsaKeypair.create()) 144 196 } 145 - const prev = await client.getPrev(did) 197 + // const prev = await client.getPrev(did) 146 198 147 199 let successes = 0 148 200 let failures = 0 149 201 await Promise.all( 150 202 keys.map(async (key) => { 151 203 try { 152 - await client.rotateSigningKey(did, key.did(), signingKey, prev) 204 + await client.applyPartialOp( 205 + did, 206 + { signingKey: key.did() }, 207 + rotationKey1, 208 + ) 153 209 successes++ 154 210 } catch (err) { 155 211 failures++ ··· 160 216 expect(failures).toBe(99) 161 217 162 218 const ops = await client.getOperationLog(did) 163 - await document.validateOperationLog(did, ops) 219 + await plc.validateOperationLog(did, ops) 164 220 }) 165 221 166 222 it('healthcheck succeeds when database is available.', async () => {
pg/README.md packages/server/pg/README.md
pg/docker-compose.yaml packages/server/pg/docker-compose.yaml
pg/with-test-db.sh packages/server/pg/with-test-db.sh