This repo is part of the Local-First Data Management project for the Software Technology Group at TU Darmstadt.
We considered the local-first approach and decided to implement UCAN as our local-first solution with a sample notes application in Scala.
We selected UCAN v0.10.0 for the following reasons:
- The specification is more complete compared to newer versions.
- Existing libraries in other programming languages are also aligned with this version, ensuring compatibility.
- It is simpler to implement at this stage.
- Yashik Garg ([email protected])
- Erdem Yaman ([email protected])
- Julian Bayer ([email protected])
- Julian Haas ([email protected])
Project-Website: Local-First Data Management
Important
The following commands were tested in the Windows Command Prompt. If you're using a different operating system, you may need to adjust them accordingly.
-
HTTPS:
git clone https://github.com/stg-tud/daimpl-lofi-auth.git
-
SSH:
git clone [email protected]:stg-tud/daimpl-lofi-auth.git
- Navigate into the project folder:
cd daimpl-lofi-auth
- Run the Scala UCAN-Backend Server:
-
Once everything is set up, execute
sbt run
. -
When executing this command, you may see the following prompt:
Multiple main classes detected. Select one to run: [1] Main [2] ucan.UcanDemo Enter number:
-
Option [1] Main:
Starts the server for the sample applications. -
Option [2] ucan.UcanDemo:
Runs a separate demo showcasing UCAN itself.
If you want to run tests, you need to execute sbt test
to run all tests.
First, make sure the Scala UCAN-Backend Server is running on port 8080.
- Open a new command prompt window and navigate into the project folder of the Node.js server:
cd daimpl-lofi-auth\example-apps\
- Install Node.js dependencies:
npm install
- Navigate to
/UcanShowcase
Run the Node.js server:cd /UcanShowcase node server.js
- Navigate to
http://localhost:3000
in your web browser.
You can now play around with UCAN invocation/delegation/revocation. The visualization displays the UCANs in the current session and all relationships.
First, make sure the Scala UCAN-Backend Server is running on port 8080.
- Open a new command prompt window and navigate into the project folder of the Node.js server:
cd daimpl-lofi-auth\example-apps\
- Install Node.js dependencies:
npm install
- Navigate to
/NotesApp
Run the Node.js server:cd /NotesApp node server.js
- Navigate to
http://localhost:5000
in your web browser. - Login with the following credentials:
- Username:
admin
- Password:
admin
- Username:
You can now perform CRUD operations on notes. If you want to register a new user or change the capabilities of a user, you can open the /admin
path or use the header for navigation.
In the below notes app example, Alice grants Bob full permissions (read, create, update) for notes. Bob then delegates a subset of these permissions to Carol (read, create) and Dan (read, update). Carol further delegates her permissions (read, create) to Dan. Finally, Dan combines all received permissions and grants Erin again comprehensive access (read, create, update).
Please note that if any link in a chain becomes invalid, alternative paths may still provide access (e.g. if Carol → Dan was invalid, Dan would still retain read and update capabilities from Bob directly).
Root
┌────────────────┐
│ │
│ iss: Alice │
│ aud: Bob │
│ Resource: |
| notes |
| cap: [read, |
| create, |
| update] |
└───┬────────┬───┘
│ │
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ │ │ │
│ iss: Bob │ │ iss: Bob │
│ aud: Carol │ │ aud: Dan │
│ Resource: | | Resource: |
| notes | | notes |
│ cap: [read, │ │ cap: [read, │
│ create]│ │ update]│
└───────┬──────┘ └──┬───────────┘
│ │
│ │
▼ │
┌──────────────┐ │
│ │ │
│ iss: Carol │ │
│ aud: Dan │ │
│ Resource: | |
| notes │ │
│ cap: [read, │ │
│ create]│ │
└───────────┬──┘ │
│ │
│ │
▼ ▼
┌────────────────┐
│ │
│ iss: Dan │
│ aud: Erin │
│ Resource: |
| notes │
│ cap: [read, │
│ create, │
│ update] │
└────────────────┘
This UCAN library can model such structures like this:
// Create cryptographic key material for all participants
val alice = Ucan.createDefaultKeymaterial()
val bob = Ucan.createDefaultKeymaterial()
val carol = Ucan.createDefaultKeymaterial()
val dan = Ucan.createDefaultKeymaterial()
val erin = Ucan.createDefaultKeymaterial()
// Root UCAN: Alice grants Bob full permissions (read, create, update)
// Alice is the root authority in this system
val aliceBobPayload = Ucan
.builder()
.issuedBy(alice)
.forAudience(bob)
.withLifetime(3600) // Token valid for 1 hour
.claimingCapability("notes", "read")
.claimingCapability("notes", "create")
.claimingCapability("notes", "update")
.build()
val aliceBobUcan = Ucan.sign(aliceBobPayload, alice)
val aliceBobCid = Ucan.createCID(aliceBobUcan)
// created with a unique content identifier (CID)
// Bob delegates read and create permissions to Carol
// Note that Bob can only delegate permissions he has received from Alice
val bobCarolPayload = Ucan
.builder()
.issuedBy(bob)
.forAudience(carol)
.withLifetime(3000) // Token valid for 50 minutes
.claimingCapability("notes", "read")
.claimingCapability("notes", "create")
.witnessedBy(aliceBobCid) // Proof that Bob has these permissions
.build()
val bobCarolUcan = Ucan.sign(bobCarolPayload, bob)
val bobCarolCid = Ucan.createCID(bobCarolUcan)
// Bob delegates read and update permissions to Dan
// This creates a different permission set than what was given to Carol
val bobDanPayload = Ucan
.builder()
.issuedBy(bob)
.forAudience(dan)
.withLifetime(3000)
.claimingCapability("notes", "read")
.claimingCapability("notes", "update")
.witnessedBy(aliceBobCid)
.build()
val bobDanUcan = Ucan.sign(bobDanPayload, bob)
val bobDanCid = Ucan.createCID(bobDanUcan)
// Carol delegates her permissions to Dan
// This creates a second path through which Dan receives read and create permissions
val carolDanPayload = Ucan
.builder()
.issuedBy(carol)
.forAudience(dan)
.withLifetime(2400)
.claimingCapability("notes", "read")
.claimingCapability("notes", "create")
.witnessedBy(bobCarolCid)
.build()
val carolDanUcan = Ucan.sign(carolDanPayload, carol)
val carolDanCid = Ucan.createCID(carolDanUcan)
// Dan combines all permissions and delegates to Erin
// Dan now has read+create (from Carol) and read+update (from Bob)
// Together this gives Dan read+create+update, which he delegates to Erin
val danErinPayload = Ucan
.builder()
.issuedBy(dan)
.forAudience(erin)
.withLifetime(1800)
.claimingCapability("notes", "read")
.claimingCapability("notes", "create")
.claimingCapability("notes", "update")
.witnessedBy(carolDanCid)
.witnessedBy(bobDanCid)
.build()
val danErinUcan = Ucan.sign(danErinPayload, dan)
val danErinJwt = Ucan.encodeJwt(danErinUcan)
// Set up proof resolution system to validate the chain
// This maps CIDs to their corresponding JWT tokens for verification
val notesProofs = Map(
aliceBobCid.encode() -> Ucan.encodeJwt(aliceBobUcan),
bobCarolCid.encode() -> Ucan.encodeJwt(bobCarolUcan),
bobDanCid.encode() -> Ucan.encodeJwt(bobDanUcan),
carolDanCid.encode() -> Ucan.encodeJwt(carolDanUcan)
)
// Use In-Memory stores for resolving the CIDs.
val notesRevocationStore = new Ucan.InMemoryRevocationStore()
val notesResolver = new Ucan.InMemoryProofResolver(notesProofs, notesRevocationStore)
// Initial validation (all delegation paths intact)
Ucan.validateToken(danErinJwt, notesResolver) match {
case Success(capabilities) =>
// Will show that Erin has read, create, and update abilities
// Full notes access will be confirmed
case Failure(e) =>
// This branch won't be executed in the initial validation
}
// Revoking Carol -> Dan UCAN (removing one path for create capability)
// This simulates Carol revoking Dan's permissions
val carolDanRevocation = Ucan.createRevocation(carolDanCid, carol).get
notesRevocationStore.addRevocation(carolDanRevocation)
// Validation after revoking Carol -> Dan UCAN
// Dan still has read+update from Bob, but the create capability path is broken
Ucan.validateToken(danErinJwt, notesResolver) match {
case Success(capabilities) =>
// Will show that Erin now only has read and update abilities
// Create ability will be missing because the Carol->Dan path was revoked
case Failure(e) =>
// This branch won't be executed yet, as there's still a valid path
}
// Revoking Bob -> Dan UCAN (removing the remaining valid path)
// This completely cuts off Dan's authority
val bobDanRevocation = Ucan.createRevocation(bobDanCid, bob).get
notesRevocationStore.addRevocation(bobDanRevocation)
// Validation after revoking both delegation paths to Dan
// At this point, there is no valid path from the root to Erin
Ucan.validateToken(danErinJwt, notesResolver) match {
case Success(capabilities) =>
// This branch won't be executed as validation will fail
case Failure(e) =>
// Will report that validation failed because all paths are invalid
}
This showcases a possible application for using UCANs. To see more of the functionality this UCAN library offers, please have a look at Demo.scala
.
- The application uses a simple in-memory database to store UCANs.
- For an ability, no caveats are applied by default, meaning that the ability is always allowed.
- Key generation is implemented using only the Ed25519 algorithm as a Public-Key System.
- Aside from these points, the implementation aligns with the UCAN specification [2].
- The implementation does not support complex token resolution methods such as RESTful services.
- Currently, only Ed25519 is used for key generation.
- Backwards compatibility with older UCAN versions is not guaranteed, as comprehensive testing across versions was not feasible.
- Some details like Section 4 ("Reserved Resources") of the specification are not implemented yet due to not being essential for basic UCAN usage. One may want to expand the library to implement this.
- Furthermore, the use of Session Content Identifiers (CIDs) for message authentication is not implemented (each invocation requires the full UCAN).
- Finally, certain limitations are inherent to UCAN itself, such as the lack of protection against man-in-the-middle (MITM) attacks and potential issues with outdated validations due to non-synchronized UCAN stores. These are further described in the UCAN Spec [2].
- One option is to use the current code as a base for implementing the latest version of UCAN, ensuring a straightforward upgrade path.
- Another option is to adapt it into a library for local-first projects, enabling a modular and flexible approach to new functionalities.
- The limitations mentioned above can also be addressed in future work.
Note
The following section about Local-First Data Management is based on the Ink & Switch article [1]. It contains the relevant background information for this project; for detailed information, please refer to the original article. Our goal is to provide a focused summary so that readers do not need to go through the entire specification, but can quickly understand the essential concepts relevant to this implementation.
- No latency; There is never a need for the user to wait for a request to a server to complete
- Local-first apps keep their data in local storage on every device
- The user can read and write to local-first applications anytime, even while offline
- Support for real-time collaboration that is on par with the best cloud apps today
- Local-first software lets you keep using your data and apps forever, even if the company shuts down or stops supporting them.
- Local-first apps are more private and secure because your data stays on your own devices and is not stored in a central database that hackers or companies can access
- Greater responsibility. The user must employ preventive measures to avoid data loss or randomware infections
- Peer-to-peer systems are never fully “online” or “offline” and it can be hard to reason about how data moves in them
1. Fast | 2. Multi-device | 3. Offline | 4. Collaboration | 5. Longevity | 6. Privacy | 7. User control | |
---|---|---|---|---|---|---|---|
Files + email attachments | ✓ | — | ✓ | ✗ | ✓ | — | ✓ |
Google Docs | — | ✓ | — | ✓ | — | ✗ | — |
Trello | — | ✓ | — | ✓ | — | ✗ | ✗ |
✗ | ✓ | ✗ | ✓ | ✗ | ✗ | ✗ | |
Dropbox | ✓ | — | — | ✗ | ✓ | — | ✓ |
Git+GitHub | ✓ | — | ✓ | — | ✓ | — | ✓ |
Web apps | ✗ | ✓ | ✗ | ✓ | ✗ | ✗ | ✗ |
Thick client | ✓ | — | ✓ | ✗ | — | ✗ | ✗ |
Firebase, CloudKit, Realm | — | ✓ | ✓ | — | ✗ | ✗ | ✗ |
CouchDB | — | — | ✓ | ✗ | — | — | — |
True local-first approach | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Git+GitHub is the closest thing to a true local-first application in this table, though it has limitations for real-time collaboration and non-textual file formats and relies on repository hosting services like GitHub for collaboration and backup.
Conflict-free replicated data types (CRDTs) are an alternative to operational transformation (OT) for collaborative editing. The main difference between the two approaches is that OT focuses on transforming index positions to make sure all clients will have on the same content, while CRDTs use mathematical models (e.g. linked lists) that do not require index transformations.
As of right now, OT is the standard for shared text editing. The issue is that OT-based systems that support shared editing without a central server can become impractical because of the complex bookkeeping they require. CRDTs are a better approach for distributed systems, providing stronger guarantees for document synchronization across clients without the need for a central authority.
The Trellis project is a Kanban board modeled after Trello, using CRDTs to build a fast, multi-device, offline, collaborative, long-living, privacy-first, user-controlled application.
Here, they state: "Access permissions for documents beyond secret URLs remain an open research question". This is something UCAN may solve.
Note
The following section about UCAN is based on the UCAN v0.10.0 Specification [2]. It contains the relevant background information for this project; for detailed information, please refer to the original article. Our goal is to provide a focused summary so that readers do not need to go through the entire specification, but can quickly understand the essential concepts relevant to this implementation.
User Controlled Authorization Network (UCAN) is a trustless, secure, local-first, user-originated, distributed authorization scheme.
When a central authority is present, using access control lists (ACLs) is common and good practice. However, when there is no such central authority (such as in local-first applications), authorization needs to be decentralized, offline-available and user-controlled (self-verifiable).
Modern applications must flexibly define trustless authorization and handle hostile environments, prompting the adoption of strong public key solutions. UCAN leverages concepts from SPKI (Simple Public Key Infrastructure) and object capabilities (OCAP), following the “capabilities as certificates” approach—with offline functionality, self-verifiability, revocation, and stateful capabilities.
There are several roles that an agent MAY assume:
Name | Description |
---|---|
Validator | Any agent that interprets a UCAN to determine that it is valid, and which capabilities it grants |
Audience | The principal delegated to in the current UCAN. Listed in the aud field |
Issuer | The signer of the current UCAN. Listed in the iss field |
Revoker | The issuer listed in a proof chain that revokes a UCAN |
Owner | The root issuer of a capability, who has some proof that they fully control the resource |
A resource is data or a process that has an address (e.g., database row, user account, or storage quota).
Abilities define the actions that can be performed on a resource (e.g., GET
, PUT
, POST
). Although you can define any semantics, they must logically apply to the resource. Abilities can be hierarchical.
A capability pairs an ability with a resource and optional caveats.
The set of capabilities delegated by a UCAN is referred to as its authority.
Delegation is the act of granting another principal (the delegate) the capability to use a resource on behalf of the delegator. This is authorized by a constructive "proof," which may be a signature from the owner or a UCAN that includes the relevant capability. Each delegation maintains or reduces the level of access, except in cases of rights amplification, where multiple proofs may be combined if allowed by the resource’s semantics.
Invocation is the actual use of a delegated capability to perform an operation on a resource.
Revocation is the invalidation of a UCAN after issuance, beyond built-in constraints like expiry. It must be performed by the issuer DID of the original proof, ensuring that only the original authority can revoke a delegated capability.
UCANs are formatted as standard JWTs (JSON Web Tokens) and include a header, payload, and signature.
The JWT header includes:
Field | Type | Description | Required |
---|---|---|---|
alg | String | Signature algorithm | Yes |
typ | String | Type ("JWT") | Yes |
Example:
{
"alg": "EdDSA",
"typ": "JWT"
}
The payload carries the UCAN-specific claims:
Field | Type | Description | Required |
---|---|---|---|
ucv | String | UCAN version (e.g., "0.2.0") | Yes |
iss | String | Issuer DID | Yes |
aud | String | Audience DID | Yes |
nbf | Integer | Not valid before (Unix timestamp) | No |
exp | Integer or null | Expiration time (Unix timestamp) | Yes |
nnc | String | Nonce for uniqueness | No |
fct | Object | Signed facts | No |
cap | Object | Capabilities (URI → abilities → caveats) | Yes |
prf | Array of CIDs | Proofs (delegated UCANs) | No |
Example:
"aud": "did:key:...",
"iss": "did:key:...",
"exp": 1700000000,
"cap": {
"https://example.com": {
"crud/read": [{}],
"crud/update": [
{"status": "draft"},
{"status": "published", "day-of-week": "Monday"}
]
}
}
Proofs are links to previous UCANs that authorize the current one’s capabilities. Each entry in the prf
array must be a CID pointing to another UCAN.
Example:
"prf": [
"bafkrei...",
"bafkrei..."
]
If any of the following criteria are not met, the UCAN MUST be considered invalid:
-
Time
A UCAN’snbf
(not before) andexp
(expiry) define its valid window; usage outside this window invalidates the UCAN. -
Principal Alignment
In delegation, each proof’saud
must match the next UCAN’siss
, forming a chain back to the resource owner. -
Proof Chaining
Every delegated capability must either be issued by the resource owner or justified by proofs in theprf
field, creating a chain to the root UCAN. Each step may only maintain or reduce capabilities (attenuation). Time bounds must also be within those of the proofs. -
Rights Amplification
Some capabilities require multiple distinct proofs to be valid. This is only allowed if it’s specified in the resource’s semantics. -
Content Identifiers
UCAN tokens are referenced by a base32 CIDv1 using SHA2-256 and the raw data multicodec (0x55
). Other codecs may be used if the token remains a valid JWT. Resolution is left to the implementation (e.g., local store, DHT, or REST). -
Revocation
The issuer of a UCAN can revoke it, or any further derived capabilities, in an eventually consistent manner. Revocations are ideally stored near the resource they affect and are irreversible; mistaken revocations require creating a new UCAN. Only proofs issued by the same DID are invalidated, leaving other valid chains intact. Time-based constraints remain a more proactive approach.
- Local-First Overview - Ink & Switch
- UCAN v0.10.0 Spec
- Local-First Enterprise Applications
Wolski, André (2024)
Local-First Enterprise Applications.
Technische Universität Darmstadt
doi: 10.26083/tuprints-00027006
Master Thesis, Primary publication, Publisher's Version