Skip to content

Commit

Permalink
feat: includes proofs chains in the delegated authorization chain (#467)
Browse files Browse the repository at this point in the history
This pr cleans up bunch of mess in the access/authorize code path and
starts including proof chains in the delegated authorization so agents
can actually utilize delegations.
  • Loading branch information
Gozala committed Mar 3, 2023
1 parent 854fd5a commit 743a72f
Show file tree
Hide file tree
Showing 8 changed files with 884 additions and 383 deletions.
98 changes: 46 additions & 52 deletions packages/access-api/src/routes/validate-email.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/* eslint-disable no-unused-vars */
import {
stringToDelegation,
delegationsToString,
Expand All @@ -15,6 +14,7 @@ import {
import * as ucanto from '@ucanto/core'
import * as validator from '@ucanto/validator'
import { Verifier, Absentee } from '@ucanto/principal'
import { collect } from 'streaming-iterables'

/**
* @param {import('@web3-storage/worker-utils/router').ParsedRequest} req
Expand All @@ -39,9 +39,10 @@ export async function validateEmail(req, env) {
return recover(req, env)
}

if (req.query && req.query.ucan && req.query.mode === 'session') {
return session(req, env)
if (req.query && req.query.ucan && req.query.mode === 'authorize') {
return authorize(req, env)
}

if (req.query && req.query.ucan) {
try {
const delegation = await env.models.validations.put(
Expand Down Expand Up @@ -134,93 +135,86 @@ async function recover(req, env) {
* @param {import('@web3-storage/worker-utils/router').ParsedRequest} req
* @param {import('../bindings.js').RouteContext} env
*/
async function session(req, env) {
/** @type {import('@ucanto/interface').Delegation<[import('@web3-storage/capabilities/src/types.js').AccessAuthorize]>} */
const delegation = stringToDelegation(req.query.ucan)

// ⚠️ This is not an ideal solution but we do need to ensure that attacker
// cannot simply send a valid `access/authorize` delegation to the service
// and get an attested session.
if (delegation.issuer.did() !== env.signer.did()) {
throw new Error('Delegation MUST be issued by the service')
}
async function authorize(req, env) {
try {
/**
* @type {import('@ucanto/interface').Delegation<[import('@web3-storage/capabilities/src/types.js').AccessConfirm]>}
*/
const request = stringToDelegation(req.query.ucan)

// TODO: Figure when do we go through a post vs get request. WebSocket message
// was send regardless of the method, but delegations were only stored on post
// requests.
if (req.method.toLowerCase() === 'post') {
const accessSessionResult = await validator.access(delegation, {
capability: Access.authorize,
const confirmation = await validator.access(request, {
capability: Access.confirm,
principal: Verifier,
authority: env.signer,
})

if (accessSessionResult.error) {
throw new Error(
`unable to validate access session: ${accessSessionResult.error}`
)
if (confirmation.error) {
throw new Error(`unable to validate access session: ${confirmation}`)
}
if (confirmation.capability.with !== env.signer.did()) {
throw new Error(`Not a valid access/confirm delegation`)
}

// Create a absentee signer for the account that authorized the delegation
const account = Absentee.from({ id: accessSessionResult.capability.nb.iss })
const agent = Verifier.parse(accessSessionResult.capability.with)
const account = Absentee.from({ id: confirmation.capability.nb.iss })
const agent = Verifier.parse(confirmation.capability.nb.aud)

// It the future we should instead render a page and allow a user to select
// which delegations they wish to re-delegate. Right now we just re-delegate
// everything that was requested for all of the resources.
const capabilities =
/** @type {ucanto.UCAN.Capabilities} */
(
accessSessionResult.capability.nb.att.map(({ can }) => ({
confirmation.capability.nb.att.map(({ can }) => ({
can,
with: /** @type {ucanto.UCAN.Resource} */ ('ucan:*'),
}))
)

// create an authorization on behalf of the account with an absent
// signature.
const authorization = await ucanto.delegate({
// create an delegation on behalf of the account with an absent signature.
const delegation = await ucanto.delegate({
issuer: account,
audience: agent,
capabilities,
expiration: Infinity,
// We should also include proofs with all the delegations we have for
// the account.
// We include all the delegations to the account so that the agent will
// have delegation chains to all the delegated resources.
// We should actually filter out only delegations that support delegated
// capabilities, but for now we just include all of them since we only
// implement sudo access anyway.
proofs: await collect(
env.models.delegations.find({
audience: account.did(),
})
),
})

const attestation = await ucanto.delegate({
const attestation = await Access.session.delegate({
issuer: env.signer,
audience: agent,
capabilities: [
{
with: env.signer.did(),
can: 'ucan/attest',
nb: { proof: authorization.cid },
},
],
with: env.signer.did(),
nb: { proof: delegation.cid },
expiration: Infinity,
})

// Store the delegations so that they can be pulled with access/claim
await env.models.delegations.putMany(authorization, attestation)
// The fact that we're storing proofs chains that we pulled from the
// database is not great, but it's a tradeoff we're making for now.
await env.models.delegations.putMany(delegation, attestation)

const authorization = delegationsToString([delegation, attestation])
// Send delegations to the client through a websocket
await env.models.validations.putSession(
delegationsToString([authorization, attestation]),
agent.did()
)
}
await env.models.validations.putSession(authorization, agent.did())

// TODO: We clearly should not render that access/delegate in the QR code, but
// I'm not sure what this QR code is used for.
try {
// We render HTML page explaining to the user what has happened and providing
// a QR code in the details if they want to drill down.
return new HtmlResponse(
(
<ValidateEmail
email={toEmail(delegation.audience.did())}
audience={delegation.audience.did()}
ucan={req.query.ucan}
qrcode={await QRCode.toString(req.query.ucan, {
email={toEmail(account.did())}
audience={agent.did()}
ucan={authorization}
qrcode={await QRCode.toString(authorization, {
type: 'svg',
errorCorrectionLevel: 'M',
margin: 10,
Expand Down
76 changes: 43 additions & 33 deletions packages/access-api/src/service/access-authorize.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
// @ts-ignore
// eslint-disable-next-line no-unused-vars
import * as Ucanto from '@ucanto/interface'
import * as Server from '@ucanto/server'
import * as Access from '@web3-storage/capabilities/access'
import * as Mailto from '../utils/did-mailto.js'
Expand All @@ -11,39 +8,52 @@ import { delegationToString } from '@web3-storage/access/encoding'
* @param {import('../bindings').RouteContext} ctx
*/
export function accessAuthorizeProvider(ctx) {
return Server.provide(
Access.authorize,
async ({ capability, invocation }) => {
/**
* We re-delegate the capability to the account DID and limit it's
* lifetime to 15 minutes which should be enough time for the user to
* complete the authorization. We don't want to allow authorization for
* long time because it could be used by an attacker to gain authorization
* by sending second request misleading a user to click a wrong one.
*/
const authorization = await Access.authorize
.invoke({
issuer: ctx.signer,
audience: DID.parse(capability.nb.iss),
with: capability.with,
lifetimeInSeconds: 60 * 15, // 15 minutes
nb: capability.nb,
proofs: [invocation],
})
.delegate()
return Server.provide(Access.authorize, async ({ capability }) => {
/**
* We delegate to the account DID `access/confirm` capability which will
* get embedded in the URL that we send to the user. When user clicks the
* link we'll get this delegation back in the `/validate-email` endpoint
* which will allow us to verify that it was the user who clicked the link
* and not some attacker impersonating the user. We will know that because
* the `with` field is our service DID and only private key holder is able
* to issue such delegation.
*
* We limit lifetime of this UCAN to 15 minutes to reduce the attack
* surface where an attacker could attempt concurrent authorization
* request in attempt confuse a user into clicking the wrong link.
*/
const confirmation = await Access.confirm
.invoke({
issuer: ctx.signer,
audience: DID.parse(capability.nb.iss),
// Because with is set to our DID no other actor will be able to issue
// this delegation without our private key.
with: ctx.signer.did(),
lifetimeInSeconds: 60 * 15, // 15 minutes
// We link to the authorization request so that this attestation can
// not be used to authorize a different request.
nb: {
// we copy request details and set the `aud` field to the agent DID
// that requested the authorization.
...capability.nb,
aud: capability.with,
},
})
.delegate()

const encoded = delegationToString(authorization)
await ctx.models.accounts.create(capability.nb.iss)

await ctx.models.accounts.create(capability.nb.iss)
// Encode authorization request and our attestation as string so that it
// can be passed as a query parameter in the URL.
const encoded = delegationToString(confirmation)

const url = `${ctx.url.protocol}//${ctx.url.host}/validate-email?ucan=${encoded}&mode=session`
const url = `${ctx.url.protocol}//${ctx.url.host}/validate-email?ucan=${encoded}&mode=authorize`

await ctx.email.sendValidation({
to: Mailto.toEmail(capability.nb.iss),
url,
})
await ctx.email.sendValidation({
to: Mailto.toEmail(capability.nb.iss),
url,
})

return {}
}
)
return {}
})
}
4 changes: 2 additions & 2 deletions packages/access-api/src/service/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export function service(ctx) {
claim: (...args) => {
// disable until hardened in test/staging
if (ctx.config.ENV === 'production') {
throw new Error(`acccess/claim invocation handling is not enabled`)
throw new Error(`access/claim invocation handling is not enabled`)
}
return accessClaimProvider({
delegations: ctx.models.delegations,
Expand All @@ -41,7 +41,7 @@ export function service(ctx) {
delegate: (...args) => {
// disable until hardened in test/staging
if (ctx.config.ENV === 'production') {
throw new Error(`acccess/delegate invocation handling is not enabled`)
throw new Error(`access/delegate invocation handling is not enabled`)
}
return accessDelegateProvider({
delegations: ctx.models.delegations,
Expand Down
Loading

0 comments on commit 743a72f

Please sign in to comment.