diff --git a/README.md b/README.md index d9ef772..5e24c7d 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # @ipld/dag-ucan -An implementation of [UCAN][]s representation in [IPLD][], designed for use with [multiformats][]. +An implementation of [UCAN][]s in [IPLD][] via [Advanced Data Layout (ADL)](ADL), designed for use with [multiformats][]. ## Overview -This library implements multicodec for represenating [UCAN]s natively in [IPLD][]. It uses [DAG-CBOR][] as a primary encoding, which is more compact and has a better hash consitency than a secondary RAW JWT encoding. Every UCAN in primary encoding can be formatted into a JWT string and consumed by spec compliant [UCAN][] implementations. However not every [UCAN][] can be encoded in a primary CBOR representation, as loss of whitespaces and key order would lead to mismatched signature. Library issues UCANs only in primary CBOR representation. When parsing UCANs that can not have valid CBOR representation, secondary RAW representation is used, which allows interop with all existing tokens in the wild. +This library implements [ADL][] for representing [UCAN]s natively in [IPLD][]. It uses [DAG-CBOR][] as a primary encoding, which is hash consistent and more compact than a secondary RAW JWT encoding. Every UCAN in either encoding can be formatted into a valid JWT string and consumed by other spec compliant [UCAN][] implementations. However [UCAN][]s issued by other libraries may end up in represented in secondory RAW JWT encoding, that is because whitespaces and key order in JWT affects signatures and there for can't be represented accurately in CBOR. When parsing UCANs library will use CBOR representation and fallback to RAW JWT, which allows interop with all existing tokens in the wild. ### Primary Representation @@ -13,47 +13,50 @@ UCANs in primary representation are encoded in [DAG-CBOR][] and have following ```ipldsch type UCAN struct { - header Heder - body Body - signature Signature -} - -type Header struct { version String - algorithm Algorithm -} -type Body struct { - issuer String - audience String, + issuer SigningKey + audience SigningKey + signature Signature + capabilities [Capability] - expiration Int proofs [&UCAN] - -- If empty omitted - facts optional [Fact] + expiration Int + + facts [Fact] nonce optional String notBefore optional Int +} representation map { + field facts default [] + field proofs default [] } + type Capability struct { - with String - -- Must be all lowercase - can String - -- can have other fields + with Resource + can Ability + -- can have arbitrary other fields } type Fact { String: Any } -enum Algorithm { - EdDSA (237) -- 0xed Ed25519 multicodec - RS256 (4613) -- 0x1205 RSA multicodec -} representation int + +-- The resource pointer in URI format +type Resource = String + +-- Must be all lower-case `/` delimeted with at least one path segment +type Ability = String -- Signature is computed by seralizing header & body -- into corresponding JSON with DAG-JSON (to achieve -- for hash consitency) then encoded into base64 and -- then signed by issuers private key type Signature = Bytes + +-- multicodec tagged public key +-- 0xed Ed25519 +-- 0x1205 RSA +type SigningKey = Bytes ``` ## API @@ -62,13 +65,13 @@ type Signature = Bytes import * as UCAN from "@ipld/dag-ucan" ``` -#### `UCAN.parse(jwt: string): UCAN.UCAN` +#### `UCAN.parse(jwt: string): UCAN.View` Parses UCAN formatted as JWT string into a representatino that can be encoded, formatted and queried. ```ts const ucan = UCAN.parse(jwt) -ucan.issuer // did:key:zAlice +ucan.issuer.did() // did:key:z6Mkk89bC3JrVqKie71YEcc5M1SMVxuCgNx6zLZ8SYJsxALi ``` #### `UCAN.format(ucan: UCAN.UCAN): string` @@ -83,29 +86,37 @@ UCAN.format(UCAN.parse(jwt)) === jwt // true Encodes UCAN into a binary representation. +```ts +UCAN.encode(UCAN.parse(jwt)) // Uint8Array(679) +``` + #### `UCAN.decode(bytes: Uint8Array): UCAN.UCAN` -Decodes byte encoded UCAN. +Decodes UCAN from binary representation into object representation. + +```ts +UCAN.decode(UCAN.encode(ucan)) +``` #### `UCAN.issue(options: UCAN.UCANOptions): Promise` -Issues or derives a UCAN. +Issues a signed UCAN. -> Please note that no capability or time bound validation takes place +> Please note that no capability or time bound validation takes place. ```ts const ucan = await UCAN.issue({ - issuer: boris, - audience: 'did:key:z6MkffDZCkCTWreg8868fG1FGFogcJj5X6PY93pPcWDn9bob' + issuer: alice, + audience: bob, capabilities: [ { - with: "wnfs://boris.fission.name/public/photos/", - can: "wnfs/append", + can: "fs/read", + with: `storage://${alice.did()}/public/photos/`, }, { - with: "mailto:boris@fission.codes", - can: "msg/send" - } + can: "pin/add", + with: alice.did(), + }, ], }) ``` @@ -115,3 +126,4 @@ const ucan = await UCAN.issue({ [ipld schema]: https://ipld.io/docs/schemas/using/authoring-guide/ [dag-cbor]: https://ipld.io/docs/codecs/known/dag-cbor/ [multiformats]: https://github.com/multiformats/js-multiformats +[adl]: https://ipld.io/docs/advanced-data-layouts/ diff --git a/package.json b/package.json index bc0a421..9c153b9 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,14 @@ "./src/lib.js": { "types": "./dist/src/lib.d.ts", "import": "./src/lib.js" + }, + "./src/codec/cbor.js": { + "types": "./dist/src/codec/cbor.d.ts", + "import": "./src/codec/cbor.js" + }, + "./src/codec/raw.js": { + "types": "./dist/src/codec/raw.d.ts", + "import": "./src/codec/raw.js" } }, "c8": { diff --git a/src/codec.js b/src/codec.js deleted file mode 100644 index e69de29..0000000 diff --git a/src/codec/cbor.js b/src/codec/cbor.js new file mode 100644 index 0000000..91169a3 --- /dev/null +++ b/src/codec/cbor.js @@ -0,0 +1,85 @@ +import * as UCAN from "../ucan.js" +import * as CBOR from "@ipld/dag-cbor" +import * as Parser from "../parser.js" +import * as View from "../view.js" +import * as DID from "../did.js" +import { CID } from "multiformats/cid" + +export const name = "dag-ucan" +export const code = CBOR.code + +/** + * Encodes given UCAN (in either IPLD or JWT representation) and encodes it into + * corresponding bytes representation. UCAN in IPLD representation is encoded as + * DAG-CBOR which JWT representation is encoded as raw bytes of JWT string. + * + * @template {UCAN.Capability} C + * @param {UCAN.Model} ucan + * @returns {UCAN.ByteView>} + */ +export const encode = ucan => { + const { facts, nonce, notBefore, ...rest } = match(ucan) + return CBOR.encode({ + ...rest, + // leave out optionals unless they are set + ...(facts.length > 0 && { facts }), + ...(ucan.nonce && { nonce }), + ...(ucan.notBefore && { notBefore: ucan.notBefore }), + signature: Parser.parseBytes(ucan.signature, "signature"), + }) +} + +/** + * Decodes UCAN in primary CBOR representation. It does not validate UCAN, it's + * signature or proof chain. This is to say decoded UCAN may be invalid. + * + * @template {UCAN.Capability} C + * @param {UCAN.ByteView>} bytes + * @returns {UCAN.View} + */ +export const decode = bytes => View.cbor(match(CBOR.decode(bytes))) + +/** + * @template {UCAN.Capability} C + * @param {{[key in PropertyKey]: unknown}|UCAN.Model} data + * @returns {UCAN.Model} + */ +export const match = data => ({ + version: Parser.parseVersion(data.version, "version"), + issuer: parseDID(data.issuer, "issuer"), + audience: parseDID(data.audience, "audience"), + capabilities: /** @type {C[]} */ ( + Parser.parseCapabilities(data.capabilities, "capabilities") + ), + expiration: Parser.parseInt(data.expiration, "expiration"), + proofs: Parser.parseOptionalArray(data.proofs, parseProof, "proofs") || [], + signature: Parser.parseBytes(data.signature, "signature"), + nonce: Parser.parseOptionalString(data.nonce, "nonce"), + facts: Parser.parseOptionalArray(data.facts, Parser.parseFact, "facts") || [], + notBefore: Parser.parseOptionalInt(data.notBefore, "notBefore"), +}) + +/** + * @template {UCAN.Capability} C + * @param {unknown} cid + * @param {string} context + */ +const parseProof = (cid, context) => + /** @type {UCAN.Proof} */ (CID.asCID(cid)) || + Parser.ParseError.throw( + `Expected ${context} to be CID, instead got ${JSON.stringify(cid)}` + ) + +/** + * + * @param {unknown} input + * @param {string} context + */ +const parseDID = (input, context) => + input instanceof Uint8Array + ? DID.decode(input) + : Parser.ParseError.throw( + `Expected ${context} to be Uint8Array, instead got ${JSON.stringify( + input + )}` + ) diff --git a/src/codec/raw.js b/src/codec/raw.js new file mode 100644 index 0000000..f713d80 --- /dev/null +++ b/src/codec/raw.js @@ -0,0 +1,26 @@ +import * as UCAN from "../ucan.js" +import * as RAW from "multiformats/codecs/raw" +import * as View from "../view.js" +import * as UTF8 from "../utf8.js" +import * as Parser from "../parser.js" + +export const name = "dag-ucan" +export const code = RAW.code + +/** + * Encodes given UCAN (in either JWT representation) and encodes it into + * corresponding bytes representation. + * + * @template {UCAN.Capability} C + * @param {UCAN.RAW} ucan + * @returns {UCAN.ByteView>} + */ +export const encode = ucan => + new Uint8Array(ucan.buffer, ucan.byteOffset, ucan.byteLength) + +/** + * @template {UCAN.Capability} C + * @param {UCAN.ByteView>} bytes + * @returns {UCAN.View} + */ +export const decode = bytes => View.jwt(Parser.parse(UTF8.decode(bytes)), bytes) diff --git a/src/crypto.ts b/src/crypto.ts index fa8171d..7a5e424 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -14,7 +14,7 @@ export interface SyncVerifier { ): T } -export interface Verifier { +export interface Verifier { readonly algorithm: A verify, T>( payload: ByteView, diff --git a/src/did.js b/src/did.js new file mode 100644 index 0000000..51e229d --- /dev/null +++ b/src/did.js @@ -0,0 +1,70 @@ +import * as UCAN from "./ucan.js" +import { base58btc } from "multiformats/bases/base58" +import { varint } from "multiformats" + +const DID_KEY_PREFIX = `did:key:` +export const ED25519 = 0xed +export const RSA = 0x1205 + +/** + * @param {Uint8Array} key + * @returns {Code} + */ +export const algorithm = key => { + const [code] = varint.decode(key) + switch (code) { + case ED25519: + case RSA: + return code + default: + throw new RangeError( + `Unsupported key algorithm with multicode 0x${code.toString(16)}.` + ) + } +} + +/** + * @typedef {typeof ED25519|typeof RSA} Code + */ + +/** + * @param {UCAN.DID} did + * @returns {UCAN.DIDView} + */ +export const parse = did => { + if (!did.startsWith(DID_KEY_PREFIX)) { + throw new RangeError(`Invalid DID "${did}", must start with 'did:key:'`) + } + return decode(base58btc.decode(did.slice(DID_KEY_PREFIX.length))) +} + +/** + * @param {UCAN.ByteView} key + * @returns {UCAN.DID} + */ +export const format = key => + /** @type {UCAN.DID} */ (`${DID_KEY_PREFIX}${base58btc.encode(encode(key))}`) + +/** + * @param {Uint8Array} bytes + * @returns {UCAN.DIDView} + */ +export const decode = bytes => { + const _ = algorithm(bytes) + return new DID(bytes.buffer, bytes.byteOffset, bytes.byteLength) +} + +/** + * @param {Uint8Array} bytes + * @returns {UCAN.ByteView} + */ +export const encode = bytes => { + const _ = algorithm(bytes) + return bytes +} + +class DID extends Uint8Array { + did() { + return format(this) + } +} diff --git a/src/formatter.js b/src/formatter.js index 7d989af..ef76427 100644 --- a/src/formatter.js +++ b/src/formatter.js @@ -1,61 +1,64 @@ import * as UCAN from "./ucan.js" +import * as DID from "./did.js" import * as json from "@ipld/dag-json" import { base64urlpad } from "multiformats/bases/base64" +import { algorithm, ED25519, RSA } from "./did.js" /** * @template {UCAN.Capability} C - * @param {UCAN.Data} data - * @returns {UCAN.JWT>} + * @param {UCAN.Model} model + * @returns {UCAN.JWT} */ -export const format = ({ header, body, signature }) => - `${formatHeader(header)}.${formatBody(body)}.${formatSignature(signature)}` +export const format = model => + `${formatHeader(model)}.${formatBody(model)}.${formatSignature( + model.signature + )}` /** * @template {UCAN.Capability} C - * @param {object} payload - * @param {UCAN.Header} payload.header - * @param {UCAN.Body} payload.body + * @param {UCAN.Input} model * @returns {`${UCAN.Header}.${UCAN.Body}`} */ -export const formatPayload = ({ header, body }) => - `${formatHeader(header)}.${formatBody(body)}` +export const formatPayload = model => + `${formatHeader(model)}.${formatBody(model)}` /** - * @param {UCAN.Header} header + * @param {UCAN.Input} model * @returns {`${UCAN.Header}`} */ -export const formatHeader = header => - base64urlpad.baseEncode(encodeHeader(header)) +export const formatHeader = model => + base64urlpad.baseEncode(encodeHeader(model)) /** - * @param {UCAN.Body} body + * @param {UCAN.Input} model * @returns {`${UCAN.Body}`} */ -export const formatBody = body => base64urlpad.baseEncode(encodeBody(body)) +export const formatBody = model => base64urlpad.baseEncode(encodeBody(model)) /** * @template {UCAN.Capability} C - * @param {UCAN.Signature<[UCAN.Header, UCAN.Body]>} signature + * @param {UCAN.Signature} signature + * @returns {UCAN.ToString>} */ export const formatSignature = signature => base64urlpad.baseEncode(signature) /** - * @param {UCAN.Header} header + * @param {UCAN.Input} model */ -export const encodeHeader = header => +export const encodeHeader = model => json.encode({ - alg: encodeAgorithm(header.algorithm), - ucv: header.version, + alg: encodeAgorithm(model), + ucv: model.version, typ: "JWT", }) /** - * @param {UCAN.Body} body + * @param {UCAN.Input} body */ export const encodeBody = body => json.encode({ - iss: body.issuer, - aud: body.audience, + iss: DID.format(body.issuer), + aud: DID.format(body.audience), att: body.capabilities, exp: body.expiration, prf: body.proofs.map(encodeProof), @@ -71,17 +74,16 @@ export const encodeBody = body => export const encodeProof = proof => proof.toString() /** - * @template {number} Code - * @param {Code} code + * @param {UCAN.Input} model */ -export const encodeAgorithm = code => { - switch (code) { - case 0xed: +export const encodeAgorithm = model => { + switch (algorithm(model.issuer)) { + case ED25519: return "EdDSA" - case 0x1205: + case RSA: return "RS256" /* c8 ignore next 2 */ default: - throw new RangeError(`Unknown KeyType "${code}"`) + throw new RangeError(`Unknown KeyType "${algorithm(model.issuer)}"`) } } diff --git a/src/lib.js b/src/lib.js index c033b78..307ba2d 100644 --- a/src/lib.js +++ b/src/lib.js @@ -1,6 +1,6 @@ import * as UCAN from "./ucan.js" -import * as CBOR from "@ipld/dag-cbor" -import * as RAW from "multiformats/codecs/raw" +import * as CBOR from "./codec/cbor.js" +import * as RAW from "./codec/raw.js" import * as UTF8 from "./utf8.js" import * as View from "./view.js" import * as Parser from "./parser.js" @@ -9,13 +9,15 @@ import { sha256 } from "multiformats/hashes/sha2" import { CID } from "multiformats/cid" export * from "./ucan.js" -import { code } from "./ucan.js" + +export * as DID from "./did.js" /** @type {UCAN.Version} */ export const VERSION = "0.8.1" export const name = "dag-ucan" -export const raw = RAW.code +/** @type {typeof CBOR.code|typeof RAW.code} */ +export const code = CBOR.code /** * Encodes given UCAN (in either IPLD or JWT representation) and encodes it into @@ -24,44 +26,10 @@ export const raw = RAW.code * * @template {UCAN.Capability} C * @param {UCAN.UCAN} ucan - * @returns {UCAN.ByteView>} + * @returns {UCAN.ByteView>|UCAN.ByteView>} */ -export const encode = ucan => { - switch (ucan.code) { - case code: - return CBOR.encode({ - header: { - version: ucan.header.version, - algorithm: ucan.header.algorithm, - }, - body: { - issuer: ucan.body.issuer, - audience: ucan.body.audience, - capabilities: ucan.body.capabilities.map(Parser.asCapability), - expiration: ucan.body.expiration, - proofs: ucan.body.proofs, - // leave out optionals unless they are set - ...(ucan.body.facts.length > 0 && { facts: ucan.body.facts }), - ...(ucan.body.nonce && { nonce: ucan.body.nonce }), - ...(ucan.body.notBefore && { notBefore: ucan.body.notBefore }), - }, - signature: ucan.signature, - }) - case raw: - return /** @type {Uint8Array} */ (UTF8.encode(ucan.jwt)) - default: - return invalidCode(ucan) - } -} - -/** - * @param {never} ucan - */ -const invalidCode = ({ code: unknown }) => { - throw new TypeError( - `Provided UCAN has unsupported code: ${unknown}, it must be ${code} for CBOR representation or ${raw} for JWT representation` - ) -} +export const encode = ucan => + ucan instanceof Uint8Array ? RAW.encode(ucan) : CBOR.encode(ucan) /** * Decodes binary encoded UCAN. It assumes UCAN is in primary IPLD @@ -70,18 +38,15 @@ const invalidCode = ({ code: unknown }) => { * a JWT. * * @template {UCAN.Capability} C - * @param {UCAN.ByteView>} bytes + * @param {UCAN.ByteView>|UCAN.ByteView>} bytes * @returns {UCAN.View} */ export const decode = bytes => { try { - const data = CBOR.decode(bytes) - data.body.facts = data.body.facts || [] - data.body.nonce = data.body.nonce || undefined - data.body.notBefore = data.body.notBefore || undefined - return View.cbor(data) + return CBOR.decode(/** @type {UCAN.ByteView>} */ (bytes)) } catch (error) { - return parse(UTF8.decode(/** @type {Uint8Array} */ (bytes))) + const jwt = UTF8.decode(/** @type {UCAN.RAW} */ (bytes)) + return parse(jwt) } } @@ -102,18 +67,22 @@ export const link = async (ucan, options) => { /** * @template {UCAN.Capability} C * @template {number} [A=typeof sha256.code] - * @param {UCAN.UCAN} ucan + * @param {UCAN.UCAN} data * @param {{hasher?: UCAN.MultihashHasher}} [options] */ export const write = async ( - ucan, + data, { hasher = /** @type {UCAN.MultihashHasher } */ (sha256) } = {} ) => { - const bytes = encode(ucan) + const [code, bytes] = + data instanceof Uint8Array + ? [RAW.code, RAW.encode(data)] + : [CBOR.code, CBOR.encode(data)] + const cid = /** @type {CID & UCAN.Proof} */ ( - CID.createV1(ucan.code, await hasher.digest(bytes)) + CID.createV1(code, await hasher.digest(bytes)) ) - return { cid, bytes } + return { cid, bytes, data } } /** @@ -129,18 +98,19 @@ export const write = async ( * if it is not. * * @template {UCAN.Capability} C - * @param {UCAN.JWT>} input + * @param {UCAN.JWT} jwt * @returns {UCAN.View} + */ -export const parse = input => { - const ucan = Parser.parse(input) +export const parse = jwt => { + const model = Parser.parse(jwt) // If formatting UCAN produces same jwt string we can use IPLD representation // otherwise we need to fallback to raw representation. This decision will // affect how we `encode` the UCAN. - return Formatter.format(ucan) === input - ? View.cbor(ucan) - : View.jwt(ucan, /** @type {UCAN.JWT>} */ (input)) + return Formatter.format(model) === jwt + ? View.cbor(model) + : View.jwt(model, UTF8.encode(jwt)) } /** @@ -148,18 +118,10 @@ export const parse = input => { * * @template {UCAN.Capability} C * @param {UCAN.UCAN} ucan - * @returns {UCAN.JWT>} + * @returns {UCAN.JWT} */ -export const format = ucan => { - switch (ucan.code) { - case code: - return Formatter.format(ucan) - case raw: - return ucan.jwt - default: - return invalidCode(ucan) - } -} +export const format = ucan => + ucan instanceof Uint8Array ? UTF8.decode(ucan) : Formatter.format(ucan) /** * Creates a new signed token with a given `options.issuer`. If expiration is @@ -182,31 +144,38 @@ export const issue = async ({ proofs = [], nonce, }) => { - const header = { + const data = CBOR.match({ version: VERSION, - algorithm: issuer.algorithm, - } - - // Validate - if (!audience.startsWith("did:")) { - throw new TypeError("The audience must be a DID") - } - - /** @type {UCAN.Body} */ - const body = { - issuer: issuer.did(), - audience, - capabilities: capabilities.map(Parser.asCapability), + issuer: parseDID(issuer, "issuer"), + audience: parseDID(audience, "audience"), + capabilities, facts, expiration, notBefore, proofs, nonce, - } + // Provide fake signature to pass validation + // we'll replace this with actual signature + signature: EMPTY, + }) - const payload = UTF8.encode(Formatter.formatPayload({ header, body })) - /** @type {UCAN.Signature<[UCAN.Header, UCAN.Body]>} */ + const payload = UTF8.encode(Formatter.formatPayload(data)) + /** @type {UCAN.Signature} */ const signature = await issuer.sign(payload) - return View.cbor({ header, body, signature }) + return View.cbor({ ...data, signature }) } + +/** + * + * @param {unknown & {did?:unknown}} value + * @param {string} context + */ +const parseDID = (value, context) => + value && typeof value.did === "function" + ? Parser.parseDID(value.did(), `${context}.did()`) + : Parser.ParseError.throw( + `The ${context}.did() must be a function that returns DID` + ) + +const EMPTY = new Uint8Array() diff --git a/src/parser.js b/src/parser.js index ad76ecc..918648f 100644 --- a/src/parser.js +++ b/src/parser.js @@ -4,14 +4,15 @@ import { base64urlpad } from "multiformats/bases/base64" import * as json from "@ipld/dag-json" import { CID } from "multiformats" import { identity } from "multiformats/hashes/identity" +import * as DID from "./did.js" import * as raw from "multiformats/codecs/raw" /** * Parse JWT formatted UCAN. Note than no validation takes place here. * * @template {UCAN.Capability} C - * @param {UCAN.JWT>} input - * @returns {UCAN.Data} + * @param {UCAN.JWT} input + * @returns {UCAN.Model} */ export const parse = input => { const segments = input.split(".") @@ -23,55 +24,72 @@ export const parse = input => { ) return { - header: parseHeader(header), - body: parseBody(body), + ...parseHeader(header), + ...parseBody(body), signature: base64urlpad.baseDecode(signature), } } /** * @param {string} header - * @returns {UCAN.Header} */ export const parseHeader = header => { const { ucv, alg, typ } = json.decode(base64urlpad.baseDecode(header)) const _type = parseJWT(typ) + const _algorithm = parseAlgorithm(alg) return { - version: parseUCV(ucv), - algorithm: parseAlgorithm(alg), + version: parseVersion(ucv, "ucv"), } } /** * @template {UCAN.Capability} C * @param {string} input - * @returns {UCAN.Body} */ export const parseBody = input => { + /** @type {UCAN.Payload} */ const body = json.decode(base64urlpad.baseDecode(input)) return { - issuer: parseDID(body.iss), - audience: parseDID(body.aud), - expiration: parseInt(body.exp, 10), + issuer: parseDID(body.iss, "iss"), + audience: parseDID(body.aud, "aud"), + expiration: parseInt(body.exp, "exp"), nonce: parseOptionalString(body.nnc, "nnc"), - notBefore: parseMaybeInt(body.nbf, "nbf"), + notBefore: parseOptionalInt(body.nbf, "nbf"), facts: parseOptionalArray(body.fct, parseFact, "fct") || [], - proofs: parseProofs(body.prf), - capabilities: /** @type {C[]} */ ( - parseArray(body.att, parseCapability, "att") - ), + proofs: parseProofs(body.prf, "prf"), + capabilities: /** @type {C[]} */ (parseCapabilities(body.att, "att")), } } /** * @param {unknown} input + * @param {string} name + * @returns {number} + */ +export const parseInt = (input, name) => + Number.isInteger(input) + ? /** @type {number} */ (input) + : ParseError.throw( + `Expected integer but instead got '${name}: ${JSON.stringify(input)}'` + ) + +/** + * @param {unknown} input + * @param {string} context */ -const parseCapability = input => parseStruct(input, asCapability, "att") +export const parseCapability = (input, context) => + parseStruct(input, asCapability, context) +/** + * @param {unknown} input + * @param {string} context + */ +export const parseCapabilities = (input, context) => + parseArray(input, parseCapability, context) /** * @template {UCAN.Capability} C * @param {object & {can?:unknown, with?:unknown}|C} input @@ -148,23 +166,23 @@ const parseURL = input => { /** * @template T * @param {unknown} input - * @param {(input:unknown) => T} parser + * @param {(input:unknown, context:string) => T} parser * @param {string} context * @returns {T[]} */ -const parseArray = (input, parser, context) => +export const parseArray = (input, parser, context) => Array.isArray(input) - ? input.map(parser) + ? input.map((element, n) => parser(element, `${context}[${n}]`)) : ParseError.throw(`${context} must be an array`) /** * @template T * @param {unknown} input - * @param {(input:unknown) => T} parser + * @param {(input:unknown, context: string) => T} parser * @param {string} context * @returns {T[]|undefined} */ -const parseOptionalArray = (input, parser, context) => +export const parseOptionalArray = (input, parser, context) => input === undefined ? input : parseArray(input, parser, context) /** @@ -174,35 +192,42 @@ const parseOptionalArray = (input, parser, context) => * @param {string} context * @returns {T} */ -const parseStruct = (input, parser, context) => +export const parseStruct = (input, parser, context) => input != null && typeof input === "object" ? parser(input) - : ParseError.throw(`${context} must be of type object`) + : ParseError.throw( + `${context} must be of type object, instead got ${input}` + ) /** * @param {unknown} input + * @param {string} context * @returns {UCAN.Fact} */ -const parseFact = input => parseStruct(input, Object, "fct elements") +export const parseFact = (input, context) => parseStruct(input, Object, context) /** * @param {unknown} input + * @param {string} context */ -const parseProofs = input => +const parseProofs = (input, context) => Array.isArray(input) - ? parseArray(input, parseProof, "prf") - : [parseProof(input)] + ? parseArray(input, parseProof, context) + : [parseProof(input, context)] /** * @param {unknown} input + * @param {string} context * @returns {UCAN.Proof} */ -const parseProof = input => { +const parseProof = (input, context) => { const proof = typeof input === "string" ? input : ParseError.throw( - `prf has invalid value ${JSON.stringify(input)}, must be a string` + `${context} has invalid value ${JSON.stringify( + input + )}, must be a string` ) try { return /** @type {UCAN.Proof} */ (CID.parse(proof)) @@ -215,18 +240,20 @@ const parseProof = input => { /** * @param {unknown} input - * @returns {UCAN.DID} + * @param {string} context */ -const parseDID = input => +export const parseDID = (input, context) => typeof input === "string" && input.startsWith("did:") - ? /** @type {UCAN.DID} */ (input) - : ParseError.throw(`DID has invalid representation '${input}'`) + ? DID.parse(/** @type {UCAN.DID} */ (input)) + : ParseError.throw( + `DID has invalid representation '${context}: ${JSON.stringify(input)}'` + ) /** * @param {unknown} input * @param {string} [context] */ -const parseOptionalString = (input, context = "Field") => { +export const parseOptionalString = (input, context = "Field") => { switch (typeof input) { case "string": case "undefined": @@ -238,14 +265,14 @@ const parseOptionalString = (input, context = "Field") => { /** * @param {unknown} input - * @param {string} [context] + * @param {string} context */ -const parseMaybeInt = (input, context = "Field") => { +export const parseOptionalInt = (input, context) => { switch (typeof input) { case "undefined": return undefined case "number": - return parseInt(/** @type {any} */ (input), 10) + return parseInt(/** @type {any} */ (input), context) default: return ParseError.throw( `${context} has invalid value ${JSON.stringify(input)}` @@ -255,13 +282,26 @@ const parseMaybeInt = (input, context = "Field") => { /** * @param {unknown} input + * @param {string} context * @returns {UCAN.Version} */ -const parseUCV = input => +export const parseVersion = (input, context) => /\d+\.\d+\.\d+/.test(/** @type {string} */ (input)) ? /** @type {UCAN.Version} */ (input) - : ParseError.throw(`Header has invalid version 'ucv: "${input}"'`) + : ParseError.throw(`Invalid version '${context}: ${JSON.stringify(input)}'`) +/** + * + * @param {unknown} input + * @param {string} context + * @returns {Uint8Array} + */ +export const parseBytes = (input, context) => + input instanceof Uint8Array + ? input + : ParseError.throw( + `${context} must be Uint8Array, instead got ${JSON.stringify(input)}` + ) /** * @param {unknown} input * @returns {"JWT"} @@ -287,7 +327,10 @@ const parseAlgorithm = input => { } } -class ParseError extends TypeError { +export class ParseError extends TypeError { + get name() { + return "ParseError" + } /** * @param {string} message * @returns {never} diff --git a/src/ucan.ts b/src/ucan.ts index 2758934..c4c6a83 100644 --- a/src/ucan.ts +++ b/src/ucan.ts @@ -4,13 +4,14 @@ import type { } from "multiformats/hashes/interface" import type { MultibaseEncoder } from "multiformats/bases/interface" import type { code as RAW_CODE } from "multiformats/codecs/raw" -import type { Signer, Signature } from "./crypto.js" +import type { code as CBOR_CODE } from "@ipld/dag-cbor" +import type { Signer } from "./crypto.js" +import * as Crypto from "./crypto.js" export * from "./crypto.js" export type { MultihashDigest, MultibaseEncoder, MultihashHasher } -export const code = 0x78c0 export type Fact = Record export interface Agent { @@ -38,35 +39,72 @@ export interface Body { facts: Fact[] proofs: Proof[] } -export type JWT = ToString - -export type UCAN = CBOR | RAW +export type JWT = ToString< + [Head, Payload, Signature], + `${ToString}.${ToString>}.${ToString>}` +> + +export type Signature = + Crypto.Signature<`${ToString}.${ToString>}>`> + +interface Head { + ucv: Version + alg: "EdDSA" | "RS256" + typ: "JWT" +} -export interface Data { - readonly header: Header - readonly body: Body - readonly signature: Signature<[Header, Body]> +export interface Payload { + iss: DID + aud: DID + exp: number + att: C[] + nnc?: string + nbf?: number + fct?: Fact[] + prf?: ToString> } -export interface CBOR extends Data { - readonly code: typeof code + +export type UCAN = Model | RAW + +// export interface Data { +// readonly header: Header +// readonly body: Body +// readonly signature: Signature<[Header, Body]> +// } +// export interface CBOR extends Data { +// readonly code: typeof code +// } + +export interface Input { + version: Version + issuer: ByteView + audience: ByteView + capabilities: C[] + expiration: number + notBefore?: number + nonce?: string + facts: Fact[] + proofs: Proof[] } -export interface RAW { - readonly code: typeof RAW_CODE - readonly jwt: JWT> +export interface Model extends Input { + signature: Signature } -export type View = UCAN & - Data & - Header & - Body +export interface RAW + extends Model, + ByteView> {} + +export interface View extends Model { + readonly model: Model +} export interface UCANOptions< C extends Capability = Capability, A extends number = number > { issuer: Issuer - audience: DID + audience: Agent capabilities: C[] lifetimeInSeconds?: number expiration?: number @@ -81,7 +119,7 @@ export interface UCANOptions< export type Proof< C extends Capability = Capability, A extends number = number -> = Link, 1, typeof code, A> | Link>, 1, typeof RAW_CODE, A> +> = Link, 1, typeof CBOR_CODE, A> | Link, 1, typeof RAW_CODE, A> export interface Block< T extends unknown = unknown, @@ -104,6 +142,7 @@ export interface Capability< } export type DID = ToString +export interface DIDView extends ByteView, Agent {} /** * Represents an IPLD link to a specific data of type `T`. diff --git a/src/view.js b/src/view.js index 23070f0..fd54338 100644 --- a/src/view.js +++ b/src/view.js @@ -1,64 +1,51 @@ import * as UCAN from "./ucan.js" import * as RAW from "multiformats/codecs/raw" +import * as DID from "./did.js" /** * @template {UCAN.Capability} C - * @template {typeof UCAN.code|typeof RAW.code} Code - * @extends {View} + * @implements {UCAN.View} */ class View { /** - * @param {Code} code - * @param {UCAN.Data} data + * @param {UCAN.Model} model */ - constructor(code, { header, body, signature }) { + constructor(model) { /** @readonly */ - this.code = code - /** @readonly */ - this.header = header - /** @readonly */ - this.body = body - /** @readonly */ - this.signature = signature + this.model = model } get version() { - return this.header.version - } - get algorithm() { - return this.header.algorithm + return this.model.version } get issuer() { - return this.body.issuer + return this.model.issuer } - /** - * @returns {UCAN.DID} - */ get audience() { - return this.body.audience + return this.model.audience } /** * @returns {C[]} */ get capabilities() { - return this.body.capabilities + return this.model.capabilities } /** * @returns {number} */ get expiration() { - return this.body.expiration + return this.model.expiration } /** * @returns {undefined|number} */ get notBefore() { - return this.body.notBefore + return this.model.notBefore } /** @@ -66,14 +53,14 @@ class View { */ get nonce() { - return this.body.nonce + return this.model.nonce } /** * @returns {UCAN.Fact[]} */ get facts() { - return this.body.facts + return this.model.facts } /** @@ -81,37 +68,106 @@ class View { */ get proofs() { - return this.body.proofs + return this.model.proofs + } + + get signature() { + return this.model.signature } } /** * @template {UCAN.Capability} C - * @extends {View} + * @implements {UCAN.View} */ -class RAWView extends View { +class JWTView extends Uint8Array { /** - * - * @param {UCAN.Data} data - * @param {UCAN.JWT>} jwt + * @param {UCAN.Model} model + * @param {object} bytes + * @param {ArrayBuffer} bytes.buffer + * @param {number} [bytes.byteOffset] + * @param {number} [bytes.byteLength] */ - constructor(data, jwt) { - super(RAW.code, data) - this.jwt = jwt + constructor( + model, + { buffer, byteOffset = 0, byteLength = buffer.byteLength } + ) { + super(buffer, byteOffset, byteLength) + this.model = model + } + + get version() { + return this.model.version + } + + get issuer() { + return this.model.issuer + } + + get audience() { + return this.model.audience + } + + /** + * @returns {C[]} + */ + get capabilities() { + return this.model.capabilities + } + + /** + * @returns {number} + */ + get expiration() { + return this.model.expiration + } + + /** + * @returns {undefined|number} + */ + get notBefore() { + return this.model.notBefore + } + + /** + * @returns {undefined|string} + */ + + get nonce() { + return this.model.nonce + } + + /** + * @returns {UCAN.Fact[]} + */ + get facts() { + return this.model.facts + } + + /** + * @returns {UCAN.Proof[]} + */ + + get proofs() { + return this.model.proofs + } + + get signature() { + return this.model.signature } } /** * @template {UCAN.Capability} C - * @param {UCAN.Data} data + * @param {UCAN.Model} data * @returns {UCAN.View} */ -export const cbor = data => new View(UCAN.code, data) +export const cbor = data => new View(data) /** * @template {UCAN.Capability} C - * @param {UCAN.Data} data - * @param {UCAN.JWT>} jwt + * @param {UCAN.Model} model + * @param {UCAN.ByteView>} bytes * @returns {UCAN.View} */ -export const jwt = (data, jwt) => new RAWView(data, jwt) +export const jwt = (model, bytes) => new JWTView(model, bytes) diff --git a/test/fixtures.js b/test/fixtures.js index cd54ff5..6237078 100644 --- a/test/fixtures.js +++ b/test/fixtures.js @@ -1,4 +1,5 @@ import { createEdIssuer, createRSAIssuer } from "./util.js" +import { base64urlpad } from "multiformats/bases/base64" /** did:key:z6Mkk89bC3JrVqKie71YEcc5M1SMVxuCgNx6zLZ8SYJsxALi */ export const alice = createEdIssuer( @@ -17,12 +18,17 @@ export const mallory = createEdIssuer( export const service = createRSAIssuer() -/** - * @param {string} did - */ -export function didToName(did) { - if (did === alice.did()) return "alice" - if (did === bob.did()) return "bob" - if (did === mallory.did()) return "mallory" - return did +export const token = { + jwt: "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsInVjdiI6IjAuOC4xIn0.eyJhdWQiOiJkaWQ6a2V5Ono2TWtmZkRaQ2tDVFdyZWc4ODY4ZkcxRkdGb2djSmo1WDZQWTkzcFBjV0RuOWJvYiIsImF0dCI6W3sid2l0aCI6InduZnM6Ly9ib3Jpcy5maXNzaW9uLm5hbWUvcHVibGljL3Bob3Rvcy8iLCJjYW4iOiJjcnVkL0RFTEVURSJ9LHsid2l0aCI6InduZnM6Ly9ib3Jpcy5maXNzaW9uLm5hbWUvcHJpdmF0ZS84NE1aN2Fxd0tuN3NOaU1Hc1NiYXhzRWE2RVBuUUxvS1liWEJ5eE5CckNFciIsImNhbiI6InduZnMvQVBQRU5EIn0seyJ3aXRoIjoibWFpbHRvOmJvcmlzQGZpc3Npb24uY29kZXMiLCJjYW4iOiJtc2cvU0VORCJ9XSwiZXhwIjoxNjUwNTAwODQ5LCJpc3MiOiJkaWQ6a2V5Ono2TWtrODliQzNKclZxS2llNzFZRWNjNU0xU01WeHVDZ054NnpMWjhTWUpzeEFMaSIsInByZiI6W119.OqM4_glZJq8GRvg7k8U3OJNgjJ_N8ORM5cOKA0O84lE9Ttzy9YJQe6e4QkOhS0uIkzIvxCdWB0DWsFhTc1rtBA", + expires: 1650500849, + issuer: "did:key:z6Mkk89bC3JrVqKie71YEcc5M1SMVxuCgNx6zLZ8SYJsxALi", + audience: "did:key:z6MkffDZCkCTWreg8868fG1FGFogcJj5X6PY93pPcWDn9bob", + signature: + "OqM4_glZJq8GRvg7k8U3OJNgjJ_N8ORM5cOKA0O84lE9Ttzy9YJQe6e4QkOhS0uIkzIvxCdWB0DWsFhTc1rtBA", } +export const JWT_UCAN = + "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsInVjdiI6IjAuOC4xIn0.eyJhdWQiOiJkaWQ6a2V5Ono2TWtmZkRaQ2tDVFdyZWc4ODY4ZkcxRkdGb2djSmo1WDZQWTkzcFBjV0RuOWJvYiIsImF0dCI6W3sid2l0aCI6InduZnM6Ly9ib3Jpcy5maXNzaW9uLm5hbWUvcHVibGljL3Bob3Rvcy8iLCJjYW4iOiJjcnVkL0RFTEVURSJ9LHsid2l0aCI6InduZnM6Ly9ib3Jpcy5maXNzaW9uLm5hbWUvcHJpdmF0ZS84NE1aN2Fxd0tuN3NOaU1Hc1NiYXhzRWE2RVBuUUxvS1liWEJ5eE5CckNFciIsImNhbiI6InduZnMvQVBQRU5EIn0seyJ3aXRoIjoibWFpbHRvOmJvcmlzQGZpc3Npb24uY29kZXMiLCJjYW4iOiJtc2cvU0VORCJ9XSwiZXhwIjoxNjUwNTAwODQ5LCJpc3MiOiJkaWQ6a2V5Ono2TWtrODliQzNKclZxS2llNzFZRWNjNU0xU01WeHVDZ054NnpMWjhTWUpzeEFMaSIsInByZiI6W119.OqM4_glZJq8GRvg7k8U3OJNgjJ_N8ORM5cOKA0O84lE9Ttzy9YJQe6e4QkOhS0uIkzIvxCdWB0DWsFhTc1rtBA" + +export const JWT_UCAN_SIG = base64urlpad.baseDecode( + "OqM4_glZJq8GRvg7k8U3OJNgjJ_N8ORM5cOKA0O84lE9Ttzy9YJQe6e4QkOhS0uIkzIvxCdWB0DWsFhTc1rtBA" +) diff --git a/test/lib.spec.js b/test/lib.spec.js index fba717f..ccb0d0d 100644 --- a/test/lib.spec.js +++ b/test/lib.spec.js @@ -1,10 +1,12 @@ /* eslint-env mocha */ import * as UCAN from "../src/lib.js" import { assert } from "chai" -import { alice, bob, mallory } from "./fixtures.js" +import { alice, bob, mallory, JWT_UCAN, JWT_UCAN_SIG } from "./fixtures.js" import * as TSUCAN from "./ts-ucan.cjs" -import * as RAW from "multiformats/codecs/raw" +import * as RAW from "../src/codec/raw.js" +import * as CBOR from "../src/codec/cbor.js" import * as UTF8 from "../src/utf8.js" +import * as DID from "../src/did.js" import { identity } from "multiformats/hashes/identity" import { createRSAIssuer, @@ -19,7 +21,7 @@ describe("dag-ucan", () => { it("self-issued token", async () => { const ucan = await UCAN.issue({ issuer: alice, - audience: alice.did(), + audience: alice, capabilities: [ { with: alice.did(), @@ -29,11 +31,9 @@ describe("dag-ucan", () => { }) assertUCAN(ucan, { - code: UCAN.code, version: UCAN.VERSION, - algorithm: alice.algorithm, - issuer: alice.did(), - audience: alice.did(), + issuer: DID.parse(alice.did()), + audience: DID.parse(alice.did()), capabilities: [ { with: alice.did(), @@ -52,7 +52,7 @@ describe("dag-ucan", () => { it("dervie token", async () => { const root = await UCAN.issue({ issuer: alice, - audience: bob.did(), + audience: bob, capabilities: [ { with: alice.did(), @@ -61,22 +61,20 @@ describe("dag-ucan", () => { ], }) const proof = await UCAN.link(root) - assert.equal(proof.code, UCAN.code) + assert.equal(proof.code, CBOR.code) const leaf = await UCAN.issue({ issuer: bob, - audience: mallory.did(), + audience: mallory, capabilities: root.capabilities, expiration: root.expiration, proofs: [proof], }) assertUCAN(leaf, { - code: UCAN.code, version: UCAN.VERSION, - algorithm: alice.algorithm, - issuer: bob.did(), - audience: mallory.did(), + issuer: DID.parse(bob.did()), + audience: DID.parse(mallory.did()), capabilities: [ { with: alice.did(), @@ -95,7 +93,7 @@ describe("dag-ucan", () => { const root = await UCAN.issue({ issuer: alice, - audience: bot.did(), + audience: bot, capabilities: [ { with: alice.did(), @@ -107,7 +105,7 @@ describe("dag-ucan", () => { const leaf = await UCAN.issue({ issuer: bot, - audience: bob.did(), + audience: bob, capabilities: [ { with: alice.did(), @@ -118,11 +116,9 @@ describe("dag-ucan", () => { }) assertUCAN(leaf, { - code: UCAN.code, version: UCAN.VERSION, - algorithm: bot.algorithm, - issuer: bot.did(), - audience: bob.did(), + issuer: DID.parse(bot.did()), + audience: DID.parse(bob.did()), capabilities: [ { with: alice.did(), @@ -141,7 +137,7 @@ describe("dag-ucan", () => { it("with nonce", async () => { const root = await UCAN.issue({ issuer: alice, - audience: bob.did(), + audience: bob, nonce: "hello", capabilities: [ { @@ -153,11 +149,9 @@ describe("dag-ucan", () => { await assertCompatible(root) assertUCAN(root, { - code: UCAN.code, version: UCAN.VERSION, - algorithm: alice.algorithm, - issuer: alice.did(), - audience: bob.did(), + issuer: DID.parse(alice.did()), + audience: DID.parse(bob.did()), capabilities: [ { with: alice.did(), @@ -174,7 +168,7 @@ describe("dag-ucan", () => { it("with facts", async () => { const root = await UCAN.issue({ issuer: alice, - audience: bob.did(), + audience: bob, facts: [ { hello: "world", @@ -190,11 +184,9 @@ describe("dag-ucan", () => { await assertCompatible(root) assertUCAN(root, { - code: UCAN.code, version: UCAN.VERSION, - algorithm: alice.algorithm, - issuer: alice.did(), - audience: bob.did(), + issuer: DID.parse(alice.did()), + audience: DID.parse(bob.did()), capabilities: [ { with: alice.did(), @@ -216,7 +208,7 @@ describe("dag-ucan", () => { const now = Math.floor(Date.now() / 1000) const root = await UCAN.issue({ issuer: alice, - audience: bob.did(), + audience: bob, facts: [], capabilities: [ { @@ -229,11 +221,9 @@ describe("dag-ucan", () => { }) assertUCAN(root, { - code: UCAN.code, version: UCAN.VERSION, - algorithm: alice.algorithm, - issuer: alice.did(), - audience: bob.did(), + issuer: DID.parse(alice.did()), + audience: DID.parse(bob.did()), capabilities: [ { with: alice.did(), @@ -251,7 +241,7 @@ describe("dag-ucan", () => { it("ts-ucan compat", async () => { const ucan = await UCAN.issue({ issuer: alice, - audience: alice.did(), + audience: alice, capabilities: [ { with: alice.did(), @@ -287,7 +277,57 @@ describe("errors", () => { }) assert.fail("Should have thrown on bad did") } catch (error) { - assert.match(String(error), /The audience must be a DID/) + assert.match( + String(error), + /The audience.did\(\) must be a function that returns DID/ + ) + } + }) + + it("throws on bad did", async () => { + try { + await UCAN.issue({ + issuer: alice, + audience: { did: () => "did:dns:ucan.storage" }, + nonce: "hello", + capabilities: [ + { + with: alice.did(), + can: "store/put", + }, + ], + }) + assert.fail("Should have thrown on bad did") + } catch (error) { + assert.match( + String(error), + /Invalid DID "did:dns:ucan\.storage", must start with 'did:key:'/ + ) + } + }) + + it("throws on unsupported algorithms", async () => { + try { + await UCAN.issue({ + issuer: alice, + audience: { + did: () => + "did:key:zDnaerDaTF5BXEavCrfRZEk316dpbLsfPDZ3WJ5hRTPFU2169", + }, + nonce: "hello", + capabilities: [ + { + with: alice.did(), + can: "store/put", + }, + ], + }) + assert.fail("Should have thrown on bad did") + } catch (error) { + assert.match( + String(error), + /Unsupported key algorithm with multicode 0x1200/ + ) } }) @@ -343,6 +383,11 @@ describe("errors", () => { /Capability has invalid 'with: ":hello"', value must be a valid URI string/, ], + "with can't be did": [ + // @ts-expect-error + { with: alice, can: "send/message" }, + /Capability has invalid 'with: {.*}', value must be a string/, + ], "with as::* may have can: *": [ { // @ts-ignore @@ -368,7 +413,7 @@ describe("errors", () => { try { await UCAN.issue({ issuer: alice, - audience: bob.did(), + audience: bob, capabilities: [capability], }) @@ -385,50 +430,89 @@ describe("errors", () => { }) } - it("decode throws on invalid code", async () => { - const ucan = await UCAN.issue({ - issuer: alice, - audience: bob.did(), - capabilities: [ - { - with: alice.did(), - can: "store/put", - }, - ], - }) - - assert.throws( - () => - UCAN.encode({ - ...ucan, + it("proofs must be CIDs", () => { + assert.throws(() => { + UCAN.encode({ + version: "0.8.1", + issuer: DID.parse(alice.did()), + audience: DID.parse(bob.did()), + expiration: Date.now(), + capabilities: [ + { + with: "my:*", + can: "*", + }, + ], + signature: new Uint8Array(), + proofs: [ // @ts-expect-error - code: 0x0129, - }), - /Provided UCAN has unsupported code/ - ) + "bafkreihgufl2d3wwp4kjo75na265sywwi3yqcx2xpk3rif4tlo62nscg4m", + ], + facts: [], + }) + }, /Expected proofs\[0\] to be CID, instead got "bafkr/) }) - it("format throws on invalid code", async () => { - const ucan = await UCAN.issue({ - issuer: alice, - audience: bob.did(), - capabilities: [ - { - with: alice.did(), - can: "store/put", - }, - ], - }) + it("proofs must be CIDs", () => { + assert.throws(() => { + UCAN.encode({ + version: "0.8.1", + issuer: DID.parse(alice.did()), + // @ts-expect-error + audience: bob.did(), + expiration: Date.now(), + capabilities: [ + { + with: "my:*", + can: "*", + }, + ], + signature: new Uint8Array(), + facts: [], + }) + }, /Expected audience to be Uint8Array, instead got "did:key/) + }) - assert.throws( - () => - UCAN.format({ - ...ucan, - // @ts-expect-error - code: 0x0129, - }), - /Provided UCAN has unsupported code/ - ) + it("expiration must be int", async () => { + try { + await UCAN.issue({ + expiration: 8.7, + issuer: alice, + audience: bob, + capabilities: [ + { + with: alice.did(), + can: "store/add", + }, + ], + }) + } catch (error) { + assert.match( + String(error), + /Expected integer but instead got 'expiration: 8.7'/ + ) + } + }) + + it("signature must be Uint8Array", () => { + assert.throws(() => { + UCAN.encode({ + version: "0.8.1", + issuer: DID.parse(alice.did()), + audience: DID.parse(bob.did()), + expiration: Date.now(), + capabilities: [ + { + with: "my:*", + can: "*", + }, + ], + // @ts-expect-error + signature: "hello world", + facts: [], + proofs: [], + }) + }, /signature must be Uint8Array, instead got "hello world"/) }) }) @@ -453,12 +537,19 @@ describe("parse", () => { }, }) const ucan = UCAN.parse(jwt) + + const v2 = await UCAN.issue({ + issuer: alice, + audience: alice, + expiration: ucan.expiration, + capabilities: [...ucan.capabilities], + }) + + assert.equal(ucan instanceof Uint8Array, false) assertUCAN(ucan, { - code: UCAN.code, version: UCAN.VERSION, - algorithm: alice.algorithm, - issuer: alice.did(), - audience: alice.did(), + issuer: DID.parse(alice.did()), + audience: DID.parse(alice.did()), capabilities: [ { can: "send/message", @@ -469,6 +560,7 @@ describe("parse", () => { notBefore: undefined, nonce: undefined, proofs: [], + signature: v2.signature, }) }) @@ -561,10 +653,7 @@ describe("parse", () => { }, }) - assert.throws( - () => UCAN.parse(jwt), - /Header has invalid version 'ucv: "9.0"'/ - ) + assert.throws(() => UCAN.parse(jwt), /Invalid version 'ucv: "9.0"'/) }) it("errors on invalid att", async () => { @@ -593,7 +682,7 @@ describe("parse", () => { }, }) - assert.throws(() => UCAN.parse(jwt), /fct elements must be of type object/) + assert.throws(() => UCAN.parse(jwt), /fct\[0\] must be of type object/) }) it("errors on invalid aud", async () => { @@ -610,7 +699,10 @@ describe("parse", () => { }, }) - assert.throws(() => UCAN.parse(jwt), /DID has invalid representation 'bob'/) + assert.throws( + () => UCAN.parse(jwt), + /DID has invalid representation 'aud: "bob"'/ + ) }) it("errors on invalid prf (must be array of string)", async () => { @@ -628,7 +720,7 @@ describe("parse", () => { assert.throws( () => UCAN.parse(jwt), - /prf has invalid value 1, must be a string/ + /prf\[0\] has invalid value 1, must be a string/ ) }) @@ -656,7 +748,7 @@ describe("encode <-> decode", () => { it("issued ucan is equal to decoded ucan", async () => { const expected = await UCAN.issue({ issuer: alice, - audience: bob.did(), + audience: bob, capabilities: [ { with: alice.did(), @@ -668,17 +760,42 @@ describe("encode <-> decode", () => { const actual = UCAN.decode(UCAN.encode(expected)) assert.deepEqual(expected, actual) }) + + it("can leave out optionals", async () => { + const v1 = await UCAN.issue({ + issuer: alice, + audience: bob, + capabilities: [ + { + with: "my:*", + can: "*", + }, + ], + }) + + // @ts-expect-error - leaving out proofs and facts + const v2 = UCAN.encode({ + version: v1.version, + issuer: v1.issuer, + audience: v1.audience, + expiration: v1.expiration, + capabilities: [...v1.capabilities], + signature: v1.signature, + }) + + assert.deepEqual(v2, UCAN.encode(v1)) + }) }) describe("ts-ucan compat", () => { it("round-trips with token.build", async () => { const jwt = await buildJWT({ issuer: alice, audience: bob }) const ucan = UCAN.parse(jwt) + assertUCAN(ucan, { - code: UCAN.raw, version: UCAN.VERSION, - issuer: alice.did(), - audience: bob.did(), + issuer: DID.parse(alice.did()), + audience: DID.parse(bob.did()), facts: [], proofs: [], notBefore: undefined, @@ -716,10 +833,9 @@ describe("ts-ucan compat", () => { const ucan = UCAN.parse(leaf) assertUCAN(ucan, { - code: UCAN.raw, version: UCAN.VERSION, - issuer: bob.did(), - audience: mallory.did(), + issuer: DID.parse(bob.did()), + audience: DID.parse(mallory.did()), facts: [], notBefore: undefined, nonce: undefined, @@ -750,7 +866,7 @@ describe("api compatibility", () => { const Block = await import("multiformats/block") const ucan = await UCAN.issue({ issuer: alice, - audience: bob.did(), + audience: DID.parse(bob.did()), capabilities: [ { with: alice.did(), @@ -760,7 +876,7 @@ describe("api compatibility", () => { }) const block = await Block.encode({ - value: { ...ucan }, + value: ucan, codec: UCAN, hasher: sha256, }) @@ -771,3 +887,88 @@ describe("api compatibility", () => { assert.deepEqual(block.value, ucan) }) }) + +describe("jwt representation", () => { + it("can parse non cbor UCANs", async () => { + const jwt = UCAN.parse(JWT_UCAN) + assert.ok(jwt instanceof Uint8Array) + + assertUCAN(jwt, { + issuer: DID.parse(alice.did()), + audience: DID.parse(bob.did()), + expiration: 1650500849, + nonce: undefined, + notBefore: undefined, + facts: [], + proofs: [], + capabilities: [ + { + with: "wnfs://boris.fission.name/public/photos/", + can: "crud/delete", + }, + { + with: "wnfs://boris.fission.name/private/84MZ7aqwKn7sNiMGsSbaxsEa6EPnQLoKYbXByxNBrCEr", + can: "wnfs/append", + }, + { with: "mailto:boris@fission.codes", can: "msg/send" }, + ], + signature: JWT_UCAN_SIG, + }) + }) + + it("can encode non cbor UCANs", () => { + const jwt = UCAN.parse(JWT_UCAN) + assert.ok(jwt instanceof Uint8Array) + + const bytes = UCAN.encode(jwt) + const jwt2 = assert.equal(JWT_UCAN, UCAN.format(UCAN.decode(bytes))) + }) + + it("can still decode into jwt representation", async () => { + const ucan = await UCAN.issue({ + issuer: alice, + audience: bob, + capabilities: [ + { + can: "access/identify", + with: "did:key:*", + as: "mailto:*", + }, + ], + }) + + const token = UCAN.format(ucan) + const cbor = UCAN.parse(token) + const jwt = RAW.decode(UTF8.encode(token)) + + assert.equal(cbor instanceof Uint8Array, false) + assertUCAN(cbor, { + issuer: DID.parse(alice.did()), + audience: DID.parse(bob.did()), + capabilities: [ + { + can: "access/identify", + with: "did:key:*", + as: "mailto:*", + }, + ], + expiration: ucan.expiration, + signature: ucan.signature, + }) + + assert.equal(jwt instanceof Uint8Array, true) + assertUCAN(jwt, { + issuer: DID.parse(alice.did()), + audience: DID.parse(bob.did()), + capabilities: [ + { + can: "access/identify", + with: "did:key:*", + as: "mailto:*", + }, + ], + expiration: ucan.expiration, + signature: ucan.signature, + }) + }) +}) diff --git a/test/util.js b/test/util.js index 499e2e4..6b62879 100644 --- a/test/util.js +++ b/test/util.js @@ -24,12 +24,14 @@ export const assertCompatible = ucan => }) /** - * @param {UCAN.View} actual - * @param {Partial} expect + * @template {UCAN.Capability} T + * @param {UCAN.View} actual + * @param {Partial>} expect */ export const assertUCAN = (actual, expect) => { assertUCANIncludes(actual, expect) assertUCANIncludes(UCAN.parse(UCAN.format(actual)), expect) + assertUCANIncludes(UCAN.decode(UCAN.encode(actual)), expect) } /** @@ -54,7 +56,8 @@ export const assertFormatLoop = actual => { * @param {UCAN.View} actual */ export const assertCodecLoop = actual => { - assert.deepEqual(UCAN.decode(UCAN.encode(actual)), actual) + const t = UCAN.encode(actual) + assert.deepEqual(UCAN.decode(t), actual) } /** @@ -62,19 +65,12 @@ export const assertCodecLoop = actual => { */ export const createEdIssuer = secret => /** @type {UCAN.Issuer & TSUCAN.EdKeypair} */ - ( - Object.assign(TSUCAN.EdKeypair.fromSecretKey(secret), { - algorithm: 0xed, - }) - ) -export const createRSAIssuer = async () => - /** @type {UCAN.Issuer & TSUCAN.RsaKeypair} */ - ( - Object.assign(await TSUCAN.RsaKeypair.create(), { - algorithm: 0x1205, - }) - ) + (TSUCAN.EdKeypair.fromSecretKey(secret)) + +export const createRSAIssuer = () => + /** @type {Promise} */ + (TSUCAN.RsaKeypair.create()) /** *