DNS handles ()

* change pkey on user table to did & rename to user_account

* migration

* tidy

* fixes suggested by bryn

* missed merge thing

* some updating hanldes

scheams

* impl + passing test

* more handle tests

* tidy

* update did doc + some new tests

* one more test

* test handle casing

* basic support for dns resolution on handles

* handle resolution

* fix up account create

* tsconfig

* workshop handle constraints

* bring pds up to speed w handle changes

* change dns subdomain

* another lil test

* bugfix

* update dns record format

* typo
This commit is contained in:
Daniel Holmgren 2023-02-16 13:59:56 -06:00 committed by GitHub
parent e3cd9c23a1
commit d4b1262f28
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 336 additions and 111 deletions

@ -19,6 +19,7 @@
},
"license": "MIT",
"dependencies": {
"@atproto/common": "*",
"@sideway/address": "^5.0.0"
}
}

@ -1,44 +1,25 @@
import * as address from '@sideway/address'
import { reservedSubdomains } from './reserved'
export const ensureValid = (
handle: string,
availableUserDomains: string[],
): void => {
export * from './resolve'
export const ensureValid = (handle: string): void => {
if (handle.startsWith('did:')) {
throw new InvalidHandleError(
'Cannot register a handle that starts with `did:`',
)
}
const supportedDomain = availableUserDomains.find((domain) =>
handle.endsWith(domain),
)
if (!supportedDomain) {
throw new InvalidHandleError('Not a supported handle domain')
}
const front = handle.slice(0, handle.length - supportedDomain.length)
if (front.length < 3) {
throw new InvalidHandleError('Handle too short')
} else if (front.length > 20) {
throw new InvalidHandleError('Handle too long')
} else if (handle.length > 253) {
if (handle.length > 40) {
throw new InvalidHandleError('Handle too long')
}
handle.split('.').map((domainLabel) => {
if (domainLabel.length > 63) {
if (domainLabel.length > 20) {
throw new InvalidHandleError('Handle too long')
}
})
if (reservedSubdomains[front]) {
throw new ReservedHandleError('Reserved handle')
}
if (front.indexOf('.') > -1) {
throw new InvalidHandleError('Invalid characters in handle')
}
const isValid = address.isDomainValid(handle)
if (!isValid) {
throw new InvalidHandleError('Invalid characters in handle')
@ -54,25 +35,59 @@ export const normalize = (handle: string): string => {
return handle.toLowerCase()
}
export const normalizeAndEnsureValid = (
handle: string,
availableUserDomains: string[],
): string => {
export const normalizeAndEnsureValid = (handle: string): string => {
const normalized = normalize(handle)
ensureValid(normalized, availableUserDomains)
ensureValid(normalized)
return normalized
}
export const isValid = (
export const isValid = (handle: string): boolean => {
try {
ensureValid(handle)
} catch (err) {
if (err instanceof InvalidHandleError) {
return false
}
throw err
}
return true
}
export const ensureServiceConstraints = (
handle: string,
availableUserDomains: string[],
reserved = reservedSubdomains,
): void => {
const supportedDomain = availableUserDomains.find((domain) =>
handle.endsWith(domain),
)
if (!supportedDomain) {
throw new UnsupportedDomainError('Not a supported handle domain')
}
const front = handle.slice(0, handle.length - supportedDomain.length)
if (front.indexOf('.') > -1) {
throw new InvalidHandleError('Invalid characters in handle')
}
if (front.length < 3) {
throw new InvalidHandleError('Handle too short')
}
if (reserved[front]) {
throw new ReservedHandleError('Reserved handle')
}
}
export const fulfillsServiceConstraints = (
handle: string,
availableUserDomains: string[],
reserved = reservedSubdomains,
): boolean => {
try {
ensureValid(handle, availableUserDomains)
ensureServiceConstraints(handle, availableUserDomains, reserved)
} catch (err) {
if (
err instanceof InvalidHandleError ||
err instanceof ReservedHandleError
err instanceof ReservedHandleError ||
err instanceof UnsupportedDomainError
) {
return false
}
@ -83,3 +98,4 @@ export const isValid = (
export class InvalidHandleError extends Error {}
export class ReservedHandleError extends Error {}
export class UnsupportedDomainError extends Error {}

@ -0,0 +1,23 @@
import { isErrnoException } from '@atproto/common'
import dns from 'dns/promises'
const SUBDOMAIN = '_atproto'
const PREFIX = 'did='
export const resolveDns = async (handle: string): Promise<string> => {
let chunkedResults: string[][]
try {
chunkedResults = await dns.resolveTxt(`${SUBDOMAIN}.${handle}`)
} catch (err) {
if (isErrnoException(err) && err.code === 'ENOTFOUND') {
throw new NoHandleRecordError()
}
throw err
}
const results = chunkedResults.map((chunks) => chunks.join(''))
const found = results.find((i) => i.startsWith(PREFIX))
if (!found) throw new NoHandleRecordError()
return found.slice(PREFIX.length)
}
export class NoHandleRecordError extends Error {}

@ -1,78 +1,98 @@
import { ensureValid, normalizeAndEnsureValid } from '../src'
import {
ensureValid,
ensureServiceConstraints,
normalizeAndEnsureValid,
} from '../src'
describe('handle validation', () => {
const domains = ['.bsky.app', '.test']
const check = (toCheck: string) => () => {
return ensureValid(toCheck, domains)
}
it('allows valid handles', () => {
check('john.test')
check('jan.test')
check('a234567890123456789.test')
check('john2.test')
check('john-john.test')
check('john-.test')
check('john.bsky.app')
check('0john.test')
check('12345.test')
check(
'short.loooooooooooooooooooooooong.loooooooooooooooooooooooong.loooooooooooooooooooooooong.loooooooooooooooooooooooong.test',
)
ensureValid('john.test')
ensureValid('jan.test')
ensureValid('a234567890123456789.test')
ensureValid('john2.test')
ensureValid('john-john.test')
ensureValid('john.bsky.app')
ensureValid('0john.test')
ensureValid('12345.test')
ensureValid('this.has.many.sub.domains.test')
})
it('allows handles that pass service constraints', () => {
ensureServiceConstraints('john.test', domains)
ensureServiceConstraints('jan.test', domains)
ensureServiceConstraints('a234567890123456789.test', domains)
ensureServiceConstraints('john2.test', domains)
ensureServiceConstraints('john-john.test', domains)
ensureServiceConstraints('john.bsky.app', domains)
ensureServiceConstraints('0john.test', domains)
ensureServiceConstraints('12345.test', domains)
})
it('allows punycode handles', () => {
check('xn--ls8h.test') // 💩.test
check('xn--bcher-kva.tld') // bücher.tld
ensureValid('xn--ls8h.test') // 💩.test
ensureValid('xn--bcher-kva.tld') // bücher.tld
})
it('throws on invalid handles', () => {
expect(check('did:john.test')).toThrow(
const expectThrow = (handle: string, err: string) => {
expect(() => ensureValid(handle)).toThrow(err)
}
expectThrow(
'did:john.test',
'Cannot register a handle that starts with `did:`',
)
expect(check('john.bsky.io')).toThrow('Not a supported handle domain')
expect(check('john.com')).toThrow('Not a supported handle domain')
expect(check('j.test')).toThrow('Handle too short')
expect(check('uk.test')).toThrow('Handle too short')
expect(check('jaymome-johnber123456.test')).toThrow('Handle too long')
expect(check('jay.mome-johnber123456.test')).toThrow('Handle too long')
expect(
check(
'short.loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong.test',
),
).toThrow('Handle too long')
expect(
check(
'short.loooooooooooooooooooooooong.loooooooooooooooooooooooong.loooooooooooooooooooooooong.loooooooooooooooooooooooong.loooooooooooooooooooooooong.loooooooooooooooooooooooong.loooooooooooooooooooooooong.loooooooooooooooooooooooong.loooooooooooooooooooooooong.test',
),
).toThrow('Handle too long')
expect(check('john.test.bsky.app')).toThrow('Invalid characters in handle')
expect(check('john..test')).toThrow('Invalid characters in handle')
expect(check('jo_hn.test')).toThrow('Invalid characters in handle')
expect(check('-john.test')).toThrow('Invalid characters in handle')
expect(check('.john.test')).toThrow('Invalid characters in handle')
expect(check('jo!hn.test')).toThrow('Invalid characters in handle')
expect(check('jo%hn.test')).toThrow('Invalid characters in handle')
expect(check('jo&hn.test')).toThrow('Invalid characters in handle')
expect(check('jo@hn.test')).toThrow('Invalid characters in handle')
expect(check('jo*hn.test')).toThrow('Invalid characters in handle')
expect(check('jo|hn.test')).toThrow('Invalid characters in handle')
expect(check('jo:hn.test')).toThrow('Invalid characters in handle')
expect(check('jo/hn.test')).toThrow('Invalid characters in handle')
expect(check('john💩.test')).toThrow('Invalid characters in handle')
expect(check('bücher.test')).toThrow('Invalid characters in handle')
expect(check('about.test')).toThrow('Reserved handle')
expect(check('atp.test')).toThrow('Reserved handle')
expect(check('barackobama.test')).toThrow('Reserved handle')
expectThrow('jaymome-johnber123456.test', 'Handle too long')
expectThrow('jay.mome-johnber1234567890.subdomain.test', 'Handle too long')
expectThrow(
'short.loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong.test',
'Handle too long',
)
expectThrow(
'short.short.short.short.short.short.short.test',
'Handle too long',
)
expectThrow('john..test', 'Invalid characters in handle')
expectThrow('jo_hn.test', 'Invalid characters in handle')
expectThrow('-john.test', 'Invalid characters in handle')
expectThrow('john-.test', 'Invalid characters in handle')
expectThrow('.john.test', 'Invalid characters in handle')
expectThrow('jo!hn.test', 'Invalid characters in handle')
expectThrow('jo%hn.test', 'Invalid characters in handle')
expectThrow('jo&hn.test', 'Invalid characters in handle')
expectThrow('jo@hn.test', 'Invalid characters in handle')
expectThrow('jo*hn.test', 'Invalid characters in handle')
expectThrow('jo|hn.test', 'Invalid characters in handle')
expectThrow('jo:hn.test', 'Invalid characters in handle')
expectThrow('jo/hn.test', 'Invalid characters in handle')
expectThrow('john💩.test', 'Invalid characters in handle')
expectThrow('bücher.test', 'Invalid characters in handle')
})
it('throw on handles that violate service constraints', () => {
const expectThrow = (handle: string, err: string) => {
expect(() => ensureServiceConstraints(handle, domains)).toThrow(err)
}
expectThrow('john.bsky.io', 'Not a supported handle domain')
expectThrow('john.com', 'Not a supported handle domain')
expectThrow('j.test', 'Handle too short')
expectThrow('uk.test', 'Handle too short')
expectThrow('john.test.bsky.app', 'Invalid characters in handle')
expectThrow('about.test', 'Reserved handle')
expectThrow('atp.test', 'Reserved handle')
expectThrow('barackobama.test', 'Reserved handle')
})
it('normalizes handles', () => {
const normalized = normalizeAndEnsureValid('JoHn.TeST', domains)
const normalized = normalizeAndEnsureValid('JoHn.TeST')
expect(normalized).toBe('john.test')
})
it('throws on invalid normalized handles', () => {
expect(() => normalizeAndEnsureValid('JoH!n.TeST', domains)).toThrow(
expect(() => normalizeAndEnsureValid('JoH!n.TeST')).toThrow(
'Invalid characters in handle',
)
})

@ -0,0 +1,48 @@
import { NoHandleRecordError, resolveDns } from '../src'
jest.mock('dns/promises', () => {
return {
resolveTxt: (handle: string) => {
if (handle === '_atproto.simple.test') {
return [['did=did:example:simpleDid']]
}
if (handle === '_atproto.noisy.test') {
return [
['blah blah blah'],
['did:example:fakeDid'],
['atproto=did:example:fakeDid'],
['did=did:example:noisyDid'],
[
'chunk long domain aspdfoiuwerpoaisdfupasodfiuaspdfoiuasdpfoiausdfpaosidfuaspodifuaspdfoiuasdpfoiasudfpasodifuaspdofiuaspdfoiuasd',
'apsodfiuweproiasudfpoasidfu',
],
]
}
if (handle === '_atproto.bad.test') {
return [
['blah blah blah'],
['did:example:fakeDid'],
['atproto=did:example:fakeDid'],
[
'chunk long domain aspdfoiuwerpoaisdfupasodfiuaspdfoiuasdpfoiausdfpaosidfuaspodifuaspdfoiuasdpfoiasudfpasodifuaspdofiuaspdfoiuasd',
'apsodfiuweproiasudfpoasidfu',
],
]
}
},
}
})
describe('handle resolution', () => {
it('handles a simple DNS resolution', async () => {
const did = await resolveDns('simple.test')
expect(did).toBe('did:example:simpleDid')
})
it('handles a noisy DNS resolution', async () => {
const did = await resolveDns('noisy.test')
expect(did).toBe('did:example:noisyDid')
})
it('handles a bad DNS resolution', async () => {
await expect(resolveDns('bad.test')).rejects.toThrow(NoHandleRecordError)
})
})

@ -6,4 +6,7 @@
"emitDeclarationOnly": true
},
"include": ["./src","__tests__/**/**.ts"],
"references": [
{ "path": "../common/tsconfig.build.json" },
]
}

@ -26,6 +26,7 @@
"postpublish": "npm run update-main-to-src"
},
"dependencies": {
"@atproto/api": "*",
"@atproto/common": "*",
"@atproto/crypto": "*",
"@atproto/did-resolver": "*",

@ -14,15 +14,15 @@ export default function (server: Server, ctx: AppContext) {
let handle: string
try {
handle = handleLib.normalizeAndEnsureValid(
input.body.handle,
ctx.cfg.availableUserDomains,
)
handle = handleLib.normalizeAndEnsureValid(input.body.handle)
handleLib.ensureServiceConstraints(handle, ctx.cfg.availableUserDomains)
} catch (err) {
if (err instanceof handleLib.InvalidHandleError) {
throw new InvalidRequestError(err.message, 'InvalidHandle')
} else if (err instanceof handleLib.ReservedHandleError) {
throw new InvalidRequestError(err.message, 'HandleNotAvailable')
} else if (err instanceof handleLib.UnsupportedDomainError) {
throw new InvalidRequestError(err.message, 'UnsupportedDomain')
}
throw err
}

@ -1,8 +1,11 @@
import ApiAgent from '@atproto/api'
import { InvalidRequestError } from '@atproto/xrpc-server'
import * as handleLib from '@atproto/handle'
import { Server } from '../../../lexicon'
import AppContext from '../../../context'
import { UserAlreadyExistsError } from '../../../services/actor'
import { httpLogger as log } from '../../../logger'
import { XRPCError } from '@atproto/xrpc'
export default function (server: Server, ctx: AppContext) {
server.com.atproto.handle.resolve(async ({ params }) => {
@ -13,17 +16,25 @@ export default function (server: Server, ctx: AppContext) {
// self
did = ctx.cfg.serverDid
} else {
const supportedHandle = ctx.cfg.availableUserDomains.some((host) =>
handle.endsWith(host),
)
if (!supportedHandle) {
throw new InvalidRequestError('Not a supported handle domain')
}
const user = await ctx.services.actor(ctx.db).getUser(handle, true)
if (!user) {
throw new InvalidRequestError('Unable to resolve handle')
if (user) {
did = user.did
} else {
const supportedHandle = ctx.cfg.availableUserDomains.some((host) =>
handle.endsWith(host),
)
// this should be in our DB & we couldn't find it, so fail
if (supportedHandle) {
throw new InvalidRequestError('Unable to resolve handle')
}
// this is not someone on our server, but we help with resolving anyway
const resolved = await resolveExternalHandle(ctx.cfg.scheme, handle)
if (!resolved) {
throw new InvalidRequestError('Unable to resolve handle')
}
did = resolved
}
did = user.did
}
return {
@ -38,19 +49,35 @@ export default function (server: Server, ctx: AppContext) {
const requester = auth.credentials.did
let handle: string
try {
handle = handleLib.normalizeAndEnsureValid(
input.body.handle,
ctx.cfg.availableUserDomains,
)
handle = handleLib.normalizeAndEnsureValid(input.body.handle)
} catch (err) {
if (err instanceof handleLib.InvalidHandleError) {
throw new InvalidRequestError(err.message, 'InvalidHandle')
} else if (err instanceof handleLib.ReservedHandleError) {
throw new InvalidRequestError(err.message, 'HandleNotAvailable')
}
throw err
}
// test against our service constraints
// if not a supported domain, then we must check that the domain correctly links to the DID
try {
handleLib.ensureServiceConstraints(handle, ctx.cfg.availableUserDomains)
} catch (err) {
if (err instanceof handleLib.UnsupportedDomainError) {
const did = await resolveExternalHandle(ctx.cfg.scheme, handle)
if (did !== requester) {
throw new InvalidRequestError(
'External handle did not resolve to DID',
)
}
} else if (err instanceof handleLib.InvalidHandleError) {
throw new InvalidRequestError(err.message, 'InvalidHandle')
} else if (err instanceof handleLib.ReservedHandleError) {
throw new InvalidRequestError(err.message, 'HandleNotAvailable')
} else {
throw err
}
}
await ctx.db.transaction(async (dbTxn) => {
try {
await ctx.services.actor(dbTxn).updateHandle(requester, handle)
@ -60,9 +87,34 @@ export default function (server: Server, ctx: AppContext) {
}
throw err
}
await ctx.plcClient.updateHandle(requester, handle, ctx.keypair)
})
},
})
}
const resolveExternalHandle = async (
scheme: string,
handle: string,
): Promise<string | null> => {
try {
const did = await handleLib.resolveDns(handle)
return did
} catch (err) {
if (err instanceof handleLib.NoHandleRecordError) {
// no worries it's just not found
} else {
log.error({ err, handle }, 'could not resolve dns handle')
}
}
try {
const agent = new ApiAgent({ service: `${scheme}://${handle}` })
const res = await agent.api.com.atproto.handle.resolve({ handle })
return res.data.did
} catch (err) {
if (err instanceof XRPCError) {
return null
}
throw err
}
}

@ -4,14 +4,30 @@ import basicSeed from './seeds/basic'
import * as util from './_util'
import { AppContext } from '../src'
// outside of suite so they can be used in mock
let alice: string
let bob: string
jest.mock('dns/promises', () => {
return {
resolveTxt: (domain: string) => {
if (domain === '_atproto.alice.external') {
return [[`did=${alice}`]]
}
if (domain === '_atproto.bob.external') {
return [[`did=${bob}`]]
}
return []
},
}
})
describe('handles', () => {
let agent: AtpAgent
let close: util.CloseFn
let sc: SeedClient
let ctx: AppContext
let alice: string
let bob: string
const newHandle = 'alice2.test'
beforeAll(async () => {
@ -120,7 +136,7 @@ describe('handles', () => {
'Cannot register a handle that starts with `did:`',
)
await expect(tryHandle('john.bsky.io')).rejects.toThrow(
'Not a supported handle domain',
'External handle did not resolve to DID',
)
await expect(tryHandle('j.test')).rejects.toThrow('Handle too short')
await expect(tryHandle('jayromy-johnber123456.test')).rejects.toThrow(
@ -153,4 +169,49 @@ describe('handles', () => {
await expect(tryHandle('about.test')).rejects.toThrow('Reserved handle')
await expect(tryHandle('atp.test')).rejects.toThrow('Reserved handle')
})
it('allows updating to a dns handles', async () => {
await agent.api.com.atproto.handle.update(
{
handle: 'alice.external',
},
{ headers: sc.getHeaders(alice), encoding: 'application/json' },
)
const profile = await agent.api.app.bsky.actor.getProfile(
{ actor: alice },
{ headers: sc.getHeaders(bob) },
)
expect(profile.data.handle).toBe('alice.external')
const data = await ctx.plcClient.getDocumentData(alice)
expect(data.handle).toBe('alice.external')
})
it('does not allow updating to an invalid dns handle', async () => {
const attempt = agent.api.com.atproto.handle.update(
{
handle: 'bob.external',
},
{ headers: sc.getHeaders(alice), encoding: 'application/json' },
)
await expect(attempt).rejects.toThrow(
'External handle did not resolve to DID',
)
const attempt2 = agent.api.com.atproto.handle.update(
{
handle: 'noexist.external',
},
{ headers: sc.getHeaders(alice), encoding: 'application/json' },
)
await expect(attempt2).rejects.toThrow(
'External handle did not resolve to DID',
)
const profile = await agent.api.app.bsky.actor.getProfile(
{ actor: alice },
{ headers: sc.getHeaders(bob) },
)
expect(profile.data.handle).toBe('alice.external')
})
})