From 9e6d191e4dbea6a54d1cd0e77ea6889cc267d4f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Wed, 8 Dec 2021 16:06:09 +0100 Subject: [PATCH 01/26] Target ES2015 for spec-compliant iterator support --- tsconfig.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index 85c4b2c..a5aae86 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,11 @@ { "compilerOptions": { + "target": "ES2015", + "moduleResolution": "node", "esModuleInterop": true, "declaration": true, "outDir": "dist", - "declarationDir": "dist", - "resolveJsonModule": true + "declarationDir": "dist" }, "include": [ "src/**/*" From b86ddd7083acf3cfc42743e7683325688821968a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Wed, 8 Dec 2021 16:07:55 +0100 Subject: [PATCH 02/26] Figure out findChain abstraction. Needs a rename though --- src/attenuation.ts | 86 ++++++++++++++++++++++++++ tests/attenuation.test.ts | 125 ++++++++++++++++++++++++++++++++++++++ tests/fixtures.ts | 14 +++++ 3 files changed, 225 insertions(+) create mode 100644 src/attenuation.ts create mode 100644 tests/attenuation.test.ts diff --git a/src/attenuation.ts b/src/attenuation.ts new file mode 100644 index 0000000..99102a7 --- /dev/null +++ b/src/attenuation.ts @@ -0,0 +1,86 @@ +// https://whitepaper.fission.codes/access-control/ucan/jwt-authentication#attenuation +import * as util from "./util" +import { Capability, Ucan } from "./types" +import { Chained } from "./chain" + +export interface CapabilityChecker { + +} + +export interface EmailCapability { + originator: string // DID + expiresAt: number + // notBefore?: number + email: string + potency: "SEND" +} + +interface CapabilityInfo { + originator: string // DID + expiresAt: number +} + +export function emailCapabilities(chain: Chained): EmailCapability[] { + return chain.attenuation().flatMap(bareCap => { + if (!isEmailCapability(bareCap)) { + return [] + } + + const matchesEmailCapability = (cap: Capability, ucan: Ucan) => { + if (isEmailCapability(cap) && cap.email === bareCap.email) { + return { + originator: ucan.payload.iss, + expiresAt: ucan.payload.exp + } + } + } + + const matchAttenutation = (proof: Ucan) => proof.payload.att.reduce( + (acc: CapabilityInfo | null, cap: Capability) => + acc != null ? acc : matchesEmailCapability(cap, proof), + null + ) + + const delegate = (ucan: Ucan, findInParent: Generator) => { + const child: CapabilityInfo | null = matchAttenutation(ucan) + if (child == null) return null + for (const parent of findInParent) { + if (parent != null) { + return { + originator: parent.originator, + expiresAt: Math.min(parent.expiresAt, child.expiresAt), + } + } + } + return child + } + + const info = findChain(chain, delegate) + return info == null ? [] : [{ + originator: info.originator, + expiresAt: info.expiresAt, + email: bareCap.email, + potency: bareCap.cap, + }] + }) +} + +/** + * @returns A representation of delgated capabilities throughout all ucan chains + */ +export function findChain( + ucan: Chained, + delegate: (ucan: Ucan, findInParent: Generator) => A +): A { + return delegate(ucan.payload(), (function *() { + for (const proof of ucan.proofs()) { + yield findChain(proof, delegate) + } + })()) +} + +function isEmailCapability(obj: Capability): obj is { email: string; cap: "SEND" } { + return util.isRecord(obj) + && util.hasProp(obj, "email") && typeof obj.email === "string" + && util.hasProp(obj, "cap") && obj.cap === "SEND" +} diff --git a/tests/attenuation.test.ts b/tests/attenuation.test.ts new file mode 100644 index 0000000..de37d8a --- /dev/null +++ b/tests/attenuation.test.ts @@ -0,0 +1,125 @@ +import { emailCapabilities, findChain } from "../src/attenuation" +import { Chained } from "../src/chain" +import * as token from "../src/token" +import { alice, bob, didToName, mallory } from "./fixtures" + + +describe("attenuation.emailCapabilities", () => { + + it("works with an example", async () => { + // alice -> bob, bob -> mallory + // alice delegates access to sending email as her to bob + // and bob delegates it further to mallory + const leafUcan = await token.build({ + issuer: alice, + audience: bob.did(), + capabilities: [{ + email: "alice@email.com", + cap: "SEND", + }] + }) + + const ucan = await token.build({ + issuer: bob, + audience: mallory.did(), + capabilities: [{ + email: "alice@email.com", + cap: "SEND", + }], + proofs: [token.encode(leafUcan)] + }) + + const emailCaps = emailCapabilities(await Chained.fromToken(token.encode(ucan))) + expect(emailCaps).toEqual([{ + originator: alice.did(), + expiresAt: Math.min(leafUcan.payload.exp, ucan.payload.exp), + email: "alice@email.com", + potency: "SEND" + }]) + }) + + it("will report the first issuer in the chain as originator", async () => { + // alice -> bob, bob -> mallory + // alice delegates nothing to bob + // and bob delegates his email to mallory + const leafUcan = await token.build({ + issuer: alice, + audience: bob.did(), + }) + + const ucan = await token.build({ + issuer: bob, + audience: mallory.did(), + capabilities: [{ + email: "bob@email.com", + cap: "SEND", + }], + proofs: [token.encode(leafUcan)] + }) + + // we implicitly expect the originator to become bob + expect(emailCapabilities(await Chained.fromToken(token.encode(ucan)))).toEqual([{ + originator: bob.did(), + expiresAt: ucan.payload.exp, + email: "bob@email.com", + potency: "SEND" + }]) + }) + + it("will find the right proof chain for the originator", async () => { + // alice -> mallory, bob -> mallory, mallory -> alice + // both alice and bob delegate their email access to mallory + // mallory then creates a UCAN with capability to send both + const leafUcanAlice = await token.build({ + issuer: alice, + audience: mallory.did(), + capabilities: [{ + email: "alice@email.com", + cap: "SEND", + }] + }) + + const leafUcanBob = await token.build({ + issuer: bob, + audience: mallory.did(), + capabilities: [{ + email: "bob@email.com", + cap: "SEND", + }] + }) + + const ucan = await token.build({ + issuer: mallory, + audience: alice.did(), + capabilities: [ + { + email: "alice@email.com", + cap: "SEND", + }, + { + email: "bob@email.com", + cap: "SEND", + } + ], + proofs: [token.encode(leafUcanAlice), token.encode(leafUcanBob)] + }) + + const chained = await Chained.fromToken(token.encode(ucan)) + + expect(emailCapabilities(chained)).toEqual([ + { + originator: alice.did(), + expiresAt: Math.min(leafUcanAlice.payload.exp, ucan.payload.exp), + email: "alice@email.com", + potency: "SEND", + }, + { + originator: bob.did(), + expiresAt: Math.min(leafUcanBob.payload.exp, ucan.payload.exp), + email: "bob@email.com", + potency: "SEND", + } + ]) + }) + +}) diff --git a/tests/fixtures.ts b/tests/fixtures.ts index ada571a..65a7133 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -1,5 +1,19 @@ import EdKey from "../src/keypair/ed25519" + +/** did:key:z6MkfWSvKVrqhmuqGReBD5CTrhJ1us4cRQmasX9rjD1MS8u7 */ export const alice = EdKey.fromSecretKey("t0rXPzUXY9lDyrIf1y96e1/hToGe/t0hBPxZdMp9NWwPrLmvmuQ0fw7vWvZfT5W9mRJKN1hW7+YrY+pAqk8X8g==") + +/** did:key:z6MkubmiZt73SiAFffHpFcmGxYce2JoFMiUbpev5TuYYFRu6 */ export const bob = EdKey.fromSecretKey("w/X3iLRv+NZmDbs1ZOyOHVcAwJTN4Gw0lRW5jOB832ThDYAoRQ3Cs5/OoMpuuXedg64tTt63C+3n/UMR5l+QrQ==") + +/** did:key:z6MkeaLWTPzwVDm2KAgSeUBuEpHfHJTYts5wGaz2srVwZ1Mz */ export const mallory = EdKey.fromSecretKey("IxS23xpPSV5Ae7tYpjVOMBAaM7SNGNBEsOLp7CUVFdMB0By5QJILOgVvSGFUzht1P8TteLd8ZOK+cLq0fexu4Q==") + + +export function didToName(did: string) { + if (did === alice.did()) return "alice" + if (did === bob.did()) return "bob" + if (did === mallory.did()) return "mallory" + return did +} From 18a1fcd99c205b0ae8d0b139a526e8772d4c9db2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Thu, 9 Dec 2021 11:23:58 +0100 Subject: [PATCH 03/26] Rename chain -> chained --- src/{chain.ts => chained.ts} | 0 tests/{chain.test.ts => chained.test.ts} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/{chain.ts => chained.ts} (100%) rename tests/{chain.test.ts => chained.test.ts} (100%) diff --git a/src/chain.ts b/src/chained.ts similarity index 100% rename from src/chain.ts rename to src/chained.ts diff --git a/tests/chain.test.ts b/tests/chained.test.ts similarity index 100% rename from tests/chain.test.ts rename to tests/chained.test.ts From e09d3b340c6b14f213c6d9415dde1fbc55fe96a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Thu, 9 Dec 2021 11:43:08 +0100 Subject: [PATCH 04/26] Move Chained.reduce (prev findChain) into Chained --- src/attenuation.ts | 22 +++++----------------- src/chained.ts | 23 +++++++++++++++++++---- src/index.ts | 2 +- tests/attenuation.test.ts | 6 +++--- tests/chained.test.ts | 2 +- 5 files changed, 29 insertions(+), 26 deletions(-) diff --git a/src/attenuation.ts b/src/attenuation.ts index 99102a7..8724713 100644 --- a/src/attenuation.ts +++ b/src/attenuation.ts @@ -1,7 +1,7 @@ // https://whitepaper.fission.codes/access-control/ucan/jwt-authentication#attenuation import * as util from "./util" import { Capability, Ucan } from "./types" -import { Chained } from "./chain" +import { Chained } from "./chained" export interface CapabilityChecker { @@ -33,6 +33,7 @@ export function emailCapabilities(chain: Chained): EmailCapability[] { expiresAt: ucan.payload.exp } } + return null } const matchAttenutation = (proof: Ucan) => proof.payload.att.reduce( @@ -41,10 +42,10 @@ export function emailCapabilities(chain: Chained): EmailCapability[] { null ) - const delegate = (ucan: Ucan, findInParent: Generator) => { + const delegate = (ucan: Ucan, delegatedInParent: Iterable) => { const child: CapabilityInfo | null = matchAttenutation(ucan) if (child == null) return null - for (const parent of findInParent) { + for (const parent of delegatedInParent) { if (parent != null) { return { originator: parent.originator, @@ -55,7 +56,7 @@ export function emailCapabilities(chain: Chained): EmailCapability[] { return child } - const info = findChain(chain, delegate) + const info = chain.reduce(delegate) return info == null ? [] : [{ originator: info.originator, expiresAt: info.expiresAt, @@ -65,19 +66,6 @@ export function emailCapabilities(chain: Chained): EmailCapability[] { }) } -/** - * @returns A representation of delgated capabilities throughout all ucan chains - */ -export function findChain( - ucan: Chained, - delegate: (ucan: Ucan, findInParent: Generator) => A -): A { - return delegate(ucan.payload(), (function *() { - for (const proof of ucan.proofs()) { - yield findChain(proof, delegate) - } - })()) -} function isEmailCapability(obj: Capability): obj is { email: string; cap: "SEND" } { return util.isRecord(obj) diff --git a/src/chained.ts b/src/chained.ts index c0aa89c..66d7d66 100644 --- a/src/chained.ts +++ b/src/chained.ts @@ -20,13 +20,13 @@ export class Chained { /** * Validate a UCAN chain from a given JWT-encoded UCAN. - * + * * This will validate * - The encoding * - The signatures (unless turned off in the `options`) * - The UCAN time bounds (unless turned off in the `options`) * - The audience from parent proof UCANs matching up with the issuer of child UCANs - * + * * @returns A promise of a deeply-validated, deeply-parsed UCAN. * @throws If the UCAN chain can't be validated. */ @@ -59,6 +59,21 @@ export class Chained { return this._encoded } + /** + * @returns A representation of delgated capabilities throughout all ucan chains + */ + reduce(reduceLayer: (ucan: Ucan, reducedProofs: Iterable) => A): A { + const that = this + + function* reduceProofs() { + for (const proof of that.proofs()) { + yield proof.reduce(reduceLayer) + } + } + + return reduceLayer(this.payload(), reduceProofs()) + } + /* Header */ /** @@ -94,7 +109,7 @@ export class Chained { /** * @returns `iss`: The issuer as a DID string ("did:key:..."). - * + * * The UCAN must be signed with the private key of the issuer to be valid. */ issuer(): string { @@ -103,7 +118,7 @@ export class Chained { /** * @returns `aud`: The audience as a DID string ("did:key:..."). - * + * * This is the identity this UCAN transfers rights to. * It could e.g. be the DID of a service you're posting this UCAN as a JWT to, * or it could be the DID of something that'll use this UCAN as a proof to diff --git a/src/index.ts b/src/index.ts index b6fd536..360c6d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,4 +4,4 @@ export * as keypair from "./keypair" export * from "./keypair/ed25519" export * from "./keypair/rsa" export * from "./types" -export * from "./chain" +export * from "./chained" diff --git a/tests/attenuation.test.ts b/tests/attenuation.test.ts index de37d8a..76e7930 100644 --- a/tests/attenuation.test.ts +++ b/tests/attenuation.test.ts @@ -1,7 +1,7 @@ -import { emailCapabilities, findChain } from "../src/attenuation" -import { Chained } from "../src/chain" +import { emailCapabilities } from "../src/attenuation" +import { Chained } from "../src/chained" import * as token from "../src/token" -import { alice, bob, didToName, mallory } from "./fixtures" +import { alice, bob, mallory } from "./fixtures" describe("attenuation.emailCapabilities", () => { diff --git a/tests/chained.test.ts b/tests/chained.test.ts index 7c23b54..0b474b9 100644 --- a/tests/chained.test.ts +++ b/tests/chained.test.ts @@ -1,5 +1,5 @@ import * as token from "../src/token" -import { Chained } from "../src/chain" +import { Chained } from "../src/chained" import { alice, bob, mallory } from "./fixtures" From 1be3f6d8f7a032c412e4fe05b700b9268762a956 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Thu, 9 Dec 2021 11:43:13 +0100 Subject: [PATCH 05/26] Switch to strict typescript & fix strict errors --- src/crypto/rsa.ts | 4 ++-- src/keypair/ed25519.ts | 2 +- src/keypair/rsa.ts | 11 +++++++---- src/token.ts | 3 +++ src/types.ts | 10 ++++++++++ tsconfig.json | 1 + 6 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/crypto/rsa.ts b/src/crypto/rsa.ts index abe9f3f..01a3c23 100644 --- a/src/crypto/rsa.ts +++ b/src/crypto/rsa.ts @@ -33,10 +33,10 @@ export const importKey = async (key: Uint8Array): Promise => { ) } -export const sign = async (msg: Uint8Array, keypair: CryptoKeyPair): Promise => { +export const sign = async (msg: Uint8Array, privateKey: CryptoKey): Promise => { const buf = await webcrypto.subtle.sign( { name: RSA_ALG, saltLength: SALT_LEGNTH }, - keypair.privateKey, + privateKey, msg.buffer ) return new Uint8Array(buf) diff --git a/src/keypair/ed25519.ts b/src/keypair/ed25519.ts index a09de75..bc92ca1 100644 --- a/src/keypair/ed25519.ts +++ b/src/keypair/ed25519.ts @@ -17,7 +17,7 @@ export class EdKeypair extends BaseKeypair { }): Promise { const { exportable } = params || {} const keypair = nacl.sign.keyPair() - return new EdKeypair(keypair.secretKey, keypair.publicKey, exportable) + return new EdKeypair(keypair.secretKey, keypair.publicKey, exportable ?? false) } static fromSecretKey(key: string, params?: { diff --git a/src/keypair/rsa.ts b/src/keypair/rsa.ts index 2e7dc4a..1d99283 100644 --- a/src/keypair/rsa.ts +++ b/src/keypair/rsa.ts @@ -1,12 +1,12 @@ import * as rsa from "../crypto/rsa" import BaseKeypair from "./base" -import { Encodings } from "../types" +import { Encodings, AvailableCryptoKeyPair, isAvailableCryptoKeyPair } from "../types" export class RsaKeypair extends BaseKeypair { - private keypair: CryptoKeyPair + private keypair: AvailableCryptoKeyPair - constructor(keypair: CryptoKeyPair, publicKey: Uint8Array, exportable: boolean) { + constructor(keypair: AvailableCryptoKeyPair, publicKey: Uint8Array, exportable: boolean) { super(publicKey, "rsa", exportable) this.keypair = keypair } @@ -17,12 +17,15 @@ export class RsaKeypair extends BaseKeypair { }): Promise { const { size = 2048, exportable = false } = params || {} const keypair = await rsa.generateKeypair(size) + if (!isAvailableCryptoKeyPair(keypair)) { + throw new Error(`Couldn't generate valid keypair`) + } const publicKey = await rsa.exportKey(keypair.publicKey) return new RsaKeypair(keypair, publicKey, exportable) } async sign(msg: Uint8Array): Promise { - return await rsa.sign(msg, this.keypair) + return await rsa.sign(msg, this.keypair.privateKey) } async export(format: Encodings = "base64pad"): Promise { diff --git a/src/token.ts b/src/token.ts index f3b2610..846905b 100644 --- a/src/token.ts +++ b/src/token.ts @@ -169,6 +169,9 @@ export function isExpired(ucan: Ucan): boolean { * @param ucan The UCAN to validate */ export const isTooEarly = (ucan: Ucan): boolean => { + if (ucan.payload.nbf == null) { + return false + } return ucan.payload.nbf > Math.floor(Date.now() / 1000) } diff --git a/src/types.ts b/src/types.ts index e8d7fe1..1dc49d2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,12 @@ export interface Keypair { sign: (msg: Uint8Array) => Promise } +/** Unlike tslib's CryptoKeyPair, this requires the `privateKey` and `publicKey` fields */ +export interface AvailableCryptoKeyPair { + privateKey: CryptoKey; + publicKey: CryptoKey; +} + export interface Didable { publicKeyStr: (format?: Encodings) => string did: () => string @@ -75,3 +81,7 @@ export function isUcanPayload(obj: unknown): obj is UcanPayload { export function isCapability(obj: unknown): obj is Capability { return util.isRecord(obj) && util.hasProp(obj, "cap") && typeof obj.cap === "string" } + +export function isAvailableCryptoKeyPair(keypair: CryptoKeyPair): keypair is AvailableCryptoKeyPair { + return keypair.publicKey != null && keypair.privateKey != null +} diff --git a/tsconfig.json b/tsconfig.json index a5aae86..eeca39d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "target": "ES2015", + "strict": true, "moduleResolution": "node", "esModuleInterop": true, "declaration": true, From b46087d70caa0720f21a31b28d99dcbafb79f98d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Thu, 9 Dec 2021 13:22:54 +0100 Subject: [PATCH 06/26] Produce CommonJS --- tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.json b/tsconfig.json index eeca39d..bb84d69 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "target": "ES2015", + "module": "CommonJS", "strict": true, "moduleResolution": "node", "esModuleInterop": true, From 5a4a9803195b8c7663c1452f8e96756b7272a5e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Thu, 9 Dec 2021 13:23:09 +0100 Subject: [PATCH 07/26] Write test for Chained.reduce --- tests/chained.test.ts | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/tests/chained.test.ts b/tests/chained.test.ts index 0b474b9..1b9c56e 100644 --- a/tests/chained.test.ts +++ b/tests/chained.test.ts @@ -1,10 +1,11 @@ import * as token from "../src/token" import { Chained } from "../src/chained" import { alice, bob, mallory } from "./fixtures" +import { Ucan } from "../src/types" -describe("UcanChain.fromToken", () => { - +describe("Chained.fromToken", () => { + it("decodes deep ucan chains", async () => { // alice -> bob, bob -> mallory // delegating rights from alice to mallory through bob @@ -62,3 +63,38 @@ describe("UcanChain.fromToken", () => { await Chained.fromToken(ucan) }) }) + + +describe("Chained.reduce", () => { + + it("can reconstruct the UCAN tree", async () => { + // alice -> bob, mallory -> bob, bob -> alice + const ucan = await Chained.fromToken(token.encode(await token.build({ + issuer: bob, + audience: alice.did(), + proofs: [ + token.encode(await token.build({ + issuer: mallory, + audience: bob.did(), + })), + token.encode(await token.build({ + issuer: alice, + audience: bob.did(), + })), + ] + }))) + + const reconstruction = ucan.reduce((ucan: Ucan, iterProofs: Iterable) => { + const proofs = Array.from(iterProofs) + return token.encode({ + ...ucan, + payload: { + ...ucan.payload, + prf: proofs + } + }) + }) + expect(reconstruction).toEqual(ucan.encoded()) + }) + +}) From 000bfcfcf892d551fd014f68da024bd395b85052 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Thu, 9 Dec 2021 13:23:20 +0100 Subject: [PATCH 08/26] Have some fun --- tests/fixtures.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/fixtures.ts b/tests/fixtures.ts index 65a7133..14e22dd 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -1,14 +1,14 @@ import EdKey from "../src/keypair/ed25519" -/** did:key:z6MkfWSvKVrqhmuqGReBD5CTrhJ1us4cRQmasX9rjD1MS8u7 */ -export const alice = EdKey.fromSecretKey("t0rXPzUXY9lDyrIf1y96e1/hToGe/t0hBPxZdMp9NWwPrLmvmuQ0fw7vWvZfT5W9mRJKN1hW7+YrY+pAqk8X8g==") +/** did:key:z6Mkk89bC3JrVqKie71YEcc5M1SMVxuCgNx6zLZ8SYJsxALi */ +export const alice = EdKey.fromSecretKey("U+bzp2GaFQHso587iSFWPSeCzbSfn/CbNHEz7ilKRZ1UQMmMS7qq4UhTzKn3X9Nj/4xgrwa+UqhMOeo4Ki8JUw==") -/** did:key:z6MkubmiZt73SiAFffHpFcmGxYce2JoFMiUbpev5TuYYFRu6 */ -export const bob = EdKey.fromSecretKey("w/X3iLRv+NZmDbs1ZOyOHVcAwJTN4Gw0lRW5jOB832ThDYAoRQ3Cs5/OoMpuuXedg64tTt63C+3n/UMR5l+QrQ==") +/** did:key:z6MkffDZCkCTWreg8868fG1FGFogcJj5X6PY93pPcWDn9bob */ +export const bob = EdKey.fromSecretKey("G4+QCX1b3a45IzQsQd4gFMMe0UB1UOx9bCsh8uOiKLER69eAvVXvc8P2yc4Iig42Bv7JD2zJxhyFALyTKBHipg==") -/** did:key:z6MkeaLWTPzwVDm2KAgSeUBuEpHfHJTYts5wGaz2srVwZ1Mz */ -export const mallory = EdKey.fromSecretKey("IxS23xpPSV5Ae7tYpjVOMBAaM7SNGNBEsOLp7CUVFdMB0By5QJILOgVvSGFUzht1P8TteLd8ZOK+cLq0fexu4Q==") +/** did:key:z6MktafZTREjJkvV5mfJxcLpNBoVPwDLhTuMg9ng7dY4zMAL */ +export const mallory = EdKey.fromSecretKey("LR9AL2MYkMARuvmV3MJV8sKvbSOdBtpggFCW8K62oZDR6UViSXdSV/dDcD8S9xVjS61vh62JITx7qmLgfQUSZQ==") export function didToName(did: string) { From 59b1d00fe974772efe1f6832c75f153dc88ac75f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Thu, 9 Dec 2021 18:01:31 +0100 Subject: [PATCH 09/26] Write failing test --- tests/attenuation.test.ts | 54 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/tests/attenuation.test.ts b/tests/attenuation.test.ts index 76e7930..451c31d 100644 --- a/tests/attenuation.test.ts +++ b/tests/attenuation.test.ts @@ -6,7 +6,7 @@ import { alice, bob, mallory } from "./fixtures" describe("attenuation.emailCapabilities", () => { - it("works with an example", async () => { + it("works with a simple example", async () => { // alice -> bob, bob -> mallory // alice delegates access to sending email as her to bob // and bob delegates it further to mallory @@ -38,7 +38,7 @@ describe("attenuation.emailCapabilities", () => { }]) }) - it("will report the first issuer in the chain as originator", async () => { + it("reports the first issuer in the chain as originator", async () => { // alice -> bob, bob -> mallory // alice delegates nothing to bob // and bob delegates his email to mallory @@ -66,7 +66,7 @@ describe("attenuation.emailCapabilities", () => { }]) }) - it("will find the right proof chain for the originator", async () => { + it("finds the right proof chain for the originator", async () => { // alice -> mallory, bob -> mallory, mallory -> alice // both alice and bob delegate their email access to mallory // mallory then creates a UCAN with capability to send both @@ -122,4 +122,52 @@ describe("attenuation.emailCapabilities", () => { ]) }) + it("reports all chain options", async () => { + // alice -> mallory, bob -> mallory, mallory -> alice + // both alice and bob claim to have access to alice@email.com + // and both grant that capability to mallory + // a verifier needs to know both to verify valid email access + + const aliceEmail = { + email: "alice@email.com", + cap: "SEND", + } + + const leafUcanAlice = await token.build({ + issuer: alice, + audience: mallory.did(), + capabilities: [aliceEmail] + }) + + const leafUcanBob = await token.build({ + issuer: bob, + audience: mallory.did(), + capabilities: [aliceEmail] + }) + + const ucan = await token.build({ + issuer: mallory, + audience: alice.did(), + capabilities: [aliceEmail], + proofs: [token.encode(leafUcanAlice), token.encode(leafUcanBob)] + }) + + const chained = await Chained.fromToken(token.encode(ucan)) + + expect(emailCapabilities(chained)).toEqual([ + { + originator: alice.did(), + expiresAt: Math.min(leafUcanAlice.payload.exp, ucan.payload.exp), + email: "alice@email.com", + potency: "SEND", + }, + { + originator: bob.did(), + expiresAt: Math.min(leafUcanBob.payload.exp, ucan.payload.exp), + email: "alice@email.com", + potency: "SEND", + } + ]) + }) + }) From d5527031f174933e7b10eeac0ec6d95f4d98628c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Fri, 10 Dec 2021 16:53:59 +0100 Subject: [PATCH 10/26] Refactor --- src/attenuation.ts | 95 ++++++++++++++++++++------------------- src/chained.ts | 4 +- tests/attenuation.test.ts | 8 ++-- 3 files changed, 56 insertions(+), 51 deletions(-) diff --git a/src/attenuation.ts b/src/attenuation.ts index 8724713..b18f807 100644 --- a/src/attenuation.ts +++ b/src/attenuation.ts @@ -7,68 +7,73 @@ export interface CapabilityChecker { } -export interface EmailCapability { +export interface CapabilityInfo { originator: string // DID expiresAt: number // notBefore?: number +} + +export interface EmailCapability extends CapabilityInfo { email: string potency: "SEND" } -interface CapabilityInfo { - originator: string // DID - expiresAt: number +function isEmailCap(obj: Capability): obj is { email: string; cap: "SEND" } { + return util.isRecord(obj) + && util.hasProp(obj, "email") && typeof obj.email === "string" + && util.hasProp(obj, "cap") && obj.cap === "SEND" } -export function emailCapabilities(chain: Chained): EmailCapability[] { - return chain.attenuation().flatMap(bareCap => { - if (!isEmailCapability(bareCap)) { - return [] +export function* emailCapabilities(ucan: Chained): Iterable { + const parseCap = (cap: Capability, ucan: Ucan) => { + if (isEmailCap(cap)) { + return { + originator: ucan.payload.iss, + expiresAt: ucan.payload.exp, + email: cap.email, + potency: cap.cap, + } as EmailCapability } + return null + } - const matchesEmailCapability = (cap: Capability, ucan: Ucan) => { - if (isEmailCapability(cap) && cap.email === bareCap.email) { - return { - originator: ucan.payload.iss, - expiresAt: ucan.payload.exp - } - } - return null + const findParsedCaps = function* (ucan: Ucan) { + for (const cap of ucan.payload.att) { + const emailCap = parseCap(cap, ucan) + if (emailCap != null) yield emailCap as EmailCapability } + } - const matchAttenutation = (proof: Ucan) => proof.payload.att.reduce( - (acc: CapabilityInfo | null, cap: Capability) => - acc != null ? acc : matchesEmailCapability(cap, proof), - null - ) + const isCapabilityLessThan = (childCap: EmailCapability, parentCap: EmailCapability) => { + return childCap.email === parentCap.email // potency is always "SEND" anyway, so doesn't need to be checked + } - const delegate = (ucan: Ucan, delegatedInParent: Iterable) => { - const child: CapabilityInfo | null = matchAttenutation(ucan) - if (child == null) return null - for (const parent of delegatedInParent) { - if (parent != null) { - return { - originator: parent.originator, - expiresAt: Math.min(parent.expiresAt, child.expiresAt), + const delegate = (ucan: Ucan, delegatedInParent: () => Iterable<() => Iterable>) => { + return function* () { + for (const parsedChildCap of findParsedCaps(ucan)) { + console.log("processing", parsedChildCap) + let isCoveredByProof = false + for (const parent of delegatedInParent()) { + for (const parsedParentCap of parent()) { + isCoveredByProof = true + if (isCapabilityLessThan(parsedChildCap, parsedParentCap)) { + console.log("Found a subsumed capability", parsedChildCap, parsedParentCap) + yield ({ + ...parsedChildCap, + originator: parsedParentCap.originator, + expiresAt: Math.min(parsedParentCap.expiresAt, parsedChildCap.expiresAt), + }) + } else { + console.log("NOT subsumed by parent", parsedChildCap, parsedParentCap) + } } } + if (!isCoveredByProof) { + yield parsedChildCap + } } - return child } + } - const info = chain.reduce(delegate) - return info == null ? [] : [{ - originator: info.originator, - expiresAt: info.expiresAt, - email: bareCap.email, - potency: bareCap.cap, - }] - }) -} - - -function isEmailCapability(obj: Capability): obj is { email: string; cap: "SEND" } { - return util.isRecord(obj) - && util.hasProp(obj, "email") && typeof obj.email === "string" - && util.hasProp(obj, "cap") && obj.cap === "SEND" + yield* ucan.reduce(delegate)() } diff --git a/src/chained.ts b/src/chained.ts index 66d7d66..8ef3943 100644 --- a/src/chained.ts +++ b/src/chained.ts @@ -62,7 +62,7 @@ export class Chained { /** * @returns A representation of delgated capabilities throughout all ucan chains */ - reduce(reduceLayer: (ucan: Ucan, reducedProofs: Iterable) => A): A { + reduce(reduceLayer: (ucan: Ucan, reducedProofs: () => Iterable) => A): A { const that = this function* reduceProofs() { @@ -71,7 +71,7 @@ export class Chained { } } - return reduceLayer(this.payload(), reduceProofs()) + return reduceLayer(this.payload(), reduceProofs) } /* Header */ diff --git a/tests/attenuation.test.ts b/tests/attenuation.test.ts index 451c31d..b2ede20 100644 --- a/tests/attenuation.test.ts +++ b/tests/attenuation.test.ts @@ -29,7 +29,7 @@ describe("attenuation.emailCapabilities", () => { proofs: [token.encode(leafUcan)] }) - const emailCaps = emailCapabilities(await Chained.fromToken(token.encode(ucan))) + const emailCaps = Array.from(emailCapabilities(await Chained.fromToken(token.encode(ucan)))) expect(emailCaps).toEqual([{ originator: alice.did(), expiresAt: Math.min(leafUcan.payload.exp, ucan.payload.exp), @@ -58,7 +58,7 @@ describe("attenuation.emailCapabilities", () => { }) // we implicitly expect the originator to become bob - expect(emailCapabilities(await Chained.fromToken(token.encode(ucan)))).toEqual([{ + expect(Array.from(emailCapabilities(await Chained.fromToken(token.encode(ucan))))).toEqual([{ originator: bob.did(), expiresAt: ucan.payload.exp, email: "bob@email.com", @@ -106,7 +106,7 @@ describe("attenuation.emailCapabilities", () => { const chained = await Chained.fromToken(token.encode(ucan)) - expect(emailCapabilities(chained)).toEqual([ + expect(Array.from(emailCapabilities(chained))).toEqual([ { originator: alice.did(), expiresAt: Math.min(leafUcanAlice.payload.exp, ucan.payload.exp), @@ -154,7 +154,7 @@ describe("attenuation.emailCapabilities", () => { const chained = await Chained.fromToken(token.encode(ucan)) - expect(emailCapabilities(chained)).toEqual([ + expect(Array.from(emailCapabilities(chained))).toEqual([ { originator: alice.did(), expiresAt: Math.min(leafUcanAlice.payload.exp, ucan.payload.exp), From 47926806253a4831607e09b3c41515d206729db7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Fri, 10 Dec 2021 16:54:39 +0100 Subject: [PATCH 11/26] :mute: remove logging --- src/attenuation.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/attenuation.ts b/src/attenuation.ts index b18f807..6cc871c 100644 --- a/src/attenuation.ts +++ b/src/attenuation.ts @@ -51,20 +51,17 @@ export function* emailCapabilities(ucan: Chained): Iterable { const delegate = (ucan: Ucan, delegatedInParent: () => Iterable<() => Iterable>) => { return function* () { for (const parsedChildCap of findParsedCaps(ucan)) { - console.log("processing", parsedChildCap) let isCoveredByProof = false for (const parent of delegatedInParent()) { for (const parsedParentCap of parent()) { isCoveredByProof = true if (isCapabilityLessThan(parsedChildCap, parsedParentCap)) { - console.log("Found a subsumed capability", parsedChildCap, parsedParentCap) yield ({ ...parsedChildCap, originator: parsedParentCap.originator, expiresAt: Math.min(parsedParentCap.expiresAt, parsedChildCap.expiresAt), }) } else { - console.log("NOT subsumed by parent", parsedChildCap, parsedParentCap) } } } From d1e942920879cb9856dbd3c6e0daa464cbd5e911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Fri, 10 Dec 2021 17:48:32 +0100 Subject: [PATCH 12/26] Refactor more --- src/attenuation.ts | 54 +++++++++++++++++++++++++--------------------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/src/attenuation.ts b/src/attenuation.ts index 6cc871c..074b11b 100644 --- a/src/attenuation.ts +++ b/src/attenuation.ts @@ -13,55 +13,59 @@ export interface CapabilityInfo { // notBefore?: number } -export interface EmailCapability extends CapabilityInfo { +export interface EmailCapability { email: string potency: "SEND" } -function isEmailCap(obj: Capability): obj is { email: string; cap: "SEND" } { - return util.isRecord(obj) - && util.hasProp(obj, "email") && typeof obj.email === "string" - && util.hasProp(obj, "cap") && obj.cap === "SEND" -} export function* emailCapabilities(ucan: Chained): Iterable { - const parseCap = (cap: Capability, ucan: Ucan) => { - if (isEmailCap(cap)) { + const parseCap = (cap: Capability): EmailCapability | null => { + if (typeof cap.email === "string" && cap.cap === "SEND") { return { - originator: ucan.payload.iss, - expiresAt: ucan.payload.exp, email: cap.email, potency: cap.cap, - } as EmailCapability + } } return null } - const findParsedCaps = function* (ucan: Ucan) { + const parseCapabilityInfo = (ucan: Ucan): CapabilityInfo => ({ + originator: ucan.payload.iss, + expiresAt: ucan.payload.exp, + }) + + function* findParsingCaps(ucan: Ucan): Iterable { + const capInfo = parseCapabilityInfo(ucan) for (const cap of ucan.payload.att) { - const emailCap = parseCap(cap, ucan) - if (emailCap != null) yield emailCap as EmailCapability + const parsedCap = parseCap(cap) + if (parsedCap != null) yield { ...parsedCap, ...capInfo } } } - const isCapabilityLessThan = (childCap: EmailCapability, parentCap: EmailCapability) => { - return childCap.email === parentCap.email // potency is always "SEND" anyway, so doesn't need to be checked + const delegateEmailCap = (childCap: EmailCapability, parentCap: EmailCapability): EmailCapability | null => { + // potency is always "SEND" anyway, so doesn't need to be checked + return childCap.email === parentCap.email ? childCap : null + } + + const delegateInfo = (childCap: A, parentCap: A): A => { + return { + ...childCap, + originator: parentCap.originator, + expiresAt: Math.min(childCap.expiresAt, parentCap.expiresAt), + } } - const delegate = (ucan: Ucan, delegatedInParent: () => Iterable<() => Iterable>) => { + const delegate = (ucan: Ucan, delegatedInParent: () => Iterable<() => Iterable>) => { return function* () { - for (const parsedChildCap of findParsedCaps(ucan)) { + for (const parsedChildCap of findParsingCaps(ucan)) { let isCoveredByProof = false for (const parent of delegatedInParent()) { for (const parsedParentCap of parent()) { isCoveredByProof = true - if (isCapabilityLessThan(parsedChildCap, parsedParentCap)) { - yield ({ - ...parsedChildCap, - originator: parsedParentCap.originator, - expiresAt: Math.min(parsedParentCap.expiresAt, parsedChildCap.expiresAt), - }) - } else { + const delegated = delegateEmailCap(parsedChildCap, parsedParentCap) + if (delegated != null) { + yield delegateInfo({ ...parsedChildCap, ...delegated }, parsedParentCap) } } } From 66f083ea06382f8127ec5a6e6c53197f2c519b72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Fri, 10 Dec 2021 20:52:17 +0100 Subject: [PATCH 13/26] Abstract out capabilities function --- src/attenuation.ts | 71 ++++++++++++++++++++++++++-------------------- 1 file changed, 40 insertions(+), 31 deletions(-) diff --git a/src/attenuation.ts b/src/attenuation.ts index 074b11b..88294cd 100644 --- a/src/attenuation.ts +++ b/src/attenuation.ts @@ -1,5 +1,4 @@ // https://whitepaper.fission.codes/access-control/ucan/jwt-authentication#attenuation -import * as util from "./util" import { Capability, Ucan } from "./types" import { Chained } from "./chained" @@ -18,24 +17,32 @@ export interface EmailCapability { potency: "SEND" } - -export function* emailCapabilities(ucan: Chained): Iterable { - const parseCap = (cap: Capability): EmailCapability | null => { - if (typeof cap.email === "string" && cap.cap === "SEND") { - return { - email: cap.email, - potency: cap.cap, - } +function parseEmailCapability(cap: Capability): EmailCapability | null { + if (typeof cap.email === "string" && cap.cap === "SEND") { + return { + email: cap.email, + potency: cap.cap, } - return null } + return null +} - const parseCapabilityInfo = (ucan: Ucan): CapabilityInfo => ({ - originator: ucan.payload.iss, - expiresAt: ucan.payload.exp, - }) +function delegateEmailCap(childCap: EmailCapability, parentCap: EmailCapability): EmailCapability | null { + // potency is always "SEND" anyway, so doesn't need to be checked + return childCap.email === parentCap.email ? childCap : null +} + +export function emailCapabilities(ucan: Chained) { + return capabilities(ucan, parseEmailCapability, delegateEmailCap) +} - function* findParsingCaps(ucan: Ucan): Iterable { +export function* capabilities( + ucan: Chained, + parseCap: (cap: Capability) => A | null, + delegateCap: (childCap: A, parentCap: A) => A | null +): Iterable { + + function* findParsingCaps(ucan: Ucan): Iterable { const capInfo = parseCapabilityInfo(ucan) for (const cap of ucan.payload.att) { const parsedCap = parseCap(cap) @@ -43,29 +50,16 @@ export function* emailCapabilities(ucan: Chained): Iterable { } } - const delegateEmailCap = (childCap: EmailCapability, parentCap: EmailCapability): EmailCapability | null => { - // potency is always "SEND" anyway, so doesn't need to be checked - return childCap.email === parentCap.email ? childCap : null - } - - const delegateInfo = (childCap: A, parentCap: A): A => { - return { - ...childCap, - originator: parentCap.originator, - expiresAt: Math.min(childCap.expiresAt, parentCap.expiresAt), - } - } - - const delegate = (ucan: Ucan, delegatedInParent: () => Iterable<() => Iterable>) => { + const delegate = (ucan: Ucan, delegatedInParent: () => Iterable<() => Iterable>) => { return function* () { for (const parsedChildCap of findParsingCaps(ucan)) { let isCoveredByProof = false for (const parent of delegatedInParent()) { for (const parsedParentCap of parent()) { isCoveredByProof = true - const delegated = delegateEmailCap(parsedChildCap, parsedParentCap) + const delegated = delegateCap(parsedChildCap, parsedParentCap) if (delegated != null) { - yield delegateInfo({ ...parsedChildCap, ...delegated }, parsedParentCap) + yield delegateCapabilityInfo({ ...parsedChildCap, ...delegated }, parsedParentCap) } } } @@ -78,3 +72,18 @@ export function* emailCapabilities(ucan: Chained): Iterable { yield* ucan.reduce(delegate)() } + +function delegateCapabilityInfo(childCap: A, parentCap: A): A { + return { + ...childCap, + originator: parentCap.originator, + expiresAt: Math.min(childCap.expiresAt, parentCap.expiresAt), + } +} + +function parseCapabilityInfo(ucan: Ucan): CapabilityInfo { + return { + originator: ucan.payload.iss, + expiresAt: ucan.payload.exp, + } +} From 530cdefc78d237a3a5e9469a4388075503975af5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Fri, 10 Dec 2021 21:09:28 +0100 Subject: [PATCH 14/26] Implement notBefore for capabilities --- src/attenuation.ts | 12 +++++++++++- tests/attenuation.test.ts | 13 +++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/attenuation.ts b/src/attenuation.ts index 88294cd..ecc70d2 100644 --- a/src/attenuation.ts +++ b/src/attenuation.ts @@ -9,7 +9,7 @@ export interface CapabilityChecker { export interface CapabilityInfo { originator: string // DID expiresAt: number - // notBefore?: number + notBefore?: number } export interface EmailCapability { @@ -74,10 +74,19 @@ export function* capabilities( } function delegateCapabilityInfo(childCap: A, parentCap: A): A { + let notBefore = {} + if (childCap.notBefore != null && parentCap.notBefore != null) { + notBefore = { notBefore: Math.max(childCap.notBefore, parentCap.notBefore) } + } else if (parentCap.notBefore != null) { + notBefore = { notBefore: parentCap.notBefore } + } else { + notBefore = { notBefore: childCap.notBefore } + } return { ...childCap, originator: parentCap.originator, expiresAt: Math.min(childCap.expiresAt, parentCap.expiresAt), + ...notBefore, } } @@ -85,5 +94,6 @@ function parseCapabilityInfo(ucan: Ucan): CapabilityInfo { return { originator: ucan.payload.iss, expiresAt: ucan.payload.exp, + ...(ucan.payload.nbf != null ? { notBefore: ucan.payload.nbf } : {}), } } diff --git a/tests/attenuation.test.ts b/tests/attenuation.test.ts index b2ede20..4d14b3f 100644 --- a/tests/attenuation.test.ts +++ b/tests/attenuation.test.ts @@ -33,6 +33,7 @@ describe("attenuation.emailCapabilities", () => { expect(emailCaps).toEqual([{ originator: alice.did(), expiresAt: Math.min(leafUcan.payload.exp, ucan.payload.exp), + notBefore: maxNbf(leafUcan.payload.nbf, ucan.payload.nbf), email: "alice@email.com", potency: "SEND" }]) @@ -61,6 +62,7 @@ describe("attenuation.emailCapabilities", () => { expect(Array.from(emailCapabilities(await Chained.fromToken(token.encode(ucan))))).toEqual([{ originator: bob.did(), expiresAt: ucan.payload.exp, + notBefore: ucan.payload.nbf, email: "bob@email.com", potency: "SEND" }]) @@ -110,12 +112,14 @@ describe("attenuation.emailCapabilities", () => { { originator: alice.did(), expiresAt: Math.min(leafUcanAlice.payload.exp, ucan.payload.exp), + notBefore: maxNbf(leafUcanAlice.payload.nbf, ucan.payload.nbf), email: "alice@email.com", potency: "SEND", }, { originator: bob.did(), expiresAt: Math.min(leafUcanBob.payload.exp, ucan.payload.exp), + notBefore: maxNbf(leafUcanBob.payload.nbf, ucan.payload.nbf), email: "bob@email.com", potency: "SEND", } @@ -158,12 +162,14 @@ describe("attenuation.emailCapabilities", () => { { originator: alice.did(), expiresAt: Math.min(leafUcanAlice.payload.exp, ucan.payload.exp), + notBefore: maxNbf(leafUcanAlice.payload.nbf, ucan.payload.nbf), email: "alice@email.com", potency: "SEND", }, { originator: bob.did(), expiresAt: Math.min(leafUcanBob.payload.exp, ucan.payload.exp), + notBefore: maxNbf(leafUcanBob.payload.nbf, ucan.payload.nbf), email: "alice@email.com", potency: "SEND", } @@ -171,3 +177,10 @@ describe("attenuation.emailCapabilities", () => { }) }) + +function maxNbf(parentNbf: number | undefined, childNbf: number | undefined): number | undefined { + if (parentNbf == null && childNbf == null) return undefined + if (parentNbf != null && childNbf != null) return Math.max(parentNbf, childNbf) + if (parentNbf != null) return parentNbf + return childNbf +} From cc3b1fc3b8e83f5aa2d6567cbd942598d1c20641 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Fri, 10 Dec 2021 21:35:05 +0100 Subject: [PATCH 15/26] Abstract out `CapabilitySemantics` --- src/attenuation.ts | 67 +++++++++++++++++++++++++++++----------------- 1 file changed, 42 insertions(+), 25 deletions(-) diff --git a/src/attenuation.ts b/src/attenuation.ts index ecc70d2..325ff51 100644 --- a/src/attenuation.ts +++ b/src/attenuation.ts @@ -2,14 +2,12 @@ import { Capability, Ucan } from "./types" import { Chained } from "./chained" -export interface CapabilityChecker { -} - -export interface CapabilityInfo { - originator: string // DID - expiresAt: number - notBefore?: number +export interface CapabilitySemantics { + parse(cap: Capability): A | null + toCapability(parsedCap: A): Capability + tryDelegating(parentCap: A, childCap: A): A | null + // TODO builders } export interface EmailCapability { @@ -17,35 +15,51 @@ export interface EmailCapability { potency: "SEND" } -function parseEmailCapability(cap: Capability): EmailCapability | null { - if (typeof cap.email === "string" && cap.cap === "SEND") { +export const emailSemantics: CapabilitySemantics = { + + parse(cap: Capability): EmailCapability | null { + if (typeof cap.email === "string" && cap.cap === "SEND") { + return { + email: cap.email, + potency: cap.cap, + } + } + return null + }, + + toCapability(parsed: EmailCapability): Capability { return { - email: cap.email, - potency: cap.cap, + email: parsed.email, + cap: parsed.potency, } - } - return null -} + }, + + tryDelegating(parentCap: EmailCapability, childCap: EmailCapability): EmailCapability | null { + // potency is always "SEND" anyway, so doesn't need to be checked + return childCap.email === parentCap.email ? childCap : null + }, -function delegateEmailCap(childCap: EmailCapability, parentCap: EmailCapability): EmailCapability | null { - // potency is always "SEND" anyway, so doesn't need to be checked - return childCap.email === parentCap.email ? childCap : null } export function emailCapabilities(ucan: Chained) { - return capabilities(ucan, parseEmailCapability, delegateEmailCap) + return capabilities(ucan, emailSemantics) +} + +export interface CapabilityInfo { + originator: string // DID + expiresAt: number + notBefore?: number } -export function* capabilities( +export function capabilities( ucan: Chained, - parseCap: (cap: Capability) => A | null, - delegateCap: (childCap: A, parentCap: A) => A | null -): Iterable { + capability: CapabilitySemantics, +): Iterable { function* findParsingCaps(ucan: Ucan): Iterable { const capInfo = parseCapabilityInfo(ucan) for (const cap of ucan.payload.att) { - const parsedCap = parseCap(cap) + const parsedCap = capability.parse(cap) if (parsedCap != null) yield { ...parsedCap, ...capInfo } } } @@ -57,12 +71,15 @@ export function* capabilities( for (const parent of delegatedInParent()) { for (const parsedParentCap of parent()) { isCoveredByProof = true - const delegated = delegateCap(parsedChildCap, parsedParentCap) + const delegated = capability.tryDelegating(parsedParentCap, parsedChildCap) if (delegated != null) { yield delegateCapabilityInfo({ ...parsedChildCap, ...delegated }, parsedParentCap) } } } + // If a capability can't be considered to be delegated by any of its proofs + // (or if there are no proofs), + // then we root its origin in the UCAN we're looking at. if (!isCoveredByProof) { yield parsedChildCap } @@ -70,7 +87,7 @@ export function* capabilities( } } - yield* ucan.reduce(delegate)() + return ucan.reduce(delegate)() } function delegateCapabilityInfo(childCap: A, parentCap: A): A { From f9d3492dc0e912c8b4f2eae1d49bb9bf3bf6c929 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Fri, 10 Dec 2021 21:37:58 +0100 Subject: [PATCH 16/26] Extract out emailCapabilities into tests --- src/attenuation.ts | 35 --------------------------------- tests/attenuation.test.ts | 4 +++- tests/emailCapabilities.ts | 40 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 36 deletions(-) create mode 100644 tests/emailCapabilities.ts diff --git a/src/attenuation.ts b/src/attenuation.ts index 325ff51..0a8748a 100644 --- a/src/attenuation.ts +++ b/src/attenuation.ts @@ -10,41 +10,6 @@ export interface CapabilitySemantics { // TODO builders } -export interface EmailCapability { - email: string - potency: "SEND" -} - -export const emailSemantics: CapabilitySemantics = { - - parse(cap: Capability): EmailCapability | null { - if (typeof cap.email === "string" && cap.cap === "SEND") { - return { - email: cap.email, - potency: cap.cap, - } - } - return null - }, - - toCapability(parsed: EmailCapability): Capability { - return { - email: parsed.email, - cap: parsed.potency, - } - }, - - tryDelegating(parentCap: EmailCapability, childCap: EmailCapability): EmailCapability | null { - // potency is always "SEND" anyway, so doesn't need to be checked - return childCap.email === parentCap.email ? childCap : null - }, - -} - -export function emailCapabilities(ucan: Chained) { - return capabilities(ucan, emailSemantics) -} - export interface CapabilityInfo { originator: string // DID expiresAt: number diff --git a/tests/attenuation.test.ts b/tests/attenuation.test.ts index 4d14b3f..ffef587 100644 --- a/tests/attenuation.test.ts +++ b/tests/attenuation.test.ts @@ -1,7 +1,9 @@ -import { emailCapabilities } from "../src/attenuation" import { Chained } from "../src/chained" import * as token from "../src/token" + import { alice, bob, mallory } from "./fixtures" +import { emailCapabilities } from "./emailCapabilities" + describe("attenuation.emailCapabilities", () => { diff --git a/tests/emailCapabilities.ts b/tests/emailCapabilities.ts new file mode 100644 index 0000000..4818ce0 --- /dev/null +++ b/tests/emailCapabilities.ts @@ -0,0 +1,40 @@ +import { capabilities, CapabilitySemantics } from "../src/attenuation" +import { Chained } from "../src/chained" +import { Capability } from "../src/types" + + +/* Very simple example capability semantics */ +export interface EmailCapability { + email: string + potency: "SEND" +} + +export const emailSemantics: CapabilitySemantics = { + + parse(cap: Capability): EmailCapability | null { + if (typeof cap.email === "string" && cap.cap === "SEND") { + return { + email: cap.email, + potency: cap.cap, + } + } + return null + }, + + toCapability(parsed: EmailCapability): Capability { + return { + email: parsed.email, + cap: parsed.potency, + } + }, + + tryDelegating(parentCap: EmailCapability, childCap: EmailCapability): EmailCapability | null { + // potency is always "SEND" anyway, so doesn't need to be checked + return childCap.email === parentCap.email ? childCap : null + }, + +} + +export function emailCapabilities(ucan: Chained) { + return capabilities(ucan, emailSemantics) +} From 63e6e7278fd4841fcd563a71fdae3a0889296bcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Fri, 10 Dec 2021 21:38:47 +0100 Subject: [PATCH 17/26] Fix Chained.reduce test --- tests/chained.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/chained.test.ts b/tests/chained.test.ts index 1b9c56e..75fbf14 100644 --- a/tests/chained.test.ts +++ b/tests/chained.test.ts @@ -84,8 +84,8 @@ describe("Chained.reduce", () => { ] }))) - const reconstruction = ucan.reduce((ucan: Ucan, iterProofs: Iterable) => { - const proofs = Array.from(iterProofs) + const reconstruction = ucan.reduce((ucan: Ucan, iterProofs: () => Iterable) => { + const proofs = Array.from(iterProofs()) return token.encode({ ...ucan, payload: { From 6b916b21de02fe49a81a93dc95582b1a0942e4db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Fri, 10 Dec 2021 21:42:51 +0100 Subject: [PATCH 18/26] Rename "potency" to "cap" to hopefully reduce confusion --- tests/attenuation.test.ts | 12 ++++++------ tests/chained.test.ts | 2 +- tests/emailCapabilities.ts | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/attenuation.test.ts b/tests/attenuation.test.ts index ffef587..851f765 100644 --- a/tests/attenuation.test.ts +++ b/tests/attenuation.test.ts @@ -37,7 +37,7 @@ describe("attenuation.emailCapabilities", () => { expiresAt: Math.min(leafUcan.payload.exp, ucan.payload.exp), notBefore: maxNbf(leafUcan.payload.nbf, ucan.payload.nbf), email: "alice@email.com", - potency: "SEND" + cap: "SEND" }]) }) @@ -66,7 +66,7 @@ describe("attenuation.emailCapabilities", () => { expiresAt: ucan.payload.exp, notBefore: ucan.payload.nbf, email: "bob@email.com", - potency: "SEND" + cap: "SEND" }]) }) @@ -116,14 +116,14 @@ describe("attenuation.emailCapabilities", () => { expiresAt: Math.min(leafUcanAlice.payload.exp, ucan.payload.exp), notBefore: maxNbf(leafUcanAlice.payload.nbf, ucan.payload.nbf), email: "alice@email.com", - potency: "SEND", + cap: "SEND", }, { originator: bob.did(), expiresAt: Math.min(leafUcanBob.payload.exp, ucan.payload.exp), notBefore: maxNbf(leafUcanBob.payload.nbf, ucan.payload.nbf), email: "bob@email.com", - potency: "SEND", + cap: "SEND", } ]) }) @@ -166,14 +166,14 @@ describe("attenuation.emailCapabilities", () => { expiresAt: Math.min(leafUcanAlice.payload.exp, ucan.payload.exp), notBefore: maxNbf(leafUcanAlice.payload.nbf, ucan.payload.nbf), email: "alice@email.com", - potency: "SEND", + cap: "SEND", }, { originator: bob.did(), expiresAt: Math.min(leafUcanBob.payload.exp, ucan.payload.exp), notBefore: maxNbf(leafUcanBob.payload.nbf, ucan.payload.nbf), email: "alice@email.com", - potency: "SEND", + cap: "SEND", } ]) }) diff --git a/tests/chained.test.ts b/tests/chained.test.ts index 75fbf14..67e0654 100644 --- a/tests/chained.test.ts +++ b/tests/chained.test.ts @@ -1,7 +1,7 @@ import * as token from "../src/token" import { Chained } from "../src/chained" -import { alice, bob, mallory } from "./fixtures" import { Ucan } from "../src/types" +import { alice, bob, mallory } from "./fixtures" describe("Chained.fromToken", () => { diff --git a/tests/emailCapabilities.ts b/tests/emailCapabilities.ts index 4818ce0..032da86 100644 --- a/tests/emailCapabilities.ts +++ b/tests/emailCapabilities.ts @@ -6,7 +6,7 @@ import { Capability } from "../src/types" /* Very simple example capability semantics */ export interface EmailCapability { email: string - potency: "SEND" + cap: "SEND" } export const emailSemantics: CapabilitySemantics = { @@ -15,7 +15,7 @@ export const emailSemantics: CapabilitySemantics = { if (typeof cap.email === "string" && cap.cap === "SEND") { return { email: cap.email, - potency: cap.cap, + cap: cap.cap, } } return null @@ -24,7 +24,7 @@ export const emailSemantics: CapabilitySemantics = { toCapability(parsed: EmailCapability): Capability { return { email: parsed.email, - cap: parsed.potency, + cap: parsed.cap, } }, From 9a3f9ec916ed43ce03de8d04b676283de3d2af53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Fri, 10 Dec 2021 21:48:06 +0100 Subject: [PATCH 19/26] Fix tab size (should be 2) --- src/chained.ts | 342 ++++++++++++++++++------------------- tests/attenuation.test.ts | 326 +++++++++++++++++------------------ tests/chained.test.ts | 168 +++++++++--------- tests/emailCapabilities.ts | 48 +++--- 4 files changed, 442 insertions(+), 442 deletions(-) diff --git a/src/chained.ts b/src/chained.ts index 8ef3943..13c7b02 100644 --- a/src/chained.ts +++ b/src/chained.ts @@ -9,176 +9,176 @@ import * as token from "./token" */ export class Chained { - // We need to keep the encoded version around to preserve the signature - private _encoded: string - private _decoded: Ucan - - constructor(encoded: string, decoded: Ucan) { - this._encoded = encoded - this._decoded = decoded - } - - /** - * Validate a UCAN chain from a given JWT-encoded UCAN. - * - * This will validate - * - The encoding - * - The signatures (unless turned off in the `options`) - * - The UCAN time bounds (unless turned off in the `options`) - * - The audience from parent proof UCANs matching up with the issuer of child UCANs - * - * @returns A promise of a deeply-validated, deeply-parsed UCAN. - * @throws If the UCAN chain can't be validated. - */ - static async fromToken(encodedUcan: string, options?: token.ValidateOptions): Promise { - const ucan = await token.validate(encodedUcan, options) - - // parse proofs recursively - const proofs = await Promise.all(ucan.payload.prf.map(encodedPrf => Chained.fromToken(encodedPrf, options))) - - // check sender/receiver matchups. A parent ucan's audience must match the child ucan's issuer - const incorrectProof = proofs.find(proof => proof.audience() !== ucan.payload.iss) - if (incorrectProof != null) { - throw new Error(`Invalid UCAN: Audience ${incorrectProof.audience()} doesn't match issuer ${ucan.payload.iss}`) - } - - const ucanTransformed: Ucan = { - ...ucan, - payload: { - ...ucan.payload, - prf: proofs - }, - } - return new Chained(encodedUcan, ucanTransformed) - } - - /** - * @returns The original JWT-encoded UCAN this chain was parsed from. - */ - encoded(): string { - return this._encoded - } - - /** - * @returns A representation of delgated capabilities throughout all ucan chains - */ - reduce(reduceLayer: (ucan: Ucan, reducedProofs: () => Iterable) => A): A { - const that = this - - function* reduceProofs() { - for (const proof of that.proofs()) { - yield proof.reduce(reduceLayer) - } - } - - return reduceLayer(this.payload(), reduceProofs) - } - - /* Header */ - - /** - * @returns An identifier for the signature algorithm used. - * Possible values include "RS256" and "EdDSA". - */ - algorithm(): string { - return this._decoded.header.alg - } - - /** - * @returns A string encoding the semantic version specified in the original encoded UCAN. - */ - version(): string { - return this._decoded.header.ucv - } - - /* payload */ - - /** - * @returns the payload the top level represented by this Chain element. - * Its proofs are omitted. To access proofs, use `.proofs()` - */ - payload(): Ucan { - return { - ...this._decoded, - payload: { - ...this._decoded.payload, - prf: ([] as never[]) - } - } - } - - /** - * @returns `iss`: The issuer as a DID string ("did:key:..."). - * - * The UCAN must be signed with the private key of the issuer to be valid. - */ - issuer(): string { - return this._decoded.payload.iss - } - - /** - * @returns `aud`: The audience as a DID string ("did:key:..."). - * - * This is the identity this UCAN transfers rights to. - * It could e.g. be the DID of a service you're posting this UCAN as a JWT to, - * or it could be the DID of something that'll use this UCAN as a proof to - * continue the UCAN chain as an issuer. - */ - audience(): string { - return this._decoded.payload.aud - } - - /** - * @returns `exp`: The POSIX timestamp for when the UCAN expires. - */ - expiresAt(): number { - return this._decoded.payload.exp - } - - /** - * @returns `nbf`: The POSIX timestamp of when the UCAN becomes active. - * If `null`, then it's only bound by `.expiresAt()`. - */ - notBefore(): number | null { - return this._decoded.payload.nbf ?? null - } - - /** - * @returns `nnc`: A nonce (number used once). - */ - nonce(): string | null { - return this._decoded.payload.nnc ?? null - } - - /** - * @returns `att`: Attenuated capabilities. - */ - attenuation(): Capability[] { - return this._decoded.payload.att - } - - /** - * @returns `fct`: Arbitrary facts or proofs of knowledge in this UCAN as an array of records. - */ - facts(): Fact[] { - return this._decoded.payload.fct ?? [] - } - - /** - * @returns `prf`: Further UCANs possibly providing proof or origin for some capabilities in this UCAN. - */ - proofs(): Chained[] { - return this._decoded.payload.prf - } - - /* signature */ - - /** - * @returns a base64-encoded signature. - * @see algorithm - */ - signature(): string { - return this._decoded.signature - } + // We need to keep the encoded version around to preserve the signature + private _encoded: string + private _decoded: Ucan + + constructor(encoded: string, decoded: Ucan) { + this._encoded = encoded + this._decoded = decoded + } + + /** + * Validate a UCAN chain from a given JWT-encoded UCAN. + * + * This will validate + * - The encoding + * - The signatures (unless turned off in the `options`) + * - The UCAN time bounds (unless turned off in the `options`) + * - The audience from parent proof UCANs matching up with the issuer of child UCANs + * + * @returns A promise of a deeply-validated, deeply-parsed UCAN. + * @throws If the UCAN chain can't be validated. + */ + static async fromToken(encodedUcan: string, options?: token.ValidateOptions): Promise { + const ucan = await token.validate(encodedUcan, options) + + // parse proofs recursively + const proofs = await Promise.all(ucan.payload.prf.map(encodedPrf => Chained.fromToken(encodedPrf, options))) + + // check sender/receiver matchups. A parent ucan's audience must match the child ucan's issuer + const incorrectProof = proofs.find(proof => proof.audience() !== ucan.payload.iss) + if (incorrectProof != null) { + throw new Error(`Invalid UCAN: Audience ${incorrectProof.audience()} doesn't match issuer ${ucan.payload.iss}`) + } + + const ucanTransformed: Ucan = { + ...ucan, + payload: { + ...ucan.payload, + prf: proofs + }, + } + return new Chained(encodedUcan, ucanTransformed) + } + + /** + * @returns The original JWT-encoded UCAN this chain was parsed from. + */ + encoded(): string { + return this._encoded + } + + /** + * @returns A representation of delgated capabilities throughout all ucan chains + */ + reduce(reduceLayer: (ucan: Ucan, reducedProofs: () => Iterable) => A): A { + const that = this + + function* reduceProofs() { + for (const proof of that.proofs()) { + yield proof.reduce(reduceLayer) + } + } + + return reduceLayer(this.payload(), reduceProofs) + } + + /* Header */ + + /** + * @returns An identifier for the signature algorithm used. + * Possible values include "RS256" and "EdDSA". + */ + algorithm(): string { + return this._decoded.header.alg + } + + /** + * @returns A string encoding the semantic version specified in the original encoded UCAN. + */ + version(): string { + return this._decoded.header.ucv + } + + /* payload */ + + /** + * @returns the payload the top level represented by this Chain element. + * Its proofs are omitted. To access proofs, use `.proofs()` + */ + payload(): Ucan { + return { + ...this._decoded, + payload: { + ...this._decoded.payload, + prf: ([] as never[]) + } + } + } + + /** + * @returns `iss`: The issuer as a DID string ("did:key:..."). + * + * The UCAN must be signed with the private key of the issuer to be valid. + */ + issuer(): string { + return this._decoded.payload.iss + } + + /** + * @returns `aud`: The audience as a DID string ("did:key:..."). + * + * This is the identity this UCAN transfers rights to. + * It could e.g. be the DID of a service you're posting this UCAN as a JWT to, + * or it could be the DID of something that'll use this UCAN as a proof to + * continue the UCAN chain as an issuer. + */ + audience(): string { + return this._decoded.payload.aud + } + + /** + * @returns `exp`: The POSIX timestamp for when the UCAN expires. + */ + expiresAt(): number { + return this._decoded.payload.exp + } + + /** + * @returns `nbf`: The POSIX timestamp of when the UCAN becomes active. + * If `null`, then it's only bound by `.expiresAt()`. + */ + notBefore(): number | null { + return this._decoded.payload.nbf ?? null + } + + /** + * @returns `nnc`: A nonce (number used once). + */ + nonce(): string | null { + return this._decoded.payload.nnc ?? null + } + + /** + * @returns `att`: Attenuated capabilities. + */ + attenuation(): Capability[] { + return this._decoded.payload.att + } + + /** + * @returns `fct`: Arbitrary facts or proofs of knowledge in this UCAN as an array of records. + */ + facts(): Fact[] { + return this._decoded.payload.fct ?? [] + } + + /** + * @returns `prf`: Further UCANs possibly providing proof or origin for some capabilities in this UCAN. + */ + proofs(): Chained[] { + return this._decoded.payload.prf + } + + /* signature */ + + /** + * @returns a base64-encoded signature. + * @see algorithm + */ + signature(): string { + return this._decoded.signature + } } diff --git a/tests/attenuation.test.ts b/tests/attenuation.test.ts index 851f765..4ae6f0f 100644 --- a/tests/attenuation.test.ts +++ b/tests/attenuation.test.ts @@ -8,181 +8,181 @@ import { emailCapabilities } from "./emailCapabilities" describe("attenuation.emailCapabilities", () => { - it("works with a simple example", async () => { - // alice -> bob, bob -> mallory - // alice delegates access to sending email as her to bob - // and bob delegates it further to mallory - const leafUcan = await token.build({ - issuer: alice, - audience: bob.did(), - capabilities: [{ - email: "alice@email.com", - cap: "SEND", - }] - }) - - const ucan = await token.build({ - issuer: bob, - audience: mallory.did(), - capabilities: [{ - email: "alice@email.com", - cap: "SEND", - }], - proofs: [token.encode(leafUcan)] - }) - - const emailCaps = Array.from(emailCapabilities(await Chained.fromToken(token.encode(ucan)))) - expect(emailCaps).toEqual([{ - originator: alice.did(), - expiresAt: Math.min(leafUcan.payload.exp, ucan.payload.exp), - notBefore: maxNbf(leafUcan.payload.nbf, ucan.payload.nbf), - email: "alice@email.com", - cap: "SEND" - }]) + it("works with a simple example", async () => { + // alice -> bob, bob -> mallory + // alice delegates access to sending email as her to bob + // and bob delegates it further to mallory + const leafUcan = await token.build({ + issuer: alice, + audience: bob.did(), + capabilities: [{ + email: "alice@email.com", + cap: "SEND", + }] }) - it("reports the first issuer in the chain as originator", async () => { - // alice -> bob, bob -> mallory - // alice delegates nothing to bob - // and bob delegates his email to mallory - const leafUcan = await token.build({ - issuer: alice, - audience: bob.did(), - }) - - const ucan = await token.build({ - issuer: bob, - audience: mallory.did(), - capabilities: [{ - email: "bob@email.com", - cap: "SEND", - }], - proofs: [token.encode(leafUcan)] - }) - - // we implicitly expect the originator to become bob - expect(Array.from(emailCapabilities(await Chained.fromToken(token.encode(ucan))))).toEqual([{ - originator: bob.did(), - expiresAt: ucan.payload.exp, - notBefore: ucan.payload.nbf, - email: "bob@email.com", - cap: "SEND" - }]) + const ucan = await token.build({ + issuer: bob, + audience: mallory.did(), + capabilities: [{ + email: "alice@email.com", + cap: "SEND", + }], + proofs: [token.encode(leafUcan)] }) - it("finds the right proof chain for the originator", async () => { - // alice -> mallory, bob -> mallory, mallory -> alice - // both alice and bob delegate their email access to mallory - // mallory then creates a UCAN with capability to send both - const leafUcanAlice = await token.build({ - issuer: alice, - audience: mallory.did(), - capabilities: [{ - email: "alice@email.com", - cap: "SEND", - }] - }) - - const leafUcanBob = await token.build({ - issuer: bob, - audience: mallory.did(), - capabilities: [{ - email: "bob@email.com", - cap: "SEND", - }] - }) - - const ucan = await token.build({ - issuer: mallory, - audience: alice.did(), - capabilities: [ - { - email: "alice@email.com", - cap: "SEND", - }, - { - email: "bob@email.com", - cap: "SEND", - } - ], - proofs: [token.encode(leafUcanAlice), token.encode(leafUcanBob)] - }) - - const chained = await Chained.fromToken(token.encode(ucan)) - - expect(Array.from(emailCapabilities(chained))).toEqual([ - { - originator: alice.did(), - expiresAt: Math.min(leafUcanAlice.payload.exp, ucan.payload.exp), - notBefore: maxNbf(leafUcanAlice.payload.nbf, ucan.payload.nbf), - email: "alice@email.com", - cap: "SEND", - }, - { - originator: bob.did(), - expiresAt: Math.min(leafUcanBob.payload.exp, ucan.payload.exp), - notBefore: maxNbf(leafUcanBob.payload.nbf, ucan.payload.nbf), - email: "bob@email.com", - cap: "SEND", - } - ]) + const emailCaps = Array.from(emailCapabilities(await Chained.fromToken(token.encode(ucan)))) + expect(emailCaps).toEqual([{ + originator: alice.did(), + expiresAt: Math.min(leafUcan.payload.exp, ucan.payload.exp), + notBefore: maxNbf(leafUcan.payload.nbf, ucan.payload.nbf), + email: "alice@email.com", + cap: "SEND" + }]) + }) + + it("reports the first issuer in the chain as originator", async () => { + // alice -> bob, bob -> mallory + // alice delegates nothing to bob + // and bob delegates his email to mallory + const leafUcan = await token.build({ + issuer: alice, + audience: bob.did(), }) - it("reports all chain options", async () => { - // alice -> mallory, bob -> mallory, mallory -> alice - // both alice and bob claim to have access to alice@email.com - // and both grant that capability to mallory - // a verifier needs to know both to verify valid email access + const ucan = await token.build({ + issuer: bob, + audience: mallory.did(), + capabilities: [{ + email: "bob@email.com", + cap: "SEND", + }], + proofs: [token.encode(leafUcan)] + }) + + // we implicitly expect the originator to become bob + expect(Array.from(emailCapabilities(await Chained.fromToken(token.encode(ucan))))).toEqual([{ + originator: bob.did(), + expiresAt: ucan.payload.exp, + notBefore: ucan.payload.nbf, + email: "bob@email.com", + cap: "SEND" + }]) + }) + + it("finds the right proof chain for the originator", async () => { + // alice -> mallory, bob -> mallory, mallory -> alice + // both alice and bob delegate their email access to mallory + // mallory then creates a UCAN with capability to send both + const leafUcanAlice = await token.build({ + issuer: alice, + audience: mallory.did(), + capabilities: [{ + email: "alice@email.com", + cap: "SEND", + }] + }) - const aliceEmail = { - email: "alice@email.com", - cap: "SEND", + const leafUcanBob = await token.build({ + issuer: bob, + audience: mallory.did(), + capabilities: [{ + email: "bob@email.com", + cap: "SEND", + }] + }) + + const ucan = await token.build({ + issuer: mallory, + audience: alice.did(), + capabilities: [ + { + email: "alice@email.com", + cap: "SEND", + }, + { + email: "bob@email.com", + cap: "SEND", } + ], + proofs: [token.encode(leafUcanAlice), token.encode(leafUcanBob)] + }) - const leafUcanAlice = await token.build({ - issuer: alice, - audience: mallory.did(), - capabilities: [aliceEmail] - }) - - const leafUcanBob = await token.build({ - issuer: bob, - audience: mallory.did(), - capabilities: [aliceEmail] - }) - - const ucan = await token.build({ - issuer: mallory, - audience: alice.did(), - capabilities: [aliceEmail], - proofs: [token.encode(leafUcanAlice), token.encode(leafUcanBob)] - }) - - const chained = await Chained.fromToken(token.encode(ucan)) - - expect(Array.from(emailCapabilities(chained))).toEqual([ - { - originator: alice.did(), - expiresAt: Math.min(leafUcanAlice.payload.exp, ucan.payload.exp), - notBefore: maxNbf(leafUcanAlice.payload.nbf, ucan.payload.nbf), - email: "alice@email.com", - cap: "SEND", - }, - { - originator: bob.did(), - expiresAt: Math.min(leafUcanBob.payload.exp, ucan.payload.exp), - notBefore: maxNbf(leafUcanBob.payload.nbf, ucan.payload.nbf), - email: "alice@email.com", - cap: "SEND", - } - ]) + const chained = await Chained.fromToken(token.encode(ucan)) + + expect(Array.from(emailCapabilities(chained))).toEqual([ + { + originator: alice.did(), + expiresAt: Math.min(leafUcanAlice.payload.exp, ucan.payload.exp), + notBefore: maxNbf(leafUcanAlice.payload.nbf, ucan.payload.nbf), + email: "alice@email.com", + cap: "SEND", + }, + { + originator: bob.did(), + expiresAt: Math.min(leafUcanBob.payload.exp, ucan.payload.exp), + notBefore: maxNbf(leafUcanBob.payload.nbf, ucan.payload.nbf), + email: "bob@email.com", + cap: "SEND", + } + ]) + }) + + it("reports all chain options", async () => { + // alice -> mallory, bob -> mallory, mallory -> alice + // both alice and bob claim to have access to alice@email.com + // and both grant that capability to mallory + // a verifier needs to know both to verify valid email access + + const aliceEmail = { + email: "alice@email.com", + cap: "SEND", + } + + const leafUcanAlice = await token.build({ + issuer: alice, + audience: mallory.did(), + capabilities: [aliceEmail] }) + const leafUcanBob = await token.build({ + issuer: bob, + audience: mallory.did(), + capabilities: [aliceEmail] + }) + + const ucan = await token.build({ + issuer: mallory, + audience: alice.did(), + capabilities: [aliceEmail], + proofs: [token.encode(leafUcanAlice), token.encode(leafUcanBob)] + }) + + const chained = await Chained.fromToken(token.encode(ucan)) + + expect(Array.from(emailCapabilities(chained))).toEqual([ + { + originator: alice.did(), + expiresAt: Math.min(leafUcanAlice.payload.exp, ucan.payload.exp), + notBefore: maxNbf(leafUcanAlice.payload.nbf, ucan.payload.nbf), + email: "alice@email.com", + cap: "SEND", + }, + { + originator: bob.did(), + expiresAt: Math.min(leafUcanBob.payload.exp, ucan.payload.exp), + notBefore: maxNbf(leafUcanBob.payload.nbf, ucan.payload.nbf), + email: "alice@email.com", + cap: "SEND", + } + ]) + }) + }) function maxNbf(parentNbf: number | undefined, childNbf: number | undefined): number | undefined { - if (parentNbf == null && childNbf == null) return undefined - if (parentNbf != null && childNbf != null) return Math.max(parentNbf, childNbf) - if (parentNbf != null) return parentNbf - return childNbf + if (parentNbf == null && childNbf == null) return undefined + if (parentNbf != null && childNbf != null) return Math.max(parentNbf, childNbf) + if (parentNbf != null) return parentNbf + return childNbf } diff --git a/tests/chained.test.ts b/tests/chained.test.ts index 67e0654..d4fe9e8 100644 --- a/tests/chained.test.ts +++ b/tests/chained.test.ts @@ -6,95 +6,95 @@ import { alice, bob, mallory } from "./fixtures" describe("Chained.fromToken", () => { - it("decodes deep ucan chains", async () => { - // alice -> bob, bob -> mallory - // delegating rights from alice to mallory through bob - const leafUcan = token.encode(await token.build({ - issuer: alice, - audience: bob.did(), - })) - - const ucan = token.encode(await token.build({ - issuer: bob, - audience: mallory.did(), - proofs: [leafUcan] - })) - - const chain = await Chained.fromToken(ucan) - expect(chain.audience()).toEqual(mallory.did()) - expect(chain.proofs()[0]?.issuer()).toEqual(alice.did()) - }) - - it("fails with incorrect chaining", async () => { - // alice -> bob, alice -> mallory - // incorrect chain. leaf's audience doesn't match final ucan's issuer - const leafUcan = token.encode(await token.build({ - issuer: alice, - audience: bob.did(), - })) - - const ucan = token.encode(await token.build({ - issuer: alice, - audience: mallory.did(), - proofs: [leafUcan] - })) - - await expect(() => Chained.fromToken(ucan)).rejects.toBeDefined() - }) - - it("can handle multiple ucan leafs", async () => { - // alice -> bob, mallory -> bob, bob -> alice - const leafUcanAlice = token.encode(await token.build({ - issuer: alice, - audience: bob.did(), - })) - - const leafUcanMallory = token.encode(await token.build({ - issuer: mallory, - audience: bob.did(), - })) - - const ucan = token.encode(await token.build({ - issuer: bob, - audience: alice.did(), - proofs: [leafUcanAlice, leafUcanMallory] - })) - - await Chained.fromToken(ucan) - }) + it("decodes deep ucan chains", async () => { + // alice -> bob, bob -> mallory + // delegating rights from alice to mallory through bob + const leafUcan = token.encode(await token.build({ + issuer: alice, + audience: bob.did(), + })) + + const ucan = token.encode(await token.build({ + issuer: bob, + audience: mallory.did(), + proofs: [leafUcan] + })) + + const chain = await Chained.fromToken(ucan) + expect(chain.audience()).toEqual(mallory.did()) + expect(chain.proofs()[0]?.issuer()).toEqual(alice.did()) + }) + + it("fails with incorrect chaining", async () => { + // alice -> bob, alice -> mallory + // incorrect chain. leaf's audience doesn't match final ucan's issuer + const leafUcan = token.encode(await token.build({ + issuer: alice, + audience: bob.did(), + })) + + const ucan = token.encode(await token.build({ + issuer: alice, + audience: mallory.did(), + proofs: [leafUcan] + })) + + await expect(() => Chained.fromToken(ucan)).rejects.toBeDefined() + }) + + it("can handle multiple ucan leafs", async () => { + // alice -> bob, mallory -> bob, bob -> alice + const leafUcanAlice = token.encode(await token.build({ + issuer: alice, + audience: bob.did(), + })) + + const leafUcanMallory = token.encode(await token.build({ + issuer: mallory, + audience: bob.did(), + })) + + const ucan = token.encode(await token.build({ + issuer: bob, + audience: alice.did(), + proofs: [leafUcanAlice, leafUcanMallory] + })) + + await Chained.fromToken(ucan) + }) }) describe("Chained.reduce", () => { - it("can reconstruct the UCAN tree", async () => { - // alice -> bob, mallory -> bob, bob -> alice - const ucan = await Chained.fromToken(token.encode(await token.build({ - issuer: bob, - audience: alice.did(), - proofs: [ - token.encode(await token.build({ - issuer: mallory, - audience: bob.did(), - })), - token.encode(await token.build({ - issuer: alice, - audience: bob.did(), - })), - ] - }))) - - const reconstruction = ucan.reduce((ucan: Ucan, iterProofs: () => Iterable) => { - const proofs = Array.from(iterProofs()) - return token.encode({ - ...ucan, - payload: { - ...ucan.payload, - prf: proofs - } - }) - }) - expect(reconstruction).toEqual(ucan.encoded()) + it("can reconstruct the UCAN tree", async () => { + // alice -> bob, mallory -> bob, bob -> alice + const ucan = await Chained.fromToken(token.encode(await token.build({ + issuer: bob, + audience: alice.did(), + proofs: [ + token.encode(await token.build({ + issuer: mallory, + audience: bob.did(), + })), + token.encode(await token.build({ + issuer: alice, + audience: bob.did(), + })), + ] + }))) + + const reconstruction = ucan.reduce((ucan: Ucan, iterProofs: () => Iterable) => { + const proofs = Array.from(iterProofs()) + return token.encode({ + ...ucan, + payload: { + ...ucan.payload, + prf: proofs + } + }) }) + expect(reconstruction).toEqual(ucan.encoded()) + }) }) diff --git a/tests/emailCapabilities.ts b/tests/emailCapabilities.ts index 032da86..ef7b22f 100644 --- a/tests/emailCapabilities.ts +++ b/tests/emailCapabilities.ts @@ -5,36 +5,36 @@ import { Capability } from "../src/types" /* Very simple example capability semantics */ export interface EmailCapability { - email: string - cap: "SEND" + email: string + cap: "SEND" } export const emailSemantics: CapabilitySemantics = { - parse(cap: Capability): EmailCapability | null { - if (typeof cap.email === "string" && cap.cap === "SEND") { - return { - email: cap.email, - cap: cap.cap, - } - } - return null - }, - - toCapability(parsed: EmailCapability): Capability { - return { - email: parsed.email, - cap: parsed.cap, - } - }, - - tryDelegating(parentCap: EmailCapability, childCap: EmailCapability): EmailCapability | null { - // potency is always "SEND" anyway, so doesn't need to be checked - return childCap.email === parentCap.email ? childCap : null - }, + parse(cap: Capability): EmailCapability | null { + if (typeof cap.email === "string" && cap.cap === "SEND") { + return { + email: cap.email, + cap: cap.cap, + } + } + return null + }, + + toCapability(parsed: EmailCapability): Capability { + return { + email: parsed.email, + cap: parsed.cap, + } + }, + + tryDelegating(parentCap: EmailCapability, childCap: EmailCapability): EmailCapability | null { + // potency is always "SEND" anyway, so doesn't need to be checked + return childCap.email === parentCap.email ? childCap : null + }, } export function emailCapabilities(ucan: Chained) { - return capabilities(ucan, emailSemantics) + return capabilities(ucan, emailSemantics) } From 16c5efb754aefb9904fbe18af73c55e36fcf2a28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Mon, 13 Dec 2021 14:04:41 +0100 Subject: [PATCH 20/26] Implement capability escalation logic --- src/attenuation.ts | 59 +++++++++++++++++++++++++++++++++------ tests/attenuation.test.ts | 9 +----- tests/utils.ts | 25 +++++++++++++++++ 3 files changed, 76 insertions(+), 17 deletions(-) create mode 100644 tests/utils.ts diff --git a/src/attenuation.ts b/src/attenuation.ts index 0a8748a..3dee81d 100644 --- a/src/attenuation.ts +++ b/src/attenuation.ts @@ -1,25 +1,52 @@ // https://whitepaper.fission.codes/access-control/ucan/jwt-authentication#attenuation import { Capability, Ucan } from "./types" import { Chained } from "./chained" +import * as util from "./util" export interface CapabilitySemantics { parse(cap: Capability): A | null toCapability(parsedCap: A): Capability - tryDelegating(parentCap: A, childCap: A): A | null + /** + * This figures out whether a given `childCap` can be delegated from `parentCap`. + * There are three possible results with three return types respectively: + * - `A`: The delegation is possible and results in the rights returned. + * - `null`: The capabilities from `parentCap` and `childCap` are unrelated and can't be compared nor delegated. + * - `CapabilityEscalation`: 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 // TODO builders } + export interface CapabilityInfo { originator: string // DID expiresAt: number notBefore?: number } + +export interface CapabilityEscalation { + escalation: string // reason + capability: A // the capability that escalated rights +} + +function isCapabilityEscalation(obj: unknown): obj is CapabilityEscalation { + return util.isRecord(obj) + && util.hasProp(obj, "escalation") && typeof obj.escalation === "string" + && util.hasProp(obj, "capability") +} + + +export type CapabilityResult + = A & CapabilityInfo + | CapabilityEscalation + + export function capabilities( ucan: Chained, capability: CapabilitySemantics, -): Iterable { +): Iterable> { function* findParsingCaps(ucan: Ucan): Iterable { const capInfo = parseCapabilityInfo(ucan) @@ -29,16 +56,30 @@ export function capabilities( } } - const delegate = (ucan: Ucan, delegatedInParent: () => Iterable<() => Iterable>) => { + const delegate = (ucan: Ucan, capabilitiesInProofs: () => Iterable<() => Iterable>>) => { return function* () { for (const parsedChildCap of findParsingCaps(ucan)) { let isCoveredByProof = false - for (const parent of delegatedInParent()) { - for (const parsedParentCap of parent()) { - isCoveredByProof = true - const delegated = capability.tryDelegating(parsedParentCap, parsedChildCap) - if (delegated != null) { - yield delegateCapabilityInfo({ ...parsedChildCap, ...delegated }, parsedParentCap) + for (const capabilitiesInProof of capabilitiesInProofs()) { + for (const parsedParentCap of capabilitiesInProof()) { + // pass through capability escalations from parents + if (isCapabilityEscalation(parsedParentCap)) { + yield parsedParentCap + } else { + // try figuring out whether we can delegate the capabilities from this to the parent + const delegated = capability.tryDelegating(parsedParentCap, parsedChildCap) + // if the capabilities *are* related, then this will be non-null + // otherwise we just continue looking + if (delegated != null) { + // we infer that the capability was meant to be delegated + isCoveredByProof = true + // it's still possible that that delegation was invalid, i.e. an escalation, though + if (isCapabilityEscalation(delegated)) { + yield delegated // which is an escalation + } else { + yield delegateCapabilityInfo({ ...parsedChildCap, ...delegated }, parsedParentCap) + } + } } } } diff --git a/tests/attenuation.test.ts b/tests/attenuation.test.ts index 4ae6f0f..96637ce 100644 --- a/tests/attenuation.test.ts +++ b/tests/attenuation.test.ts @@ -3,7 +3,7 @@ import * as token from "../src/token" import { alice, bob, mallory } from "./fixtures" import { emailCapabilities } from "./emailCapabilities" - +import { maxNbf } from "./utils" describe("attenuation.emailCapabilities", () => { @@ -179,10 +179,3 @@ describe("attenuation.emailCapabilities", () => { }) }) - -function maxNbf(parentNbf: number | undefined, childNbf: number | undefined): number | undefined { - if (parentNbf == null && childNbf == null) return undefined - if (parentNbf != null && childNbf != null) return Math.max(parentNbf, childNbf) - if (parentNbf != null) return parentNbf - return childNbf -} diff --git a/tests/utils.ts b/tests/utils.ts new file mode 100644 index 0000000..89108e9 --- /dev/null +++ b/tests/utils.ts @@ -0,0 +1,25 @@ +import { CapabilityInfo } from "../src/attenuation" +import { Ucan } from "../src/types" + +export function maxNbf(parentNbf: number | undefined, childNbf: number | undefined): number | undefined { + if (parentNbf == null && childNbf == null) return undefined + if (parentNbf != null && childNbf != null) return Math.max(parentNbf, childNbf) + if (parentNbf != null) return parentNbf + return childNbf +} + +export function combineTimeBounds(ucan: Ucan, ...ucans: Ucan[]): { expiresAt: number, notBefore?: number } { + const expiresAt = ucans.map(u => u.payload.exp).reduce((a, b) => Math.min(a, b), ucan.payload.exp) + const notBefore = ucans.map(u => u.payload.nbf).reduce(maxNbf, ucan.payload.nbf) + if (notBefore != null) { + return { expiresAt, notBefore } + } + return { expiresAt } +} + +export function capabilityInfoFromChain(origin: Ucan, ...ucans: Ucan[]): CapabilityInfo { + return { + originator: origin.payload.iss, + ...combineTimeBounds(origin, ...ucans), + } +} From 0c0b543e5432105d44062271556192eb144ab58f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Mon, 13 Dec 2021 14:04:49 +0100 Subject: [PATCH 21/26] Implement wnfs public capabilities --- src/capability/wnfs.ts | 88 +++++++++++++++++++++++++ tests/capabilitiy/wnfs.test.ts | 113 +++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 src/capability/wnfs.ts create mode 100644 tests/capabilitiy/wnfs.test.ts diff --git a/src/capability/wnfs.ts b/src/capability/wnfs.ts new file mode 100644 index 0000000..bc89d40 --- /dev/null +++ b/src/capability/wnfs.ts @@ -0,0 +1,88 @@ +import { Capability } from "../types" +import { capabilities, CapabilityEscalation, CapabilityResult, CapabilitySemantics } from "../attenuation" +import { Chained } from "../chained" + + +export const wnfsCapLevels = { + "SUPER_USER": 0, + "OVERWRITE": -1, + "SOFT_DELETE": -2, + "REVISE": -3, + "CREATE": -4, +} + +export type WnfsCap = keyof typeof wnfsCapLevels + +export function isWnfsCap(obj: unknown): obj is WnfsCap { + return typeof obj === "string" && Object.keys(wnfsCapLevels).includes(obj) +} + +export interface WnfsPublicCapability { + user: string // e.g. matheus23.fission.name + publicPath: string[] + cap: WnfsCap +} + +export const wnfsPublicSemantics: CapabilitySemantics = { + + parse(cap: Capability): WnfsPublicCapability | null { + if (typeof cap.wnfs === "string" && isWnfsCap(cap.cap)) { + // remove trailing slash + const trimmed = cap.wnfs.endsWith("/") ? cap.wnfs.slice(0, -1) : cap.wnfs + const split = trimmed.split("/") + const user = split[0] + const publicPath = split.slice(2) // drop first two: matheus23.fission.name/public/keep/this + if (user == null || split[1] !== "public") return null + return { + user, + publicPath, + cap: cap.cap, + } + } + return null + }, + + toCapability(parsed: WnfsPublicCapability): Capability { + return { + wnfs: `${parsed.user}/public/${parsed.publicPath.join("/")}`, + cap: parsed.cap, + } + }, + + tryDelegating(parentCap: WnfsPublicCapability, childCap: WnfsPublicCapability): WnfsPublicCapability | null | CapabilityEscalation { + // need to delegate the same user's file system + if (childCap.user !== parentCap.user) return null + + // must not escalate capability level + if (wnfsCapLevels[childCap.cap] > wnfsCapLevels[parentCap.cap]) { + return { + escalation: "Capability level escalation", + capability: { user: childCap.user, publicPath: childCap.publicPath, cap: childCap.cap }, + } + } + + // parentCap path must be a prefix of childCap path + if (childCap.publicPath.length < parentCap.publicPath.length) { + return { + escalation: "WNFS Public path access escalation", + capability: { user: childCap.user, publicPath: childCap.publicPath, cap: childCap.cap }, + } + } + + for (let i = 0; i < parentCap.publicPath.length; i++) { + if (childCap.publicPath[i] !== parentCap.publicPath[i]) { + return { + escalation: "WNFS Public path access escalation", + capability: { user: childCap.user, publicPath: childCap.publicPath, cap: childCap.cap }, + } + } + } + + return childCap + }, + +} + +export function wnfsPublicCapabilities(ucan: Chained) { + return capabilities(ucan, wnfsPublicSemantics) +} diff --git a/tests/capabilitiy/wnfs.test.ts b/tests/capabilitiy/wnfs.test.ts new file mode 100644 index 0000000..1f74905 --- /dev/null +++ b/tests/capabilitiy/wnfs.test.ts @@ -0,0 +1,113 @@ +import { WnfsPublicCapability, wnfsPublicCapabilities } from "../../src/capability/wnfs" +import * as token from "../../src/token" +import { Chained } from "../../src/chained" +import { Capability } from "../../src/types" + +import { alice, bob, mallory } from "../fixtures" +import { maxNbf } from "../utils" + + +async function makeSimpleDelegation(aliceCapabilities: Capability[], bobCapabilities: Capability[]) { + const leaf = await token.build({ + issuer: alice, + audience: bob.did(), + capabilities: aliceCapabilities + }) + + const ucan = await token.build({ + issuer: bob, + audience: mallory.did(), + capabilities: bobCapabilities, + proofs: [token.encode(leaf)] + }) + + const chain = await Chained.fromToken(token.encode(ucan)) + + return { leaf, ucan, chain } +} + + +describe("wnfs public capability", () => { + + it("works with a simple example", async () => { + const { leaf, ucan, chain } = await makeSimpleDelegation( + [{ + wnfs: "boris.fission.codes/public/Apps/", + cap: "OVERWRITE", + }], + [{ + wnfs: "boris.fission.codes/public/Apps/appinator/", + cap: "REVISE", + }] + ) + + expect(Array.from(wnfsPublicCapabilities(chain))).toEqual([ + { + originator: alice.did(), + expiresAt: Math.min(leaf.payload.exp, ucan.payload.exp), + notBefore: maxNbf(leaf.payload.nbf, ucan.payload.nbf), + user: "boris.fission.codes", + publicPath: ["Apps", "appinator"], + cap: "REVISE", + } + ]) + }) + + it("detects capability escalations", async () => { + const { chain } = await makeSimpleDelegation( + [{ + wnfs: "boris.fission.codes/public/Apps/", + cap: "CREATE", + }], + [{ + wnfs: "boris.fission.codes/public/Apps/appinator/", + cap: "OVERWRITE", + }] + ) + + expect(Array.from(wnfsPublicCapabilities(chain))).toEqual([{ + escalation: "Capability level escalation", + capability: { + user: "boris.fission.codes", + publicPath: ["Apps", "appinator"], + cap: "OVERWRITE", + } + }]) + }) + + it("detects capability escalations, even if there's valid capabilities", async () => { + const { leaf, ucan, chain } = await makeSimpleDelegation( + [{ + wnfs: "boris.fission.codes/public/Apps/", + cap: "CREATE", + },{ + wnfs: "boris.fission.codes/public/Apps/", + cap: "SUPER_USER", + }], + [{ + wnfs: "boris.fission.codes/public/Apps/appinator/", + cap: "OVERWRITE", + }] + ) + + expect(Array.from(wnfsPublicCapabilities(chain))).toEqual([ + { + escalation: "Capability level escalation", + capability: { + user: "boris.fission.codes", + publicPath: ["Apps", "appinator"], + cap: "OVERWRITE", + } + }, + { + originator: alice.did(), + expiresAt: Math.min(leaf.payload.exp, ucan.payload.exp), + notBefore: maxNbf(leaf.payload.nbf, ucan.payload.nbf), + user: "boris.fission.codes", + publicPath: ["Apps", "appinator"], + cap: "OVERWRITE", + } + ]) + }) + +}) From 393ad2250d73bf2833fbe7231551090e2e58cf54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Mon, 13 Dec 2021 17:17:32 +0100 Subject: [PATCH 22/26] Clean up --- src/attenuation.ts | 2 +- src/capability/wnfs.ts | 26 ++++++++++++-------------- tests/capabilitiy/wnfs.test.ts | 2 +- tests/emailCapabilities.ts | 4 ++-- 4 files changed, 16 insertions(+), 18 deletions(-) diff --git a/src/attenuation.ts b/src/attenuation.ts index 3dee81d..8389529 100644 --- a/src/attenuation.ts +++ b/src/attenuation.ts @@ -14,7 +14,7 @@ export interface CapabilitySemantics { * - `null`: The capabilities from `parentCap` and `childCap` are unrelated and can't be compared nor delegated. * - `CapabilityEscalation`: 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 + tryDelegating(parentCap: T, childCTp: T): T | null | CapabilityEscalation // TODO builders } diff --git a/src/capability/wnfs.ts b/src/capability/wnfs.ts index bc89d40..4a6ad5f 100644 --- a/src/capability/wnfs.ts +++ b/src/capability/wnfs.ts @@ -1,5 +1,5 @@ import { Capability } from "../types" -import { capabilities, CapabilityEscalation, CapabilityResult, CapabilitySemantics } from "../attenuation" +import { capabilities, CapabilityEscalation, CapabilitySemantics } from "../attenuation" import { Chained } from "../chained" @@ -49,32 +49,23 @@ export const wnfsPublicSemantics: CapabilitySemantics = { } }, - tryDelegating(parentCap: WnfsPublicCapability, childCap: WnfsPublicCapability): WnfsPublicCapability | null | CapabilityEscalation { + tryDelegating(parentCap: T, childCap: T): T | null | CapabilityEscalation { // need to delegate the same user's file system if (childCap.user !== parentCap.user) return null // must not escalate capability level if (wnfsCapLevels[childCap.cap] > wnfsCapLevels[parentCap.cap]) { - return { - escalation: "Capability level escalation", - capability: { user: childCap.user, publicPath: childCap.publicPath, cap: childCap.cap }, - } + return escalation("Capability level escalation", childCap) } // parentCap path must be a prefix of childCap path if (childCap.publicPath.length < parentCap.publicPath.length) { - return { - escalation: "WNFS Public path access escalation", - capability: { user: childCap.user, publicPath: childCap.publicPath, cap: childCap.cap }, - } + return escalation("WNFS Public path access escalation", childCap) } for (let i = 0; i < parentCap.publicPath.length; i++) { if (childCap.publicPath[i] !== parentCap.publicPath[i]) { - return { - escalation: "WNFS Public path access escalation", - capability: { user: childCap.user, publicPath: childCap.publicPath, cap: childCap.cap }, - } + return escalation("WNFS Public path access escalation", childCap) } } @@ -83,6 +74,13 @@ export const wnfsPublicSemantics: CapabilitySemantics = { } +function escalation(reason: string, cap: T): CapabilityEscalation { + return { + escalation: reason, + capability: { user: cap.user, publicPath: cap.publicPath, cap: cap.cap } + } +} + export function wnfsPublicCapabilities(ucan: Chained) { return capabilities(ucan, wnfsPublicSemantics) } diff --git a/tests/capabilitiy/wnfs.test.ts b/tests/capabilitiy/wnfs.test.ts index 1f74905..15ff433 100644 --- a/tests/capabilitiy/wnfs.test.ts +++ b/tests/capabilitiy/wnfs.test.ts @@ -1,4 +1,4 @@ -import { WnfsPublicCapability, wnfsPublicCapabilities } from "../../src/capability/wnfs" +import { wnfsPublicCapabilities } from "../../src/capability/wnfs" import * as token from "../../src/token" import { Chained } from "../../src/chained" import { Capability } from "../../src/types" diff --git a/tests/emailCapabilities.ts b/tests/emailCapabilities.ts index ef7b22f..910ff4c 100644 --- a/tests/emailCapabilities.ts +++ b/tests/emailCapabilities.ts @@ -1,4 +1,4 @@ -import { capabilities, CapabilitySemantics } from "../src/attenuation" +import { capabilities, CapabilityEscalation, CapabilitySemantics } from "../src/attenuation" import { Chained } from "../src/chained" import { Capability } from "../src/types" @@ -28,7 +28,7 @@ export const emailSemantics: CapabilitySemantics = { } }, - tryDelegating(parentCap: EmailCapability, childCap: EmailCapability): EmailCapability | null { + tryDelegating(parentCap: T, childCap: T): T | null | CapabilityEscalation { // potency is always "SEND" anyway, so doesn't need to be checked return childCap.email === parentCap.email ? childCap : null }, From 20b74780cc5b42b53b4fc1a07440e903af15d13d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Mon, 13 Dec 2021 19:58:58 +0100 Subject: [PATCH 23/26] Private wnfs capabilities --- src/attenuation.ts | 2 +- src/capability/wnfs.ts | 143 +++++++++++++++--- tests/capabilitiy/wnfs.test.ts | 267 +++++++++++++++++++++++++++++---- 3 files changed, 361 insertions(+), 51 deletions(-) diff --git a/src/attenuation.ts b/src/attenuation.ts index 8389529..a383821 100644 --- a/src/attenuation.ts +++ b/src/attenuation.ts @@ -14,7 +14,7 @@ export interface CapabilitySemantics { * - `null`: The capabilities from `parentCap` and `childCap` are unrelated and can't be compared nor delegated. * - `CapabilityEscalation`: It's clear that `childCap` is meant to be delegated from `parentCap`, but there's a rights escalation. */ - tryDelegating(parentCap: T, childCTp: T): T | null | CapabilityEscalation + tryDelegating(parentCap: T, childCap: T): T | null | CapabilityEscalation // TODO builders } diff --git a/src/capability/wnfs.ts b/src/capability/wnfs.ts index 4a6ad5f..964d421 100644 --- a/src/capability/wnfs.ts +++ b/src/capability/wnfs.ts @@ -17,6 +17,13 @@ export function isWnfsCap(obj: unknown): obj is WnfsCap { return typeof obj === "string" && Object.keys(wnfsCapLevels).includes(obj) } + + +///////////////////////////// +// Public WNFS Capabilities +///////////////////////////// + + export interface WnfsPublicCapability { user: string // e.g. matheus23.fission.name publicPath: string[] @@ -25,21 +32,29 @@ export interface WnfsPublicCapability { export const wnfsPublicSemantics: CapabilitySemantics = { + /** + * Example valid public wnfs capability: + * ```js + * { + * wnfs: "boris.fission.name/public/path/to/dir/or/file", + * cap: "OVERWRITE" + * } + * ``` + */ parse(cap: Capability): WnfsPublicCapability | null { - if (typeof cap.wnfs === "string" && isWnfsCap(cap.cap)) { - // remove trailing slash - const trimmed = cap.wnfs.endsWith("/") ? cap.wnfs.slice(0, -1) : cap.wnfs - const split = trimmed.split("/") - const user = split[0] - const publicPath = split.slice(2) // drop first two: matheus23.fission.name/public/keep/this - if (user == null || split[1] !== "public") return null - return { - user, - publicPath, - cap: cap.cap, - } + if (typeof cap.wnfs !== "string" || !isWnfsCap(cap.cap)) return null + + // remove trailing slash + const trimmed = cap.wnfs.endsWith("/") ? cap.wnfs.slice(0, -1) : cap.wnfs + const split = trimmed.split("/") + const user = split[0] + const publicPath = split.slice(2) // drop first two: matheus23.fission.name/public/keep/this + if (user == null || split[1] !== "public") return null + return { + user, + publicPath, + cap: cap.cap, } - return null }, toCapability(parsed: WnfsPublicCapability): Capability { @@ -55,17 +70,17 @@ export const wnfsPublicSemantics: CapabilitySemantics = { // must not escalate capability level if (wnfsCapLevels[childCap.cap] > wnfsCapLevels[parentCap.cap]) { - return escalation("Capability level escalation", childCap) + return escalationPublic("Capability level escalation", childCap) } // parentCap path must be a prefix of childCap path if (childCap.publicPath.length < parentCap.publicPath.length) { - return escalation("WNFS Public path access escalation", childCap) + return escalationPublic("WNFS Public path access escalation", childCap) } for (let i = 0; i < parentCap.publicPath.length; i++) { if (childCap.publicPath[i] !== parentCap.publicPath[i]) { - return escalation("WNFS Public path access escalation", childCap) + return escalationPublic("WNFS Public path access escalation", childCap) } } @@ -74,13 +89,103 @@ export const wnfsPublicSemantics: CapabilitySemantics = { } -function escalation(reason: string, cap: T): CapabilityEscalation { +export function wnfsPublicCapabilities(ucan: Chained) { + return capabilities(ucan, wnfsPublicSemantics) +} + + + +///////////////////////////// +// Private WNFS Capabilities +///////////////////////////// + + +export interface WnfsPrivateCapability { + user: string + requiredINumbers: Set + cap: WnfsCap +} + +const wnfsPrivateSemantics: CapabilitySemantics = { + + /** + * Example valid private wnfs capability: + * + * ```js + * { + * wnfs: "boris.fission.name/private/fccXmZ8HYmpwxkvPSjwW9A", + * cap: "OVERWRITE" + * } + * ``` + */ + parse(cap: Capability): WnfsPrivateCapability | null { + if (typeof cap.wnfs !== "string" || !isWnfsCap(cap.cap)) return null + + // split up "boris.fission.name/private/fccXmZ8HYmpwxkvPSjwW9A" into "/private/" + const split = cap.wnfs.split("/") + const user = split[0] + const inumberBase64url = split[2] + + if (user == null || split[1] !== "private" || inumberBase64url == null) return null + + return { + user, + requiredINumbers: new Set([inumberBase64url]), + cap: cap.cap, + } + }, + + toCapability(parsed: WnfsPrivateCapability): Capability { + const inumbers = Array.from(parsed.requiredINumbers.values()) + const [inumber] = inumbers + if (inumbers.length !== 1 || inumber == null) { + // Private wnfs capabilities will only have an encoding with a single inumber. + // Multiple inumbers are the result of delegations with multiple private capabilities interacting. + throw new Error(`Can only construct a private capability with exactly one inumber.`) + } + return { + wnfs: `${parsed.user}/private/${inumber}`, + cap: parsed.cap, + } + }, + + tryDelegating(parentCap: T, childCap: T): T | null | CapabilityEscalation { + // If the users don't match, these capabilities are unrelated. + if (childCap.user !== parentCap.user) return null + + // This escalation *could* be wrong, but we shouldn't assume they're unrelated either. + if (wnfsCapLevels[childCap.cap] > wnfsCapLevels[parentCap.cap]) { + return escalationPrivate("Capability level escalation", childCap) + } + + return { + ...childCap, + requiredINumbers: new Set([...childCap.requiredINumbers.values(), ...parentCap.requiredINumbers.values()]), + } + }, + +} + +export function wnfsPrivateCapabilities(ucan: Chained) { + return capabilities(ucan, wnfsPrivateSemantics) +} + + + +// ㊙️ + + +function escalationPublic(reason: string, cap: T): CapabilityEscalation { return { escalation: reason, capability: { user: cap.user, publicPath: cap.publicPath, cap: cap.cap } } } -export function wnfsPublicCapabilities(ucan: Chained) { - return capabilities(ucan, wnfsPublicSemantics) + +function escalationPrivate(reason: string, cap: T): CapabilityEscalation { + return { + escalation: reason, + capability: { user: cap.user, requiredINumbers: cap.requiredINumbers, cap: cap.cap } + } } diff --git a/tests/capabilitiy/wnfs.test.ts b/tests/capabilitiy/wnfs.test.ts index 15ff433..5bc8672 100644 --- a/tests/capabilitiy/wnfs.test.ts +++ b/tests/capabilitiy/wnfs.test.ts @@ -1,42 +1,23 @@ -import { wnfsPublicCapabilities } from "../../src/capability/wnfs" import * as token from "../../src/token" import { Chained } from "../../src/chained" import { Capability } from "../../src/types" +import { wnfsPrivateCapabilities, wnfsPublicCapabilities } from "../../src/capability/wnfs" import { alice, bob, mallory } from "../fixtures" import { maxNbf } from "../utils" -async function makeSimpleDelegation(aliceCapabilities: Capability[], bobCapabilities: Capability[]) { - const leaf = await token.build({ - issuer: alice, - audience: bob.did(), - capabilities: aliceCapabilities - }) - - const ucan = await token.build({ - issuer: bob, - audience: mallory.did(), - capabilities: bobCapabilities, - proofs: [token.encode(leaf)] - }) - - const chain = await Chained.fromToken(token.encode(ucan)) - - return { leaf, ucan, chain } -} - describe("wnfs public capability", () => { it("works with a simple example", async () => { const { leaf, ucan, chain } = await makeSimpleDelegation( [{ - wnfs: "boris.fission.codes/public/Apps/", + wnfs: "boris.fission.name/public/Apps/", cap: "OVERWRITE", }], [{ - wnfs: "boris.fission.codes/public/Apps/appinator/", + wnfs: "boris.fission.name/public/Apps/appinator/", cap: "REVISE", }] ) @@ -46,7 +27,7 @@ describe("wnfs public capability", () => { originator: alice.did(), expiresAt: Math.min(leaf.payload.exp, ucan.payload.exp), notBefore: maxNbf(leaf.payload.nbf, ucan.payload.nbf), - user: "boris.fission.codes", + user: "boris.fission.name", publicPath: ["Apps", "appinator"], cap: "REVISE", } @@ -56,11 +37,11 @@ describe("wnfs public capability", () => { it("detects capability escalations", async () => { const { chain } = await makeSimpleDelegation( [{ - wnfs: "boris.fission.codes/public/Apps/", + wnfs: "boris.fission.name/public/Apps/", cap: "CREATE", }], [{ - wnfs: "boris.fission.codes/public/Apps/appinator/", + wnfs: "boris.fission.name/public/Apps/appinator/", cap: "OVERWRITE", }] ) @@ -68,7 +49,7 @@ describe("wnfs public capability", () => { expect(Array.from(wnfsPublicCapabilities(chain))).toEqual([{ escalation: "Capability level escalation", capability: { - user: "boris.fission.codes", + user: "boris.fission.name", publicPath: ["Apps", "appinator"], cap: "OVERWRITE", } @@ -78,14 +59,14 @@ describe("wnfs public capability", () => { it("detects capability escalations, even if there's valid capabilities", async () => { const { leaf, ucan, chain } = await makeSimpleDelegation( [{ - wnfs: "boris.fission.codes/public/Apps/", + wnfs: "boris.fission.name/public/Apps/", cap: "CREATE", },{ - wnfs: "boris.fission.codes/public/Apps/", + wnfs: "boris.fission.name/public/Apps/", cap: "SUPER_USER", }], [{ - wnfs: "boris.fission.codes/public/Apps/appinator/", + wnfs: "boris.fission.name/public/Apps/appinator/", cap: "OVERWRITE", }] ) @@ -94,7 +75,7 @@ describe("wnfs public capability", () => { { escalation: "Capability level escalation", capability: { - user: "boris.fission.codes", + user: "boris.fission.name", publicPath: ["Apps", "appinator"], cap: "OVERWRITE", } @@ -103,7 +84,7 @@ describe("wnfs public capability", () => { originator: alice.did(), expiresAt: Math.min(leaf.payload.exp, ucan.payload.exp), notBefore: maxNbf(leaf.payload.nbf, ucan.payload.nbf), - user: "boris.fission.codes", + user: "boris.fission.name", publicPath: ["Apps", "appinator"], cap: "OVERWRITE", } @@ -111,3 +92,227 @@ describe("wnfs public capability", () => { }) }) + +describe("wnfs private capability", () => { + + it("works with a simple example", async () => { + const { leaf, ucan, chain } = await makeSimpleDelegation( + [{ + wnfs: "boris.fission.name/private/abc", + cap: "OVERWRITE", + }], + [{ + wnfs: "boris.fission.name/private/def", + cap: "REVISE", + }] + ) + + expect(Array.from(wnfsPrivateCapabilities(chain))).toEqual([ + { + originator: alice.did(), + expiresAt: Math.min(leaf.payload.exp, ucan.payload.exp), + notBefore: maxNbf(leaf.payload.nbf, ucan.payload.nbf), + user: "boris.fission.name", + requiredINumbers: new Set(["abc", "def"]), + cap: "REVISE", + } + ]) + }) + + it("detects capability escalations", async () => { + const { chain } = await makeSimpleDelegation( + [{ + wnfs: "boris.fission.name/private/abc", + cap: "OVERWRITE", + }], + [{ + wnfs: "boris.fission.name/private/def", + cap: "SUPER_USER", + }] + ) + + expect(Array.from(wnfsPrivateCapabilities(chain))).toEqual([ + { + escalation: "Capability level escalation", + capability: { + user: "boris.fission.name", + requiredINumbers: new Set(["def"]), + cap: "SUPER_USER", + } + }, + ]) + }) + + it("detects capability escalations, but still returns valid delegations", async () => { + const { leaf, ucan, chain } = await makeSimpleDelegation( + [{ + wnfs: "boris.fission.name/private/abc", + cap: "OVERWRITE", + }], + [ + { + wnfs: "boris.fission.name/private/def", + cap: "SUPER_USER", + }, + { + wnfs: "boris.fission.name/private/ghi", + cap: "CREATE", + } + ] + ) + + expect(Array.from(wnfsPrivateCapabilities(chain))).toEqual([ + { + escalation: "Capability level escalation", + capability: { + user: "boris.fission.name", + requiredINumbers: new Set(["def"]), + cap: "SUPER_USER", + } + }, + { + originator: alice.did(), + expiresAt: Math.min(leaf.payload.exp, ucan.payload.exp), + notBefore: maxNbf(leaf.payload.nbf, ucan.payload.nbf), + user: "boris.fission.name", + requiredINumbers: new Set(["abc", "ghi"]), + cap: "CREATE", + } + ]) + }) + + it("lists all possible inumber combinations", async () => { + const { leafAlice, leafBob, ucan, chain } = await makeComplexDelegation( + { + alice: [{ + wnfs: "boris.fission.name/private/inumalice", + cap: "OVERWRITE", + }], + bob: [{ + wnfs: "boris.fission.name/private/inumbob", + cap: "OVERWRITE", + }] + }, + [{ + wnfs: "boris.fission.name/private/subinum", + cap: "OVERWRITE", + }] + ) + + expect(Array.from(wnfsPrivateCapabilities(chain))).toEqual([ + { + originator: alice.did(), + expiresAt: Math.min(leafAlice.payload.exp, ucan.payload.exp), + notBefore: maxNbf(leafAlice.payload.nbf, ucan.payload.nbf), + user: "boris.fission.name", + requiredINumbers: new Set(["inumalice", "subinum"]), + cap: "OVERWRITE", + }, + { + originator: bob.did(), + expiresAt: Math.min(leafBob.payload.exp, ucan.payload.exp), + notBefore: maxNbf(leafBob.payload.nbf, ucan.payload.nbf), + user: "boris.fission.name", + requiredINumbers: new Set(["inumbob", "subinum"]), + cap: "OVERWRITE", + } + ]) + }) + + it("lists all possible inumber combinations except escalations", async () => { + const { leafBob, ucan, chain } = await makeComplexDelegation( + { + alice: [{ + wnfs: "boris.fission.name/private/inumalice", + cap: "CREATE", + }], + bob: [{ + wnfs: "boris.fission.name/private/inumbob", + cap: "OVERWRITE", + }] + }, + [{ + wnfs: "boris.fission.name/private/subinum", + cap: "OVERWRITE", + }] + ) + + expect(Array.from(wnfsPrivateCapabilities(chain))).toEqual([ + { + escalation: "Capability level escalation", + capability: { + user: "boris.fission.name", + requiredINumbers: new Set(["subinum"]), + cap: "OVERWRITE", + } + }, + { + originator: bob.did(), + expiresAt: Math.min(leafBob.payload.exp, ucan.payload.exp), + notBefore: maxNbf(leafBob.payload.nbf, ucan.payload.nbf), + user: "boris.fission.name", + requiredINumbers: new Set(["inumbob", "subinum"]), + cap: "OVERWRITE", + } + ]) + }) + +}) + +/** + * A linear delegation chain: + * alice -> bob -> mallory + * + * The arguments are the capabilities delegated in the first and second arrow, respectively. + */ +async function makeSimpleDelegation(aliceCapabilities: Capability[], bobCapabilities: Capability[]) { + const leaf = await token.build({ + issuer: alice, + audience: bob.did(), + capabilities: aliceCapabilities + }) + + const ucan = await token.build({ + issuer: bob, + audience: mallory.did(), + capabilities: bobCapabilities, + proofs: [token.encode(leaf)] + }) + + const chain = await Chained.fromToken(token.encode(ucan)) + + return { leaf, ucan, chain } +} + + +/** + * A tree-like delegation ucan: + * alice & bob => mallory -> alice + * + * The first argument are the capabilities delegated in the first two arrows, + * the second argument are the capabilities delegated in the last arrow. + */ +async function makeComplexDelegation(proofs: { alice: Capability[], bob: Capability[] }, final: Capability[]) { + const leafAlice = await token.build({ + issuer: alice, + audience: mallory.did(), + capabilities: proofs.alice, + }) + + const leafBob = await token.build({ + issuer: bob, + audience: mallory.did(), + capabilities: proofs.bob, + }) + + const ucan = await token.build({ + issuer: mallory, + audience: alice.did(), + capabilities: final, + proofs: [token.encode(leafAlice), token.encode(leafBob)], + }) + + const chain = await Chained.fromToken(token.encode(ucan)) + + return { leafAlice, leafBob, ucan, chain } +} From e9450e95eef63f44f881b411ef00fcbd3ad196d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Mon, 13 Dec 2021 20:09:10 +0100 Subject: [PATCH 24/26] Remove toCapability & rename parse -> tryParsing --- src/attenuation.ts | 5 ++--- src/capability/wnfs.ts | 25 ++----------------------- tests/emailCapabilities.ts | 9 +-------- 3 files changed, 5 insertions(+), 34 deletions(-) diff --git a/src/attenuation.ts b/src/attenuation.ts index a383821..fa4db83 100644 --- a/src/attenuation.ts +++ b/src/attenuation.ts @@ -5,8 +5,7 @@ import * as util from "./util" export interface CapabilitySemantics { - parse(cap: Capability): A | null - toCapability(parsedCap: A): Capability + 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: @@ -51,7 +50,7 @@ export function capabilities( function* findParsingCaps(ucan: Ucan): Iterable { const capInfo = parseCapabilityInfo(ucan) for (const cap of ucan.payload.att) { - const parsedCap = capability.parse(cap) + const parsedCap = capability.tryParsing(cap) if (parsedCap != null) yield { ...parsedCap, ...capInfo } } } diff --git a/src/capability/wnfs.ts b/src/capability/wnfs.ts index 964d421..5714a45 100644 --- a/src/capability/wnfs.ts +++ b/src/capability/wnfs.ts @@ -41,7 +41,7 @@ export const wnfsPublicSemantics: CapabilitySemantics = { * } * ``` */ - parse(cap: Capability): WnfsPublicCapability | null { + tryParsing(cap: Capability): WnfsPublicCapability | null { if (typeof cap.wnfs !== "string" || !isWnfsCap(cap.cap)) return null // remove trailing slash @@ -57,13 +57,6 @@ export const wnfsPublicSemantics: CapabilitySemantics = { } }, - toCapability(parsed: WnfsPublicCapability): Capability { - return { - wnfs: `${parsed.user}/public/${parsed.publicPath.join("/")}`, - cap: parsed.cap, - } - }, - tryDelegating(parentCap: T, childCap: T): T | null | CapabilityEscalation { // need to delegate the same user's file system if (childCap.user !== parentCap.user) return null @@ -118,7 +111,7 @@ const wnfsPrivateSemantics: CapabilitySemantics = { * } * ``` */ - parse(cap: Capability): WnfsPrivateCapability | null { + tryParsing(cap: Capability): WnfsPrivateCapability | null { if (typeof cap.wnfs !== "string" || !isWnfsCap(cap.cap)) return null // split up "boris.fission.name/private/fccXmZ8HYmpwxkvPSjwW9A" into "/private/" @@ -135,20 +128,6 @@ const wnfsPrivateSemantics: CapabilitySemantics = { } }, - toCapability(parsed: WnfsPrivateCapability): Capability { - const inumbers = Array.from(parsed.requiredINumbers.values()) - const [inumber] = inumbers - if (inumbers.length !== 1 || inumber == null) { - // Private wnfs capabilities will only have an encoding with a single inumber. - // Multiple inumbers are the result of delegations with multiple private capabilities interacting. - throw new Error(`Can only construct a private capability with exactly one inumber.`) - } - return { - wnfs: `${parsed.user}/private/${inumber}`, - cap: parsed.cap, - } - }, - tryDelegating(parentCap: T, childCap: T): T | null | CapabilityEscalation { // If the users don't match, these capabilities are unrelated. if (childCap.user !== parentCap.user) return null diff --git a/tests/emailCapabilities.ts b/tests/emailCapabilities.ts index 910ff4c..087bbdb 100644 --- a/tests/emailCapabilities.ts +++ b/tests/emailCapabilities.ts @@ -11,7 +11,7 @@ export interface EmailCapability { export const emailSemantics: CapabilitySemantics = { - parse(cap: Capability): EmailCapability | null { + tryParsing(cap: Capability): EmailCapability | null { if (typeof cap.email === "string" && cap.cap === "SEND") { return { email: cap.email, @@ -21,13 +21,6 @@ export const emailSemantics: CapabilitySemantics = { return null }, - toCapability(parsed: EmailCapability): Capability { - return { - email: parsed.email, - cap: parsed.cap, - } - }, - tryDelegating(parentCap: T, childCap: T): T | null | CapabilityEscalation { // potency is always "SEND" anyway, so doesn't need to be checked return childCap.email === parentCap.email ? childCap : null From 2ee27756802eacea4126181dc592a89d0c79f6f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Mon, 13 Dec 2021 20:11:11 +0100 Subject: [PATCH 25/26] Some docs --- src/attenuation.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/attenuation.ts b/src/attenuation.ts index fa4db83..828dfcd 100644 --- a/src/attenuation.ts +++ b/src/attenuation.ts @@ -5,6 +5,14 @@ import * as util from "./util" export interface CapabilitySemantics { + /** + * Try to parse a capability into a representation used for + * delegation & returning in the `capabilities` call. + * + * If the capability doesn't seem to match the format expected + * for the capabilities with the semantics currently defined, + * return `null`. + */ tryParsing(cap: Capability): A | null /** * This figures out whether a given `childCap` can be delegated from `parentCap`. From d31b2d13ee60868a6d0822479549ef7c0e65c89f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Mon, 13 Dec 2021 20:29:18 +0100 Subject: [PATCH 26/26] Use nesting instead of combining flat records --- src/attenuation.ts | 46 ++++++++------ src/capability/wnfs.ts | 42 +++++-------- tests/attenuation.test.ts | 84 ++++++++++++++++--------- tests/capabilitiy/wnfs.test.ts | 112 ++++++++++++++++++++------------- 4 files changed, 168 insertions(+), 116 deletions(-) diff --git a/src/attenuation.ts b/src/attenuation.ts index 828dfcd..fd9ace9 100644 --- a/src/attenuation.ts +++ b/src/attenuation.ts @@ -21,7 +21,7 @@ export interface CapabilitySemantics { * - `null`: The capabilities from `parentCap` and `childCap` are unrelated and can't be compared nor delegated. * - `CapabilityEscalation`: It's clear that `childCap` is meant to be delegated from `parentCap`, but there's a rights escalation. */ - tryDelegating(parentCap: T, childCap: T): T | null | CapabilityEscalation + tryDelegating(parentCap: A, childCap: A): A | null | CapabilityEscalation // TODO builders } @@ -33,6 +33,17 @@ export interface CapabilityInfo { } +export interface CapabilityWithInfo { + info: CapabilityInfo + capability: A +} + + +export type CapabilityResult + = CapabilityWithInfo + | CapabilityEscalation + + export interface CapabilityEscalation { escalation: string // reason capability: A // the capability that escalated rights @@ -45,21 +56,16 @@ function isCapabilityEscalation(obj: unknown): obj is CapabilityEscalation } -export type CapabilityResult - = A & CapabilityInfo - | CapabilityEscalation - - export function capabilities( ucan: Chained, capability: CapabilitySemantics, ): Iterable> { - function* findParsingCaps(ucan: Ucan): Iterable { + function* findParsingCaps(ucan: Ucan): Iterable> { const capInfo = parseCapabilityInfo(ucan) for (const cap of ucan.payload.att) { const parsedCap = capability.tryParsing(cap) - if (parsedCap != null) yield { ...parsedCap, ...capInfo } + if (parsedCap != null) yield { info: capInfo, capability: parsedCap } } } @@ -74,7 +80,7 @@ export function capabilities( yield parsedParentCap } else { // try figuring out whether we can delegate the capabilities from this to the parent - const delegated = capability.tryDelegating(parsedParentCap, parsedChildCap) + const delegated = capability.tryDelegating(parsedParentCap.capability, parsedChildCap.capability) // if the capabilities *are* related, then this will be non-null // otherwise we just continue looking if (delegated != null) { @@ -84,7 +90,10 @@ export function capabilities( if (isCapabilityEscalation(delegated)) { yield delegated // which is an escalation } else { - yield delegateCapabilityInfo({ ...parsedChildCap, ...delegated }, parsedParentCap) + yield { + info: delegateCapabilityInfo(parsedChildCap.info, parsedParentCap.info), + capability: delegated + } } } } @@ -103,19 +112,18 @@ export function capabilities( return ucan.reduce(delegate)() } -function delegateCapabilityInfo(childCap: A, parentCap: A): A { +function delegateCapabilityInfo(childInfo: CapabilityInfo, parentInfo: CapabilityInfo): CapabilityInfo { let notBefore = {} - if (childCap.notBefore != null && parentCap.notBefore != null) { - notBefore = { notBefore: Math.max(childCap.notBefore, parentCap.notBefore) } - } else if (parentCap.notBefore != null) { - notBefore = { notBefore: parentCap.notBefore } + if (childInfo.notBefore != null && parentInfo.notBefore != null) { + notBefore = { notBefore: Math.max(childInfo.notBefore, parentInfo.notBefore) } + } else if (parentInfo.notBefore != null) { + notBefore = { notBefore: parentInfo.notBefore } } else { - notBefore = { notBefore: childCap.notBefore } + notBefore = { notBefore: childInfo.notBefore } } return { - ...childCap, - originator: parentCap.originator, - expiresAt: Math.min(childCap.expiresAt, parentCap.expiresAt), + originator: parentInfo.originator, + expiresAt: Math.min(childInfo.expiresAt, parentInfo.expiresAt), ...notBefore, } } diff --git a/src/capability/wnfs.ts b/src/capability/wnfs.ts index 5714a45..eca89bc 100644 --- a/src/capability/wnfs.ts +++ b/src/capability/wnfs.ts @@ -57,23 +57,32 @@ export const wnfsPublicSemantics: CapabilitySemantics = { } }, - tryDelegating(parentCap: T, childCap: T): T | null | CapabilityEscalation { + tryDelegating(parentCap: WnfsPublicCapability, childCap: WnfsPublicCapability): WnfsPublicCapability | null | CapabilityEscalation { // need to delegate the same user's file system if (childCap.user !== parentCap.user) return null // must not escalate capability level if (wnfsCapLevels[childCap.cap] > wnfsCapLevels[parentCap.cap]) { - return escalationPublic("Capability level escalation", childCap) + return { + escalation: "Capability level escalation", + capability: childCap, + } } // parentCap path must be a prefix of childCap path if (childCap.publicPath.length < parentCap.publicPath.length) { - return escalationPublic("WNFS Public path access escalation", childCap) + return { + escalation: "WNFS Public path access escalation", + capability: childCap, + } } for (let i = 0; i < parentCap.publicPath.length; i++) { if (childCap.publicPath[i] !== parentCap.publicPath[i]) { - return escalationPublic("WNFS Public path access escalation", childCap) + return { + escalation: "WNFS Public path access escalation", + capability: childCap, + } } } @@ -134,7 +143,10 @@ const wnfsPrivateSemantics: CapabilitySemantics = { // This escalation *could* be wrong, but we shouldn't assume they're unrelated either. if (wnfsCapLevels[childCap.cap] > wnfsCapLevels[parentCap.cap]) { - return escalationPrivate("Capability level escalation", childCap) + return { + escalation: "Capability level escalation", + capability: childCap, + } } return { @@ -148,23 +160,3 @@ const wnfsPrivateSemantics: CapabilitySemantics = { export function wnfsPrivateCapabilities(ucan: Chained) { return capabilities(ucan, wnfsPrivateSemantics) } - - - -// ㊙️ - - -function escalationPublic(reason: string, cap: T): CapabilityEscalation { - return { - escalation: reason, - capability: { user: cap.user, publicPath: cap.publicPath, cap: cap.cap } - } -} - - -function escalationPrivate(reason: string, cap: T): CapabilityEscalation { - return { - escalation: reason, - capability: { user: cap.user, requiredINumbers: cap.requiredINumbers, cap: cap.cap } - } -} diff --git a/tests/attenuation.test.ts b/tests/attenuation.test.ts index 96637ce..cacbcc9 100644 --- a/tests/attenuation.test.ts +++ b/tests/attenuation.test.ts @@ -33,11 +33,15 @@ describe("attenuation.emailCapabilities", () => { const emailCaps = Array.from(emailCapabilities(await Chained.fromToken(token.encode(ucan)))) expect(emailCaps).toEqual([{ - originator: alice.did(), - expiresAt: Math.min(leafUcan.payload.exp, ucan.payload.exp), - notBefore: maxNbf(leafUcan.payload.nbf, ucan.payload.nbf), - email: "alice@email.com", - cap: "SEND" + info: { + originator: alice.did(), + expiresAt: Math.min(leafUcan.payload.exp, ucan.payload.exp), + notBefore: maxNbf(leafUcan.payload.nbf, ucan.payload.nbf), + }, + capability: { + email: "alice@email.com", + cap: "SEND" + } }]) }) @@ -62,11 +66,15 @@ describe("attenuation.emailCapabilities", () => { // we implicitly expect the originator to become bob expect(Array.from(emailCapabilities(await Chained.fromToken(token.encode(ucan))))).toEqual([{ - originator: bob.did(), - expiresAt: ucan.payload.exp, - notBefore: ucan.payload.nbf, - email: "bob@email.com", - cap: "SEND" + info: { + originator: bob.did(), + expiresAt: ucan.payload.exp, + notBefore: ucan.payload.nbf, + }, + capability: { + email: "bob@email.com", + cap: "SEND" + } }]) }) @@ -112,18 +120,26 @@ describe("attenuation.emailCapabilities", () => { expect(Array.from(emailCapabilities(chained))).toEqual([ { - originator: alice.did(), - expiresAt: Math.min(leafUcanAlice.payload.exp, ucan.payload.exp), - notBefore: maxNbf(leafUcanAlice.payload.nbf, ucan.payload.nbf), - email: "alice@email.com", - cap: "SEND", + info: { + originator: alice.did(), + expiresAt: Math.min(leafUcanAlice.payload.exp, ucan.payload.exp), + notBefore: maxNbf(leafUcanAlice.payload.nbf, ucan.payload.nbf), + }, + capability: { + email: "alice@email.com", + cap: "SEND", + } }, { - originator: bob.did(), - expiresAt: Math.min(leafUcanBob.payload.exp, ucan.payload.exp), - notBefore: maxNbf(leafUcanBob.payload.nbf, ucan.payload.nbf), - email: "bob@email.com", - cap: "SEND", + info: { + originator: bob.did(), + expiresAt: Math.min(leafUcanBob.payload.exp, ucan.payload.exp), + notBefore: maxNbf(leafUcanBob.payload.nbf, ucan.payload.nbf), + }, + capability: { + email: "bob@email.com", + cap: "SEND", + } } ]) }) @@ -162,18 +178,26 @@ describe("attenuation.emailCapabilities", () => { expect(Array.from(emailCapabilities(chained))).toEqual([ { - originator: alice.did(), - expiresAt: Math.min(leafUcanAlice.payload.exp, ucan.payload.exp), - notBefore: maxNbf(leafUcanAlice.payload.nbf, ucan.payload.nbf), - email: "alice@email.com", - cap: "SEND", + info: { + originator: alice.did(), + expiresAt: Math.min(leafUcanAlice.payload.exp, ucan.payload.exp), + notBefore: maxNbf(leafUcanAlice.payload.nbf, ucan.payload.nbf), + }, + capability: { + email: "alice@email.com", + cap: "SEND", + } }, { - originator: bob.did(), - expiresAt: Math.min(leafUcanBob.payload.exp, ucan.payload.exp), - notBefore: maxNbf(leafUcanBob.payload.nbf, ucan.payload.nbf), - email: "alice@email.com", - cap: "SEND", + info: { + originator: bob.did(), + expiresAt: Math.min(leafUcanBob.payload.exp, ucan.payload.exp), + notBefore: maxNbf(leafUcanBob.payload.nbf, ucan.payload.nbf), + }, + capability: { + email: "alice@email.com", + cap: "SEND", + } } ]) }) diff --git a/tests/capabilitiy/wnfs.test.ts b/tests/capabilitiy/wnfs.test.ts index 5bc8672..3bf19ed 100644 --- a/tests/capabilitiy/wnfs.test.ts +++ b/tests/capabilitiy/wnfs.test.ts @@ -24,12 +24,16 @@ describe("wnfs public capability", () => { expect(Array.from(wnfsPublicCapabilities(chain))).toEqual([ { - originator: alice.did(), - expiresAt: Math.min(leaf.payload.exp, ucan.payload.exp), - notBefore: maxNbf(leaf.payload.nbf, ucan.payload.nbf), - user: "boris.fission.name", - publicPath: ["Apps", "appinator"], - cap: "REVISE", + info: { + originator: alice.did(), + expiresAt: Math.min(leaf.payload.exp, ucan.payload.exp), + notBefore: maxNbf(leaf.payload.nbf, ucan.payload.nbf), + }, + capability: { + user: "boris.fission.name", + publicPath: ["Apps", "appinator"], + cap: "REVISE", + } } ]) }) @@ -81,12 +85,16 @@ describe("wnfs public capability", () => { } }, { - originator: alice.did(), - expiresAt: Math.min(leaf.payload.exp, ucan.payload.exp), - notBefore: maxNbf(leaf.payload.nbf, ucan.payload.nbf), - user: "boris.fission.name", - publicPath: ["Apps", "appinator"], - cap: "OVERWRITE", + info: { + originator: alice.did(), + expiresAt: Math.min(leaf.payload.exp, ucan.payload.exp), + notBefore: maxNbf(leaf.payload.nbf, ucan.payload.nbf), + }, + capability: { + user: "boris.fission.name", + publicPath: ["Apps", "appinator"], + cap: "OVERWRITE", + } } ]) }) @@ -109,12 +117,16 @@ describe("wnfs private capability", () => { expect(Array.from(wnfsPrivateCapabilities(chain))).toEqual([ { - originator: alice.did(), - expiresAt: Math.min(leaf.payload.exp, ucan.payload.exp), - notBefore: maxNbf(leaf.payload.nbf, ucan.payload.nbf), - user: "boris.fission.name", - requiredINumbers: new Set(["abc", "def"]), - cap: "REVISE", + info: { + originator: alice.did(), + expiresAt: Math.min(leaf.payload.exp, ucan.payload.exp), + notBefore: maxNbf(leaf.payload.nbf, ucan.payload.nbf), + }, + capability: { + user: "boris.fission.name", + requiredINumbers: new Set(["abc", "def"]), + cap: "REVISE", + } } ]) }) @@ -171,12 +183,16 @@ describe("wnfs private capability", () => { } }, { - originator: alice.did(), - expiresAt: Math.min(leaf.payload.exp, ucan.payload.exp), - notBefore: maxNbf(leaf.payload.nbf, ucan.payload.nbf), - user: "boris.fission.name", - requiredINumbers: new Set(["abc", "ghi"]), - cap: "CREATE", + info: { + originator: alice.did(), + expiresAt: Math.min(leaf.payload.exp, ucan.payload.exp), + notBefore: maxNbf(leaf.payload.nbf, ucan.payload.nbf), + }, + capability: { + user: "boris.fission.name", + requiredINumbers: new Set(["abc", "ghi"]), + cap: "CREATE", + } } ]) }) @@ -201,20 +217,28 @@ describe("wnfs private capability", () => { expect(Array.from(wnfsPrivateCapabilities(chain))).toEqual([ { - originator: alice.did(), - expiresAt: Math.min(leafAlice.payload.exp, ucan.payload.exp), - notBefore: maxNbf(leafAlice.payload.nbf, ucan.payload.nbf), - user: "boris.fission.name", - requiredINumbers: new Set(["inumalice", "subinum"]), - cap: "OVERWRITE", + info: { + originator: alice.did(), + expiresAt: Math.min(leafAlice.payload.exp, ucan.payload.exp), + notBefore: maxNbf(leafAlice.payload.nbf, ucan.payload.nbf), + }, + capability: { + user: "boris.fission.name", + requiredINumbers: new Set(["inumalice", "subinum"]), + cap: "OVERWRITE", + } }, { - originator: bob.did(), - expiresAt: Math.min(leafBob.payload.exp, ucan.payload.exp), - notBefore: maxNbf(leafBob.payload.nbf, ucan.payload.nbf), - user: "boris.fission.name", - requiredINumbers: new Set(["inumbob", "subinum"]), - cap: "OVERWRITE", + info: { + originator: bob.did(), + expiresAt: Math.min(leafBob.payload.exp, ucan.payload.exp), + notBefore: maxNbf(leafBob.payload.nbf, ucan.payload.nbf), + }, + capability: { + user: "boris.fission.name", + requiredINumbers: new Set(["inumbob", "subinum"]), + cap: "OVERWRITE", + } } ]) }) @@ -247,12 +271,16 @@ describe("wnfs private capability", () => { } }, { - originator: bob.did(), - expiresAt: Math.min(leafBob.payload.exp, ucan.payload.exp), - notBefore: maxNbf(leafBob.payload.nbf, ucan.payload.nbf), - user: "boris.fission.name", - requiredINumbers: new Set(["inumbob", "subinum"]), - cap: "OVERWRITE", + info: { + originator: bob.did(), + expiresAt: Math.min(leafBob.payload.exp, ucan.payload.exp), + notBefore: maxNbf(leafBob.payload.nbf, ucan.payload.nbf), + }, + capability: { + user: "boris.fission.name", + requiredINumbers: new Set(["inumbob", "subinum"]), + cap: "OVERWRITE", + } } ]) })