@ucanto/validator
Version:
UCAN RPC validators
612 lines (563 loc) • 18.6 kB
JavaScript
import * as API from '@ucanto/interface'
import { isDelegation, UCAN, ok, fail } from '@ucanto/core'
import { capability } from './capability.js'
import * as Schema from '@ucanto/core/schema'
import * as Authorization from './authorization.js'
import {
UnavailableProof,
Unauthorized,
PrincipalAlignmentError,
Expired,
Revoked,
NotValidBefore,
InvalidSignature,
SessionEscalation,
Failure,
MalformedCapability,
DIDKeyResolutionError,
li,
} from './error.js'
export { capability } from './capability.js'
export * from '@ucanto/core/schema'
export {
Schema,
Authorization,
Failure,
fail,
ok,
Revoked,
UnavailableProof,
Unauthorized,
MalformedCapability,
DIDKeyResolutionError as DIDResolutionError,
}
/**
* @param {UCAN.Link} proof
* @returns {{error:API.UnavailableProof}}
*/
const unavailable = proof => ({ error: new UnavailableProof(proof) })
/**
*
* @param {UCAN.DID} did
* @returns {{error:API.DIDKeyResolutionError}}
*/
const failDIDKeyResolution = did => ({ error: new DIDKeyResolutionError(did) })
/**
* @param {Required<API.ClaimOptions>} config
* @param {API.Match<unknown, API.Match>} match
*/
const resolveMatch = async (match, config) => {
const promises = []
const includes = new Set()
for (const source of match.source) {
const id = source.delegation.cid.toString()
if (!includes.has(id)) {
promises.push(await resolveSources(source, config))
}
}
const groups = await Promise.all(promises)
const sources = []
const errors = []
for (const group of groups) {
sources.push(...group.sources)
errors.push(...group.errors)
}
return { sources, errors }
}
/**
* Takes `proofs` from the delegation which may contain `Delegation` or a link
* to one and attempts to resolve links by side loading them. Returns set of
* resolved `Delegation`s and errors for the proofs that could not be resolved.
*
* @param {API.Proof[]} proofs
* @param {Required<API.ProofResolver>} config
*/
const resolveProofs = async (proofs, config) => {
/** @type {API.Delegation[]} */
const delegations = []
/** @type {API.UnavailableProof[]} */
const errors = []
const promises = []
for (const proof of proofs) {
// If it is a delegation we can just add it to the resolved set.
if (isDelegation(proof)) {
delegations.push(proof)
}
// otherwise we attempt to resolve the link asynchronously. To avoid doing
// sequential requests we create promise for each link and then wait for
// all of them at the end.
else {
promises.push(
new Promise(async resolve => {
// config.resolve is not supposed to throw, but we catch it just in
// case it does and consider proof resolution failed.
try {
const result = await config.resolve(proof)
if (result.error) {
errors.push(result.error)
} else {
delegations.push(result.ok)
}
} catch (error) {
errors.push(
new UnavailableProof(proof, /** @type {Error} */(error))
)
}
// we don't care about the result, we just need to signal that we are
// done with this promise.
resolve(null)
})
)
}
}
// Wait for all the promises to resolve. At this point we have collected all
// the resolved delegations and errors.
await Promise.all(promises)
return { delegations, errors }
}
/**
* Takes a delegation source and attempts to resolve all the linked proofs.
*
* @param {API.Source} from
* @param {Required<API.ClaimOptions>} config
* @return {Promise<{sources:API.Source[], errors:ProofError[]}>}
*/
const resolveSources = async ({ delegation }, config) => {
const errors = []
const sources = []
const proofs = []
// First we attempt to resolve all the linked proofs.
const { delegations, errors: failedProofs } = await resolveProofs(
delegation.proofs,
config
)
// All the proofs that failed to resolve are saved as proof errors.
for (const error of failedProofs) {
errors.push(new ProofError(error.link, error))
}
// All the proofs that resolved are checked for principal alignment. Ones that
// do not align are saved as proof errors.
for (const proof of delegations) {
// If proof does not delegate to a matching audience save an proof error.
if (delegation.issuer.did() !== proof.audience.did()) {
errors.push(
new ProofError(
proof.cid,
new PrincipalAlignmentError(delegation.issuer, proof)
)
)
} else {
proofs.push(proof)
}
}
// In the second pass we attempt to proofs that were resolved and are aligned.
for (const proof of proofs) {
// If proof is not valid (expired, not active yet or has incorrect
// signature) save a corresponding proof error.
const validation = await validate(proof, proofs, config)
if (validation.error) {
errors.push(new ProofError(proof.cid, validation.error))
} else {
// otherwise create source objects for it's capabilities, so we could
// track which proof in which capability the are from.
for (const capability of proof.capabilities) {
sources.push(
/** @type {API.Source} */({
capability,
delegation: proof,
})
)
}
}
}
return { sources, errors }
}
/**
* @param {API.ParsedCapability} capability
* @param {API.DID} issuer
*/
const isSelfIssued = (capability, issuer) => capability.with === issuer
/**
* Finds a valid path in a proof chain of the given `invocation` by exploring
* every possible option. On success an `Authorization` object is returned that
* illustrates the valid path. If no valid path is found `Unauthorized` error
* is returned detailing all explored paths and where they proved to fail.
*
* @template {API.Ability} A
* @template {API.URI} R
* @template {R} URI
* @template {API.Caveats} C
* @param {API.Invocation<API.Capability<A, URI, C>>} invocation
* @param {API.ValidationOptions<API.ParsedCapability<A, R, C>>} options
* @returns {Promise<API.Result<API.Authorization<API.ParsedCapability<A, R, C>>, API.Unauthorized>>}
*/
export const access = async (invocation, { capability, ...config }) =>
claim(capability, [invocation], config)
/**
* Attempts to find a valid proof chain for the claimed `capability` given set
* of `proofs`. On success an `Authorization` object with detailed proof chain
* is returned and on failure `Unauthorized` error is returned with details on
* paths explored and why they have failed.
*
* @template {API.Ability} A
* @template {API.URI} R
* @template {API.Caveats} C
* @param {API.CapabilityParser<API.Match<API.ParsedCapability<A, R, C>>>} capability
* @param {API.Proof[]} proofs
* @param {API.ClaimOptions} config
* @returns {Promise<API.Result<API.Authorization<API.ParsedCapability<A, R, C>>, API.Unauthorized>>}
*/
export const claim = async (
capability,
proofs,
{
authority,
principal,
validateAuthorization,
resolveDIDKey = failDIDKeyResolution,
canIssue = isSelfIssued,
resolve = unavailable,
proofs: localProofs = [],
}
) => {
const config = {
canIssue,
resolve,
principal,
capability,
authority,
validateAuthorization,
resolveDIDKey,
proofs: localProofs,
}
const invalidProofs = []
/** @type {API.Source[]} */
const sources = []
const { delegations, errors } = await resolveProofs(proofs, config)
invalidProofs.push(...errors)
for (const proof of delegations) {
// Validate each proof if valid add ech capability to the list of sources.
// otherwise collect the error.
const validation = await validate(proof, delegations, config)
if (validation.ok) {
for (const capability of validation.ok.capabilities.values()) {
sources.push(
/** @type {API.Source} */({
capability,
delegation: validation.ok,
})
)
}
} else {
invalidProofs.push(validation.error)
}
}
// look for the matching capability
const selection = capability.select(sources)
const { errors: delegationErrors, unknown: unknownCapabilities } = selection
const failedProofs = []
for (const matched of selection.matches) {
const selector = matched.prune(config)
if (selector == null) {
const authorization = Authorization.create(matched, [])
const result = await validateAuthorization(authorization)
if (result.error) {
invalidProofs.push(result.error)
} else {
return { ok: authorization }
}
} else {
const result = await authorize(selector, config)
if (result.error) {
failedProofs.push(result.error)
} else {
const authorization = Authorization.create(matched, [result.ok])
const approval = await validateAuthorization(authorization)
if (approval.error) {
invalidProofs.push(approval.error)
} else {
return { ok: authorization }
}
}
}
}
return {
error: new Unauthorized({
capability,
delegationErrors,
unknownCapabilities,
invalidProofs,
failedProofs,
}),
}
}
/**
* Verifies whether any of the delegated proofs grant give capability.
*
* @template {API.Match} Match
* @param {Match} match
* @param {Required<API.ClaimOptions>} config
* @returns {Promise<API.Result<API.Authorization<API.ParsedCapability>, API.InvalidClaim>>}
*/
export const authorize = async (match, config) => {
// load proofs from all delegations
const { sources, errors: invalidProofs } = await resolveMatch(match, config)
const selection = match.select(sources)
const { errors: delegationErrors, unknown: unknownCapabilities } = selection
const failedProofs = []
for (const matched of selection.matches) {
const selector = matched.prune(config)
if (selector == null) {
return {
ok: Authorization.create(
// @ts-expect-error - it may not be a parsed capability but rather a
// group of capabilities but we can deal with that in the future.
matched,
[]
),
}
} else {
const result = await authorize(selector, config)
if (result.error) {
failedProofs.push(result.error)
} else {
return {
ok: Authorization.create(
// @ts-expect-error - it may not be a parsed capability but rather a
// group of capabilities but we can deal with that in the future.
matched,
[result.ok]
),
}
}
}
}
return {
error: new InvalidClaim({
match,
delegationErrors,
unknownCapabilities,
invalidProofs,
failedProofs,
}),
}
}
class ProofError extends Failure {
/**
* @param {API.UCANLink} proof
* @param {API.Failure} cause
*/
constructor(proof, cause) {
super()
this.name = 'ProofError'
this.proof = proof
this.cause = cause
}
describe() {
return [
`Capability can not be derived from prf:${this.proof} because:`,
li(this.cause.message),
].join(`\n`)
}
}
/**
* @implements {API.InvalidClaim}
*/
class InvalidClaim extends Failure {
/**
* @param {{
* match: API.Match
* delegationErrors: API.DelegationError[]
* unknownCapabilities: API.Capability[]
* invalidProofs: ProofError[]
* failedProofs: API.InvalidClaim[]
* }} info
*/
constructor(info) {
super()
this.info = info
this.name = /** @type {const} */ ('InvalidClaim')
}
get issuer() {
return this.delegation.issuer
}
get delegation() {
return this.info.match.source[0].delegation
}
describe() {
const errors = [
...this.info.failedProofs.map(error => li(error.message)),
...this.info.delegationErrors.map(error => li(error.message)),
...this.info.invalidProofs.map(error => li(error.message)),
]
const unknown = this.info.unknownCapabilities.map(c =>
li(JSON.stringify(c))
)
return [
`Capability ${this.info.match} is not authorized because:`,
li(`Capability can not be (self) issued by '${this.issuer.did()}'`),
...(errors.length > 0 ? errors : [li(`Delegated capability not found`)]),
...(unknown.length > 0
? [li(`Encountered unknown capabilities\n${unknown.join('\n')}`)]
: []),
].join('\n')
}
}
/**
* Validate a delegation to check it is within the time bound and that it is
* authorized by the issuer.
*
* @template {API.Delegation} T
* @param {T} delegation
* @param {API.Delegation[]} proofs
* @param {Required<API.ClaimOptions>} config
* @returns {Promise<API.Result<T, API.InvalidProof|API.SessionEscalation|API.DIDKeyResolutionError>>}
*/
const validate = async (delegation, proofs, config) => {
if (UCAN.isExpired(delegation.data)) {
return {
error: new Expired(
/** @type {API.Delegation & {expiration: number}} */(delegation)
),
}
}
if (UCAN.isTooEarly(delegation.data)) {
return {
error: new NotValidBefore(
/** @type {API.Delegation & {notBefore: number}} */(delegation)
),
}
}
return await verifyAuthorization(delegation, proofs, config)
}
/**
* Verifies that delegation has been authorized by the issuer. If issued by the
* did:key principal checks that the signature is valid. If issued by the root
* authority checks that the signature is valid. If issued by the principal
* identified by other DID method attempts to resolve a valid `ucan/attest`
* attestation from the authority, if attestation is not found falls back to
* resolving did:key for the issuer and verifying its signature.
*
* @template {API.Delegation} T
* @param {T} delegation
* @param {API.Delegation[]} proofs
* @param {Required<API.ClaimOptions>} config
* @returns {Promise<API.Result<T, API.InvalidSignature|API.SessionEscalation|API.DIDKeyResolutionError>>}
*/
const verifyAuthorization = async (delegation, proofs, config) => {
const issuer = delegation.issuer.did()
// If the issuer is a did:key we just verify a signature
if (issuer.startsWith('did:key:')) {
return verifySignature(delegation, config.principal.parse(issuer))
}
// If the issuer is the root authority we use authority itself to verify
else if (issuer === config.authority.did()) {
return verifySignature(delegation, config.authority)
} else {
// If issuer is not a did:key principal nor configured authority, we
// attempt to resolve embedded authorization session from the authority.
const session = await verifySession(delegation, proofs, config)
// If we have valid session we consider authorization valid
if (session.ok) {
return { ok: delegation }
} else if (session.error.failedProofs.length > 0) {
return {
error: new SessionEscalation({ delegation, cause: session.error }),
}
}
// Otherwise we try to resolve did:key from the DID instead
// and use that to verify the signature
else {
const result = await config.resolveDIDKey(issuer)
if (result.error) {
return result
}
const verifiers = result.ok
/** @type {(API.InvalidSignature | API.DIDKeyResolutionError)[]} */
const verificationErrResults = []
for (const verifier of verifiers) {
const verificationResult = await verifySignature(
delegation,
config.principal.parse(verifier).withDID(issuer)
)
if (verificationResult.ok) {
return verificationResult
}
if (verificationResult.error) {
verificationErrResults.push(verificationResult.error)
}
}
// If no verifiers were found, there is no way to verify the signature
if (verificationErrResults.length === 0) {
return { error: new DIDKeyResolutionError(issuer) }
}
const combinedError = verificationErrResults[0]
const combinedMessage = verificationErrResults
.map(err => err.message)
.join('\n ')
// @ts-expect-error - both error types have describe method, override it to return the concatenated message
combinedError.describe = () => combinedMessage
return { error: combinedError }
}
}
}
/**
* @template {API.Delegation} T
* @param {T} delegation
* @param {API.Verifier} verifier
* @returns {Promise<API.Result<T, API.InvalidSignature|API.DIDKeyResolutionError>>}
*/
const verifySignature = async (delegation, verifier) => {
const valid = await UCAN.verifySignature(delegation.data, verifier)
return valid
? { ok: delegation }
: { error: new InvalidSignature(delegation, verifier) }
}
/**
* Attempts to find an authorization session - an `ucan/attest` capability
* delegation where `with` matches `config.authority` and `nb.proof`
* matches given delegation.
* @see https://github.com/web3-storage/specs/blob/feat/auth+account/w3-session.md#authorization-session
*
* @param {API.Delegation} delegation
* @param {API.Delegation[]} proofs
* @param {Required<API.ClaimOptions>} config
*/
const verifySession = async (delegation, proofs, config) => {
// Recognize attestations from all authorized principals, not just authority
const withSchemas = config.proofs
.filter(
p =>
p.capabilities[0].can === 'ucan/attest' &&
p.capabilities[0].with === config.authority.did()
)
.map(p => Schema.literal(p.audience.did()))
const withSchema = withSchemas.length
? Schema.union([Schema.literal(config.authority.did()), ...withSchemas])
: Schema.literal(config.authority.did())
// Create a schema that will match an authorization for this exact delegation
const attestation = capability({
with: withSchema,
can: 'ucan/attest',
nb: Schema.struct({
proof: Schema.link(delegation.cid),
}),
})
return await claim(
attestation,
proofs
// We only consider attestations otherwise we will end up doing an
// exponential scan if there are other proofs that require attestations.
.filter(isAttestation)
// Also filter any proofs that _are_ the delegation we're verifying so
// we don't recurse indefinitely.
.filter(p => p.cid.toString() !== delegation.cid.toString()),
config
)
}
/**
* Checks if the delegation is an attestation.
*
* @param {API.Delegation} proof
*/
const isAttestation = proof => proof.capabilities[0]?.can === 'ucan/attest'