Merge pull request #115 from ucan-wg/tooling

Add a somewhat complete toolkit
This commit is contained in:
Michael Muré
2025-08-07 14:39:42 +02:00
committed by GitHub
49 changed files with 2858 additions and 154 deletions

1
go.mod
View File

@@ -6,6 +6,7 @@ toolchain go1.24.5
require (
github.com/MetaMask/go-did-it v1.0.0-pre1
github.com/avast/retry-go/v4 v4.6.1
github.com/ipfs/go-cid v0.5.0
github.com/ipld/go-ipld-prime v0.21.0
github.com/multiformats/go-multibase v0.2.0

2
go.sum
View File

@@ -1,6 +1,8 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/MetaMask/go-did-it v1.0.0-pre1 h1:NTGAC7z52TwFegEF7c+csUr/6Al1nAo6ValAAxOsjto=
github.com/MetaMask/go-did-it v1.0.0-pre1/go.mod h1:7m9syDnXFTg5GmUEcydpO4Rs3eYT4McFH7vCw5fp3A4=
github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk=
github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
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

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
PH4sIAAAAAAAA_5zWa5cUxR3HcVcRiejmuCII3siKwgnubnd1dVW1eIS5rzM9w8zs3HZETFd19_T07PTcZ2cH1KMgoB41YgIo3nM0G1GCRhCRiILIEaIhIEo0Bi9c9ewaFfEazYMd8iCPdvIGvk8-p_71e1RnZaujyt_e2_LQLYk5Fbb1QefFR9-_5LzWO59-9PfWoWV_nN_f177z06eH31j13Je8-Wpk35bTccKjtV_22MRfnz9DufuSg8Ntn02KzZtw83sLxj-mGE441FIoVZhidal9qTl8J9fJdRRZJz_IlIpaI2pavTKjDVxZR_5MQXa4aFIFgr-gMCrrOTWUqWNajNZERzin0GLRz7IxsTcfAgnGsqrRpedyXVQpMq2Wn5K68dmLWLpU-p9msRbQ-usuHEsGorZK0BbwxWzxvCdrhDK2vL23UMCRTM0RlStVxPK5vpsXMaWvj3Vee90i5Wq9s6r0VbRTWalC__-smtXKyu8MERNOwJSZkCACeAFgzkC6xGkYAhOLqkJEiBXJkJAuCUBUTMIA4KEgAEWzchbT5DXHfxwc7LiqNnPmRwdHgBa8NO6jDQ9uvW6wa7DHYetfP3DxO4s7JhybLa1YcerUV-Yae7aDww_P2_zjvH9euWqOOW3yjBWtO2__bKzLvfnR_enEpmaBMtFuy4qEsgrlqRMpPm86mql6apjI4V63V_bFgl5WT-USpQhDowVK2amiFmM8tEUsiRJcCeowHM9AvVQreLGE83LMkcBBI99dAE0ANZNtAGFewJjwkilBJPG8TphoKEQhmihKpsgBXuIVhXEGRRQpRGAmhzkRqIpOaQNo4afuoTWTj60cfjw5fQQoxy98ZPGmF3YsWLJo8voJ587Y9-X38geyLBq_nHXZIy9fPun5m1pebqVzv7J-NuaM08XEzYl3v3kmtfuCr2Yc-VAa3vtxS7NAnpKSKsBEzVaMphWdFB0kgNSEgHpwiPe7a0qaxYtVG_SVq_7RAhkey-asYmfUZ1XKMbGSr-Vo1UZ60rZyNV6Uy8l8MY_iES1ic0abAGom2wCCEqcLVFRNpEKBaghAaCBJUYgKOVNVGMQCUyXRQBqFTJWwKQkIIoZFQW8AvZV64bRDez_6flYbbmu8oDa_euu2yJB9w1vX3uO-YrZvobj2yK07V7-1YebjrRcgbsvS4fiyJ9_gpx4KvT5n_A7PTz-cqF_asuuaoTFnn2N-bbU2CaSHi0bJlrEi3kyh6NFrgqOu59VKOJ2t9Xj4sllT4wXqJdFQfzI2aiBvIGSG_Zls2IsDSdKblRV3QlQz2X7Z2evudVBk16QIM1yiCZsBaiLbAOIEnROQppu8AjgKiKqIhgQVwDQCTCKJkqCIBIqGpoicCilvcoxDWJewcPLE3TD0fHzb0y_BrtRd2gjQN3fd--36ju_CYzuV8adcsOeA95UvVv-09N1t05_bN7DllouWTN-18r4nTm-9Y8uZ2L02fj1XLizHlzyfrZ-x65Pt503b0uyJs0oDhpWuSh4vwFpJjlcDciKRCbtQTzmvxkWrKIWCUQm51FomO1ogKyMLyXSPx0xHxKrixwFT7a9XgzUUTuedXgDi8XKmgGJmJOwMNQHUTLYBhERGkE6YCTXEYYAYxxuE5xGnCbopAkWjAtCgbhCMKAUQmQqkvA4wJaQBpNyhPfDyK9Ijs-yOv40ArTv_hoMtSbB12NjtzD1Ef35ow47k_D3frn41f-2LX6YmdTxzzba3t180_7XFV5m-fz304tj2Td-t7Nx_oblszv7ctI2Hmz1xhayl1myOkhEOxUt2S_S4shFvMI7dIbseyPa4AlalJ8q6g2KhGBktUJGVfQEaSsXtsK7EnXnC6zIP84ZHrYUyGR9vcxXj9lwuaIvG7E0ANZM9-QcBHkNCNFMFEKhYFTRiSBoUOV0RTE3DKmWMCJKhijzgeJWakgoIwyLUuAZQSlq-Y177mBOSHt49AjRF-PBAe9d1H3ffcvyZczrHLAhP9X3jC5y797YFa-mNxYl_evDqo4fu_8NZh3d88un1R1f_5cfBf8_76p3tv_psyooll_k-nj2mWaC6b8BCmVrCytYjIFZKF1wytmUFKdijVuoCLIdrBV8dk3AxbIwWqJRAkpyRMnkY9ReLcipX8PR3u6RcrheykFINGCCc7-lJ25LMpjUB1Ez25IljDHFYpCYUiUYR4yVkUMKYIEiSiTDBSFcRgAajPAOU6ibjmShBToOwAXRf-6V31zceuXHdjHV9I0D7Jn7wgXzr4TVX_-PYw_mpLatO69_qW7R1Q-m8o9OcsGP2rM_f3lM_3n0_-aL18zWHV-eHzkik9r4_LriMt16a98Tm889sdiTASDqRwFLW3evj6_mU6i96cqInGEY4Ea0F1brlsPUXJSlCrcpogfRCzGll1YKRsqXrWEuVajq21wK5buKv5FxOV7wQy4Wz5Wwg3NRIaCbbABKopoi6BkymSSrTRCpSQ8UqEEQBmkTTBQp0VcSGBgAvYaiZCgQMUiz89wXdVtm7cNtjqvLmb5ZtHAHq2vfX9Uem2q-ZXTzw3oub0ruemj52f29hwuWeZ2_6_qylidcO5d9p3310-_wptTV3Dv1WXZzbuLsNDU2-dzDy7Sf84MxxTQKZAxWi5wO0Agci0WTOnStHklUpbPaHSi7JcsV9UbvH6_IHzHTVNeqRwLtlj6rV_T3ZehxGex2KLA3EXX4XhNmaGZdj-VjCRgy37HA4mhkJTWQbQBImhOkcbyIeQA1oiOcNBVBEdBWYlOc0IgBAREPnBYoABSavUAI0RhFuAM22T_nzs8NTTyz94YFVI0Bze_9-4utz3dvfW5B7fXL7uJWvr-1rO-uG5evusY_jlk-ynvzheOfE5IFIt4ieunAfO3bK-CWuX2w-0HbQP_fDi-8Y2NPsitNYLCfr2YLGEhrwg7LNoL3ZWKLSAzwDxNvNV_OA1L0oFVdToz5xuURswBupyoo7Ggua8Vg8kIjV5TLvcyCUDJsV7PXKuglTehn5mwBqJnvyBSEd6qpKTV5HCEOeU4mBCJSwpImmIkBd4BRGoUEUHUlMQ6amQVVVJB2CBpBwxRsH-9-cvnYnOfvd_wQAAP__hMJ751MQAAA
PH4sIAAAAAAAA_5zW65MU1f3HcS7-hB8igpfIgqUxCqLC0tfTfUADMzs7M87ObubaOzMEsM-lp6eb6Zmee49GvEKwVBQRgygIUVHLCwoqoJAAikCQaJRICCqFGPCCcXVFoaBMJbukKs929h94P3nV-ZzvSg2XrEkV9u7kwFW3J6bv7bj7tflfrjm4e8_grc7Dke4jPx6c_Mm87olH_O9Of35_59-X7z2xe056dKx7156F074aOXzCcemCRe-Y4T3n3v7SNV-fdd4qVfcLA48NPDbwArtYxqo1mcxJT2ebmWZmUgE3s09jtUxqMsmQKSZ1ptRBu5lF0WK75ZbawpVO25NW2rLtfgG5XFZKtlwBr1GPVfyKU9YjZS_BOEv0yVouNxmpBUxr-TH6osmv4Uyx-L9NM5PUWqLtmqTohbQnkWp1i5JlxiOWLxAFAdaJeU13Z0gwWsN2AOdzc267A6tz5uDmGTPvUH-pNVfUOWU6CBfLqP9ZkqUl9fc6w3KqRmTVUDWGB1BiiKoDCBhJptRABAuCxrJY0zHPQBZjbLCY8hKCVBOplbMwDXbdsO9O4ZT5m20fTPywBymh3HfTBjDl5MzlT65xpm15kC4-f_2hlTtKyQUbrj78wBPfjn1e3T9i7OmuEfmt25d4z_q2K_0H36lRiw_9NbK0Szy0d3A_kPKuFk6qOYqVTSCj2uGNknxdT0BN0Etlqklxr8QaSqzF468oQl-R0oo7rztKQaK5crqzmMq7vOV4uh4jFZ2zPIrfFL0hS_ZYOFikDSA1ku1FAqJEZIRUA2gMy0OeY4EOKGVlHmCDIgZKKhYhp2NKGQR5YvBYZAWVp4j0Io1b4N905xU_e_SJj1-4pAfpbefa207fnMit_9XP39xxw7D3Fk0bH7vs09W_3uwcG3Vk5Nxv14yYP2Zj7lLPQ01Nnw0bsmXTssuZprL6-dX3zB3U9NH9q8_tB1KhgwUG8gA54HH5OcMyW1vqjmLnO11GNB0Ph6ut8WI1zAbdSMv2FclKhWK2riCS9trFvDvmd3tC0bpo8SAeC5QExZfiO3LeIOuKePkGkBrJnkEiWJYlnjEg1nhERSKouowIUgEvGUTGRBN5Ims6QiyVZVUwNIp4GUNOxr1I199x4OH_y6103Q-3DOhBuhYw1x1Y9eHYjejREfahp2Z9SNaqo83Xb0yff-mFr3_srFg7at3FV2aEy28atv2p8zbc-313YOQ3y65gm1Z9MHzdipHm0H4gFYmaLrQGWjpFLp4ICmagamWKHI560tlAsoa1uNcotoQKWj7fGuvz3Ck1v5hJgvZQJGYIKTlcFrypWocVLHPuEC_oWa2e0M3OVsen-BuZuwayZ-YOckQARDJ4TLAsChqUdVHVOBkA1pA5GVKGkaiqA04CDISiIcoUMhRjCHuRmjJraxO7f_ji-PiTE3pf0g83F4Zc-hF4-7uTV4XPG7xw986TC__2_qlnjrf9OLDrrKfeXioPGvtN0-NHRv8OHh7_5fzP1V3Lt40Zar-z-c3Vtx5O54b0AykTCrGsVS1iKmkdapQ1q6lqKWkV2gD1J3MIY09SlHw5O6yHgn1FqnoTLU66YobsbASoKq46rmoka2c8LomNkHBFYH2VVldNa-GTQgNIjWR7kTiVAIYRsYEFTcMCgpDqogw4TFXGwKqmYaACRtMJ4ClmEDAQFaHAYAbSXqRXxt9z8fIVxXOXjpjI9SDtXIkK6MJ3T6xbv-WuN7azi9K8UGl5a1t056x9G6fgxJJ5g03lSBk_O2juXV0Lnh2kVHfIwcqrKw-9dboId5X-eU5__iRPLhyxMsF03C9ZnWYKtRfZajIbUDjb68k7XFG2UL5adKesKOwrku42qkgrRaK2lI1jYJaMelnpaLf9RhW0sfmUrjk-FymaAcHBDSA1ku1FYjVeBBpGBqUCpjIUKdI5TuAwwqqhiQAAyvAQ6BoDBCgiwSCizMsSQhzfi_SP-2K_-PGE9saxTzMze5Dy-z0P7Di679olRys3fzdIXbZgbpvy0Krm5oXnnP_1w9yzj0yF8_64bCjzW2H22feEv48NLk6_8UD32fWPNvlf_CS18OUB_UDSE1WcaNUKSsqx_SjA-zNeLYI4T0cybfM4pbMg4AknnbbOetjqK1K-blcKUt4DWcNrVBzBIiJ2fGE7W8gCKZbI4WhBy4KkP9MWhA0gNZL9LxJLVMQhA_KEkTAjEkbnBZFILMQGQRLPi5gSpAOqEShqqoFFWZA4jCHoRRLeO9XcMqtwy7p35cd6kIY99snirRBtnvqcHN9benz4hCuG3LJt9bafZki7thTP_mHpi5PGXfbY6N1Nm68Z992tLD51S-q5DR98dsnhAbNSNzwZvOr_-4FUTdpy0XIjlg0nZSHkqaneUBVUCylgZEtmwQ3LhZrhR3WtkrX7PHegSpISVKtKuCYFyu11s5pK6FHbR112BNtsIZUOt1fSHVIuLzYydw1kz8ydoGqUF4nBizIHZYFyog5ZLHCU0QwEOZGROVEiusr824blDCgBSSIMgGdO8ClPP7KYPJnsQIeKG3uQrnz5teFvvD98hrrOc3nrmK3rn9s_-09PjBzaddOwxDl3Hz1xfEx810uPhvYcbL3sL9dNWjnuxamjXnCFEheAP29i5nXf3tGf6y7r6qwrJZfHlLyGLCvuWCf05Qp6JGDYBaNWqnvS5bKN_VRBVaWvSDnJBG4lHBEAl05yIccVIHZabmmLBttdobSjaKY3FRY9alvY42oAqZFsL5KKCAaEQkMlokgpyzKijhlRpaIoGZClCLCU1ahOIGQhFWSDQwLLqkhlzrwkvOH-ScEtSw68Omr87B6kpYWRa185WbvuC8u8aMC0i1pXbHeO72P9982fsHhh2xbf8WGe0y5Zq-wAk3YenZ05evG93MzVAx78aXJlxr1r2hce7A9Srqh6jIIVjNU7INENUg7Tqmz7tFBb2pfy-oq6zxuOOO0kWo27-3yC58tCwi900DxOtpk-k2txFCfuCGKllnOscqVk-W0N1dmoFcw3coI3kO1FElVWYhiiGhzgCS-xPES6zFKVAZxgyIAXJZWjAOsygppAEG_8Z-wIQZzai7Ri5eyp3V8981nL9fET_woAAP__svLMp3sQAAA

Binary file not shown.

View File

@@ -142,12 +142,29 @@ func (ctn Reader) GetDelegation(cid cid.Cid) (*delegation.Token, error) {
return nil, delegation.ErrDelegationNotFound
}
// GetDelegationBundle is the same as GetToken but only return a delegation.Bundle, with the right type.
// If not found, delegation.ErrDelegationNotFound is returned.
func (ctn Reader) GetDelegationBundle(cid cid.Cid) (*delegation.Bundle, error) {
bndl, ok := ctn[cid]
if !ok {
return nil, delegation.ErrDelegationNotFound
}
if tkn, ok := bndl.token.(*delegation.Token); ok {
return &delegation.Bundle{
Cid: cid,
Decoded: tkn,
Sealed: bndl.sealed,
}, nil
}
return nil, delegation.ErrDelegationNotFound
}
// GetAllDelegations returns all the delegation.Token in the container.
func (ctn Reader) GetAllDelegations() iter.Seq[delegation.Bundle] {
return func(yield func(delegation.Bundle) bool) {
func (ctn Reader) GetAllDelegations() iter.Seq[*delegation.Bundle] {
return func(yield func(*delegation.Bundle) bool) {
for c, bndl := range ctn {
if t, ok := bndl.token.(*delegation.Token); ok {
if !yield(delegation.Bundle{
if !yield(&delegation.Bundle{
Cid: c,
Decoded: t,
Sealed: bndl.sealed,

View File

@@ -73,12 +73,12 @@ func (ctn Writer) ToBase64URLWriter(w io.Writer) error {
return ctn.toWriter(headerBase64URL, w)
}
// ToBase64URL encode the container into pre-gzipped base64 string, with URL-safe encoding and no padding.
// ToBase64URLGzipped encode the container into pre-gzipped base64 string, with URL-safe encoding and no padding.
func (ctn Writer) ToBase64URLGzipped() (string, error) {
return ctn.toString(headerBase64URLGzip)
}
// ToBase64URLWriter is the same as ToBase64URL, but with an io.Writer.
// ToBase64URLGzipWriter is the same as ToBase64URL, but with an io.Writer.
func (ctn Writer) ToBase64URLGzipWriter(w io.Writer) error {
return ctn.toWriter(headerBase64URLGzip, w)
}

View File

@@ -6,12 +6,12 @@ import (
"testing"
"time"
"github.com/MetaMask/go-did-it/didtest"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/pkg/command"
"github.com/ucan-wg/go-ucan/pkg/policy"
"github.com/ucan-wg/go-ucan/token/delegation"
"github.com/ucan-wg/go-ucan/token/internal/didtest"
)
//go:embed testdata/new.dagjson

View File

@@ -12,6 +12,7 @@ import (
"github.com/MetaMask/go-did-it"
didkeyctl "github.com/MetaMask/go-did-it/controller/did-key"
"github.com/MetaMask/go-did-it/crypto"
"github.com/MetaMask/go-did-it/didtest"
"github.com/ipfs/go-cid"
"github.com/ucan-wg/go-ucan/pkg/command"
@@ -19,7 +20,6 @@ import (
"github.com/ucan-wg/go-ucan/pkg/policy/policytest"
"github.com/ucan-wg/go-ucan/token/delegation"
"github.com/ucan-wg/go-ucan/token/delegation/delegationtest"
"github.com/ucan-wg/go-ucan/token/internal/didtest"
)
const (

View File

@@ -1,7 +1,7 @@
package main
import (
"github.com/ucan-wg/go-ucan/token/internal/didtest"
"github.com/MetaMask/go-did-it/didtest"
)
func main() {

View File

@@ -8,6 +8,7 @@ import (
"fmt"
"time"
"github.com/MetaMask/go-did-it/didtest"
"github.com/ipfs/go-cid"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/codec/dagcbor"
@@ -17,7 +18,6 @@ import (
"github.com/ucan-wg/go-ucan/pkg/policy"
"github.com/ucan-wg/go-ucan/pkg/policy/literal"
"github.com/ucan-wg/go-ucan/token/delegation"
"github.com/ucan-wg/go-ucan/token/internal/didtest"
"github.com/ucan-wg/go-ucan/token/internal/envelope"
)

View File

@@ -5,12 +5,12 @@ import (
_ "embed"
"testing"
"github.com/MetaMask/go-did-it/didtest"
"github.com/ipld/go-ipld-prime"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/token/delegation"
"github.com/ucan-wg/go-ucan/token/internal/didtest"
"github.com/ucan-wg/go-ucan/token/internal/envelope"
)

View File

@@ -1,139 +0,0 @@
// Package didtest provides Personas that can be used for testing. Each
// Persona has a name, crypto.PrivKey and associated crypto.PubKey and
// did.DID.
package didtest
import (
"encoding/base64"
"fmt"
"github.com/MetaMask/go-did-it"
didkeyctl "github.com/MetaMask/go-did-it/controller/did-key"
"github.com/MetaMask/go-did-it/crypto"
"github.com/MetaMask/go-did-it/crypto/ed25519"
)
const (
// all are ed25519 as base64
alicePrivKeyB64 = "zth/9cTSUVwlLzfEWwLCcOkaEmjrRGPOI6mOJksWAYZ3Toe7ymxAzDeiseyxbmEpJ81qYM3dZ8XrXqgonnTTEw=="
bobPrivKeyB64 = "+p1REV3MkUnLhUMbFe9RcSsmo33TT/FO85yaV+c6fiYJCBsdiwfMwodlkzSAG3sHQIuZj8qnJ678oJucYy7WEg=="
carolPrivKeyB64 = "aSu3vTwE7z3pXaTaAhVLeizuqnZUJZQHTCSLMLxyZh5LDoZQn80uoQgMEdsbOhR+zIqrjBn5WviGurDkKYVfug=="
danPrivKeyB64 = "s1zM1av6og3o0UMNbEs/RyezS7Nk/jbSYL2Z+xPEw9Cho/KuEAa75Sf4yJHclLwpKXNucbrZ2scE8Iy8K05KWQ=="
erinPrivKeyB64 = "+qHpaAR3iivWMEl+pkXmq+uJeHtqFiY++XOXtZ9Tu/WPABCO+eRFrTCLJykJEzAPGFmkJF8HQ7DMwOH7Ry3Aqw=="
frankPrivKeyB64 = "4k/1N0+Fq73DxmNbGis9PY2KgKxWmtDWhmi1E6sBLuGd7DS0TWjCn1Xa3lXkY49mFszMjhWC+V6DCBf7R68u4Q=="
)
// Persona is a generic participant used for cryptographic testing.
type Persona int
// The provided Personas were selected from the first few generic
// participants listed in this [table].
//
// [table]: https://en.wikipedia.org/wiki/Alice_and_Bob#Cryptographic_systems
const (
PersonaAlice Persona = iota + 1
PersonaBob
PersonaCarol
PersonaDan
PersonaErin
PersonaFrank
)
var privKeys map[Persona]crypto.PrivateKeySigningBytes
func init() {
privKeys = make(map[Persona]crypto.PrivateKeySigningBytes, 6)
for persona, pB64 := range privKeyB64() {
privBytes, err := base64.StdEncoding.DecodeString(pB64)
if err != nil {
return
}
privKey, err := ed25519.PrivateKeyFromBytes(privBytes)
if err != nil {
return
}
privKeys[persona] = privKey
}
}
// DID returns a did.DID based on the Persona's Ed25519 public key.
func (p Persona) DID() did.DID {
return didkeyctl.FromPrivateKey(p.PrivKey())
}
// Name returns the username of the Persona.
func (p Persona) Name() string {
name, ok := map[Persona]string{
PersonaAlice: "Alice",
PersonaBob: "Bob",
PersonaCarol: "Carol",
PersonaDan: "Dan",
PersonaErin: "Erin",
PersonaFrank: "Frank",
}[p]
if !ok {
panic(fmt.Sprintf("Unknown persona: %v", p))
}
return name
}
// PrivKey returns the Ed25519 private key for the Persona.
func (p Persona) PrivKey() crypto.PrivateKeySigningBytes {
res, ok := privKeys[p]
if !ok {
panic(fmt.Sprintf("Unknown persona: %v", p))
}
return res
}
func (p Persona) PrivKeyConfig() string {
res, ok := privKeyB64()[p]
if !ok {
panic(fmt.Sprintf("Unknown persona: %v", p))
}
return res
}
// PubKey returns the Ed25519 public key for the Persona.
func (p Persona) PubKey() crypto.PublicKey {
return p.PrivKey().Public()
}
func privKeyB64() map[Persona]string {
return map[Persona]string{
PersonaAlice: alicePrivKeyB64,
PersonaBob: bobPrivKeyB64,
PersonaCarol: carolPrivKeyB64,
PersonaDan: danPrivKeyB64,
PersonaErin: erinPrivKeyB64,
PersonaFrank: frankPrivKeyB64,
}
}
// Personas returns an (alphabetically) ordered list of the defined
// Persona values.
func Personas() []Persona {
return []Persona{
PersonaAlice,
PersonaBob,
PersonaCarol,
PersonaDan,
PersonaErin,
PersonaFrank,
}
}
// DidToName retrieve the persona's name from its DID.
func DidToName(d did.DID) string {
return map[did.DID]string{
PersonaAlice.DID(): "Alice",
PersonaBob.DID(): "Bob",
PersonaCarol.DID(): "Carol",
PersonaDan.DID(): "Dan",
PersonaErin.DID(): "Erin",
PersonaFrank.DID(): "Frank",
}[d]
}

View File

@@ -4,6 +4,7 @@ import (
_ "embed"
"testing"
"github.com/MetaMask/go-did-it/didtest"
"github.com/ipfs/go-cid"
"github.com/stretchr/testify/require"
@@ -11,7 +12,6 @@ import (
"github.com/ucan-wg/go-ucan/pkg/command"
"github.com/ucan-wg/go-ucan/pkg/policy/policytest"
"github.com/ucan-wg/go-ucan/token/delegation/delegationtest"
"github.com/ucan-wg/go-ucan/token/internal/didtest"
"github.com/ucan-wg/go-ucan/token/invocation"
)

View File

@@ -0,0 +1,36 @@
## UCAN examples
This directory contains an example of UCAN usage across multiple agents, and their respective implementations.
Please note that UCAN in itself doesn't enforce any protocol, topology or transport, and as such what you have here is one possibility among many others. In particular:
- this example is really geared towards using UCAN for an HTTP API
- it uses a particular flavor of issuer protocol and token exchange. In particular, that issuer gives delegation tokens to anyone asking.
Your situation may be different from this, and would call for a different setup.
Remember that everything in `/toolkit` is essentially helpers, pre-made building blocks. You can use them, change them or make your own.
## Scenario 1
Starting simple, if we run `service`, `service-issuer` and `alice-client-server`, we have the following scenario:
![scenario 1](scenario1.png)
- `service` controls the access to the resource (it's the `Executor` in that diagram). You can think about it as a proxy with authentication.
- `service-issuer` gives a delegation tokens to clients. `service` and `service-issuer` share the same DID and keypair.
- `alice-client-server` ask for a token, and periodically makes request
## Scenario 2
Building on the previous scenario, we are adding sub-delegation.
![scenario 2](scenario2.png)
- `alice-client-server` still do the same thing, but also expose a similar token issuer with the same protocol (for simplicity in that example)
- `bob-client` request a delegation from Alice, and make **direct** request to the service
Note a few things:
- Alice can finely tune what Bob can do
- Bob receives **two** delegations: the original one Alice got and a second one delegating some of that original power to him
- Bob can make direct calls to the service without having to be proxied somewhere
- The service doesn't have to know beforehand about Bob or what power is given to him

View File

@@ -0,0 +1,41 @@
package protocol
import (
"encoding/json"
"net/http"
"github.com/MetaMask/go-did-it"
"github.com/ucan-wg/go-ucan/pkg/command"
"github.com/ucan-wg/go-ucan/toolkit/issuer"
)
func RequestResolver(r *http.Request) (*issuer.ResolvedRequest, error) {
// Let's make up a simple json protocol
req := struct {
Audience string `json:"aud"`
Cmd string `json:"cmd"`
Subject string `json:"sub"`
}{}
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
return nil, err
}
aud, err := did.Parse(req.Audience)
if err != nil {
return nil, err
}
cmd, err := command.Parse(req.Cmd)
if err != nil {
return nil, err
}
sub, err := did.Parse(req.Subject)
if err != nil {
return nil, err
}
return &issuer.ResolvedRequest{
Audience: aud,
Cmd: cmd,
Subject: sub,
}, nil
}

View File

@@ -0,0 +1,60 @@
package protocol
import (
"bytes"
"context"
"encoding/json"
"iter"
"log"
"net/http"
"github.com/MetaMask/go-did-it"
"github.com/ucan-wg/go-ucan/pkg/command"
"github.com/ucan-wg/go-ucan/token/delegation"
"github.com/ucan-wg/go-ucan/toolkit/client"
"github.com/ucan-wg/go-ucan/toolkit/issuer"
)
var _ client.DelegationRequester = &Requester{}
type Requester struct {
issuerURL string
}
func NewRequester(issuerURL string) *Requester {
return &Requester{issuerURL: issuerURL}
}
func (r Requester) RequestDelegation(ctx context.Context, audience did.DID, cmd command.Command, subject did.DID) (iter.Seq2[*delegation.Bundle, error], error) {
log.Printf("requesting delegation for %s on %s", cmd, subject)
// we match the simple json protocol of the issuer
data := struct {
Audience string `json:"aud"`
Cmd string `json:"cmd"`
Subject string `json:"sub"`
}{
Audience: audience.String(),
Cmd: cmd.String(),
Subject: subject.String(),
}
buf := &bytes.Buffer{}
err := json.NewEncoder(buf).Encode(data)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, r.issuerURL, buf)
if err != nil {
return nil, err
}
res, err := http.DefaultClient.Do(req.WithContext(ctx))
if err != nil {
return nil, err
}
return issuer.DecodeResponse(res, audience, cmd, subject)
}

View File

@@ -0,0 +1,154 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"os/signal"
"syscall"
"time"
"github.com/MetaMask/go-did-it"
"github.com/MetaMask/go-did-it/crypto"
"github.com/ucan-wg/go-ucan/pkg/command"
"github.com/ucan-wg/go-ucan/pkg/container"
"github.com/ucan-wg/go-ucan/pkg/policy"
"github.com/ucan-wg/go-ucan/pkg/policy/literal"
"github.com/ucan-wg/go-ucan/token/delegation"
example "github.com/ucan-wg/go-ucan/toolkit/_example"
protocol "github.com/ucan-wg/go-ucan/toolkit/_example/_protocol-issuer"
"github.com/ucan-wg/go-ucan/toolkit/client"
"github.com/ucan-wg/go-ucan/toolkit/issuer"
"github.com/ucan-wg/go-ucan/toolkit/server/bearer"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
// register as handler of the interrupt signal to trigger the teardown
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, os.Interrupt)
go func() {
<-quit
cancel()
}()
err := run(ctx,
example.AliceIssuerUrl, example.AlicePrivKey, example.AliceDid,
example.ServiceIssuerUrl, example.ServiceUrl, example.ServiceDid)
if err != nil {
log.Println(err)
os.Exit(1)
}
}
func run(ctx context.Context, ownIssuerUrl string, priv crypto.PrivateKeySigningBytes, d did.DID,
serviceIssuerUrl string, serviceUrl string, serviceDid did.DID) error {
log.Printf("Alice DID is %s", d.String())
issuingLogic := func(iss did.DID, aud did.DID, cmd command.Command, subject did.DID) (*delegation.Token, error) {
log.Printf("issuing delegation to %v for %v to operate on %v", aud, cmd, subject)
// As another example, we'll force Bob to use a specific HTTP sub-path
policies, err := policy.Construct(
policy.Equal(".http.path", literal.String(fmt.Sprintf("/%s/%s", iss.String(), aud.String()))),
)
if err != nil {
return nil, err
}
return delegation.New(iss, aud, cmd, policies, subject)
}
cli, err := client.NewWithIssuer(priv, d, protocol.NewRequester("http://"+serviceIssuerUrl), issuingLogic)
if err != nil {
return err
}
go startIssuerHttp(ctx, ownIssuerUrl, cli)
for {
proofs, err := cli.PrepareInvoke(ctx, command.MustParse("/foo/bar"), serviceDid)
if err != nil {
return err
}
err = makeRequest(ctx, d, serviceUrl, proofs)
if err != nil {
log.Println(err)
}
select {
case <-ctx.Done():
return nil
case <-time.After(20 * time.Second):
}
}
}
func startIssuerHttp(ctx context.Context, issuerUrl string, cli *client.WithIssuer) {
handler := issuer.HttpWrapper(cli, protocol.RequestResolver)
srv := &http.Server{
Addr: issuerUrl,
Handler: handler,
}
go func() {
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %s\n", err)
}
}()
log.Printf("issuer listening on %s\n", srv.Addr)
<-ctx.Done()
if err := srv.Shutdown(ctx); err != nil && !errors.Is(err, context.Canceled) {
log.Printf("issuer error: %v\n", err)
}
}
func makeRequest(ctx context.Context, clientDid did.DID, serviceUrl string, proofs container.Writer) error {
// we construct a URL that include the client DID as path, as requested by the UCAN policy we get issued
u, err := url.JoinPath("http://"+serviceUrl, clientDid.String())
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodGet, u, nil)
if err != nil {
return err
}
err = bearer.AddBearerContainerCompressed(req.Header, proofs)
if err != nil {
return err
}
res, err := http.DefaultClient.Do(req.WithContext(ctx))
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
body, err := io.ReadAll(res.Body)
if err != nil {
return fmt.Errorf("unexpected status code: %d, error reading body: %w", res.StatusCode, err)
}
return fmt.Errorf("unexpected status code: %d, body: %v", res.StatusCode, string(body))
}
log.Printf("response status code: %d", res.StatusCode)
return nil
}

View File

@@ -0,0 +1,115 @@
package main
import (
"context"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"os/signal"
"syscall"
"time"
"github.com/MetaMask/go-did-it"
didkeyctl "github.com/MetaMask/go-did-it/controller/did-key"
"github.com/MetaMask/go-did-it/crypto/ed25519"
"github.com/ucan-wg/go-ucan/pkg/command"
"github.com/ucan-wg/go-ucan/pkg/container"
example "github.com/ucan-wg/go-ucan/toolkit/_example"
protocol "github.com/ucan-wg/go-ucan/toolkit/_example/_protocol-issuer"
"github.com/ucan-wg/go-ucan/toolkit/client"
"github.com/ucan-wg/go-ucan/toolkit/server/bearer"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
// register as handler of the interrupt signal to trigger the teardown
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, os.Interrupt)
go func() {
<-quit
cancel()
}()
err := run(ctx, example.AliceIssuerUrl, example.AliceDid, example.ServiceUrl, example.ServiceDid)
if err != nil {
log.Println(err)
os.Exit(1)
}
}
func run(ctx context.Context, aliceUrl string, aliceDid did.DID, serverUrl string, serviceDid did.DID) error {
// Let's generate a keypair for our client:
pub, priv, err := ed25519.GenerateKeyPair()
if err != nil {
return err
}
d := didkeyctl.FromPublicKey(pub)
log.Printf("Bob DID is %s", d.String())
cli, err := client.NewClient(priv, d, protocol.NewRequester("http://"+aliceUrl))
if err != nil {
return err
}
for {
proofs, err := cli.PrepareInvoke(ctx, command.MustParse("/foo/bar"), serviceDid)
if err != nil {
return err
}
err = makeRequest(ctx, d, serverUrl, aliceDid, proofs)
if err != nil {
log.Println(err)
}
select {
case <-ctx.Done():
return nil
case <-time.After(1 * time.Second):
}
}
}
func makeRequest(ctx context.Context, clientDid did.DID, serviceUrl string, aliceDid did.DID, proofs container.Writer) error {
// we construct a URL that include our DID and Alice DID as path, as requested by the UCAN policy we get issued
u, err := url.JoinPath("http://"+serviceUrl, aliceDid.String(), clientDid.String())
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodGet, u, nil)
if err != nil {
return err
}
err = bearer.AddBearerContainerCompressed(req.Header, proofs)
if err != nil {
return err
}
res, err := http.DefaultClient.Do(req.WithContext(ctx))
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
body, err := io.ReadAll(res.Body)
if err != nil {
return fmt.Errorf("unexpected status code: %d, error reading body: %w", res.StatusCode, err)
}
return fmt.Errorf("unexpected status code: %d, body: %v", res.StatusCode, string(body))
}
log.Printf("response status code: %d", res.StatusCode)
return nil
}

View File

@@ -0,0 +1,57 @@
@startuml
left to right direction
rectangle Service as owner {
rectangle Issuer as issuer
rectangle Executor as exec
}
node resource as res
owner --> res : Controls
exec --> res : Allow access to
rectangle "Alice" as alice {
rectangle Client as aliceclient
}
aliceclient --> issuer : [1] request delegation
aliceclient <-- issuer : [2] issue delegation A
aliceclient --> exec : [3] make request with A
@enduml
@startuml
left to right direction
rectangle Service as owner {
rectangle Issuer as issuer
rectangle Executor as exec
}
node resource as res
owner --> res : Controls
exec --> res : Allow access to
rectangle "Alice" as alice {
rectangle Client as aliceclient
rectangle Issuer as aliceissuer
}
aliceclient --> issuer : [1] request delegation
aliceclient <-- issuer : [2] issue delegation A
aliceclient --> exec : [3] make request with A
rectangle "Bob" as bob {
rectangle Client as bobclient
}
bobclient --> aliceissuer : [4] request delegation
bobclient <-- aliceissuer : [5] issue delegation B\nalso returns A
bobclient -down-> exec : [6] make request with A+B
@enduml

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -0,0 +1,97 @@
package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/MetaMask/go-did-it"
"github.com/MetaMask/go-did-it/crypto"
"github.com/ucan-wg/go-ucan/pkg/command"
"github.com/ucan-wg/go-ucan/pkg/policy"
"github.com/ucan-wg/go-ucan/pkg/policy/literal"
"github.com/ucan-wg/go-ucan/token/delegation"
example "github.com/ucan-wg/go-ucan/toolkit/_example"
protocol "github.com/ucan-wg/go-ucan/toolkit/_example/_protocol-issuer"
"github.com/ucan-wg/go-ucan/toolkit/issuer"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
// register as handler of the interrupt signal to trigger the teardown
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, os.Interrupt)
go func() {
<-quit
cancel()
}()
err := run(ctx, example.ServiceIssuerUrl, example.ServicePrivKey)
if err != nil {
log.Println(err)
os.Exit(1)
}
}
func run(ctx context.Context, issuerUrl string, servicePrivKey crypto.PrivateKeySigningBytes) error {
issuingLogic := func(iss did.DID, aud did.DID, cmd command.Command) (*delegation.Token, error) {
log.Printf("issuing delegation to %v for %v", aud, cmd)
// We construct an arbitrary policy.
// Here, we enforce that the caller uses its own DID endpoint (an arbitrary construct for this example).
// You will notice that the server doesn't need to know about this logic to enforce it.
policies, err := policy.Construct(
policy.Or(
// allow exact path
policy.Equal(".http.path", literal.String(fmt.Sprintf("/%s", aud.String()))),
// allow sub-path
policy.Like(".http.path", fmt.Sprintf("/%s/*", aud.String())),
),
)
if err != nil {
return nil, err
}
return delegation.Root(iss, aud, cmd, policies,
// let's add an expiration, this will force the client to renew its token.
delegation.WithExpirationIn(10*time.Second),
)
}
rootIssuer, err := issuer.NewRootIssuer(servicePrivKey, issuingLogic)
if err != nil {
return err
}
handler := issuer.HttpWrapper(rootIssuer, protocol.RequestResolver)
srv := &http.Server{
Addr: issuerUrl,
Handler: handler,
}
go func() {
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %s\n", err)
}
}()
log.Printf("listening on %s\n", srv.Addr)
<-ctx.Done()
if err := srv.Shutdown(ctx); err != nil && !errors.Is(err, context.Canceled) {
return err
}
return nil
}

View File

@@ -0,0 +1,88 @@
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"github.com/MetaMask/go-did-it"
example "github.com/ucan-wg/go-ucan/toolkit/_example"
"github.com/ucan-wg/go-ucan/toolkit/server/exectx"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
// register as handler of the interrupt signal to trigger the teardown
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, os.Interrupt)
go func() {
<-quit
cancel()
}()
err := run(ctx, example.ServiceUrl, example.ServiceDid)
if err != nil {
log.Println(err)
os.Exit(1)
}
}
func run(ctx context.Context, serviceUrl string, serviceDID did.DID) error {
log.Printf("service DID is %s\n", serviceDID.String())
// we'll make a simple handling pipeline:
// - exectx.ExtractMW to extract and decode the UCAN context, verify the service DID
// - exectx.HttpExtArgsVerify to verify the HTTP policies
// - exectx.EnforceMW to perform the final UCAN checks
// - our handler to execute the commands
var handler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ucanCtx, ok := exectx.FromContext(r.Context())
if !ok {
http.Error(w, "no ucan-ctx found", http.StatusInternalServerError)
return
}
switch ucanCtx.Command().String() {
case "/foo/bar":
log.Printf("handled command %v at %v for %v", ucanCtx.Command(), r.URL.Path, ucanCtx.Invocation().Issuer())
log.Printf("proof is %v", ucanCtx.Invocation().Proof())
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("OK"))
default:
http.Error(w, "unknown UCAN commmand", http.StatusBadRequest)
return
}
})
handler = exectx.EnforceMW(handler)
handler = exectx.HttpExtArgsVerify(handler)
handler = exectx.ExtractMW(handler, serviceDID)
srv := &http.Server{
Addr: serviceUrl,
Handler: handler,
}
go func() {
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %s\n", err)
}
}()
log.Printf("listening on %s\n", srv.Addr)
<-ctx.Done()
if err := srv.Shutdown(ctx); err != nil && !errors.Is(err, context.Canceled) {
return err
}
return nil
}

View File

@@ -0,0 +1,37 @@
package example
import (
"encoding/base64"
"github.com/MetaMask/go-did-it"
didkeyctl "github.com/MetaMask/go-did-it/controller/did-key"
"github.com/MetaMask/go-did-it/crypto"
"github.com/MetaMask/go-did-it/crypto/ed25519"
)
// Endpoints
var ServiceUrl = ":8080"
var ServiceIssuerUrl = ":8081"
var AliceIssuerUrl = ":8082"
// Service
var ServicePrivKey crypto.PrivateKeySigningBytes
var ServiceDid did.DID
// Alice
var AlicePrivKey crypto.PrivateKeySigningBytes
var AliceDid did.DID
func init() {
servPrivRaw, _ := base64.StdEncoding.DecodeString("HVcbgoj30c+7zoQzUgpl7Jc7bkXoyvo9bMX5OHaAohpv036EMxuWXGqmEWhFKHPEuRAaIGSURK8pyUYOAseiiQ==")
ServicePrivKey, _ = ed25519.PrivateKeyFromBytes(servPrivRaw)
ServiceDid = didkeyctl.FromPrivateKey(ServicePrivKey)
alicePrivRaw, _ := base64.StdEncoding.DecodeString("jIIk/4ZBgIzx7fU41AWYRUDjgQmgFTIXxN4WeZAPCjwE04oLfiHgNjwIIZi97a6WwSIL5tFGdkrqDkSmDx95tw==")
AlicePrivKey, _ = ed25519.PrivateKeyFromBytes(alicePrivRaw)
AliceDid = didkeyctl.FromPrivateKey(AlicePrivKey)
}

126
toolkit/client/client.go Normal file
View File

@@ -0,0 +1,126 @@
package client
import (
"context"
"fmt"
"iter"
"github.com/MetaMask/go-did-it"
"github.com/MetaMask/go-did-it/crypto"
"github.com/ipfs/go-cid"
"github.com/ucan-wg/go-ucan/pkg/command"
"github.com/ucan-wg/go-ucan/pkg/container"
"github.com/ucan-wg/go-ucan/pkg/policy"
"github.com/ucan-wg/go-ucan/token/delegation"
"github.com/ucan-wg/go-ucan/token/invocation"
)
type Client struct {
did did.DID
privKey crypto.PrivateKeySigningBytes
pool *Pool
requester DelegationRequester
}
func NewClient(privKey crypto.PrivateKeySigningBytes, d did.DID, requester DelegationRequester) (*Client, error) {
return &Client{
did: d,
privKey: privKey,
pool: NewPool(),
requester: requester,
}, nil
}
// AddBundle adds a delegation.Bundle to the client's pool.
func (c *Client) AddBundle(bundle *delegation.Bundle) {
c.pool.AddBundle(bundle)
}
// AddBundles adds a sequence of delegation.Bundles to the client's pool.
func (c *Client) AddBundles(bundles iter.Seq[*delegation.Bundle]) {
c.pool.AddBundles(bundles)
}
// PrepareInvoke returns an invocation, bundled in a container.Writer with the necessary proofs.
func (c *Client) PrepareInvoke(ctx context.Context, cmd command.Command, subject did.DID, opts ...invocation.Option) (container.Writer, error) {
proof, err := c.findProof(ctx, cmd, subject)
if err != nil {
return nil, err
}
inv, err := invocation.New(c.did, cmd, subject, proof, opts...)
if err != nil {
return nil, err
}
invSealed, _, err := inv.ToSealed(c.privKey)
if err != nil {
return nil, err
}
cont := container.NewWriter()
cont.AddSealed(invSealed)
for bundle, err := range c.pool.GetBundles(proof) {
if err != nil {
return nil, err
}
cont.AddSealed(bundle.Sealed)
}
return cont, nil
}
// PrepareDelegation returns a new delegation for a third party DID, bundled in a container.Writer with the necessary proofs.
func (c *Client) PrepareDelegation(ctx context.Context, aud did.DID, cmd command.Command, subject did.DID, policies policy.Policy, opts ...delegation.Option) (container.Writer, error) {
proof, err := c.findProof(ctx, cmd, subject)
if err != nil {
return nil, err
}
dlg, err := delegation.New(c.did, aud, cmd, policies, subject, opts...)
if err != nil {
return nil, err
}
dlgSealed, _, err := dlg.ToSealed(c.privKey)
if err != nil {
return nil, err
}
cont := container.NewWriter()
cont.AddSealed(dlgSealed)
for bundle, err := range c.pool.GetBundles(proof) {
if err != nil {
return nil, err
}
cont.AddSealed(bundle.Sealed)
}
return cont, nil
}
func (c *Client) findProof(ctx context.Context, cmd command.Command, subject did.DID) ([]cid.Cid, error) {
var proof []cid.Cid
// do we already have a valid proof?
if proof = c.pool.FindProof(c.did, cmd, subject); len(proof) == 0 {
// we need to request a new proof
proofBundles, err := c.requester.RequestDelegation(ctx, c.did, cmd, subject)
if err != nil {
return nil, fmt.Errorf("requesting delegation: %w", err)
}
// cache the new proofs
for bundle, err := range proofBundles {
if err != nil {
return nil, err
}
proof = append(proof, bundle.Cid)
c.pool.AddBundle(bundle)
}
}
return proof, nil
}

View File

@@ -0,0 +1,77 @@
package client
import (
"context"
"fmt"
"iter"
"time"
"github.com/MetaMask/go-did-it"
"github.com/MetaMask/go-did-it/didtest"
"github.com/ucan-wg/go-ucan/pkg/command"
"github.com/ucan-wg/go-ucan/pkg/policy"
"github.com/ucan-wg/go-ucan/token/delegation"
"github.com/ucan-wg/go-ucan/token/invocation"
)
func ExampleNewClient() {
servicePersona := didtest.PersonaAlice
clientPersona := didtest.PersonaBob
// requester is an adaptor for a real world issuer, we use a mock in that example
requester := &requesterMock{persona: servicePersona}
client, err := NewClient(clientPersona.PrivKey(), clientPersona.DID(), requester)
handleError(err)
cont, err := client.PrepareInvoke(
context.Background(),
command.New("crud", "add"),
servicePersona.DID(),
// extra invocation parameters:
invocation.WithExpirationIn(10*time.Minute),
invocation.WithArgument("foo", "bar"),
invocation.WithMeta("baz", 1234),
)
handleError(err)
// this container holds the invocation and all the delegation proofs
b64, err := cont.ToBase64StdPadding()
handleError(err)
fmt.Println(b64)
}
func handleError(err error) {
if err != nil {
panic(err)
}
}
type requesterMock struct {
persona didtest.Persona
}
func (r requesterMock) RequestDelegation(_ context.Context, audience did.DID, cmd command.Command, _ did.DID) (iter.Seq2[*delegation.Bundle, error], error) {
// the mock issue whatever the client asks:
dlg, err := delegation.Root(r.persona.DID(), audience, cmd, policy.Policy{})
if err != nil {
return nil, err
}
dlgBytes, dlgCid, err := dlg.ToSealed(r.persona.PrivKey())
if err != nil {
return nil, err
}
bundle := &delegation.Bundle{
Cid: dlgCid,
Decoded: dlg,
Sealed: dlgBytes,
}
return func(yield func(*delegation.Bundle, error) bool) {
yield(bundle, nil)
}, nil
}

View File

@@ -0,0 +1,104 @@
package client
import (
"context"
"fmt"
"iter"
"github.com/MetaMask/go-did-it"
"github.com/MetaMask/go-did-it/crypto"
"github.com/ipfs/go-cid"
"github.com/ucan-wg/go-ucan/pkg/command"
"github.com/ucan-wg/go-ucan/token/delegation"
)
// DlgIssuingLogic is a function that decides what powers are given to a client.
// - issuer: the DID of our issuer
// - audience: the DID of the client, also the issuer of the invocation token
// - cmd: the command to execute
// - subject: the DID of the resource to operate on, also the subject (or audience if defined) of the invocation token
// Note: you can read it as "(audience) wants to do (cmd) on (subject)".
// Note: you can decide to match the input parameters exactly or issue a broader power, as long as it allows the
// expected action. If you don't want to give that power, return an error instead.
type DlgIssuingLogic func(iss did.DID, aud did.DID, cmd command.Command, subject did.DID) (*delegation.Token, error)
var _ DelegationRequester = &WithIssuer{}
type WithIssuer struct {
*Client
logic DlgIssuingLogic
}
func NewWithIssuer(privKey crypto.PrivateKeySigningBytes, d did.DID, requester DelegationRequester, logic DlgIssuingLogic) (*WithIssuer, error) {
client, err := NewClient(privKey, d, requester)
if err != nil {
return nil, err
}
return &WithIssuer{Client: client, logic: logic}, nil
}
// RequestDelegation retrieve chain of delegation for the given parameters.
// - audience: the DID of the client, also the issuer of the invocation token
// - cmd: the command to execute
// - subject: the DID of the resource to operate on, also the subject (or audience if defined) of the invocation token
// Note: you can read it as "(audience) does (cmd) on (subject)".
// Note: the returned delegation(s) don't have to match exactly the parameters, as long as they allow them.
func (c *WithIssuer) RequestDelegation(ctx context.Context, audience did.DID, cmd command.Command, subject did.DID) (iter.Seq2[*delegation.Bundle, error], error) {
var proof []cid.Cid
// is there already a valid proof chain?
if proof = c.pool.FindProof(audience, cmd, subject); len(proof) > 0 {
return c.pool.GetBundles(proof), nil
}
// do we have the power to delegate this?
if proof = c.pool.FindProof(c.did, cmd, subject); len(proof) == 0 {
// we need to request a new proof
proofBundles, err := c.requester.RequestDelegation(ctx, c.did, cmd, subject)
if err != nil {
return nil, err
}
// cache the new proofs
for bundle, err := range proofBundles {
if err != nil {
return nil, err
}
proof = append(proof, bundle.Cid)
c.pool.AddBundle(bundle)
}
}
// run the custom logic to get what we actually issue
dlg, err := c.logic(c.did, audience, cmd, subject)
if err != nil {
return nil, err
}
if dlg.IsRoot() {
return nil, fmt.Errorf("issuing logic should return a non-root delegation")
}
// sign and cache the new token
dlgBytes, dlgCid, err := dlg.ToSealed(c.privKey)
if err != nil {
return nil, err
}
bundle := &delegation.Bundle{
Cid: dlgCid,
Decoded: dlg,
Sealed: dlgBytes,
}
// output the relevant delegations
return func(yield func(*delegation.Bundle, error) bool) {
if !yield(bundle, nil) {
return
}
for b, err := range c.pool.GetBundles(proof) {
if !yield(b, err) {
return
}
}
}, nil
}

91
toolkit/client/pool.go Normal file
View File

@@ -0,0 +1,91 @@
package client
import (
"fmt"
"iter"
"sync"
"time"
"github.com/MetaMask/go-did-it"
"github.com/ipfs/go-cid"
"github.com/ucan-wg/go-ucan/pkg/command"
"github.com/ucan-wg/go-ucan/token/delegation"
)
type Pool struct {
mu sync.RWMutex
dlgs map[cid.Cid]*delegation.Bundle
}
func NewPool() *Pool {
return &Pool{dlgs: make(map[cid.Cid]*delegation.Bundle)}
}
func (p *Pool) AddBundle(bundle *delegation.Bundle) {
p.mu.Lock()
defer p.mu.Unlock()
p.dlgs[bundle.Cid] = bundle
}
func (p *Pool) AddBundles(bundles iter.Seq[*delegation.Bundle]) {
for bundle := range bundles {
p.AddBundle(bundle)
}
}
// FindProof find in the pool the best (shortest, smallest in bytes) chain of delegation(s) matching the given invocation parameters.
// - issuer: the DID of the client, also the issuer of the invocation token
// - cmd: the command to execute
// - subject: the DID of the resource to operate on, also the subject (or audience if defined) of the invocation token
// Note: you can read it as "(issuer) wants to do (cmd) on (subject)".
// Note: the returned delegation(s) don't have to match exactly the parameters, as long as they allow them.
// Note: the implemented algorithm won't perform well with a large number of delegations.
func (p *Pool) FindProof(issuer did.DID, cmd command.Command, subject did.DID) []cid.Cid {
// TODO: move to some kind of background trim job?
p.trim()
p.mu.RLock()
defer p.mu.RUnlock()
return FindProof(func() iter.Seq[*delegation.Bundle] {
return func(yield func(*delegation.Bundle) bool) {
for _, bundle := range p.dlgs {
if !yield(bundle) {
return
}
}
}
}, issuer, cmd, subject)
}
func (p *Pool) GetBundles(cids []cid.Cid) iter.Seq2[*delegation.Bundle, error] {
p.mu.RLock()
defer p.mu.RUnlock()
return func(yield func(*delegation.Bundle, error) bool) {
for _, c := range cids {
if b, ok := p.dlgs[c]; ok {
if !yield(b, nil) {
return
}
} else {
yield(nil, fmt.Errorf("bundle not found"))
return
}
}
}
}
// trim removes expired tokens
func (p *Pool) trim() {
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
for c, bundle := range p.dlgs {
if bundle.Decoded.Expiration() != nil && bundle.Decoded.Expiration().Before(now) {
delete(p.dlgs, c)
}
}
}

98
toolkit/client/proof.go Normal file
View File

@@ -0,0 +1,98 @@
package client
import (
"iter"
"math"
"github.com/MetaMask/go-did-it"
"github.com/ipfs/go-cid"
"github.com/ucan-wg/go-ucan/pkg/command"
"github.com/ucan-wg/go-ucan/token/delegation"
)
// FindProof find in the pool the best (shortest, smallest in bytes) chain of delegation(s) matching the given invocation parameters.
// - issuer: the DID of the client, also the issuer of the invocation token
// - cmd: the command to execute
// - subject: the DID of the resource to operate on, also the subject (or audience if defined) of the invocation token
// The returned delegation chain is ordered starting from the leaf (the one matching the invocation) to the root
// (the one given by the service).
// Note: you can read it as "(issuer) wants to do (cmd) on (subject)".
// Note: the returned delegation(s) don't have to match exactly the parameters, as long as they allow them.
// Note: the implemented algorithm won't perform well with a large number of delegations.
func FindProof(dlgs func() iter.Seq[*delegation.Bundle], issuer did.DID, cmd command.Command, subject did.DID) []cid.Cid {
// TODO: maybe that should be part of delegation.Token directly?
dlgMatch := func(dlg *delegation.Token, issuer did.DID, cmd command.Command, subject did.DID) bool {
// The Subject of each delegation must equal the invocation's Subject (or Audience if defined). - 4f
if !dlg.Subject().Equal(subject) {
return false
}
// The first proof must be issued to the Invoker (audience DID). - 4c
// The Issuer of each delegation must be the Audience in the next one. - 4d
if !dlg.Audience().Equal(issuer) {
return false
}
// The command of each delegation must "allow" the one before it. - 4g
if !dlg.Command().Covers(cmd) {
return false
}
// Time bound - 3b, 3c
if !dlg.IsValidNow() {
return false
}
return true
}
// STEP 1: Find the possible leaf delegations, directly matching the invocation parameters
var candidateLeaf []*delegation.Bundle
for bundle := range dlgs() {
if !dlgMatch(bundle.Decoded, issuer, cmd, subject) {
continue
}
candidateLeaf = append(candidateLeaf, bundle)
}
// STEP 2: Perform a depth-first search on the DAG of connected delegations, for each of our candidates
type state struct {
bundle *delegation.Bundle
path []cid.Cid
size int
}
var bestSize = math.MaxInt
var bestProof []cid.Cid
for _, leaf := range candidateLeaf {
var stack = []state{{bundle: leaf, path: []cid.Cid{leaf.Cid}, size: len(leaf.Sealed)}}
for len(stack) > 0 {
// dequeue a delegation
cur := stack[len(stack)-1]
stack = stack[:len(stack)-1]
at := cur.bundle
// if it's a root delegation, we found a valid proof
if at.Decoded.Issuer().Equal(at.Decoded.Subject()) {
if len(bestProof) == 0 || len(cur.path) < len(bestProof) || len(cur.path) == len(bestProof) && cur.size < bestSize {
bestProof = append([]cid.Cid{}, cur.path...) // make a copy
bestSize = cur.size
continue
}
}
// find parent delegation for our current delegation
for candidate := range dlgs() {
if !dlgMatch(candidate.Decoded, at.Decoded.Issuer(), at.Decoded.Command(), subject) {
continue
}
newPath := append([]cid.Cid{}, cur.path...) // make a copy
newPath = append(newPath, candidate.Cid)
stack = append(stack, state{bundle: candidate, path: newPath, size: cur.size + len(candidate.Sealed)})
}
}
}
return bestProof
}

View File

@@ -0,0 +1,39 @@
package client
import (
"iter"
"testing"
"github.com/MetaMask/go-did-it/didtest"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/pkg/command"
"github.com/ucan-wg/go-ucan/token/delegation"
"github.com/ucan-wg/go-ucan/token/delegation/delegationtest"
)
func TestFindProof(t *testing.T) {
dlgs := func() iter.Seq[*delegation.Bundle] {
return func(yield func(*delegation.Bundle) bool) {
for _, bundle := range delegationtest.AllBundles {
if !yield(&bundle) {
return
}
}
}
}
require.Equal(t, delegationtest.ProofAliceBob,
FindProof(dlgs, didtest.PersonaBob.DID(), delegationtest.NominalCommand, didtest.PersonaAlice.DID()))
require.Equal(t, delegationtest.ProofAliceBobCarol,
FindProof(dlgs, didtest.PersonaCarol.DID(), delegationtest.NominalCommand, didtest.PersonaAlice.DID()))
require.Equal(t, delegationtest.ProofAliceBobCarolDan,
FindProof(dlgs, didtest.PersonaDan.DID(), delegationtest.NominalCommand, didtest.PersonaAlice.DID()))
require.Equal(t, delegationtest.ProofAliceBobCarolDanErin,
FindProof(dlgs, didtest.PersonaErin.DID(), delegationtest.NominalCommand, didtest.PersonaAlice.DID()))
require.Equal(t, delegationtest.ProofAliceBobCarolDanErinFrank,
FindProof(dlgs, didtest.PersonaFrank.DID(), delegationtest.NominalCommand, didtest.PersonaAlice.DID()))
// wrong command
require.Empty(t, FindProof(dlgs, didtest.PersonaBob.DID(), command.New("foo"), didtest.PersonaAlice.DID()))
}

View File

@@ -0,0 +1,53 @@
package client
import (
"context"
"iter"
"time"
"github.com/MetaMask/go-did-it"
"github.com/avast/retry-go/v4"
"github.com/ucan-wg/go-ucan/pkg/command"
"github.com/ucan-wg/go-ucan/token/delegation"
)
type DelegationRequester interface {
// RequestDelegation retrieve a delegation or chain of delegation for the given parameters.
// - cmd: the command to execute
// - audience: the DID of the client, also the issuer of the invocation token
// - subject: the DID of the resource to operate on, also the subject (or audience if defined) of the invocation token
// The returned delegations MUST be ordered starting from the leaf (the one matching the invocation) to the root
// (the one given by the service).
// Note: you can read it as "(audience) wants to do (cmd) on (subject)".
// Note: the returned delegation(s) don't have to match exactly the parameters, as long as they allow them.
RequestDelegation(ctx context.Context, audience did.DID, cmd command.Command, subject did.DID) (iter.Seq2[*delegation.Bundle, error], error)
}
var _ DelegationRequester = &withRetry{}
type withRetry struct {
requester DelegationRequester
initialDelay time.Duration
maxAttempts uint
}
// RequesterWithRetry wraps a DelegationRequester to perform exponential backoff,
// with an initial delay and a maximum attempt count.
func RequesterWithRetry(requester DelegationRequester, initialDelay time.Duration, maxAttempt uint) DelegationRequester {
return &withRetry{
requester: requester,
initialDelay: initialDelay,
maxAttempts: maxAttempt,
}
}
func (w withRetry) RequestDelegation(ctx context.Context, audience did.DID, cmd command.Command, subject did.DID) (iter.Seq2[*delegation.Bundle, error], error) {
return retry.DoWithData(func() (iter.Seq2[*delegation.Bundle, error], error) {
return w.requester.RequestDelegation(ctx, audience, cmd, subject)
},
retry.Context(ctx),
retry.Delay(w.initialDelay),
retry.Attempts(w.maxAttempts),
)
}

View File

@@ -0,0 +1,94 @@
package issuer
import (
"fmt"
"io"
"iter"
"net/http"
"github.com/MetaMask/go-did-it"
"github.com/ucan-wg/go-ucan/pkg/command"
"github.com/ucan-wg/go-ucan/pkg/container"
"github.com/ucan-wg/go-ucan/token/delegation"
"github.com/ucan-wg/go-ucan/toolkit/client"
)
type RequestResolver func(r *http.Request) (*ResolvedRequest, error)
type ResolvedRequest struct {
Audience did.DID
Cmd command.Command
Subject did.DID
}
// HttpWrapper implements an HTTP transport for a UCAN issuer.
// It provides a common response shape, while allowing customisation of the request.
func HttpWrapper(requester client.DelegationRequester, resolver RequestResolver) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
resolved, err := resolver(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
dlgs, err := requester.RequestDelegation(r.Context(), resolved.Audience, resolved.Cmd, resolved.Subject)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
cont := container.NewWriter()
for bundle, err := range dlgs {
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
cont.AddSealed(bundle.Sealed)
}
err = cont.ToBytesGzippedWriter(w)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}
func DecodeResponse(res *http.Response, audience did.DID, cmd command.Command, subject did.DID) (iter.Seq2[*delegation.Bundle, error], error) {
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
msg, err := io.ReadAll(res.Body)
if err != nil {
return nil, fmt.Errorf("request failed with status %d, then failed to read response body: %w", res.StatusCode, err)
}
return nil, fmt.Errorf("request failed with status %d: %s", res.StatusCode, msg)
}
cont, err := container.FromReader(res.Body)
if err != nil {
return nil, err
}
// the container doesn't guarantee the ordering, so we must order the delegation in a chain
proof := client.FindProof(func() iter.Seq[*delegation.Bundle] {
return cont.GetAllDelegations()
}, audience, cmd, subject)
return func(yield func(*delegation.Bundle, error) bool) {
for _, c := range proof {
bndl, err := cont.GetDelegationBundle(c)
if err != nil {
yield(nil, err)
return
}
if !yield(bndl, nil) {
return
}
}
}, nil
}

View File

@@ -0,0 +1,76 @@
package issuer
import (
"context"
"fmt"
"iter"
"github.com/MetaMask/go-did-it"
didkeyctl "github.com/MetaMask/go-did-it/controller/did-key"
"github.com/MetaMask/go-did-it/crypto"
"github.com/ucan-wg/go-ucan/pkg/command"
"github.com/ucan-wg/go-ucan/token/delegation"
"github.com/ucan-wg/go-ucan/toolkit/client"
)
// RootIssuingLogic is a function that decides what powers are given to a client.
// - issuer: the DID of our issuer
// - audience: the DID of the client, also the issuer of the invocation token
// - cmd: the command to execute
// Note: you can read it as "(audience) wants to do (cmd) on (subject)".
// Note: you can decide to match the input parameters exactly or issue a broader power, as long as it allows the
// expected action. If you don't want to give that power, return an error instead.
type RootIssuingLogic func(iss did.DID, aud did.DID, cmd command.Command) (*delegation.Token, error)
var _ client.DelegationRequester = &RootIssuer{}
// RootIssuer is an implementation of a root delegation issuer.
// Note: Your actual needs for an issuer can easily be different (caching...) than the choices made here.
// Feel free to replace this component with your own flavor.
type RootIssuer struct {
did did.DID
privKey crypto.PrivateKeySigningBytes
logic RootIssuingLogic
}
func NewRootIssuer(privKey crypto.PrivateKeySigningBytes, logic RootIssuingLogic) (*RootIssuer, error) {
d := didkeyctl.FromPrivateKey(privKey)
return &RootIssuer{
did: d,
privKey: privKey,
logic: logic,
}, nil
}
func (r *RootIssuer) RequestDelegation(ctx context.Context, audience did.DID, cmd command.Command, subject did.DID) (iter.Seq2[*delegation.Bundle, error], error) {
if !subject.Equal(r.did) {
return nil, fmt.Errorf("subject DID doesn't match the issuer DID")
}
// run the custom logic to get what we actually issue
dlg, err := r.logic(r.did, audience, cmd)
if err != nil {
return nil, err
}
if !dlg.IsRoot() {
return nil, fmt.Errorf("issuing logic should return a root delegation")
}
// sign and cache the new token
dlgBytes, dlgCid, err := dlg.ToSealed(r.privKey)
if err != nil {
return nil, err
}
bundle := &delegation.Bundle{
Cid: dlgCid,
Decoded: dlg,
Sealed: dlgBytes,
}
// output the root delegation
return func(yield func(*delegation.Bundle, error) bool) {
yield(bundle, nil)
}, nil
}

View File

@@ -0,0 +1,64 @@
package bearer
import (
"fmt"
"net/http"
"strings"
"github.com/ucan-wg/go-ucan/pkg/container"
)
var ErrNoUcan = fmt.Errorf("no ucan")
var ErrContainerMalformed = fmt.Errorf("malformed container")
// ExtractBearerContainer extract a full UCAN container from an HTTP "Authorization: Bearer" header.
// It can return:
// - ErrNoUcan if no such HTTP header is found
// - ErrContainerMalformed if the container or token can't be decoded or if a token is invalid (bad signature ...)
func ExtractBearerContainer(h http.Header) (container.Reader, error) {
header := h.Get("Authorization")
if header == "" {
return nil, ErrNoUcan
}
if !strings.HasPrefix(header, "Bearer ") {
return nil, ErrNoUcan
}
// skip prefix
reader := strings.NewReader(header[len("Bearer "):])
// read container from any supported format
ctn, err := container.FromReader(reader)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrContainerMalformed, err)
}
return ctn, nil
}
// AddBearerContainer adds the given UCAN container into an `Authorization: Bearer` HTTP header.
//
// You should use this with HTTP/2 or HTTP/3, as both of those already compress their header.
func AddBearerContainer(h http.Header, container container.Writer) error {
str, err := container.ToBase64StdPadding()
if err != nil {
return err
}
h.Set("Authorization", fmt.Sprintf("Bearer %s", str))
return nil
}
// AddBearerContainerCompressed adds the given UCAN container into an `Authorization: Bearer` HTTP header.
//
// You should use this with HTTP/1, as it doesn't compress its headers.
func AddBearerContainerCompressed(h http.Header, container container.Writer) error {
str, err := container.ToBase64StdPaddingGzipped()
if err != nil {
return err
}
h.Set("Authorization", fmt.Sprintf("Bearer %s", str))
return nil
}

View File

@@ -0,0 +1,34 @@
package bearer
import (
"net/http"
"testing"
_ "github.com/MetaMask/go-did-it/verifiers/did-key"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/pkg/container"
"github.com/ucan-wg/go-ucan/pkg/container/containertest"
)
func TestHTTPBearer(t *testing.T) {
for _, fn := range []func(h http.Header, container container.Writer) error{
AddBearerContainer,
AddBearerContainerCompressed,
} {
r, err := http.NewRequest(http.MethodPost, "/foo/bar", nil)
require.NoError(t, err)
cont, err := container.FromBytes(containertest.Bytes)
require.NoError(t, err)
err = fn(r.Header, cont.ToWriter())
require.NoError(t, err)
contRead, err := ExtractBearerContainer(r.Header)
require.NoError(t, err)
require.NotEmpty(t, contRead)
require.Equal(t, cont, contRead)
}
}

View File

@@ -0,0 +1,16 @@
package exectx
import "context"
type ctxKey struct{}
// AddUcanCtxToContext insert a UcanCtx into a go context.
func AddUcanCtxToContext(ctx context.Context, ucanCtx *UcanCtx) context.Context {
return context.WithValue(ctx, ctxKey{}, ucanCtx)
}
// FromContext retrieve a UcanCtx from a go context.
func FromContext(ctx context.Context) (*UcanCtx, bool) {
ucanCtx, ok := ctx.Value(ctxKey{}).(*UcanCtx)
return ucanCtx, ok
}

View File

@@ -0,0 +1,87 @@
package exectx
import (
"errors"
"net/http"
"github.com/MetaMask/go-did-it"
"github.com/ucan-wg/go-ucan/toolkit/server/bearer"
)
// ExtractMW returns an HTTP middleware tasked with:
// - extracting UCAN credentials from the `Authorization: Bearer <data>` HTTP header
// - performing basic checks, and returning HTTP errors if necessary
// - verify that the invocation targets our service
// - exposing those credentials in the go context as a UcanCtx for further usage
func ExtractMW(next http.Handler, serviceDID did.DID) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctn, err := bearer.ExtractBearerContainer(r.Header)
if errors.Is(err, bearer.ErrNoUcan) {
http.Error(w, "no UCAN auth", http.StatusUnauthorized)
return
}
if errors.Is(err, bearer.ErrContainerMalformed) {
http.Error(w, "malformed token", http.StatusBadRequest)
return
}
if err != nil {
// should not happen, defensive programming
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// prepare a UcanCtx from the container, for further evaluation in the server pipeline
ucanCtx, err := FromContainer(ctn)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
if !ucanCtx.Invocation().Subject().Equal(serviceDID) {
http.Error(w, "UCAN delegation doesn't match the service DID", http.StatusUnauthorized)
return
}
// insert into the go context
r = r.WithContext(AddUcanCtxToContext(r.Context(), ucanCtx))
next.ServeHTTP(w, r)
})
}
// HttpExtArgsVerify returns an HTTP middleware tasked with verifying the UCAN policies applying on the HTTP request.
func HttpExtArgsVerify(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ucanCtx, ok := FromContext(r.Context())
if !ok {
http.Error(w, "no ucan-ctx found", http.StatusInternalServerError)
return
}
if err := ucanCtx.VerifyHttp(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// EnforceMW returns an HTTP middleware tasked with the final verification of the UCAN policies.
func EnforceMW(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ucanCtx, ok := FromContext(r.Context())
if !ok {
http.Error(w, "no ucan-ctx found", http.StatusInternalServerError)
return
}
if err := ucanCtx.ExecutionAllowed(); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,129 @@
package exectx
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/MetaMask/go-did-it/didtest"
"github.com/ipfs/go-cid"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/pkg/container"
"github.com/ucan-wg/go-ucan/token/delegation"
"github.com/ucan-wg/go-ucan/token/invocation"
)
func TestExtractMW(t *testing.T) {
const service = didtest.PersonaAlice
const client = didtest.PersonaBob
const cmd = "/foo/bar"
for _, tc := range []struct {
name string
addHeaderFn func(func(key string, value string))
expectedStatusCode int
successful bool
}{
{
name: "no auth",
addHeaderFn: func(f func(key string, value string)) {},
expectedStatusCode: http.StatusUnauthorized,
successful: false,
},
{
name: "wrong kind of auth",
addHeaderFn: func(f func(key string, value string)) {
f("Authorization", "Basic foobar")
},
expectedStatusCode: http.StatusUnauthorized,
successful: false,
},
{
name: "invalid container",
addHeaderFn: func(f func(key string, value string)) {
f("Authorization", "Bearer foobar")
},
expectedStatusCode: http.StatusBadRequest,
successful: false,
},
{
name: "valid containter, for incorrect service",
addHeaderFn: func(f func(key string, value string)) {
cont := container.NewWriter()
const service2 = didtest.PersonaCarol
dlg, _ := delegation.Root(service2.DID(), client.DID(), cmd, nil)
dlgByte, dlgCid, _ := dlg.ToSealed(service2.PrivKey())
cont.AddSealed(dlgByte)
inv, _ := invocation.New(client.DID(), cmd, service2.DID(), []cid.Cid{dlgCid})
invBytes, _, _ := inv.ToSealed(client.PrivKey())
cont.AddSealed(invBytes)
contB64, _ := cont.ToBase64StdPadding()
f("Authorization", "Bearer "+contB64)
},
expectedStatusCode: http.StatusUnauthorized,
successful: false,
},
{
name: "valid containter, missing invocation",
addHeaderFn: func(f func(key string, value string)) {
cont := container.NewWriter()
dlg, _ := delegation.Root(service.DID(), client.DID(), cmd, nil)
dlgByte, _, _ := dlg.ToSealed(service.PrivKey())
cont.AddSealed(dlgByte)
contB64, _ := cont.ToBase64StdPadding()
f("Authorization", "Bearer "+contB64)
},
expectedStatusCode: http.StatusUnauthorized,
successful: false,
},
{
name: "valid containter, valid tokens",
addHeaderFn: func(f func(key string, value string)) {
cont := container.NewWriter()
dlg, _ := delegation.Root(service.DID(), client.DID(), cmd, nil)
dlgByte, dlgCid, _ := dlg.ToSealed(service.PrivKey())
cont.AddSealed(dlgByte)
inv, _ := invocation.New(client.DID(), cmd, service.DID(), []cid.Cid{dlgCid})
invBytes, _, _ := inv.ToSealed(client.PrivKey())
cont.AddSealed(invBytes)
contB64, _ := cont.ToBase64StdPadding()
f("Authorization", "Bearer "+contB64)
},
expectedStatusCode: http.StatusOK,
successful: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
var handler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, has := FromContext(r.Context())
require.Equal(t, tc.successful, has)
_, _ = io.WriteString(w, "OK")
})
handler = ExtractMW(handler, service.DID())
req := httptest.NewRequest("GET", "https://example.com/foo", nil)
tc.addHeaderFn(req.Header.Set)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
require.Equal(t, tc.expectedStatusCode, w.Code)
})
}
}

View File

@@ -0,0 +1,178 @@
package exectx
import (
"errors"
"fmt"
"net/http"
"slices"
"github.com/ipfs/go-cid"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ucan-wg/go-ucan/pkg/args"
"github.com/ucan-wg/go-ucan/pkg/command"
"github.com/ucan-wg/go-ucan/pkg/container"
"github.com/ucan-wg/go-ucan/pkg/meta"
"github.com/ucan-wg/go-ucan/pkg/policy"
"github.com/ucan-wg/go-ucan/token/delegation"
"github.com/ucan-wg/go-ucan/token/invocation"
"github.com/ucan-wg/go-ucan/toolkit/server/extargs"
)
var _ delegation.Loader = &UcanCtx{}
// UcanCtx is a UCAN execution context meant to be inserted in the go context while handling a request.
// It allows to handle the control of the execution in multiple steps across different middlewares,
// as well as doing "bearer" types of controls, when arguments are derived from the request itself (HTTP, JsonRpc).
type UcanCtx struct {
inv *invocation.Token
dlgs map[cid.Cid]*delegation.Token
policies policy.Policy // all policies combined
meta *meta.Meta // all meta combined, with no overwriting
// argument sources
http *extargs.HttpExtArgs
custom map[string]*extargs.CustomExtArgs
}
// FromContainer prepare a UcanCtx from a UCAN container, for further evaluation in a server pipeline.
// It is expected that the container holds a single invocation and the matching delegations. If not,
// an error is returned.
func FromContainer(cont container.Reader) (*UcanCtx, error) {
inv, err := cont.GetInvocation()
if err != nil {
return nil, fmt.Errorf("no invocation: %w", err)
}
ctx := &UcanCtx{
inv: inv,
dlgs: make(map[cid.Cid]*delegation.Token, len(cont)-1),
meta: meta.NewMeta(),
}
// iterate in reverse, from the root delegation to the leaf
for _, c := range slices.Backward(inv.Proof()) {
// make sure we have the delegation
dlg, err := cont.GetDelegation(c)
if errors.Is(err, delegation.ErrDelegationNotFound) {
return nil, fmt.Errorf("delegation proof %s is missing", c)
}
if err != nil {
return nil, err
}
ctx.dlgs[c] = dlg
// accumulate the policies
ctx.policies = append(ctx.policies, dlg.Policy()...)
// accumulate the meta values, with no overwriting
ctx.meta.Include(dlg.Meta())
}
// DX: As the invocation is created without the delegation, no check is done that the proof chain (CIDs only)
// is ordered properly and not broken. We don't check that in the container either as it doesn't make any assumption
// on what is being carried around. That UcanCtx is the first place where we enforce having a single invocation and
// only the matching delegation.
// For sanity, we verify that the proofs are ordered properly. This will be checked later anyway, but it's cheap to
// verify here and catch an easy mistake.
chainTo := inv.Issuer()
for _, c := range inv.Proof() {
dlg := ctx.dlgs[c]
if !dlg.Audience().Equal(chainTo) {
return nil, fmt.Errorf("proof chain is broken or not ordered correctly")
}
chainTo = dlg.Issuer()
}
return ctx, nil
}
// Command returns the command triggered by the invocation.
func (ctn *UcanCtx) Command() command.Command {
return ctn.inv.Command()
}
// Invocation returns the invocation.Token.
func (ctn *UcanCtx) Invocation() *invocation.Token {
return ctn.inv
}
// GetDelegation returns the delegation.Token matching the given CID.
// If not found, delegation.ErrDelegationNotFound is returned.
func (ctn *UcanCtx) GetDelegation(cid cid.Cid) (*delegation.Token, error) {
if dlg, ok := ctn.dlgs[cid]; ok {
return dlg, nil
}
return nil, delegation.ErrDelegationNotFound
}
// GetRootDelegation returns the delegation.Token at the root of the proof chain.
func (ctn *UcanCtx) GetRootDelegation() *delegation.Token {
proofs := ctn.inv.Proof()
c := proofs[len(proofs)-1]
return ctn.dlgs[c]
}
// Policies return the full set of policy statements to satisfy.
func (ctn *UcanCtx) Policies() policy.Policy {
return ctn.policies
}
// Meta returns all the meta values from the delegations.
// They are accumulated from the root delegation to the leaf delegation, with no overwriting.
func (ctn *UcanCtx) Meta() meta.ReadOnly {
return ctn.meta.ReadOnly()
}
// VerifyHttp verify the delegation's policies against arguments constructed from the HTTP request.
// These arguments will be set in the `.http` argument key, at the root.
// This function can only be called once per context.
// After being used, those constructed arguments will be used in ExecutionAllowed as well.
func (ctn *UcanCtx) VerifyHttp(req *http.Request) error {
if ctn.http != nil {
panic("only use once per request context")
}
ctn.http = extargs.NewHttpExtArgs(ctn.policies, ctn.inv.Arguments(), req)
return ctn.http.Verify()
}
// VerifyCustom verify the delegation's policies against arbitrary arguments provider through an IPLD MapAssembler.
// These arguments will be set under the given argument key, at the root.
// This function can only be called once per context and key.
// After being used, those constructed arguments will be used in ExecutionAllowed as well.
func (ctn *UcanCtx) VerifyCustom(key string, assembler func(ma datamodel.MapAssembler)) error {
if ctn.custom == nil {
ctn.custom = make(map[string]*extargs.CustomExtArgs)
}
if _, ok := ctn.custom[key]; ok {
panic("only use once per request context and key")
}
ctn.custom[key] = extargs.NewCustomExtArgs(key, ctn.policies, assembler)
return ctn.custom[key].Verify()
}
// ExecutionAllowed does the final verification of the invocation.
// If VerifyHttp or VerifyJsonRpc has been used, those arguments are part of the verification.
func (ctn *UcanCtx) ExecutionAllowed() error {
return ctn.inv.ExecutionAllowedWithArgsHook(ctn, func(args args.ReadOnly) (*args.Args, error) {
newArgs := args.WriteableClone()
if ctn.http != nil {
httpArgs, err := ctn.http.Args()
if err != nil {
return nil, err
}
newArgs.Include(httpArgs)
}
if ctn.custom != nil {
for _, cea := range ctn.custom {
customArgs, err := cea.Args()
if err != nil {
return nil, err
}
newArgs.Include(customArgs)
}
}
return newArgs, nil
})
}

View File

@@ -0,0 +1,234 @@
package exectx_test
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/MetaMask/go-did-it/didtest"
"github.com/ipfs/go-cid"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ipld/go-ipld-prime/fluent/qp"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/pkg/command"
"github.com/ucan-wg/go-ucan/pkg/container"
"github.com/ucan-wg/go-ucan/pkg/policy"
"github.com/ucan-wg/go-ucan/pkg/policy/literal"
"github.com/ucan-wg/go-ucan/token/delegation"
"github.com/ucan-wg/go-ucan/token/invocation"
"github.com/ucan-wg/go-ucan/toolkit/server/exectx"
)
const (
network = "eth-mainnet"
)
func TestUcanCtxFullFlow(t *testing.T) {
// let's use some pre-made DID+privkey.
// use go-ucan/did to generate or parse them.
service := didtest.PersonaAlice
user := didtest.PersonaBob
// DELEGATION: the service gives some power to the user, in the form of a root UCAN token.
// The command defines the shape of the arguments on which the policies operate, it is specific to that service.
// Policies define what the user can do.
cmd := command.New("foo")
pol := policy.MustConstruct(
// some basic HTTP constraints
policy.Equal(".http.method", literal.String("GET")),
policy.Like(".http.path", "/foo/*"),
// some custom constraints
// Network
policy.Equal(".custom.ntwk", literal.String(network)),
// Quota
policy.LessThanOrEqual(".custom.quota.ur", literal.Int(1234)),
)
dlg, err := delegation.Root(service.DID(), user.DID(), cmd, pol,
delegation.WithExpirationIn(24*time.Hour),
)
require.NoError(t, err)
dlgBytes, dlgCid, err := dlg.ToSealed(service.PrivKey())
require.NoError(t, err)
// INVOCATION: the user leverages the delegation (power) to make a request.
inv, err := invocation.New(user.DID(), cmd, service.DID(), []cid.Cid{dlgCid},
invocation.WithExpirationIn(10*time.Minute),
invocation.WithArgument("myarg", "hello"), // we can specify invocation parameters
)
require.NoError(t, err)
invBytes, _, err := inv.ToSealed(user.PrivKey())
require.NoError(t, err)
// PACKAGING: no obligation for the transport, but the user needs to give the service the invocation
// and all the proof delegations. We can use a container for that.
cont := container.NewWriter()
cont.AddSealed(dlgBytes)
cont.AddSealed(invBytes)
contBytes, err := cont.ToBase64StdPadding()
require.NoError(t, err)
// MAKING A REQUEST: we pass the container in the Bearer HTTP header
req, err := http.NewRequest(http.MethodGet, "/foo/bar", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+string(contBytes))
// SERVER: Auth middleware
// - decode the container
// - create the context
authMw := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Note: we obviously want something more robust, this is an example
// Note: if an error occur, we'll want to return an HTTP 401 Unauthorized
data := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
cont, err := container.FromString(data)
require.NoError(t, err)
ucanCtx, err := exectx.FromContainer(cont)
require.NoError(t, err)
// insert into the go context
r = r.WithContext(exectx.AddUcanCtxToContext(r.Context(), ucanCtx))
next.ServeHTTP(w, r)
})
}
// SERVER: http checks
httpMw := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ucanCtx, ok := exectx.FromContext(r.Context())
require.True(t, ok)
err := ucanCtx.VerifyHttp(r)
if err != nil {
// This will print something like:
// `the following UCAN policy is not satisfied: ["==", ".http.path", "/foo"]`
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// SERVER: custom args checks
customArgsMw := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ucanCtx, ok := exectx.FromContext(r.Context())
require.True(t, ok)
err := ucanCtx.VerifyCustom("custom", func(ma datamodel.MapAssembler) {
qp.MapEntry(ma, "ntwk", qp.String(network))
qp.MapEntry(ma, "quota", qp.Map(1, func(ma datamodel.MapAssembler) {
qp.MapEntry(ma, "ur", qp.Int(1234))
}))
})
require.NoError(t, err)
next.ServeHTTP(w, r)
})
}
// SERVER: final handler
handler := func(w http.ResponseWriter, r *http.Request) {
ucanCtx, ok := exectx.FromContext(r.Context())
require.True(t, ok)
if err := ucanCtx.ExecutionAllowed(); err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusOK)
}
sut := authMw(httpMw(customArgsMw(http.HandlerFunc(handler))))
rec := httptest.NewRecorder()
sut.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
}
func TestGoCtx(t *testing.T) {
ctx := context.Background()
ucanCtx, ok := exectx.FromContext(ctx)
require.False(t, ok)
require.Nil(t, ucanCtx)
expected := &exectx.UcanCtx{}
ctx = exectx.AddUcanCtxToContext(ctx, expected)
got, ok := exectx.FromContext(ctx)
require.True(t, ok)
require.Equal(t, expected, got)
}
func TestUcanCtx(t *testing.T) {
const service = didtest.PersonaAlice
const client1 = didtest.PersonaBob
const client2 = didtest.PersonaCarol
const cmd = "/foo/bar"
cont := container.NewWriter()
pol1 := policy.MustConstruct(
policy.Equal(".http.scheme", literal.String("https")),
)
dlg1, err := delegation.Root(service.DID(), client1.DID(), cmd, pol1,
delegation.WithMeta("foo", "bar"),
)
require.NoError(t, err)
dlg1Byte, dlg1Cid, err := dlg1.ToSealed(service.PrivKey())
require.NoError(t, err)
cont.AddSealed(dlg1Byte)
pol2 := policy.MustConstruct(
policy.Equal(".http.method", literal.String("GET")),
)
dlg2, err := delegation.New(client1.DID(), client2.DID(), cmd, pol2, service.DID(),
delegation.WithMeta("foo", "foo"), // attempt to replace
)
require.NoError(t, err)
dlg2Byte, dlg2Cid, err := dlg2.ToSealed(client1.PrivKey())
require.NoError(t, err)
cont.AddSealed(dlg2Byte)
inv, err := invocation.New(client2.DID(), cmd, service.DID(), []cid.Cid{dlg2Cid, dlg1Cid})
require.NoError(t, err)
invBytes, _, err := inv.ToSealed(client2.PrivKey())
require.NoError(t, err)
cont.AddSealed(invBytes)
ctx, err := exectx.FromContainer(cont.ToReader())
require.NoError(t, err)
require.NotNil(t, ctx.Invocation())
require.Equal(t, cmd, ctx.Command().String())
require.Equal(t, 1, ctx.Meta().Len())
require.Equal(t, "bar", must(ctx.Meta().GetString("foo")))
require.Equal(t, service.DID(), ctx.GetRootDelegation().Issuer())
require.Equal(t, append(pol1, pol2...), ctx.Policies())
require.ErrorContains(t, ctx.ExecutionAllowed(), `the following UCAN policy is not satisfied: ["==", ".http.method", "GET"]`)
r := httptest.NewRequest(http.MethodGet, "https://foo/bar", nil)
require.NoError(t, ctx.VerifyHttp(r))
require.NoError(t, ctx.ExecutionAllowed())
}
func must[T any](e T, err error) T {
if err != nil {
panic(err)
}
return e
}

View File

@@ -0,0 +1,63 @@
## Motivations
UCAN is normally a pure RPC construct, when the entirety of the request's parameters is part of the invocation, in the form of `args`. Those `args` are evaluated against the delegation's [policy](https://github.com/ucan-wg/delegation/tree/v1_ipld?tab=readme-ov-file#policy) to determine if the request is allowed or not, then the request handling happens purely based on those args and the `command`. In that setup, the service would have a single entry point.
Unfortunately, we live in a world of REST APIs, or JSON-RPC. Some adaptations or concessions need to be made.
In this package, we cross the chasm of the pure UCAN world into our practical needs. This can, however, be done in a reasonable way.
## Example
Below is an example of `args` in Dag-Json format, where the values are recomposed server-side from the HTTP request:
```json
{
"http": {
"scheme": "https",
"method": "POST",
"host": "example.com",
"path": ""
},
"custom": {
"foo": "bar"
}
}
```
Those `args` can be evaluated against a delegation's policy, for example:
```json
{
"cmd": "/foo/bar",
"pol": [
["==", ".http.host", "example.com"],
["like", ".custom.foo", "ba*"]
]
}
```
## Security implications
UCAN essentially aims for perfect security. By having external args, we break that security perimeter, and we now need to arbitrate between security and practicality.
First, what are we breaking exactly? Normally, the invocation has all the parameters and is signed by the invoker. This means that an attacker cannot intercept (MITM) a request and change the parameters when relaying it to the server. As they are signed, that would make the request invalid.
If we have external args, now an attacker can intercept the request, change it, and pretend to be that person doing other things than intended. **That may of may not be an actual problem, depending on the situation.**
There is a way to get around that, and have the best of both worlds, but **it comes with a client side complexity**: we can hash the external values and put them into the invocation's `args`. For example:
```json
{
"http": "zQmSnuWmxptJZdLJpKRarxBMS2Ju2oANVrgbr2xWbie9b2D",
"custom": "zQmP8jTG1m9GSDJLCbeWhVSVgEzCPPwXRdCRuJtQ5Tz9Kc9"
}
```
When the server receives the request, it can now reconstruct the values from the HTTP request, verify the hash and replace it with the real values for evaluation against the policies. **We are now back to the better security model**, but at the price of client-side complexity.
## API design
Therefore, the server-side logic is made to have this hashing optional:
- if present, the server honors the hash and enforces the security
- the client can opt out of passing that hash, and won't benefit from the enforced security
The particular hash selected is SHA2-256 of the DAG-CBOR encoded argument, expressed in the form of a Multihash in raw bytes.
The arguments being hashed are the complete map of values, including the root key being replaced (for example `http` or `custom` here).

View File

@@ -0,0 +1,85 @@
package extargs
import (
"fmt"
"sync"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ipld/go-ipld-prime/fluent/qp"
"github.com/ipld/go-ipld-prime/node/basicnode"
"github.com/ucan-wg/go-ucan/pkg/args"
"github.com/ucan-wg/go-ucan/pkg/policy"
)
type CustomExtArgs struct {
key string
pol policy.Policy
originalArgs args.ReadOnly
assembler func(ma datamodel.MapAssembler)
once sync.Once
args *args.Args
argsIpld ipld.Node
}
func NewCustomExtArgs(key string, pol policy.Policy, assembler func(ma datamodel.MapAssembler)) *CustomExtArgs {
return &CustomExtArgs{key: key, pol: pol, assembler: assembler}
}
func (cea *CustomExtArgs) Verify() error {
if err := cea.makeArgs(); err != nil {
return err
}
// Note: CustomExtArgs doesn't support verifying a hash computed client-side like the other
// external args, as the arguments are by nature dynamic. The client can't generate a meaningful hash.
ok, leaf := cea.pol.PartialMatch(cea.argsIpld)
if !ok {
return fmt.Errorf("the following UCAN policy is not satisfied: %v", leaf.String())
}
return nil
}
func (cea *CustomExtArgs) Args() (*args.Args, error) {
if err := cea.makeArgs(); err != nil {
return nil, err
}
return cea.args, nil
}
func (cea *CustomExtArgs) makeArgs() error {
var outerErr error
cea.once.Do(func() {
var err error
cea.args, err = makeCustomArgs(cea.key, cea.assembler)
if err != nil {
outerErr = err
return
}
cea.argsIpld, err = cea.args.ToIPLD()
if err != nil {
outerErr = err
return
}
})
return outerErr
}
func makeCustomArgs(key string, assembler func(ma datamodel.MapAssembler)) (*args.Args, error) {
n, err := qp.BuildMap(basicnode.Prototype.Any, -1, assembler)
if err != nil {
return nil, err
}
res := args.New()
err = res.Add(key, n)
if err != nil {
return nil, err
}
return res, nil
}

View File

@@ -0,0 +1,115 @@
package extargs
import (
"fmt"
"testing"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ipld/go-ipld-prime/fluent/qp"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/pkg/policy"
"github.com/ucan-wg/go-ucan/pkg/policy/literal"
)
func ExampleCustomExtArgs() {
// Note: this is an example for how to build arguments, but you likely want to use CustomExtArgs
// through UcanCtx.
pol := policy.Policy{} // policies from the delegations
// We will construct the following args:
// "key": {
// "ntwk":"eth-mainnet",
// "quota":{
// "ur":1234,
// "udc":1234,
// "arch":1234,
// "down":1234,
// "store":1234,
// "up":1234
// }
// }
customArgs := NewCustomExtArgs("key", pol, func(ma datamodel.MapAssembler) {
qp.MapEntry(ma, "ntwk", qp.String("eth-mainnet"))
qp.MapEntry(ma, "quota", qp.Map(6, func(ma datamodel.MapAssembler) {
qp.MapEntry(ma, "ur", qp.Int(1234))
qp.MapEntry(ma, "udc", qp.Int(1234))
qp.MapEntry(ma, "arch", qp.Int(1234))
qp.MapEntry(ma, "down", qp.Int(1234))
qp.MapEntry(ma, "store", qp.Int(1234))
qp.MapEntry(ma, "up", qp.Int(1234))
}))
})
err := customArgs.Verify()
fmt.Println(err)
// Output:
// <nil>
}
func TestCustom(t *testing.T) {
assembler := func(ma datamodel.MapAssembler) {
qp.MapEntry(ma, "ntwk", qp.String("eth-mainnet"))
qp.MapEntry(ma, "quota", qp.Map(6, func(ma datamodel.MapAssembler) {
qp.MapEntry(ma, "ur", qp.Int(1234))
qp.MapEntry(ma, "udc", qp.Int(1234))
qp.MapEntry(ma, "arch", qp.Int(1234))
qp.MapEntry(ma, "down", qp.Int(1234))
qp.MapEntry(ma, "store", qp.Int(1234))
qp.MapEntry(ma, "up", qp.Int(1234))
}))
}
tests := []struct {
name string
pol policy.Policy
expected bool
}{
{
name: "no policies",
pol: policy.Policy{},
expected: true,
},
{
name: "matching args",
pol: policy.MustConstruct(
policy.Equal(".key.ntwk", literal.String("eth-mainnet")),
policy.LessThanOrEqual(".key.quota.ur", literal.Int(1234)),
),
expected: true,
},
{
name: "wrong network",
pol: policy.MustConstruct(
policy.Equal(".key.ntwk", literal.String("avalanche-fuji")),
policy.LessThanOrEqual(".key.quota.ur", literal.Int(1234)),
),
expected: false,
},
{
name: "unrespected quota",
pol: policy.MustConstruct(
policy.Equal(".key.ntwk", literal.String("eth-mainnet")),
policy.LessThanOrEqual(".key.quota.ur", literal.Int(100)),
),
expected: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
extArgs := NewCustomExtArgs("key", tc.pol, assembler)
_, err := extArgs.Args()
require.NoError(t, err)
if tc.expected {
require.NoError(t, extArgs.Verify())
} else {
require.Error(t, extArgs.Verify())
}
})
}
}

View File

@@ -0,0 +1,165 @@
// Package extargs adds external arguments to the invocation's arguments before the policy is evaluated.
package extargs
import (
"bytes"
"fmt"
"net/http"
"sync"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/codec/dagcbor"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ipld/go-ipld-prime/fluent/qp"
"github.com/ipld/go-ipld-prime/node/basicnode"
"github.com/multiformats/go-multihash"
"github.com/ucan-wg/go-ucan/pkg/args"
"github.com/ucan-wg/go-ucan/pkg/policy"
"github.com/ucan-wg/go-ucan/token/invocation"
)
// HttpArgsKey is the key in the args, used for:
// - if it exists in the invocation, holds a hash of the args derived from the HTTP request
// - in the final args to be evaluated against the policies, holds the args derived from the HTTP request
const HttpArgsKey = "http"
type HttpExtArgs struct {
pol policy.Policy
originalArgs args.ReadOnly
req *http.Request
once sync.Once
args *args.Args
argsIpld ipld.Node
}
func NewHttpExtArgs(pol policy.Policy, originalArgs args.ReadOnly, req *http.Request) *HttpExtArgs {
return &HttpExtArgs{pol: pol, originalArgs: originalArgs, req: req}
}
func (hea *HttpExtArgs) Verify() error {
if err := hea.makeArgs(); err != nil {
return err
}
if err := hea.verifyHash(); err != nil {
return err
}
ok, leaf := hea.pol.PartialMatch(hea.argsIpld)
if !ok {
return fmt.Errorf("the following UCAN policy is not satisfied: %v", leaf.String())
}
return nil
}
func (hea *HttpExtArgs) Args() (*args.Args, error) {
if err := hea.makeArgs(); err != nil {
return nil, err
}
return hea.args, nil
}
func (hea *HttpExtArgs) makeArgs() error {
var outerErr error
hea.once.Do(func() {
var err error
hea.args, err = makeHttpArgs(hea.req)
if err != nil {
outerErr = err
return
}
hea.argsIpld, err = hea.args.ToIPLD()
if err != nil {
outerErr = err
return
}
})
return outerErr
}
func (hea *HttpExtArgs) verifyHash() error {
n, err := hea.originalArgs.GetNode(HttpArgsKey)
if err != nil {
// no hash found, nothing to verify
return nil
}
mhBytes, err := n.AsBytes()
if err != nil {
return fmt.Errorf("http args hash should be bytes")
}
data, err := ipld.Encode(hea.argsIpld, dagcbor.Encode)
if err != nil {
return fmt.Errorf("can't encode derived args in dag-cbor: %w", err)
}
sum, err := multihash.Sum(data, multihash.SHA2_256, -1)
if err != nil {
return err
}
if !bytes.Equal(mhBytes, sum) {
return fmt.Errorf("derived args from http request don't match the expected hash")
}
return nil
}
// MakeHttpHash compute the hash of the derived arguments from the HTTP request.
// If that hash is inserted at the HttpArgsKey key in the invocation arguments,
// this increases the security as the UCAN token cannot be used with a different
// HTTP request.
// For convenience, the hash is returned as a ready-to-use invocation argument.
func MakeHttpHash(req *http.Request) (invocation.Option, error) {
// Note: the hash is computed on the full IPLD args, including HttpArgsKey
computedArgs, err := makeHttpArgs(req)
if err != nil {
return nil, err
}
n, err := computedArgs.ToIPLD()
if err != nil {
return nil, err
}
data, err := ipld.Encode(n, dagcbor.Encode)
if err != nil {
return nil, err
}
sum, err := multihash.Sum(data, multihash.SHA2_256, -1)
if err != nil {
return nil, err
}
return invocation.WithArgument(HttpArgsKey, []byte(sum)), nil
}
func makeHttpArgs(req *http.Request) (*args.Args, error) {
n, err := qp.BuildMap(basicnode.Prototype.Any, 4, func(ma datamodel.MapAssembler) {
qp.MapEntry(ma, "scheme", qp.String(req.URL.Scheme)) // https
qp.MapEntry(ma, "method", qp.String(req.Method)) // GET
qp.MapEntry(ma, "host", qp.String(req.Host)) // example.com
qp.MapEntry(ma, "path", qp.String(req.URL.Path)) // /foo
qp.MapEntry(ma, "headers", qp.Map(2, func(ma datamodel.MapAssembler) {
qp.MapEntry(ma, "Origin", qp.String(req.Header.Get("Origin")))
qp.MapEntry(ma, "User-Agent", qp.String(req.Header.Get("User-Agent")))
}))
})
if err != nil {
return nil, err
}
res := args.New()
err = res.Add(HttpArgsKey, n)
if err != nil {
return nil, err
}
return res, nil
}

View File

@@ -0,0 +1,210 @@
package extargs
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/MetaMask/go-did-it/didtest"
"github.com/multiformats/go-multihash"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/pkg/args"
"github.com/ucan-wg/go-ucan/pkg/command"
"github.com/ucan-wg/go-ucan/pkg/policy"
"github.com/ucan-wg/go-ucan/pkg/policy/literal"
"github.com/ucan-wg/go-ucan/token/invocation"
)
func TestHttp(t *testing.T) {
pol := policy.MustConstruct(
policy.Equal(".http.scheme", literal.String("http")),
policy.Equal(".http.method", literal.String("GET")),
policy.Equal(".http.host", literal.String("example.com")),
policy.Equal(".http.path", literal.String("/foo")),
policy.Like(".http.headers.User-Agent", "*Mozilla*"),
policy.Equal(".http.headers.Origin", literal.String("dapps.com")),
)
tests := []struct {
name string
method string
target string
headers map[string]string
expected bool
}{
{
name: "happy path",
method: http.MethodGet,
target: "http://example.com/foo",
headers: map[string]string{
"User-Agent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0",
"Origin": "dapps.com",
},
expected: true,
},
{
name: "wrong scheme",
method: http.MethodGet,
target: "https://example.com/foo",
headers: map[string]string{
"User-Agent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0",
"Origin": "dapps.com",
},
expected: false,
},
{
name: "wrong method",
method: http.MethodPost,
target: "http://example.com/foo",
headers: map[string]string{
"User-Agent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0",
"Origin": "dapps.com",
},
expected: false,
},
{
name: "wrong host",
method: http.MethodGet,
target: "http://foo.com/foo",
headers: map[string]string{
"User-Agent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0",
"Origin": "dapps.com",
},
expected: false,
},
{
name: "wrong path",
method: http.MethodGet,
target: "http://example.com/bar",
headers: map[string]string{
"User-Agent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0",
"Origin": "dapps.com",
},
expected: false,
},
{
name: "wrong origin",
method: http.MethodGet,
target: "http://example.com/foo",
headers: map[string]string{
"User-Agent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0",
"Origin": "foo.com",
},
expected: false,
},
{
name: "wrong user-agent",
method: http.MethodGet,
target: "http://example.com/foo",
headers: map[string]string{
"User-Agent": "Chrome/51.0.2704.103 Safari/537.36",
"Origin": "dapps.com",
},
expected: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
handler := func(w http.ResponseWriter, r *http.Request) {
// we don't test the args hash here
emptyArgs := args.New().ReadOnly()
ctx := NewHttpExtArgs(pol, emptyArgs, r)
_, err := ctx.Args()
require.NoError(t, err)
if tc.expected {
require.NoError(t, ctx.Verify())
} else {
require.Error(t, ctx.Verify())
}
}
req := httptest.NewRequest(tc.method, tc.target, nil)
for k, v := range tc.headers {
req.Header.Set(k, v)
}
w := httptest.NewRecorder()
handler(w, req)
})
}
}
func TestHttpHash(t *testing.T) {
servicePersona := didtest.PersonaAlice
clientPersona := didtest.PersonaBob
req, err := http.NewRequest(http.MethodGet, "http://example.com/foo", nil)
require.NoError(t, err)
req.Header.Add("User-Agent", "Chrome/51.0.2704.103 Safari/537.36")
req.Header.Add("Origin", "dapps.com")
pol := policy.MustConstruct(
policy.Equal(".http.scheme", literal.String("http")),
)
makeArg := func(data []byte, code uint64) invocation.Option {
mh, err := multihash.Sum(data, code, -1)
require.NoError(t, err)
return invocation.WithArgument(HttpArgsKey, []byte(mh))
}
tests := []struct {
name string
argOptions []invocation.Option
expected bool
}{
{
name: "correct hash",
argOptions: []invocation.Option{must(MakeHttpHash(req))},
expected: true,
},
{
name: "non-matching hash",
argOptions: []invocation.Option{makeArg([]byte{1, 2, 3, 4}, multihash.SHA2_256)},
expected: false,
},
{
name: "wrong type of hash",
argOptions: []invocation.Option{makeArg([]byte{1, 2, 3, 4}, multihash.BLAKE3)},
expected: false,
},
{
name: "no hash",
argOptions: nil,
expected: true, // having a hash is not enforced
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
inv, err := invocation.New(
clientPersona.DID(),
command.MustParse("/foo"),
servicePersona.DID(),
nil,
tc.argOptions..., // inject hash argument, if any
)
require.NoError(t, err)
ctx := NewHttpExtArgs(pol, inv.Arguments(), req)
if tc.expected {
require.NoError(t, ctx.Verify())
} else {
require.Error(t, ctx.Verify())
}
})
}
}
func must[T any](t T, err error) T {
if err != nil {
panic(err)
}
return t
}