Bring your own DID (#1011)
* allow bringing your own did * tests + tidy * one more check/test * fix typo * better err names
This commit is contained in:
parent
deabb71da4
commit
5804716504
lexicons/com/atproto/server
packages
api/src/client
pds
src
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(
|
||||
{
|
||||
|
Loading…
x
Reference in New Issue
Block a user