Bring your own DID ()

* allow bringing your own did

* tests + tidy

* one more check/test

* fix typo

* better err names
This commit is contained in:
Daniel Holmgren 2023-05-12 16:18:49 -05:00 committed by GitHub
parent deabb71da4
commit 5804716504
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 259 additions and 40 deletions
lexicons/com/atproto/server
packages
api/src/client
lexicons.ts
types/com/atproto/server
pds
src
api/com/atproto/server
lexicon
lexicons.ts
types/com/atproto/server
tests

@ -13,6 +13,7 @@
"properties": {
"email": {"type": "string"},
"handle": {"type": "string", "format": "handle"},
"did": {"type": "string", "format": "did"},
"inviteCode": {"type": "string"},
"password": {"type": "string"},
"recoveryKey": {"type": "string"}
@ -37,7 +38,9 @@
{"name": "InvalidPassword"},
{"name": "InvalidInviteCode"},
{"name": "HandleNotAvailable"},
{"name": "UnsupportedDomain"}
{"name": "UnsupportedDomain"},
{"name": "UnresolvableDid"},
{"name": "IncompatibleDidDoc"}
]
}
}

@ -2114,6 +2114,10 @@ export const schemaDict = {
type: 'string',
format: 'handle',
},
did: {
type: 'string',
format: 'did',
},
inviteCode: {
type: 'string',
},
@ -2165,6 +2169,12 @@ export const schemaDict = {
{
name: 'UnsupportedDomain',
},
{
name: 'UnresolvableDid',
},
{
name: 'IncompatibleDidDoc',
},
],
},
},

@ -12,6 +12,7 @@ export interface QueryParams {}
export interface InputSchema {
email: string
handle: string
did?: string
inviteCode?: string
password: string
recoveryKey?: string
@ -68,6 +69,18 @@ export class UnsupportedDomainError extends XRPCError {
}
}
export class UnresolvableDidError extends XRPCError {
constructor(src: XRPCError) {
super(src.status, src.error, src.message)
}
}
export class IncompatibleDidDocError extends XRPCError {
constructor(src: XRPCError) {
super(src.status, src.error, src.message)
}
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
if (e.error === 'InvalidHandle') return new InvalidHandleError(e)
@ -75,6 +88,8 @@ export function toKnownErr(e: any) {
if (e.error === 'InvalidInviteCode') return new InvalidInviteCodeError(e)
if (e.error === 'HandleNotAvailable') return new HandleNotAvailableError(e)
if (e.error === 'UnsupportedDomain') return new UnsupportedDomainError(e)
if (e.error === 'UnresolvableDid') return new UnresolvableDidError(e)
if (e.error === 'IncompatibleDidDoc') return new IncompatibleDidDocError(e)
}
return e
}

@ -3,14 +3,17 @@ import * as ident from '@atproto/identifier'
import * as plc from '@did-plc/lib'
import * as scrypt from '../../../../db/scrypt'
import { Server } from '../../../../lexicon'
import { InputSchema as CreateAccountInput } from '../../../../lexicon/types/com/atproto/server/createAccount'
import { countAll } from '../../../../db/util'
import { UserAlreadyExistsError } from '../../../../services/account'
import AppContext from '../../../../context'
import Database from '../../../../db'
import { resolveExternalHandle } from '../identity/util'
import { AtprotoData } from '@atproto/did-resolver'
export default function (server: Server, ctx: AppContext) {
server.com.atproto.server.createAccount(async ({ input, req }) => {
const { email, password, inviteCode, recoveryKey } = input.body
const { email, password, inviteCode } = input.body
if (ctx.cfg.inviteRequired && !inviteCode) {
throw new InvalidRequestError(
@ -19,43 +22,19 @@ export default function (server: Server, ctx: AppContext) {
)
}
// validate handle
let handle: string
try {
handle = ident.normalizeAndEnsureValidHandle(input.body.handle)
ident.ensureHandleServiceConstraints(handle, ctx.cfg.availableUserDomains)
} catch (err) {
if (err instanceof ident.InvalidHandleError) {
throw new InvalidRequestError(err.message, 'InvalidHandle')
} else if (err instanceof ident.ReservedHandleError) {
throw new InvalidRequestError(err.message, 'HandleNotAvailable')
} else if (err instanceof ident.UnsupportedDomainError) {
throw new InvalidRequestError(err.message, 'UnsupportedDomain')
}
throw err
}
// normalize & ensure valid handle
const handle = await ensureValidHandle(ctx, input.body)
// check that the invite code still has uses
if (ctx.cfg.inviteRequired && inviteCode) {
await ensureCodeIsAvailable(ctx.db, inviteCode)
}
// determine the did & any plc ops we need to send
// if the provided did document is poorly setup, we throw
const { did, plcOp } = await getDidAndPlcOp(ctx, handle, input.body)
const now = new Date().toISOString()
const rotationKeys = [ctx.cfg.recoveryKey, ctx.plcRotationKey.did()]
if (recoveryKey) {
rotationKeys.unshift(recoveryKey)
}
// format create op, but don't send until we ensure the username & email are available
const plcCreate = await plc.createOp({
signingKey: ctx.repoSigningKey.did(),
rotationKeys,
handle,
pds: ctx.cfg.publicUrl,
signer: ctx.plcRotationKey,
})
const did = plcCreate.did
const passwordScrypt = await scrypt.genSaltAndHash(password)
const result = await ctx.db.transaction(async (dbTxn) => {
@ -85,14 +64,16 @@ export default function (server: Server, ctx: AppContext) {
}
// Generate a real did with PLC
try {
await ctx.plcClient.sendOperation(did, plcCreate.op)
} catch (err) {
req.log.error(
{ didKey: ctx.plcRotationKey.did(), handle },
'failed to create did:plc',
)
throw err
if (plcOp) {
try {
await ctx.plcClient.sendOperation(did, plcOp)
} catch (err) {
req.log.error(
{ didKey: ctx.plcRotationKey.did(), handle },
'failed to create did:plc',
)
throw err
}
}
// insert invite code use
@ -158,3 +139,102 @@ export const ensureCodeIsAvailable = async (
)
}
}
const ensureValidHandle = async (
ctx: AppContext,
input: CreateAccountInput,
): Promise<string> => {
try {
const handle = ident.normalizeAndEnsureValidHandle(input.handle)
ident.ensureHandleServiceConstraints(handle, ctx.cfg.availableUserDomains)
return handle
} catch (err) {
if (err instanceof ident.InvalidHandleError) {
throw new InvalidRequestError(err.message, 'InvalidHandle')
} else if (err instanceof ident.ReservedHandleError) {
throw new InvalidRequestError(err.message, 'HandleNotAvailable')
} else if (err instanceof ident.UnsupportedDomainError) {
if (input.did === undefined) {
throw new InvalidRequestError(err.message, 'UnsupportedDomain')
}
const resolvedHandleDid = await resolveExternalHandle(
ctx.cfg.scheme,
input.handle,
)
if (input.did !== resolvedHandleDid) {
throw new InvalidRequestError('External handle did not resolve to DID')
}
}
throw err
}
}
const getDidAndPlcOp = async (
ctx: AppContext,
handle: string,
input: CreateAccountInput,
): Promise<{
did: string
plcOp: plc.Operation | null
}> => {
// if the user is not bringing a DID, then we format a create op for PLC
// but we don't send until we ensure the username & email are available
if (!input.did) {
const rotationKeys = [ctx.cfg.recoveryKey, ctx.plcRotationKey.did()]
if (input.recoveryKey) {
rotationKeys.unshift(input.recoveryKey)
}
const plcCreate = await plc.createOp({
signingKey: ctx.repoSigningKey.did(),
rotationKeys,
handle,
pds: ctx.cfg.publicUrl,
signer: ctx.plcRotationKey,
})
return {
did: plcCreate.did,
plcOp: plcCreate.op,
}
}
// if the user is bringing their own did:
// resolve the user's did doc data, including rotationKeys if did:plc
// determine if we have the capability to make changes to their DID
let atpData: AtprotoData
try {
atpData = await ctx.didResolver.resolveAtprotoData(input.did)
} catch (err) {
throw new InvalidRequestError(
`could not resolve valid DID document :${input.did}`,
'UnresolvableDid',
)
}
if (atpData.handle !== handle) {
throw new InvalidRequestError(
'provided handle does not match DID document handle',
'IncompatibleDidDoc',
)
} else if (atpData.pds !== ctx.cfg.publicUrl) {
throw new InvalidRequestError(
'DID document pds endpoint does not match service endpoint',
'IncompatibleDidDoc',
)
} else if (atpData.signingKey !== ctx.repoSigningKey.did()) {
throw new InvalidRequestError(
'DID document signing key does not match service signing key',
'IncompatibleDidDoc',
)
}
if (input.did.startsWith('did:plc')) {
const data = await ctx.plcClient.getDocumentData(input.did)
if (!data.rotationKeys.includes(ctx.plcRotationKey.did())) {
throw new InvalidRequestError(
'PLC DID does not include service rotation key',
'IncompatibleDidDoc',
)
}
}
return { did: input.did, plcOp: null }
}

@ -2114,6 +2114,10 @@ export const schemaDict = {
type: 'string',
format: 'handle',
},
did: {
type: 'string',
format: 'did',
},
inviteCode: {
type: 'string',
},
@ -2165,6 +2169,12 @@ export const schemaDict = {
{
name: 'UnsupportedDomain',
},
{
name: 'UnresolvableDid',
},
{
name: 'IncompatibleDidDoc',
},
],
},
},

@ -13,6 +13,7 @@ export interface QueryParams {}
export interface InputSchema {
email: string
handle: string
did?: string
inviteCode?: string
password: string
recoveryKey?: string
@ -46,6 +47,8 @@ export interface HandlerError {
| 'InvalidInviteCode'
| 'HandleNotAvailable'
| 'UnsupportedDomain'
| 'UnresolvableDid'
| 'IncompatibleDidDoc'
}
export type HandlerOutput = HandlerError | HandlerSuccess

@ -168,6 +168,104 @@ describe('account', () => {
])
})
it('allows a user to bring their own DID', async () => {
const inviteCode = await createInviteCode(agent, 1)
const userKey = await crypto.Secp256k1Keypair.create()
const handle = 'byo-did.test'
const did = await ctx.plcClient.createDid({
signingKey: ctx.repoSigningKey.did(),
handle,
rotationKeys: [
userKey.did(),
ctx.cfg.recoveryKey,
ctx.plcRotationKey.did(),
],
pds: ctx.cfg.publicUrl,
signer: userKey,
})
const res = await agent.api.com.atproto.server.createAccount({
email: 'byo-did@test.com',
handle,
did,
password: 'byo-did-pass',
inviteCode,
})
expect(res.data.handle).toEqual(handle)
expect(res.data.did).toEqual(did)
})
it('requires that the did a user brought be correctly set up for the server', async () => {
const inviteCode = await createInviteCode(agent, 1)
const userKey = await crypto.Secp256k1Keypair.create()
const baseDidInfo = {
signingKey: ctx.repoSigningKey.did(),
handle: 'byo-did.test',
rotationKeys: [
userKey.did(),
ctx.cfg.recoveryKey,
ctx.plcRotationKey.did(),
],
pds: ctx.cfg.publicUrl,
signer: userKey,
}
const baseAccntInfo = {
email: 'byo-did@test.com',
handle: 'byo-did.test',
password: 'byo-did-pass',
inviteCode,
}
const did1 = await ctx.plcClient.createDid({
...baseDidInfo,
handle: 'different-handle.test',
})
const attempt1 = agent.api.com.atproto.server.createAccount({
...baseAccntInfo,
did: did1,
})
await expect(attempt1).rejects.toThrow(
'provided handle does not match DID document handle',
)
const did2 = await ctx.plcClient.createDid({
...baseDidInfo,
pds: 'https://other-pds.com',
})
const attempt2 = agent.api.com.atproto.server.createAccount({
...baseAccntInfo,
did: did2,
})
await expect(attempt2).rejects.toThrow(
'DID document pds endpoint does not match service endpoint',
)
const did3 = await ctx.plcClient.createDid({
...baseDidInfo,
rotationKeys: [userKey.did()],
})
const attempt3 = agent.api.com.atproto.server.createAccount({
...baseAccntInfo,
did: did3,
})
await expect(attempt3).rejects.toThrow(
'PLC DID does not include service rotation key',
)
const did4 = await ctx.plcClient.createDid({
...baseDidInfo,
signingKey: userKey.did(),
})
const attempt4 = agent.api.com.atproto.server.createAccount({
...baseAccntInfo,
did: did4,
})
await expect(attempt4).rejects.toThrow(
'DID document signing key does not match service signing key',
)
})
it('allows administrative email updates', async () => {
await agent.api.com.atproto.admin.updateAccountEmail(
{