@atproto/did
Version:
DID resolution and verification library
266 lines (239 loc) • 6.17 kB
text/typescript
import { z } from 'zod'
import { DidError, InvalidDidError } from './did-error.js'
const DID_PREFIX = 'did:'
const DID_PREFIX_LENGTH = DID_PREFIX.length
export { DID_PREFIX }
/**
* Type representation of a Did, with method.
*
* ```bnf
* did = "did:" method-name ":" method-specific-id
* method-name = 1*method-char
* method-char = %x61-7A / DIGIT
* method-specific-id = *( *idchar ":" ) 1*idchar
* idchar = ALPHA / DIGIT / "." / "-" / "_" / pct-encoded
* pct-encoded = "%" HEXDIG HEXDIG
* ```
*
* @example
* ```ts
* type DidWeb = Did<'web'> // `did:web:${string}`
* type DidCustom = Did<'web' | 'plc'> // `did:${'web' | 'plc'}:${string}`
* type DidNever = Did<' invalid 🥴 '> // never
* type DidFoo = Did<'foo' | ' invalid 🥴 '> // `did:foo:${string}`
* ```
*
* @see {@link https://www.w3.org/TR/did-core/#did-syntax}
*/
export type Did<M extends string = string> = `did:${AsDidMethod<M>}:${string}`
/**
* DID Method
*/
export type AsDidMethod<M> = string extends M
? string // can't know...
: AsDidMethodInternal<M, ''>
type AlphanumericChar = DigitChar | LowerAlphaChar
type DigitChar = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
type LowerAlphaChar =
| 'a'
| 'b'
| 'c'
| 'd'
| 'e'
| 'f'
| 'g'
| 'h'
| 'i'
| 'j'
| 'k'
| 'l'
| 'm'
| 'n'
| 'o'
| 'p'
| 'q'
| 'r'
| 's'
| 't'
| 'u'
| 'v'
| 'w'
| 'x'
| 'y'
| 'z'
type AsDidMethodInternal<
S,
Acc extends string,
> = S extends `${infer H}${infer T}`
? H extends AlphanumericChar
? AsDidMethodInternal<T, `${Acc}${H}`>
: never
: Acc extends ''
? never
: Acc
/**
* DID Method-name check function.
*
* Check if the input is a valid DID method name, at the position between
* `start` (inclusive) and `end` (exclusive).
*/
export function assertDidMethod(
input: string,
start = 0,
end = input.length,
): void {
if (
!Number.isFinite(end) ||
!Number.isFinite(start) ||
end < start ||
end > input.length
) {
throw new TypeError('Invalid start or end position')
}
if (end === start) {
throw new InvalidDidError(input, `Empty method name`)
}
let c: number
for (let i = start; i < end; i++) {
c = input.charCodeAt(i)
if (
(c < 0x61 || c > 0x7a) && // a-z
(c < 0x30 || c > 0x39) // 0-9
) {
throw new InvalidDidError(
input,
`Invalid character at position ${i} in DID method name`,
)
}
}
}
/**
* This method assumes the input is a valid Did
*/
export function extractDidMethod<D extends Did>(did: D) {
const msidSep = did.indexOf(':', DID_PREFIX_LENGTH)
const method = did.slice(DID_PREFIX_LENGTH, msidSep)
return method as D extends Did<infer M> ? M : string
}
/**
* DID Method-specific identifier check function.
*
* Check if the input is a valid DID method-specific identifier, at the position
* between `start` (inclusive) and `end` (exclusive).
*/
export function assertDidMsid(
input: string,
start = 0,
end = input.length,
): void {
if (
!Number.isFinite(end) ||
!Number.isFinite(start) ||
end < start ||
end > input.length
) {
throw new TypeError('Invalid start or end position')
}
if (end === start) {
throw new InvalidDidError(input, `DID method-specific id must not be empty`)
}
let c: number
for (let i = start; i < end; i++) {
c = input.charCodeAt(i)
// Check for frequent chars first
if (
(c < 0x61 || c > 0x7a) && // a-z
(c < 0x41 || c > 0x5a) && // A-Z
(c < 0x30 || c > 0x39) && // 0-9
c !== 0x2e && // .
c !== 0x2d && // -
c !== 0x5f // _
) {
// Less frequent chars are checked here
// ":"
if (c === 0x3a) {
if (i === end - 1) {
throw new InvalidDidError(input, `DID cannot end with ":"`)
}
continue
}
// pct-encoded
if (c === 0x25) {
c = input.charCodeAt(++i)
if ((c < 0x30 || c > 0x39) && (c < 0x41 || c > 0x46)) {
throw new InvalidDidError(
input,
`Invalid pct-encoded character at position ${i}`,
)
}
c = input.charCodeAt(++i)
if ((c < 0x30 || c > 0x39) && (c < 0x41 || c > 0x46)) {
throw new InvalidDidError(
input,
`Invalid pct-encoded character at position ${i}`,
)
}
// There must always be 2 HEXDIG after a "%"
if (i >= end) {
throw new InvalidDidError(
input,
`Incomplete pct-encoded character at position ${i - 2}`,
)
}
continue
}
throw new InvalidDidError(
input,
`Disallowed character in DID at position ${i}`,
)
}
}
}
export function assertDid(input: unknown): asserts input is Did {
if (typeof input !== 'string') {
throw new InvalidDidError(typeof input, `DID must be a string`)
}
const { length } = input
if (length > 2048) {
throw new InvalidDidError(input, `DID is too long (2048 chars max)`)
}
if (!input.startsWith(DID_PREFIX)) {
throw new InvalidDidError(input, `DID requires "${DID_PREFIX}" prefix`)
}
const idSep = input.indexOf(':', DID_PREFIX_LENGTH)
if (idSep === -1) {
throw new InvalidDidError(input, `Missing colon after method name`)
}
assertDidMethod(input, DID_PREFIX_LENGTH, idSep)
assertDidMsid(input, idSep + 1, length)
}
export function isDid(input: unknown): input is Did {
try {
assertDid(input)
return true
} catch (err) {
if (err instanceof DidError) {
return false
}
// Unexpected TypeError (should never happen)
throw err
}
}
export function asDid(input: unknown): Did {
assertDid(input)
return input
}
export const didSchema = z
.string()
.superRefine((value: string, ctx: z.RefinementCtx): value is Did => {
try {
assertDid(value)
return true
} catch (err) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: err instanceof Error ? err.message : 'Unexpected error',
})
return false
}
})