Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: update session API #227

Merged
merged 12 commits into from
Feb 28, 2023
2 changes: 1 addition & 1 deletion packages/client/test/fixtures.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as ed25519 from '@ucanto/principal/ed25519'

/** did:key:z6Mkqa4oY9Z5Pf5tUcjLHLUsDjKwMC95HGXdE1j22jkbhz6r */
/** did:key:z6Mkk89bC3JrVqKie71YEcc5M1SMVxuCgNx6zLZ8SYJsxALi */
export const alice = ed25519.parse(
'MgCZT5vOnYZoVAeyjnzuJIVY9J4LNtJ+f8Js0cTPuKUpFne0BVEDJjEu6quFIU8yp91/TY/+MYK8GvlKoTDnqOCovCVM='
)
Expand Down
192 changes: 192 additions & 0 deletions packages/core/src/delegation.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import * as UCAN from '@ipld/dag-ucan'
import * as Signature from '@ipld/dag-ucan/signature'
import { from as toPrincipal } from '@ipld/dag-ucan/did'
import * as API from '@ucanto/interface'
import * as Link from './link.js'
import * as CBOR from '@ipld/dag-cbor'
import { sha256 } from 'multiformats/hashes/sha2'

/**
* @deprecated
Expand Down Expand Up @@ -149,6 +153,39 @@ export class Delegation {
}
}

/**
* Signer that produces empty signature regardless of the payload, which is used
* when signing delegations from `did:mailto` principal to signal that verifier
* needs to verify authorization interactively.
*
* @template {UCAN.DID} ID
* @implements {UCAN.Signer<ID, Signature.NON_STANDARD>}
*/
class AuthorizationSigner {
/**
* @param {UCAN.Principal<ID>} principal
*/
constructor(principal) {
this.principal = principal
}
did() {
return this.principal.did()
}
/* c8 ignore next 3 */
get signatureCode() {
return Signature.NON_STANDARD
}
get signatureAlgorithm() {
return ''
}
sign() {
return Signature.createNonStandard(
this.signatureAlgorithm,
new Uint8Array(0)
)
}
}

/**
* @param {API.Delegation} delegation
* @returns {IterableIterator<API.Delegation>}
Expand Down Expand Up @@ -222,6 +259,161 @@ export const delegate = async (
return delegation
}

/**
* Takes a delegation data and derives a permit, which in nutshell is
* a CID of the delegation without proofs and signature. Here we return a view
* of the permit that provides some sugar along the way.
*
* @template {API.Capabilities} Capabilities
* @param {object} input
* @param {API.Principal} input.issuer - Authorizing principal
* @param {API.Principal} input.audience - Agent been authorized
* @param {Capabilities} input.capabilities - Capabilities delegated
* @param {number} [input.lifetimeInSeconds]
* @param {UCAN.UTCUnixTimestamp} [input.expiration]
* @param {UCAN.UTCUnixTimestamp} [input.notBefore]
* @param {UCAN.Fact[]} [input.facts]
* @param {UCAN.Nonce} [input.nonce]
*/
export const permit = async ({
issuer,
audience,
capabilities,
lifetimeInSeconds = 30,
expiration = UCAN.now() + lifetimeInSeconds,
notBefore,
facts = [],
nonce,
}) => {
const value = {
iss: toPrincipal(issuer.did()),
aud: toPrincipal(audience.did()),
att: capabilities,
exp: expiration === Infinity ? null : expiration,
...(facts.length > 0 && { fct: facts }),
// need to omit optionals or CBOR will throw
Gozala marked this conversation as resolved.
Show resolved Hide resolved
...(notBefore && { nbf: notBefore }),
...(nonce && { nnc: nonce }),
}
const bytes = CBOR.encode(value)
/** @type {API.Link<AuthorizationModel<Capabilities>, typeof CBOR.code, typeof sha256.code>} */
const cid = Link.create(CBOR.code, await sha256.digest(bytes))
return new Permit({ bytes, cid, value })
}

/**
* @template {API.Capabilities} Capabilities
* @typedef {{
* iss: UCAN.PrincipalView
* aud: UCAN.PrincipalView
* att: Capabilities
* exp: UCAN.UTCUnixTimestamp | null
* fct?: UCAN.Fact[]
* nbf?: UCAN.UTCUnixTimestamp
* nnc?: UCAN.Nonce
* }} AuthorizationModel
*/

/**
* @template {API.Capabilities} Capabilities
*/
class Permit {
/**
* @param {object} root
* @param {AuthorizationModel<Capabilities>} root.value
* @param {API.ByteView<AuthorizationModel<Capabilities>>} root.bytes
* @param {API.Link<AuthorizationModel<Capabilities>, typeof CBOR.code, typeof sha256.code>} root.cid
*/
constructor(root) {
this.root = root
}
get cid() {
return this.root.cid
}
get issuer() {
return this.root.value.iss
}
get audience() {
return this.root.value.aud
}
/**
* @returns {number}
*/
get expiration() {
const { exp } = this.root.value
return exp === null ? Infinity : exp
}
get capabilities() {
return this.root.value.att
}

/**
* @returns {undefined|number}
*/
get notBefore() {
return this.root.value.nbf
}
/**
* @returns {undefined|string}
*/
get nonce() {
return this.root.value.nnc
}

/**
* @returns {API.Fact[]}
*/
get facts() {
return this.root.value.fct || []
}

/**
* Issues an authorized delegation, that is delegation that contains
* authorization session in the proofs.
*
* @param {object} options
* @param {API.Signer} options.issuer
* @param {API.Principal} [options.authority]
* @param {API.Proof[]} [options.proofs]
* @param {API.UCAN.UTCUnixTimestamp} [options.expiration]
* @param {API.UCAN.UTCUnixTimestamp} [options.notBefore]
*/

async authorize({
issuer,
authority = issuer,
proofs = [],
expiration = Infinity,
notBefore = this.notBefore,
}) {
const proof = await delegate({
issuer,
audience: this.issuer,
capabilities: [
{
can: './update',
with: authority.did(),
nb: { permit: this.cid },
},
],
expiration,
notBefore,
proofs,
})

return await delegate({
issuer: new AuthorizationSigner(this.issuer),
audience: this.audience,
capabilities: this.capabilities,
expiration: this.expiration,
notBefore: this.notBefore,
nonce: this.nonce,
facts: this.facts,
proofs: [proof],
})
}
}

/**
* @template {API.Capabilities} C
* @param {API.UCANBlock<C>} root
Expand Down
106 changes: 106 additions & 0 deletions packages/core/test/authorization.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { assert, test } from './test.js'
import {
Delegation,
UCAN,
delegate,
parseLink,
isLink,
isDelegation,
} from '../src/lib.js'
import { alice, bob, mallory, service as w3 } from './fixtures.js'

test('basic authorize', async () => {
const account = alice.withDID('did:mailto:web.mail:alice')
const now = UCAN.now()
const permit = await Delegation.permit({
issuer: account,
audience: alice,
capabilities: [
{
can: 'access/claim',
with: account.did(),
},
],
})

assert.deepEqual(permit.issuer.did(), account.did())
assert.deepEqual(permit.audience.did(), alice.did())
assert.deepEqual(permit.capabilities, [
{
can: 'access/claim',
with: account.did(),
},
])
assert.equal(permit.expiration > now, true)
assert.equal(permit.notBefore, undefined)
assert.equal(permit.nonce, undefined)
assert.deepEqual(isLink(permit.cid), true)
assert.deepEqual(permit.facts, [])

const session = await permit.authorize({ issuer: w3 })
assert.deepEqual(session.expiration, permit.expiration)
assert.deepEqual(session.notBefore, permit.notBefore)
assert.deepEqual(session.issuer.did(), permit.issuer.did())
assert.deepEqual(session.audience.did(), permit.audience.did())
assert.deepEqual(session.capabilities, permit.capabilities)
assert.deepEqual(session.nonce, permit.nonce)
assert.deepEqual(session.facts, permit.facts)
assert.deepEqual(session.signature.code, 0xd000)
assert.deepEqual(session.signature.algorithm, '')
assert.deepEqual(session.signature.raw, new Uint8Array())

const [proof] = session.proofs
if (!isDelegation(proof)) {
assert.fail('expect delegation')
}
assert.deepEqual(proof.expiration, Infinity)
assert.deepEqual(proof.notBefore, permit.notBefore)
assert.deepEqual(proof.issuer.did(), w3.did())
assert.deepEqual(proof.audience.did(), permit.issuer.did())
assert.deepEqual(proof.nonce, permit.nonce)
assert.deepEqual(proof.facts, permit.facts)
assert.deepEqual(proof.capabilities, [
{
with: w3.did(),
can: './update',
nb: { permit: permit.cid },
},
])
})

test('authorize with optionals', async () => {
const account = alice.withDID('did:mailto:web.mail:alice')
const now = 1676532426
const auth = await Delegation.permit({
issuer: account,
audience: alice,
capabilities: [
{
can: 'access/claim',
with: account.did(),
},
],
nonce: 'whatever',
expiration: Infinity,
notBefore: now,
facts: [{ hello: 'world' }],
})

assert.deepEqual(auth.issuer.did(), account.did())
assert.deepEqual(auth.audience.did(), alice.did())
assert.deepEqual(auth.capabilities, [
{
can: 'access/claim',
with: account.did(),
},
])
assert.equal(auth.expiration, Infinity)
assert.equal(auth.notBefore, now)
assert.equal(auth.nonce, 'whatever')
console.log(auth.cid)
assert.deepEqual(
auth.cid,
parseLink('bafyreihz2lyz7zen2wayj3zujra4wor226obrzpru6flslntblnxp7t4qi')
)
assert.deepEqual(auth.facts, [{ hello: 'world' }])
})
2 changes: 1 addition & 1 deletion packages/core/test/fixtures.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as ed25519 from '@ucanto/principal/ed25519'

/** did:key:z6Mkqa4oY9Z5Pf5tUcjLHLUsDjKwMC95HGXdE1j22jkbhz6r */
/** did:key:z6Mkk89bC3JrVqKie71YEcc5M1SMVxuCgNx6zLZ8SYJsxALi */
export const alice = ed25519.parse(
'MgCZT5vOnYZoVAeyjnzuJIVY9J4LNtJ+f8Js0cTPuKUpFne0BVEDJjEu6quFIU8yp91/TY/+MYK8GvlKoTDnqOCovCVM='
)
Expand Down
9 changes: 8 additions & 1 deletion packages/interface/src/capability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@ export interface DIDKeyResolutionError extends Failure {
readonly name: 'DIDKeyResolutionError'
readonly did: UCAN.DID

readonly cause?: Unauthorized
readonly cause?: Failure
}

export interface Expired extends Failure {
Expand All @@ -436,6 +436,12 @@ export interface InvalidSignature extends Failure {
readonly delegation: Delegation
}

export interface SessionEscalation extends Failure {
Gozala marked this conversation as resolved.
Show resolved Hide resolved
readonly name: 'SessionEscalation'
readonly delegation: Delegation
readonly cause: Failure
}

/**
* Error produces by invalid proof
*/
Expand All @@ -444,6 +450,7 @@ export type InvalidProof =
| NotValidBefore
| InvalidSignature
| InvalidAudience
| SessionEscalation
| DIDKeyResolutionError
| UnavailableProof

Expand Down
2 changes: 1 addition & 1 deletion packages/interface/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export interface DelegationOptions<C extends Capabilities> extends UCANOptions {
* the `audience` {@link Principal}.
*
*/
issuer: Signer
issuer: UCAN.Signer

/**
* The `audience` for a {@link Delegation} is the party being delegated to, or the
Expand Down
2 changes: 1 addition & 1 deletion packages/server/test/fixtures.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as ed25519 from '@ucanto/principal/ed25519'

/** did:key:z6Mkqa4oY9Z5Pf5tUcjLHLUsDjKwMC95HGXdE1j22jkbhz6r */
/** did:key:z6Mkk89bC3JrVqKie71YEcc5M1SMVxuCgNx6zLZ8SYJsxALi */
export const alice = ed25519.parse(
'MgCZT5vOnYZoVAeyjnzuJIVY9J4LNtJ+f8Js0cTPuKUpFne0BVEDJjEu6quFIU8yp91/TY/+MYK8GvlKoTDnqOCovCVM='
)
Expand Down
2 changes: 1 addition & 1 deletion packages/transport/test/fixtures.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as ed25519 from '@ucanto/principal/ed25519'

/** did:key:z6Mkqa4oY9Z5Pf5tUcjLHLUsDjKwMC95HGXdE1j22jkbhz6r */
/** did:key:z6Mkk89bC3JrVqKie71YEcc5M1SMVxuCgNx6zLZ8SYJsxALi */
export const alice = ed25519.parse(
'MgCZT5vOnYZoVAeyjnzuJIVY9J4LNtJ+f8Js0cTPuKUpFne0BVEDJjEu6quFIU8yp91/TY/+MYK8GvlKoTDnqOCovCVM='
)
Expand Down
Loading