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

Merge pull request #5 from bluesky-social/did-doc-semantics

Mirror did doc semantics

+72 -42
packages/lib/src/client.ts
···
import { check, cidForCbor } from '@atproto/common'
import { Keypair } from '@atproto/crypto'
import axios from 'axios'
-
import { didForCreateOp, normalizeOp, signOperation } from './operations'
+
import {
+
atprotoOp,
+
createUpdateOp,
+
didForCreateOp,
+
tombstoneOp,
+
updateAtprotoKeyOp,
+
updateHandleOp,
+
updatePdsOp,
+
updateRotationKeysOp,
+
} from './operations'
import * as t from './types'
export class Client {
···
return res.data
}
-
async applyPartialOp(
-
did: string,
-
delta: Partial<t.UnsignedOperation>,
-
key: Keypair,
-
) {
-
const lastOp = await this.getLastOp(did)
-
if (check.is(lastOp, t.def.tombstone)) {
-
throw new Error('Cannot apply op to tombstone')
-
}
-
const prev = await cidForCbor(lastOp)
-
const { signingKey, rotationKeys, handles, services } = normalizeOp(lastOp)
-
const op = await signOperation(
-
{
-
signingKey,
-
rotationKeys,
-
handles,
-
services,
-
prev: prev.toString(),
-
...delta,
-
},
-
key,
-
)
-
await this.sendOperation(did, op)
-
}
-
-
async create(
-
op: Omit<t.UnsignedOperation, 'prev'>,
-
key: Keypair,
-
): Promise<string> {
-
const createOp = await signOperation(
-
{
-
...op,
-
prev: null,
-
},
-
key,
-
)
-
const did = await didForCreateOp(createOp)
-
await this.sendOperation(did, createOp)
-
return did
-
}
-
async sendOperation(did: string, op: t.OpOrTombstone) {
await axios.post(this.postOpUrl(did), op)
}
···
const res = await axios.get(url.toString())
const lines = res.data.split('\n')
return lines.map((l) => JSON.parse(l))
+
}
+
+
async createDid(opts: {
+
signingKey: string
+
handle: string
+
pds: string
+
rotationKeys: string[]
+
signer: Keypair
+
}): Promise<string> {
+
const op = await atprotoOp({ ...opts, prev: null })
+
const did = await didForCreateOp(op)
+
await this.sendOperation(did, op)
+
return did
+
}
+
+
private async ensureLastOp(did) {
+
const lastOp = await this.getLastOp(did)
+
if (check.is(lastOp, t.def.tombstone)) {
+
throw new Error('Cannot apply op to tombstone')
+
}
+
return lastOp
+
}
+
+
async updateData(
+
did: string,
+
signer: Keypair,
+
fn: (lastOp: t.UnsignedOperation) => Omit<t.UnsignedOperation, 'prev'>,
+
) {
+
const lastOp = await this.ensureLastOp(did)
+
const op = await createUpdateOp(lastOp, signer, fn)
+
await this.sendOperation(did, op)
+
}
+
+
async updateAtprotoKey(did: string, signer: Keypair, atprotoKey: string) {
+
const lastOp = await this.ensureLastOp(did)
+
const op = await updateAtprotoKeyOp(lastOp, signer, atprotoKey)
+
await this.sendOperation(did, op)
+
}
+
+
async updateHandle(did: string, signer: Keypair, handle: string) {
+
const lastOp = await this.ensureLastOp(did)
+
const op = await updateHandleOp(lastOp, signer, handle)
+
await this.sendOperation(did, op)
+
}
+
+
async updatePds(did: string, signer: Keypair, endpoint: string) {
+
const lastOp = await this.ensureLastOp(did)
+
const op = await updatePdsOp(lastOp, signer, endpoint)
+
await this.sendOperation(did, op)
+
}
+
+
async updateRotationKeys(did: string, signer: Keypair, keys: string[]) {
+
const lastOp = await this.ensureLastOp(did)
+
const op = await updateRotationKeysOp(lastOp, signer, keys)
+
await this.sendOperation(did, op)
+
}
+
+
async tombstone(did: string, signer: Keypair) {
+
const lastOp = await this.ensureLastOp(did)
+
const prev = await cidForCbor(lastOp)
+
const op = await tombstoneOp(prev, signer)
+
await this.sendOperation(did, op)
}
async health() {
+2 -2
packages/lib/src/data.ts
···
throw new MisorderedOperationError()
}
}
-
const { signingKey, rotationKeys, handles, services } = op
-
doc = { did, signingKey, rotationKeys, handles, services }
+
const { verificationMethods, rotationKeys, alsoKnownAs, services } = op
+
doc = { did, verificationMethods, rotationKeys, alsoKnownAs, services }
prev = await cidForCbor(op)
}
+25 -27
packages/lib/src/document.ts
···
export const formatDidDoc = (data: t.DocumentData): t.DidDocument => {
const context = ['https://www.w3.org/ns/did/v1']
-
const signingKeyInfo = formatKeyAndContext(data.signingKey)
-
if (!context.includes(signingKeyInfo.context)) {
-
context.push(signingKeyInfo.context)
+
const verificationMethods: VerificationMethod[] = []
+
for (const [keyid, key] of Object.entries(data.verificationMethods)) {
+
const info = formatKeyAndContext(key)
+
if (!context.includes(info.context)) {
+
context.push(info.context)
+
}
+
verificationMethods.push({
+
id: `#${keyid}`,
+
type: info.type,
+
controller: data.did,
+
publicKeyMultibase: info.publicKeyMultibase,
+
})
}
-
const alsoKnownAs = data.handles.map((h) => ensureHttpPrefix(h))
const services: Service[] = []
-
if (data.services.atpPds) {
+
for (const [serviceId, service] of Object.entries(data.services)) {
services.push({
-
id: `#atpPds`,
-
type: 'AtpPersonalDataServer',
-
serviceEndpoint: ensureHttpPrefix(data.services.atpPds),
+
id: `#${serviceId}`,
+
type: service.type,
+
serviceEndpoint: service.endpoint,
})
}
return {
'@context': context,
id: data.did,
-
alsoKnownAs: alsoKnownAs,
-
verificationMethod: [
-
{
-
id: `#signingKey`,
-
type: signingKeyInfo.type,
-
controller: data.did,
-
publicKeyMultibase: signingKeyInfo.publicKeyMultibase,
-
},
-
],
-
assertionMethod: [`#signingKey`],
-
capabilityInvocation: [`#signingKey`],
-
capabilityDelegation: [`#signingKey`],
+
alsoKnownAs: data.alsoKnownAs,
+
verificationMethod: verificationMethods,
service: services,
}
+
}
+
+
type VerificationMethod = {
+
id: string
+
type: string
+
controller: string
+
publicKeyMultibase: string
}
type Service = {
···
}
throw new UnsupportedKeyError(key, `Unsupported key type: ${jwtAlg}`)
}
-
-
export const ensureHttpPrefix = (str: string): string => {
-
if (str.startsWith('http://') || str.startsWith('https://')) {
-
return str
-
}
-
return `https://${str}`
-
}
+195 -19
packages/lib/src/operations.ts
···
import { CID } from 'multiformats/cid'
import * as uint8arrays from 'uint8arrays'
import { Keypair, parseDidKey, sha256, verifySignature } from '@atproto/crypto'
-
import { check } from '@atproto/common'
+
import { check, cidForCbor } from '@atproto/common'
import * as t from './types'
import {
GenesisHashError,
···
return `did:plc:${truncated}`
}
+
// Operations formatting
+
// ---------------------------
+
+
export const formatAtprotoOp = (opts: {
+
signingKey: string
+
handle: string
+
pds: string
+
rotationKeys: string[]
+
prev: CID | null
+
}): t.UnsignedOperation => {
+
return {
+
type: 'plc_operation',
+
verificationMethods: {
+
atproto: opts.signingKey,
+
},
+
rotationKeys: opts.rotationKeys,
+
alsoKnownAs: [ensureAtprotoPrefix(opts.handle)],
+
services: {
+
atproto_pds: {
+
type: 'AtprotoPersonalDataServer',
+
endpoint: ensureHttpPrefix(opts.pds),
+
},
+
},
+
prev: opts.prev?.toString() ?? null,
+
}
+
}
+
+
export const atprotoOp = async (opts: {
+
signingKey: string
+
handle: string
+
pds: string
+
rotationKeys: string[]
+
prev: CID | null
+
signer: Keypair
+
}) => {
+
return addSignature(formatAtprotoOp(opts), opts.signer)
+
}
+
+
export const createOp = async (opts: {
+
signingKey: string
+
handle: string
+
pds: string
+
rotationKeys: string[]
+
signer: Keypair
+
}): Promise<{ op: t.Operation; did: string }> => {
+
const op = await atprotoOp({ ...opts, prev: null })
+
const did = await didForCreateOp(op)
+
return { op, did }
+
}
+
+
export const createUpdateOp = async (
+
lastOp: t.CompatibleOp,
+
signer: Keypair,
+
fn: (normalized: t.UnsignedOperation) => Omit<t.UnsignedOperation, 'prev'>,
+
): Promise<t.Operation> => {
+
const prev = await cidForCbor(lastOp)
+
// omit sig so it doesn't accidentally make its way into the next operation
+
const { sig, ...normalized } = normalizeOp(lastOp)
+
const unsigned = await fn(normalized)
+
return addSignature(
+
{
+
...unsigned,
+
prev: prev.toString(),
+
},
+
signer,
+
)
+
}
+
+
export const updateAtprotoKeyOp = async (
+
lastOp: t.CompatibleOp,
+
signer: Keypair,
+
atprotoKey: string,
+
): Promise<t.Operation> => {
+
return createUpdateOp(lastOp, signer, (normalized) => ({
+
...normalized,
+
verificationMethods: {
+
...normalized.verificationMethods,
+
atproto: atprotoKey,
+
},
+
}))
+
}
+
+
export const updateHandleOp = async (
+
lastOp: t.CompatibleOp,
+
signer: Keypair,
+
handle: string,
+
): Promise<t.Operation> => {
+
const formatted = ensureAtprotoPrefix(handle)
+
return createUpdateOp(lastOp, signer, (normalized) => {
+
const handleI = normalized.alsoKnownAs.findIndex((h) =>
+
h.startsWith('at://'),
+
)
+
let aka: string[]
+
if (handleI < 0) {
+
aka = [formatted, ...normalized.alsoKnownAs]
+
} else {
+
aka = [
+
...normalized.alsoKnownAs.slice(0, handleI),
+
formatted,
+
...normalized.alsoKnownAs.slice(handleI + 1),
+
]
+
}
+
return {
+
...normalized,
+
alsoKnownAs: aka,
+
}
+
})
+
}
+
+
export const updatePdsOp = async (
+
lastOp: t.CompatibleOp,
+
signer: Keypair,
+
endpoint: string,
+
): Promise<t.Operation> => {
+
const formatted = ensureHttpPrefix(endpoint)
+
return createUpdateOp(lastOp, signer, (normalized) => {
+
return {
+
...normalized,
+
services: {
+
...normalized.services,
+
atproto_pds: {
+
type: 'AtprotoPersonalDataServer',
+
endpoint: formatted,
+
},
+
},
+
}
+
})
+
}
+
+
export const updateRotationKeysOp = async (
+
lastOp: t.CompatibleOp,
+
signer: Keypair,
+
rotationKeys: string[],
+
): Promise<t.Operation> => {
+
return createUpdateOp(lastOp, signer, (normalized) => {
+
return {
+
...normalized,
+
rotationKeys,
+
}
+
})
+
}
+
+
export const tombstoneOp = async (
+
prev: CID,
+
key: Keypair,
+
): Promise<t.Tombstone> => {
+
return addSignature(
+
{
+
type: 'plc_tombstone',
+
prev: prev.toString(),
+
},
+
key,
+
)
+
}
+
+
// Signing operations
+
// ---------------------------
+
export const addSignature = async <T extends Record<string, unknown>>(
object: T,
key: Keypair,
···
return addSignature(op, signingKey)
}
-
export const signTombstone = async (
-
prev: CID,
-
key: Keypair,
-
): Promise<t.Tombstone> => {
-
return addSignature(
-
{
-
tombstone: true,
-
prev: prev.toString(),
-
},
-
key,
-
)
-
}
+
// Backwards compatibility
+
// ---------------------------
export const deprecatedSignCreate = async (
op: t.UnsignedCreateOpV1,
···
return op
}
return {
-
signingKey: op.signingKey,
+
type: 'plc_operation',
+
verificationMethods: {
+
atproto: op.signingKey,
+
},
rotationKeys: [op.recoveryKey, op.signingKey],
-
handles: [op.handle],
+
alsoKnownAs: [ensureAtprotoPrefix(op.handle)],
services: {
-
atpPds: op.service,
+
atproto_pds: {
+
type: 'AtprotoPersonalDataServer',
+
endpoint: ensureHttpPrefix(op.service),
+
},
},
prev: op.prev,
sig: op.sig,
}
}
+
// Verifying operations/signatures
+
// ---------------------------
+
export const assureValidOp = async (op: t.OpOrTombstone) => {
if (check.is(op, t.def.tombstone)) {
return true
}
// ensure we support the op's keys
-
const keys = [op.signingKey, ...op.rotationKeys]
+
const keys = [...Object.values(op.verificationMethods), ...op.rotationKeys]
await Promise.all(
keys.map(async (k) => {
try {
···
if (op.prev !== null) {
throw new ImproperOperationError('expected null prev on create', op)
}
-
const { signingKey, rotationKeys, handles, services } = normalized
-
return { did, signingKey, rotationKeys, handles, services }
+
const { verificationMethods, rotationKeys, alsoKnownAs, services } =
+
normalized
+
return { did, verificationMethods, rotationKeys, alsoKnownAs, services }
}
export const assureValidSig = async (
···
}
throw new InvalidSignatureError(op)
}
+
+
// Util
+
// ---------------------------
+
+
export const ensureHttpPrefix = (str: string): string => {
+
if (str.startsWith('http://') || str.startsWith('https://')) {
+
return str
+
}
+
return `https://${str}`
+
}
+
+
export const ensureAtprotoPrefix = (str: string): string => {
+
if (str.startsWith('at://')) {
+
return str
+
}
+
const stripped = str.replace('http://', '').replace('https://', '')
+
return `at://${stripped}`
+
}
+13 -14
packages/lib/src/types.ts
···
})
.transform((obj: unknown) => mf.CID.asCID(obj) as mf.CID)
+
const service = z.object({
+
type: z.string(),
+
endpoint: z.string(),
+
})
+
const documentData = z.object({
did: z.string(),
-
signingKey: z.string(),
rotationKeys: z.array(z.string()),
-
handles: z.array(z.string()),
-
services: z.object({
-
atpPds: z.string().optional(),
-
}),
+
verificationMethods: z.record(z.string()),
+
alsoKnownAs: z.array(z.string()),
+
services: z.record(service),
})
export type DocumentData = z.infer<typeof documentData>
···
export type CreateOpV1 = z.infer<typeof createOpV1>
const unsignedOperation = z.object({
-
signingKey: z.string(),
+
type: z.literal('plc_operation'),
rotationKeys: z.array(z.string()),
-
handles: z.array(z.string()),
-
services: z.object({
-
atpPds: z.string().optional(),
-
}),
+
verificationMethods: z.record(z.string()),
+
alsoKnownAs: z.array(z.string()),
+
services: z.record(service),
prev: z.string().nullable(),
})
export type UnsignedOperation = z.infer<typeof unsignedOperation>
···
export type Operation = z.infer<typeof operation>
const unsignedTombstone = z.object({
-
tombstone: z.literal(true),
+
type: z.literal('plc_tombstone'),
prev: z.string(),
})
export type UnsignedTombstone = z.infer<typeof unsignedTombstone>
···
id: z.string(),
alsoKnownAs: z.array(z.string()),
verificationMethod: z.array(didDocVerificationMethod),
-
assertionMethod: z.array(z.string()),
-
capabilityInvocation: z.array(z.string()),
-
capabilityDelegation: z.array(z.string()),
service: z.array(didDocService),
})
export type DidDocument = z.infer<typeof didDocument>
+18 -12
packages/lib/tests/compatibility.test.ts
···
deprecatedSignCreate,
didForCreateOp,
normalizeOp,
-
signOperation,
+
updateRotationKeysOp,
+
updateAtprotoKeyOp,
validateOperationLog,
} from '../src'
···
const normalized = normalizeOp(legacyOp)
expect(normalized).toEqual({
-
signingKey: signingKey.did(),
+
type: 'plc_operation',
+
verificationMethods: {
+
atproto: signingKey.did(),
+
},
rotationKeys: [recoveryKey.did(), signingKey.did()],
-
handles: [handle],
+
alsoKnownAs: [`at://${handle}`],
services: {
-
atpPds: service,
+
atproto_pds: {
+
type: 'AtprotoPersonalDataServer',
+
endpoint: service,
+
},
},
prev: null,
sig: legacyOp.sig,
···
const legacyCid = await cidForCbor(legacyOp)
const newSigner = await Secp256k1Keypair.create()
const newRotater = await Secp256k1Keypair.create()
-
const nextOp = await signOperation(
-
{
-
signingKey: newSigner.did(),
-
rotationKeys: [newRotater.did()],
-
handles: [handle],
-
services: { atpPds: service },
-
prev: legacyCid.toString(),
-
},
+
const nextOp = await updateAtprotoKeyOp(
+
legacyOp,
signingKey,
+
newSigner.did(),
)
+
const anotherOp = await updateRotationKeysOp(nextOp, signingKey, [
+
newRotater.did(),
+
])
await validateOperationLog(did, [legacyOp, nextOp])
+
await validateOperationLog(did, [legacyOp, nextOp, anotherOp])
const indexedLegacy = {
did,
+67 -130
packages/lib/tests/data.test.ts
···
import { check, cidForCbor } from '@atproto/common'
-
import { EcdsaKeypair, Keypair, Secp256k1Keypair } from '@atproto/crypto'
+
import { EcdsaKeypair, Secp256k1Keypair } from '@atproto/crypto'
import {
GenesisHashError,
ImproperOperationError,
···
let rotationKey1: Secp256k1Keypair
let rotationKey2: EcdsaKeypair
let did: string
-
let handle = 'alice.example.com'
+
let handle = 'at://alice.example.com'
let atpPds = 'https://example.com'
let oldRotationKey1: Secp256k1Keypair
···
rotationKey2 = await EcdsaKeypair.create()
})
-
const makeNextOp = async (
-
changes: Partial<t.Operation>,
-
key: Keypair,
-
): Promise<t.Operation> => {
+
const lastOp = () => {
const lastOp = ops.at(-1)
if (!lastOp) {
throw new Error('expected an op on log')
}
-
const prev = await cidForCbor(lastOp)
-
return operations.signOperation(
-
{
-
signingKey: lastOp.signingKey,
-
rotationKeys: lastOp.rotationKeys,
-
handles: lastOp.handles,
-
services: lastOp.services,
-
prev: prev.toString(),
-
...changes,
+
return lastOp
+
}
+
+
const verifyDoc = (doc: t.DocumentData | null) => {
+
if (!doc) {
+
throw new Error('expected doc')
+
}
+
expect(doc.did).toEqual(did)
+
expect(doc.verificationMethods).toEqual({ atproto: signingKey.did() })
+
expect(doc.rotationKeys).toEqual([rotationKey1.did(), rotationKey2.did()])
+
expect(doc.alsoKnownAs).toEqual([handle])
+
expect(doc.services).toEqual({
+
atproto_pds: {
+
type: 'AtprotoPersonalDataServer',
+
endpoint: atpPds,
},
-
key,
-
)
+
})
}
it('creates a valid create op', async () => {
-
const createOp = await operations.signOperation(
-
{
-
signingKey: signingKey.did(),
-
rotationKeys: [rotationKey1.did(), rotationKey2.did()],
-
handles: [handle],
-
services: {
-
atpPds,
-
},
-
prev: null,
-
},
-
rotationKey1,
-
)
+
const createOp = await operations.atprotoOp({
+
signingKey: signingKey.did(),
+
rotationKeys: [rotationKey1.did(), rotationKey2.did()],
+
handle,
+
pds: atpPds,
+
prev: null,
+
signer: rotationKey1,
+
})
const isValid = check.is(createOp, t.def.operation)
expect(isValid).toBeTruthy()
ops.push(createOp)
···
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()])
-
expect(doc.handles).toEqual([handle])
-
expect(doc.services).toEqual({ atpPds })
+
verifyDoc(doc)
})
it('updates handle', async () => {
-
handle = 'ali.example2.com'
-
const op = await makeNextOp({ handles: [handle] }, rotationKey1)
+
const noPrefix = 'ali.exampl2.com'
+
handle = `at://${noPrefix}`
+
const op = await operations.updateHandleOp(lastOp(), rotationKey1, noPrefix)
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()])
-
expect(doc.handles).toEqual([handle])
-
expect(doc.services).toEqual({ atpPds })
+
verifyDoc(doc)
})
it('updates atpPds', async () => {
-
atpPds = 'https://example2.com'
-
const op = await makeNextOp(
-
{
-
services: {
-
atpPds,
-
},
-
},
-
rotationKey1,
-
)
+
const noPrefix = 'example2.com'
+
atpPds = `https://${noPrefix}`
+
const op = await operations.updatePdsOp(lastOp(), rotationKey1, noPrefix)
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()])
-
expect(doc.handles).toEqual([handle])
-
expect(doc.services).toEqual({ atpPds })
+
verifyDoc(doc)
})
it('rotates signingKey', async () => {
const newSigningKey = await Secp256k1Keypair.create()
-
const op = await makeNextOp(
-
{
-
signingKey: newSigningKey.did(),
-
},
+
const op = await operations.updateAtprotoKeyOp(
+
lastOp(),
rotationKey1,
+
newSigningKey.did(),
)
ops.push(op)
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()])
-
expect(doc.handles).toEqual([handle])
-
expect(doc.services).toEqual({ atpPds })
+
verifyDoc(doc)
})
it('rotates rotation keys', async () => {
const newRotationKey = await Secp256k1Keypair.create()
-
const op = await makeNextOp(
-
{
-
rotationKeys: [newRotationKey.did(), rotationKey2.did()],
-
},
-
rotationKey1,
-
)
+
const op = await operations.updateRotationKeysOp(lastOp(), rotationKey1, [
+
newRotationKey.did(),
+
rotationKey2.did(),
+
])
ops.push(op)
oldRotationKey1 = rotationKey1
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()])
-
expect(doc.handles).toEqual([handle])
-
expect(doc.services).toEqual({ atpPds })
+
verifyDoc(doc)
})
it('no longer allows operations from old rotation key', async () => {
-
const op = await makeNextOp(
-
{
-
handles: ['bob'],
-
},
+
const op = await operations.updateHandleOp(
+
lastOp(),
oldRotationKey1,
+
'at://bob',
)
expect(data.validateOperationLog(did, [...ops, op])).rejects.toThrow(
InvalidSignatureError,
···
})
it('does not allow operations from the signingKey', async () => {
-
const op = await makeNextOp(
-
{
-
handles: ['bob'],
-
},
-
signingKey,
-
)
+
const op = await operations.updateHandleOp(lastOp(), signingKey, 'at://bob')
expect(data.validateOperationLog(did, [...ops, op])).rejects.toThrow(
InvalidSignatureError,
)
})
it('allows for operations from either rotation key', async () => {
-
const newHandle = 'ali.example.com'
-
const op = await makeNextOp(
-
{
-
handles: [newHandle],
-
},
+
const newHandle = 'at://ali.example.com'
+
const op = await operations.updateHandleOp(
+
lastOp(),
rotationKey2,
+
newHandle,
)
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.handles).toEqual([handle])
-
expect(doc.services).toEqual({ atpPds })
+
verifyDoc(doc)
})
it('allows tombstoning a DID', async () => {
const last = await data.getLastOpWithCid(ops)
-
const op = await operations.signTombstone(last.cid, rotationKey1)
+
const op = await operations.tombstoneOp(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(
-
{
-
handles: ['bob.test'],
-
prev: prev.toString(),
-
},
+
const op = await operations.updateHandleOp(
+
ops[ops.length - 2],
rotationKey1,
+
'at://bob.test',
)
expect(data.validateOperationLog(did, [...ops, op])).rejects.toThrow(
MisorderedOperationError,
···
})
it('does not allow a create operation in the middle of the log', async () => {
-
const op = await makeNextOp(
-
{
-
handles: ['bob.test'],
-
prev: null,
-
},
-
rotationKey1,
-
)
+
const op = await operations.atprotoOp({
+
signingKey: signingKey.did(),
+
rotationKeys: [rotationKey1.did(), rotationKey2.did()],
+
handle,
+
pds: atpPds,
+
prev: null,
+
signer: rotationKey1,
+
})
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)
+
const tombstone = await operations.tombstoneOp(prev, rotationKey1)
expect(
data.validateOperationLog(did, [
...ops.slice(0, ops.length - 1),
+46 -69
packages/lib/tests/document.test.ts
···
describe('document', () => {
it('formats a valid DID document', async () => {
-
const signingKey = await Secp256k1Keypair.create()
+
const atprotoKey = await Secp256k1Keypair.create()
+
const otherKey = await EcdsaKeypair.create()
const rotate1 = await Secp256k1Keypair.create()
const rotate2 = await EcdsaKeypair.create()
-
const handles = ['alice.test', 'bob.test']
+
const alsoKnownAs = ['at://alice.test', 'https://bob.test']
const atpPds = 'https://example.com'
+
const otherService = 'https://other.com'
const data: t.DocumentData = {
did: 'did:example:alice',
-
signingKey: signingKey.did(),
+
verificationMethods: {
+
atproto: atprotoKey.did(),
+
other: otherKey.did(),
+
},
rotationKeys: [rotate1.did(), rotate2.did()],
-
handles,
+
alsoKnownAs,
services: {
-
atpPds,
+
atproto_pds: {
+
type: 'AtprotoPersonalDataServer',
+
endpoint: atpPds,
+
},
+
other: {
+
type: 'SomeService',
+
endpoint: otherService,
+
},
},
}
const doc = await document.formatDidDoc(data)
+
// only expected keys
+
expect(Object.keys(doc).sort()).toEqual(
+
['@context', 'id', 'alsoKnownAs', 'verificationMethod', 'service'].sort(),
+
)
expect(doc['@context']).toEqual([
'https://www.w3.org/ns/did/v1',
'https://w3id.org/security/suites/secp256k1-2019/v1',
+
'https://w3id.org/security/suites/ecdsa-2019/v1',
])
expect(doc.id).toEqual(data.did)
-
const formattedHandles = handles.map((h) => `https://${h}`)
-
expect(doc.alsoKnownAs).toEqual(formattedHandles)
-
expect(doc.verificationMethod.length).toBe(1)
-
expect(doc.verificationMethod[0].id).toEqual('#signingKey')
+
expect(doc.alsoKnownAs).toEqual(alsoKnownAs)
+
+
expect(doc.verificationMethod.length).toBe(2)
+
+
expect(doc.verificationMethod[0].id).toEqual('#atproto')
expect(doc.verificationMethod[0].type).toEqual(
'EcdsaSecp256k1VerificationKey2019',
)
expect(doc.verificationMethod[0].controller).toEqual(data.did)
-
const parsedSigningKey = parseDidKey(signingKey.did())
-
const signingKeyMultibase =
-
'z' + uint8arrays.toString(parsedSigningKey.keyBytes, 'base58btc')
+
const parsedAtprotoKey = parseDidKey(atprotoKey.did())
+
const atprotoKeyMultibase =
+
'z' + uint8arrays.toString(parsedAtprotoKey.keyBytes, 'base58btc')
expect(doc.verificationMethod[0].publicKeyMultibase).toEqual(
-
signingKeyMultibase,
+
atprotoKeyMultibase,
)
-
expect(doc.assertionMethod).toEqual(['#signingKey'])
-
expect(doc.capabilityInvocation).toEqual(['#signingKey'])
-
expect(doc.capabilityDelegation).toEqual(['#signingKey'])
-
expect(doc.service.length).toBe(1)
-
expect(doc.service[0].id).toEqual('#atpPds')
-
expect(doc.service[0].type).toEqual('AtpPersonalDataServer')
-
expect(doc.service[0].serviceEndpoint).toEqual(atpPds)
-
})
-
it('handles P-256 keys', async () => {
-
const signingKey = await EcdsaKeypair.create()
-
const rotate1 = await Secp256k1Keypair.create()
-
const rotate2 = await EcdsaKeypair.create()
-
const handles = ['alice.test', 'bob.test']
-
const atpPds = 'https://example.com'
-
const data: t.DocumentData = {
-
did: 'did:example:alice',
-
signingKey: signingKey.did(),
-
rotationKeys: [rotate1.did(), rotate2.did()],
-
handles,
-
services: {
-
atpPds,
-
},
-
}
-
const doc = await document.formatDidDoc(data)
-
expect(doc.verificationMethod.length).toBe(1)
-
expect(doc['@context']).toEqual([
-
'https://www.w3.org/ns/did/v1',
-
'https://w3id.org/security/suites/ecdsa-2019/v1',
-
])
-
expect(doc.verificationMethod[0].id).toEqual('#signingKey')
-
expect(doc.verificationMethod[0].type).toEqual(
+
expect(doc.verificationMethod[1].id).toEqual('#other')
+
expect(doc.verificationMethod[1].type).toEqual(
'EcdsaSecp256r1VerificationKey2019',
)
-
expect(doc.verificationMethod[0].controller).toEqual(data.did)
-
const parsedSigningKey = parseDidKey(signingKey.did())
-
const signingKeyMultibase =
-
'z' + uint8arrays.toString(parsedSigningKey.keyBytes, 'base58btc')
-
expect(doc.verificationMethod[0].publicKeyMultibase).toEqual(
-
signingKeyMultibase,
+
expect(doc.verificationMethod[1].controller).toEqual(data.did)
+
const parsedOtherKey = parseDidKey(otherKey.did())
+
const otherKeyMultibase =
+
'z' + uint8arrays.toString(parsedOtherKey.keyBytes, 'base58btc')
+
expect(doc.verificationMethod[1].publicKeyMultibase).toEqual(
+
otherKeyMultibase,
)
-
})
-
it('formats a valid DID document regardless of leading https://', async () => {
-
const signingKey = await Secp256k1Keypair.create()
-
const rotate1 = await Secp256k1Keypair.create()
-
const rotate2 = await EcdsaKeypair.create()
-
const handles = ['https://alice.test', 'bob.test']
-
const atpPds = 'example.com'
-
const data: t.DocumentData = {
-
did: 'did:example:alice',
-
signingKey: signingKey.did(),
-
rotationKeys: [rotate1.did(), rotate2.did()],
-
handles,
-
services: {
-
atpPds,
-
},
-
}
-
const doc = await document.formatDidDoc(data)
-
expect(doc.alsoKnownAs).toEqual(['https://alice.test', 'https://bob.test'])
-
expect(doc.service[0].serviceEndpoint).toEqual(`https://${atpPds}`)
+
expect(doc.service.length).toBe(2)
+
expect(doc.service[0].id).toEqual('#atproto_pds')
+
expect(doc.service[0].type).toEqual('AtprotoPersonalDataServer')
+
expect(doc.service[0].serviceEndpoint).toEqual(atpPds)
+
expect(doc.service[1].id).toEqual('#other')
+
expect(doc.service[1].type).toEqual('SomeService')
+
expect(doc.service[1].serviceEndpoint).toEqual(otherService)
})
})
+11 -13
packages/lib/tests/recovery.test.ts
···
signer: Keypair,
otherChanges: Partial<t.Operation> = {},
) => {
-
const op = await operations.signOperation(
-
{
+
const unsigned = {
+
...operations.formatAtprotoOp({
signingKey: signingKey.did(),
rotationKeys: keys.map((k) => k.did()),
-
handles: [handle],
-
services: {
-
atpPds,
-
},
-
prev: prev ? prev.toString() : null,
-
...otherChanges,
-
},
-
signer,
-
)
+
handle,
+
pds: atpPds,
+
prev,
+
}),
+
...otherChanges,
+
}
+
const op = await operations.addSignature(unsigned, signer)
const indexed = await formatIndexed(op)
return { op, indexed }
}
···
[rotationKey3],
rotate.indexed.cid,
rotationKey3,
-
{ handles: ['newhandle.test'] },
+
{ alsoKnownAs: ['newhandle.test'] },
)
log.push({
···
})
it('allows recovery from a tombstoned DID', async () => {
-
const tombstone = await operations.signTombstone(createCid, rotationKey2)
+
const tombstone = await operations.tombstoneOp(createCid, rotationKey2)
const cid = await cidForCbor(tombstone)
const tombstoneOps = [
log[0],
+4 -1
packages/server/src/db/index.ts
···
return results
}
-
async validateAndAddOp(did: string, proposed: plc.Operation): Promise<void> {
+
async validateAndAddOp(
+
did: string,
+
proposed: plc.OpOrTombstone,
+
): Promise<void> {
const ops = await this.indexedOpsForDid(did)
// throws if invalid
const { nullified, prev } = await plc.assureValidNextOp(did, ops, proposed)
+4 -1
packages/server/src/db/mock.ts
···
async close(): Promise<void> {}
async healthCheck(): Promise<void> {}
-
async validateAndAddOp(did: string, proposed: plc.Operation): Promise<void> {
+
async validateAndAddOp(
+
did: string,
+
proposed: plc.OpOrTombstone,
+
): Promise<void> {
this.contents[did] ??= []
const opsBefore = this.contents[did]
// throws if invalid
+1 -1
packages/server/src/db/types.ts
···
export interface PlcDatabase {
close(): Promise<void>
healthCheck(): Promise<void>
-
validateAndAddOp(did: string, proposed: plc.Operation): Promise<void>
+
validateAndAddOp(did: string, proposed: plc.OpOrTombstone): Promise<void>
opsForDid(did: string): Promise<plc.CompatibleOpOrTombstone[]>
indexedOpsForDid(
did: string,
+1 -1
packages/server/src/routes.ts
···
router.post('/:did', async function (req, res) {
const { did } = req.params
const op = req.body
-
if (!check.is(op, plc.def.operation)) {
+
if (!check.is(op, plc.def.opOrTombstone)) {
throw new ServerError(400, `Not a valid operation: ${JSON.stringify(op)}`)
}
await ctx.db.validateAndAddOp(did, op)
+69 -89
packages/server/tests/server.test.ts
···
import { EcdsaKeypair } from '@atproto/crypto'
import * as plc from '@did-plc/lib'
import { CloseFn, runTestServer } from './_util'
-
import { check, cidForCbor } from '@atproto/common'
+
import { check } from '@atproto/common'
import { AxiosError } from 'axios'
import { Database } from '../src'
-
import { signOperation } from '@did-plc/lib'
describe('PLC server', () => {
-
let handle = 'alice.example.com'
-
let atpPds = 'example.com'
+
let handle = 'at://alice.example.com'
+
let atpPds = 'https://example.com'
let close: CloseFn
let db: Database
···
}
})
-
it('registers a did', async () => {
-
did = await client.create(
-
{
-
signingKey: signingKey.did(),
-
rotationKeys: [rotationKey1.did(), rotationKey2.did()],
-
handles: [handle],
-
services: {
-
atpPds,
-
},
+
const verifyDoc = (doc: plc.DocumentData | null) => {
+
if (!doc) {
+
throw new Error('expected doc')
+
}
+
expect(doc.did).toEqual(did)
+
expect(doc.verificationMethods).toEqual({ atproto: signingKey.did() })
+
expect(doc.rotationKeys).toEqual([rotationKey1.did(), rotationKey2.did()])
+
expect(doc.alsoKnownAs).toEqual([handle])
+
expect(doc.services).toEqual({
+
atproto_pds: {
+
type: 'AtprotoPersonalDataServer',
+
endpoint: atpPds,
},
-
rotationKey1,
-
)
+
})
+
}
+
+
it('registers a did', async () => {
+
did = await client.createDid({
+
signingKey: signingKey.did(),
+
rotationKeys: [rotationKey1.did(), rotationKey2.did()],
+
handle,
+
pds: atpPds,
+
signer: rotationKey1,
+
})
})
it('retrieves did doc data', async () => {
const doc = await client.getDocumentData(did)
-
expect(doc.did).toEqual(did)
-
expect(doc.signingKey).toEqual(signingKey.did())
-
expect(doc.rotationKeys).toEqual([rotationKey1.did(), rotationKey2.did()])
-
expect(doc.handles).toEqual([handle])
-
expect(doc.services).toEqual({ atpPds })
+
verifyDoc(doc)
})
it('can perform some updates', async () => {
const newRotationKey = await EcdsaKeypair.create()
signingKey = await EcdsaKeypair.create()
-
handle = 'ali.example2.com'
-
atpPds = 'example2.com'
+
handle = 'at://ali.example2.com'
+
atpPds = 'https://example2.com'
-
await client.applyPartialOp(
-
did,
-
{ signingKey: signingKey.did() },
-
rotationKey1,
-
)
-
-
await client.applyPartialOp(
-
did,
-
{ rotationKeys: [newRotationKey.did(), rotationKey2.did()] },
-
rotationKey1,
-
)
+
await client.updateAtprotoKey(did, rotationKey1, signingKey.did())
+
await client.updateRotationKeys(did, rotationKey1, [
+
newRotationKey.did(),
+
rotationKey2.did(),
+
])
rotationKey1 = newRotationKey
-
await client.applyPartialOp(did, { handles: [handle] }, rotationKey1)
-
await client.applyPartialOp(did, { services: { atpPds } }, rotationKey1)
+
await client.updateHandle(did, rotationKey1, handle)
+
await client.updatePds(did, rotationKey1, atpPds)
const doc = await client.getDocumentData(did)
-
expect(doc.did).toEqual(did)
-
expect(doc.signingKey).toEqual(signingKey.did())
-
expect(doc.rotationKeys).toEqual([rotationKey1.did(), rotationKey2.did()])
-
expect(doc.handles).toEqual([handle])
-
expect(doc.services).toEqual({ atpPds })
+
verifyDoc(doc)
})
it('does not allow key types that we do not support', async () => {
···
const newSigningKey =
'did:key:z6MkjwbBXZnFqL8su24wGL2Fdjti6GSLv9SWdYGswfazUPm9'
-
const promise = client.applyPartialOp(
-
did,
-
{ signingKey: newSigningKey },
-
rotationKey1,
-
)
+
const promise = client.updateAtprotoKey(did, rotationKey1, newSigningKey)
await expect(promise).rejects.toThrow(AxiosError)
+
+
const promise2 = client.updateRotationKeys(did, rotationKey1, [
+
newSigningKey,
+
])
+
await expect(promise2).rejects.toThrow(AxiosError)
})
it('retrieves the operation log', async () => {
···
it('rejects on bad updates', async () => {
const newKey = await EcdsaKeypair.create()
-
const operation = client.applyPartialOp(
-
did,
-
{ signingKey: newKey.did() },
-
newKey,
-
)
+
const operation = client.updateAtprotoKey(did, newKey, newKey.did())
await expect(operation).rejects.toThrow()
})
it('allows for recovery through a forked history', async () => {
const attackerKey = await EcdsaKeypair.create()
-
await client.applyPartialOp(
-
did,
-
{ signingKey: attackerKey.did(), rotationKeys: [attackerKey.did()] },
-
rotationKey2,
-
)
+
await client.updateRotationKeys(did, rotationKey2, [attackerKey.did()])
const newKey = await EcdsaKeypair.create()
const ops = await client.getOperationLog(did)
···
if (!check.is(forkPoint, plc.def.operation)) {
throw new Error('Could not find fork point')
}
-
const forkCid = await cidForCbor(forkPoint)
-
const op = await signOperation(
-
{
-
signingKey: signingKey.did(),
-
rotationKeys: [newKey.did()],
-
handles: forkPoint.handles,
-
services: forkPoint.services,
-
prev: forkCid.toString(),
-
},
-
rotationKey1,
-
)
+
const op = await plc.updateRotationKeysOp(forkPoint, rotationKey1, [
+
rotationKey1.did(),
+
newKey.did(),
+
])
await client.sendOperation(did, op)
-
rotationKey1 = newKey
+
rotationKey2 = newKey
const doc = await client.getDocumentData(did)
-
expect(doc.did).toEqual(did)
-
expect(doc.signingKey).toEqual(signingKey.did())
-
expect(doc.rotationKeys).toEqual([newKey.did()])
-
expect(doc.handles).toEqual([handle])
-
expect(doc.services).toEqual({ atpPds })
+
verifyDoc(doc)
})
it('retrieves the auditable operation log', async () => {
···
}
await Promise.all(
keys.map(async (key, index) => {
-
await client.create(
-
{
-
signingKey: key.did(),
-
rotationKeys: [key.did()],
-
handles: [`user${index}`],
-
services: {
-
atpPds: `example.com`,
-
},
-
},
-
key,
-
)
+
await client.createDid({
+
signingKey: key.did(),
+
rotationKeys: [key.did()],
+
handle: `user${index}`,
+
pds: `example.com`,
+
signer: key,
+
})
}),
)
})
···
await Promise.all(
keys.map(async (key) => {
try {
-
await client.applyPartialOp(
-
did,
-
{ signingKey: key.did() },
-
rotationKey1,
-
)
+
await client.updateAtprotoKey(did, rotationKey1, key.did())
successes++
} catch (err) {
failures++
···
await plc.validateOperationLog(did, ops)
})
+
it('tombstones the did', async () => {
+
await client.tombstone(did, rotationKey1)
+
+
const promise = client.getDocument(did)
+
await expect(promise).rejects.toThrow(AxiosError)
+
const promise2 = client.getDocumentData(did)
+
await expect(promise2).rejects.toThrow(AxiosError)
+
})
+
it('exports the data set', async () => {
const data = await client.export()
expect(data.every((row) => check.is(row, plc.def.exportedOp))).toBeTruthy()
-
expect(data.length).toBe(58)
+
expect(data.length).toBe(59)
for (let i = 1; i < data.length; i++) {
expect(data[i].createdAt >= data[i - 1].createdAt).toBeTruthy()
}