Are you working with or considering working with UCANs? This post introduces UCAN verification with concrete examples that will show you how UCANs are validated, how UCAN delegation works and what capability delegation semantics are.

UCANs are technically JWTs. But make no mistake, checking a UCAN for validity is not the same procedure as checking JWT validity.

Most JWT are valid once you've checked their time bounds and signature. Afterwards you look at the payload to get some context about the requester's identity.

For UCANs, checking signature validity isn't enough, because they're self-signed.
As a service, for every action that a user is trying to invoke you need to check that

  1. the UCAN is valid in terms of integrity as described in the spec (valid signatures, issuers matching proof's audiences, time bounds not exceeding proof's bounds, etc.),
  2. the UCAN's time bounds are respected,
  3. the UCAN grants the capability required for given action,
  4. the capability was delegated in a chain that started in a UCAN with a known root issuer,
  5. the audience of the outermost UCAN is your service's DID,
  6. none of the UCANs or DIDs in the capability delegation chain were revoked.

This might all sound very abstract to you, so let's dive into it concretely.

Just one more thing before we do so: This article assumes

  • you have seen/skimmed/partly read the UCAN spec and/or have looked at a decoded UCAN before;
  • you know that DIDs are somewhat like public keys and have an associated private key and can be used to sign & verify signatures, and you know what all of that means.

A Concrete Example

Let's introduce some dramatis personae for our example:

  • A data storage/backup service called "storagely". We abbreviate its DID as did:key:zstoragely, and this DID is also the DNS TXT record at _did.storagely.io, because that's neat and easy to look up.
  • A user "Alice". We abbreviate her DID as did:key:zAlice.
  • A user "Bob". We abbreviate his DID as did:key:zBob.

Storagely has a REST route PUT https://storagely.io/files/<did>/<path> which uploads the request body as a file on the given path.

Alice wants to upload her DnD character, so she runs PUT https://storagely.io/files/did:key:zAlice/documents/dnd/miranda_lovelace.pdf with her character sheet attached to the request.

She uses an authorization header with a UCAN that contains the following correctly-signed payload:

{
  iss: "did:key:zAlice", // issuer
  aud: "did:key:zstoragely", // audience
  exp: <now + 30 seconds>, // expiration
  att: [ // attenuation
    { // a capability
      with: "storagely:/documents/dnd/miranda_lovelace.pdf", // resource
      can: "http/PUT" // ability
    }
  ],
  prf: [] // proofs
}

If we check back with the above mentioned requirements for valid UCANs, it checks out:

  1. We've assumed that this UCAN was correctly signed. All required fields are present and parse correctly in the payload, and we don't have any proofs to check in this simple UCAN.
  2. The UCAN did not yet expire and has no lower time bound (no nbf field).
  3. The attenuation contains a capability with exactly what's required for our example PUT request.
  4. The root issuer for that capability is did:key:zAlice, also matching the owner of the file system that we're about to update, so that matches as well.
  5. We're looking at the "outermost" UCAN in this case and the audience matches storagely's DID.
  6. We don't know of any revocations.

Great! The request looks good to storagely and goes through.

However, Alice isn't alone in her DnD-playing group. She helps her DM with collecting all character sheets by suggesting to use her storagely account.

The first person Alice wants to allow access is Bob. She wants to scope the access rights to 7 days, since that's when they meet to play and she wants Bob to only be able to access the documents/dnd/ folder.

Delegation

She can give Bob access to her storagely folder without having to consult storagely at all, by creating a validly signed UCAN that has a payload looking like this:

{
  iss: "did:key:zAlice",
  aud: "did:key:zBob", // Bob is the audience!
  exp: now + 7 days, // bob will have write access for 7 days.
  att: [
    {
      with: "storagely:/documents/dnd/",
      can: "http/PUT"
    }
  ],
  prf: []
}

Please take a moment to see how this UCAN on its own can not be used to authenticate the request that Bob is about to make: PUT https://storagely.io/files/did:key:zAlice/documents/dnd/jorum_riverscar.pdf

Most notably, requirement (5) is not met: The UCAN's audience is did:key:zBob, not did:key:zstoragely.

To make use of this UCAN, Bob first has to wrap it with another UCAN layer and refer to the UCAN delegated from Alice in the prf section:

{
  iss: "did:key:zBob",
  aud: "did:key:zstoragely",
  exp: now + 30 seconds, // short-lived!
  att: [
    {
      with: "storagely:/documents/dnd/jorum_riverscar.pdf",
      can: "http/PUT"
    }
  ],
  prf: [ "<JWT encoding of the UCAN delegated from Alice above>" ]
}

Now, Bob can use this UCAN to authenticate for storagely.

Let's go over why that is by walking through all the checks we've enumerated above:

  1. The UCAN is "integrally valid", because we assumed they're all signed correctly and have the correct format and the iss matches the aud of the proof (both are did:key:zBob). Also: The expiry of the outer ucan (now + 30 seconds) is a subset of the time bounds of its proof (now + 7 days).
  2. The outer time bound is met, because we assume the request is checked somewhere between now and now + 30 seconds.
  3. The UCAN grants the capability to PUT something at storagely:/documents/dnd/jorum_riverscar.pdf.
  4. The capability can be delegated from a proof's capability: storagely:/documents/dnd/jorum_riverscar.pdf is a sub-resource of storagely:/documents/dnd/ and the proof's issuer is did:key:zAlice, whom the service knows and expects, because that's the owner of the filesystem in the request URL.
  5. This time, the outer UCAN's audience does match the service, did:key:zstoragely.
  6. For the sake of this example we expect nothing to be revoked.

Great! Bob is able to verify having access to parts of Alice's storagely account.
Also notice that although the outer UCAN layer is very short-lived, but that doesn't restrict Bobs access to less than 7 days: Bob can just issue and sign more short-lived UCANs with different expirations, as long as the expiration is still within the 7-day window of the UCAN proof's time bounds.

Side Note: Stealing UCANs

An interesting property of UCANs is that most UCANs aren't super sensitive information, compared to regular JWTs.

Let's assume someone got ahold of the UCAN that Alice issued for Bob. Is that similarly dangerous as an attacker getting ahold of a regular JWT?

No, it's not. As mentioned previously, such a UCAN on its own would be rejected by the storagely service, because the storagely service requires UCANs to be addressed to the storagely DID. In order to use the stolen UCAN, you need to wrap it in yet another UCAN layer that is addressed to storagely - in other words, that has did:key:zstoragely set as audience. However, the UCANs in the prf field are only considered valid if their audience matches the outer issuer.

This in turn means: In order to be able to invoke the stolen UCAN, you'd need to have control over a private key associated with Bob's DID. This is what makes the delegated UCAN truly Bob's ownership.

So it's Bob's private key that needs to be kept safe. Luckily we've got the WebCrypto API in browsers, which makes it possible to create so-called "non-extractable" private keys. They're sandboxed in your browser: No extension or javascript website code can steal these keys. You can only generate signatures using these non-extractable keys.

What about stealing the UCAN addressed to did:key:zstoragely, though?
If someone gets ahold of that UCAN and they prevent storagely from receiving the original request, only then it's possible to impersonate Bob with a faulty request. However, that UCAN is as restricted as possible for the required action: Notice how the capability only allows writing the jorum_riverscar.pdf file and nothing else! Also, its use is restricted to 30 seconds into the future, so very short-lived, very much unlike the original delegation UCAN from Alice which allowed more access for longer (7 days).

/end sidenote

Capability Delegation

I want to draw attention to requirement number 4 in our requirements list:

the capability was delegated in a chain that started in a UCAN with a known root issuer

In the last section, we've argued that this requirement was met like this:

The capability can be delegated from a proof's capability: storagely:/documents/dnd/jorum_riverscar.pdf is a sub-resource of storagely:/documents/dnd/ and the proof's issuer is did:key:zAlice, whom the service knows and expects, because that's the owner of the filesystem in the request URL.

There is quite some complexity in this requirement that I want to unpack.

First of all, the UCAN specification doesn't specify what is a valid "sub-resource" for the "storagely" resource. The spec only specifies that capabilities should have a with field (resource) that's a URI and a can field that is a case-insensitive string with a scope (ability). It doesn't enumerate all possible capabilities.
It's left intentionally open to allow for services to define their own capabilities and semantics.
In our example storagely came up with their own. They specified what capability delegations are valid or not. According to their specs, this capability

{
  with: "storagely:/documents/dnd/",
  can: "http/PUT"
}

can delegate that capability:

{
  with: "storagely:/documents/dnd/jorum_riverscar.pdf",
  can: "http/PUT"
}

Because they chose to consider sub-path URIs to be delegatable.

Other services that want to interoperate with storagely can then decide to have their endpoints also "understand" these capabilities and their semantics (meaning "what delegations are valid"). This is what we call CapabilitySemantics in ts-ucan or DelegationSemantics in hs-ucan, and this is why they're provided by the library user! They need to be programmable.

But UCAN payloads can contain multiple capabilities in the attenuations array! What if our service doesn't know the semantics of one of them? Well, they need to be ignored. If our service doesn't know these capabilities, then they won't be necessary to invoke their endpoints, right?

Cutting down a UCAN and only including capabilities that make sense to the service you're making a request is impossible, since you can't modify UCANs after the fact. You'd need to re-sign it. The user might have gotten a bunch of capabilities delegated "in the same package" (in the same UCAN) from someone else and may not be able to ask them to re-issue them another one.

Delegating "Everything, Everywhere All At Once"

Let's expand on our example. We introduce another DID, did:key:zAlicePhone. The way Alice manages her identity, her laptop which she used to create her account originally with (with DID did:key:zAlice) doesn't use the same DID as her phone which she now wants to use for uploading some images to her storagely.

In order for her phone to get access, she could delegate a capability like this:

{
  with: "storagely:/",
  can: "*"
}

Where "*" indicates the "super user" ability, which subsumes all other abilities, and the storagely:/ URI allows root access to her storage.

However, this capability might not quite cover everything Alice can do at storagely: This capability only grants her phone access to the storagely:/ resource!
What if at some point storagely implements a new feature that allows, let's say, managing a friends list. The friends list access is authorized through a resource friends:/ (sorry, we're coming up with weird protocols today!).

Her phone won't have access to that resource. The storagely:/ resource just can't be used to delegate a friends:/ resource. So she'll need to re-authenticate her phone with her laptop (if she has it around!).

What you can do instead in these cases (device linking), is use the as: and my: capabilities. They look like this:

{
  with: "my:*",
  can: "*"
}

They're specified in UCAN v0.8. The above capability is a "super capability" in a sense, because it allows delegating any capability. It effectively makes the issuer and audience in the UCAN equally powerful.

So ideally that's the capability that Alice's laptop uses to delegate access to her account on her laptop to her phone.

The as: capability is used for any further delegations of the my: resource, and in comparison to my: it actually disambiguates which DID we're talking about. So if, let's say, Alice wants to delegate her storagely access to a computer in an internet cafe she can issue a UCAN from her phone to the internet cafe's computed with this capability:

{
  with: "as:did:key:zAlicePhone:*",
  can: "*"
}

As you can see, the as: resource is pretty much the same as the my: resource, except it specifies a DID. my: resources can not be delegated from other my: resources, only as: resources can be delegated from my: resources. So these chains end up always starting with my -> as -> as, and never as -> my or my -> my.

And because the as and my capabilities by design subsume all capabilities that can exist, you end up delegating "everything, everywhere, all at once" with them :stuck_out_tongue: or more exactly, you delegate all capabilities that have existed and will exist in the future.

Conclusion

I hope this was a useful deep dive into one way of how you can use UCANs as a service & it's now clearer to you what's required in validating a UCAN.
There's many things very much specific to this example. UCANs can authorize access between peers, they're useful for more than only client-server architectures.
If all of this was confusing, you don't need to worry much about this: Libraries like ts-ucan will eventually abstract UCAN verification away for you.

There's lots of topics left undiscussed in this post:

  • Revocation
  • Ambiguity in the delegation chain
  • Rights amplification
  • Caching strategies and preventing UCANs from blowing up in size

We'll hopefully cover more of these topics soon.