Skip to content

Commit

Permalink
Implement 0.8 spec (#52)
Browse files Browse the repository at this point in the history
* Adapt to 0.8 spec

* Readme feedback
  • Loading branch information
icidasset authored Mar 29, 2022
1 parent 46f406d commit 978dfd2
Show file tree
Hide file tree
Showing 32 changed files with 990 additions and 628 deletions.
15 changes: 8 additions & 7 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,20 @@ module.exports = {
"plugin:@typescript-eslint/recommended",
],
rules: {
"@typescript-eslint/member-delimiter-style": ["error", {
"@typescript-eslint/member-delimiter-style": [ "error", {
"multiline": {
"delimiter": "none",
"requireLast": false
},
}],
"@typescript-eslint/no-use-before-define": ["off"],
"@typescript-eslint/semi": ["error", "never"],
} ],
"@typescript-eslint/no-use-before-define": [ "off" ],
"@typescript-eslint/semi": [ "error", "never" ],
"@typescript-eslint/ban-ts-comment": 1,
"@typescript-eslint/quotes": ["error", "double", {
"@typescript-eslint/quotes": [ "error", "double", {
allowTemplateLiterals: true
}],
} ],
// If you want to *intentionally* run a promise without awaiting, prepend it with "void " instead of "await "
"@typescript-eslint/no-floating-promises": ["error"],
"@typescript-eslint/no-floating-promises": [ "error" ],
"@typescript-eslint/no-inferrable-types": [ "off" ],
}
}
69 changes: 43 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ At a high level, UCANs (“User Controlled Authorization Network”) are an auth

No all-powerful authorization server or server of any kind is required for UCANs. Instead, everything a user can do is captured directly in a key or token, which can be sent to anyone who knows how to interpret the UCAN format. Because UCANs are self-contained, they are easy to consume permissionlessly, and they work well offline and in distributed systems.


UCANs work
- Server -> Server
- Client -> Server
Expand All @@ -20,14 +19,18 @@ UCANs work
Read more in the whitepaper: https://whitepaper.fission.codes/access-control/ucan



## Structure
### Header

### Header

`alg`, Algorithm, the type of signature.

`typ`, Type, the type of this data structure, JWT.

`uav`, UCAN version.
### Payload

### Payload

`att`, Attenuation, a list of resources and capabilities that the ucan grants.

Expand All @@ -44,41 +47,52 @@ Read more in the whitepaper: https://whitepaper.fission.codes/access-control/uca
`prf`, Proof, an optional nested token with equal or greater privileges.

### Signature

A signature (using `alg`) of the base64 encoded header and payload concatenated together and delimited by `.`

## Build params
Use `ucan.build` to help in formatting and signing a ucan. It takes the following parameters


## Build

`ucan.build` can be used to help in formatting and signing a UCAN. It takes the following parameters:
```ts
export type BuildParams = {
// to/from
audience: string
type BuildParams = {
// from/to
issuer: Keypair
audience: string

// capabilities
capabilities: Array<Capability>
capabilities?: Array<Capability>

// time bounds
lifetimeInSeconds?: number // expiration overrides lifetimeInSeconds
expiration?: number
notBefore?: number

// proof / other info
// proofs / other info
facts?: Array<Fact>
proof?: string

// in the weeds
ucanVersion?: string
proofs?: Array<string>
addNonce?: boolean
}
```
### Capabilities
`capabilities` is an array of resources and permission level formatted as:
`capabilities` is an array of resource pointers and abilities:
```ts
{
$TYPE: $IDENTIFIER,
"cap": $CAPABILITY
// `with` is a resource pointer in the form of a URI, which has a `scheme` and `hierPart`.
// → "mailto:[email protected]"
with: { scheme: "mailto", hierPart: "[email protected]" },

// `can` is an ability, which always has a namespace and optional segments.
// → "msg/SEND"
can: { namespace: "msg", segments: [ "SEND" ] }
}
```
## Installation
### NPM:
Expand All @@ -95,25 +109,25 @@ yarn add ucans
## Example
```ts
import * as ucan from 'ucans'
import * as ucan from "ucans"

// in-memory keypair
const keypair = await ucan.EdKeypair.create()
const u = await ucan.build({
audience: "did:key:zabcde...", //recipient DID
issuer: keypair, //signing key
audience: "did:key:zabcde...", // recipient DID
issuer: keypair, // signing key
capabilities: [ // permissions for ucan
{
"wnfs": "boris.fission.name/public/photos/",
"cap": "OVERWRITE"
with: { scheme: "wnfs", hierPart: "//boris.fission.name/public/photos/" },
can: { namespace: "wnfs", segments: [ "OVERWRITE" ] }
},
{
"wnfs": "boris.fission.name/private/4tZA6S61BSXygmJGGW885odfQwpnR2UgmCaS5CfCuWtEKQdtkRnvKVdZ4q6wBXYTjhewomJWPL2ui3hJqaSodFnKyWiPZWLwzp1h7wLtaVBQqSW4ZFgyYaJScVkBs32BThn6BZBJTmayeoA9hm8XrhTX4CGX5CVCwqvEUvHTSzAwdaR",
"cap": "APPEND"
with: { scheme: "wnfs", hierPart: "//boris.fission.name/private/4tZA6S61BSXygmJGGW885odfQwpnR2UgmCaS5CfCuWtEKQdtkRnvKVdZ4q6wBXYTjhewomJWPL2ui3hJqaSodFnKyWiPZWLwzp1h7wLtaVBQqSW4ZFgyYaJScVkBs32BThn6BZBJTmayeoA9hm8XrhTX4CGX5CVCwqvEUvHTSzAwdaR" },
can: { namespace: "wnfs", segments: [ "APPEND" ] }
},
{
"email": "[email protected]",
"cap": "SEND"
with: { scheme: "mailto", hierPart: "[email protected]" },
can: { namespace: "wnfs", segments: [ "SEND" ] }
}
]
})
Expand All @@ -124,6 +138,8 @@ const payload = await ucan.buildPayload(...)
const u = await ucan.sign(payload, keyType, signingFn)
```



## Sponsors

Sponsors that contribute developer time or resources to this implementation of UCANs:
Expand All @@ -133,4 +149,5 @@ Sponsors that contribute developer time or resources to this implementation of U


## UCAN Toucan

![](https://ipfs.runfission.com/ipfs/QmcyAwK7AjvLXbGuL4cqG5nufEKJquFmFGo2SDsaAe939Z)
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,13 @@
"dependencies": {
"@stablelib/ed25519": "^1.0.2",
"one-webcrypto": "^1.0.1",
"semver": "^7.3.5",
"uint8arrays": "^3.0.0"
},
"devDependencies": {
"@types/jest": "^27.0.1",
"@types/node": "^16.9.1",
"@types/semver": "^7.3.9",
"@typescript-eslint/eslint-plugin": "^5.5.0",
"@typescript-eslint/parser": "^5.5.0",
"eslint": "^8.3.0",
Expand Down
84 changes: 51 additions & 33 deletions src/attenuation.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
// https://whitepaper.fission.codes/access-control/ucan/jwt-authentication#attenuation
import { Capability, Ucan } from "./types"
// https://github.com/ucan-wg/spec/blob/dd4ac83f893cef109f5a26b07970b2484f23aabf/README.md#325-attenuation-scope
import { Capability } from "./capability"
import { Chained } from "./chained"
import { Ucan } from "./types"
import * as util from "./util"


// TYPES


export interface CapabilitySemantics<A> {
/**
* Try to parse a capability into a representation used for
Expand All @@ -14,6 +18,7 @@ export interface CapabilitySemantics<A> {
* return `null`.
*/
tryParsing(cap: Capability): A | null

/**
* This figures out whether a given `childCap` can be delegated from `parentCap`.
* There are three possible results with three return types respectively:
Expand All @@ -22,62 +27,57 @@ export interface CapabilitySemantics<A> {
* - `CapabilityEscalation<A>`: It's clear that `childCap` is meant to be delegated from `parentCap`, but there's a rights escalation.
*/
tryDelegating(parentCap: A, childCap: A): A | null | CapabilityEscalation<A>
// TODO builders
}

export type CapabilityResult<A>
= CapabilityWithInfo<A>
| CapabilityEscalation<A>

export interface CapabilityInfo {
originator: string // DID
expiresAt: number
notBefore?: number
}


export interface CapabilityWithInfo<A> {
info: CapabilityInfo
capability: A
}


export type CapabilityResult<A>
= CapabilityWithInfo<A>
| CapabilityEscalation<A>


export interface CapabilityEscalation<A> {
escalation: string // reason
capability: A // the capability that escalated rights
}



// TYPE CHECKING


export function isCapabilityEscalation<A>(obj: unknown): obj is CapabilityEscalation<A> {
return util.isRecord(obj)
&& util.hasProp(obj, "escalation") && typeof obj.escalation === "string"
&& util.hasProp(obj, "capability")
}

export function hasCapability<Cap>(semantics: CapabilitySemantics<Cap>, capability: CapabilityWithInfo<Cap>, ucan: Chained): CapabilityWithInfo<Cap> | false {
for (const cap of capabilities(ucan, semantics)) {
if (isCapabilityEscalation(cap)) {
continue
}

const delegatedCapability = semantics.tryDelegating(cap.capability, capability.capability)

if (isCapabilityEscalation(delegatedCapability)) {
continue
}
// PARSING

if (delegatedCapability != null) {
return {
info: delegateCapabilityInfo(capability.info, cap.info),
capability: delegatedCapability,
}
}
}

return false
function parseCapabilityInfo(ucan: Ucan<never>): CapabilityInfo {
return {
originator: ucan.payload.iss,
expiresAt: ucan.payload.exp,
...(ucan.payload.nbf != null ? { notBefore: ucan.payload.nbf } : {}),
}
}



// FUNCTIONS


export function canDelegate<A>(semantics: CapabilitySemantics<A>, capability: A, ucan: Chained): boolean {
for (const cap of capabilities(ucan, semantics)) {
if (isCapabilityEscalation(cap)) {
Expand All @@ -98,7 +98,6 @@ export function canDelegate<A>(semantics: CapabilitySemantics<A>, capability: A,
return false
}


export function capabilities<A>(
ucan: Chained,
capability: CapabilitySemantics<A>,
Expand Down Expand Up @@ -171,10 +170,29 @@ function delegateCapabilityInfo(childInfo: CapabilityInfo, parentInfo: Capabilit
}
}

function parseCapabilityInfo(ucan: Ucan<never>): CapabilityInfo {
return {
originator: ucan.payload.iss,
expiresAt: ucan.payload.exp,
...(ucan.payload.nbf != null ? { notBefore: ucan.payload.nbf } : {}),
export function hasCapability<Cap>(
semantics: CapabilitySemantics<Cap>,
capability: CapabilityWithInfo<Cap>,
ucan: Chained
): CapabilityWithInfo<Cap> | false {
for (const cap of capabilities(ucan, semantics)) {
if (isCapabilityEscalation(cap)) {
continue
}

const delegatedCapability = semantics.tryDelegating(cap.capability, capability.capability)

if (isCapabilityEscalation(delegatedCapability)) {
continue
}

if (delegatedCapability != null) {
return {
info: delegateCapabilityInfo(capability.info, cap.info),
capability: delegatedCapability,
}
}
}

return false
}
15 changes: 8 additions & 7 deletions src/builder.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as token from "./token"
import * as util from "./util"
import { Capability, Keypair, Fact, UcanPayload, isKeypair, isCapability } from "./types"
import { Keypair, Fact, UcanPayload, isKeypair } from "./types"
import { Capability, isCapability } from "./capability"
import { CapabilityInfo, CapabilitySemantics, canDelegate } from "./attenuation"
import { Chained } from "./chained"
import { Store } from "./store"
Expand Down Expand Up @@ -158,7 +159,7 @@ export class Builder<State extends Partial<BuildableState>> {
}
return new Builder(this.state, {
...this.defaultable,
facts: [...this.defaultable.facts, fact, ...facts]
facts: [ ...this.defaultable.facts, fact, ...facts ]
})
}

Expand All @@ -180,7 +181,7 @@ export class Builder<State extends Partial<BuildableState>> {
}
return new Builder(this.state, {
...this.defaultable,
capabilities: [...this.defaultable.capabilities, capability, ...capabilities]
capabilities: [ ...this.defaultable.capabilities, capability, ...capabilities ]
})
}

Expand Down Expand Up @@ -231,9 +232,9 @@ export class Builder<State extends Partial<BuildableState>> {
}
return new Builder(this.state, {
...this.defaultable,
capabilities: [...this.defaultable.capabilities, requiredCapability],
capabilities: [ ...this.defaultable.capabilities, requiredCapability ],
proofs: this.defaultable.proofs.find(proof => proof.encoded() === storeOrProof.encoded()) == null
? [...this.defaultable.proofs, storeOrProof]
? [ ...this.defaultable.proofs, storeOrProof ]
: this.defaultable.proofs
})
} else {
Expand All @@ -243,9 +244,9 @@ export class Builder<State extends Partial<BuildableState>> {
if (result.success) {
return new Builder(this.state, {
...this.defaultable,
capabilities: [...this.defaultable.capabilities, requiredCapability],
capabilities: [ ...this.defaultable.capabilities, requiredCapability ],
proofs: this.defaultable.proofs.find(proof => proof.encoded() === result.ucan.encoded()) == null
? [...this.defaultable.proofs, result.ucan]
? [ ...this.defaultable.proofs, result.ucan ]
: this.defaultable.proofs
})
} else {
Expand Down
Loading

0 comments on commit 978dfd2

Please sign in to comment.